Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .agents/skills/custom-codereview-guide.md +201 -0
- .agents/skills/pr-design-doc/SKILL.md +168 -0
- .agents/skills/pr-design-doc/references/html-craft.md +322 -0
- .agents/skills/release.md +110 -0
- .gitattributes +8 -0
- .github/pr-assets/pr-16268-02-lightbox-open.png +3 -0
- .github/pr-assets/pr-16605-test-results.png +3 -0
- .github/pr-assets/pr-16676-01-list.png +3 -0
- .github/pr-assets/pr-16676-02-templates.png +3 -0
- .github/pr-assets/pr-16676-04-edit-modal.png +3 -0
- .github/pr-assets/pr-16860-01-skills-default.png +3 -0
- .github/pr-assets/pr-16860-02-recommended-facet.png +3 -0
- __tests__/api/acp-service/acp-service.api.test.ts +130 -0
- __tests__/api/agent-profiles-service/profile-field-support.test.ts +59 -0
- __tests__/api/backend-registry/active-store.test.ts +263 -0
- __tests__/api/backend-registry/health-store.test.ts +111 -0
- __tests__/api/backend-registry/last-conversation-store.test.ts +67 -0
- __tests__/api/backend-registry/storage.test.ts +365 -0
- __tests__/api/backend-registry/url-selection.test.ts +166 -0
- __tests__/api/cloud/conversation-create.test.ts +128 -0
- __tests__/api/cloud/conversation-delete.test.ts +51 -0
- __tests__/api/cloud/conversation-download.test.ts +63 -0
- __tests__/api/cloud/conversation-pause.test.ts +93 -0
- __tests__/api/cloud/conversation-public-flag.test.ts +73 -0
- __tests__/api/cloud/conversation-runtime-info.test.ts +135 -0
- __tests__/api/cloud/conversation-title.test.ts +84 -0
- __tests__/api/cloud/fetch-test-utils.ts +33 -0
- __tests__/api/cloud/git-service.test.ts +69 -0
- __tests__/api/cloud/organization-me.test.ts +88 -0
- __tests__/api/cloud/organization-service.test.ts +144 -0
- __tests__/api/cloud/profiles-service.test.ts +217 -0
- __tests__/api/cloud/provider-connections-service.test.ts +140 -0
- __tests__/api/cloud/proxy.test.ts +272 -0
- __tests__/api/cloud/sandbox-service.test.ts +59 -0
- __tests__/api/cloud/secrets-service.test.ts +179 -0
- __tests__/api/cloud/settings-service.test.ts +200 -0
- __tests__/api/cloud/skills-service.test.ts +136 -0
- __tests__/api/mcp-health/mcp-health-store.test.ts +64 -0
- __tests__/api/mcp-health/probe-mcp-server-health.test.ts +179 -0
- __tests__/api/mcp-service/mcp-service.api.test.ts +395 -0
- __tests__/api/runtime-service/agent-server-runtime-service.test.ts +307 -0
- __tests__/components/analytics/telemetry-consent-banner.test.tsx +198 -0
- __tests__/components/automations/add-automation-modal.test.tsx +107 -0
- __tests__/components/automations/automation-card.test.tsx +368 -0
- __tests__/components/automations/automation-list-row.permissions.test.tsx +145 -0
- __tests__/components/automations/automation-list-row.test.tsx +225 -0
- __tests__/components/automations/automation-view-toggle.test.tsx +46 -0
- __tests__/components/automations/backend-not-configured.test.tsx +49 -0
- __tests__/components/automations/build-automation-pills.test.tsx +82 -0
- __tests__/components/automations/create-instructions.test.tsx +123 -0
.agents/skills/custom-codereview-guide.md
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: custom-codereview-guide
|
| 3 |
+
description: Repository-specific review rules for the OpenHands Agent Canvas frontend.
|
| 4 |
+
triggers:
|
| 5 |
+
- /codereview
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
# OpenHands Agent Canvas Code Review Guidelines
|
| 9 |
+
|
| 10 |
+
This guide supplements the public `code-review` skill with rules specific to
|
| 11 |
+
`OpenHands/OpenHands`, the Agent Canvas frontend. Read `AGENTS.md` first; it is
|
| 12 |
+
the detailed source of truth for current architecture and test conventions.
|
| 13 |
+
|
| 14 |
+
Be direct and constructive. Review correctness and architecture, not formatting
|
| 15 |
+
that lint or the compiler already checks.
|
| 16 |
+
|
| 17 |
+
## Review Decision
|
| 18 |
+
|
| 19 |
+
- Submit exactly one review: **APPROVE** or **COMMENT**. Never use
|
| 20 |
+
**REQUEST_CHANGES**.
|
| 21 |
+
- Default to **APPROVE** when there are no important findings. Nitpicks and
|
| 22 |
+
optional cleanup are not reasons to withhold approval.
|
| 23 |
+
- Use **COMMENT** for correctness, security, architecture, missing evidence, or
|
| 24 |
+
unmet acceptance criteria. Let a human maintainer make the blocking decision.
|
| 25 |
+
- Do not approve changes that can affect agent or benchmark behavior—prompts,
|
| 26 |
+
tool selection, conversation payloads, terminal behavior, planning, memory,
|
| 27 |
+
or evaluation paths—without human review and appropriate lightweight evals.
|
| 28 |
+
- Read the linked issue and include a compact checklist covering each acceptance
|
| 29 |
+
criterion. Meeting the checklist is necessary but does not replace review for
|
| 30 |
+
regressions, security, or maintainability.
|
| 31 |
+
|
| 32 |
+
## Repository Ownership
|
| 33 |
+
|
| 34 |
+
Put behavior in the repository that owns it:
|
| 35 |
+
|
| 36 |
+
| Repository | Owns |
|
| 37 |
+
| ------------------------------ | --------------------------------------------------------------------------------------------------------------- |
|
| 38 |
+
| `OpenHands/OpenHands` | Agent Canvas UI, frontend state, backend selection, frontend service integration, and local-stack orchestration |
|
| 39 |
+
| `OpenHands/software-agent-sdk` | Agent Server, agents, tools, conversations, events, workspaces, and the canonical server API |
|
| 40 |
+
| `OpenHands/typescript-client` | Browser-compatible typed access to the Agent Server API |
|
| 41 |
+
| `OpenHands/extensions` | Reusable skills, plugins, and integrations |
|
| 42 |
+
| `OpenHands/automation` | Scheduling, webhooks, run history, and automation dispatch |
|
| 43 |
+
|
| 44 |
+
The normal dependency direction is Agent Server contract → TypeScript client →
|
| 45 |
+
Canvas. Flag raw endpoint reimplementations, Canvas-local copies of server
|
| 46 |
+
contracts, and changes opened in the wrong repository.
|
| 47 |
+
|
| 48 |
+
## Architecture That Guides Agents
|
| 49 |
+
|
| 50 |
+
Agents tend to copy the nearest pattern and choose the shortest compiling path.
|
| 51 |
+
Review the codebase as part of the product surface that guides those choices:
|
| 52 |
+
|
| 53 |
+
1. **Make the conventional path cheapest.** New work should naturally reuse a
|
| 54 |
+
named hook, service, store, or feature module instead of adding another branch
|
| 55 |
+
to a shared root.
|
| 56 |
+
2. **Fail forbidden dependencies mechanically.** Repeated review guidance should
|
| 57 |
+
become a lint rule, compiler boundary, or architecture test. Do not grow this
|
| 58 |
+
document when a small executable guard would be clearer.
|
| 59 |
+
3. **Give durable state one obvious writer.** A backend setting, consent value,
|
| 60 |
+
conversation cache entry, or persisted browser value should have one named
|
| 61 |
+
owner. Flag second writers and component-local mirrors of authoritative state.
|
| 62 |
+
4. **Prefer owned feature files over shared switches.** Product work should
|
| 63 |
+
usually extend a feature-owned module. Shared registries and root conditionals
|
| 64 |
+
need a concrete reason.
|
| 65 |
+
5. **Keep exceptions narrow and visible.** Exceptions belong in a small allowlist
|
| 66 |
+
next to the guard that enforces the rule and should be reviewed as architecture
|
| 67 |
+
changes.
|
| 68 |
+
|
| 69 |
+
Treat “deep module” as a design heuristic, not a line-count target. A good module
|
| 70 |
+
has a narrow, stable interface and hides cohesive complexity. Do not split a file
|
| 71 |
+
merely because it is long, and do not create layers that only rename or forward
|
| 72 |
+
arguments. Prefer a small pure seam when it removes duplicated decisions, makes
|
| 73 |
+
ownership explicit, or enables focused tests.
|
| 74 |
+
|
| 75 |
+
### React effects
|
| 76 |
+
|
| 77 |
+
`useEffect` is for synchronizing React with an external system. Flag effects used
|
| 78 |
+
to:
|
| 79 |
+
|
| 80 |
+
- derive render data from props or state;
|
| 81 |
+
- respond to a user action that can run in the event handler;
|
| 82 |
+
- initialize a value that belongs in a lazy state initializer;
|
| 83 |
+
- mirror one store or cache into another component state value; or
|
| 84 |
+
- repair ordering created by competing writers.
|
| 85 |
+
|
| 86 |
+
An effect is not automatically wrong. Subscription, browser API, timer, and
|
| 87 |
+
network synchronization still belong in effects when cleanup and dependency
|
| 88 |
+
semantics are explicit.
|
| 89 |
+
|
| 90 |
+
## Blocking Architecture Checkpoints
|
| 91 |
+
|
| 92 |
+
### Agent Server and Cloud API access
|
| 93 |
+
|
| 94 |
+
`src/api/no-direct-agent-server-calls.test.ts` is the executable source of truth.
|
| 95 |
+
Do not approve new raw `fetch`, `axios`, shared `openHands`, or low-level HTTP
|
| 96 |
+
client access to Agent Server endpoints. Use `@openhands/typescript-client` with
|
| 97 |
+
the options from `src/api/agent-server-client-options.ts`.
|
| 98 |
+
|
| 99 |
+
Cloud and runtime-sandbox requests must go through `callCloudProxy`; runtime
|
| 100 |
+
requests must provide the correct `hostOverride` and authentication mode. Review
|
| 101 |
+
changes to the guard's allowlist as architecture changes. Do not copy its current
|
| 102 |
+
entries into this guide—the test should remain the one authoritative list.
|
| 103 |
+
|
| 104 |
+
### Event wire contracts
|
| 105 |
+
|
| 106 |
+
The SDK event model is the wire authority, the TypeScript client mirrors it, and
|
| 107 |
+
Canvas consumes the published client type. Do not approve Canvas-local
|
| 108 |
+
redeclarations, partial intersections, module augmentation, or presentation
|
| 109 |
+
fields added to wire-event interfaces.
|
| 110 |
+
|
| 111 |
+
A contract change should land in this order:
|
| 112 |
+
|
| 113 |
+
1. SDK model/schema and serialization coverage.
|
| 114 |
+
2. TypeScript-client mirror derived from the SDK payload.
|
| 115 |
+
3. Published client release.
|
| 116 |
+
4. Canvas consumption and rendering/telemetry coverage.
|
| 117 |
+
|
| 118 |
+
Canvas-only presentation state belongs in a separate view model keyed by event
|
| 119 |
+
identity.
|
| 120 |
+
|
| 121 |
+
### Telemetry and durable frontend state
|
| 122 |
+
|
| 123 |
+
- `src/services/telemetry.ts` is the only owner of the Canvas PostHog client.
|
| 124 |
+
- React events go through typed functions in `src/hooks/use-tracking.ts`; components
|
| 125 |
+
must not call PostHog directly.
|
| 126 |
+
- Consent rendering uses the telemetry consent external store, not mirrored local
|
| 127 |
+
state. `setTelemetryConsent` remains the single consent controller.
|
| 128 |
+
- A business milestone has one canonical capture. Flag duplicate conditional
|
| 129 |
+
captures.
|
| 130 |
+
- For other durable values, prefer the existing named service/store/hook and flag
|
| 131 |
+
new storage writes from arbitrary components.
|
| 132 |
+
|
| 133 |
+
## Dependencies and Releases
|
| 134 |
+
|
| 135 |
+
- Direct dependencies are exact-pinned. Keep `package.json` and
|
| 136 |
+
`package-lock.json` synchronized through npm; do not hand-edit one side only.
|
| 137 |
+
- Treat changes to dependency exemptions, git pins, and security overrides as
|
| 138 |
+
reviewable policy changes. `__tests__/package-library.test.ts` is the executable
|
| 139 |
+
source of truth for allowed specs.
|
| 140 |
+
- Scrutinize newly published third-party dependency versions for supply-chain
|
| 141 |
+
risk. First-party OpenHands packages are exempt from a waiting period but not
|
| 142 |
+
from contract and release-order review.
|
| 143 |
+
- Package version changes belong in explicit release PRs and must match the
|
| 144 |
+
release workflow expectations.
|
| 145 |
+
|
| 146 |
+
## Testing and Evidence
|
| 147 |
+
|
| 148 |
+
- Require evidence proportional to the behavior changed. For UI behavior, use a
|
| 149 |
+
screenshot or video from the real app. For CLI, API, or scripts, require the
|
| 150 |
+
exact runtime command and observed result.
|
| 151 |
+
- Runtime and user-visible bug fixes require before-and-after evidence through
|
| 152 |
+
the real production-facing path. The before evidence must reproduce the bug on
|
| 153 |
+
the base branch or released version; the after evidence must repeat the same
|
| 154 |
+
setup on the PR head and show the corrected behavior. Include exact commands,
|
| 155 |
+
relevant output, and screenshots or video when the behavior is visual.
|
| 156 |
+
- For lifecycle fixes, evidence must also verify the resulting process or resource
|
| 157 |
+
state—for example, the parent exit code and whether child services or listening
|
| 158 |
+
ports remain after shutdown.
|
| 159 |
+
- Unit and integration tests are regression proof, not a substitute for live
|
| 160 |
+
evidence. If required live evidence is missing, submit **COMMENT**, not
|
| 161 |
+
**APPROVE**, and identify the exact production-facing verification still needed.
|
| 162 |
+
- Prefer tests that exercise real logic and observable state. Do not reward mocks
|
| 163 |
+
that only prove another mock was called.
|
| 164 |
+
- Keep tests focused: one meaningful assertion path per behavior, no duplicated
|
| 165 |
+
coverage of library behavior, and no brittle presentation-only snapshots.
|
| 166 |
+
- Follow the test routing in `AGENTS.md`. The mock-LLM, Docker mock-LLM, and
|
| 167 |
+
live LLM-backed E2E suites run after changes reach `main`, not from PR labels.
|
| 168 |
+
If a risky PR needs pre-merge E2E evidence, recommend manually dispatching the
|
| 169 |
+
relevant workflow against the PR branch.
|
| 170 |
+
- Never broaden live E2E triggers or secret exposure for convenience.
|
| 171 |
+
|
| 172 |
+
## Review Context Integrity
|
| 173 |
+
|
| 174 |
+
Before submitting the review, compare its summary and every finding against the
|
| 175 |
+
current PR title, changed-file manifest, linked issues, and acceptance criteria.
|
| 176 |
+
If the review describes files, behavior, issues, or release paths that are not in
|
| 177 |
+
that context, stop and re-read the PR rather than submitting stale or mismatched
|
| 178 |
+
feedback. Do not approve until this final context check passes.
|
| 179 |
+
|
| 180 |
+
## What Not to Comment On
|
| 181 |
+
|
| 182 |
+
Do not leave review comments for:
|
| 183 |
+
|
| 184 |
+
- formatting or minor style that tooling handles;
|
| 185 |
+
- optional “nice to have” refactors unrelated to the change;
|
| 186 |
+
- praise-only observations—approve instead;
|
| 187 |
+
- extra tests for straightforward data/config changes when existing checks cover
|
| 188 |
+
the risk; or
|
| 189 |
+
- temporary `.pr/` artifacts, which are cleaned up by repository automation.
|
| 190 |
+
|
| 191 |
+
When raising a finding, trace the relevant call or data flow far enough to show
|
| 192 |
+
the concrete failure mode. Prefer one high-signal comment over several symptoms
|
| 193 |
+
of the same ownership problem.
|
| 194 |
+
|
| 195 |
+
## Communication Style
|
| 196 |
+
|
| 197 |
+
- Be concise, specific, and friendly.
|
| 198 |
+
- Explain the user-visible or architectural consequence.
|
| 199 |
+
- Suggest the smallest viable correction.
|
| 200 |
+
- Use GitHub suggestion syntax for local fixes.
|
| 201 |
+
- If the PR is sound, approve it without manufacturing feedback.
|
.agents/skills/pr-design-doc/SKILL.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: pr-design-doc
|
| 3 |
+
description: >
|
| 4 |
+
For a non-trivial pull request, write a self-contained HTML design doc under the
|
| 5 |
+
temporary `.pr/` directory and link a visibility-appropriate preview in the PR
|
| 6 |
+
description, so maintainers grasp the proposal at a glance - code/API design, and the
|
| 7 |
+
before/after of the change, grounded to real code. Use when opening or updating a
|
| 8 |
+
non-trivial PR, or when the user says "add a design doc", "document this PR for
|
| 9 |
+
reviewers", "show the before/after", "make the design reviewable", or "write the .pr/
|
| 10 |
+
page".
|
| 11 |
+
triggers:
|
| 12 |
+
- /pr-design-doc
|
| 13 |
+
- /design-doc
|
| 14 |
+
license: MIT
|
| 15 |
+
metadata:
|
| 16 |
+
tags: pull-request, design-doc, html, review, before-after, htmlpreview
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
# pr-design-doc - a reviewable design doc for a non-trivial PR
|
| 20 |
+
|
| 21 |
+
A diff shows *what changed line by line*. It does not show *the design*: the shape of the
|
| 22 |
+
change, the API before and after, and why this approach. Reviewers reconstruct that by
|
| 23 |
+
hand, slowly. The scarce resource is the maintainer's attention and trust budget - not the
|
| 24 |
+
agent's effort. Spend extra effort to hand them **one self-contained HTML page** that
|
| 25 |
+
conveys the **big picture** and the **before → after core difference**, with every claim
|
| 26 |
+
**clickable back to the real code**, then link it from the PR description.
|
| 27 |
+
|
| 28 |
+
This is the same craft as a "show me this change" explainer, aimed at one job: making a
|
| 29 |
+
non-trivial PR easy to review.
|
| 30 |
+
|
| 31 |
+
## When to use it
|
| 32 |
+
|
| 33 |
+
- Opening or updating a **non-trivial** PR: new/changed public API, a new module or
|
| 34 |
+
subsystem, a behavior change in core logic, a migration, or anything a reviewer can't
|
| 35 |
+
fully judge from the diff in a couple of minutes.
|
| 36 |
+
- **Skip it** for trivial PRs - a typo, a one-line guard, a dependency bump, a docs tweak, a simple bug fix.
|
| 37 |
+
A design doc adds more to review. Use judgment; if the diff *is* the explanation, don't add a
|
| 38 |
+
page.
|
| 39 |
+
|
| 40 |
+
## The `.pr/` workflow
|
| 41 |
+
|
| 42 |
+
Use the temporary **`.pr/`** directory for PR-only artifacts. Before relying on automatic
|
| 43 |
+
cleanup, verify that the target repository has an enabled
|
| 44 |
+
`.github/workflows/pr-artifacts.yml` workflow that removes `.pr/` after approval.
|
| 45 |
+
|
| 46 |
+
- Same-repository PR with verified cleanup workflow: the workflow removes `.pr/` after
|
| 47 |
+
approval.
|
| 48 |
+
- Fork PR, or repository without a verified cleanup workflow: remove `.pr/` manually before
|
| 49 |
+
merge.
|
| 50 |
+
|
| 51 |
+
The design doc is a review aid that lives with the branch while the PR is open. It must not
|
| 52 |
+
ship in the merged tree.
|
| 53 |
+
|
| 54 |
+
## Workflow
|
| 55 |
+
|
| 56 |
+
1. **Check out and verify the PR head.** Do not write or commit the design doc from the base
|
| 57 |
+
branch or an unrelated checkout. Start with a clean worktree, then inspect and check out
|
| 58 |
+
the PR:
|
| 59 |
+
```bash
|
| 60 |
+
gh pr view <n> --json title,body,url,baseRefName,baseRefOid,headRefName,headRefOid,headRepository,headRepositoryOwner,isCrossRepository,files,additions,deletions
|
| 61 |
+
gh pr checkout <n>
|
| 62 |
+
git rev-parse HEAD
|
| 63 |
+
gh pr view <n> --json headRefOid --jq .headRefOid
|
| 64 |
+
```
|
| 65 |
+
The final two SHAs must match before you continue. If they do not, stop and fix the
|
| 66 |
+
checkout. Compute the merge-base SHA with
|
| 67 |
+
`git merge-base <baseRefOid> <headRefOid>`. Group changed files by area and keep both the
|
| 68 |
+
merge-base SHA and head SHA for source links.
|
| 69 |
+
|
| 70 |
+
2. **Read both sides of each logical file.** Compare
|
| 71 |
+
`git show <merge-base-sha>:<path>` with the verified head. Capture the
|
| 72 |
+
**function-level** behavioral difference - what the code *did* vs *does now*.
|
| 73 |
+
- new file → no "before"; one "after" diagram + a line on the role it adds.
|
| 74 |
+
- deleted file → "before" diagram + who/what takes over.
|
| 75 |
+
- edited file → a before/after pair, with the delta highlighted.
|
| 76 |
+
|
| 77 |
+
3. **Classify each file.** *Logic* change (behavior moved) → draw before/after. *Mechanical*
|
| 78 |
+
change (rename, constant, config, import move) → a one-line `before → after` row, no
|
| 79 |
+
diagram. Don't dilute the signal by drawing mechanical edits.
|
| 80 |
+
|
| 81 |
+
4. **If the change is an API change, lead with the API.** Show the signature/schema/type
|
| 82 |
+
**before and after** side by side (function signature, endpoint + payload, config field,
|
| 83 |
+
event shape). Name the compatibility impact plainly: additive, breaking, or behind a flag.
|
| 84 |
+
|
| 85 |
+
5. **Find the cross-file story.** If one call chain threads several files, draw a single
|
| 86 |
+
**overview** before/after at the top; per-file cards drill in.
|
| 87 |
+
|
| 88 |
+
6. **Build the page** per [`references/html-craft.md`](references/html-craft.md) - one
|
| 89 |
+
self-contained, offline, editorial HTML file with hand-drawn SVG figures. Save it to
|
| 90 |
+
the repo's `.pr/` directory, e.g. `.pr/design.html` (or `.pr/<topic>.html`). Before
|
| 91 |
+
writing, reject a symlink at `.pr` or at the exact output path; never follow a
|
| 92 |
+
branch-controlled symlink outside the worktree.
|
| 93 |
+
```bash
|
| 94 |
+
test ! -L .pr && test ! -L .pr/design.html
|
| 95 |
+
mkdir -p .pr
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
7. **Commit under `.pr/`, push to the verified PR head, and link it.** Confirm that the push
|
| 99 |
+
remote resolves to `headRepository.nameWithOwner`; never push the artifact to the base
|
| 100 |
+
repository's default branch.
|
| 101 |
+
```bash
|
| 102 |
+
git add .pr/design.html
|
| 103 |
+
git commit -m "docs(.pr): design doc for <PR topic>"
|
| 104 |
+
git push <head-repo-remote> HEAD:<headRefName>
|
| 105 |
+
```
|
| 106 |
+
Query the base repository's visibility before choosing the link:
|
| 107 |
+
```bash
|
| 108 |
+
gh repo view <base-owner>/<base-repo> --json visibility,url
|
| 109 |
+
```
|
| 110 |
+
- **Public repository:** add an htmlpreview link near the top of the PR description,
|
| 111 |
+
pointing at the **fork and branch the PR is opened from** (it renders before merge):
|
| 112 |
+
```
|
| 113 |
+
📄 Design doc: https://htmlpreview.github.io/?https://github.com/<fork-owner>/<repo>/blob/<pr-branch>/.pr/design.html
|
| 114 |
+
```
|
| 115 |
+
- **Private or internal repository:** link the access-controlled GitHub blob and include
|
| 116 |
+
local download/open instructions, or use an existing access-controlled artifact
|
| 117 |
+
service. Never send the document through htmlpreview or another public host.
|
| 118 |
+
|
| 119 |
+
## What the page contains
|
| 120 |
+
|
| 121 |
+
1. **What changed (decision first)** - one paragraph: the intent, net effect, and why the
|
| 122 |
+
reviewer should care. Put the highest-impact conclusion, risk, or API-compat note in a
|
| 123 |
+
`★` callout, with the most important changed `path:line` nearby. Stats (`N files ·
|
| 124 |
+
+A / −D`) are context, not the lead. If there's a cross-file flow, the **overview
|
| 125 |
+
before/after SVG** goes here.
|
| 126 |
+
2. **API before → after** (when the PR changes an interface) - signatures/schemas/types side
|
| 127 |
+
by side, with the compatibility verdict stated.
|
| 128 |
+
3. **Left rail / index** - changed files grouped by area, each tagged (🟢 added · 🔴 removed ·
|
| 129 |
+
✏️ changed · ⚙️ mechanical) with +/− counts; click to jump.
|
| 130 |
+
4. **Per-file cards** - for each logical file: a claim-carrying title, a one-line summary of
|
| 131 |
+
how its behavior changed, **before/after** diagrams with real symbol names + `file:line`
|
| 132 |
+
(changed nodes in orange), and the diff in a collapsed `<details>`. Mechanical files get a
|
| 133 |
+
small `before → after` table, no diagram.
|
| 134 |
+
5. **(optional) Risk / follow-ups** - only if grounded in what you read.
|
| 135 |
+
|
| 136 |
+
## Non-negotiable principles
|
| 137 |
+
|
| 138 |
+
1. **Optimize for scarce reviewer attention.** The first screen answers, in ~15 seconds:
|
| 139 |
+
what this PR does, whether it's risky, where to look first, and what evidence backs the
|
| 140 |
+
claim. Lead with the conclusion, not your process.
|
| 141 |
+
2. **Show the difference, not just the after.** For any logic or API change, draw **before**
|
| 142 |
+
and **after** and make the *delta* visually loud (color + line style). The contrast is
|
| 143 |
+
the product.
|
| 144 |
+
3. **Ground everything to code, beside the claim.** Every box, node, and sentence names a
|
| 145 |
+
real symbol + `path:line`, and links to the correct source revision where possible: the
|
| 146 |
+
merge-base SHA for before-state evidence and the verified head SHA for after-state
|
| 147 |
+
evidence. One click from "this changed" to the exact code.
|
| 148 |
+
4. **Hand-draw the carrying diagrams.** Prefer bespoke inline SVG for the before/after that
|
| 149 |
+
makes the argument; Mermaid is fine only for quick auxiliary graphs.
|
| 150 |
+
5. **Self-contained & offline.** One HTML file, inline CSS/SVG, no external scripts or
|
| 151 |
+
assets, opens by double-click, and survives being copied to another machine.
|
| 152 |
+
6. **`.pr/` only, and temporary.** The doc is a review aid, not project docs. Keep it in
|
| 153 |
+
`.pr/` and ensure it is removed before merge. Rely on automatic cleanup only when the
|
| 154 |
+
repository's workflow has been verified; otherwise remove it manually. Do not move design
|
| 155 |
+
HTML into `docs/` or ship it in the merged tree.
|
| 156 |
+
|
| 157 |
+
## Anti-patterns
|
| 158 |
+
|
| 159 |
+
- ❌ Dumping the raw diff / file tree and calling it a "design doc" - adds nothing over the
|
| 160 |
+
PR page.
|
| 161 |
+
- ❌ Empty nodes ("process data", "handle request") - every node is a real symbol +
|
| 162 |
+
location.
|
| 163 |
+
- ❌ Only the after-state when something changed - reviewers want the *contrast*.
|
| 164 |
+
- ❌ A design doc on a trivial PR - noise. Skip it.
|
| 165 |
+
- ❌ Committing the HTML outside `.pr/` (e.g. `docs/`), where it would merge into `main`.
|
| 166 |
+
- ❌ Publishing a private-repository design doc through htmlpreview, GitHub Pages, or another
|
| 167 |
+
public host. Use the private/local preview path in the craft reference. Use GitHub Pages
|
| 168 |
+
only with explicit user authorization after verifying private Pages access control.
|
.agents/skills/pr-design-doc/references/html-craft.md
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# html-craft - how to build the page
|
| 2 |
+
|
| 3 |
+
Shared craft for every show-me dimension. One self-contained, offline, editorial HTML
|
| 4 |
+
page with hand-drawn SVG figures and code-grounded claims.
|
| 5 |
+
|
| 6 |
+
## The look (editorial document, light theme)
|
| 7 |
+
|
| 8 |
+
Calm, readable, document-like - not a dark dashboard. Skeleton:
|
| 9 |
+
|
| 10 |
+
```html
|
| 11 |
+
<!doctype html><html lang="en"><head><meta charset="utf-8">
|
| 12 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 13 |
+
<title>{{Title}}</title>
|
| 14 |
+
<style>
|
| 15 |
+
:root{
|
| 16 |
+
/* single source of truth - prose AND inline-SVG fills both read these (see SVG section) */
|
| 17 |
+
--fg:#1a1a1a; --muted:#5a5a5a; --subtle:#8a8a82; /* 3 text weights: title · detail · sublabel */
|
| 18 |
+
--bg:#fdfdfb; --card:#ffffff; --elevated:#f7f6f1; /* page · node fill · raised band/pill */
|
| 19 |
+
--accent:#2b4eaa; --accent-soft:#e6ecfa;
|
| 20 |
+
--rule:#e2e2dc; --rule-strong:#bdbcb4; /* hairline · emphasized border */
|
| 21 |
+
--code-bg:#f3f1ec; --warn:#b25b0e; --ok:#2f7a3a; --crit:#a02020;
|
| 22 |
+
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
| 23 |
+
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Helvetica,Arial,sans-serif;
|
| 24 |
+
}
|
| 25 |
+
*{box-sizing:border-box} html,body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans)}
|
| 26 |
+
body{line-height:1.6;font-size:16px}
|
| 27 |
+
.wrap{display:grid;grid-template-columns:240px minmax(0,1fr);gap:48px;max-width:1180px;margin:0 auto;padding:32px 28px 96px}
|
| 28 |
+
nav.toc{position:sticky;top:24px;align-self:start;font-size:13px}
|
| 29 |
+
nav.toc h2{font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);margin:0 0 10px}
|
| 30 |
+
nav.toc ol{list-style:none;padding:0;margin:0;counter-reset:toc}
|
| 31 |
+
nav.toc li{counter-increment:toc;margin:4px 0}
|
| 32 |
+
nav.toc li::before{content:counter(toc) ". ";color:var(--muted)}
|
| 33 |
+
nav.toc a{color:var(--fg);text-decoration:none} nav.toc a:hover{color:var(--accent)}
|
| 34 |
+
header.title{border-bottom:1px solid var(--rule);padding-bottom:22px;margin-bottom:28px}
|
| 35 |
+
header.title .eyebrow{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);font-weight:700}
|
| 36 |
+
header.title h1{font-size:34px;margin:6px 0 4px;letter-spacing:-.01em}
|
| 37 |
+
header.title .sub{color:var(--muted);max-width:740px}
|
| 38 |
+
h2{font-size:22px;margin:44px 0 10px} h2 .num{color:var(--muted);font-weight:500;margin-right:8px}
|
| 39 |
+
h3{font-size:16px;margin:22px 0 8px}
|
| 40 |
+
code{font-family:var(--mono);font-size:.88em;background:var(--code-bg);padding:1px 5px;border-radius:3px}
|
| 41 |
+
pre{font-family:var(--mono);font-size:13px;background:var(--code-bg);padding:14px 16px;border-radius:6px;overflow-x:auto;border:1px solid var(--rule)}
|
| 42 |
+
pre code{background:transparent;padding:0}
|
| 43 |
+
.kw{color:#7048a8}.str{color:var(--ok)}.com{color:var(--subtle);font-style:italic}
|
| 44 |
+
table{border-collapse:collapse;width:100%;font-size:14px;margin:12px 0}
|
| 45 |
+
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--rule);vertical-align:top}
|
| 46 |
+
th{font-weight:600;background:var(--code-bg)}
|
| 47 |
+
.callout{border-left:3px solid var(--accent);background:var(--accent-soft);padding:10px 14px;margin:14px 0;border-radius:0 4px 4px 0;font-size:14.5px}
|
| 48 |
+
.callout.warn{border-color:var(--warn);background:#fbf1e6} .callout.note{border-color:var(--muted);background:#f3f1ec}
|
| 49 |
+
.fig{margin:18px 0 22px}
|
| 50 |
+
.fig svg{display:block;max-width:100%;height:auto;background:#fff;border:1px solid var(--rule);border-radius:6px}
|
| 51 |
+
.fig figcaption{font-size:13px;color:var(--muted);margin-top:6px;text-align:center}
|
| 52 |
+
a.src{font-family:var(--mono);font-size:12px;color:var(--accent);text-decoration:none;border-bottom:1px dotted var(--accent)}
|
| 53 |
+
details{border:1px solid var(--rule);border-radius:6px;margin:12px 0}
|
| 54 |
+
details>summary{cursor:pointer;padding:8px 12px;color:var(--muted);font-size:13px;list-style:none}
|
| 55 |
+
details>summary::-webkit-details-marker{display:none}
|
| 56 |
+
details>summary::before{content:"▸ "} details[open]>summary::before{content:"▾ "}
|
| 57 |
+
.ba{display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:start}
|
| 58 |
+
@media(max-width:760px){.wrap{grid-template-columns:1fr}.ba{grid-template-columns:1fr}}
|
| 59 |
+
</style></head><body>
|
| 60 |
+
<div class="wrap">
|
| 61 |
+
<nav class="toc"><h2>Contents</h2><ol>
|
| 62 |
+
<li><a href="#sec1">…</a></li>
|
| 63 |
+
</ol></nav>
|
| 64 |
+
<main>
|
| 65 |
+
<header class="title"><div class="eyebrow">{{kind}}</div><h1>{{Title}}</h1>
|
| 66 |
+
<p class="sub">{{one-paragraph mental model - the big picture in 2-3 sentences}}</p></header>
|
| 67 |
+
<section id="sec1"><h2><span class="num">1</span>…</h2> … </section>
|
| 68 |
+
</main>
|
| 69 |
+
</div></body></html>
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
Numbered sticky TOC + one-paragraph mental model up top + sectioned body. No JS needed
|
| 73 |
+
for this shell.
|
| 74 |
+
|
| 75 |
+
## First screen: 15-second orientation
|
| 76 |
+
|
| 77 |
+
The reader's attention is the budget. The first viewport should answer four questions
|
| 78 |
+
without requiring a full read:
|
| 79 |
+
|
| 80 |
+
1. **What is this?** A one-paragraph mental model in the title subtitle.
|
| 81 |
+
2. **Why does it matter?** The highest-impact conclusion, risk, or action in a `★`
|
| 82 |
+
callout near the top.
|
| 83 |
+
3. **Where should I jump?** A numbered sticky TOC whose labels carry information, not
|
| 84 |
+
just categories.
|
| 85 |
+
4. **Why should I trust it?** Nearby source links (`path:line`, test, command, commit,
|
| 86 |
+
or fixture) for the first substantive claim.
|
| 87 |
+
|
| 88 |
+
Do not open with "I read these files" or a chronological work log. Start with the
|
| 89 |
+
reader's decision: what changed, how the system works, what path matters, or where to
|
| 90 |
+
look next. Put methods, command output, and raw diffs behind `<details>` unless they
|
| 91 |
+
are the point of the report.
|
| 92 |
+
|
| 93 |
+
## Skimmable structure
|
| 94 |
+
|
| 95 |
+
Design the page so a busy reader can scan headings, captions, callouts, and tables
|
| 96 |
+
before choosing where to dive:
|
| 97 |
+
|
| 98 |
+
- **Headings make claims.** Prefer "Writes only cross the queue" over "Architecture",
|
| 99 |
+
when the section has a specific finding. Generic labels are acceptable only when the
|
| 100 |
+
title/subtitle already carries the finding.
|
| 101 |
+
- **One paragraph, one judgment.** Keep paragraphs short; split when a sentence starts
|
| 102 |
+
proving a different point.
|
| 103 |
+
- **Number parallel points.** If you say "three rules" or "two risks", number them so
|
| 104 |
+
the reader can reconcile the claim with the list.
|
| 105 |
+
- **Use tables only for stable comparison axes.** A table should reduce cognitive
|
| 106 |
+
work, not force subtle judgments into neat boxes.
|
| 107 |
+
- **Make captions do work.** A caption states the takeaway of the figure, not merely
|
| 108 |
+
its type.
|
| 109 |
+
|
| 110 |
+
## Callouts (give them semantic types)
|
| 111 |
+
|
| 112 |
+
The `.callout` / `.callout.warn` / `.callout.note` styles aren't interchangeable - assign
|
| 113 |
+
each a fixed job and the reader learns to skim by them:
|
| 114 |
+
|
| 115 |
+
- **`★` key takeaway** (accent) - the one load-bearing sentence of a section. At most one per
|
| 116 |
+
section; it's what the reader should remember if they read nothing else.
|
| 117 |
+
- **`ⓘ` note** (`.note`, muted) - a reading hint for a figure, an aside, a "why we did it this
|
| 118 |
+
way" that isn't on the critical path.
|
| 119 |
+
- **`⚠` warning** (`.warn`) - a boundary, a risk, a gotcha, a guardrail: trust boundaries,
|
| 120 |
+
"this does NOT do X", "never commit the secret". Reserve it for things that bite.
|
| 121 |
+
|
| 122 |
+
Don't let callouts become wallpaper - if every paragraph is a colored box, none of them carry
|
| 123 |
+
weight. A glyph prefix (`★ ⓘ ⚠`) makes the type legible before the reader parses the text.
|
| 124 |
+
|
| 125 |
+
## Hand-drawn SVG figures (the diagrams that carry the argument)
|
| 126 |
+
|
| 127 |
+
Draw bespoke inline `<svg viewBox="0 0 W H">` with a `<defs>` for arrow markers and a
|
| 128 |
+
scoped `<style>`. You place every box → before/after stays aligned, legible, and the
|
| 129 |
+
delta is unmistakable. Semantic class system:
|
| 130 |
+
|
| 131 |
+
```html
|
| 132 |
+
<svg viewBox="0 0 960 460" role="img" aria-labelledby="f1-t f1-d">
|
| 133 |
+
<title id="f1-t">moduleA call path - before vs after</title>
|
| 134 |
+
<desc id="f1-d">Old direct call is replaced by a routed path through the new resolver.</desc>
|
| 135 |
+
<defs>
|
| 136 |
+
<!-- one marker per semantic color; marker fills read the page palette -->
|
| 137 |
+
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
| 138 |
+
<path d="M0,0 L10,5 L0,10 Z" fill="var(--subtle)"/></marker>
|
| 139 |
+
<marker id="arr-chg" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
| 140 |
+
<path d="M0,0 L10,5 L0,10 Z" fill="var(--warn)"/></marker>
|
| 141 |
+
<!-- subtle "elevated" wash for the box that is the figure's subject -->
|
| 142 |
+
<linearGradient id="subj" x1="0" y1="0" x2="0" y2="1">
|
| 143 |
+
<stop offset="0" stop-color="var(--accent)" stop-opacity=".10"/>
|
| 144 |
+
<stop offset="1" stop-color="var(--accent)" stop-opacity=".02"/></linearGradient>
|
| 145 |
+
<style>
|
| 146 |
+
/* every fill/stroke is a --token: the palette lives once, in :root - prose & figures can't drift */
|
| 147 |
+
.existing{fill:var(--code-bg);stroke:var(--subtle);stroke-width:1.4;stroke-dasharray:5 3} /* before */
|
| 148 |
+
.ex-title{font:700 13px var(--sans);fill:var(--muted)} .ex-detail{font:11.5px var(--mono);fill:var(--subtle)}
|
| 149 |
+
.new{fill:var(--accent-soft);stroke:var(--accent);stroke-width:2} /* after / added */
|
| 150 |
+
.new-title{font:800 13px var(--sans);fill:var(--accent)} .detail{font:11.5px var(--mono);fill:var(--fg)}
|
| 151 |
+
.edge{stroke:var(--subtle);stroke-width:1.5;stroke-dasharray:5 3;fill:none}
|
| 152 |
+
.edge-chg{stroke:var(--warn);stroke-width:1.8;fill:none;stroke-dasharray:5 3} /* the delta */
|
| 153 |
+
.lbl{font:600 10.5px var(--mono);fill:var(--accent)} .gap{font:700 11.5px var(--mono);fill:var(--warn)}
|
| 154 |
+
</style>
|
| 155 |
+
</defs>
|
| 156 |
+
<!-- ===== BEFORE: direct call ===== -->
|
| 157 |
+
<g>
|
| 158 |
+
<rect class="existing" x="40" y="60" width="180" height="74" rx="6"/>
|
| 159 |
+
<text class="ex-title" x="130" y="90" text-anchor="middle">moduleA.fn()</text>
|
| 160 |
+
<text class="ex-detail" x="130" y="110" text-anchor="middle">old behavior · a.py:42</text>
|
| 161 |
+
</g>
|
| 162 |
+
<!-- ===== AFTER: routed call (the change) ===== -->
|
| 163 |
+
<g>
|
| 164 |
+
<rect class="new" x="300" y="60" width="180" height="74" rx="6"/>
|
| 165 |
+
<text class="new-title" x="390" y="90" text-anchor="middle">moduleA.fn()</text>
|
| 166 |
+
<path class="edge-chg" d="M220,97 L300,97" marker-end="url(#arr-chg)"/>
|
| 167 |
+
<!-- pill behind an on-edge label so it stays legible over the line -->
|
| 168 |
+
<rect x="236" y="79" width="48" height="16" rx="5" fill="var(--elevated)" stroke="var(--rule)"/>
|
| 169 |
+
<text class="gap" x="260" y="91" text-anchor="middle">new path</text>
|
| 170 |
+
</g>
|
| 171 |
+
</svg>
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
**Five craft moves in that skeleton - they're what make a figure read like a designed
|
| 175 |
+
diagram instead of a sketch:**
|
| 176 |
+
- **Drive every fill/stroke from `var(--token)`.** Define the palette once in `:root`; the
|
| 177 |
+
SVG references it. One source of truth - prose and figures stay in lockstep, and a palette
|
| 178 |
+
tweak reflows every diagram. Never paste a raw hex into a figure.
|
| 179 |
+
- **Three text weights, always the same three.** `--fg` for the node title (the real symbol),
|
| 180 |
+
`--muted` for the detail line, `--subtle` for sublabels/`path:line`. The eye sorts the
|
| 181 |
+
hierarchy without reading. Mixing weights ad-hoc is what makes a diagram look noisy.
|
| 182 |
+
- **`<title>` + `<desc>` with `aria-labelledby`.** Not decoration: it's the figure's own
|
| 183 |
+
caption-of-record (screen readers, and a note-to-self of what the figure claims).
|
| 184 |
+
- **A gradient wash marks the subject.** The one container the figure is *about* gets the
|
| 185 |
+
`#subj` wash (accent 10%→2%); everything else is flat. The reader's eye lands on the
|
| 186 |
+
protagonist before reading a word.
|
| 187 |
+
- **`<g>` groups + `<!-- section -->` comments + on-edge label pills.** Author the SVG like
|
| 188 |
+
code: one `<g>` per logical unit, a comment naming it, and a small `rect` pill behind any
|
| 189 |
+
label that sits on a line (a bare label over an edge turns to mush). It stays editable when
|
| 190 |
+
you revise the layout three times.
|
| 191 |
+
|
| 192 |
+
**The before/after encoding (use consistently):**
|
| 193 |
+
- **dashed gray** (`.existing`, `.edge` muted) = what was there before / unchanged context.
|
| 194 |
+
- **solid accent** (`.new`) = what this change adds / the after-state.
|
| 195 |
+
- **orange dashed** (`.edge-chg`, `.gap`) = the *delta* - the new/changed flow, the gap being filled. This is what the eye should land on.
|
| 196 |
+
|
| 197 |
+
Node boxes carry a **title (real symbol) + a detail line (behavior + `file:line`)**, so the
|
| 198 |
+
diagram is already half-grounded to code.
|
| 199 |
+
|
| 200 |
+
Two ways to show before/after:
|
| 201 |
+
1. **Side-by-side figures** in a `.ba` grid - `<figure>` "Before" | `<figure>` "After". Best when layouts differ a lot.
|
| 202 |
+
2. **One overlaid figure** - existing nodes dashed-gray, new nodes/edges solid-accent + orange delta, in the same coordinate space. Best when the change is *additive* to an existing structure (most legible "what's new" read).
|
| 203 |
+
|
| 204 |
+
## Caption every figure, and hint how to read the hard ones
|
| 205 |
+
|
| 206 |
+
A figure with no caption makes the reader reverse-engineer your intent. Two cheap habits
|
| 207 |
+
fix it:
|
| 208 |
+
|
| 209 |
+
- **The `<figcaption>` states the takeaway, not a label.** Not "Architecture diagram" -
|
| 210 |
+
*"Requests never touch the DB directly; every write goes through the queue."* The caption
|
| 211 |
+
is the one sentence you'd say pointing at the figure. Number them (`Fig 3 ·`) and
|
| 212 |
+
**cross-reference between figures** (`state machine in Fig 2`, `evolve timing in Fig 5`) so
|
| 213 |
+
a multi-figure report reads as one system at different altitudes, not five loose pictures.
|
| 214 |
+
- **For a figure with a non-obvious convention, add a one-line reading hint** in a `.callout.note`
|
| 215 |
+
right under it, naming the single thing that unlocks it: *"Every arrow enters or leaves the
|
| 216 |
+
bus - there are no agent-to-agent arrows, because the protocol is 'read/write the bus'."*
|
| 217 |
+
One sentence that says *how to look* saves the reader a minute of squinting. Put the hint in
|
| 218 |
+
the figure too when you can - an in-diagram `→ §3.2 / Fig 2` link (accent mono) turns the
|
| 219 |
+
picture into a table of contents.
|
| 220 |
+
|
| 221 |
+
## Ground every claim to code (clickable)
|
| 222 |
+
|
| 223 |
+
A picture the human can't verify is a liability. Make claims droppable to source:
|
| 224 |
+
|
| 225 |
+
- Resolve both repository web bases explicitly. For a fork PR, before-state evidence belongs
|
| 226 |
+
to the base repository and merge-base SHA; after-state evidence belongs to the head
|
| 227 |
+
repository and verified head SHA. Query each with
|
| 228 |
+
`gh repo view <owner>/<repo> --json url`, then build links as
|
| 229 |
+
`https://github.com/<o>/<r>/blob/<sha>/<path>#L<n>`. A deleted file must link to the base
|
| 230 |
+
repository at the merge-base SHA; a newly added file has no before-state link.
|
| 231 |
+
- Render locations as `<a class="src" href="{{blobURL}}">path:line</a>` in node detail lines, section text, and a per-component "source" link.
|
| 232 |
+
- Rule: if a box can't be tied to a symbol+location, it's a *concept* box - style it differently and say so; don't fake a link.
|
| 233 |
+
- Put evidence beside the claim it supports. The reader should not have to scroll to a
|
| 234 |
+
bibliography to verify a behavior statement, edge, risk, metric, or test result.
|
| 235 |
+
- Commands count as evidence when behavior must be observed: show the command and the
|
| 236 |
+
relevant result near the claim, with full logs collapsed if noisy.
|
| 237 |
+
|
| 238 |
+
### Grounding discipline (borrowed from DeepWiki)
|
| 239 |
+
|
| 240 |
+
DeepWiki-style wikis enforce grounding hard - worth copying:
|
| 241 |
+
|
| 242 |
+
- **Source manifest per section.** Open each major section / component card with a small
|
| 243 |
+
collapsed list of the exact files it's built from:
|
| 244 |
+
`<details><summary>Sources</summary> path/a.py · path/b.py …</details>`. The reader sees
|
| 245 |
+
what the claim rests on before trusting it.
|
| 246 |
+
- **Cite after every substantive claim, diagram, table, and snippet.** Inline format:
|
| 247 |
+
`Sources: [path:start-end]` (range) or `[path:line]` (single), multiple allowed. A
|
| 248 |
+
section with no citations is a smell - either ground it or cut it.
|
| 249 |
+
- **Solely from the code. Do not invent.** Every statement, box, edge, and number must be
|
| 250 |
+
derived from files you actually read - not from how "similar systems usually work." If
|
| 251 |
+
something important isn't in the code, say so explicitly rather than guessing. Mark genuine
|
| 252 |
+
inferences as inferences.
|
| 253 |
+
- **Breadth check.** Decide if necessary, depending on PR. A "comprehensive" page that cites only 1-2 files is probably shallow -
|
| 254 |
+
pull in the related files (callers, callees, config, tests) until the picture is real.
|
| 255 |
+
|
| 256 |
+
## Code excerpts (when shown)
|
| 257 |
+
|
| 258 |
+
Keep them short and hand-highlight with spans (no JS highlighter): wrap keywords
|
| 259 |
+
`<span class="kw">`, strings `<span class="str">`, comments `<span class="com">`.
|
| 260 |
+
**Escape for the insertion context before wrapping** - source text is untrusted. In text
|
| 261 |
+
nodes, escape `&`, `<`, and `>`. In quoted HTML or SVG attributes, additionally escape `"`
|
| 262 |
+
as `"` and `'` as `'`; always quote attributes. For source URLs, percent-encode the
|
| 263 |
+
dynamic owner, repository, and ref as URL path segments; encode each source-path segment
|
| 264 |
+
separately and rejoin with `/`. Then attribute-escape the completed URL. Put long diffs/code
|
| 265 |
+
inside `<details>` (collapsed). Escape every source-derived value
|
| 266 |
+
inserted into HTML or SVG, including titles, symbol names, paths, labels, link text, and
|
| 267 |
+
attribute values. Never treat source-derived text as markup.
|
| 268 |
+
|
| 269 |
+
## Self-contained
|
| 270 |
+
|
| 271 |
+
- One `.html` file. Inline all CSS and SVG. No build, no framework.
|
| 272 |
+
- No external scripts, styles, fonts, images, or other assets. Besides breaking offline use,
|
| 273 |
+
third-party active content can read and disclose a private design document opened locally.
|
| 274 |
+
|
| 275 |
+
## Mermaid (quick / auxiliary only)
|
| 276 |
+
|
| 277 |
+
For drafting a throwaway or simple auxiliary graph, locally installed Mermaid can be faster
|
| 278 |
+
than hand-SVG. Render it locally to static SVG, inspect and sanitize the output, then inline
|
| 279 |
+
that SVG. Never ship a Mermaid runtime or CDN script in the page. For the **carrying**
|
| 280 |
+
before/after diagram, hand-SVG wins - auto-layout drifts and breaks the side-by-side alignment
|
| 281 |
+
that makes the delta readable.
|
| 282 |
+
|
| 283 |
+
### Public repositories: commit under `.pr/` and use an htmlpreview link
|
| 284 |
+
|
| 285 |
+
Because the page is **self-contained**, GitHub can render it through `htmlpreview.github.io`
|
| 286 |
+
(it fetches the raw HTML and serves it with the right content-type - plain `raw.githubusercontent.com`
|
| 287 |
+
won't, it returns `text/plain`). Commit the file to the PR branch under the temporary `.pr/`
|
| 288 |
+
directory, then build the link and paste it in the PR description:
|
| 289 |
+
|
| 290 |
+
```bash
|
| 291 |
+
# commit the html under .pr/ on your PR branch (this dir is temporary, see the skill)
|
| 292 |
+
git add .pr/design.html && git commit -m "docs(.pr): design doc" && git push <your-fork> <branch>
|
| 293 |
+
# link template - use YOUR fork + PR branch, so it renders before the PR is merged:
|
| 294 |
+
# https://htmlpreview.github.io/?https://github.com/<fork-owner>/<repo>/blob/<pr-branch>/.pr/design.html
|
| 295 |
+
```
|
| 296 |
+
|
| 297 |
+
Example shape:
|
| 298 |
+
`https://htmlpreview.github.io/?https://github.com/FORK_OWNER/REPO/blob/PR_BRANCH/.pr/design.html`
|
| 299 |
+
|
| 300 |
+
`<pr-branch>` can be any branch or commit - `raw.githubusercontent.com` serves it regardless
|
| 301 |
+
of merge state, so the doc renders while the PR is still open. Point the URL at the **fork
|
| 302 |
+
and branch the PR is opened from**, not `main`.
|
| 303 |
+
|
| 304 |
+
For public repositories, anyone can open the rendered page without a download or local server.
|
| 305 |
+
This works with self-contained pages containing only inline CSS and SVG.
|
| 306 |
+
|
| 307 |
+
### Private repositories: keep the preview private
|
| 308 |
+
|
| 309 |
+
`htmlpreview` cannot fetch private repository content. Do not work around that by publishing
|
| 310 |
+
the design doc to GitHub Pages or another public host. Keep the document free of external
|
| 311 |
+
scripts and assets: opening a local file does not make third-party active content private.
|
| 312 |
+
|
| 313 |
+
- Link authorized reviewers to the committed GitHub blob and ask them to download and open the
|
| 314 |
+
self-contained HTML file locally; or use an existing access-controlled artifact service.
|
| 315 |
+
- For a local browser preview, open `.pr/design.html` directly. If the browser needs HTTP,
|
| 316 |
+
serve only on loopback:
|
| 317 |
+
```bash
|
| 318 |
+
python -m http.server 8000 --bind 127.0.0.1 --directory .pr
|
| 319 |
+
# open http://127.0.0.1:8000/design.html
|
| 320 |
+
```
|
| 321 |
+
- Use GitHub Pages only when the user explicitly authorizes publication and an administrator
|
| 322 |
+
confirms that private Pages access control is enabled for this repository.
|
.agents/skills/release.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: release
|
| 3 |
+
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.
|
| 4 |
+
triggers:
|
| 5 |
+
- release
|
| 6 |
+
- new release
|
| 7 |
+
- cut a release
|
| 8 |
+
- publish release
|
| 9 |
+
- bump version
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# Release Process for @openhands/agent-canvas
|
| 13 |
+
|
| 14 |
+
## Overview
|
| 15 |
+
|
| 16 |
+
Releases are **trunk-based and automated by release-please**, via the shared reusable
|
| 17 |
+
workflows in [`OpenHands/release-actions`](https://github.com/OpenHands/release-actions):
|
| 18 |
+
|
| 19 |
+
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.
|
| 20 |
+
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.
|
| 21 |
+
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`).
|
| 22 |
+
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`.
|
| 23 |
+
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).
|
| 24 |
+
|
| 25 |
+
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.
|
| 26 |
+
|
| 27 |
+
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).
|
| 28 |
+
|
| 29 |
+
**Never commit to the release PR's branch by hand** — release-please owns it and force-pushes it on every push to `main`.
|
| 30 |
+
|
| 31 |
+
---
|
| 32 |
+
|
| 33 |
+
## Step 1: Find the Release PR
|
| 34 |
+
|
| 35 |
+
```bash
|
| 36 |
+
gh pr list --state open --label "autorelease: pending"
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
If no release PR is open, nothing releasable (`feat`/`fix`/breaking) has merged since the last release — or `release.yml` is failing on `main`:
|
| 40 |
+
|
| 41 |
+
```bash
|
| 42 |
+
gh run list --workflow=release.yml --limit=3
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
---
|
| 46 |
+
|
| 47 |
+
## Step 2: Review It
|
| 48 |
+
|
| 49 |
+
Open the release PR and confirm:
|
| 50 |
+
|
| 51 |
+
- **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.
|
| 52 |
+
- **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.
|
| 53 |
+
|
| 54 |
+
---
|
| 55 |
+
|
| 56 |
+
## Step 3: Cut the Release
|
| 57 |
+
|
| 58 |
+
**STOP HERE and confirm with the user before proceeding.** Marking the PR ready and merging it publishes to npm and GHCR.
|
| 59 |
+
|
| 60 |
+
```bash
|
| 61 |
+
gh pr ready <release-pr-number>
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
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).
|
| 65 |
+
|
| 66 |
+
---
|
| 67 |
+
|
| 68 |
+
## Step 4: Watch the Pipeline
|
| 69 |
+
|
| 70 |
+
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:
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
gh run list --workflow=release.yml --limit=3
|
| 74 |
+
gh run list --workflow=npm-publish.yml --limit=3
|
| 75 |
+
gh run list --workflow=docker.yml --limit=3
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
---
|
| 79 |
+
|
| 80 |
+
## Step 5: Verify the Release
|
| 81 |
+
|
| 82 |
+
```bash
|
| 83 |
+
# GitHub release
|
| 84 |
+
gh release view v<version>
|
| 85 |
+
|
| 86 |
+
# npm (allow ~2 min for publish to propagate)
|
| 87 |
+
npm view @openhands/agent-canvas@<version>
|
| 88 |
+
npm view @openhands/agent-canvas dist-tags # stable releases get `latest`
|
| 89 |
+
|
| 90 |
+
# Docker
|
| 91 |
+
docker pull ghcr.io/openhands/agent-canvas:<version>
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
External install docs on docs.openhands.dev are maintained separately; update them there when closing #1073.
|
| 95 |
+
|
| 96 |
+
---
|
| 97 |
+
|
| 98 |
+
## Troubleshooting
|
| 99 |
+
|
| 100 |
+
### No release PR appears after merging to main
|
| 101 |
+
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).
|
| 102 |
+
|
| 103 |
+
### The proposed version is wrong
|
| 104 |
+
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.
|
| 105 |
+
|
| 106 |
+
### No Slack message when the PR was marked ready
|
| 107 |
+
`SLACK_BOT_TOKEN` is optional by design — the gate still applies the `release: ready` label and the release proceeds normally.
|
| 108 |
+
|
| 109 |
+
### package.json version doesn't match the tag
|
| 110 |
+
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.
|
.gitattributes
CHANGED
|
@@ -4,3 +4,11 @@
|
|
| 4 |
*.png binary
|
| 5 |
*.ico binary
|
| 6 |
*.icns binary
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
*.png binary
|
| 5 |
*.ico binary
|
| 6 |
*.icns binary
|
| 7 |
+
electron/build-resources/icon.icns filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
.github/pr-assets/pr-16268-02-lightbox-open.png filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
.github/pr-assets/pr-16860-01-skills-default.png filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
.github/pr-assets/pr-16676-01-list.png filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
.github/pr-assets/pr-16605-test-results.png filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
.github/pr-assets/pr-16676-02-templates.png filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
.github/pr-assets/pr-16860-02-recommended-facet.png filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
.github/pr-assets/pr-16676-04-edit-modal.png filter=lfs diff=lfs merge=lfs -text
|
.github/pr-assets/pr-16268-02-lightbox-open.png
ADDED
|
Git LFS Details
|
.github/pr-assets/pr-16605-test-results.png
ADDED
|
Git LFS Details
|
.github/pr-assets/pr-16676-01-list.png
ADDED
|
Git LFS Details
|
.github/pr-assets/pr-16676-02-templates.png
ADDED
|
Git LFS Details
|
.github/pr-assets/pr-16676-04-edit-modal.png
ADDED
|
Git LFS Details
|
.github/pr-assets/pr-16860-01-skills-default.png
ADDED
|
Git LFS Details
|
.github/pr-assets/pr-16860-02-recommended-facet.png
ADDED
|
Git LFS Details
|
__tests__/api/acp-service/acp-service.api.test.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import type { BashOutput } from "@openhands/typescript-client";
|
| 3 |
+
import AcpService from "#/api/acp-service/acp-service.api";
|
| 4 |
+
|
| 5 |
+
// Capture the command the service runs and control the BashOutput it sees.
|
| 6 |
+
const executeCommand = vi.hoisted(() => vi.fn());
|
| 7 |
+
vi.mock("@openhands/typescript-client/clients", () => ({
|
| 8 |
+
BashClient: class {
|
| 9 |
+
executeCommand = executeCommand;
|
| 10 |
+
},
|
| 11 |
+
}));
|
| 12 |
+
vi.mock("#/api/agent-server-client-options", () => ({
|
| 13 |
+
getAgentServerClientOptions: () => ({
|
| 14 |
+
host: "http://localhost",
|
| 15 |
+
workingDir: "/",
|
| 16 |
+
}),
|
| 17 |
+
}));
|
| 18 |
+
|
| 19 |
+
function bashOutput(partial: Partial<BashOutput>): BashOutput {
|
| 20 |
+
return {
|
| 21 |
+
id: "1",
|
| 22 |
+
timestamp: "2026-01-01T00:00:00Z",
|
| 23 |
+
command_id: "c1",
|
| 24 |
+
order: 0,
|
| 25 |
+
exit_code: 0,
|
| 26 |
+
stdout: null,
|
| 27 |
+
stderr: null,
|
| 28 |
+
kind: "BashOutput",
|
| 29 |
+
...partial,
|
| 30 |
+
} as BashOutput;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
beforeEach(() => vi.clearAllMocks());
|
| 34 |
+
|
| 35 |
+
describe("AcpService.getAuthStatus", () => {
|
| 36 |
+
describe("claude-code (claude auth status --json)", () => {
|
| 37 |
+
it("runs the right command and maps loggedIn:true → authenticated", async () => {
|
| 38 |
+
executeCommand.mockResolvedValue(
|
| 39 |
+
bashOutput({
|
| 40 |
+
stdout: JSON.stringify({ loggedIn: true, authMethod: "claude.ai" }),
|
| 41 |
+
}),
|
| 42 |
+
);
|
| 43 |
+
await expect(AcpService.getAuthStatus("claude-code")).resolves.toBe(
|
| 44 |
+
"authenticated",
|
| 45 |
+
);
|
| 46 |
+
expect(executeCommand).toHaveBeenCalledWith(
|
| 47 |
+
"claude auth status --json",
|
| 48 |
+
undefined,
|
| 49 |
+
expect.any(Number),
|
| 50 |
+
);
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
it("maps loggedIn:false (even with a non-zero exit) → unauthenticated", async () => {
|
| 54 |
+
executeCommand.mockResolvedValue(
|
| 55 |
+
bashOutput({
|
| 56 |
+
stdout: JSON.stringify({ loggedIn: false }),
|
| 57 |
+
exit_code: 1,
|
| 58 |
+
}),
|
| 59 |
+
);
|
| 60 |
+
await expect(AcpService.getAuthStatus("claude-code")).resolves.toBe(
|
| 61 |
+
"unauthenticated",
|
| 62 |
+
);
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
it("→ unknown when the CLI is missing (exit 127, no JSON on stdout)", async () => {
|
| 66 |
+
// The "no available ACP process / CLI not installed" path.
|
| 67 |
+
executeCommand.mockResolvedValue(
|
| 68 |
+
bashOutput({
|
| 69 |
+
exit_code: 127,
|
| 70 |
+
stderr: "env: claude: No such file or directory",
|
| 71 |
+
}),
|
| 72 |
+
);
|
| 73 |
+
await expect(AcpService.getAuthStatus("claude-code")).resolves.toBe(
|
| 74 |
+
"unknown",
|
| 75 |
+
);
|
| 76 |
+
});
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
describe("codex (codex login status)", () => {
|
| 80 |
+
it("→ authenticated even though the CLI writes to stderr", async () => {
|
| 81 |
+
executeCommand.mockResolvedValue(
|
| 82 |
+
bashOutput({ stderr: "Logged in using ChatGPT\n" }),
|
| 83 |
+
);
|
| 84 |
+
await expect(AcpService.getAuthStatus("codex")).resolves.toBe(
|
| 85 |
+
"authenticated",
|
| 86 |
+
);
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
it("→ unauthenticated on 'Not logged in'", async () => {
|
| 90 |
+
executeCommand.mockResolvedValue(
|
| 91 |
+
bashOutput({ stderr: "Not logged in\n" }),
|
| 92 |
+
);
|
| 93 |
+
await expect(AcpService.getAuthStatus("codex")).resolves.toBe(
|
| 94 |
+
"unauthenticated",
|
| 95 |
+
);
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
it("→ unknown when the CLI is missing", async () => {
|
| 99 |
+
executeCommand.mockResolvedValue(
|
| 100 |
+
bashOutput({ exit_code: 127, stderr: "codex: command not found" }),
|
| 101 |
+
);
|
| 102 |
+
await expect(AcpService.getAuthStatus("codex")).resolves.toBe("unknown");
|
| 103 |
+
});
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
describe("gemini-cli (credentials file check)", () => {
|
| 107 |
+
it("→ authenticated when the creds file is present", async () => {
|
| 108 |
+
executeCommand.mockResolvedValue(bashOutput({ stdout: "present\n" }));
|
| 109 |
+
await expect(AcpService.getAuthStatus("gemini-cli")).resolves.toBe(
|
| 110 |
+
"authenticated",
|
| 111 |
+
);
|
| 112 |
+
// Probes the OAuth creds file, not a (nonexistent) gemini status command.
|
| 113 |
+
expect(executeCommand.mock.calls[0][0]).toContain("oauth_creds.json");
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
it("→ unauthenticated when the creds file is absent", async () => {
|
| 117 |
+
executeCommand.mockResolvedValue(bashOutput({ stdout: "absent\n" }));
|
| 118 |
+
await expect(AcpService.getAuthStatus("gemini-cli")).resolves.toBe(
|
| 119 |
+
"unauthenticated",
|
| 120 |
+
);
|
| 121 |
+
});
|
| 122 |
+
});
|
| 123 |
+
|
| 124 |
+
it("→ unknown for an unprobeable provider, without running any command", async () => {
|
| 125 |
+
await expect(AcpService.getAuthStatus("openhands")).resolves.toBe(
|
| 126 |
+
"unknown",
|
| 127 |
+
);
|
| 128 |
+
expect(executeCommand).not.toHaveBeenCalled();
|
| 129 |
+
});
|
| 130 |
+
});
|
__tests__/api/agent-profiles-service/profile-field-support.test.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
MIN_AGENT_SERVER_VERSION_FOR_PROFILE_SWITCH_LLM_TOOL,
|
| 4 |
+
agentProfileSupportsSwitchLlmTool,
|
| 5 |
+
} from "#/api/agent-profiles-service/profile-field-support";
|
| 6 |
+
|
| 7 |
+
const mockGetCachedAgentServerVersion = vi.fn<() => string | null>();
|
| 8 |
+
|
| 9 |
+
// Only the version *lookup* is mocked — the comparison stays the real one so
|
| 10 |
+
// these cases exercise the same parser the boot-time compatibility check uses.
|
| 11 |
+
vi.mock("#/api/agent-server-compatibility", async (importOriginal) => {
|
| 12 |
+
const actual =
|
| 13 |
+
await importOriginal<typeof import("#/api/agent-server-compatibility")>();
|
| 14 |
+
return {
|
| 15 |
+
...actual,
|
| 16 |
+
getCachedAgentServerVersion: () => mockGetCachedAgentServerVersion(),
|
| 17 |
+
};
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
describe("agentProfileSupportsSwitchLlmTool", () => {
|
| 21 |
+
beforeEach(() => {
|
| 22 |
+
mockGetCachedAgentServerVersion.mockReset();
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
it("pins the gate to the release that added the profile field", () => {
|
| 26 |
+
expect(MIN_AGENT_SERVER_VERSION_FOR_PROFILE_SWITCH_LLM_TOOL).toBe("1.31.0");
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
it.each(["1.29.0", "1.29.3", "1.30.0"])(
|
| 30 |
+
"reports no support on %s, where agent profiles exist but the field does not",
|
| 31 |
+
(version) => {
|
| 32 |
+
mockGetCachedAgentServerVersion.mockReturnValue(version);
|
| 33 |
+
expect(agentProfileSupportsSwitchLlmTool()).toBe(false);
|
| 34 |
+
},
|
| 35 |
+
);
|
| 36 |
+
|
| 37 |
+
it.each(["1.31.0", "1.31.2", "1.36.1", "2.0.0"])(
|
| 38 |
+
"reports support on %s",
|
| 39 |
+
(version) => {
|
| 40 |
+
mockGetCachedAgentServerVersion.mockReturnValue(version);
|
| 41 |
+
expect(agentProfileSupportsSwitchLlmTool()).toBe(true);
|
| 42 |
+
},
|
| 43 |
+
);
|
| 44 |
+
|
| 45 |
+
it("treats a prerelease of the gating version as older", () => {
|
| 46 |
+
mockGetCachedAgentServerVersion.mockReturnValue("1.31.0-rc.1");
|
| 47 |
+
expect(agentProfileSupportsSwitchLlmTool()).toBe(false);
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
it("assumes support when no version is cached (cloud backends)", () => {
|
| 51 |
+
mockGetCachedAgentServerVersion.mockReturnValue(null);
|
| 52 |
+
expect(agentProfileSupportsSwitchLlmTool()).toBe(true);
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
it("assumes support when the reported version does not parse", () => {
|
| 56 |
+
mockGetCachedAgentServerVersion.mockReturnValue("main");
|
| 57 |
+
expect(agentProfileSupportsSwitchLlmTool()).toBe(true);
|
| 58 |
+
});
|
| 59 |
+
});
|
__tests__/api/backend-registry/active-store.test.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
__resetActiveStoreForTests,
|
| 4 |
+
getActiveBackend,
|
| 5 |
+
getEffectiveLocalBackend,
|
| 6 |
+
NO_BACKEND_ID,
|
| 7 |
+
setActiveSelection,
|
| 8 |
+
setRegisteredBackends,
|
| 9 |
+
subscribeActiveBackend,
|
| 10 |
+
} from "#/api/backend-registry/active-store";
|
| 11 |
+
import { SEEDED_DEFAULT_BACKEND_ID } from "#/api/backend-registry/default-backend";
|
| 12 |
+
import { MAX_CONSECUTIVE_FAILURES } from "#/api/backend-registry/health-storage";
|
| 13 |
+
import {
|
| 14 |
+
__resetHealthStoreForTests,
|
| 15 |
+
recordBackendFailure,
|
| 16 |
+
} from "#/api/backend-registry/health-store";
|
| 17 |
+
import {
|
| 18 |
+
ACTIVE_BACKEND_STORAGE_KEY,
|
| 19 |
+
BACKENDS_STORAGE_KEY,
|
| 20 |
+
} from "#/api/backend-registry/storage";
|
| 21 |
+
import {
|
| 22 |
+
BACKEND_QUERY_PARAM,
|
| 23 |
+
ORG_QUERY_PARAM,
|
| 24 |
+
} from "#/api/backend-registry/url-selection";
|
| 25 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 26 |
+
|
| 27 |
+
beforeEach(() => {
|
| 28 |
+
window.localStorage.clear();
|
| 29 |
+
window.sessionStorage.clear();
|
| 30 |
+
__resetHealthStoreForTests();
|
| 31 |
+
__resetActiveStoreForTests();
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
afterEach(() => {
|
| 35 |
+
window.localStorage.clear();
|
| 36 |
+
window.sessionStorage.clear();
|
| 37 |
+
vi.unstubAllEnvs();
|
| 38 |
+
__resetHealthStoreForTests();
|
| 39 |
+
__resetActiveStoreForTests();
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
const cloudBackend: Backend = {
|
| 43 |
+
id: "prod",
|
| 44 |
+
name: "Production",
|
| 45 |
+
host: "https://app.all-hands.dev",
|
| 46 |
+
apiKey: "bearer-key",
|
| 47 |
+
kind: "cloud",
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
const localBackend: Backend = {
|
| 51 |
+
id: "local-1",
|
| 52 |
+
name: "Local 1",
|
| 53 |
+
host: "http://localhost:9000",
|
| 54 |
+
apiKey: "k",
|
| 55 |
+
kind: "local",
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
const secondLocalBackend: Backend = {
|
| 59 |
+
id: "local-2",
|
| 60 |
+
name: "Local 2",
|
| 61 |
+
host: "http://localhost:9001",
|
| 62 |
+
apiKey: "k2",
|
| 63 |
+
kind: "local",
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
function markBackendUnhealthy(id: string): void {
|
| 67 |
+
for (let i = 0; i < MAX_CONSECUTIVE_FAILURES; i += 1) {
|
| 68 |
+
recordBackendFailure(id, new Error("connection failed"));
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
describe("active-store", () => {
|
| 73 |
+
it("uses the no-backend sentinel when no backend details are available", () => {
|
| 74 |
+
window.localStorage.clear();
|
| 75 |
+
window.sessionStorage.clear();
|
| 76 |
+
vi.stubEnv("VITE_SESSION_API_KEY", "");
|
| 77 |
+
__resetActiveStoreForTests();
|
| 78 |
+
|
| 79 |
+
const { backend, orgId } = getActiveBackend();
|
| 80 |
+
expect(backend.id).toBe(NO_BACKEND_ID);
|
| 81 |
+
expect(orgId).toBeNull();
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
it("seeds the registry with a default local backend when host and API key are available", () => {
|
| 85 |
+
vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000");
|
| 86 |
+
vi.stubEnv("VITE_SESSION_API_KEY", "session-key");
|
| 87 |
+
__resetActiveStoreForTests();
|
| 88 |
+
|
| 89 |
+
const { backend, orgId } = getActiveBackend();
|
| 90 |
+
expect(backend.id).toBe(SEEDED_DEFAULT_BACKEND_ID);
|
| 91 |
+
expect(backend.kind).toBe("local");
|
| 92 |
+
expect(orgId).toBeNull();
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
it("returns the registered backend matching the active selection", () => {
|
| 96 |
+
setRegisteredBackends([cloudBackend]);
|
| 97 |
+
setActiveSelection({ backendId: cloudBackend.id, orgId: "org-2" });
|
| 98 |
+
|
| 99 |
+
const { backend, orgId } = getActiveBackend();
|
| 100 |
+
expect(backend).toEqual(cloudBackend);
|
| 101 |
+
expect(orgId).toBe("org-2");
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
it("falls back to the first local backend when the active selection points at a removed entry", () => {
|
| 105 |
+
setRegisteredBackends([cloudBackend, localBackend]);
|
| 106 |
+
setActiveSelection({ backendId: cloudBackend.id, orgId: null });
|
| 107 |
+
setRegisteredBackends([localBackend]);
|
| 108 |
+
|
| 109 |
+
expect(getActiveBackend().backend).toEqual(localBackend);
|
| 110 |
+
expect(getActiveBackend().orgId).toBeNull();
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
+
it("falls back to a healthy local backend when a cloud backend is registered first", () => {
|
| 114 |
+
setRegisteredBackends([cloudBackend, localBackend]);
|
| 115 |
+
setActiveSelection(null);
|
| 116 |
+
|
| 117 |
+
expect(getActiveBackend().backend).toEqual(localBackend);
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
it("skips an unhealthy local backend in favor of a healthy one further down", () => {
|
| 121 |
+
markBackendUnhealthy(localBackend.id);
|
| 122 |
+
setRegisteredBackends([localBackend, secondLocalBackend]);
|
| 123 |
+
setActiveSelection(null);
|
| 124 |
+
|
| 125 |
+
expect(getActiveBackend().backend).toEqual(secondLocalBackend);
|
| 126 |
+
});
|
| 127 |
+
|
| 128 |
+
it("still selects a local backend when every local backend is unhealthy", () => {
|
| 129 |
+
markBackendUnhealthy(localBackend.id);
|
| 130 |
+
markBackendUnhealthy(secondLocalBackend.id);
|
| 131 |
+
setRegisteredBackends([cloudBackend, localBackend, secondLocalBackend]);
|
| 132 |
+
setActiveSelection(null);
|
| 133 |
+
|
| 134 |
+
const { backend } = getActiveBackend();
|
| 135 |
+
expect(backend.kind).toBe("local");
|
| 136 |
+
expect(backend.id).not.toBe(NO_BACKEND_ID);
|
| 137 |
+
// Deterministic: first local backend in insertion order.
|
| 138 |
+
expect(backend).toEqual(localBackend);
|
| 139 |
+
});
|
| 140 |
+
|
| 141 |
+
it("selects a single healthy local backend at the first registry position", () => {
|
| 142 |
+
setRegisteredBackends([localBackend]);
|
| 143 |
+
setActiveSelection(null);
|
| 144 |
+
|
| 145 |
+
expect(getActiveBackend().backend).toEqual(localBackend);
|
| 146 |
+
});
|
| 147 |
+
|
| 148 |
+
it("falls back to the first registered backend when the registry has no local entry", () => {
|
| 149 |
+
setRegisteredBackends([cloudBackend]);
|
| 150 |
+
setActiveSelection(null);
|
| 151 |
+
|
| 152 |
+
expect(getActiveBackend().backend).toEqual(cloudBackend);
|
| 153 |
+
});
|
| 154 |
+
|
| 155 |
+
it("uses the active local backend as the effective local backend", () => {
|
| 156 |
+
setRegisteredBackends([localBackend, cloudBackend]);
|
| 157 |
+
setActiveSelection({ backendId: localBackend.id });
|
| 158 |
+
|
| 159 |
+
expect(getEffectiveLocalBackend()).toEqual(localBackend);
|
| 160 |
+
});
|
| 161 |
+
|
| 162 |
+
it("does not borrow a registered local backend when the active backend is cloud", () => {
|
| 163 |
+
setRegisteredBackends([localBackend, cloudBackend]);
|
| 164 |
+
setActiveSelection({ backendId: cloudBackend.id });
|
| 165 |
+
|
| 166 |
+
expect(getEffectiveLocalBackend()).toBeNull();
|
| 167 |
+
});
|
| 168 |
+
|
| 169 |
+
it("keeps an explicit cloud selection even when a healthy local backend exists", () => {
|
| 170 |
+
setRegisteredBackends([localBackend, cloudBackend]);
|
| 171 |
+
setActiveSelection({ backendId: cloudBackend.id, orgId: "org-2" });
|
| 172 |
+
|
| 173 |
+
const { backend, orgId } = getActiveBackend();
|
| 174 |
+
expect(backend).toEqual(cloudBackend);
|
| 175 |
+
expect(orgId).toBe("org-2");
|
| 176 |
+
});
|
| 177 |
+
|
| 178 |
+
it("notifies subscribers when selection changes", () => {
|
| 179 |
+
const listener = vi.fn();
|
| 180 |
+
const unsubscribe = subscribeActiveBackend(listener);
|
| 181 |
+
|
| 182 |
+
setRegisteredBackends([cloudBackend]);
|
| 183 |
+
setActiveSelection({ backendId: cloudBackend.id });
|
| 184 |
+
|
| 185 |
+
expect(listener).toHaveBeenCalled();
|
| 186 |
+
|
| 187 |
+
unsubscribe();
|
| 188 |
+
listener.mockClear();
|
| 189 |
+
setActiveSelection(null);
|
| 190 |
+
expect(listener).not.toHaveBeenCalled();
|
| 191 |
+
});
|
| 192 |
+
});
|
| 193 |
+
|
| 194 |
+
describe("backend pinned in the URL", () => {
|
| 195 |
+
function seedRegistry(backends: Backend[], selection: string) {
|
| 196 |
+
window.localStorage.setItem(BACKENDS_STORAGE_KEY, JSON.stringify(backends));
|
| 197 |
+
window.localStorage.setItem(
|
| 198 |
+
ACTIVE_BACKEND_STORAGE_KEY,
|
| 199 |
+
JSON.stringify({ backendId: selection, orgId: null }),
|
| 200 |
+
);
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
function bootAt(search: string) {
|
| 204 |
+
window.history.replaceState({}, "", `/conversations/abc${search}`);
|
| 205 |
+
__resetActiveStoreForTests();
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
afterEach(() => {
|
| 209 |
+
window.history.replaceState({}, "", "/");
|
| 210 |
+
});
|
| 211 |
+
|
| 212 |
+
it("boots on the backend named in the URL instead of the stored one", () => {
|
| 213 |
+
seedRegistry([localBackend, secondLocalBackend], localBackend.id);
|
| 214 |
+
|
| 215 |
+
bootAt(`?${BACKEND_QUERY_PARAM}=${secondLocalBackend.id}`);
|
| 216 |
+
|
| 217 |
+
expect(getActiveBackend().backend).toEqual(secondLocalBackend);
|
| 218 |
+
});
|
| 219 |
+
|
| 220 |
+
it("persists the pinned selection so later in-tab navigation keeps it", () => {
|
| 221 |
+
seedRegistry([localBackend, secondLocalBackend], localBackend.id);
|
| 222 |
+
|
| 223 |
+
bootAt(`?${BACKEND_QUERY_PARAM}=${secondLocalBackend.id}`);
|
| 224 |
+
|
| 225 |
+
expect(
|
| 226 |
+
JSON.parse(
|
| 227 |
+
window.sessionStorage.getItem(ACTIVE_BACKEND_STORAGE_KEY) ?? "null",
|
| 228 |
+
),
|
| 229 |
+
).toEqual({ backendId: secondLocalBackend.id, orgId: null });
|
| 230 |
+
});
|
| 231 |
+
|
| 232 |
+
it("carries the org id for a cloud backend", () => {
|
| 233 |
+
seedRegistry([localBackend, cloudBackend], localBackend.id);
|
| 234 |
+
|
| 235 |
+
bootAt(
|
| 236 |
+
`?${BACKEND_QUERY_PARAM}=${cloudBackend.id}&${ORG_QUERY_PARAM}=org-9`,
|
| 237 |
+
);
|
| 238 |
+
|
| 239 |
+
const { backend, orgId } = getActiveBackend();
|
| 240 |
+
expect(backend).toEqual(cloudBackend);
|
| 241 |
+
expect(orgId).toBe("org-9");
|
| 242 |
+
});
|
| 243 |
+
|
| 244 |
+
it("falls back to the stored selection when the URL names an unknown backend", () => {
|
| 245 |
+
seedRegistry([localBackend, secondLocalBackend], secondLocalBackend.id);
|
| 246 |
+
|
| 247 |
+
bootAt(`?${BACKEND_QUERY_PARAM}=removed-backend`);
|
| 248 |
+
|
| 249 |
+
expect(getActiveBackend().backend).toEqual(secondLocalBackend);
|
| 250 |
+
});
|
| 251 |
+
|
| 252 |
+
it("prefers the URL over a tab-scoped sessionStorage selection", () => {
|
| 253 |
+
seedRegistry([localBackend, secondLocalBackend], localBackend.id);
|
| 254 |
+
window.sessionStorage.setItem(
|
| 255 |
+
ACTIVE_BACKEND_STORAGE_KEY,
|
| 256 |
+
JSON.stringify({ backendId: localBackend.id, orgId: null }),
|
| 257 |
+
);
|
| 258 |
+
|
| 259 |
+
bootAt(`?${BACKEND_QUERY_PARAM}=${secondLocalBackend.id}`);
|
| 260 |
+
|
| 261 |
+
expect(getActiveBackend().backend).toEqual(secondLocalBackend);
|
| 262 |
+
});
|
| 263 |
+
});
|
__tests__/api/backend-registry/health-store.test.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
BACKEND_HEALTH_STORAGE_KEY,
|
| 4 |
+
MAX_CONSECUTIVE_FAILURES,
|
| 5 |
+
} from "#/api/backend-registry/health-storage";
|
| 6 |
+
import {
|
| 7 |
+
__resetHealthStoreForTests,
|
| 8 |
+
getBackendHealthEntry,
|
| 9 |
+
recordBackendFailure,
|
| 10 |
+
recordBackendSuccess,
|
| 11 |
+
resetBackendHealth,
|
| 12 |
+
subscribeBackendHealth,
|
| 13 |
+
} from "#/api/backend-registry/health-store";
|
| 14 |
+
|
| 15 |
+
const BACKEND_ID = "backend-under-test";
|
| 16 |
+
|
| 17 |
+
beforeEach(() => {
|
| 18 |
+
window.localStorage.clear();
|
| 19 |
+
__resetHealthStoreForTests();
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
afterEach(() => {
|
| 23 |
+
window.localStorage.clear();
|
| 24 |
+
__resetHealthStoreForTests();
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
describe("backend health store", () => {
|
| 28 |
+
it("increments the failure count and persists to localStorage without disabling polling below the cap", () => {
|
| 29 |
+
// Arrange / Act
|
| 30 |
+
recordBackendFailure(BACKEND_ID, new Error("ECONNREFUSED"));
|
| 31 |
+
recordBackendFailure(BACKEND_ID, new Error("ECONNREFUSED"));
|
| 32 |
+
|
| 33 |
+
// Assert
|
| 34 |
+
const entry = getBackendHealthEntry(BACKEND_ID);
|
| 35 |
+
expect(entry).toMatchObject({
|
| 36 |
+
consecutiveFailures: 2,
|
| 37 |
+
disabled: false,
|
| 38 |
+
lastError: "ECONNREFUSED",
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
const persisted = JSON.parse(
|
| 42 |
+
window.localStorage.getItem(BACKEND_HEALTH_STORAGE_KEY) ?? "{}",
|
| 43 |
+
);
|
| 44 |
+
expect(persisted[BACKEND_ID]).toMatchObject({
|
| 45 |
+
consecutiveFailures: 2,
|
| 46 |
+
disabled: false,
|
| 47 |
+
});
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
it("flips disabled=true once consecutive failures hit the cap and persists that flag", () => {
|
| 51 |
+
// Arrange / Act
|
| 52 |
+
for (let i = 0; i < MAX_CONSECUTIVE_FAILURES; i += 1) {
|
| 53 |
+
recordBackendFailure(BACKEND_ID, new Error("timeout"));
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// Assert — the cap is reached; polling-consumers will see disabled.
|
| 57 |
+
const entry = getBackendHealthEntry(BACKEND_ID);
|
| 58 |
+
expect(entry?.consecutiveFailures).toBe(MAX_CONSECUTIVE_FAILURES);
|
| 59 |
+
expect(entry?.disabled).toBe(true);
|
| 60 |
+
|
| 61 |
+
const persisted = JSON.parse(
|
| 62 |
+
window.localStorage.getItem(BACKEND_HEALTH_STORAGE_KEY) ?? "{}",
|
| 63 |
+
);
|
| 64 |
+
expect(persisted[BACKEND_ID].disabled).toBe(true);
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
it("caps the failure count at the max when a disabled backend fails another recheck", () => {
|
| 68 |
+
for (let i = 0; i < MAX_CONSECUTIVE_FAILURES; i += 1) {
|
| 69 |
+
recordBackendFailure(BACKEND_ID, new Error("timeout"));
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
recordBackendFailure(BACKEND_ID, new Error("still down"));
|
| 73 |
+
|
| 74 |
+
const entry = getBackendHealthEntry(BACKEND_ID);
|
| 75 |
+
expect(entry).toMatchObject({
|
| 76 |
+
consecutiveFailures: MAX_CONSECUTIVE_FAILURES,
|
| 77 |
+
disabled: true,
|
| 78 |
+
lastError: "still down",
|
| 79 |
+
});
|
| 80 |
+
|
| 81 |
+
const persisted = JSON.parse(
|
| 82 |
+
window.localStorage.getItem(BACKEND_HEALTH_STORAGE_KEY) ?? "{}",
|
| 83 |
+
);
|
| 84 |
+
expect(persisted[BACKEND_ID].consecutiveFailures).toBe(
|
| 85 |
+
MAX_CONSECUTIVE_FAILURES,
|
| 86 |
+
);
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
it("clears the entry (and storage) and notifies subscribers when the backend recovers or the user edits its config", () => {
|
| 90 |
+
// Arrange — record one failure so there is something to clear, and
|
| 91 |
+
// subscribe so we can confirm listeners get notified.
|
| 92 |
+
recordBackendFailure(BACKEND_ID, new Error("boom"));
|
| 93 |
+
let notifications = 0;
|
| 94 |
+
const unsubscribe = subscribeBackendHealth(() => {
|
| 95 |
+
notifications += 1;
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
// Act — `recordBackendSuccess` (probe recovers) and
|
| 99 |
+
// `resetBackendHealth` (user edits host/apiKey) share the same
|
| 100 |
+
// clear-entry semantics, so we cover both in one go.
|
| 101 |
+
recordBackendSuccess(BACKEND_ID);
|
| 102 |
+
recordBackendFailure(BACKEND_ID, new Error("again"));
|
| 103 |
+
resetBackendHealth(BACKEND_ID);
|
| 104 |
+
unsubscribe();
|
| 105 |
+
|
| 106 |
+
// Assert
|
| 107 |
+
expect(getBackendHealthEntry(BACKEND_ID)).toBeNull();
|
| 108 |
+
expect(window.localStorage.getItem(BACKEND_HEALTH_STORAGE_KEY)).toBeNull();
|
| 109 |
+
expect(notifications).toBeGreaterThanOrEqual(3);
|
| 110 |
+
});
|
| 111 |
+
});
|
__tests__/api/backend-registry/last-conversation-store.test.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
clearLastConversationId,
|
| 4 |
+
getLastConversationId,
|
| 5 |
+
LAST_CONVERSATION_STORAGE_KEY,
|
| 6 |
+
setLastConversationId,
|
| 7 |
+
} from "#/api/backend-registry/last-conversation-store";
|
| 8 |
+
|
| 9 |
+
beforeEach(() => {
|
| 10 |
+
window.localStorage.clear();
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
afterEach(() => {
|
| 14 |
+
window.localStorage.clear();
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
describe("last-conversation-store", () => {
|
| 18 |
+
it("returns null when nothing has been remembered for a backend", () => {
|
| 19 |
+
expect(getLastConversationId("backend-a", null)).toBeNull();
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
it("remembers and reads back the most recently selected conversation per backend", () => {
|
| 23 |
+
setLastConversationId("backend-a", null, "convo-a-1");
|
| 24 |
+
setLastConversationId("backend-b", null, "convo-b-1");
|
| 25 |
+
|
| 26 |
+
expect(getLastConversationId("backend-a", null)).toBe("convo-a-1");
|
| 27 |
+
expect(getLastConversationId("backend-b", null)).toBe("convo-b-1");
|
| 28 |
+
|
| 29 |
+
// The most recent selection wins.
|
| 30 |
+
setLastConversationId("backend-a", null, "convo-a-2");
|
| 31 |
+
expect(getLastConversationId("backend-a", null)).toBe("convo-a-2");
|
| 32 |
+
// …without affecting other backends.
|
| 33 |
+
expect(getLastConversationId("backend-b", null)).toBe("convo-b-1");
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
it("keys cloud backends by (backendId, orgId) so each org gets its own slot", () => {
|
| 37 |
+
setLastConversationId("cloud-x", "org-1", "convo-org-1");
|
| 38 |
+
setLastConversationId("cloud-x", "org-2", "convo-org-2");
|
| 39 |
+
|
| 40 |
+
expect(getLastConversationId("cloud-x", "org-1")).toBe("convo-org-1");
|
| 41 |
+
expect(getLastConversationId("cloud-x", "org-2")).toBe("convo-org-2");
|
| 42 |
+
expect(getLastConversationId("cloud-x", null)).toBeNull();
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
it("clears a backend's slot without touching others", () => {
|
| 46 |
+
setLastConversationId("backend-a", null, "convo-a");
|
| 47 |
+
setLastConversationId("backend-b", null, "convo-b");
|
| 48 |
+
|
| 49 |
+
clearLastConversationId("backend-a", null);
|
| 50 |
+
|
| 51 |
+
expect(getLastConversationId("backend-a", null)).toBeNull();
|
| 52 |
+
expect(getLastConversationId("backend-b", null)).toBe("convo-b");
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
it("ignores empty conversation ids on write", () => {
|
| 56 |
+
setLastConversationId("backend-a", null, "");
|
| 57 |
+
expect(getLastConversationId("backend-a", null)).toBeNull();
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
it("survives malformed JSON in storage", () => {
|
| 61 |
+
window.localStorage.setItem(LAST_CONVERSATION_STORAGE_KEY, "not-json");
|
| 62 |
+
expect(getLastConversationId("backend-a", null)).toBeNull();
|
| 63 |
+
// A subsequent write recovers the storage shape.
|
| 64 |
+
setLastConversationId("backend-a", null, "convo-a");
|
| 65 |
+
expect(getLastConversationId("backend-a", null)).toBe("convo-a");
|
| 66 |
+
});
|
| 67 |
+
});
|
__tests__/api/backend-registry/storage.test.ts
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
ACTIVE_BACKEND_STORAGE_KEY,
|
| 4 |
+
BACKENDS_STORAGE_KEY,
|
| 5 |
+
readStoredActiveBackend,
|
| 6 |
+
readStoredBackends,
|
| 7 |
+
writeStoredActiveBackend,
|
| 8 |
+
writeStoredBackends,
|
| 9 |
+
} from "#/api/backend-registry/storage";
|
| 10 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 11 |
+
|
| 12 |
+
const ORIGINAL_LOCATION = window.location;
|
| 13 |
+
|
| 14 |
+
function mockWindowLocation(url: string) {
|
| 15 |
+
Object.defineProperty(window, "location", {
|
| 16 |
+
configurable: true,
|
| 17 |
+
value: new URL(url),
|
| 18 |
+
});
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
afterEach(() => {
|
| 22 |
+
window.localStorage.clear();
|
| 23 |
+
window.sessionStorage.clear();
|
| 24 |
+
delete (window as unknown as Record<string, unknown>)
|
| 25 |
+
.__AGENT_CANVAS_LOCK_TO_CLOUD__;
|
| 26 |
+
Object.defineProperty(window, "location", {
|
| 27 |
+
configurable: true,
|
| 28 |
+
value: ORIGINAL_LOCATION,
|
| 29 |
+
});
|
| 30 |
+
window.history.pushState({}, "", "/");
|
| 31 |
+
vi.unstubAllEnvs();
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
describe("backend-registry storage", () => {
|
| 35 |
+
it("round-trips a list of backends", () => {
|
| 36 |
+
const backends: Backend[] = [
|
| 37 |
+
{
|
| 38 |
+
id: "abc",
|
| 39 |
+
name: "Local 1",
|
| 40 |
+
host: "http://127.0.0.1:9000",
|
| 41 |
+
apiKey: "key-1",
|
| 42 |
+
kind: "local",
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
id: "xyz",
|
| 46 |
+
name: "Production",
|
| 47 |
+
host: "https://app.all-hands.dev",
|
| 48 |
+
apiKey: "bearer-2",
|
| 49 |
+
kind: "cloud",
|
| 50 |
+
},
|
| 51 |
+
];
|
| 52 |
+
|
| 53 |
+
writeStoredBackends(backends);
|
| 54 |
+
|
| 55 |
+
expect(readStoredBackends()).toEqual(backends);
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
it("returns empty list when storage is malformed", () => {
|
| 59 |
+
window.localStorage.setItem(BACKENDS_STORAGE_KEY, "{not-json");
|
| 60 |
+
expect(readStoredBackends()).toEqual([]);
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
it("does not seed the default Local backend when launcher details are missing", () => {
|
| 64 |
+
vi.stubEnv("VITE_SESSION_API_KEY", "");
|
| 65 |
+
expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).toBeNull();
|
| 66 |
+
|
| 67 |
+
expect(readStoredBackends()).toEqual([]);
|
| 68 |
+
expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).toBeNull();
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
it("seeds the default Local backend when host and API key are available", () => {
|
| 72 |
+
vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000");
|
| 73 |
+
vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
|
| 74 |
+
|
| 75 |
+
const result = readStoredBackends();
|
| 76 |
+
|
| 77 |
+
expect(result).toHaveLength(1);
|
| 78 |
+
expect(result[0]).toMatchObject({
|
| 79 |
+
id: "default-local",
|
| 80 |
+
host: "http://localhost:9000",
|
| 81 |
+
apiKey: "fresh-session-key",
|
| 82 |
+
kind: "local",
|
| 83 |
+
});
|
| 84 |
+
expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).not.toBeNull();
|
| 85 |
+
expect(readStoredBackends()).toEqual(result);
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
it("seeds a cookie-auth Cloud backend when locked to the current origin", () => {
|
| 89 |
+
(
|
| 90 |
+
window as unknown as Record<string, unknown>
|
| 91 |
+
).__AGENT_CANVAS_LOCK_TO_CLOUD__ = window.location.origin;
|
| 92 |
+
window.localStorage.setItem(
|
| 93 |
+
BACKENDS_STORAGE_KEY,
|
| 94 |
+
JSON.stringify([
|
| 95 |
+
{
|
| 96 |
+
id: "stale-local",
|
| 97 |
+
name: "Stale Local",
|
| 98 |
+
host: "http://localhost:18000",
|
| 99 |
+
apiKey: "stale-session",
|
| 100 |
+
kind: "local",
|
| 101 |
+
},
|
| 102 |
+
]),
|
| 103 |
+
);
|
| 104 |
+
|
| 105 |
+
const result = readStoredBackends();
|
| 106 |
+
|
| 107 |
+
expect(result).toEqual([
|
| 108 |
+
{
|
| 109 |
+
id: "locked-cloud",
|
| 110 |
+
name: "OpenHands Cloud",
|
| 111 |
+
host: window.location.origin,
|
| 112 |
+
apiKey: "",
|
| 113 |
+
kind: "cloud",
|
| 114 |
+
authMode: "cookie",
|
| 115 |
+
},
|
| 116 |
+
]);
|
| 117 |
+
expect(readStoredActiveBackend()).toEqual({
|
| 118 |
+
backendId: "locked-cloud",
|
| 119 |
+
orgId: null,
|
| 120 |
+
});
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
+
it("seeds the cookie-auth Cloud backend on the current origin when the locked host is an equivalent transition domain", () => {
|
| 124 |
+
mockWindowLocation("https://pr-254.staging.openhands.dev/canvas");
|
| 125 |
+
(
|
| 126 |
+
window as unknown as Record<string, unknown>
|
| 127 |
+
).__AGENT_CANVAS_LOCK_TO_CLOUD__ = "https://pr-254.staging.all-hands.dev";
|
| 128 |
+
|
| 129 |
+
expect(readStoredBackends()).toEqual([
|
| 130 |
+
{
|
| 131 |
+
id: "locked-cloud",
|
| 132 |
+
name: "OpenHands Cloud",
|
| 133 |
+
host: "https://pr-254.staging.openhands.dev",
|
| 134 |
+
apiKey: "",
|
| 135 |
+
kind: "cloud",
|
| 136 |
+
authMode: "cookie",
|
| 137 |
+
},
|
| 138 |
+
]);
|
| 139 |
+
});
|
| 140 |
+
|
| 141 |
+
it("preserves an existing locked Cloud org selection while reseeding", () => {
|
| 142 |
+
(
|
| 143 |
+
window as unknown as Record<string, unknown>
|
| 144 |
+
).__AGENT_CANVAS_LOCK_TO_CLOUD__ = window.location.origin;
|
| 145 |
+
writeStoredActiveBackend({ backendId: "locked-cloud", orgId: "org-1" });
|
| 146 |
+
|
| 147 |
+
expect(readStoredBackends()[0]).toMatchObject({ id: "locked-cloud" });
|
| 148 |
+
expect(readStoredActiveBackend()).toEqual({
|
| 149 |
+
backendId: "locked-cloud",
|
| 150 |
+
orgId: "org-1",
|
| 151 |
+
});
|
| 152 |
+
});
|
| 153 |
+
|
| 154 |
+
it("does not seed a cookie-auth Cloud backend when locked cross-origin", () => {
|
| 155 |
+
(
|
| 156 |
+
window as unknown as Record<string, unknown>
|
| 157 |
+
).__AGENT_CANVAS_LOCK_TO_CLOUD__ = "https://app.all-hands.dev";
|
| 158 |
+
vi.stubEnv("VITE_SESSION_API_KEY", "");
|
| 159 |
+
|
| 160 |
+
expect(readStoredBackends()).toEqual([]);
|
| 161 |
+
});
|
| 162 |
+
|
| 163 |
+
it("re-seeds the default Local backend when storage holds an empty array and launcher details are available", () => {
|
| 164 |
+
vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000");
|
| 165 |
+
vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
|
| 166 |
+
window.localStorage.setItem(BACKENDS_STORAGE_KEY, JSON.stringify([]));
|
| 167 |
+
|
| 168 |
+
const result = readStoredBackends();
|
| 169 |
+
|
| 170 |
+
expect(result).toHaveLength(1);
|
| 171 |
+
expect(result[0]).toMatchObject({ id: "default-local", kind: "local" });
|
| 172 |
+
});
|
| 173 |
+
|
| 174 |
+
it("re-seeds the default Local backend when every stored entry is invalid and launcher details are available", () => {
|
| 175 |
+
vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000");
|
| 176 |
+
vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
|
| 177 |
+
window.localStorage.setItem(
|
| 178 |
+
BACKENDS_STORAGE_KEY,
|
| 179 |
+
JSON.stringify([{ kind: "cloud" }, "not-an-object"]),
|
| 180 |
+
);
|
| 181 |
+
|
| 182 |
+
const result = readStoredBackends();
|
| 183 |
+
|
| 184 |
+
expect(result).toHaveLength(1);
|
| 185 |
+
expect(result[0]).toMatchObject({ id: "default-local", kind: "local" });
|
| 186 |
+
});
|
| 187 |
+
|
| 188 |
+
it("filters out backends with invalid shape", () => {
|
| 189 |
+
window.localStorage.setItem(
|
| 190 |
+
BACKENDS_STORAGE_KEY,
|
| 191 |
+
JSON.stringify([
|
| 192 |
+
{ id: "ok", name: "x", host: "y", apiKey: "z", kind: "local" },
|
| 193 |
+
{ id: "missing-kind", name: "x", host: "y", apiKey: "z" },
|
| 194 |
+
{ kind: "cloud" },
|
| 195 |
+
"not-an-object",
|
| 196 |
+
]),
|
| 197 |
+
);
|
| 198 |
+
|
| 199 |
+
expect(readStoredBackends()).toEqual([
|
| 200 |
+
{ id: "ok", name: "x", host: "y", apiKey: "z", kind: "local" },
|
| 201 |
+
]);
|
| 202 |
+
});
|
| 203 |
+
|
| 204 |
+
it("preserves stored backends without API keys", () => {
|
| 205 |
+
vi.stubEnv("VITE_SESSION_API_KEY", "");
|
| 206 |
+
const storedBackend: Backend = {
|
| 207 |
+
id: "default-local",
|
| 208 |
+
name: "Local",
|
| 209 |
+
host: window.location.origin,
|
| 210 |
+
apiKey: "",
|
| 211 |
+
kind: "local",
|
| 212 |
+
};
|
| 213 |
+
window.localStorage.setItem(
|
| 214 |
+
BACKENDS_STORAGE_KEY,
|
| 215 |
+
JSON.stringify([storedBackend]),
|
| 216 |
+
);
|
| 217 |
+
|
| 218 |
+
expect(readStoredBackends()).toEqual([storedBackend]);
|
| 219 |
+
});
|
| 220 |
+
|
| 221 |
+
it("syncs a stale default Local API key from env defaults", () => {
|
| 222 |
+
vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
|
| 223 |
+
window.localStorage.setItem(
|
| 224 |
+
BACKENDS_STORAGE_KEY,
|
| 225 |
+
JSON.stringify([
|
| 226 |
+
{
|
| 227 |
+
id: "default-local",
|
| 228 |
+
name: "Local",
|
| 229 |
+
host: window.location.origin,
|
| 230 |
+
apiKey: "stored-session-key",
|
| 231 |
+
kind: "local",
|
| 232 |
+
},
|
| 233 |
+
]),
|
| 234 |
+
);
|
| 235 |
+
|
| 236 |
+
expect(readStoredBackends()[0]).toMatchObject({
|
| 237 |
+
id: "default-local",
|
| 238 |
+
apiKey: "fresh-session-key",
|
| 239 |
+
});
|
| 240 |
+
});
|
| 241 |
+
|
| 242 |
+
it("syncs a stale default Local API key across localhost and 127.0.0.1", () => {
|
| 243 |
+
vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000");
|
| 244 |
+
vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
|
| 245 |
+
window.localStorage.setItem(
|
| 246 |
+
BACKENDS_STORAGE_KEY,
|
| 247 |
+
JSON.stringify([
|
| 248 |
+
{
|
| 249 |
+
id: "default-local",
|
| 250 |
+
name: "Local",
|
| 251 |
+
host: "http://localhost:8000",
|
| 252 |
+
apiKey: "stored-session-key",
|
| 253 |
+
kind: "local",
|
| 254 |
+
},
|
| 255 |
+
]),
|
| 256 |
+
);
|
| 257 |
+
|
| 258 |
+
expect(readStoredBackends()[0]).toMatchObject({
|
| 259 |
+
id: "default-local",
|
| 260 |
+
host: "http://localhost:8000",
|
| 261 |
+
apiKey: "fresh-session-key",
|
| 262 |
+
});
|
| 263 |
+
});
|
| 264 |
+
|
| 265 |
+
it("preserves a custom backend API key instead of syncing from env defaults", () => {
|
| 266 |
+
vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
|
| 267 |
+
const storedBackend: Backend = {
|
| 268 |
+
id: "custom-local",
|
| 269 |
+
name: "Custom Local",
|
| 270 |
+
host: window.location.origin,
|
| 271 |
+
apiKey: "stored-session-key",
|
| 272 |
+
kind: "local",
|
| 273 |
+
};
|
| 274 |
+
window.localStorage.setItem(
|
| 275 |
+
BACKENDS_STORAGE_KEY,
|
| 276 |
+
JSON.stringify([storedBackend]),
|
| 277 |
+
);
|
| 278 |
+
|
| 279 |
+
expect(readStoredBackends()[0]).toMatchObject({
|
| 280 |
+
id: "custom-local",
|
| 281 |
+
apiKey: "stored-session-key",
|
| 282 |
+
});
|
| 283 |
+
});
|
| 284 |
+
|
| 285 |
+
it("preserves a user-edited non-loopback default Local backend API key", () => {
|
| 286 |
+
vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
|
| 287 |
+
const storedBackend: Backend = {
|
| 288 |
+
id: "default-local",
|
| 289 |
+
name: "Edited Local",
|
| 290 |
+
host: "https://example.com",
|
| 291 |
+
apiKey: "stored-session-key",
|
| 292 |
+
kind: "local",
|
| 293 |
+
};
|
| 294 |
+
window.localStorage.setItem(
|
| 295 |
+
BACKENDS_STORAGE_KEY,
|
| 296 |
+
JSON.stringify([storedBackend]),
|
| 297 |
+
);
|
| 298 |
+
|
| 299 |
+
expect(readStoredBackends()[0]).toMatchObject({
|
| 300 |
+
id: "default-local",
|
| 301 |
+
host: "https://example.com",
|
| 302 |
+
apiKey: "stored-session-key",
|
| 303 |
+
});
|
| 304 |
+
});
|
| 305 |
+
|
| 306 |
+
it("round-trips active selection with orgId", () => {
|
| 307 |
+
writeStoredActiveBackend({ backendId: "xyz", orgId: "org-1" });
|
| 308 |
+
expect(readStoredActiveBackend()).toEqual({
|
| 309 |
+
backendId: "xyz",
|
| 310 |
+
orgId: "org-1",
|
| 311 |
+
});
|
| 312 |
+
});
|
| 313 |
+
|
| 314 |
+
it("normalizes missing orgId to null", () => {
|
| 315 |
+
writeStoredActiveBackend({ backendId: "xyz" });
|
| 316 |
+
expect(readStoredActiveBackend()).toEqual({
|
| 317 |
+
backendId: "xyz",
|
| 318 |
+
orgId: null,
|
| 319 |
+
});
|
| 320 |
+
});
|
| 321 |
+
|
| 322 |
+
it("prefers the tab-scoped active selection over the global fallback", () => {
|
| 323 |
+
window.localStorage.setItem(
|
| 324 |
+
ACTIVE_BACKEND_STORAGE_KEY,
|
| 325 |
+
JSON.stringify({ backendId: "global-backend", orgId: null }),
|
| 326 |
+
);
|
| 327 |
+
window.sessionStorage.setItem(
|
| 328 |
+
ACTIVE_BACKEND_STORAGE_KEY,
|
| 329 |
+
JSON.stringify({ backendId: "tab-backend", orgId: "org-1" }),
|
| 330 |
+
);
|
| 331 |
+
|
| 332 |
+
expect(readStoredActiveBackend()).toEqual({
|
| 333 |
+
backendId: "tab-backend",
|
| 334 |
+
orgId: "org-1",
|
| 335 |
+
});
|
| 336 |
+
});
|
| 337 |
+
|
| 338 |
+
it("falls back to the global active selection for new tabs", () => {
|
| 339 |
+
window.localStorage.setItem(
|
| 340 |
+
ACTIVE_BACKEND_STORAGE_KEY,
|
| 341 |
+
JSON.stringify({ backendId: "global-backend", orgId: null }),
|
| 342 |
+
);
|
| 343 |
+
|
| 344 |
+
expect(readStoredActiveBackend()).toEqual({
|
| 345 |
+
backendId: "global-backend",
|
| 346 |
+
orgId: null,
|
| 347 |
+
});
|
| 348 |
+
});
|
| 349 |
+
|
| 350 |
+
it("clears storage when active selection is set to null", () => {
|
| 351 |
+
writeStoredActiveBackend({ backendId: "xyz", orgId: "o" });
|
| 352 |
+
writeStoredActiveBackend(null);
|
| 353 |
+
|
| 354 |
+
expect(
|
| 355 |
+
window.sessionStorage.getItem(ACTIVE_BACKEND_STORAGE_KEY),
|
| 356 |
+
).toBeNull();
|
| 357 |
+
expect(window.localStorage.getItem(ACTIVE_BACKEND_STORAGE_KEY)).toBeNull();
|
| 358 |
+
expect(readStoredActiveBackend()).toBeNull();
|
| 359 |
+
});
|
| 360 |
+
|
| 361 |
+
it("returns null active selection when storage is malformed", () => {
|
| 362 |
+
window.localStorage.setItem(ACTIVE_BACKEND_STORAGE_KEY, "{broken");
|
| 363 |
+
expect(readStoredActiveBackend()).toBeNull();
|
| 364 |
+
});
|
| 365 |
+
});
|
__tests__/api/backend-registry/url-selection.test.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
BACKEND_QUERY_PARAM,
|
| 4 |
+
ORG_QUERY_PARAM,
|
| 5 |
+
readBackendSelectionFromUrl,
|
| 6 |
+
withBackendSelectionParams,
|
| 7 |
+
} from "#/api/backend-registry/url-selection";
|
| 8 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 9 |
+
|
| 10 |
+
const localBackend: Backend = {
|
| 11 |
+
id: "local-1",
|
| 12 |
+
name: "Local 1",
|
| 13 |
+
host: "http://localhost:9000",
|
| 14 |
+
apiKey: "k",
|
| 15 |
+
kind: "local",
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
const cloudBackend: Backend = {
|
| 19 |
+
id: "prod",
|
| 20 |
+
name: "Production",
|
| 21 |
+
host: "https://app.all-hands.dev",
|
| 22 |
+
apiKey: "bearer-key",
|
| 23 |
+
kind: "cloud",
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
const backends = [localBackend, cloudBackend];
|
| 27 |
+
|
| 28 |
+
describe("withBackendSelectionParams", () => {
|
| 29 |
+
it("pins the active backend id onto the path", () => {
|
| 30 |
+
const path = withBackendSelectionParams("/conversations/abc", {
|
| 31 |
+
backend: localBackend,
|
| 32 |
+
orgId: null,
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
expect(path).toBe(`/conversations/abc?${BACKEND_QUERY_PARAM}=local-1`);
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
it("includes the org id for a cloud backend", () => {
|
| 39 |
+
const path = withBackendSelectionParams("/conversations/abc", {
|
| 40 |
+
backend: cloudBackend,
|
| 41 |
+
orgId: "org-7",
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
expect(path).toBe(
|
| 45 |
+
`/conversations/abc?${BACKEND_QUERY_PARAM}=prod&${ORG_QUERY_PARAM}=org-7`,
|
| 46 |
+
);
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
it("omits the org id when there is none", () => {
|
| 50 |
+
const path = withBackendSelectionParams("/conversations/abc", {
|
| 51 |
+
backend: cloudBackend,
|
| 52 |
+
orgId: null,
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
expect(path).not.toContain(ORG_QUERY_PARAM);
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
it("preserves query parameters already on the path", () => {
|
| 59 |
+
const path = withBackendSelectionParams("/conversations/abc?tab=files", {
|
| 60 |
+
backend: localBackend,
|
| 61 |
+
orgId: null,
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
expect(path).toBe(
|
| 65 |
+
`/conversations/abc?tab=files&${BACKEND_QUERY_PARAM}=local-1`,
|
| 66 |
+
);
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
it("keeps a fragment after existing query parameters intact and after the query", () => {
|
| 70 |
+
const path = withBackendSelectionParams(
|
| 71 |
+
"/conversations/abc?tab=files#detail",
|
| 72 |
+
{
|
| 73 |
+
backend: localBackend,
|
| 74 |
+
orgId: null,
|
| 75 |
+
},
|
| 76 |
+
);
|
| 77 |
+
|
| 78 |
+
expect(path).toBe(
|
| 79 |
+
`/conversations/abc?tab=files&${BACKEND_QUERY_PARAM}=local-1#detail`,
|
| 80 |
+
);
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
it("keeps a fragment on a path without query parameters after the query", () => {
|
| 84 |
+
const path = withBackendSelectionParams("/conversations/abc#detail", {
|
| 85 |
+
backend: localBackend,
|
| 86 |
+
orgId: null,
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
expect(path).toBe(
|
| 90 |
+
`/conversations/abc?${BACKEND_QUERY_PARAM}=local-1#detail`,
|
| 91 |
+
);
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
it("does not treat a ? inside the fragment as a query separator", () => {
|
| 95 |
+
const path = withBackendSelectionParams("/conversations/abc#detail?x=1", {
|
| 96 |
+
backend: localBackend,
|
| 97 |
+
orgId: null,
|
| 98 |
+
});
|
| 99 |
+
|
| 100 |
+
expect(path).toBe(
|
| 101 |
+
`/conversations/abc?${BACKEND_QUERY_PARAM}=local-1#detail?x=1`,
|
| 102 |
+
);
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
it("round-trips an empty fragment verbatim", () => {
|
| 106 |
+
const path = withBackendSelectionParams("/conversations/abc#", {
|
| 107 |
+
backend: localBackend,
|
| 108 |
+
orgId: null,
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
expect(path).toBe(`/conversations/abc?${BACKEND_QUERY_PARAM}=local-1#`);
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
+
it("keeps the org id and the fragment together", () => {
|
| 115 |
+
const path = withBackendSelectionParams("/conversations/abc#detail", {
|
| 116 |
+
backend: cloudBackend,
|
| 117 |
+
orgId: "org-7",
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
expect(path).toBe(
|
| 121 |
+
`/conversations/abc?${BACKEND_QUERY_PARAM}=prod&${ORG_QUERY_PARAM}=org-7#detail`,
|
| 122 |
+
);
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
it("keeps query data that itself contains a ?", () => {
|
| 126 |
+
const path = withBackendSelectionParams("/conversations/abc?next=/a?b=1", {
|
| 127 |
+
backend: localBackend,
|
| 128 |
+
orgId: null,
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
expect(path).toBe(
|
| 132 |
+
`/conversations/abc?next=%2Fa%3Fb%3D1&${BACKEND_QUERY_PARAM}=local-1`,
|
| 133 |
+
);
|
| 134 |
+
});
|
| 135 |
+
});
|
| 136 |
+
|
| 137 |
+
describe("readBackendSelectionFromUrl", () => {
|
| 138 |
+
it("reads a registered backend id", () => {
|
| 139 |
+
expect(
|
| 140 |
+
readBackendSelectionFromUrl(backends, `?${BACKEND_QUERY_PARAM}=local-1`),
|
| 141 |
+
).toEqual({ backendId: "local-1", orgId: null });
|
| 142 |
+
});
|
| 143 |
+
|
| 144 |
+
it("reads the org id alongside the backend id", () => {
|
| 145 |
+
expect(
|
| 146 |
+
readBackendSelectionFromUrl(
|
| 147 |
+
backends,
|
| 148 |
+
`?${BACKEND_QUERY_PARAM}=prod&${ORG_QUERY_PARAM}=org-7`,
|
| 149 |
+
),
|
| 150 |
+
).toEqual({ backendId: "prod", orgId: "org-7" });
|
| 151 |
+
});
|
| 152 |
+
|
| 153 |
+
it("ignores a backend id that is not registered", () => {
|
| 154 |
+
expect(
|
| 155 |
+
readBackendSelectionFromUrl(backends, `?${BACKEND_QUERY_PARAM}=gone`),
|
| 156 |
+
).toBeNull();
|
| 157 |
+
});
|
| 158 |
+
|
| 159 |
+
it("ignores an empty or absent parameter", () => {
|
| 160 |
+
expect(readBackendSelectionFromUrl(backends, "")).toBeNull();
|
| 161 |
+
expect(readBackendSelectionFromUrl(backends, "?tab=files")).toBeNull();
|
| 162 |
+
expect(
|
| 163 |
+
readBackendSelectionFromUrl(backends, `?${BACKEND_QUERY_PARAM}=`),
|
| 164 |
+
).toBeNull();
|
| 165 |
+
});
|
| 166 |
+
});
|
__tests__/api/cloud/conversation-create.test.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
__resetActiveStoreForTests,
|
| 4 |
+
setActiveSelection,
|
| 5 |
+
setRegisteredBackends,
|
| 6 |
+
} from "#/api/backend-registry/active-store";
|
| 7 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 8 |
+
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
|
| 9 |
+
import {
|
| 10 |
+
getFetchCall,
|
| 11 |
+
getJsonBody,
|
| 12 |
+
mockJsonResponse,
|
| 13 |
+
} from "./fetch-test-utils";
|
| 14 |
+
|
| 15 |
+
const cloudBackend: Backend = {
|
| 16 |
+
id: "prod",
|
| 17 |
+
name: "Production",
|
| 18 |
+
host: "https://app.all-hands.dev",
|
| 19 |
+
apiKey: "bearer-token",
|
| 20 |
+
kind: "cloud",
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
const originalFetch = global.fetch;
|
| 24 |
+
const fetchMock = vi.fn();
|
| 25 |
+
|
| 26 |
+
beforeEach(() => {
|
| 27 |
+
window.localStorage.clear();
|
| 28 |
+
__resetActiveStoreForTests();
|
| 29 |
+
setRegisteredBackends([cloudBackend]);
|
| 30 |
+
setActiveSelection({ backendId: cloudBackend.id });
|
| 31 |
+
fetchMock.mockReset();
|
| 32 |
+
global.fetch = fetchMock as typeof fetch;
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
afterEach(() => {
|
| 36 |
+
window.localStorage.clear();
|
| 37 |
+
__resetActiveStoreForTests();
|
| 38 |
+
fetchMock.mockReset();
|
| 39 |
+
global.fetch = originalFetch;
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
describe("AgentServerConversationService cloud branch", () => {
|
| 43 |
+
it("createConversation POSTs the cloud payload directly and returns a WORKING task", async () => {
|
| 44 |
+
fetchMock.mockResolvedValue(
|
| 45 |
+
mockJsonResponse({
|
| 46 |
+
id: "task-123",
|
| 47 |
+
created_by_user_id: null,
|
| 48 |
+
status: "WORKING",
|
| 49 |
+
detail: null,
|
| 50 |
+
app_conversation_id: null,
|
| 51 |
+
agent_server_url: null,
|
| 52 |
+
request: {},
|
| 53 |
+
created_at: "2026-05-06T00:00:00Z",
|
| 54 |
+
updated_at: "2026-05-06T00:00:00Z",
|
| 55 |
+
}),
|
| 56 |
+
);
|
| 57 |
+
|
| 58 |
+
const result = await AgentServerConversationService.createConversation({
|
| 59 |
+
initialUserMsg: "fix the bug",
|
| 60 |
+
conversationInstructions: "Optional title",
|
| 61 |
+
metadata: {
|
| 62 |
+
selected_repository: "user/repo",
|
| 63 |
+
selected_branch: "main",
|
| 64 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 65 |
+
git_provider: "github" as any,
|
| 66 |
+
},
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
expect(fetchMock).toHaveBeenCalledOnce();
|
| 70 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 71 |
+
expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations`);
|
| 72 |
+
expect(init).toMatchObject({
|
| 73 |
+
method: "POST",
|
| 74 |
+
headers: { Authorization: "Bearer bearer-token" },
|
| 75 |
+
});
|
| 76 |
+
const requestBody = getJsonBody(init);
|
| 77 |
+
|
| 78 |
+
// cloud payload shape — flat fields, NO encrypted-settings round-trip.
|
| 79 |
+
expect(requestBody.selected_repository).toBe("user/repo");
|
| 80 |
+
expect(requestBody.selected_branch).toBe("main");
|
| 81 |
+
expect(requestBody.git_provider).toBe("github");
|
| 82 |
+
expect(requestBody.title).toBe("Optional title");
|
| 83 |
+
expect(requestBody.initial_message).toEqual({
|
| 84 |
+
role: "user",
|
| 85 |
+
content: [{ type: "text", text: "fix the bug" }],
|
| 86 |
+
});
|
| 87 |
+
// The local-only encrypted-settings keys must NOT be present.
|
| 88 |
+
expect(requestBody).not.toHaveProperty("agent_settings_encrypted");
|
| 89 |
+
expect(requestBody).not.toHaveProperty("conversation_settings_encrypted");
|
| 90 |
+
|
| 91 |
+
// The returned task is the upstream task — WORKING, no app_conversation_id yet.
|
| 92 |
+
expect(result.id).toBe("task-123");
|
| 93 |
+
expect(result.status).toBe("WORKING");
|
| 94 |
+
expect(result.app_conversation_id).toBeNull();
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
it("getStartTask polls /api/v1/app-conversations/start-tasks?ids= directly", async () => {
|
| 98 |
+
fetchMock.mockResolvedValue(
|
| 99 |
+
mockJsonResponse([
|
| 100 |
+
{
|
| 101 |
+
id: "task-123",
|
| 102 |
+
created_by_user_id: null,
|
| 103 |
+
status: "READY",
|
| 104 |
+
detail: null,
|
| 105 |
+
app_conversation_id: "conv-456",
|
| 106 |
+
agent_server_url: "https://runtime-456.app.all-hands.dev",
|
| 107 |
+
request: {},
|
| 108 |
+
created_at: "2026-05-06T00:00:00Z",
|
| 109 |
+
updated_at: "2026-05-06T00:00:00Z",
|
| 110 |
+
},
|
| 111 |
+
]),
|
| 112 |
+
);
|
| 113 |
+
|
| 114 |
+
const result =
|
| 115 |
+
await AgentServerConversationService.getStartTask("task-123");
|
| 116 |
+
|
| 117 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 118 |
+
expect(url).toBe(
|
| 119 |
+
`${cloudBackend.host}/api/v1/app-conversations/start-tasks?ids=task-123`,
|
| 120 |
+
);
|
| 121 |
+
expect(init).toMatchObject({
|
| 122 |
+
method: "GET",
|
| 123 |
+
headers: { Authorization: "Bearer bearer-token" },
|
| 124 |
+
});
|
| 125 |
+
expect(result?.status).toBe("READY");
|
| 126 |
+
expect(result?.app_conversation_id).toBe("conv-456");
|
| 127 |
+
});
|
| 128 |
+
});
|
__tests__/api/cloud/conversation-delete.test.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
__resetActiveStoreForTests,
|
| 4 |
+
setActiveSelection,
|
| 5 |
+
setRegisteredBackends,
|
| 6 |
+
} from "#/api/backend-registry/active-store";
|
| 7 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 8 |
+
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
|
| 9 |
+
import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
|
| 10 |
+
|
| 11 |
+
const cloudBackend: Backend = {
|
| 12 |
+
id: "prod",
|
| 13 |
+
name: "Production",
|
| 14 |
+
host: "https://app.all-hands.dev",
|
| 15 |
+
apiKey: "bearer-token",
|
| 16 |
+
kind: "cloud",
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
const originalFetch = global.fetch;
|
| 20 |
+
const fetchMock = vi.fn();
|
| 21 |
+
|
| 22 |
+
beforeEach(() => {
|
| 23 |
+
window.localStorage.clear();
|
| 24 |
+
__resetActiveStoreForTests();
|
| 25 |
+
setRegisteredBackends([cloudBackend]);
|
| 26 |
+
setActiveSelection({ backendId: cloudBackend.id });
|
| 27 |
+
fetchMock.mockReset();
|
| 28 |
+
fetchMock.mockResolvedValue(mockJsonResponse({ success: true }));
|
| 29 |
+
global.fetch = fetchMock as typeof fetch;
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
afterEach(() => {
|
| 33 |
+
window.localStorage.clear();
|
| 34 |
+
__resetActiveStoreForTests();
|
| 35 |
+
fetchMock.mockReset();
|
| 36 |
+
global.fetch = originalFetch;
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
describe("AgentServerConversationService.deleteConversation cloud branch", () => {
|
| 40 |
+
it("calls the cloud DELETE app-conversations endpoint directly", async () => {
|
| 41 |
+
await AgentServerConversationService.deleteConversation("conv-abc");
|
| 42 |
+
|
| 43 |
+
expect(fetchMock).toHaveBeenCalledOnce();
|
| 44 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 45 |
+
expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations/conv-abc`);
|
| 46 |
+
expect(init).toMatchObject({
|
| 47 |
+
method: "DELETE",
|
| 48 |
+
headers: { Authorization: "Bearer bearer-token" },
|
| 49 |
+
});
|
| 50 |
+
});
|
| 51 |
+
});
|
__tests__/api/cloud/conversation-download.test.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
__resetActiveStoreForTests,
|
| 4 |
+
setActiveSelection,
|
| 5 |
+
setRegisteredBackends,
|
| 6 |
+
} from "#/api/backend-registry/active-store";
|
| 7 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 8 |
+
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
|
| 9 |
+
import {
|
| 10 |
+
getFetchCall,
|
| 11 |
+
mockBlobResponse,
|
| 12 |
+
mockJsonResponse,
|
| 13 |
+
} from "./fetch-test-utils";
|
| 14 |
+
|
| 15 |
+
const cloudBackend: Backend = {
|
| 16 |
+
id: "prod",
|
| 17 |
+
name: "Production",
|
| 18 |
+
host: "https://app.all-hands.dev",
|
| 19 |
+
apiKey: "bearer-token",
|
| 20 |
+
kind: "cloud",
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
const originalFetch = global.fetch;
|
| 24 |
+
const fetchMock = vi.fn();
|
| 25 |
+
|
| 26 |
+
beforeEach(() => {
|
| 27 |
+
window.localStorage.clear();
|
| 28 |
+
__resetActiveStoreForTests();
|
| 29 |
+
setRegisteredBackends([cloudBackend]);
|
| 30 |
+
setActiveSelection({ backendId: cloudBackend.id });
|
| 31 |
+
fetchMock.mockReset();
|
| 32 |
+
fetchMock.mockResolvedValue(mockJsonResponse({}));
|
| 33 |
+
global.fetch = fetchMock as typeof fetch;
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
afterEach(() => {
|
| 37 |
+
window.localStorage.clear();
|
| 38 |
+
__resetActiveStoreForTests();
|
| 39 |
+
fetchMock.mockReset();
|
| 40 |
+
global.fetch = originalFetch;
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
describe("AgentServerConversationService.downloadConversation cloud branch", () => {
|
| 44 |
+
it("calls the cloud download endpoint directly with responseType blob and returns the Blob", async () => {
|
| 45 |
+
fetchMock.mockResolvedValueOnce(
|
| 46 |
+
mockBlobResponse("zip-bytes", "application/zip"),
|
| 47 |
+
);
|
| 48 |
+
|
| 49 |
+
const result =
|
| 50 |
+
await AgentServerConversationService.downloadConversation("conv-abc");
|
| 51 |
+
|
| 52 |
+
expect(fetchMock).toHaveBeenCalledOnce();
|
| 53 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 54 |
+
expect(url).toBe(
|
| 55 |
+
`${cloudBackend.host}/api/v1/app-conversations/conv-abc/download`,
|
| 56 |
+
);
|
| 57 |
+
expect(init).toMatchObject({
|
| 58 |
+
method: "GET",
|
| 59 |
+
headers: { Authorization: "Bearer bearer-token" },
|
| 60 |
+
});
|
| 61 |
+
await expect(result.text()).resolves.toBe("zip-bytes");
|
| 62 |
+
});
|
| 63 |
+
});
|
__tests__/api/cloud/conversation-pause.test.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
__resetActiveStoreForTests,
|
| 4 |
+
setActiveSelection,
|
| 5 |
+
setRegisteredBackends,
|
| 6 |
+
} from "#/api/backend-registry/active-store";
|
| 7 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 8 |
+
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
|
| 9 |
+
import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types";
|
| 10 |
+
import { pauseConversation } from "#/hooks/mutation/conversation-mutation-utils";
|
| 11 |
+
import { ExecutionStatus } from "#/types/agent-server/core/base/common";
|
| 12 |
+
import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
|
| 13 |
+
|
| 14 |
+
const cloudBackend: Backend = {
|
| 15 |
+
id: "prod",
|
| 16 |
+
name: "Production",
|
| 17 |
+
host: "https://app.all-hands.dev",
|
| 18 |
+
apiKey: "bearer-token",
|
| 19 |
+
kind: "cloud",
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
const buildConversation = (
|
| 23 |
+
overrides: Partial<AppConversation> = {},
|
| 24 |
+
): AppConversation => ({
|
| 25 |
+
id: "conv-abc",
|
| 26 |
+
created_by_user_id: null,
|
| 27 |
+
selected_repository: null,
|
| 28 |
+
selected_branch: null,
|
| 29 |
+
git_provider: null,
|
| 30 |
+
title: "Test",
|
| 31 |
+
trigger: null,
|
| 32 |
+
pr_number: [],
|
| 33 |
+
llm_model: null,
|
| 34 |
+
metrics: null,
|
| 35 |
+
created_at: "2026-04-16T00:00:00Z",
|
| 36 |
+
updated_at: "2026-04-16T00:00:00Z",
|
| 37 |
+
execution_status: ExecutionStatus.RUNNING,
|
| 38 |
+
conversation_url: null,
|
| 39 |
+
session_api_key: null,
|
| 40 |
+
sandbox_id: "sandbox-xyz",
|
| 41 |
+
sub_conversation_ids: [],
|
| 42 |
+
...overrides,
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
const originalFetch = global.fetch;
|
| 46 |
+
const fetchMock = vi.fn();
|
| 47 |
+
|
| 48 |
+
beforeEach(() => {
|
| 49 |
+
window.localStorage.clear();
|
| 50 |
+
__resetActiveStoreForTests();
|
| 51 |
+
setRegisteredBackends([cloudBackend]);
|
| 52 |
+
setActiveSelection({ backendId: cloudBackend.id });
|
| 53 |
+
fetchMock.mockReset();
|
| 54 |
+
fetchMock.mockResolvedValue(mockJsonResponse({ success: true }));
|
| 55 |
+
global.fetch = fetchMock as typeof fetch;
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
afterEach(() => {
|
| 59 |
+
window.localStorage.clear();
|
| 60 |
+
__resetActiveStoreForTests();
|
| 61 |
+
fetchMock.mockReset();
|
| 62 |
+
global.fetch = originalFetch;
|
| 63 |
+
vi.restoreAllMocks();
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
describe("pauseConversation cloud branch", () => {
|
| 67 |
+
it("POSTs directly to the cloud sandbox pause endpoint", async () => {
|
| 68 |
+
vi.spyOn(
|
| 69 |
+
AgentServerConversationService,
|
| 70 |
+
"batchGetAppConversations",
|
| 71 |
+
).mockResolvedValue([buildConversation({ sandbox_id: "sandbox-xyz" })]);
|
| 72 |
+
|
| 73 |
+
await pauseConversation("conv-abc");
|
| 74 |
+
|
| 75 |
+
expect(fetchMock).toHaveBeenCalledOnce();
|
| 76 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 77 |
+
expect(url).toBe(`${cloudBackend.host}/api/v1/sandboxes/sandbox-xyz/pause`);
|
| 78 |
+
expect(init).toMatchObject({
|
| 79 |
+
method: "POST",
|
| 80 |
+
headers: { Authorization: "Bearer bearer-token" },
|
| 81 |
+
});
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
it("throws and does not call the cloud API when the cloud conversation has no sandbox_id", async () => {
|
| 85 |
+
vi.spyOn(
|
| 86 |
+
AgentServerConversationService,
|
| 87 |
+
"batchGetAppConversations",
|
| 88 |
+
).mockResolvedValue([buildConversation({ sandbox_id: null })]);
|
| 89 |
+
|
| 90 |
+
await expect(pauseConversation("conv-abc")).rejects.toThrow(/sandbox_id/);
|
| 91 |
+
expect(fetchMock).not.toHaveBeenCalled();
|
| 92 |
+
});
|
| 93 |
+
});
|
__tests__/api/cloud/conversation-public-flag.test.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
__resetActiveStoreForTests,
|
| 4 |
+
setActiveSelection,
|
| 5 |
+
setRegisteredBackends,
|
| 6 |
+
} from "#/api/backend-registry/active-store";
|
| 7 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 8 |
+
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
|
| 9 |
+
import {
|
| 10 |
+
getFetchCall,
|
| 11 |
+
getJsonBody,
|
| 12 |
+
mockJsonResponse,
|
| 13 |
+
} from "./fetch-test-utils";
|
| 14 |
+
|
| 15 |
+
const cloudBackend: Backend = {
|
| 16 |
+
id: "prod",
|
| 17 |
+
name: "Production",
|
| 18 |
+
host: "https://app.all-hands.dev",
|
| 19 |
+
apiKey: "bearer-token",
|
| 20 |
+
kind: "cloud",
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
const originalFetch = global.fetch;
|
| 24 |
+
const fetchMock = vi.fn();
|
| 25 |
+
|
| 26 |
+
beforeEach(() => {
|
| 27 |
+
window.localStorage.clear();
|
| 28 |
+
__resetActiveStoreForTests();
|
| 29 |
+
fetchMock.mockReset();
|
| 30 |
+
fetchMock.mockResolvedValue(
|
| 31 |
+
mockJsonResponse({ id: "conv-abc", public: true }),
|
| 32 |
+
);
|
| 33 |
+
global.fetch = fetchMock as typeof fetch;
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
afterEach(() => {
|
| 37 |
+
window.localStorage.clear();
|
| 38 |
+
__resetActiveStoreForTests();
|
| 39 |
+
fetchMock.mockReset();
|
| 40 |
+
global.fetch = originalFetch;
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
describe("AgentServerConversationService.updateConversationPublicFlag", () => {
|
| 44 |
+
it("PATCHes /api/v1/app-conversations/{id} directly on a cloud backend", async () => {
|
| 45 |
+
setRegisteredBackends([cloudBackend]);
|
| 46 |
+
setActiveSelection({ backendId: cloudBackend.id });
|
| 47 |
+
|
| 48 |
+
await AgentServerConversationService.updateConversationPublicFlag(
|
| 49 |
+
"conv-abc",
|
| 50 |
+
true,
|
| 51 |
+
);
|
| 52 |
+
|
| 53 |
+
expect(fetchMock).toHaveBeenCalledOnce();
|
| 54 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 55 |
+
expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations/conv-abc`);
|
| 56 |
+
expect(init).toMatchObject({
|
| 57 |
+
method: "PATCH",
|
| 58 |
+
headers: { Authorization: "Bearer bearer-token" },
|
| 59 |
+
});
|
| 60 |
+
expect(getJsonBody(init)).toEqual({ public: true });
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
it("rejects without calling the cloud API when the active backend is local", async () => {
|
| 64 |
+
// Default state after reset is the bundled local backend.
|
| 65 |
+
await expect(
|
| 66 |
+
AgentServerConversationService.updateConversationPublicFlag(
|
| 67 |
+
"conv-abc",
|
| 68 |
+
true,
|
| 69 |
+
),
|
| 70 |
+
).rejects.toThrow(/cloud backend/);
|
| 71 |
+
expect(fetchMock).not.toHaveBeenCalled();
|
| 72 |
+
});
|
| 73 |
+
});
|
__tests__/api/cloud/conversation-runtime-info.test.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
__resetActiveStoreForTests,
|
| 4 |
+
setActiveSelection,
|
| 5 |
+
setRegisteredBackends,
|
| 6 |
+
} from "#/api/backend-registry/active-store";
|
| 7 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 8 |
+
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
|
| 9 |
+
import {
|
| 10 |
+
getFetchCall,
|
| 11 |
+
getJsonBody,
|
| 12 |
+
mockJsonResponse,
|
| 13 |
+
} from "./fetch-test-utils";
|
| 14 |
+
|
| 15 |
+
const cloudBackend: Backend = {
|
| 16 |
+
id: "prod",
|
| 17 |
+
name: "Production",
|
| 18 |
+
host: "https://app.all-hands.dev",
|
| 19 |
+
apiKey: "bearer-token",
|
| 20 |
+
kind: "cloud",
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
const localBackend: Backend = {
|
| 24 |
+
id: "self-hosted",
|
| 25 |
+
name: "Self-hosted",
|
| 26 |
+
host: "http://192.168.1.99:9999",
|
| 27 |
+
apiKey: "local-key",
|
| 28 |
+
kind: "local",
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
const runtimeResponse = {
|
| 32 |
+
id: "conv-abc",
|
| 33 |
+
title: "Test conversation",
|
| 34 |
+
created_at: "2026-04-16T00:00:00Z",
|
| 35 |
+
updated_at: "2026-04-16T00:00:00Z",
|
| 36 |
+
execution_status: "idle",
|
| 37 |
+
metrics: null,
|
| 38 |
+
stats: {
|
| 39 |
+
usage_to_metrics: {
|
| 40 |
+
agent: {
|
| 41 |
+
model_name: "test-model",
|
| 42 |
+
accumulated_cost: 1.23,
|
| 43 |
+
max_budget_per_task: null,
|
| 44 |
+
accumulated_token_usage: null,
|
| 45 |
+
costs: [],
|
| 46 |
+
response_latencies: [],
|
| 47 |
+
token_usages: [],
|
| 48 |
+
},
|
| 49 |
+
},
|
| 50 |
+
},
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
const fetchMock = vi.fn();
|
| 54 |
+
|
| 55 |
+
afterEach(() => {
|
| 56 |
+
window.localStorage.clear();
|
| 57 |
+
__resetActiveStoreForTests();
|
| 58 |
+
fetchMock.mockReset();
|
| 59 |
+
vi.unstubAllGlobals();
|
| 60 |
+
vi.restoreAllMocks();
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
describe("AgentServerConversationService.getRuntimeConversation", () => {
|
| 64 |
+
describe("cloud mode", () => {
|
| 65 |
+
beforeEach(() => {
|
| 66 |
+
__resetActiveStoreForTests();
|
| 67 |
+
setRegisteredBackends([cloudBackend]);
|
| 68 |
+
setActiveSelection({ backendId: cloudBackend.id });
|
| 69 |
+
fetchMock.mockReset();
|
| 70 |
+
vi.stubGlobal("fetch", fetchMock);
|
| 71 |
+
});
|
| 72 |
+
|
| 73 |
+
it("targets the conversation runtime host directly and forwards X-Session-API-Key", async () => {
|
| 74 |
+
// Arrange
|
| 75 |
+
fetchMock.mockResolvedValue(mockJsonResponse(runtimeResponse));
|
| 76 |
+
const conversationUrl =
|
| 77 |
+
"http://abc123.runtime.all-hands.dev/api/conversations/conv-abc";
|
| 78 |
+
|
| 79 |
+
// Act
|
| 80 |
+
const result =
|
| 81 |
+
await AgentServerConversationService.getRuntimeConversation(
|
| 82 |
+
"conv-abc",
|
| 83 |
+
conversationUrl,
|
| 84 |
+
"session-xyz",
|
| 85 |
+
);
|
| 86 |
+
|
| 87 |
+
// Assert — fetch goes directly to the runtime host, not through
|
| 88 |
+
// /api/cloud-proxy (which was removed from the agent-server).
|
| 89 |
+
expect(fetchMock).toHaveBeenCalledOnce();
|
| 90 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 91 |
+
expect(url).toContain("abc123.runtime.all-hands.dev");
|
| 92 |
+
expect(url).toContain("/api/conversations/conv-abc");
|
| 93 |
+
expect(url).not.toMatch(/\/api\/cloud-proxy$/);
|
| 94 |
+
expect(init.headers).toMatchObject({
|
| 95 |
+
"X-Session-API-Key": "session-xyz",
|
| 96 |
+
});
|
| 97 |
+
expect(result.stats.usage_to_metrics.agent?.accumulated_cost).toBe(1.23);
|
| 98 |
+
});
|
| 99 |
+
});
|
| 100 |
+
|
| 101 |
+
describe("local mode", () => {
|
| 102 |
+
beforeEach(() => {
|
| 103 |
+
__resetActiveStoreForTests();
|
| 104 |
+
setRegisteredBackends([localBackend]);
|
| 105 |
+
setActiveSelection({ backendId: localBackend.id });
|
| 106 |
+
fetchMock.mockReset();
|
| 107 |
+
vi.stubGlobal("fetch", fetchMock);
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
it("targets the conversation_url host (not the active backend host) and forwards X-Session-API-Key", async () => {
|
| 111 |
+
// Arrange
|
| 112 |
+
fetchMock.mockResolvedValue(mockJsonResponse(runtimeResponse));
|
| 113 |
+
const conversationUrl =
|
| 114 |
+
"http://192.168.1.42:8888/api/conversations/conv-abc";
|
| 115 |
+
|
| 116 |
+
// Act
|
| 117 |
+
const result =
|
| 118 |
+
await AgentServerConversationService.getRuntimeConversation(
|
| 119 |
+
"conv-abc",
|
| 120 |
+
conversationUrl,
|
| 121 |
+
"session-xyz",
|
| 122 |
+
);
|
| 123 |
+
|
| 124 |
+
// Assert
|
| 125 |
+
expect(fetchMock).toHaveBeenCalledOnce();
|
| 126 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 127 |
+
expect(url).toContain("192.168.1.42:8888");
|
| 128 |
+
expect(url).not.toContain(localBackend.host);
|
| 129 |
+
expect(init.headers).toMatchObject({
|
| 130 |
+
"X-Session-API-Key": "session-xyz",
|
| 131 |
+
});
|
| 132 |
+
expect(result.id).toBe("conv-abc");
|
| 133 |
+
});
|
| 134 |
+
});
|
| 135 |
+
});
|
__tests__/api/cloud/conversation-title.test.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
__resetActiveStoreForTests,
|
| 4 |
+
setActiveSelection,
|
| 5 |
+
setRegisteredBackends,
|
| 6 |
+
} from "#/api/backend-registry/active-store";
|
| 7 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 8 |
+
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
|
| 9 |
+
import {
|
| 10 |
+
getFetchCall,
|
| 11 |
+
getJsonBody,
|
| 12 |
+
mockJsonResponse,
|
| 13 |
+
} from "./fetch-test-utils";
|
| 14 |
+
|
| 15 |
+
vi.mock("@openhands/typescript-client/clients", async (importOriginal) => {
|
| 16 |
+
const actual =
|
| 17 |
+
await importOriginal<
|
| 18 |
+
typeof import("@openhands/typescript-client/clients")
|
| 19 |
+
>();
|
| 20 |
+
|
| 21 |
+
return {
|
| 22 |
+
...actual,
|
| 23 |
+
ConversationClient: vi.fn().mockImplementation(() => ({
|
| 24 |
+
updateConversation: vi
|
| 25 |
+
.fn()
|
| 26 |
+
.mockRejectedValue(
|
| 27 |
+
new Error("Cloud title updates must not use ConversationClient"),
|
| 28 |
+
),
|
| 29 |
+
})),
|
| 30 |
+
};
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
const cloudBackend: Backend = {
|
| 34 |
+
id: "prod",
|
| 35 |
+
name: "Production",
|
| 36 |
+
host: "https://app.all-hands.dev",
|
| 37 |
+
apiKey: "bearer-token",
|
| 38 |
+
kind: "cloud",
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
const originalFetch = global.fetch;
|
| 42 |
+
const fetchMock = vi.fn();
|
| 43 |
+
|
| 44 |
+
beforeEach(() => {
|
| 45 |
+
window.localStorage.clear();
|
| 46 |
+
__resetActiveStoreForTests();
|
| 47 |
+
fetchMock.mockReset();
|
| 48 |
+
fetchMock.mockResolvedValue(
|
| 49 |
+
mockJsonResponse({ id: "conv-abc", title: "Renamed conversation" }),
|
| 50 |
+
);
|
| 51 |
+
global.fetch = fetchMock as typeof fetch;
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
afterEach(() => {
|
| 55 |
+
window.localStorage.clear();
|
| 56 |
+
__resetActiveStoreForTests();
|
| 57 |
+
fetchMock.mockReset();
|
| 58 |
+
global.fetch = originalFetch;
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
describe("AgentServerConversationService.updateConversationTitle", () => {
|
| 62 |
+
it("PATCHes the app-conversation directly on a cloud backend", async () => {
|
| 63 |
+
setRegisteredBackends([cloudBackend]);
|
| 64 |
+
setActiveSelection({ backendId: cloudBackend.id });
|
| 65 |
+
|
| 66 |
+
const result = await AgentServerConversationService.updateConversationTitle(
|
| 67 |
+
"conv-abc",
|
| 68 |
+
"Renamed conversation",
|
| 69 |
+
);
|
| 70 |
+
|
| 71 |
+
expect(fetchMock).toHaveBeenCalledOnce();
|
| 72 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 73 |
+
expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations/conv-abc`);
|
| 74 |
+
expect(init).toMatchObject({
|
| 75 |
+
method: "PATCH",
|
| 76 |
+
headers: { Authorization: "Bearer bearer-token" },
|
| 77 |
+
});
|
| 78 |
+
expect(getJsonBody(init)).toEqual({ title: "Renamed conversation" });
|
| 79 |
+
expect(result).toMatchObject({
|
| 80 |
+
id: "conv-abc",
|
| 81 |
+
title: "Renamed conversation",
|
| 82 |
+
});
|
| 83 |
+
});
|
| 84 |
+
});
|
__tests__/api/cloud/fetch-test-utils.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { vi } from "vitest";
|
| 2 |
+
|
| 3 |
+
type FetchMock = ReturnType<typeof vi.fn>;
|
| 4 |
+
|
| 5 |
+
export function mockJsonResponse(data: unknown, status = 200): Response {
|
| 6 |
+
return new Response(JSON.stringify(data), {
|
| 7 |
+
status,
|
| 8 |
+
statusText: status === 200 ? "OK" : "Error",
|
| 9 |
+
headers: { "content-type": "application/json" },
|
| 10 |
+
});
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export function mockBlobResponse(
|
| 14 |
+
body: BodyInit,
|
| 15 |
+
contentType: string,
|
| 16 |
+
): Response {
|
| 17 |
+
return new Response(body, {
|
| 18 |
+
status: 200,
|
| 19 |
+
headers: { "content-type": contentType },
|
| 20 |
+
});
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export function getFetchCall(
|
| 24 |
+
fetchMock: FetchMock,
|
| 25 |
+
index = 0,
|
| 26 |
+
): [string, RequestInit] {
|
| 27 |
+
const [url, init] = fetchMock.mock.calls[index] as [string, RequestInit];
|
| 28 |
+
return [url, init ?? {}];
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export function getJsonBody(init: RequestInit): Record<string, unknown> {
|
| 32 |
+
return JSON.parse(String(init.body ?? "{}")) as Record<string, unknown>;
|
| 33 |
+
}
|
__tests__/api/cloud/git-service.test.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
__resetActiveStoreForTests,
|
| 4 |
+
setActiveSelection,
|
| 5 |
+
setRegisteredBackends,
|
| 6 |
+
} from "#/api/backend-registry/active-store";
|
| 7 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 8 |
+
import { getCloudRepositoryBranches } from "#/api/cloud/git-service.api";
|
| 9 |
+
import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
|
| 10 |
+
|
| 11 |
+
const cloudBackend: Backend = {
|
| 12 |
+
id: "prod",
|
| 13 |
+
name: "Production",
|
| 14 |
+
host: "https://app.all-hands.dev",
|
| 15 |
+
apiKey: "bearer-token",
|
| 16 |
+
kind: "cloud",
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
const emptyBranchPage = { items: [], next_page_id: null };
|
| 20 |
+
const originalFetch = global.fetch;
|
| 21 |
+
const fetchMock = vi.fn();
|
| 22 |
+
|
| 23 |
+
beforeEach(() => {
|
| 24 |
+
window.localStorage.clear();
|
| 25 |
+
__resetActiveStoreForTests();
|
| 26 |
+
setRegisteredBackends([cloudBackend]);
|
| 27 |
+
setActiveSelection({ backendId: cloudBackend.id });
|
| 28 |
+
fetchMock.mockReset();
|
| 29 |
+
fetchMock.mockResolvedValue(mockJsonResponse(emptyBranchPage));
|
| 30 |
+
global.fetch = fetchMock as typeof fetch;
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
afterEach(() => {
|
| 34 |
+
window.localStorage.clear();
|
| 35 |
+
__resetActiveStoreForTests();
|
| 36 |
+
fetchMock.mockReset();
|
| 37 |
+
global.fetch = originalFetch;
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
describe("getCloudRepositoryBranches", () => {
|
| 41 |
+
it("includes an empty query parameter when listing all branches so the upstream schema is satisfied", async () => {
|
| 42 |
+
// Act
|
| 43 |
+
await getCloudRepositoryBranches({
|
| 44 |
+
provider: "github",
|
| 45 |
+
repository: "hieptl/hieptl",
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
// Assert
|
| 49 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 50 |
+
expect(init).toMatchObject({
|
| 51 |
+
method: "GET",
|
| 52 |
+
headers: { Authorization: "Bearer bearer-token" },
|
| 53 |
+
});
|
| 54 |
+
expect(url).toMatch(/[?&]query=(&|$)/);
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
it("forwards a non-empty query parameter when searching branches", async () => {
|
| 58 |
+
// Act
|
| 59 |
+
await getCloudRepositoryBranches({
|
| 60 |
+
provider: "github",
|
| 61 |
+
repository: "hieptl/hieptl",
|
| 62 |
+
query: "feature/login",
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
// Assert
|
| 66 |
+
const [url] = getFetchCall(fetchMock);
|
| 67 |
+
expect(url).toContain("query=feature%2Flogin");
|
| 68 |
+
});
|
| 69 |
+
});
|
__tests__/api/cloud/organization-me.test.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
__resetActiveStoreForTests,
|
| 4 |
+
setActiveSelection,
|
| 5 |
+
setRegisteredBackends,
|
| 6 |
+
} from "#/api/backend-registry/active-store";
|
| 7 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 8 |
+
import { getCloudOrganizationMe } from "#/api/cloud/organization-service.api";
|
| 9 |
+
import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
|
| 10 |
+
|
| 11 |
+
const cloudBackend: Backend = {
|
| 12 |
+
id: "prod",
|
| 13 |
+
name: "Production",
|
| 14 |
+
host: "https://app.all-hands.dev",
|
| 15 |
+
apiKey: "bearer-token",
|
| 16 |
+
kind: "cloud",
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
const originalFetch = global.fetch;
|
| 20 |
+
const fetchMock = vi.fn();
|
| 21 |
+
|
| 22 |
+
beforeEach(() => {
|
| 23 |
+
window.localStorage.clear();
|
| 24 |
+
__resetActiveStoreForTests();
|
| 25 |
+
setRegisteredBackends([cloudBackend]);
|
| 26 |
+
setActiveSelection({ backendId: cloudBackend.id });
|
| 27 |
+
fetchMock.mockReset();
|
| 28 |
+
global.fetch = fetchMock as typeof fetch;
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
afterEach(() => {
|
| 32 |
+
window.localStorage.clear();
|
| 33 |
+
__resetActiveStoreForTests();
|
| 34 |
+
fetchMock.mockReset();
|
| 35 |
+
global.fetch = originalFetch;
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
describe("cloud organization /me", () => {
|
| 39 |
+
it("calls /api/organizations/{orgId}/me directly and returns user_id", async () => {
|
| 40 |
+
const orgId = "0b93b5f2-5396-49f2-8d98-61f906184270";
|
| 41 |
+
fetchMock.mockResolvedValue(
|
| 42 |
+
mockJsonResponse({
|
| 43 |
+
org_id: orgId,
|
| 44 |
+
user_id: orgId,
|
| 45 |
+
email: "hieptl.developer@gmail.com",
|
| 46 |
+
role: "owner",
|
| 47 |
+
permissions: ["view_org_settings", "edit_org_settings"],
|
| 48 |
+
}),
|
| 49 |
+
);
|
| 50 |
+
|
| 51 |
+
const result = await getCloudOrganizationMe(orgId);
|
| 52 |
+
|
| 53 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 54 |
+
expect(url).toBe(`${cloudBackend.host}/api/organizations/${orgId}/me`);
|
| 55 |
+
expect(init).toMatchObject({
|
| 56 |
+
method: "GET",
|
| 57 |
+
headers: { Authorization: "Bearer bearer-token" },
|
| 58 |
+
});
|
| 59 |
+
expect(result).toEqual({
|
| 60 |
+
orgId,
|
| 61 |
+
userId: orgId,
|
| 62 |
+
role: "owner",
|
| 63 |
+
permissions: ["view_org_settings", "edit_org_settings"],
|
| 64 |
+
});
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
it("returns null permissions when the app-server omits them (older version)", async () => {
|
| 68 |
+
const orgId = "0b93b5f2-5396-49f2-8d98-61f906184270";
|
| 69 |
+
fetchMock.mockResolvedValue(
|
| 70 |
+
mockJsonResponse({
|
| 71 |
+
org_id: orgId,
|
| 72 |
+
user_id: orgId,
|
| 73 |
+
email: "x@example.com",
|
| 74 |
+
role: "member",
|
| 75 |
+
}),
|
| 76 |
+
);
|
| 77 |
+
|
| 78 |
+
const result = await getCloudOrganizationMe(orgId);
|
| 79 |
+
|
| 80 |
+
// Absent `permissions` → null, so callers fall back to the role check.
|
| 81 |
+
expect(result).toEqual({
|
| 82 |
+
orgId,
|
| 83 |
+
userId: orgId,
|
| 84 |
+
role: "member",
|
| 85 |
+
permissions: null,
|
| 86 |
+
});
|
| 87 |
+
});
|
| 88 |
+
});
|
__tests__/api/cloud/organization-service.test.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import { HttpError } from "@openhands/typescript-client";
|
| 3 |
+
import {
|
| 4 |
+
__resetActiveStoreForTests,
|
| 5 |
+
setRegisteredBackends,
|
| 6 |
+
} from "#/api/backend-registry/active-store";
|
| 7 |
+
import {
|
| 8 |
+
getCloudOrganizationMember,
|
| 9 |
+
getCloudOrganizations,
|
| 10 |
+
getCurrentCloudApiKey,
|
| 11 |
+
} from "#/api/cloud/organization-service.api";
|
| 12 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 13 |
+
|
| 14 |
+
const cloudBackend: Backend = {
|
| 15 |
+
id: "prod",
|
| 16 |
+
name: "Production",
|
| 17 |
+
host: "https://app.all-hands.dev",
|
| 18 |
+
apiKey: "bearer-token",
|
| 19 |
+
kind: "cloud",
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
const originalFetch = global.fetch;
|
| 23 |
+
const fetchMock = vi.fn();
|
| 24 |
+
|
| 25 |
+
function mockJsonResponse(data: unknown, status = 200): Response {
|
| 26 |
+
return new Response(JSON.stringify(data), {
|
| 27 |
+
status,
|
| 28 |
+
statusText: status === 200 ? "OK" : "Error",
|
| 29 |
+
headers: { "content-type": "application/json" },
|
| 30 |
+
});
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
beforeEach(() => {
|
| 34 |
+
window.localStorage.clear();
|
| 35 |
+
__resetActiveStoreForTests();
|
| 36 |
+
setRegisteredBackends([]);
|
| 37 |
+
fetchMock.mockReset();
|
| 38 |
+
global.fetch = fetchMock as typeof fetch;
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
afterEach(() => {
|
| 42 |
+
window.localStorage.clear();
|
| 43 |
+
__resetActiveStoreForTests();
|
| 44 |
+
fetchMock.mockReset();
|
| 45 |
+
global.fetch = originalFetch;
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
describe("cloud organization-service", () => {
|
| 49 |
+
it("getCloudOrganizations calls the cloud API directly and returns normalized data", async () => {
|
| 50 |
+
fetchMock.mockResolvedValueOnce(
|
| 51 |
+
mockJsonResponse({
|
| 52 |
+
items: [{ id: "org-1", name: "Personal" }],
|
| 53 |
+
current_org_id: "org-1",
|
| 54 |
+
}),
|
| 55 |
+
);
|
| 56 |
+
|
| 57 |
+
const result = await getCloudOrganizations(cloudBackend);
|
| 58 |
+
|
| 59 |
+
expect(fetchMock).toHaveBeenCalledOnce();
|
| 60 |
+
const [url, init] = fetchMock.mock.calls[0]!;
|
| 61 |
+
|
| 62 |
+
expect(url).toBe(`${cloudBackend.host}/api/organizations`);
|
| 63 |
+
expect(init).toMatchObject({
|
| 64 |
+
method: "GET",
|
| 65 |
+
headers: { Authorization: "Bearer bearer-token" },
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
expect(result).toEqual({
|
| 69 |
+
items: [{ id: "org-1", name: "Personal" }],
|
| 70 |
+
currentOrgId: "org-1",
|
| 71 |
+
});
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
it("getCurrentCloudApiKey hits /api/keys/current and returns the bound orgId", async () => {
|
| 75 |
+
fetchMock.mockResolvedValueOnce(
|
| 76 |
+
mockJsonResponse({
|
| 77 |
+
id: "key-1",
|
| 78 |
+
name: "k",
|
| 79 |
+
org_id: "org-bound",
|
| 80 |
+
user_id: "user-1",
|
| 81 |
+
auth_type: "bearer",
|
| 82 |
+
}),
|
| 83 |
+
);
|
| 84 |
+
|
| 85 |
+
const result = await getCurrentCloudApiKey(cloudBackend);
|
| 86 |
+
|
| 87 |
+
const [url, init] = fetchMock.mock.calls[0]!;
|
| 88 |
+
expect(url).toBe(`${cloudBackend.host}/api/keys/current`);
|
| 89 |
+
expect(init).toMatchObject({
|
| 90 |
+
method: "GET",
|
| 91 |
+
headers: { Authorization: "Bearer bearer-token" },
|
| 92 |
+
});
|
| 93 |
+
expect(result).toEqual({ orgId: "org-bound", isLegacyKey: false });
|
| 94 |
+
});
|
| 95 |
+
|
| 96 |
+
it("getCurrentCloudApiKey treats an upstream 400 as a legacy key (no binding)", async () => {
|
| 97 |
+
fetchMock.mockResolvedValueOnce(mockJsonResponse({ detail: "bad" }, 400));
|
| 98 |
+
|
| 99 |
+
const result = await getCurrentCloudApiKey(cloudBackend);
|
| 100 |
+
|
| 101 |
+
expect(result).toEqual({ orgId: null, isLegacyKey: true });
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
it("getCurrentCloudApiKey rethrows non-400 upstream errors (e.g. revoked key)", async () => {
|
| 105 |
+
fetchMock.mockResolvedValueOnce(
|
| 106 |
+
mockJsonResponse({ detail: "unauthorized" }, 401),
|
| 107 |
+
);
|
| 108 |
+
|
| 109 |
+
await expect(getCurrentCloudApiKey(cloudBackend)).rejects.toBeInstanceOf(
|
| 110 |
+
HttpError,
|
| 111 |
+
);
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
+
it("getCloudOrganizationMember hits /api/organizations/{orgId}/members/{userId} and returns the member", async () => {
|
| 115 |
+
// Arrange
|
| 116 |
+
const member = {
|
| 117 |
+
org_id: "org-1",
|
| 118 |
+
user_id: "user-1",
|
| 119 |
+
email: "jdoe@acme.com",
|
| 120 |
+
role: "member",
|
| 121 |
+
status: "active",
|
| 122 |
+
};
|
| 123 |
+
fetchMock.mockResolvedValueOnce(mockJsonResponse(member));
|
| 124 |
+
|
| 125 |
+
// Act
|
| 126 |
+
const result = await getCloudOrganizationMember(
|
| 127 |
+
"org-1",
|
| 128 |
+
"user-1",
|
| 129 |
+
cloudBackend,
|
| 130 |
+
);
|
| 131 |
+
|
| 132 |
+
// Assert
|
| 133 |
+
expect(fetchMock).toHaveBeenCalledOnce();
|
| 134 |
+
const [url, init] = fetchMock.mock.calls[0]!;
|
| 135 |
+
expect(url).toBe(
|
| 136 |
+
`${cloudBackend.host}/api/organizations/org-1/members/user-1`,
|
| 137 |
+
);
|
| 138 |
+
expect(init).toMatchObject({
|
| 139 |
+
method: "GET",
|
| 140 |
+
headers: { Authorization: "Bearer bearer-token" },
|
| 141 |
+
});
|
| 142 |
+
expect(result).toEqual(member);
|
| 143 |
+
});
|
| 144 |
+
});
|
__tests__/api/cloud/profiles-service.test.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
__resetActiveStoreForTests,
|
| 4 |
+
setActiveSelection,
|
| 5 |
+
setRegisteredBackends,
|
| 6 |
+
} from "#/api/backend-registry/active-store";
|
| 7 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 8 |
+
import ProfilesService, {
|
| 9 |
+
type SaveProfileRequest,
|
| 10 |
+
} from "#/api/profiles-service/profiles-service.api";
|
| 11 |
+
import {
|
| 12 |
+
getFetchCall,
|
| 13 |
+
getJsonBody,
|
| 14 |
+
mockJsonResponse,
|
| 15 |
+
} from "./fetch-test-utils";
|
| 16 |
+
|
| 17 |
+
const cloudBackend: Backend = {
|
| 18 |
+
id: "prod",
|
| 19 |
+
name: "Production",
|
| 20 |
+
host: "https://app.all-hands.dev",
|
| 21 |
+
apiKey: "bearer-token",
|
| 22 |
+
kind: "cloud",
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
const ORG_ID = "org-1";
|
| 26 |
+
const ORG_BASE = `https://app.all-hands.dev/api/organizations/${ORG_ID}/profiles`;
|
| 27 |
+
const SETTINGS_BASE = "https://app.all-hands.dev/api/v1/settings/profiles";
|
| 28 |
+
const originalFetch = global.fetch;
|
| 29 |
+
const fetchMock = vi.fn();
|
| 30 |
+
|
| 31 |
+
beforeEach(() => {
|
| 32 |
+
window.localStorage.clear();
|
| 33 |
+
__resetActiveStoreForTests();
|
| 34 |
+
setRegisteredBackends([cloudBackend]);
|
| 35 |
+
fetchMock.mockReset();
|
| 36 |
+
fetchMock.mockResolvedValue(mockJsonResponse({}));
|
| 37 |
+
global.fetch = fetchMock as typeof fetch;
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
afterEach(() => {
|
| 41 |
+
window.localStorage.clear();
|
| 42 |
+
__resetActiveStoreForTests();
|
| 43 |
+
fetchMock.mockReset();
|
| 44 |
+
global.fetch = originalFetch;
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
// With an org bound, profile CRUD goes through the org-gated routes so the
|
| 48 |
+
// server enforces EDIT_ORG_SETTINGS (a member's mutation 403s, not just hidden).
|
| 49 |
+
describe("ProfilesService against a cloud org (gated org routes)", () => {
|
| 50 |
+
beforeEach(() => {
|
| 51 |
+
setActiveSelection({ backendId: cloudBackend.id, orgId: ORG_ID });
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
it("lists profiles via GET /api/organizations/{orgId}/profiles", async () => {
|
| 55 |
+
fetchMock.mockResolvedValueOnce(
|
| 56 |
+
mockJsonResponse({
|
| 57 |
+
profiles: [
|
| 58 |
+
{ name: "gpt", model: "gpt-4o", base_url: null, api_key_set: true },
|
| 59 |
+
],
|
| 60 |
+
active_profile: "gpt",
|
| 61 |
+
}),
|
| 62 |
+
);
|
| 63 |
+
|
| 64 |
+
const res = await ProfilesService.listProfiles();
|
| 65 |
+
|
| 66 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 67 |
+
expect(url).toBe(ORG_BASE);
|
| 68 |
+
expect(init).toMatchObject({
|
| 69 |
+
method: "GET",
|
| 70 |
+
headers: { Authorization: "Bearer bearer-token" },
|
| 71 |
+
});
|
| 72 |
+
expect(res.active_profile).toBe("gpt");
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
it("fetches a profile and maps the org `llm` onto `config`", async () => {
|
| 76 |
+
fetchMock.mockResolvedValueOnce(
|
| 77 |
+
mockJsonResponse({
|
| 78 |
+
name: "my profile",
|
| 79 |
+
llm: { model: "gpt-4o", api_key: null },
|
| 80 |
+
}),
|
| 81 |
+
);
|
| 82 |
+
|
| 83 |
+
const res = await ProfilesService.getProfile("my profile");
|
| 84 |
+
|
| 85 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 86 |
+
expect(url).toBe(`${ORG_BASE}/my%20profile`);
|
| 87 |
+
expect(init).toMatchObject({
|
| 88 |
+
method: "GET",
|
| 89 |
+
});
|
| 90 |
+
expect(res).toEqual({
|
| 91 |
+
name: "my profile",
|
| 92 |
+
config: { model: "gpt-4o", api_key: null },
|
| 93 |
+
api_key_set: false,
|
| 94 |
+
});
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
it("saves a profile via POST .../{name} forwarding the request body", async () => {
|
| 98 |
+
fetchMock.mockResolvedValueOnce(
|
| 99 |
+
mockJsonResponse({ name: "gpt", message: "Profile 'gpt' saved" }),
|
| 100 |
+
);
|
| 101 |
+
|
| 102 |
+
await ProfilesService.saveProfile("gpt", {
|
| 103 |
+
llm: { model: "gpt-4o" } as SaveProfileRequest["llm"],
|
| 104 |
+
include_secrets: true,
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 108 |
+
expect(url).toBe(`${ORG_BASE}/gpt`);
|
| 109 |
+
expect(init).toMatchObject({
|
| 110 |
+
method: "POST",
|
| 111 |
+
});
|
| 112 |
+
expect(getJsonBody(init)).toEqual({
|
| 113 |
+
llm: { model: "gpt-4o" },
|
| 114 |
+
include_secrets: true,
|
| 115 |
+
});
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
it("deletes a profile via DELETE .../{name}", async () => {
|
| 119 |
+
fetchMock.mockResolvedValueOnce(
|
| 120 |
+
mockJsonResponse({ name: "gpt", message: "Profile 'gpt' deleted" }),
|
| 121 |
+
);
|
| 122 |
+
|
| 123 |
+
await ProfilesService.deleteProfile("gpt");
|
| 124 |
+
|
| 125 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 126 |
+
expect(url).toBe(`${ORG_BASE}/gpt`);
|
| 127 |
+
expect(init).toMatchObject({ method: "DELETE" });
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
it("renames a profile via POST .../{name}/rename with new_name", async () => {
|
| 131 |
+
fetchMock.mockResolvedValueOnce(
|
| 132 |
+
mockJsonResponse({ name: "new", message: "renamed" }),
|
| 133 |
+
);
|
| 134 |
+
|
| 135 |
+
await ProfilesService.renameProfile("old", "new");
|
| 136 |
+
|
| 137 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 138 |
+
expect(url).toBe(`${ORG_BASE}/old/rename`);
|
| 139 |
+
expect(init).toMatchObject({
|
| 140 |
+
method: "POST",
|
| 141 |
+
});
|
| 142 |
+
expect(getJsonBody(init)).toEqual({ new_name: "new" });
|
| 143 |
+
});
|
| 144 |
+
|
| 145 |
+
it("activates a profile and maps the org `llm` onto `llm_applied`", async () => {
|
| 146 |
+
fetchMock.mockResolvedValueOnce(
|
| 147 |
+
mockJsonResponse({
|
| 148 |
+
name: "gpt",
|
| 149 |
+
message: "Switched to profile 'gpt'",
|
| 150 |
+
llm: { model: "gpt-4o" },
|
| 151 |
+
}),
|
| 152 |
+
);
|
| 153 |
+
|
| 154 |
+
const res = await ProfilesService.activateProfile("gpt");
|
| 155 |
+
|
| 156 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 157 |
+
expect(url).toBe(`${ORG_BASE}/gpt/activate`);
|
| 158 |
+
expect(init).toMatchObject({
|
| 159 |
+
method: "POST",
|
| 160 |
+
});
|
| 161 |
+
expect(res).toEqual({
|
| 162 |
+
name: "gpt",
|
| 163 |
+
message: "Switched to profile 'gpt'",
|
| 164 |
+
llm_applied: true,
|
| 165 |
+
});
|
| 166 |
+
});
|
| 167 |
+
});
|
| 168 |
+
|
| 169 |
+
// Legacy API keys have no org bound; CRUD falls back to the per-user settings
|
| 170 |
+
// route (ungated — there is no org role to enforce against).
|
| 171 |
+
describe("ProfilesService on a cloud backend with no org (fallback)", () => {
|
| 172 |
+
beforeEach(() => {
|
| 173 |
+
setActiveSelection({ backendId: cloudBackend.id });
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
it("lists via the per-user settings route", async () => {
|
| 177 |
+
fetchMock.mockResolvedValueOnce(
|
| 178 |
+
mockJsonResponse({ profiles: [], active_profile: null }),
|
| 179 |
+
);
|
| 180 |
+
|
| 181 |
+
await ProfilesService.listProfiles();
|
| 182 |
+
|
| 183 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 184 |
+
expect(url).toBe(SETTINGS_BASE);
|
| 185 |
+
expect(init).toMatchObject({ method: "GET" });
|
| 186 |
+
});
|
| 187 |
+
|
| 188 |
+
it("saves via the per-user settings route", async () => {
|
| 189 |
+
fetchMock.mockResolvedValueOnce(
|
| 190 |
+
mockJsonResponse({ name: "gpt", message: "saved" }),
|
| 191 |
+
);
|
| 192 |
+
|
| 193 |
+
await ProfilesService.saveProfile("gpt", {
|
| 194 |
+
llm: { model: "gpt-4o" } as SaveProfileRequest["llm"],
|
| 195 |
+
include_secrets: true,
|
| 196 |
+
});
|
| 197 |
+
|
| 198 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 199 |
+
expect(url).toBe(`${SETTINGS_BASE}/gpt`);
|
| 200 |
+
expect(init).toMatchObject({ method: "POST" });
|
| 201 |
+
});
|
| 202 |
+
|
| 203 |
+
it("activates via the per-user settings route and maps `model`", async () => {
|
| 204 |
+
fetchMock.mockResolvedValueOnce(
|
| 205 |
+
mockJsonResponse({ name: "gpt", message: "ok", model: "gpt-4o" }),
|
| 206 |
+
);
|
| 207 |
+
|
| 208 |
+
const res = await ProfilesService.activateProfile("gpt");
|
| 209 |
+
|
| 210 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 211 |
+
expect(url).toBe(`${SETTINGS_BASE}/gpt/activate`);
|
| 212 |
+
expect(init).toMatchObject({
|
| 213 |
+
method: "POST",
|
| 214 |
+
});
|
| 215 |
+
expect(res.llm_applied).toBe(true);
|
| 216 |
+
});
|
| 217 |
+
});
|
__tests__/api/cloud/provider-connections-service.test.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
__resetActiveStoreForTests,
|
| 4 |
+
setActiveSelection,
|
| 5 |
+
setRegisteredBackends,
|
| 6 |
+
} from "#/api/backend-registry/active-store";
|
| 7 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 8 |
+
import ProviderConnectionsService from "#/api/provider-connections-service/provider-connections-service.api";
|
| 9 |
+
import {
|
| 10 |
+
getFetchCall,
|
| 11 |
+
getJsonBody,
|
| 12 |
+
mockJsonResponse,
|
| 13 |
+
} from "./fetch-test-utils";
|
| 14 |
+
|
| 15 |
+
const cloudBackend: Backend = {
|
| 16 |
+
id: "prod",
|
| 17 |
+
name: "Production",
|
| 18 |
+
host: "https://app.all-hands.dev",
|
| 19 |
+
apiKey: "bearer-token",
|
| 20 |
+
kind: "cloud",
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
const ORG_ID = "org-1";
|
| 24 |
+
const BASE = `https://app.all-hands.dev/api/organizations/${ORG_ID}/provider-connections`;
|
| 25 |
+
const originalFetch = global.fetch;
|
| 26 |
+
const fetchMock = vi.fn();
|
| 27 |
+
|
| 28 |
+
const connection = {
|
| 29 |
+
id: "conn-1",
|
| 30 |
+
display_name: "My OpenAI",
|
| 31 |
+
provider: "openai",
|
| 32 |
+
base_url: null,
|
| 33 |
+
created_at: 1,
|
| 34 |
+
updated_at: 2,
|
| 35 |
+
api_key_set: true,
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
beforeEach(() => {
|
| 39 |
+
window.localStorage.clear();
|
| 40 |
+
__resetActiveStoreForTests();
|
| 41 |
+
setRegisteredBackends([cloudBackend]);
|
| 42 |
+
fetchMock.mockReset();
|
| 43 |
+
fetchMock.mockResolvedValue(mockJsonResponse({}));
|
| 44 |
+
global.fetch = fetchMock as typeof fetch;
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
afterEach(() => {
|
| 48 |
+
window.localStorage.clear();
|
| 49 |
+
__resetActiveStoreForTests();
|
| 50 |
+
fetchMock.mockReset();
|
| 51 |
+
global.fetch = originalFetch;
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
// With an org bound, provider-connection CRUD goes through the org-gated routes
|
| 55 |
+
// so the server enforces EDIT_ORG_SETTINGS / VIEW_ORG_SETTINGS.
|
| 56 |
+
describe("ProviderConnectionsService against a cloud org", () => {
|
| 57 |
+
beforeEach(() => {
|
| 58 |
+
setActiveSelection({ backendId: cloudBackend.id, orgId: ORG_ID });
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
it("lists connections and unwraps the { connections } envelope", async () => {
|
| 62 |
+
fetchMock.mockResolvedValueOnce(
|
| 63 |
+
mockJsonResponse({ connections: [connection] }),
|
| 64 |
+
);
|
| 65 |
+
|
| 66 |
+
const res = await ProviderConnectionsService.list();
|
| 67 |
+
|
| 68 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 69 |
+
expect(url).toBe(BASE);
|
| 70 |
+
expect(init).toMatchObject({
|
| 71 |
+
method: "GET",
|
| 72 |
+
headers: { Authorization: "Bearer bearer-token" },
|
| 73 |
+
});
|
| 74 |
+
expect(res).toEqual([connection]);
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
it("returns [] when the org has no connections", async () => {
|
| 78 |
+
fetchMock.mockResolvedValueOnce(mockJsonResponse({ connections: [] }));
|
| 79 |
+
|
| 80 |
+
const res = await ProviderConnectionsService.list();
|
| 81 |
+
|
| 82 |
+
expect(res).toEqual([]);
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
it("creates a connection via POST with the request body", async () => {
|
| 86 |
+
fetchMock.mockResolvedValueOnce(mockJsonResponse(connection));
|
| 87 |
+
|
| 88 |
+
const request = {
|
| 89 |
+
display_name: "My OpenAI",
|
| 90 |
+
provider: "openai",
|
| 91 |
+
api_key: "sk-123",
|
| 92 |
+
base_url: null,
|
| 93 |
+
};
|
| 94 |
+
const res = await ProviderConnectionsService.create(request);
|
| 95 |
+
|
| 96 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 97 |
+
expect(url).toBe(BASE);
|
| 98 |
+
expect(init).toMatchObject({ method: "POST" });
|
| 99 |
+
expect(getJsonBody(init)).toEqual(request);
|
| 100 |
+
expect(res).toEqual(connection);
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
it("updates a connection via PATCH at the id-scoped path", async () => {
|
| 104 |
+
fetchMock.mockResolvedValueOnce(mockJsonResponse(connection));
|
| 105 |
+
|
| 106 |
+
const res = await ProviderConnectionsService.update("conn-1", {
|
| 107 |
+
display_name: "Renamed",
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 111 |
+
expect(url).toBe(`${BASE}/conn-1`);
|
| 112 |
+
expect(init).toMatchObject({ method: "PATCH" });
|
| 113 |
+
expect(getJsonBody(init)).toEqual({ display_name: "Renamed" });
|
| 114 |
+
expect(res).toEqual(connection);
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
it("url-encodes the id when deleting", async () => {
|
| 118 |
+
fetchMock.mockResolvedValueOnce(mockJsonResponse(connection));
|
| 119 |
+
|
| 120 |
+
await ProviderConnectionsService.delete("a b/c");
|
| 121 |
+
|
| 122 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 123 |
+
expect(url).toBe(`${BASE}/a%20b%2Fc`);
|
| 124 |
+
expect(init).toMatchObject({ method: "DELETE" });
|
| 125 |
+
});
|
| 126 |
+
});
|
| 127 |
+
|
| 128 |
+
// Without an org bound (legacy API keys), the org-scoped route is unaddressable.
|
| 129 |
+
describe("ProviderConnectionsService on a cloud backend with no org", () => {
|
| 130 |
+
beforeEach(() => {
|
| 131 |
+
setActiveSelection({ backendId: cloudBackend.id });
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
it("throws rather than firing an unaddressable request", async () => {
|
| 135 |
+
await expect(ProviderConnectionsService.list()).rejects.toThrow(
|
| 136 |
+
/organization-bound/i,
|
| 137 |
+
);
|
| 138 |
+
expect(fetchMock).not.toHaveBeenCalled();
|
| 139 |
+
});
|
| 140 |
+
});
|
__tests__/api/cloud/proxy.test.ts
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
__resetActiveStoreForTests,
|
| 4 |
+
setActiveSelection,
|
| 5 |
+
setRegisteredBackends,
|
| 6 |
+
} from "#/api/backend-registry/active-store";
|
| 7 |
+
import { callCloudProxy } from "#/api/cloud/proxy";
|
| 8 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 9 |
+
|
| 10 |
+
const cloudPersonal: Backend = {
|
| 11 |
+
id: "cloud-personal",
|
| 12 |
+
name: "Production - Personal",
|
| 13 |
+
host: "https://app.all-hands.dev",
|
| 14 |
+
apiKey: "personal-key",
|
| 15 |
+
kind: "cloud",
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
const cloudAcme: Backend = {
|
| 19 |
+
id: "cloud-acme",
|
| 20 |
+
name: "Production - Acme",
|
| 21 |
+
host: "https://app.all-hands.dev",
|
| 22 |
+
apiKey: "acme-key",
|
| 23 |
+
kind: "cloud",
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
const cookieCloud: Backend = {
|
| 27 |
+
id: "cloud-cookie",
|
| 28 |
+
name: "Production - Cookie",
|
| 29 |
+
host: "https://app.all-hands.dev",
|
| 30 |
+
apiKey: "",
|
| 31 |
+
kind: "cloud",
|
| 32 |
+
authMode: "cookie",
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
const originalFetch = global.fetch;
|
| 36 |
+
const fetchMock = vi.fn();
|
| 37 |
+
|
| 38 |
+
function mockJsonResponse(data: unknown, status = 200): Response {
|
| 39 |
+
return new Response(JSON.stringify(data), {
|
| 40 |
+
status,
|
| 41 |
+
headers: { "content-type": "application/json" },
|
| 42 |
+
});
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
beforeEach(() => {
|
| 46 |
+
window.localStorage.clear();
|
| 47 |
+
__resetActiveStoreForTests();
|
| 48 |
+
fetchMock.mockReset();
|
| 49 |
+
fetchMock.mockResolvedValue(mockJsonResponse({}));
|
| 50 |
+
global.fetch = fetchMock as typeof fetch;
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
afterEach(() => {
|
| 54 |
+
window.localStorage.clear();
|
| 55 |
+
__resetActiveStoreForTests();
|
| 56 |
+
fetchMock.mockReset();
|
| 57 |
+
global.fetch = originalFetch;
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
describe("callCloudProxy X-Org-Id injection", () => {
|
| 61 |
+
it("sends X-Org-Id when targeting the active cloud backend with a selected orgId", async () => {
|
| 62 |
+
// Arrange — active selection points at the cloud backend with a
|
| 63 |
+
// resolved orgId. This is the steady-state case after the user picks
|
| 64 |
+
// an org row in the BackendSelector.
|
| 65 |
+
setRegisteredBackends([cloudPersonal]);
|
| 66 |
+
setActiveSelection({
|
| 67 |
+
backendId: cloudPersonal.id,
|
| 68 |
+
orgId: "org-personal-uuid",
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
// Act
|
| 72 |
+
await callCloudProxy({
|
| 73 |
+
backend: cloudPersonal,
|
| 74 |
+
method: "GET",
|
| 75 |
+
path: "/api/v1/app-conversations/search",
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
// Assert — the request carries the X-Org-Id of the active selection so the
|
| 79 |
+
// cloud backend can scope this request to the user's locally-chosen org
|
| 80 |
+
// without depending on user.current_org_id.
|
| 81 |
+
const [url, init] = fetchMock.mock.calls[0]!;
|
| 82 |
+
expect(url).toBe(`${cloudPersonal.host}/api/v1/app-conversations/search`);
|
| 83 |
+
expect(init).toMatchObject({
|
| 84 |
+
method: "GET",
|
| 85 |
+
});
|
| 86 |
+
expect(
|
| 87 |
+
(init as { headers: Record<string, string> }).headers["X-Org-Id"],
|
| 88 |
+
).toBe("org-personal-uuid");
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
it("omits X-Org-Id when targeting a different cloud backend than the active one", async () => {
|
| 92 |
+
// Arrange — the BackendSelector fan-out (e.g. useAllCloudOrganizations)
|
| 93 |
+
// calls callCloudProxy(b) for every registered cloud backend. Sending
|
| 94 |
+
// the active backend's orgId across an unrelated API key would cause
|
| 95 |
+
// the cloud backend to 403 on api_key_org_id / X-Org-Id mismatch.
|
| 96 |
+
setRegisteredBackends([cloudPersonal, cloudAcme]);
|
| 97 |
+
setActiveSelection({
|
| 98 |
+
backendId: cloudPersonal.id,
|
| 99 |
+
orgId: "org-personal-uuid",
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
// Act — request targets the non-active backend.
|
| 103 |
+
await callCloudProxy({
|
| 104 |
+
backend: cloudAcme,
|
| 105 |
+
method: "GET",
|
| 106 |
+
path: "/api/keys/current",
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
+
// Assert
|
| 110 |
+
const [, init] = fetchMock.mock.calls[0]!;
|
| 111 |
+
expect(
|
| 112 |
+
(init as { headers: Record<string, string> }).headers,
|
| 113 |
+
).not.toHaveProperty("X-Org-Id");
|
| 114 |
+
});
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
describe("callCloudProxy cookie auth", () => {
|
| 118 |
+
it("omits bearer auth for same-origin cookie-backed Cloud requests", async () => {
|
| 119 |
+
setRegisteredBackends([cookieCloud]);
|
| 120 |
+
setActiveSelection({ backendId: cookieCloud.id, orgId: null });
|
| 121 |
+
|
| 122 |
+
await callCloudProxy({
|
| 123 |
+
backend: cookieCloud,
|
| 124 |
+
method: "GET",
|
| 125 |
+
path: "/api/v1/users/me",
|
| 126 |
+
});
|
| 127 |
+
|
| 128 |
+
const [url, init] = fetchMock.mock.calls[0]!;
|
| 129 |
+
expect(url).toBe(`${cookieCloud.host}/api/v1/users/me`);
|
| 130 |
+
expect(init).toMatchObject({ method: "GET" });
|
| 131 |
+
expect(
|
| 132 |
+
(init as { headers: Record<string, string> }).headers,
|
| 133 |
+
).not.toHaveProperty("Authorization");
|
| 134 |
+
});
|
| 135 |
+
|
| 136 |
+
it("omits bearer auth in runtime proxy envelope requests", async () => {
|
| 137 |
+
setRegisteredBackends([cookieCloud]);
|
| 138 |
+
setActiveSelection({ backendId: cookieCloud.id, orgId: null });
|
| 139 |
+
|
| 140 |
+
await callCloudProxy({
|
| 141 |
+
backend: cookieCloud,
|
| 142 |
+
method: "GET",
|
| 143 |
+
path: "/api/bash/bash_events/search",
|
| 144 |
+
hostOverride: "https://abc123.prod-runtime.all-hands.dev",
|
| 145 |
+
});
|
| 146 |
+
|
| 147 |
+
const [, init] = fetchMock.mock.calls[0]!;
|
| 148 |
+
const envelope = JSON.parse((init as { body: string }).body);
|
| 149 |
+
expect(
|
| 150 |
+
(envelope as { headers: Record<string, string> }).headers,
|
| 151 |
+
).not.toHaveProperty("Authorization");
|
| 152 |
+
});
|
| 153 |
+
});
|
| 154 |
+
|
| 155 |
+
describe("callCloudProxy automation direct routing", () => {
|
| 156 |
+
it("sends automation requests straight to the cloud host with the API key instead of the /api/cloud-proxy envelope", async () => {
|
| 157 |
+
// Arrange — the automation service grants permissive CORS to API-key
|
| 158 |
+
// requests (automation#185), so app-host automation calls no longer
|
| 159 |
+
// need the same-origin proxy hop through the bundled agent-server.
|
| 160 |
+
setRegisteredBackends([cloudPersonal]);
|
| 161 |
+
setActiveSelection({ backendId: cloudPersonal.id, orgId: null });
|
| 162 |
+
const page = { automations: [], total: 0 };
|
| 163 |
+
fetchMock.mockResolvedValueOnce(mockJsonResponse(page));
|
| 164 |
+
|
| 165 |
+
// Act
|
| 166 |
+
const result = await callCloudProxy({
|
| 167 |
+
backend: cloudPersonal,
|
| 168 |
+
method: "GET",
|
| 169 |
+
path: "/api/automation/v1?limit=50&offset=0",
|
| 170 |
+
});
|
| 171 |
+
|
| 172 |
+
// Assert — the browser calls the automation API on the cloud host
|
| 173 |
+
// directly, authenticated by the backend's API key, and no envelope
|
| 174 |
+
// POST reaches /api/cloud-proxy.
|
| 175 |
+
const [url, init] = fetchMock.mock.calls[0]!;
|
| 176 |
+
expect(url).toBe(
|
| 177 |
+
`${cloudPersonal.host}/api/automation/v1?limit=50&offset=0`,
|
| 178 |
+
);
|
| 179 |
+
expect(init).toMatchObject({
|
| 180 |
+
method: "GET",
|
| 181 |
+
});
|
| 182 |
+
expect(
|
| 183 |
+
(init as { headers: Record<string, string> }).headers.Authorization,
|
| 184 |
+
).toBe(`Bearer ${cloudPersonal.apiKey}`);
|
| 185 |
+
expect(result).toEqual(page);
|
| 186 |
+
});
|
| 187 |
+
|
| 188 |
+
it("forwards the blob responseType and fail-fast timeout to the direct request", async () => {
|
| 189 |
+
// Arrange — tarball downloads and health probes rely on these
|
| 190 |
+
// per-request options surviving the switch from the proxy envelope to
|
| 191 |
+
// the direct call.
|
| 192 |
+
setRegisteredBackends([cloudPersonal]);
|
| 193 |
+
setActiveSelection({ backendId: cloudPersonal.id, orgId: null });
|
| 194 |
+
|
| 195 |
+
// Act
|
| 196 |
+
await callCloudProxy({
|
| 197 |
+
backend: cloudPersonal,
|
| 198 |
+
method: "GET",
|
| 199 |
+
path: "/api/automation/v1/auto-1/tarball",
|
| 200 |
+
responseType: "blob",
|
| 201 |
+
timeoutSeconds: 5,
|
| 202 |
+
});
|
| 203 |
+
|
| 204 |
+
// Assert
|
| 205 |
+
const [url, init] = fetchMock.mock.calls[0]!;
|
| 206 |
+
expect(url).toBe(`${cloudPersonal.host}/api/automation/v1/auto-1/tarball`);
|
| 207 |
+
expect(init).toMatchObject({ method: "GET" });
|
| 208 |
+
});
|
| 209 |
+
});
|
| 210 |
+
|
| 211 |
+
describe("callCloudProxy hostOverride routing", () => {
|
| 212 |
+
const runtimeHost = "https://abc123.prod-runtime.all-hands.dev";
|
| 213 |
+
|
| 214 |
+
it("routes through the local /api/cloud-proxy instead of the upstream host when hostOverride is set", async () => {
|
| 215 |
+
// Arrange — runtime-sandbox endpoints need the proxy hop because the
|
| 216 |
+
// per-conversation runtime hosts reject browser requests from the
|
| 217 |
+
// local GUI origin.
|
| 218 |
+
setRegisteredBackends([cloudPersonal]);
|
| 219 |
+
setActiveSelection({ backendId: cloudPersonal.id, orgId: null });
|
| 220 |
+
fetchMock.mockResolvedValueOnce(mockJsonResponse({ items: [] }));
|
| 221 |
+
|
| 222 |
+
// Act
|
| 223 |
+
const result = await callCloudProxy({
|
| 224 |
+
backend: cloudPersonal,
|
| 225 |
+
method: "GET",
|
| 226 |
+
path: "/api/bash/bash_events/search",
|
| 227 |
+
hostOverride: runtimeHost,
|
| 228 |
+
});
|
| 229 |
+
|
| 230 |
+
// Assert — the browser only makes a same-origin POST to the bundled
|
| 231 |
+
// agent-server's proxy endpoint carrying the upstream call as an
|
| 232 |
+
// envelope, and the upstream payload is unwrapped for the caller.
|
| 233 |
+
const [url, init] = fetchMock.mock.calls[0]!;
|
| 234 |
+
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
| 235 |
+
const envelope = JSON.parse((init as { body: string }).body);
|
| 236 |
+
expect(envelope).toMatchObject({
|
| 237 |
+
host: runtimeHost,
|
| 238 |
+
method: "GET",
|
| 239 |
+
path: "/api/bash/bash_events/search",
|
| 240 |
+
});
|
| 241 |
+
expect(result).toEqual({ items: [] });
|
| 242 |
+
});
|
| 243 |
+
|
| 244 |
+
it("carries bearer auth and X-Org-Id inside the proxy envelope", async () => {
|
| 245 |
+
// Arrange — org scoping must survive the server-side hop: the envelope
|
| 246 |
+
// headers are what the agent-server attaches to the upstream call in
|
| 247 |
+
// place of the headers a direct browser request would have sent.
|
| 248 |
+
setRegisteredBackends([cloudPersonal]);
|
| 249 |
+
setActiveSelection({
|
| 250 |
+
backendId: cloudPersonal.id,
|
| 251 |
+
orgId: "org-personal-uuid",
|
| 252 |
+
});
|
| 253 |
+
|
| 254 |
+
// Act
|
| 255 |
+
await callCloudProxy({
|
| 256 |
+
backend: cloudPersonal,
|
| 257 |
+
method: "GET",
|
| 258 |
+
path: "/api/bash/bash_events/search",
|
| 259 |
+
hostOverride: runtimeHost,
|
| 260 |
+
});
|
| 261 |
+
|
| 262 |
+
// Assert
|
| 263 |
+
const [, init] = fetchMock.mock.calls[0]!;
|
| 264 |
+
const envelope = JSON.parse((init as { body: string }).body);
|
| 265 |
+
expect(
|
| 266 |
+
(envelope as { headers: Record<string, string> }).headers,
|
| 267 |
+
).toMatchObject({
|
| 268 |
+
Authorization: `Bearer ${cloudPersonal.apiKey}`,
|
| 269 |
+
"X-Org-Id": "org-personal-uuid",
|
| 270 |
+
});
|
| 271 |
+
});
|
| 272 |
+
});
|
__tests__/api/cloud/sandbox-service.test.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
__resetActiveStoreForTests,
|
| 4 |
+
setActiveSelection,
|
| 5 |
+
setRegisteredBackends,
|
| 6 |
+
} from "#/api/backend-registry/active-store";
|
| 7 |
+
import { batchGetCloudSandboxes } from "#/api/cloud/sandbox-service.api";
|
| 8 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 9 |
+
import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
|
| 10 |
+
|
| 11 |
+
const cloudBackend: Backend = {
|
| 12 |
+
id: "cloud-prod",
|
| 13 |
+
name: "Production",
|
| 14 |
+
host: "https://app.all-hands.dev",
|
| 15 |
+
apiKey: "bearer-token",
|
| 16 |
+
kind: "cloud",
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
const originalFetch = global.fetch;
|
| 20 |
+
const fetchMock = vi.fn();
|
| 21 |
+
|
| 22 |
+
beforeEach(() => {
|
| 23 |
+
window.localStorage.clear();
|
| 24 |
+
__resetActiveStoreForTests();
|
| 25 |
+
setRegisteredBackends([cloudBackend]);
|
| 26 |
+
setActiveSelection({ backendId: cloudBackend.id });
|
| 27 |
+
fetchMock.mockReset();
|
| 28 |
+
fetchMock.mockResolvedValue(mockJsonResponse([]));
|
| 29 |
+
global.fetch = fetchMock as typeof fetch;
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
afterEach(() => {
|
| 33 |
+
window.localStorage.clear();
|
| 34 |
+
__resetActiveStoreForTests();
|
| 35 |
+
fetchMock.mockReset();
|
| 36 |
+
global.fetch = originalFetch;
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
describe("batchGetCloudSandboxes", () => {
|
| 40 |
+
it("targets /api/v1/sandboxes with one id query param per sandbox id", async () => {
|
| 41 |
+
// Arrange — multiple ids exercises the URLSearchParams.append path,
|
| 42 |
+
// which is the cloud contract for batch-fetching sandboxes (the GUI
|
| 43 |
+
// reads sandbox.exposed_urls from the response to find the VSCODE
|
| 44 |
+
// URL instead of asking the runtime for a localhost address).
|
| 45 |
+
const ids = ["sandbox-a", "sandbox-b"];
|
| 46 |
+
|
| 47 |
+
// Act
|
| 48 |
+
await batchGetCloudSandboxes(ids);
|
| 49 |
+
|
| 50 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 51 |
+
expect(init).toMatchObject({
|
| 52 |
+
method: "GET",
|
| 53 |
+
headers: { Authorization: "Bearer bearer-token" },
|
| 54 |
+
});
|
| 55 |
+
expect(url).toBe(
|
| 56 |
+
`${cloudBackend.host}/api/v1/sandboxes?id=sandbox-a&id=sandbox-b`,
|
| 57 |
+
);
|
| 58 |
+
});
|
| 59 |
+
});
|
__tests__/api/cloud/secrets-service.test.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
__resetActiveStoreForTests,
|
| 4 |
+
setActiveSelection,
|
| 5 |
+
setRegisteredBackends,
|
| 6 |
+
} from "#/api/backend-registry/active-store";
|
| 7 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 8 |
+
import { SecretsService } from "#/api/secrets-service";
|
| 9 |
+
import {
|
| 10 |
+
getFetchCall,
|
| 11 |
+
getJsonBody,
|
| 12 |
+
mockJsonResponse,
|
| 13 |
+
} from "./fetch-test-utils";
|
| 14 |
+
|
| 15 |
+
const cloudBackend: Backend = {
|
| 16 |
+
id: "prod",
|
| 17 |
+
name: "Production",
|
| 18 |
+
host: "https://app.all-hands.dev",
|
| 19 |
+
apiKey: "bearer-token",
|
| 20 |
+
kind: "cloud",
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
const originalFetch = global.fetch;
|
| 24 |
+
const fetchMock = vi.fn();
|
| 25 |
+
|
| 26 |
+
beforeEach(() => {
|
| 27 |
+
window.localStorage.clear();
|
| 28 |
+
__resetActiveStoreForTests();
|
| 29 |
+
setRegisteredBackends([cloudBackend]);
|
| 30 |
+
setActiveSelection({ backendId: cloudBackend.id });
|
| 31 |
+
fetchMock.mockReset();
|
| 32 |
+
fetchMock.mockResolvedValue(mockJsonResponse({}));
|
| 33 |
+
global.fetch = fetchMock as typeof fetch;
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
afterEach(() => {
|
| 37 |
+
window.localStorage.clear();
|
| 38 |
+
__resetActiveStoreForTests();
|
| 39 |
+
fetchMock.mockReset();
|
| 40 |
+
global.fetch = originalFetch;
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
describe("SecretsService against cloud backend", () => {
|
| 44 |
+
it("paginates getSecrets directly and returns the merged list", async () => {
|
| 45 |
+
fetchMock
|
| 46 |
+
.mockResolvedValueOnce(
|
| 47 |
+
mockJsonResponse({
|
| 48 |
+
items: [
|
| 49 |
+
{ name: "ALPHA", description: "first" },
|
| 50 |
+
{ name: "BETA", description: "second" },
|
| 51 |
+
],
|
| 52 |
+
next_page_id: "BETA",
|
| 53 |
+
}),
|
| 54 |
+
)
|
| 55 |
+
.mockResolvedValueOnce(
|
| 56 |
+
mockJsonResponse({
|
| 57 |
+
items: [{ name: "GAMMA", description: "third" }],
|
| 58 |
+
next_page_id: null,
|
| 59 |
+
}),
|
| 60 |
+
);
|
| 61 |
+
|
| 62 |
+
const secrets = await SecretsService.getSecrets();
|
| 63 |
+
|
| 64 |
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
| 65 |
+
|
| 66 |
+
const [firstUrl, firstInit] = getFetchCall(fetchMock, 0);
|
| 67 |
+
expect(firstInit).toMatchObject({
|
| 68 |
+
method: "GET",
|
| 69 |
+
headers: { Authorization: "Bearer bearer-token" },
|
| 70 |
+
});
|
| 71 |
+
expect(firstUrl).toMatch(
|
| 72 |
+
/^https:\/\/app\.all-hands\.dev\/api\/v1\/secrets\/search\?/,
|
| 73 |
+
);
|
| 74 |
+
expect(firstUrl).not.toContain("page_id=");
|
| 75 |
+
|
| 76 |
+
const [secondUrl] = getFetchCall(fetchMock, 1);
|
| 77 |
+
expect(secondUrl).toContain("page_id=BETA");
|
| 78 |
+
|
| 79 |
+
expect(secrets.map((s) => s.name)).toEqual(["ALPHA", "BETA", "GAMMA"]);
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
it("creates a secret via direct POST /api/v1/secrets", async () => {
|
| 83 |
+
await SecretsService.createSecret(
|
| 84 |
+
"OPENAI_API_KEY",
|
| 85 |
+
"sk-test",
|
| 86 |
+
"OpenAI key",
|
| 87 |
+
);
|
| 88 |
+
|
| 89 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 90 |
+
expect(url).toBe(`${cloudBackend.host}/api/v1/secrets`);
|
| 91 |
+
expect(init).toMatchObject({
|
| 92 |
+
method: "POST",
|
| 93 |
+
headers: { Authorization: "Bearer bearer-token" },
|
| 94 |
+
});
|
| 95 |
+
expect(getJsonBody(init)).toEqual({
|
| 96 |
+
name: "OPENAI_API_KEY",
|
| 97 |
+
value: "sk-test",
|
| 98 |
+
description: "OpenAI key",
|
| 99 |
+
});
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
it("updates a secret via PUT /api/v1/secrets/{id} with name + description only", async () => {
|
| 103 |
+
// The form/hook calls updateSecret(secretToEdit, newName, description).
|
| 104 |
+
await SecretsService.updateSecret("OLD_NAME", "NEW_NAME", "renamed");
|
| 105 |
+
|
| 106 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 107 |
+
expect(url).toBe(`${cloudBackend.host}/api/v1/secrets/OLD_NAME`);
|
| 108 |
+
expect(init).toMatchObject({
|
| 109 |
+
method: "PUT",
|
| 110 |
+
headers: { Authorization: "Bearer bearer-token" },
|
| 111 |
+
});
|
| 112 |
+
expect(getJsonBody(init)).toEqual({
|
| 113 |
+
name: "NEW_NAME",
|
| 114 |
+
description: "renamed",
|
| 115 |
+
});
|
| 116 |
+
// No value supplied, so nothing overwrites the stored one.
|
| 117 |
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
it("overwrites the value with a POST following the PUT", async () => {
|
| 121 |
+
// Fresh Response per call: a Response body can only be consumed once.
|
| 122 |
+
fetchMock.mockImplementation(() => Promise.resolve(mockJsonResponse({})));
|
| 123 |
+
|
| 124 |
+
await SecretsService.updateSecret(
|
| 125 |
+
"API_KEY",
|
| 126 |
+
"API_KEY",
|
| 127 |
+
"Demo secret",
|
| 128 |
+
"new-value",
|
| 129 |
+
);
|
| 130 |
+
|
| 131 |
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
| 132 |
+
|
| 133 |
+
const [putUrl, putInit] = getFetchCall(fetchMock, 0);
|
| 134 |
+
expect(putUrl).toBe(`${cloudBackend.host}/api/v1/secrets/API_KEY`);
|
| 135 |
+
expect(putInit).toMatchObject({ method: "PUT" });
|
| 136 |
+
|
| 137 |
+
const [postUrl, postInit] = getFetchCall(fetchMock, 1);
|
| 138 |
+
expect(postUrl).toBe(`${cloudBackend.host}/api/v1/secrets`);
|
| 139 |
+
expect(postInit).toMatchObject({ method: "POST" });
|
| 140 |
+
expect(getJsonBody(postInit)).toEqual({
|
| 141 |
+
name: "API_KEY",
|
| 142 |
+
value: "new-value",
|
| 143 |
+
description: "Demo secret",
|
| 144 |
+
});
|
| 145 |
+
});
|
| 146 |
+
|
| 147 |
+
it("deletes a secret via direct DELETE /api/v1/secrets/{id}", async () => {
|
| 148 |
+
await SecretsService.deleteSecret("token with space");
|
| 149 |
+
|
| 150 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 151 |
+
expect(url).toBe(
|
| 152 |
+
`${cloudBackend.host}/api/v1/secrets/token%20with%20space`,
|
| 153 |
+
);
|
| 154 |
+
expect(init).toMatchObject({
|
| 155 |
+
method: "DELETE",
|
| 156 |
+
headers: { Authorization: "Bearer bearer-token" },
|
| 157 |
+
});
|
| 158 |
+
});
|
| 159 |
+
|
| 160 |
+
it("treats a delete 404 as success (secret already gone)", async () => {
|
| 161 |
+
// Fresh Response per attempt: the retry helper re-fetches and a
|
| 162 |
+
// Response body can only be consumed once. Fake timers skip the
|
| 163 |
+
// retry backoff sleeps.
|
| 164 |
+
fetchMock.mockImplementation(() =>
|
| 165 |
+
Promise.resolve(mockJsonResponse({ detail: "Secret not found" }, 404)),
|
| 166 |
+
);
|
| 167 |
+
vi.useFakeTimers();
|
| 168 |
+
|
| 169 |
+
try {
|
| 170 |
+
const assertion = expect(
|
| 171 |
+
SecretsService.deleteSecret("ALREADY_GONE"),
|
| 172 |
+
).resolves.toBeUndefined();
|
| 173 |
+
await vi.runAllTimersAsync();
|
| 174 |
+
await assertion;
|
| 175 |
+
} finally {
|
| 176 |
+
vi.useRealTimers();
|
| 177 |
+
}
|
| 178 |
+
});
|
| 179 |
+
});
|
__tests__/api/cloud/settings-service.test.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
__resetActiveStoreForTests,
|
| 4 |
+
setActiveSelection,
|
| 5 |
+
setRegisteredBackends,
|
| 6 |
+
} from "#/api/backend-registry/active-store";
|
| 7 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 8 |
+
import {
|
| 9 |
+
fetchCloudSettings,
|
| 10 |
+
saveCloudSettings,
|
| 11 |
+
} from "#/api/cloud/settings-service.api";
|
| 12 |
+
import SettingsService from "#/api/settings-service/settings-service.api";
|
| 13 |
+
import {
|
| 14 |
+
getFetchCall,
|
| 15 |
+
getJsonBody,
|
| 16 |
+
mockJsonResponse,
|
| 17 |
+
} from "./fetch-test-utils";
|
| 18 |
+
|
| 19 |
+
const cloudBackend: Backend = {
|
| 20 |
+
id: "prod",
|
| 21 |
+
name: "Production",
|
| 22 |
+
host: "https://app.all-hands.dev",
|
| 23 |
+
apiKey: "bearer-token",
|
| 24 |
+
kind: "cloud",
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
const originalFetch = global.fetch;
|
| 28 |
+
const fetchMock = vi.fn();
|
| 29 |
+
|
| 30 |
+
beforeEach(() => {
|
| 31 |
+
window.localStorage.clear();
|
| 32 |
+
__resetActiveStoreForTests();
|
| 33 |
+
setRegisteredBackends([cloudBackend]);
|
| 34 |
+
setActiveSelection({ backendId: cloudBackend.id });
|
| 35 |
+
fetchMock.mockReset();
|
| 36 |
+
fetchMock.mockResolvedValue(mockJsonResponse({}));
|
| 37 |
+
global.fetch = fetchMock as typeof fetch;
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
afterEach(() => {
|
| 41 |
+
window.localStorage.clear();
|
| 42 |
+
__resetActiveStoreForTests();
|
| 43 |
+
fetchMock.mockReset();
|
| 44 |
+
global.fetch = originalFetch;
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
describe("cloud settings", () => {
|
| 48 |
+
it("fetchCloudSettings preserves provider_tokens_set so the repo chain can fire", async () => {
|
| 49 |
+
fetchMock.mockResolvedValueOnce(
|
| 50 |
+
mockJsonResponse({
|
| 51 |
+
llm_model: "anthropic/claude-3-5-sonnet",
|
| 52 |
+
llm_base_url: "https://api.anthropic.com",
|
| 53 |
+
llm_api_key_set: true,
|
| 54 |
+
agent: "CodeActAgent",
|
| 55 |
+
confirmation_mode: true,
|
| 56 |
+
security_analyzer: "llm",
|
| 57 |
+
max_iterations: 30,
|
| 58 |
+
provider_tokens_set: { github: "***" },
|
| 59 |
+
}),
|
| 60 |
+
);
|
| 61 |
+
|
| 62 |
+
const result = await fetchCloudSettings();
|
| 63 |
+
|
| 64 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 65 |
+
expect(url).toBe(`${cloudBackend.host}/api/v1/settings`);
|
| 66 |
+
expect(init).toMatchObject({
|
| 67 |
+
method: "GET",
|
| 68 |
+
headers: { Authorization: "Bearer bearer-token" },
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
// provider_tokens_set must round-trip — it's what drives
|
| 72 |
+
// useUserProviders → useAppInstallations → useGitRepositories.
|
| 73 |
+
expect(result.provider_tokens_set).toEqual({ github: "***" });
|
| 74 |
+
|
| 75 |
+
// Top-level cloud fields are preserved as-is.
|
| 76 |
+
expect(result.llm_model).toBe("anthropic/claude-3-5-sonnet");
|
| 77 |
+
expect(result.llm_api_key_set).toBe(true);
|
| 78 |
+
expect(result.agent).toBe("CodeActAgent");
|
| 79 |
+
|
| 80 |
+
// Nested shape derived for the local-mode settings page.
|
| 81 |
+
expect(result.agent_settings?.agent).toBe("CodeActAgent");
|
| 82 |
+
expect(result.agent_settings?.llm).toEqual({
|
| 83 |
+
model: "anthropic/claude-3-5-sonnet",
|
| 84 |
+
base_url: "https://api.anthropic.com",
|
| 85 |
+
});
|
| 86 |
+
expect(result.conversation_settings?.confirmation_mode).toBe(true);
|
| 87 |
+
expect(result.conversation_settings?.security_analyzer).toBe("llm");
|
| 88 |
+
expect(result.conversation_settings?.max_iterations).toBe(30);
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
it("saveCloudSettings forwards diffs verbatim and omits the legacy keys the cloud rejects", async () => {
|
| 92 |
+
const agentDiff = {
|
| 93 |
+
llm: { model: "openai/gpt-4o", base_url: "https://api.openai.com" },
|
| 94 |
+
agent: "CodeActAgent",
|
| 95 |
+
};
|
| 96 |
+
const conversationDiff = { max_iterations: 50 };
|
| 97 |
+
|
| 98 |
+
await saveCloudSettings({
|
| 99 |
+
agent_settings_diff: agentDiff,
|
| 100 |
+
conversation_settings_diff: conversationDiff,
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 104 |
+
expect(url).toBe(`${cloudBackend.host}/api/v1/settings`);
|
| 105 |
+
expect(init).toMatchObject({
|
| 106 |
+
method: "POST",
|
| 107 |
+
headers: { Authorization: "Bearer bearer-token" },
|
| 108 |
+
});
|
| 109 |
+
const requestBody = getJsonBody(init);
|
| 110 |
+
expect(requestBody).toEqual({
|
| 111 |
+
agent_settings_diff: agentDiff,
|
| 112 |
+
conversation_settings_diff: conversationDiff,
|
| 113 |
+
});
|
| 114 |
+
expect(requestBody).not.toHaveProperty("agent_settings");
|
| 115 |
+
expect(requestBody).not.toHaveProperty("conversation_settings");
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
it("SettingsService.saveSettings forwards disabled_skills to cloud when active backend is cloud", async () => {
|
| 119 |
+
// Act: save a skills-only update — previously this short-circuited and
|
| 120 |
+
// sent nothing at all, leaving the toggle un-persisted.
|
| 121 |
+
await SettingsService.saveSettings({
|
| 122 |
+
disabled_skills: ["SSH Microagent"],
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
// Assert: a single POST /api/v1/settings reached the wire with
|
| 126 |
+
// disabled_skills as a top-level field.
|
| 127 |
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
| 128 |
+
const [url, init] = getFetchCall(fetchMock);
|
| 129 |
+
expect(url).toBe(`${cloudBackend.host}/api/v1/settings`);
|
| 130 |
+
expect(init).toMatchObject({
|
| 131 |
+
method: "POST",
|
| 132 |
+
headers: { Authorization: "Bearer bearer-token" },
|
| 133 |
+
});
|
| 134 |
+
expect(getJsonBody(init)).toEqual({
|
| 135 |
+
disabled_skills: ["SSH Microagent"],
|
| 136 |
+
});
|
| 137 |
+
});
|
| 138 |
+
|
| 139 |
+
it("saveCloudSettings omits an empty conversation_settings_diff (LLM-only save)", async () => {
|
| 140 |
+
await saveCloudSettings({
|
| 141 |
+
agent_settings_diff: {
|
| 142 |
+
llm: { model: "anthropic/claude-sonnet-4-20250514" },
|
| 143 |
+
},
|
| 144 |
+
conversation_settings_diff: {},
|
| 145 |
+
});
|
| 146 |
+
|
| 147 |
+
const [, init] = getFetchCall(fetchMock);
|
| 148 |
+
const requestBody = getJsonBody(init);
|
| 149 |
+
expect(requestBody).toEqual({
|
| 150 |
+
agent_settings_diff: {
|
| 151 |
+
llm: { model: "anthropic/claude-sonnet-4-20250514" },
|
| 152 |
+
},
|
| 153 |
+
});
|
| 154 |
+
});
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
describe("saveCloudSettings drops agent_context: null (agent-canvas#981)", () => {
|
| 158 |
+
it("strips a null agent_context while preserving sibling agent settings", async () => {
|
| 159 |
+
// Act
|
| 160 |
+
await saveCloudSettings({
|
| 161 |
+
agent_settings_diff: {
|
| 162 |
+
llm: { model: "anthropic/claude-sonnet-4-20250514" },
|
| 163 |
+
agent_context: null,
|
| 164 |
+
},
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
// Assert: agent_context never reaches the wire, but the real llm change does.
|
| 168 |
+
const [, init] = getFetchCall(fetchMock);
|
| 169 |
+
const requestBody = getJsonBody(init);
|
| 170 |
+
expect(requestBody).toEqual({
|
| 171 |
+
agent_settings_diff: {
|
| 172 |
+
llm: { model: "anthropic/claude-sonnet-4-20250514" },
|
| 173 |
+
},
|
| 174 |
+
});
|
| 175 |
+
});
|
| 176 |
+
|
| 177 |
+
it("preserves a null mcp_config so clearing MCP servers still round-trips", async () => {
|
| 178 |
+
// Act
|
| 179 |
+
await saveCloudSettings({
|
| 180 |
+
agent_settings_diff: { mcp_config: null },
|
| 181 |
+
});
|
| 182 |
+
|
| 183 |
+
// Assert: the null mcp_config must survive (don't over-strip nulls).
|
| 184 |
+
const [, init] = getFetchCall(fetchMock);
|
| 185 |
+
const requestBody = getJsonBody(init);
|
| 186 |
+
expect(requestBody).toEqual({ agent_settings_diff: { mcp_config: null } });
|
| 187 |
+
});
|
| 188 |
+
|
| 189 |
+
it("omits agent_settings_diff when agent_context: null is its only key", async () => {
|
| 190 |
+
// Act
|
| 191 |
+
await saveCloudSettings({
|
| 192 |
+
agent_settings_diff: { agent_context: null },
|
| 193 |
+
});
|
| 194 |
+
|
| 195 |
+
// Assert: nothing is left to send, so no agent_settings_diff goes on the wire.
|
| 196 |
+
const [, init] = getFetchCall(fetchMock);
|
| 197 |
+
const requestBody = getJsonBody(init);
|
| 198 |
+
expect(requestBody).toEqual({});
|
| 199 |
+
});
|
| 200 |
+
});
|
__tests__/api/cloud/skills-service.test.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
__resetActiveStoreForTests,
|
| 4 |
+
setActiveSelection,
|
| 5 |
+
setRegisteredBackends,
|
| 6 |
+
} from "#/api/backend-registry/active-store";
|
| 7 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 8 |
+
import SkillsService from "#/api/skills-service";
|
| 9 |
+
import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
|
| 10 |
+
|
| 11 |
+
const cloudBackend: Backend = {
|
| 12 |
+
id: "prod",
|
| 13 |
+
name: "Production",
|
| 14 |
+
host: "https://app.all-hands.dev",
|
| 15 |
+
apiKey: "bearer-token",
|
| 16 |
+
kind: "cloud",
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
const originalFetch = global.fetch;
|
| 20 |
+
const fetchMock = vi.fn();
|
| 21 |
+
|
| 22 |
+
beforeEach(() => {
|
| 23 |
+
window.localStorage.clear();
|
| 24 |
+
__resetActiveStoreForTests();
|
| 25 |
+
setRegisteredBackends([cloudBackend]);
|
| 26 |
+
setActiveSelection({ backendId: cloudBackend.id });
|
| 27 |
+
fetchMock.mockReset();
|
| 28 |
+
global.fetch = fetchMock as typeof fetch;
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
afterEach(() => {
|
| 32 |
+
window.localStorage.clear();
|
| 33 |
+
__resetActiveStoreForTests();
|
| 34 |
+
fetchMock.mockReset();
|
| 35 |
+
global.fetch = originalFetch;
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
describe("SkillsService.getSkills against cloud backend", () => {
|
| 39 |
+
it("paginates /api/v1/skills/search directly and returns the merged list", async () => {
|
| 40 |
+
fetchMock
|
| 41 |
+
.mockResolvedValueOnce(
|
| 42 |
+
mockJsonResponse({
|
| 43 |
+
items: [
|
| 44 |
+
{ name: "alpha", type: "knowledge", source: "global" },
|
| 45 |
+
{
|
| 46 |
+
name: "beta",
|
| 47 |
+
type: "task",
|
| 48 |
+
source: "user",
|
| 49 |
+
triggers: ["foo"],
|
| 50 |
+
},
|
| 51 |
+
],
|
| 52 |
+
next_page_id: "beta",
|
| 53 |
+
}),
|
| 54 |
+
)
|
| 55 |
+
.mockResolvedValueOnce(
|
| 56 |
+
mockJsonResponse({
|
| 57 |
+
items: [{ name: "gamma", type: "knowledge", source: "user" }],
|
| 58 |
+
next_page_id: null,
|
| 59 |
+
}),
|
| 60 |
+
);
|
| 61 |
+
|
| 62 |
+
const skills = await SkillsService.getSkills();
|
| 63 |
+
|
| 64 |
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
| 65 |
+
|
| 66 |
+
const [firstUrl, firstInit] = getFetchCall(fetchMock, 0);
|
| 67 |
+
expect(firstInit).toMatchObject({
|
| 68 |
+
method: "GET",
|
| 69 |
+
headers: { Authorization: "Bearer bearer-token" },
|
| 70 |
+
});
|
| 71 |
+
expect(firstUrl).toMatch(
|
| 72 |
+
/^https:\/\/app\.all-hands\.dev\/api\/v1\/skills\/search\?/,
|
| 73 |
+
);
|
| 74 |
+
expect(firstUrl).not.toContain("page_id=");
|
| 75 |
+
|
| 76 |
+
const [secondUrl] = getFetchCall(fetchMock, 1);
|
| 77 |
+
expect(secondUrl).toContain("page_id=beta");
|
| 78 |
+
|
| 79 |
+
expect(skills.map((s) => s.name)).toEqual(["alpha", "beta", "gamma"]);
|
| 80 |
+
expect(skills[1]).toMatchObject({ triggers: ["foo"] });
|
| 81 |
+
});
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
describe("SkillsService.getConversationSkills against cloud backend", () => {
|
| 85 |
+
it("reads the conversation's own skills route and maps entries to SkillInfo", async () => {
|
| 86 |
+
// Arrange
|
| 87 |
+
fetchMock.mockResolvedValueOnce(
|
| 88 |
+
mockJsonResponse({
|
| 89 |
+
skills: [
|
| 90 |
+
{
|
| 91 |
+
name: "release-notes",
|
| 92 |
+
type: "agentskills",
|
| 93 |
+
content: "# Release notes",
|
| 94 |
+
triggers: ["/release-notes"],
|
| 95 |
+
},
|
| 96 |
+
{
|
| 97 |
+
name: "repo",
|
| 98 |
+
type: "repo",
|
| 99 |
+
content: "Repository instructions",
|
| 100 |
+
triggers: [],
|
| 101 |
+
},
|
| 102 |
+
],
|
| 103 |
+
}),
|
| 104 |
+
);
|
| 105 |
+
|
| 106 |
+
// Act
|
| 107 |
+
const skills = await SkillsService.getConversationSkills("conv-1");
|
| 108 |
+
|
| 109 |
+
// Assert
|
| 110 |
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
| 111 |
+
const [url, init] = getFetchCall(fetchMock, 0);
|
| 112 |
+
expect(url).toBe(
|
| 113 |
+
"https://app.all-hands.dev/api/v1/app-conversations/conv-1/skills",
|
| 114 |
+
);
|
| 115 |
+
expect(init).toMatchObject({
|
| 116 |
+
method: "GET",
|
| 117 |
+
headers: { Authorization: "Bearer bearer-token" },
|
| 118 |
+
});
|
| 119 |
+
expect(skills).toEqual([
|
| 120 |
+
{
|
| 121 |
+
name: "release-notes",
|
| 122 |
+
type: "agentskills",
|
| 123 |
+
content: "# Release notes",
|
| 124 |
+
triggers: ["/release-notes"],
|
| 125 |
+
source: null,
|
| 126 |
+
},
|
| 127 |
+
{
|
| 128 |
+
name: "repo",
|
| 129 |
+
type: "repo",
|
| 130 |
+
content: "Repository instructions",
|
| 131 |
+
triggers: [],
|
| 132 |
+
source: null,
|
| 133 |
+
},
|
| 134 |
+
]);
|
| 135 |
+
});
|
| 136 |
+
});
|
__tests__/api/mcp-health/mcp-health-store.test.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { beforeEach, describe, expect, it } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
__resetMcpHealthStoreForTests,
|
| 4 |
+
beginMcpHealthCheck,
|
| 5 |
+
clearMcpServerHealth,
|
| 6 |
+
getMcpHealthSnapshot,
|
| 7 |
+
resolveMcpHealthCheck,
|
| 8 |
+
} from "#/api/mcp-health/mcp-health-store";
|
| 9 |
+
import type { McpServerHealth } from "#/types/mcp-health";
|
| 10 |
+
|
| 11 |
+
const HEALTHY: McpServerHealth = {
|
| 12 |
+
status: "healthy",
|
| 13 |
+
verification: "verified",
|
| 14 |
+
toolCount: 3,
|
| 15 |
+
checkedAt: 1,
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
const FAILED: McpServerHealth = {
|
| 19 |
+
status: "failed",
|
| 20 |
+
kind: "connection",
|
| 21 |
+
error: "refused",
|
| 22 |
+
checkedAt: 2,
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
describe("mcp-health-store", () => {
|
| 26 |
+
beforeEach(() => {
|
| 27 |
+
__resetMcpHealthStoreForTests();
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
it("marks a check as in flight and commits its result", () => {
|
| 31 |
+
const checkId = beginMcpHealthCheck("key");
|
| 32 |
+
expect(getMcpHealthSnapshot().key).toEqual({ status: "checking", checkId });
|
| 33 |
+
|
| 34 |
+
resolveMcpHealthCheck("key", checkId, HEALTHY);
|
| 35 |
+
|
| 36 |
+
expect(getMcpHealthSnapshot().key).toEqual(HEALTHY);
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
it("drops a stale result that was superseded by a newer check", () => {
|
| 40 |
+
const staleId = beginMcpHealthCheck("key");
|
| 41 |
+
const freshId = beginMcpHealthCheck("key");
|
| 42 |
+
|
| 43 |
+
// The slow older probe must not overwrite the newer probe's lifecycle.
|
| 44 |
+
resolveMcpHealthCheck("key", staleId, HEALTHY);
|
| 45 |
+
expect(getMcpHealthSnapshot().key).toEqual({
|
| 46 |
+
status: "checking",
|
| 47 |
+
checkId: freshId,
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
resolveMcpHealthCheck("key", freshId, FAILED);
|
| 51 |
+
expect(getMcpHealthSnapshot().key).toEqual(FAILED);
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
it("drops a result whose entry was cleared mid-flight", () => {
|
| 55 |
+
// e.g. the server was edited or deleted while its probe was running —
|
| 56 |
+
// the late result must not resurrect a verdict for a gone config.
|
| 57 |
+
const checkId = beginMcpHealthCheck("key");
|
| 58 |
+
clearMcpServerHealth("key");
|
| 59 |
+
|
| 60 |
+
resolveMcpHealthCheck("key", checkId, HEALTHY);
|
| 61 |
+
|
| 62 |
+
expect(getMcpHealthSnapshot().key).toBeUndefined();
|
| 63 |
+
});
|
| 64 |
+
});
|
__tests__/api/mcp-health/probe-mcp-server-health.test.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
__resetMcpHealthStoreForTests,
|
| 4 |
+
getMcpHealthSnapshot,
|
| 5 |
+
setMcpServerHealth,
|
| 6 |
+
} from "#/api/mcp-health/mcp-health-store";
|
| 7 |
+
import {
|
| 8 |
+
interpretMcpTestResponse,
|
| 9 |
+
probeMcpServerHealth,
|
| 10 |
+
seedMcpServerHealth,
|
| 11 |
+
} from "#/api/mcp-health/probe-mcp-server-health";
|
| 12 |
+
import McpService from "#/api/mcp-service/mcp-service.api";
|
| 13 |
+
import type { ExtendedMCPTestResponse, MCPServerConfig } from "#/types/mcp-server";
|
| 14 |
+
import { getMcpServerHealthKey } from "#/utils/mcp-server-health-key";
|
| 15 |
+
|
| 16 |
+
/** Matches the catalog `github` entry, so the `get_me` probe spec applies. */
|
| 17 |
+
const GITHUB: MCPServerConfig = {
|
| 18 |
+
id: "shttp-0",
|
| 19 |
+
type: "shttp",
|
| 20 |
+
name: "github",
|
| 21 |
+
url: "https://api.githubcopilot.com/mcp/",
|
| 22 |
+
auth: { strategy: "api_key", value: "github_pat_x" },
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
/** Matches no catalog entry, so no probe spec exists. */
|
| 26 |
+
const CUSTOM: MCPServerConfig = {
|
| 27 |
+
id: "shttp-1",
|
| 28 |
+
type: "shttp",
|
| 29 |
+
name: "custom",
|
| 30 |
+
url: "https://mcp.example.com/mcp",
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
describe("interpretMcpTestResponse", () => {
|
| 34 |
+
it("passes a non-auth connection failure through with its kind", () => {
|
| 35 |
+
const health = interpretMcpTestResponse(CUSTOM, {
|
| 36 |
+
ok: false,
|
| 37 |
+
error: "connection refused",
|
| 38 |
+
error_kind: "connection",
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
expect(health).toMatchObject({
|
| 42 |
+
status: "failed",
|
| 43 |
+
kind: "connection",
|
| 44 |
+
error: "connection refused",
|
| 45 |
+
});
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
it("reclassifies a connection failure with auth-rejection text as a credentials failure", () => {
|
| 49 |
+
// Hosted servers reject bad tokens at the HTTP handshake, which the
|
| 50 |
+
// backend can only report as a connection failure.
|
| 51 |
+
const health = interpretMcpTestResponse(GITHUB, {
|
| 52 |
+
ok: false,
|
| 53 |
+
error: "Client error '401 Unauthorized' for url …",
|
| 54 |
+
error_kind: "connection",
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
expect(health).toMatchObject({ status: "failed", kind: "credentials" });
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
it("reports verified health when the read-only probe tool ran cleanly", () => {
|
| 61 |
+
const health = interpretMcpTestResponse(GITHUB, {
|
| 62 |
+
ok: true,
|
| 63 |
+
tools: ["get_me", "search_code"],
|
| 64 |
+
tool_result: { is_error: false, text: '{"login":"octocat"}' },
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
expect(health).toMatchObject({
|
| 68 |
+
status: "healthy",
|
| 69 |
+
verification: "verified",
|
| 70 |
+
toolCount: 2,
|
| 71 |
+
});
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
it("downgrades to connectivity-only when the probe tool is not advertised", () => {
|
| 75 |
+
const health = interpretMcpTestResponse(GITHUB, {
|
| 76 |
+
ok: true,
|
| 77 |
+
tools: ["search_code"],
|
| 78 |
+
tool_result: {
|
| 79 |
+
is_error: true,
|
| 80 |
+
text: "Tool 'get_me' not advertised by server",
|
| 81 |
+
},
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
expect(health).toMatchObject({
|
| 85 |
+
status: "healthy",
|
| 86 |
+
verification: "connectivity-only",
|
| 87 |
+
});
|
| 88 |
+
});
|
| 89 |
+
|
| 90 |
+
it("reports connectivity-only for servers without a probe spec", () => {
|
| 91 |
+
const health = interpretMcpTestResponse(CUSTOM, {
|
| 92 |
+
ok: true,
|
| 93 |
+
tools: ["a"],
|
| 94 |
+
});
|
| 95 |
+
|
| 96 |
+
expect(health).toMatchObject({
|
| 97 |
+
status: "healthy",
|
| 98 |
+
verification: "connectivity-only",
|
| 99 |
+
toolCount: 1,
|
| 100 |
+
});
|
| 101 |
+
});
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
describe("probeMcpServerHealth", () => {
|
| 105 |
+
beforeEach(() => {
|
| 106 |
+
__resetMcpHealthStoreForTests();
|
| 107 |
+
vi.restoreAllMocks();
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
it("publishes checking while the probe runs, then the interpreted result", async () => {
|
| 111 |
+
let resolveProbe!: (value: ExtendedMCPTestResponse) => void;
|
| 112 |
+
vi.spyOn(McpService, "testServer").mockReturnValue(
|
| 113 |
+
new Promise((resolve) => {
|
| 114 |
+
resolveProbe = resolve;
|
| 115 |
+
}),
|
| 116 |
+
);
|
| 117 |
+
const key = getMcpServerHealthKey(CUSTOM);
|
| 118 |
+
|
| 119 |
+
const probe = probeMcpServerHealth(CUSTOM);
|
| 120 |
+
expect(getMcpHealthSnapshot()[key]).toMatchObject({ status: "checking" });
|
| 121 |
+
|
| 122 |
+
resolveProbe({ ok: true, tools: ["a", "b"] });
|
| 123 |
+
await probe;
|
| 124 |
+
|
| 125 |
+
expect(getMcpHealthSnapshot()[key]).toMatchObject({
|
| 126 |
+
status: "healthy",
|
| 127 |
+
verification: "connectivity-only",
|
| 128 |
+
toolCount: 2,
|
| 129 |
+
});
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
it("converts a thrown transport error into a failed verdict", async () => {
|
| 133 |
+
vi.spyOn(McpService, "testServer").mockRejectedValue(
|
| 134 |
+
new Error("network down"),
|
| 135 |
+
);
|
| 136 |
+
|
| 137 |
+
await probeMcpServerHealth(CUSTOM);
|
| 138 |
+
|
| 139 |
+
expect(getMcpHealthSnapshot()[getMcpServerHealthKey(CUSTOM)]).toMatchObject(
|
| 140 |
+
{ status: "failed", kind: "unknown", error: "network down" },
|
| 141 |
+
);
|
| 142 |
+
});
|
| 143 |
+
});
|
| 144 |
+
|
| 145 |
+
describe("seedMcpServerHealth", () => {
|
| 146 |
+
beforeEach(() => {
|
| 147 |
+
__resetMcpHealthStoreForTests();
|
| 148 |
+
});
|
| 149 |
+
|
| 150 |
+
it("publishes the saved server's health from its pre-save test result", () => {
|
| 151 |
+
seedMcpServerHealth(CUSTOM, { ok: true, tools: ["a"] }, []);
|
| 152 |
+
|
| 153 |
+
expect(getMcpHealthSnapshot()[getMcpServerHealthKey(CUSTOM)]).toMatchObject(
|
| 154 |
+
{ status: "healthy" },
|
| 155 |
+
);
|
| 156 |
+
});
|
| 157 |
+
|
| 158 |
+
it("skips seeding when another installed server shares the health key", () => {
|
| 159 |
+
// A second install of the same catalog entry collides on the key until
|
| 160 |
+
// the save suffixes its name; it must not overwrite the first card.
|
| 161 |
+
const existingVerdict = {
|
| 162 |
+
status: "failed",
|
| 163 |
+
kind: "credentials",
|
| 164 |
+
error: "bad token",
|
| 165 |
+
checkedAt: 1,
|
| 166 |
+
} as const;
|
| 167 |
+
setMcpServerHealth(getMcpServerHealthKey(GITHUB), existingVerdict);
|
| 168 |
+
|
| 169 |
+
seedMcpServerHealth(
|
| 170 |
+
{ ...GITHUB, id: "shttp-9" },
|
| 171 |
+
{ ok: true, tools: ["get_me"] },
|
| 172 |
+
[GITHUB],
|
| 173 |
+
);
|
| 174 |
+
|
| 175 |
+
expect(getMcpHealthSnapshot()[getMcpServerHealthKey(GITHUB)]).toEqual(
|
| 176 |
+
existingVerdict,
|
| 177 |
+
);
|
| 178 |
+
});
|
| 179 |
+
});
|
__tests__/api/mcp-service/mcp-service.api.test.ts
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
| 2 |
+
import McpService from "#/api/mcp-service/mcp-service.api";
|
| 3 |
+
import SettingsService, {
|
| 4 |
+
type SettingsApiResponse,
|
| 5 |
+
} from "#/api/settings-service/settings-service.api";
|
| 6 |
+
import * as activeStore from "#/api/backend-registry/active-store";
|
| 7 |
+
import type { MCPServerConfig } from "#/types/mcp-server";
|
| 8 |
+
import { REDACTED_MCP_SECRET_VALUE } from "#/utils/mcp-config";
|
| 9 |
+
|
| 10 |
+
// vi.mock factories are hoisted before imports, so spy functions must be
|
| 11 |
+
// created with vi.hoisted() to be in scope inside the factory.
|
| 12 |
+
const { mockTestServer } = vi.hoisted(() => ({
|
| 13 |
+
mockTestServer: vi.fn(),
|
| 14 |
+
}));
|
| 15 |
+
|
| 16 |
+
vi.mock("@openhands/typescript-client/clients", () => ({
|
| 17 |
+
// Real class so `new MCPClient(...)` works; testServer delegates to the
|
| 18 |
+
// shared spy so each test can configure the return value independently.
|
| 19 |
+
MCPClient: class {
|
| 20 |
+
// eslint-disable-next-line class-methods-use-this
|
| 21 |
+
testServer = mockTestServer;
|
| 22 |
+
|
| 23 |
+
// eslint-disable-next-line class-methods-use-this
|
| 24 |
+
close = vi.fn();
|
| 25 |
+
},
|
| 26 |
+
}));
|
| 27 |
+
|
| 28 |
+
vi.mock("#/api/agent-server-client-options", () => ({
|
| 29 |
+
getAgentServerClientOptions: () => ({
|
| 30 |
+
host: "http://localhost:3000",
|
| 31 |
+
apiKey: "test-key",
|
| 32 |
+
}),
|
| 33 |
+
}));
|
| 34 |
+
|
| 35 |
+
vi.mock("#/api/backend-registry/active-store", () => ({
|
| 36 |
+
getActiveBackend: vi.fn(),
|
| 37 |
+
}));
|
| 38 |
+
|
| 39 |
+
const mockGetActiveBackend = vi.mocked(activeStore.getActiveBackend);
|
| 40 |
+
|
| 41 |
+
const localActive = () =>
|
| 42 |
+
mockGetActiveBackend.mockReturnValue({
|
| 43 |
+
backend: {
|
| 44 |
+
id: "local-1",
|
| 45 |
+
name: "Local",
|
| 46 |
+
host: "http://localhost:3000",
|
| 47 |
+
apiKey: "test-key",
|
| 48 |
+
kind: "local",
|
| 49 |
+
},
|
| 50 |
+
orgId: null,
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
const cloudActive = () =>
|
| 54 |
+
mockGetActiveBackend.mockReturnValue({
|
| 55 |
+
backend: {
|
| 56 |
+
id: "cloud-1",
|
| 57 |
+
name: "Cloud",
|
| 58 |
+
host: "https://app.all-hands.dev",
|
| 59 |
+
apiKey: "cloud-key",
|
| 60 |
+
kind: "cloud",
|
| 61 |
+
},
|
| 62 |
+
orgId: null,
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
const SERVER: MCPServerConfig = {
|
| 66 |
+
id: "shttp-1",
|
| 67 |
+
type: "shttp",
|
| 68 |
+
url: "https://mcp.example.com/mcp",
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
describe("McpService.testServer", () => {
|
| 72 |
+
beforeEach(() => {
|
| 73 |
+
vi.clearAllMocks();
|
| 74 |
+
localActive();
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
it("passes success responses through unchanged", async () => {
|
| 78 |
+
mockTestServer.mockResolvedValue({ ok: true, tools: ["search", "fetch"] });
|
| 79 |
+
|
| 80 |
+
const result = await McpService.testServer(SERVER);
|
| 81 |
+
|
| 82 |
+
expect(result).toEqual({ ok: true, tools: ["search", "fetch"] });
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
it("passes failure responses through unchanged (no server-side escaping)", async () => {
|
| 86 |
+
// The backend returns plain text; HTML-escaping of {{-error}} is handled
|
| 87 |
+
// by the i18next no-escape prefix in the translation string, not here.
|
| 88 |
+
mockTestServer.mockResolvedValue({
|
| 89 |
+
ok: false,
|
| 90 |
+
error:
|
| 91 |
+
"Client error '401 Unauthorized' for url https://mcp.example.com/mcp",
|
| 92 |
+
error_kind: "unknown",
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
const result = await McpService.testServer(SERVER);
|
| 96 |
+
|
| 97 |
+
expect(result).toEqual({
|
| 98 |
+
ok: false,
|
| 99 |
+
error:
|
| 100 |
+
"Client error '401 Unauthorized' for url https://mcp.example.com/mcp",
|
| 101 |
+
error_kind: "unknown",
|
| 102 |
+
});
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
it("maps a stdio config to a StdioMCPServerSpec", async () => {
|
| 106 |
+
mockTestServer.mockResolvedValue({ ok: true, tools: [] });
|
| 107 |
+
const stdio: MCPServerConfig = {
|
| 108 |
+
id: "my-server",
|
| 109 |
+
type: "stdio",
|
| 110 |
+
name: "my-server",
|
| 111 |
+
command: "npx",
|
| 112 |
+
args: ["-y", "@my/mcp-server"],
|
| 113 |
+
env: { API_KEY: "secret" },
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
await McpService.testServer(stdio);
|
| 117 |
+
|
| 118 |
+
// Exact match also guards that non-catalog servers get no `tool_call`.
|
| 119 |
+
expect(mockTestServer).toHaveBeenCalledWith({
|
| 120 |
+
server: {
|
| 121 |
+
type: "stdio",
|
| 122 |
+
command: "npx",
|
| 123 |
+
args: ["-y", "@my/mcp-server"],
|
| 124 |
+
env: { API_KEY: "secret" },
|
| 125 |
+
},
|
| 126 |
+
name: "my-server",
|
| 127 |
+
});
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
// -------------------------------------------------------------------------
|
| 131 |
+
// Credential verification for marketplace servers (Slack)
|
| 132 |
+
//
|
| 133 |
+
// The Slack MCP server lists its tools with any credentials and reports
|
| 134 |
+
// upstream auth failures as ordinary text content, so the service attaches
|
| 135 |
+
// a read-only verification tool call and interprets its payload.
|
| 136 |
+
// -------------------------------------------------------------------------
|
| 137 |
+
|
| 138 |
+
const SLACK_SERVER: MCPServerConfig = {
|
| 139 |
+
id: "slack",
|
| 140 |
+
type: "stdio",
|
| 141 |
+
name: "slack",
|
| 142 |
+
command: "npx",
|
| 143 |
+
args: ["-y", "@zencoderai/slack-mcp-server"],
|
| 144 |
+
env: { SLACK_TEAM_ID: "T01", SLACK_BOT_TOKEN: "xoxb-abc" },
|
| 145 |
+
};
|
| 146 |
+
|
| 147 |
+
const slackToolResult = (text: string, isError = false) => ({
|
| 148 |
+
ok: true,
|
| 149 |
+
tools: ["slack_list_channels"],
|
| 150 |
+
tool_result: { is_error: isError, text },
|
| 151 |
+
});
|
| 152 |
+
|
| 153 |
+
it("attaches the read-only Slack verification tool call to the request", async () => {
|
| 154 |
+
mockTestServer.mockResolvedValue({ ok: true, tools: [] });
|
| 155 |
+
|
| 156 |
+
await McpService.testServer(SLACK_SERVER);
|
| 157 |
+
|
| 158 |
+
expect(mockTestServer).toHaveBeenCalledWith(
|
| 159 |
+
expect.objectContaining({
|
| 160 |
+
tool_call: { name: "slack_list_channels", arguments: { limit: 1 } },
|
| 161 |
+
}),
|
| 162 |
+
);
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
it("maps an in-band Slack auth error to a credentials failure", async () => {
|
| 166 |
+
mockTestServer.mockResolvedValue(
|
| 167 |
+
slackToolResult('{"ok":false,"error":"invalid_auth"}'),
|
| 168 |
+
);
|
| 169 |
+
|
| 170 |
+
const result = await McpService.testServer(SLACK_SERVER);
|
| 171 |
+
|
| 172 |
+
expect(result).toEqual({
|
| 173 |
+
ok: false,
|
| 174 |
+
error: "invalid_auth",
|
| 175 |
+
error_kind: "credentials",
|
| 176 |
+
});
|
| 177 |
+
});
|
| 178 |
+
|
| 179 |
+
it("does not flag non-auth Slack errors (missing_scope) as bad credentials", async () => {
|
| 180 |
+
// A valid token lacking a scope authenticated successfully — failing it
|
| 181 |
+
// would block correctly-configured installs.
|
| 182 |
+
const response = slackToolResult('{"ok":false,"error":"missing_scope"}');
|
| 183 |
+
mockTestServer.mockResolvedValue(response);
|
| 184 |
+
|
| 185 |
+
const result = await McpService.testServer(SLACK_SERVER);
|
| 186 |
+
|
| 187 |
+
expect(result).toEqual(response);
|
| 188 |
+
});
|
| 189 |
+
|
| 190 |
+
it("passes a succeeding Slack payload through unchanged", async () => {
|
| 191 |
+
const response = slackToolResult('{"ok":true,"channels":[]}');
|
| 192 |
+
mockTestServer.mockResolvedValue(response);
|
| 193 |
+
|
| 194 |
+
const result = await McpService.testServer(SLACK_SERVER);
|
| 195 |
+
|
| 196 |
+
expect(result).toEqual(response);
|
| 197 |
+
});
|
| 198 |
+
|
| 199 |
+
it("maps an errored verification call to a credentials failure", async () => {
|
| 200 |
+
mockTestServer.mockResolvedValue(
|
| 201 |
+
slackToolResult("Tool 'slack_list_channels' call timed out", true),
|
| 202 |
+
);
|
| 203 |
+
|
| 204 |
+
const result = await McpService.testServer(SLACK_SERVER);
|
| 205 |
+
|
| 206 |
+
expect(result).toEqual({
|
| 207 |
+
ok: false,
|
| 208 |
+
error: "Tool 'slack_list_channels' call timed out",
|
| 209 |
+
error_kind: "credentials",
|
| 210 |
+
});
|
| 211 |
+
});
|
| 212 |
+
|
| 213 |
+
it("returns the response unchanged when an older backend omits tool_result", async () => {
|
| 214 |
+
mockTestServer.mockResolvedValue({ ok: true, tools: ["a", "b"] });
|
| 215 |
+
|
| 216 |
+
const result = await McpService.testServer(SLACK_SERVER);
|
| 217 |
+
|
| 218 |
+
expect(result).toEqual({ ok: true, tools: ["a", "b"] });
|
| 219 |
+
});
|
| 220 |
+
|
| 221 |
+
it("skips credential interpretation when the probe tool is not advertised", async () => {
|
| 222 |
+
// A server variant that doesn't expose the probe tool (e.g. Slack's
|
| 223 |
+
// hosted MCP) returns a deterministic "not advertised" tool error; that
|
| 224 |
+
// proves nothing about credentials and must not block the install.
|
| 225 |
+
const response = {
|
| 226 |
+
ok: true,
|
| 227 |
+
tools: ["conversations_history"],
|
| 228 |
+
tool_result: {
|
| 229 |
+
is_error: true,
|
| 230 |
+
text: "Tool 'slack_list_channels' not advertised by server",
|
| 231 |
+
},
|
| 232 |
+
};
|
| 233 |
+
mockTestServer.mockResolvedValue(response);
|
| 234 |
+
|
| 235 |
+
const result = await McpService.testServer(SLACK_SERVER);
|
| 236 |
+
|
| 237 |
+
expect(result).toEqual(response);
|
| 238 |
+
});
|
| 239 |
+
|
| 240 |
+
// -------------------------------------------------------------------------
|
| 241 |
+
// Read-only credential probes for the hosted GitHub and Linear servers
|
| 242 |
+
// -------------------------------------------------------------------------
|
| 243 |
+
|
| 244 |
+
const GITHUB_SERVER: MCPServerConfig = {
|
| 245 |
+
id: "shttp-0",
|
| 246 |
+
type: "shttp",
|
| 247 |
+
name: "github",
|
| 248 |
+
url: "https://api.githubcopilot.com/mcp/",
|
| 249 |
+
auth: { strategy: "api_key", value: "github_pat_LIVETOKENVALUE" },
|
| 250 |
+
};
|
| 251 |
+
|
| 252 |
+
const LINEAR_SERVER: MCPServerConfig = {
|
| 253 |
+
id: "shttp-1",
|
| 254 |
+
type: "shttp",
|
| 255 |
+
name: "linear",
|
| 256 |
+
url: "https://mcp.linear.app/mcp",
|
| 257 |
+
auth: { strategy: "bearer", value: "lin_api_LIVETOKEN" },
|
| 258 |
+
};
|
| 259 |
+
|
| 260 |
+
it("attaches the read-only GitHub probe (get_me) to matching servers", async () => {
|
| 261 |
+
mockTestServer.mockResolvedValue({ ok: true, tools: [] });
|
| 262 |
+
|
| 263 |
+
await McpService.testServer(GITHUB_SERVER);
|
| 264 |
+
|
| 265 |
+
expect(mockTestServer).toHaveBeenCalledWith(
|
| 266 |
+
expect.objectContaining({
|
| 267 |
+
tool_call: { name: "get_me", arguments: {} },
|
| 268 |
+
}),
|
| 269 |
+
);
|
| 270 |
+
});
|
| 271 |
+
|
| 272 |
+
it("attaches the read-only Linear probe (list_teams) to matching servers", async () => {
|
| 273 |
+
mockTestServer.mockResolvedValue({ ok: true, tools: [] });
|
| 274 |
+
|
| 275 |
+
await McpService.testServer(LINEAR_SERVER);
|
| 276 |
+
|
| 277 |
+
expect(mockTestServer).toHaveBeenCalledWith(
|
| 278 |
+
expect.objectContaining({
|
| 279 |
+
tool_call: { name: "list_teams", arguments: {} },
|
| 280 |
+
}),
|
| 281 |
+
);
|
| 282 |
+
});
|
| 283 |
+
|
| 284 |
+
it("maps an errored GitHub probe call to a redacted credentials failure", async () => {
|
| 285 |
+
mockTestServer.mockResolvedValue({
|
| 286 |
+
ok: true,
|
| 287 |
+
tools: ["get_me"],
|
| 288 |
+
tool_result: {
|
| 289 |
+
is_error: true,
|
| 290 |
+
text: "401 for token github_pat_LIVETOKENVALUE",
|
| 291 |
+
},
|
| 292 |
+
});
|
| 293 |
+
|
| 294 |
+
const result = await McpService.testServer(GITHUB_SERVER);
|
| 295 |
+
|
| 296 |
+
// Interpretation runs on already-redacted text, so the surfaced
|
| 297 |
+
// credentials error never contains the configured secret.
|
| 298 |
+
expect(result).toEqual({
|
| 299 |
+
ok: false,
|
| 300 |
+
error: `401 for token ${REDACTED_MCP_SECRET_VALUE}`,
|
| 301 |
+
error_kind: "credentials",
|
| 302 |
+
});
|
| 303 |
+
});
|
| 304 |
+
|
| 305 |
+
it("redacts configured secrets from failure error text", async () => {
|
| 306 |
+
mockTestServer.mockResolvedValue({
|
| 307 |
+
ok: false,
|
| 308 |
+
error: "handshake rejected Bearer github_pat_LIVETOKENVALUE",
|
| 309 |
+
error_kind: "connection",
|
| 310 |
+
});
|
| 311 |
+
|
| 312 |
+
const result = await McpService.testServer(GITHUB_SERVER);
|
| 313 |
+
|
| 314 |
+
expect(result).toMatchObject({ ok: false, error_kind: "connection" });
|
| 315 |
+
expect(JSON.stringify(result)).not.toContain("github_pat_LIVETOKENVALUE");
|
| 316 |
+
});
|
| 317 |
+
|
| 318 |
+
// -------------------------------------------------------------------------
|
| 319 |
+
// Redacted-secret round-trip for the edit flow
|
| 320 |
+
//
|
| 321 |
+
// The MCP page reads settings with redacted secrets, so unchanged env
|
| 322 |
+
// values arrive as the literal redaction placeholder. The service swaps
|
| 323 |
+
// them for the stored values in encrypted form (decrypted server-side) so
|
| 324 |
+
// the test exercises the real credentials.
|
| 325 |
+
// -------------------------------------------------------------------------
|
| 326 |
+
|
| 327 |
+
const REDACTED_SLACK_SERVER: MCPServerConfig = {
|
| 328 |
+
...SLACK_SERVER,
|
| 329 |
+
env: { SLACK_TEAM_ID: "T01", SLACK_BOT_TOKEN: REDACTED_MCP_SECRET_VALUE },
|
| 330 |
+
};
|
| 331 |
+
|
| 332 |
+
it("substitutes redacted env values with encrypted stored values", async () => {
|
| 333 |
+
mockTestServer.mockResolvedValue({ ok: true, tools: [] });
|
| 334 |
+
vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({
|
| 335 |
+
agent_settings: {
|
| 336 |
+
mcp_config: {
|
| 337 |
+
slack: { env: { SLACK_BOT_TOKEN: "gAAAAA-encrypted-token" } },
|
| 338 |
+
},
|
| 339 |
+
},
|
| 340 |
+
} as unknown as SettingsApiResponse);
|
| 341 |
+
|
| 342 |
+
await McpService.testServer(REDACTED_SLACK_SERVER);
|
| 343 |
+
|
| 344 |
+
expect(SettingsService.fetchSettingsFromApi).toHaveBeenCalledWith(
|
| 345 |
+
"encrypted",
|
| 346 |
+
);
|
| 347 |
+
expect(mockTestServer).toHaveBeenCalledWith(
|
| 348 |
+
expect.objectContaining({
|
| 349 |
+
server: expect.objectContaining({
|
| 350 |
+
// Placeholder replaced by ciphertext; typed value left untouched.
|
| 351 |
+
env: {
|
| 352 |
+
SLACK_TEAM_ID: "T01",
|
| 353 |
+
SLACK_BOT_TOKEN: "gAAAAA-encrypted-token",
|
| 354 |
+
},
|
| 355 |
+
}),
|
| 356 |
+
}),
|
| 357 |
+
);
|
| 358 |
+
});
|
| 359 |
+
|
| 360 |
+
it("keeps the placeholder when encrypted settings cannot be fetched", async () => {
|
| 361 |
+
// e.g. HTTP 503 from a backend without a cipher — the test must still
|
| 362 |
+
// run (and fail the credential check honestly) instead of crashing.
|
| 363 |
+
mockTestServer.mockResolvedValue({ ok: true, tools: [] });
|
| 364 |
+
vi.spyOn(SettingsService, "fetchSettingsFromApi").mockRejectedValue(
|
| 365 |
+
new Error("503 no cipher"),
|
| 366 |
+
);
|
| 367 |
+
|
| 368 |
+
await McpService.testServer(REDACTED_SLACK_SERVER);
|
| 369 |
+
|
| 370 |
+
expect(mockTestServer).toHaveBeenCalledWith(
|
| 371 |
+
expect.objectContaining({
|
| 372 |
+
server: expect.objectContaining({
|
| 373 |
+
env: {
|
| 374 |
+
SLACK_TEAM_ID: "T01",
|
| 375 |
+
SLACK_BOT_TOKEN: REDACTED_MCP_SECRET_VALUE,
|
| 376 |
+
},
|
| 377 |
+
}),
|
| 378 |
+
}),
|
| 379 |
+
);
|
| 380 |
+
});
|
| 381 |
+
|
| 382 |
+
it("short-circuits with a synthetic ok response on cloud backends", async () => {
|
| 383 |
+
// Regression: when the active backend is cloud, the local agent-server's
|
| 384 |
+
// /api/mcp/test endpoint is not reachable. Previously, the helper threw
|
| 385 |
+
// `NoBackendAvailableError("No backend is configured.")` which surfaced
|
| 386 |
+
// in the install modal and blocked users from creating any MCP server
|
| 387 |
+
// (e.g. Slack) on a cloud session.
|
| 388 |
+
cloudActive();
|
| 389 |
+
|
| 390 |
+
const result = await McpService.testServer(SERVER);
|
| 391 |
+
|
| 392 |
+
expect(result).toEqual({ ok: true, tools: [] });
|
| 393 |
+
expect(mockTestServer).not.toHaveBeenCalled();
|
| 394 |
+
});
|
| 395 |
+
});
|
__tests__/api/runtime-service/agent-server-runtime-service.test.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { FileClient } from "@openhands/typescript-client/clients";
|
| 2 |
+
import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace";
|
| 3 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 4 |
+
import {
|
| 5 |
+
__resetActiveStoreForTests,
|
| 6 |
+
setActiveSelection,
|
| 7 |
+
setRegisteredBackends,
|
| 8 |
+
} from "#/api/backend-registry/active-store";
|
| 9 |
+
import AgentServerRuntimeService from "#/api/runtime-service/agent-server-runtime-service";
|
| 10 |
+
import { callCloudProxy } from "#/api/cloud/proxy";
|
| 11 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 12 |
+
|
| 13 |
+
// ─── SDK client mocks ───────────────────────────────────────────────────────
|
| 14 |
+
|
| 15 |
+
const { executeCommandMock, downloadFileMock } = vi.hoisted(() => ({
|
| 16 |
+
executeCommandMock: vi.fn(),
|
| 17 |
+
downloadFileMock: vi.fn(),
|
| 18 |
+
}));
|
| 19 |
+
|
| 20 |
+
vi.mock("@openhands/typescript-client/workspace/remote-workspace", () => ({
|
| 21 |
+
RemoteWorkspace: vi.fn(function RemoteWorkspaceMock() {
|
| 22 |
+
return { executeCommand: executeCommandMock };
|
| 23 |
+
}),
|
| 24 |
+
}));
|
| 25 |
+
|
| 26 |
+
vi.mock("@openhands/typescript-client/clients", () => ({
|
| 27 |
+
FileClient: vi.fn(function FileClientMock() {
|
| 28 |
+
return { downloadFile: downloadFileMock };
|
| 29 |
+
}),
|
| 30 |
+
}));
|
| 31 |
+
|
| 32 |
+
vi.mock("#/api/agent-server-client-options", () => ({
|
| 33 |
+
getAgentServerClientOptions: vi.fn(() => ({
|
| 34 |
+
host: "http://local-agent.example.com",
|
| 35 |
+
apiKey: "local-key",
|
| 36 |
+
workingDir: "/workspace/project",
|
| 37 |
+
})),
|
| 38 |
+
}));
|
| 39 |
+
|
| 40 |
+
vi.mock("#/api/cloud/proxy", () => ({
|
| 41 |
+
callCloudProxy: vi.fn(),
|
| 42 |
+
}));
|
| 43 |
+
|
| 44 |
+
// ─── Backend fixtures ────────────────────────────────────────────────────────
|
| 45 |
+
|
| 46 |
+
const cloudBackend: Backend = {
|
| 47 |
+
id: "cloud-1",
|
| 48 |
+
name: "Production",
|
| 49 |
+
host: "https://app.all-hands.dev",
|
| 50 |
+
apiKey: "cloud-api-key",
|
| 51 |
+
kind: "cloud",
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
const CLOUD_CONVERSATION_URL =
|
| 55 |
+
"https://runtime.example.com/api/conversations/conv-1";
|
| 56 |
+
const SESSION_KEY = "session-key-abc";
|
| 57 |
+
|
| 58 |
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
| 59 |
+
|
| 60 |
+
function activateCloud() {
|
| 61 |
+
setRegisteredBackends([cloudBackend]);
|
| 62 |
+
setActiveSelection({ backendId: cloudBackend.id, orgId: null });
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// ─── Setup ───────────────────────────────────────────────────────────────────
|
| 66 |
+
|
| 67 |
+
beforeEach(() => {
|
| 68 |
+
window.localStorage.clear();
|
| 69 |
+
__resetActiveStoreForTests();
|
| 70 |
+
vi.mocked(RemoteWorkspace).mockClear();
|
| 71 |
+
vi.mocked(FileClient).mockClear();
|
| 72 |
+
executeCommandMock.mockReset();
|
| 73 |
+
downloadFileMock.mockReset();
|
| 74 |
+
vi.mocked(callCloudProxy).mockReset();
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
afterEach(() => {
|
| 78 |
+
window.localStorage.clear();
|
| 79 |
+
__resetActiveStoreForTests();
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
// ─── executeCommand ──────────────────────────────────────────────────────────
|
| 83 |
+
|
| 84 |
+
describe("AgentServerRuntimeService.executeCommand", () => {
|
| 85 |
+
describe("local backend", () => {
|
| 86 |
+
it("creates RemoteWorkspace with resolved options and delegates", async () => {
|
| 87 |
+
executeCommandMock.mockResolvedValue({
|
| 88 |
+
exit_code: 0,
|
| 89 |
+
stdout: "main\n",
|
| 90 |
+
stderr: "",
|
| 91 |
+
});
|
| 92 |
+
|
| 93 |
+
const result = await AgentServerRuntimeService.executeCommand(
|
| 94 |
+
"http://local-agent.example.com/api/conversations/conv-1",
|
| 95 |
+
SESSION_KEY,
|
| 96 |
+
"git rev-parse --abbrev-ref HEAD",
|
| 97 |
+
"/workspace/project",
|
| 98 |
+
10,
|
| 99 |
+
);
|
| 100 |
+
|
| 101 |
+
expect(RemoteWorkspace).toHaveBeenCalledTimes(1);
|
| 102 |
+
expect(executeCommandMock).toHaveBeenCalledWith(
|
| 103 |
+
"git rev-parse --abbrev-ref HEAD",
|
| 104 |
+
"/workspace/project",
|
| 105 |
+
10,
|
| 106 |
+
);
|
| 107 |
+
expect(result).toEqual({ exit_code: 0, stdout: "main\n", stderr: "" });
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
it("does not call callCloudProxy for local backends", async () => {
|
| 111 |
+
executeCommandMock.mockResolvedValue({
|
| 112 |
+
exit_code: 0,
|
| 113 |
+
stdout: "",
|
| 114 |
+
stderr: "",
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
await AgentServerRuntimeService.executeCommand(null, null, "ls", "/", 5);
|
| 118 |
+
|
| 119 |
+
expect(callCloudProxy).not.toHaveBeenCalled();
|
| 120 |
+
});
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
+
describe("cloud backend", () => {
|
| 124 |
+
beforeEach(activateCloud);
|
| 125 |
+
|
| 126 |
+
it("routes through callCloudProxy with correct path, body, and auth", async () => {
|
| 127 |
+
vi.mocked(callCloudProxy).mockResolvedValue({
|
| 128 |
+
exit_code: 0,
|
| 129 |
+
stdout: "src/index.ts\n",
|
| 130 |
+
stderr: "",
|
| 131 |
+
});
|
| 132 |
+
|
| 133 |
+
const result = await AgentServerRuntimeService.executeCommand(
|
| 134 |
+
CLOUD_CONVERSATION_URL,
|
| 135 |
+
SESSION_KEY,
|
| 136 |
+
"find . -type f",
|
| 137 |
+
"/workspace/project",
|
| 138 |
+
30,
|
| 139 |
+
);
|
| 140 |
+
|
| 141 |
+
const proxyCall = vi.mocked(callCloudProxy).mock.calls[0][0];
|
| 142 |
+
expect(proxyCall.method).toBe("POST");
|
| 143 |
+
expect(proxyCall.path).toBe("/api/bash/execute_bash_command");
|
| 144 |
+
expect(proxyCall.hostOverride).toBe("https://runtime.example.com");
|
| 145 |
+
expect(proxyCall.body).toEqual({
|
| 146 |
+
command: "find . -type f",
|
| 147 |
+
cwd: "/workspace/project",
|
| 148 |
+
timeout: 30,
|
| 149 |
+
});
|
| 150 |
+
expect(proxyCall.authMode).toBe("session-api-key");
|
| 151 |
+
expect(proxyCall.sessionApiKey).toBe(SESSION_KEY);
|
| 152 |
+
expect(proxyCall.timeoutSeconds).toBe(40);
|
| 153 |
+
expect(result).toEqual({
|
| 154 |
+
exit_code: 0,
|
| 155 |
+
stdout: "src/index.ts\n",
|
| 156 |
+
stderr: "",
|
| 157 |
+
});
|
| 158 |
+
});
|
| 159 |
+
|
| 160 |
+
it("omits cwd from proxy body when not provided", async () => {
|
| 161 |
+
vi.mocked(callCloudProxy).mockResolvedValue({ exit_code: 0 });
|
| 162 |
+
|
| 163 |
+
await AgentServerRuntimeService.executeCommand(
|
| 164 |
+
CLOUD_CONVERSATION_URL,
|
| 165 |
+
SESSION_KEY,
|
| 166 |
+
"echo hi",
|
| 167 |
+
undefined,
|
| 168 |
+
10,
|
| 169 |
+
);
|
| 170 |
+
|
| 171 |
+
const proxyBody = vi.mocked(callCloudProxy).mock.calls[0][0].body as Record<string, unknown>;
|
| 172 |
+
expect(proxyBody).not.toHaveProperty("cwd");
|
| 173 |
+
expect(proxyBody.command).toBe("echo hi");
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
it("normalises missing stdout/stderr fields to empty strings", async () => {
|
| 177 |
+
vi.mocked(callCloudProxy).mockResolvedValue({ exit_code: 1 });
|
| 178 |
+
|
| 179 |
+
const result = await AgentServerRuntimeService.executeCommand(
|
| 180 |
+
CLOUD_CONVERSATION_URL,
|
| 181 |
+
SESSION_KEY,
|
| 182 |
+
"false",
|
| 183 |
+
undefined,
|
| 184 |
+
5,
|
| 185 |
+
);
|
| 186 |
+
|
| 187 |
+
expect(result).toEqual({ exit_code: 1, stdout: "", stderr: "" });
|
| 188 |
+
});
|
| 189 |
+
|
| 190 |
+
it("does not create a RemoteWorkspace for cloud calls", async () => {
|
| 191 |
+
vi.mocked(callCloudProxy).mockResolvedValue({ exit_code: 0 });
|
| 192 |
+
|
| 193 |
+
await AgentServerRuntimeService.executeCommand(
|
| 194 |
+
CLOUD_CONVERSATION_URL,
|
| 195 |
+
SESSION_KEY,
|
| 196 |
+
"echo ok",
|
| 197 |
+
);
|
| 198 |
+
|
| 199 |
+
expect(RemoteWorkspace).not.toHaveBeenCalled();
|
| 200 |
+
});
|
| 201 |
+
|
| 202 |
+
it("falls back to local path when conversationUrl is null", async () => {
|
| 203 |
+
executeCommandMock.mockResolvedValue({
|
| 204 |
+
exit_code: 0,
|
| 205 |
+
stdout: "",
|
| 206 |
+
stderr: "",
|
| 207 |
+
});
|
| 208 |
+
|
| 209 |
+
await AgentServerRuntimeService.executeCommand(
|
| 210 |
+
null,
|
| 211 |
+
SESSION_KEY,
|
| 212 |
+
"echo ok",
|
| 213 |
+
);
|
| 214 |
+
|
| 215 |
+
expect(callCloudProxy).not.toHaveBeenCalled();
|
| 216 |
+
expect(RemoteWorkspace).toHaveBeenCalledTimes(1);
|
| 217 |
+
});
|
| 218 |
+
});
|
| 219 |
+
});
|
| 220 |
+
|
| 221 |
+
// ─── downloadFile ─────────────────────────────────────────────────────────────
|
| 222 |
+
|
| 223 |
+
describe("AgentServerRuntimeService.downloadFile", () => {
|
| 224 |
+
describe("local backend", () => {
|
| 225 |
+
it("creates FileClient with resolved options and returns the ArrayBuffer", async () => {
|
| 226 |
+
const fileBytes = new TextEncoder().encode("# README");
|
| 227 |
+
downloadFileMock.mockResolvedValue(fileBytes.buffer);
|
| 228 |
+
|
| 229 |
+
const result = await AgentServerRuntimeService.downloadFile(
|
| 230 |
+
"http://local-agent.example.com/api/conversations/conv-1",
|
| 231 |
+
SESSION_KEY,
|
| 232 |
+
"/workspace/project/README.md",
|
| 233 |
+
);
|
| 234 |
+
|
| 235 |
+
expect(FileClient).toHaveBeenCalledTimes(1);
|
| 236 |
+
expect(downloadFileMock).toHaveBeenCalledWith(
|
| 237 |
+
"/workspace/project/README.md",
|
| 238 |
+
);
|
| 239 |
+
expect(result).toBe(fileBytes.buffer);
|
| 240 |
+
});
|
| 241 |
+
|
| 242 |
+
it("does not call callCloudProxy for local backends", async () => {
|
| 243 |
+
downloadFileMock.mockResolvedValue(new ArrayBuffer(0));
|
| 244 |
+
|
| 245 |
+
await AgentServerRuntimeService.downloadFile(
|
| 246 |
+
null,
|
| 247 |
+
null,
|
| 248 |
+
"/workspace/file.txt",
|
| 249 |
+
);
|
| 250 |
+
|
| 251 |
+
expect(callCloudProxy).not.toHaveBeenCalled();
|
| 252 |
+
});
|
| 253 |
+
});
|
| 254 |
+
|
| 255 |
+
describe("cloud backend", () => {
|
| 256 |
+
beforeEach(activateCloud);
|
| 257 |
+
|
| 258 |
+
it("routes through callCloudProxy with GET and URL-encoded path", async () => {
|
| 259 |
+
const blob = new Blob([new TextEncoder().encode("file content")]);
|
| 260 |
+
vi.mocked(callCloudProxy).mockResolvedValue(blob);
|
| 261 |
+
|
| 262 |
+
const result = await AgentServerRuntimeService.downloadFile(
|
| 263 |
+
CLOUD_CONVERSATION_URL,
|
| 264 |
+
SESSION_KEY,
|
| 265 |
+
"/workspace/project/src/main.ts",
|
| 266 |
+
);
|
| 267 |
+
|
| 268 |
+
const proxyCall = vi.mocked(callCloudProxy).mock.calls[0][0];
|
| 269 |
+
expect(proxyCall.method).toBe("GET");
|
| 270 |
+
expect(proxyCall.path).toBe(
|
| 271 |
+
"/api/file/download?path=%2Fworkspace%2Fproject%2Fsrc%2Fmain.ts",
|
| 272 |
+
);
|
| 273 |
+
expect(proxyCall.hostOverride).toBe("https://runtime.example.com");
|
| 274 |
+
expect(proxyCall.authMode).toBe("session-api-key");
|
| 275 |
+
expect(proxyCall.sessionApiKey).toBe(SESSION_KEY);
|
| 276 |
+
expect(proxyCall.responseType).toBe("blob");
|
| 277 |
+
|
| 278 |
+
// Blob.arrayBuffer() round-trip: decoded text should match the original.
|
| 279 |
+
expect(new TextDecoder().decode(result)).toBe("file content");
|
| 280 |
+
});
|
| 281 |
+
|
| 282 |
+
it("does not create a FileClient for cloud calls", async () => {
|
| 283 |
+
vi.mocked(callCloudProxy).mockResolvedValue(new Blob());
|
| 284 |
+
|
| 285 |
+
await AgentServerRuntimeService.downloadFile(
|
| 286 |
+
CLOUD_CONVERSATION_URL,
|
| 287 |
+
SESSION_KEY,
|
| 288 |
+
"/workspace/file.txt",
|
| 289 |
+
);
|
| 290 |
+
|
| 291 |
+
expect(FileClient).not.toHaveBeenCalled();
|
| 292 |
+
});
|
| 293 |
+
|
| 294 |
+
it("falls back to local path when conversationUrl is null", async () => {
|
| 295 |
+
downloadFileMock.mockResolvedValue(new ArrayBuffer(0));
|
| 296 |
+
|
| 297 |
+
await AgentServerRuntimeService.downloadFile(
|
| 298 |
+
null,
|
| 299 |
+
SESSION_KEY,
|
| 300 |
+
"/workspace/file.txt",
|
| 301 |
+
);
|
| 302 |
+
|
| 303 |
+
expect(callCloudProxy).not.toHaveBeenCalled();
|
| 304 |
+
expect(FileClient).toHaveBeenCalledTimes(1);
|
| 305 |
+
});
|
| 306 |
+
});
|
| 307 |
+
});
|
__tests__/components/analytics/telemetry-consent-banner.test.tsx
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { render, screen, waitFor } from "@testing-library/react";
|
| 2 |
+
import userEvent from "@testing-library/user-event";
|
| 3 |
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
| 4 |
+
import { TelemetryConsentBanner } from "#/components/features/analytics/telemetry-consent-banner";
|
| 5 |
+
|
| 6 |
+
const useActiveBackendMock = vi.fn();
|
| 7 |
+
const useSettingsMock = vi.fn();
|
| 8 |
+
const useBackendsHealthMock = vi.fn();
|
| 9 |
+
const saveSettingsMock = vi.fn();
|
| 10 |
+
const getLockedCloudHostMock = vi.fn();
|
| 11 |
+
const setTelemetryConsentMock = vi.fn();
|
| 12 |
+
|
| 13 |
+
vi.mock("react-i18next", () => ({
|
| 14 |
+
useTranslation: () => ({
|
| 15 |
+
ready: true,
|
| 16 |
+
t: (key: string, options?: Record<string, string>) =>
|
| 17 |
+
key === "TELEMETRY$BACKEND_SCOPE"
|
| 18 |
+
? `This preference is saved for ${options?.name} at ${options?.host}.`
|
| 19 |
+
: key,
|
| 20 |
+
}),
|
| 21 |
+
}));
|
| 22 |
+
|
| 23 |
+
vi.mock("#/i18n", () => ({
|
| 24 |
+
OPENHANDS_I18N_NAMESPACE: "openhands",
|
| 25 |
+
}));
|
| 26 |
+
|
| 27 |
+
vi.mock("#/api/agent-server-config", () => ({
|
| 28 |
+
getLockedCloudHost: () => getLockedCloudHostMock(),
|
| 29 |
+
}));
|
| 30 |
+
|
| 31 |
+
vi.mock("#/contexts/active-backend-context", () => ({
|
| 32 |
+
useActiveBackend: () => useActiveBackendMock(),
|
| 33 |
+
}));
|
| 34 |
+
|
| 35 |
+
vi.mock("#/hooks/query/use-settings", () => ({
|
| 36 |
+
useSettings: () => useSettingsMock(),
|
| 37 |
+
}));
|
| 38 |
+
|
| 39 |
+
vi.mock("#/hooks/query/use-backends-health", () => ({
|
| 40 |
+
useBackendsHealth: (...args: unknown[]) => useBackendsHealthMock(...args),
|
| 41 |
+
}));
|
| 42 |
+
|
| 43 |
+
vi.mock("#/hooks/mutation/use-save-settings", () => ({
|
| 44 |
+
useSaveSettings: () => ({
|
| 45 |
+
mutateAsync: saveSettingsMock,
|
| 46 |
+
isPending: false,
|
| 47 |
+
}),
|
| 48 |
+
}));
|
| 49 |
+
|
| 50 |
+
vi.mock("#/services/telemetry", () => ({
|
| 51 |
+
setTelemetryConsent: (...args: unknown[]) => setTelemetryConsentMock(...args),
|
| 52 |
+
}));
|
| 53 |
+
|
| 54 |
+
describe("TelemetryConsentBanner", () => {
|
| 55 |
+
beforeEach(() => {
|
| 56 |
+
vi.clearAllMocks();
|
| 57 |
+
saveSettingsMock.mockResolvedValue(undefined);
|
| 58 |
+
getLockedCloudHostMock.mockReturnValue(null);
|
| 59 |
+
useActiveBackendMock.mockReturnValue({
|
| 60 |
+
backend: {
|
| 61 |
+
id: "local",
|
| 62 |
+
kind: "local",
|
| 63 |
+
name: "Local Dev",
|
| 64 |
+
host: "http://localhost:12000",
|
| 65 |
+
},
|
| 66 |
+
});
|
| 67 |
+
useBackendsHealthMock.mockReturnValue({
|
| 68 |
+
local: { isConnected: true },
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
useSettingsMock.mockReturnValue({
|
| 72 |
+
data: { user_consents_to_analytics: null },
|
| 73 |
+
isSuccess: true,
|
| 74 |
+
});
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
it("renders in local mode when agent-server consent is null", async () => {
|
| 78 |
+
render(<TelemetryConsentBanner />);
|
| 79 |
+
|
| 80 |
+
expect(
|
| 81 |
+
await screen.findByTestId("telemetry-consent-form"),
|
| 82 |
+
).toBeInTheDocument();
|
| 83 |
+
expect(
|
| 84 |
+
screen.getByText(
|
| 85 |
+
"This preference is saved for Local Dev at http://localhost:12000.",
|
| 86 |
+
),
|
| 87 |
+
).toBeInTheDocument();
|
| 88 |
+
});
|
| 89 |
+
|
| 90 |
+
it("does not render until the local backend settings are connected", async () => {
|
| 91 |
+
useSettingsMock.mockReturnValue({
|
| 92 |
+
data: { user_consents_to_analytics: null },
|
| 93 |
+
isSuccess: false,
|
| 94 |
+
});
|
| 95 |
+
|
| 96 |
+
render(<TelemetryConsentBanner />);
|
| 97 |
+
|
| 98 |
+
await waitFor(() => {
|
| 99 |
+
expect(
|
| 100 |
+
screen.queryByTestId("telemetry-consent-form"),
|
| 101 |
+
).not.toBeInTheDocument();
|
| 102 |
+
});
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
it("does not render when the active local backend is unhealthy", async () => {
|
| 106 |
+
useBackendsHealthMock.mockReturnValue({
|
| 107 |
+
local: { isConnected: false },
|
| 108 |
+
});
|
| 109 |
+
useSettingsMock.mockReturnValue({
|
| 110 |
+
data: { user_consents_to_analytics: null },
|
| 111 |
+
isSuccess: true,
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
+
render(<TelemetryConsentBanner />);
|
| 115 |
+
|
| 116 |
+
await waitFor(() => {
|
| 117 |
+
expect(
|
| 118 |
+
screen.queryByTestId("telemetry-consent-form"),
|
| 119 |
+
).not.toBeInTheDocument();
|
| 120 |
+
});
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
+
it.each([true, false])(
|
| 124 |
+
"does not render when agent-server consent is %s",
|
| 125 |
+
async (serverConsent) => {
|
| 126 |
+
useSettingsMock.mockReturnValue({
|
| 127 |
+
data: { user_consents_to_analytics: serverConsent },
|
| 128 |
+
isSuccess: true,
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
render(<TelemetryConsentBanner />);
|
| 132 |
+
|
| 133 |
+
await waitFor(() => {
|
| 134 |
+
expect(
|
| 135 |
+
screen.queryByTestId("telemetry-consent-form"),
|
| 136 |
+
).not.toBeInTheDocument();
|
| 137 |
+
});
|
| 138 |
+
},
|
| 139 |
+
);
|
| 140 |
+
|
| 141 |
+
it("does not render for cloud backends", async () => {
|
| 142 |
+
useActiveBackendMock.mockReturnValue({
|
| 143 |
+
backend: { id: "cloud", kind: "cloud" },
|
| 144 |
+
});
|
| 145 |
+
|
| 146 |
+
render(<TelemetryConsentBanner />);
|
| 147 |
+
|
| 148 |
+
await waitFor(() => {
|
| 149 |
+
expect(
|
| 150 |
+
screen.queryByTestId("telemetry-consent-form"),
|
| 151 |
+
).not.toBeInTheDocument();
|
| 152 |
+
});
|
| 153 |
+
});
|
| 154 |
+
|
| 155 |
+
it("does not render in locked Cloud mode", async () => {
|
| 156 |
+
getLockedCloudHostMock.mockReturnValue("https://app.all-hands.dev");
|
| 157 |
+
|
| 158 |
+
render(<TelemetryConsentBanner />);
|
| 159 |
+
|
| 160 |
+
await waitFor(() => {
|
| 161 |
+
expect(
|
| 162 |
+
screen.queryByTestId("telemetry-consent-form"),
|
| 163 |
+
).not.toBeInTheDocument();
|
| 164 |
+
});
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
it("persists the user's local-mode consent choice without useTelemetry", async () => {
|
| 168 |
+
const user = userEvent.setup();
|
| 169 |
+
render(<TelemetryConsentBanner />);
|
| 170 |
+
|
| 171 |
+
const checkbox = await screen.findByRole("checkbox");
|
| 172 |
+
await user.click(checkbox);
|
| 173 |
+
await user.click(screen.getByTestId("confirm-telemetry-preferences"));
|
| 174 |
+
|
| 175 |
+
expect(setTelemetryConsentMock).toHaveBeenCalledWith("denied");
|
| 176 |
+
expect(saveSettingsMock).toHaveBeenCalledWith({
|
| 177 |
+
user_consents_to_analytics: false,
|
| 178 |
+
});
|
| 179 |
+
});
|
| 180 |
+
|
| 181 |
+
it("does not persist browser consent when the backend save fails", async () => {
|
| 182 |
+
const user = userEvent.setup();
|
| 183 |
+
saveSettingsMock.mockRejectedValue(new Error("unauthorized"));
|
| 184 |
+
render(<TelemetryConsentBanner />);
|
| 185 |
+
|
| 186 |
+
await screen.findByTestId("telemetry-consent-form");
|
| 187 |
+
await user.click(screen.getByTestId("confirm-telemetry-preferences"));
|
| 188 |
+
|
| 189 |
+
await waitFor(() => {
|
| 190 |
+
expect(saveSettingsMock).toHaveBeenCalledWith({
|
| 191 |
+
user_consents_to_analytics: true,
|
| 192 |
+
});
|
| 193 |
+
});
|
| 194 |
+
expect(setTelemetryConsentMock).not.toHaveBeenCalled();
|
| 195 |
+
expect(screen.getByTestId("telemetry-consent-form")).toBeInTheDocument();
|
| 196 |
+
});
|
| 197 |
+
|
| 198 |
+
});
|
__tests__/components/automations/add-automation-modal.test.tsx
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { render, screen } from "@testing-library/react";
|
| 3 |
+
import userEvent from "@testing-library/user-event";
|
| 4 |
+
import { describe, expect, it, vi } from "vitest";
|
| 5 |
+
import {
|
| 6 |
+
NavigationProvider,
|
| 7 |
+
type NavigationContextValue,
|
| 8 |
+
} from "#/context/navigation-context";
|
| 9 |
+
import { AddAutomationModal } from "#/components/features/automations/add-automation-modal";
|
| 10 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 11 |
+
|
| 12 |
+
vi.mock("#/hooks/query/use-settings", () => ({
|
| 13 |
+
useSettings: () => ({ data: { user_consents_to_analytics: true } }),
|
| 14 |
+
}));
|
| 15 |
+
|
| 16 |
+
vi.mock("react-i18next", () => ({
|
| 17 |
+
useTranslation: () => ({
|
| 18 |
+
t: (key: string) => key,
|
| 19 |
+
}),
|
| 20 |
+
Trans: ({
|
| 21 |
+
i18nKey,
|
| 22 |
+
components,
|
| 23 |
+
children,
|
| 24 |
+
}: {
|
| 25 |
+
i18nKey: string;
|
| 26 |
+
components?: Record<string, React.ReactElement>;
|
| 27 |
+
children?: React.ReactNode;
|
| 28 |
+
}) => {
|
| 29 |
+
if (i18nKey !== I18nKey.AUTOMATIONS$EMPTY_OPTION_CONVERSATION_DESC) {
|
| 30 |
+
return children ?? i18nKey;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
return (
|
| 34 |
+
<>
|
| 35 |
+
Start a new conversation and tell OpenHands to{" "}
|
| 36 |
+
{components?.example
|
| 37 |
+
? React.cloneElement(
|
| 38 |
+
components.example,
|
| 39 |
+
{},
|
| 40 |
+
<>
|
| 41 |
+
{components.cmd
|
| 42 |
+
? React.cloneElement(
|
| 43 |
+
components.cmd,
|
| 44 |
+
{},
|
| 45 |
+
"Create an automation",
|
| 46 |
+
)
|
| 47 |
+
: null}
|
| 48 |
+
{components.punct
|
| 49 |
+
? React.cloneElement(components.punct, {}, ".")
|
| 50 |
+
: null}
|
| 51 |
+
</>,
|
| 52 |
+
)
|
| 53 |
+
: null}
|
| 54 |
+
</>
|
| 55 |
+
);
|
| 56 |
+
},
|
| 57 |
+
}));
|
| 58 |
+
|
| 59 |
+
function renderModal(isOpen = true) {
|
| 60 |
+
const onClose = vi.fn();
|
| 61 |
+
const navigation: NavigationContextValue = {
|
| 62 |
+
currentPath: "/automations",
|
| 63 |
+
conversationId: null,
|
| 64 |
+
isNavigating: false,
|
| 65 |
+
navigate: vi.fn(),
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
render(
|
| 69 |
+
<NavigationProvider value={navigation}>
|
| 70 |
+
<AddAutomationModal isOpen={isOpen} onClose={onClose} />
|
| 71 |
+
</NavigationProvider>,
|
| 72 |
+
);
|
| 73 |
+
|
| 74 |
+
return { onClose };
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
describe("AddAutomationModal", () => {
|
| 78 |
+
it("renders the create instructions content when open", () => {
|
| 79 |
+
renderModal();
|
| 80 |
+
|
| 81 |
+
expect(screen.getByTestId("add-automation-modal")).toBeInTheDocument();
|
| 82 |
+
expect(
|
| 83 |
+
screen.getByTestId("automations-create-instructions-example"),
|
| 84 |
+
).toHaveTextContent("Create an automation");
|
| 85 |
+
expect(
|
| 86 |
+
screen.getByTestId("automations-create-automation"),
|
| 87 |
+
).toHaveTextContent(I18nKey.AUTOMATIONS$CREATE_AUTOMATION_BUTTON);
|
| 88 |
+
expect(
|
| 89 |
+
screen.queryByText(I18nKey.AUTOMATIONS$EMPTY_OPTION_PLUGIN_TITLE),
|
| 90 |
+
).not.toBeInTheDocument();
|
| 91 |
+
});
|
| 92 |
+
|
| 93 |
+
it("does not render when closed", () => {
|
| 94 |
+
renderModal(false);
|
| 95 |
+
|
| 96 |
+
expect(screen.queryByTestId("add-automation-modal")).not.toBeInTheDocument();
|
| 97 |
+
});
|
| 98 |
+
|
| 99 |
+
it("calls onClose when the close button is clicked", async () => {
|
| 100 |
+
const user = userEvent.setup();
|
| 101 |
+
const { onClose } = renderModal();
|
| 102 |
+
|
| 103 |
+
await user.click(screen.getByTestId("add-automation-modal-close"));
|
| 104 |
+
|
| 105 |
+
expect(onClose).toHaveBeenCalledTimes(1);
|
| 106 |
+
});
|
| 107 |
+
});
|
__tests__/components/automations/automation-card.test.tsx
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { render, screen } from "@testing-library/react";
|
| 2 |
+
import userEvent from "@testing-library/user-event";
|
| 3 |
+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
| 4 |
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
| 5 |
+
import { http, HttpResponse } from "msw";
|
| 6 |
+
import { AutomationCard } from "#/components/features/automations/automation-card";
|
| 7 |
+
import {
|
| 8 |
+
AutomationRunStatus,
|
| 9 |
+
type Automation,
|
| 10 |
+
type AutomationRun,
|
| 11 |
+
} from "#/types/automation";
|
| 12 |
+
import { useAutomationRunSummaries } from "#/hooks/query/use-automation-run-summaries";
|
| 13 |
+
import {
|
| 14 |
+
__resetActiveStoreForTests,
|
| 15 |
+
setActiveSelection,
|
| 16 |
+
setRegisteredBackends,
|
| 17 |
+
} from "#/api/backend-registry/active-store";
|
| 18 |
+
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
|
| 19 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 20 |
+
import { server } from "#/mocks/node";
|
| 21 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 22 |
+
import type { InterfaceListInsights } from "#/manifests/types";
|
| 23 |
+
|
| 24 |
+
vi.mock("react-i18next", () => ({
|
| 25 |
+
useTranslation: () => ({
|
| 26 |
+
t: (key: string) => key,
|
| 27 |
+
i18n: { language: "en" },
|
| 28 |
+
}),
|
| 29 |
+
}));
|
| 30 |
+
|
| 31 |
+
vi.mock("#/context/navigation-context", () => ({
|
| 32 |
+
useNavigation: () => ({ navigate: vi.fn(), currentPath: "/" }),
|
| 33 |
+
}));
|
| 34 |
+
|
| 35 |
+
vi.mock("#/hooks/use-automation-permissions", () => ({
|
| 36 |
+
useAutomationPermissions: () => ({
|
| 37 |
+
canView: true,
|
| 38 |
+
canManage: true,
|
| 39 |
+
isLoading: false,
|
| 40 |
+
}),
|
| 41 |
+
useIsAutomationOwner: () => true,
|
| 42 |
+
}));
|
| 43 |
+
|
| 44 |
+
// The pinned package predates the `impact` field, so an entry carrying one is
|
| 45 |
+
// appended to the real catalog.
|
| 46 |
+
vi.mock("@openhands/extensions/automations", async (importOriginal) => {
|
| 47 |
+
const actual =
|
| 48 |
+
await importOriginal<typeof import("@openhands/extensions/automations")>();
|
| 49 |
+
return {
|
| 50 |
+
...actual,
|
| 51 |
+
AUTOMATION_CATALOG: [
|
| 52 |
+
...actual.AUTOMATION_CATALOG,
|
| 53 |
+
{
|
| 54 |
+
id: "widget-checker",
|
| 55 |
+
impact: {
|
| 56 |
+
basis: "completed-runs",
|
| 57 |
+
one: "1 widget check completed",
|
| 58 |
+
other: "{{count}} widget checks completed",
|
| 59 |
+
},
|
| 60 |
+
},
|
| 61 |
+
] as typeof actual.AUTOMATION_CATALOG,
|
| 62 |
+
};
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
const automation: Automation = {
|
| 66 |
+
id: "automation-1",
|
| 67 |
+
name: "Async Standup Digest",
|
| 68 |
+
prompt: "Generate an async standup digest from Slack activity.",
|
| 69 |
+
enabled: true,
|
| 70 |
+
trigger: { type: "cron", schedule_human: "Mondays at 09:00" },
|
| 71 |
+
created_at: "2026-01-01T00:00:00Z",
|
| 72 |
+
updated_at: "2026-01-01T00:00:00Z",
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
const insightsSpec = {
|
| 76 |
+
health: {
|
| 77 |
+
healthy: "Healthy",
|
| 78 |
+
failing: "Failing",
|
| 79 |
+
running: "Running",
|
| 80 |
+
disabled: "Disabled",
|
| 81 |
+
neverRun: "Never run",
|
| 82 |
+
checking: "Checking",
|
| 83 |
+
},
|
| 84 |
+
lastRun: { label: "Last run", never: "Never", justNow: "Just now" },
|
| 85 |
+
stats: { runs: "Runs", recentSuccess: "Success", averageDuration: "Avg" },
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
function createRun(overrides: Partial<AutomationRun> = {}): AutomationRun {
|
| 89 |
+
return {
|
| 90 |
+
id: "run-1",
|
| 91 |
+
status: AutomationRunStatus.COMPLETED,
|
| 92 |
+
conversation_id: null,
|
| 93 |
+
bash_command_id: null,
|
| 94 |
+
error_detail: null,
|
| 95 |
+
started_at: "2026-01-02T00:00:00Z",
|
| 96 |
+
completed_at: "2026-01-02T00:02:00Z",
|
| 97 |
+
...overrides,
|
| 98 |
+
};
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
describe("AutomationCard", () => {
|
| 102 |
+
it("uses the shared extension module interactive class without a resting border", () => {
|
| 103 |
+
render(
|
| 104 |
+
<AutomationCard
|
| 105 |
+
automation={automation}
|
| 106 |
+
onToggle={vi.fn()}
|
| 107 |
+
onRunNow={vi.fn()}
|
| 108 |
+
onExport={vi.fn()}
|
| 109 |
+
onDelete={vi.fn()}
|
| 110 |
+
/>,
|
| 111 |
+
);
|
| 112 |
+
|
| 113 |
+
const card = screen.getByTestId("automation-card-automation-1");
|
| 114 |
+
expect(card.className).toContain("extension-module-card-interactive");
|
| 115 |
+
expect(card.className).toContain("bg-base-secondary");
|
| 116 |
+
expect(card.className).not.toContain("border-[var(--oh-border)]");
|
| 117 |
+
});
|
| 118 |
+
|
| 119 |
+
it("renders title, description, and overflow pills", () => {
|
| 120 |
+
render(
|
| 121 |
+
<AutomationCard
|
| 122 |
+
automation={automation}
|
| 123 |
+
onToggle={vi.fn()}
|
| 124 |
+
onRunNow={vi.fn()}
|
| 125 |
+
onExport={vi.fn()}
|
| 126 |
+
onDelete={vi.fn()}
|
| 127 |
+
/>,
|
| 128 |
+
);
|
| 129 |
+
|
| 130 |
+
expect(screen.getByText("Async Standup Digest")).toBeInTheDocument();
|
| 131 |
+
expect(
|
| 132 |
+
screen.getByText("Generate an async standup digest from Slack activity."),
|
| 133 |
+
).toBeInTheDocument();
|
| 134 |
+
expect(screen.getByText("Mondays at 09:00")).toBeInTheDocument();
|
| 135 |
+
expect(
|
| 136 |
+
screen.getByTestId("automation-pills-automation-1"),
|
| 137 |
+
).toBeInTheDocument();
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
it("renders a play run button and menu actions instead of a toggle switch", async () => {
|
| 141 |
+
const user = userEvent.setup();
|
| 142 |
+
|
| 143 |
+
render(
|
| 144 |
+
<AutomationCard
|
| 145 |
+
automation={automation}
|
| 146 |
+
onToggle={vi.fn()}
|
| 147 |
+
onRunNow={vi.fn()}
|
| 148 |
+
onExport={vi.fn()}
|
| 149 |
+
onDelete={vi.fn()}
|
| 150 |
+
/>,
|
| 151 |
+
);
|
| 152 |
+
|
| 153 |
+
expect(
|
| 154 |
+
screen.getByTestId("automation-run-now-automation-1"),
|
| 155 |
+
).toHaveAttribute("aria-label", "AUTOMATIONS$RUN_NOW");
|
| 156 |
+
expect(screen.getByTestId("automation-run-now-automation-1")).toHaveClass(
|
| 157 |
+
"size-8",
|
| 158 |
+
);
|
| 159 |
+
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
|
| 160 |
+
|
| 161 |
+
await user.click(
|
| 162 |
+
screen.getByRole("button", { name: "AUTOMATIONS$ACTIONS_MENU" }),
|
| 163 |
+
);
|
| 164 |
+
|
| 165 |
+
expect(screen.getByText("COMMON$VIEW")).toBeInTheDocument();
|
| 166 |
+
expect(screen.getByText("AUTOMATIONS$RUN_NOW")).toBeInTheDocument();
|
| 167 |
+
});
|
| 168 |
+
|
| 169 |
+
it("shows a status strip and sparkline when insights are present", () => {
|
| 170 |
+
const latestRun = createRun({
|
| 171 |
+
started_at: new Date(Date.now() - 10 * 60_000).toISOString(),
|
| 172 |
+
completed_at: new Date(Date.now() - 8 * 60_000).toISOString(),
|
| 173 |
+
});
|
| 174 |
+
|
| 175 |
+
render(
|
| 176 |
+
<AutomationCard
|
| 177 |
+
automation={automation}
|
| 178 |
+
onToggle={vi.fn()}
|
| 179 |
+
onRunNow={vi.fn()}
|
| 180 |
+
onExport={vi.fn()}
|
| 181 |
+
onDelete={vi.fn()}
|
| 182 |
+
insights={{
|
| 183 |
+
spec: insightsSpec satisfies InterfaceListInsights,
|
| 184 |
+
state: {
|
| 185 |
+
summary: {
|
| 186 |
+
total: 4,
|
| 187 |
+
completedTotal: 4,
|
| 188 |
+
latestRun,
|
| 189 |
+
recentRuns: [latestRun],
|
| 190 |
+
recentSuccessRate: 1,
|
| 191 |
+
averageDurationMs: 120_000,
|
| 192 |
+
},
|
| 193 |
+
isLoading: false,
|
| 194 |
+
isError: false,
|
| 195 |
+
},
|
| 196 |
+
}}
|
| 197 |
+
/>,
|
| 198 |
+
);
|
| 199 |
+
|
| 200 |
+
expect(
|
| 201 |
+
screen.queryByTestId("automation-health-badge"),
|
| 202 |
+
).not.toBeInTheDocument();
|
| 203 |
+
expect(
|
| 204 |
+
screen.getByTestId("automation-last-run-automation-1"),
|
| 205 |
+
).toHaveTextContent("AUTOMATIONS$DETAIL$TIME_MINUTES_AGO");
|
| 206 |
+
expect(screen.getByTestId("run-status-icon-completed")).toBeInTheDocument();
|
| 207 |
+
expect(
|
| 208 |
+
screen.getByTestId("automation-activity-automation-1"),
|
| 209 |
+
).toBeInTheDocument();
|
| 210 |
+
expect(screen.getByTestId("automation-run-stats")).toBeInTheDocument();
|
| 211 |
+
expect(screen.getByText("4")).toBeInTheDocument();
|
| 212 |
+
expect(screen.getByText("100%")).toBeInTheDocument();
|
| 213 |
+
});
|
| 214 |
+
|
| 215 |
+
it("shows the value statement for its completed runs", () => {
|
| 216 |
+
// Arrange — provenance joining back to a catalog entry with an impact
|
| 217 |
+
// declaration, and a summary carrying the lifetime completed count.
|
| 218 |
+
const latestRun = createRun();
|
| 219 |
+
|
| 220 |
+
// Act
|
| 221 |
+
render(
|
| 222 |
+
<AutomationCard
|
| 223 |
+
automation={{
|
| 224 |
+
...automation,
|
| 225 |
+
preset_metadata: {
|
| 226 |
+
template: { id: "widget-checker", version: "1.0.0", config: {} },
|
| 227 |
+
},
|
| 228 |
+
}}
|
| 229 |
+
onToggle={vi.fn()}
|
| 230 |
+
onRunNow={vi.fn()}
|
| 231 |
+
onExport={vi.fn()}
|
| 232 |
+
onDelete={vi.fn()}
|
| 233 |
+
insights={{
|
| 234 |
+
spec: insightsSpec satisfies InterfaceListInsights,
|
| 235 |
+
state: {
|
| 236 |
+
summary: {
|
| 237 |
+
total: 5,
|
| 238 |
+
completedTotal: 4,
|
| 239 |
+
latestRun,
|
| 240 |
+
recentRuns: [latestRun],
|
| 241 |
+
recentSuccessRate: 1,
|
| 242 |
+
averageDurationMs: 120_000,
|
| 243 |
+
},
|
| 244 |
+
isLoading: false,
|
| 245 |
+
isError: false,
|
| 246 |
+
},
|
| 247 |
+
}}
|
| 248 |
+
/>,
|
| 249 |
+
);
|
| 250 |
+
|
| 251 |
+
// Assert
|
| 252 |
+
expect(
|
| 253 |
+
screen.getByTestId("automation-impact-automation-1"),
|
| 254 |
+
).toHaveTextContent("4 widget checks completed");
|
| 255 |
+
});
|
| 256 |
+
});
|
| 257 |
+
|
| 258 |
+
describe("AutomationCard — run phase", () => {
|
| 259 |
+
const localBackend: Backend = {
|
| 260 |
+
id: "local-1",
|
| 261 |
+
name: "Local 1",
|
| 262 |
+
host: "http://localhost:8000",
|
| 263 |
+
apiKey: "k",
|
| 264 |
+
kind: "local",
|
| 265 |
+
};
|
| 266 |
+
|
| 267 |
+
const insightAutomation: Automation = {
|
| 268 |
+
id: "auto-with-active-run",
|
| 269 |
+
name: "Digest",
|
| 270 |
+
prompt: null,
|
| 271 |
+
enabled: true,
|
| 272 |
+
trigger: { type: "cron", schedule_human: "cron" },
|
| 273 |
+
created_at: "2026-01-01T00:00:00Z",
|
| 274 |
+
updated_at: "2026-01-01T00:00:00Z",
|
| 275 |
+
};
|
| 276 |
+
|
| 277 |
+
const insightsSpec: InterfaceListInsights = {
|
| 278 |
+
health: {
|
| 279 |
+
healthy: "Healthy",
|
| 280 |
+
failing: "Failing",
|
| 281 |
+
running: "Running",
|
| 282 |
+
disabled: "Disabled",
|
| 283 |
+
neverRun: "Never run",
|
| 284 |
+
checking: "Checking",
|
| 285 |
+
},
|
| 286 |
+
lastRun: { label: "Last run", never: "Never", justNow: "Just now" },
|
| 287 |
+
stats: {
|
| 288 |
+
runs: "Runs",
|
| 289 |
+
recentSuccess: "Success",
|
| 290 |
+
averageDuration: "Duration",
|
| 291 |
+
},
|
| 292 |
+
};
|
| 293 |
+
|
| 294 |
+
beforeEach(() => {
|
| 295 |
+
__resetActiveStoreForTests();
|
| 296 |
+
setRegisteredBackends([localBackend]);
|
| 297 |
+
setActiveSelection({ backendId: localBackend.id });
|
| 298 |
+
});
|
| 299 |
+
|
| 300 |
+
afterEach(() => {
|
| 301 |
+
__resetActiveStoreForTests();
|
| 302 |
+
});
|
| 303 |
+
|
| 304 |
+
function Harness() {
|
| 305 |
+
const byId = useAutomationRunSummaries([insightAutomation]);
|
| 306 |
+
return (
|
| 307 |
+
<AutomationCard
|
| 308 |
+
automation={insightAutomation}
|
| 309 |
+
onToggle={vi.fn()}
|
| 310 |
+
onRunNow={vi.fn()}
|
| 311 |
+
onExport={vi.fn()}
|
| 312 |
+
onDelete={vi.fn()}
|
| 313 |
+
insights={{ spec: insightsSpec, state: byId.get(insightAutomation.id) }}
|
| 314 |
+
/>
|
| 315 |
+
);
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
function renderHarness() {
|
| 319 |
+
const queryClient = new QueryClient({
|
| 320 |
+
defaultOptions: { queries: { retry: false } },
|
| 321 |
+
});
|
| 322 |
+
return render(
|
| 323 |
+
<QueryClientProvider client={queryClient}>
|
| 324 |
+
<ActiveBackendProvider>
|
| 325 |
+
<Harness />
|
| 326 |
+
</ActiveBackendProvider>
|
| 327 |
+
</QueryClientProvider>,
|
| 328 |
+
);
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
it("shows the active run's phase using only the run-summaries fetch insights already makes — no extra request", async () => {
|
| 332 |
+
// Arrange: count every hit to the runs endpoint the card's insights
|
| 333 |
+
// already fetch (via useAutomationRunSummaries) �� if displaying the
|
| 334 |
+
// phase required a second request, this would be > 1.
|
| 335 |
+
let callCount = 0;
|
| 336 |
+
server.use(
|
| 337 |
+
http.get("*/api/automation/v1/:id/runs", () => {
|
| 338 |
+
callCount += 1;
|
| 339 |
+
return HttpResponse.json({
|
| 340 |
+
runs: [
|
| 341 |
+
{
|
| 342 |
+
id: "run-active",
|
| 343 |
+
status: AutomationRunStatus.RUNNING,
|
| 344 |
+
conversation_id: null,
|
| 345 |
+
bash_command_id: null,
|
| 346 |
+
error_detail: null,
|
| 347 |
+
phase_code: "running_agent",
|
| 348 |
+
phase_label: null,
|
| 349 |
+
phase_updated_at: null,
|
| 350 |
+
started_at: "2026-01-01T09:00:00Z",
|
| 351 |
+
completed_at: null,
|
| 352 |
+
},
|
| 353 |
+
],
|
| 354 |
+
total: 1,
|
| 355 |
+
});
|
| 356 |
+
}),
|
| 357 |
+
);
|
| 358 |
+
|
| 359 |
+
// Act
|
| 360 |
+
renderHarness();
|
| 361 |
+
|
| 362 |
+
// Assert: the phase renders ...
|
| 363 |
+
await screen.findByText(I18nKey.AUTOMATIONS$DETAIL$PHASE_RUNNING_AGENT);
|
| 364 |
+
// ... and exactly one request was made — the pre-existing insights
|
| 365 |
+
// fetch, not a new one just for the phase.
|
| 366 |
+
expect(callCount).toBe(1);
|
| 367 |
+
});
|
| 368 |
+
});
|
__tests__/components/automations/automation-list-row.permissions.test.tsx
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { render, screen } from "@testing-library/react";
|
| 2 |
+
import userEvent from "@testing-library/user-event";
|
| 3 |
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
| 4 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 5 |
+
import {
|
| 6 |
+
__resetActiveStoreForTests,
|
| 7 |
+
setActiveSelection,
|
| 8 |
+
setRegisteredBackends,
|
| 9 |
+
} from "#/api/backend-registry/active-store";
|
| 10 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 11 |
+
import {
|
| 12 |
+
getCloudOrganizationMe,
|
| 13 |
+
getCloudOrganizations,
|
| 14 |
+
} from "#/api/cloud/organization-service.api";
|
| 15 |
+
import { AutomationListRow } from "#/components/features/automations/automation-list-row";
|
| 16 |
+
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
|
| 17 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 18 |
+
import type { Automation } from "#/types/automation";
|
| 19 |
+
|
| 20 |
+
// `automation-list-row.test.tsx` mocks the permission hooks wholesale. These
|
| 21 |
+
// tests run the real hooks against a mocked `/me` service so the
|
| 22 |
+
// creator-vs-manager rule for the enabled toggle is exercised end to end.
|
| 23 |
+
vi.mock("#/api/cloud/organization-service.api", async (importOriginal) => ({
|
| 24 |
+
...(await importOriginal<
|
| 25 |
+
typeof import("#/api/cloud/organization-service.api")
|
| 26 |
+
>()),
|
| 27 |
+
getCloudOrganizations: vi.fn(),
|
| 28 |
+
getCloudOrganizationMe: vi.fn(),
|
| 29 |
+
}));
|
| 30 |
+
|
| 31 |
+
vi.mock("#/context/navigation-context", () => ({
|
| 32 |
+
useNavigation: () => ({ navigate: vi.fn(), currentPath: "/" }),
|
| 33 |
+
}));
|
| 34 |
+
|
| 35 |
+
const ORG_ID = "org-1";
|
| 36 |
+
|
| 37 |
+
const cloudBackend: Backend = {
|
| 38 |
+
id: "cloud-1",
|
| 39 |
+
name: "Production",
|
| 40 |
+
host: "https://app.all-hands.dev",
|
| 41 |
+
apiKey: "bearer-key",
|
| 42 |
+
kind: "cloud",
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
function makeAutomation(overrides: Partial<Automation> = {}): Automation {
|
| 46 |
+
return {
|
| 47 |
+
id: "auto-1",
|
| 48 |
+
name: "Teammate digest",
|
| 49 |
+
prompt: "Summarize",
|
| 50 |
+
trigger: {
|
| 51 |
+
type: "cron",
|
| 52 |
+
schedule: "0 9 * * *",
|
| 53 |
+
schedule_human: "Daily at 09:00",
|
| 54 |
+
},
|
| 55 |
+
enabled: true,
|
| 56 |
+
user_id: "creator-user",
|
| 57 |
+
created_at: "2026-01-01T00:00:00Z",
|
| 58 |
+
updated_at: "2026-01-01T00:00:00Z",
|
| 59 |
+
...overrides,
|
| 60 |
+
};
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function renderRow(automation: Automation) {
|
| 64 |
+
const queryClient = new QueryClient({
|
| 65 |
+
defaultOptions: { queries: { retry: false } },
|
| 66 |
+
});
|
| 67 |
+
return render(
|
| 68 |
+
<QueryClientProvider client={queryClient}>
|
| 69 |
+
<ActiveBackendProvider>
|
| 70 |
+
<AutomationListRow
|
| 71 |
+
automation={automation}
|
| 72 |
+
onToggle={vi.fn()}
|
| 73 |
+
onRunNow={vi.fn()}
|
| 74 |
+
onExport={vi.fn()}
|
| 75 |
+
onDelete={vi.fn()}
|
| 76 |
+
/>
|
| 77 |
+
</ActiveBackendProvider>
|
| 78 |
+
</QueryClientProvider>,
|
| 79 |
+
);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
async function openActionsMenu(user: ReturnType<typeof userEvent.setup>) {
|
| 83 |
+
// Run now only renders once the caller's permissions have resolved.
|
| 84 |
+
await screen.findByTestId("automation-run-now-auto-1");
|
| 85 |
+
await user.click(screen.getByLabelText(I18nKey.AUTOMATIONS$ACTIONS_MENU));
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
beforeEach(() => {
|
| 89 |
+
window.localStorage.clear();
|
| 90 |
+
__resetActiveStoreForTests();
|
| 91 |
+
setRegisteredBackends([cloudBackend]);
|
| 92 |
+
setActiveSelection({ backendId: cloudBackend.id, orgId: ORG_ID });
|
| 93 |
+
vi.mocked(getCloudOrganizations).mockResolvedValue({
|
| 94 |
+
items: [],
|
| 95 |
+
currentOrgId: null,
|
| 96 |
+
});
|
| 97 |
+
// A manager (has manage_automations) who did not create the automation.
|
| 98 |
+
vi.mocked(getCloudOrganizationMe).mockResolvedValue({
|
| 99 |
+
orgId: ORG_ID,
|
| 100 |
+
userId: "manager-user",
|
| 101 |
+
role: "admin",
|
| 102 |
+
permissions: ["view_automations", "manage_automations"],
|
| 103 |
+
});
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
afterEach(() => {
|
| 107 |
+
window.localStorage.clear();
|
| 108 |
+
__resetActiveStoreForTests();
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
describe("AutomationListRow — non-creator manager", () => {
|
| 112 |
+
it("offers Turn off and Delete on an enabled automation", async () => {
|
| 113 |
+
// Arrange
|
| 114 |
+
const user = userEvent.setup();
|
| 115 |
+
renderRow(makeAutomation({ enabled: true }));
|
| 116 |
+
|
| 117 |
+
// Act
|
| 118 |
+
await openActionsMenu(user);
|
| 119 |
+
|
| 120 |
+
// Assert
|
| 121 |
+
expect(
|
| 122 |
+
screen.getByRole("button", { name: I18nKey.AUTOMATIONS$TURN_OFF }),
|
| 123 |
+
).toBeInTheDocument();
|
| 124 |
+
expect(
|
| 125 |
+
screen.getByRole("button", { name: I18nKey.AUTOMATIONS$DELETE }),
|
| 126 |
+
).toBeInTheDocument();
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
it("hides Turn on but keeps Delete on a disabled automation", async () => {
|
| 130 |
+
// Arrange
|
| 131 |
+
const user = userEvent.setup();
|
| 132 |
+
renderRow(makeAutomation({ enabled: false }));
|
| 133 |
+
|
| 134 |
+
// Act
|
| 135 |
+
await openActionsMenu(user);
|
| 136 |
+
|
| 137 |
+
// Assert
|
| 138 |
+
expect(
|
| 139 |
+
screen.queryByRole("button", { name: I18nKey.AUTOMATIONS$TURN_ON }),
|
| 140 |
+
).not.toBeInTheDocument();
|
| 141 |
+
expect(
|
| 142 |
+
screen.getByRole("button", { name: I18nKey.AUTOMATIONS$DELETE }),
|
| 143 |
+
).toBeInTheDocument();
|
| 144 |
+
});
|
| 145 |
+
});
|
__tests__/components/automations/automation-list-row.test.tsx
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { render, screen } from "@testing-library/react";
|
| 2 |
+
import userEvent from "@testing-library/user-event";
|
| 3 |
+
import { describe, expect, it, vi } from "vitest";
|
| 4 |
+
import { AutomationListRow } from "#/components/features/automations/automation-list-row";
|
| 5 |
+
import {
|
| 6 |
+
AutomationRunStatus,
|
| 7 |
+
type Automation,
|
| 8 |
+
type AutomationRun,
|
| 9 |
+
} from "#/types/automation";
|
| 10 |
+
import type { InterfaceListInsights } from "#/manifests/types";
|
| 11 |
+
|
| 12 |
+
vi.mock("react-i18next", () => ({
|
| 13 |
+
useTranslation: () => ({
|
| 14 |
+
t: (key: string) => key,
|
| 15 |
+
i18n: { language: "en" },
|
| 16 |
+
}),
|
| 17 |
+
}));
|
| 18 |
+
|
| 19 |
+
vi.mock("#/context/navigation-context", () => ({
|
| 20 |
+
useNavigation: () => ({ navigate: vi.fn(), currentPath: "/" }),
|
| 21 |
+
}));
|
| 22 |
+
|
| 23 |
+
vi.mock("#/hooks/use-automation-permissions", () => ({
|
| 24 |
+
useAutomationPermissions: () => ({
|
| 25 |
+
canView: true,
|
| 26 |
+
canManage: true,
|
| 27 |
+
isLoading: false,
|
| 28 |
+
}),
|
| 29 |
+
useIsAutomationOwner: () => true,
|
| 30 |
+
}));
|
| 31 |
+
|
| 32 |
+
// The pinned package predates the `impact` field, so an entry carrying one is
|
| 33 |
+
// appended to the real catalog.
|
| 34 |
+
vi.mock("@openhands/extensions/automations", async (importOriginal) => {
|
| 35 |
+
const actual =
|
| 36 |
+
await importOriginal<typeof import("@openhands/extensions/automations")>();
|
| 37 |
+
return {
|
| 38 |
+
...actual,
|
| 39 |
+
AUTOMATION_CATALOG: [
|
| 40 |
+
...actual.AUTOMATION_CATALOG,
|
| 41 |
+
{
|
| 42 |
+
id: "widget-checker",
|
| 43 |
+
impact: {
|
| 44 |
+
basis: "completed-runs",
|
| 45 |
+
one: "1 widget check completed",
|
| 46 |
+
other: "{{count}} widget checks completed",
|
| 47 |
+
},
|
| 48 |
+
},
|
| 49 |
+
] as typeof actual.AUTOMATION_CATALOG,
|
| 50 |
+
};
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
const automation: Automation = {
|
| 54 |
+
id: "automation-1",
|
| 55 |
+
name: "GitHub PR Reviewer",
|
| 56 |
+
prompt: "Review pull requests.",
|
| 57 |
+
enabled: true,
|
| 58 |
+
trigger: {
|
| 59 |
+
type: "event",
|
| 60 |
+
on: "pull_request.opened",
|
| 61 |
+
source: "github",
|
| 62 |
+
},
|
| 63 |
+
repository: "acme/repo",
|
| 64 |
+
model: "Claude",
|
| 65 |
+
created_at: "2026-01-01T00:00:00Z",
|
| 66 |
+
updated_at: "2026-01-01T00:00:00Z",
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
const insightsSpec = {
|
| 70 |
+
health: {
|
| 71 |
+
healthy: "Healthy",
|
| 72 |
+
failing: "Failing",
|
| 73 |
+
running: "Running",
|
| 74 |
+
disabled: "Disabled",
|
| 75 |
+
neverRun: "Never run",
|
| 76 |
+
checking: "Checking",
|
| 77 |
+
},
|
| 78 |
+
lastRun: { label: "Last run", never: "Never", justNow: "Just now" },
|
| 79 |
+
stats: { runs: "Runs", recentSuccess: "Success", averageDuration: "Avg" },
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
function createRun(overrides: Partial<AutomationRun> = {}): AutomationRun {
|
| 83 |
+
return {
|
| 84 |
+
id: "run-1",
|
| 85 |
+
status: AutomationRunStatus.COMPLETED,
|
| 86 |
+
conversation_id: null,
|
| 87 |
+
bash_command_id: null,
|
| 88 |
+
error_detail: null,
|
| 89 |
+
started_at: "2026-01-02T00:00:00Z",
|
| 90 |
+
completed_at: "2026-01-02T00:02:00Z",
|
| 91 |
+
...overrides,
|
| 92 |
+
};
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
describe("AutomationListRow", () => {
|
| 96 |
+
it("renders title, trigger meta, and action icons in a two-line list row", () => {
|
| 97 |
+
render(
|
| 98 |
+
<AutomationListRow
|
| 99 |
+
automation={automation}
|
| 100 |
+
onToggle={vi.fn()}
|
| 101 |
+
onRunNow={vi.fn()}
|
| 102 |
+
onExport={vi.fn()}
|
| 103 |
+
onDelete={vi.fn()}
|
| 104 |
+
/>,
|
| 105 |
+
);
|
| 106 |
+
|
| 107 |
+
expect(
|
| 108 |
+
screen.getByTestId("automation-list-row-automation-1"),
|
| 109 |
+
).toBeInTheDocument();
|
| 110 |
+
expect(screen.getByText("GitHub PR Reviewer")).toBeInTheDocument();
|
| 111 |
+
expect(screen.getByText("pull_request.opened")).toBeInTheDocument();
|
| 112 |
+
expect(screen.getByText("GitHub")).toBeInTheDocument();
|
| 113 |
+
expect(
|
| 114 |
+
screen.queryByTestId("automation-pills-automation-1"),
|
| 115 |
+
).not.toBeInTheDocument();
|
| 116 |
+
expect(
|
| 117 |
+
screen.getByTestId("automation-run-now-automation-1"),
|
| 118 |
+
).toHaveAttribute("aria-label", "AUTOMATIONS$RUN_NOW");
|
| 119 |
+
expect(screen.getByTestId("automation-run-now-automation-1")).toHaveClass(
|
| 120 |
+
"size-8",
|
| 121 |
+
);
|
| 122 |
+
});
|
| 123 |
+
|
| 124 |
+
it("shows last-run status, relative time, and a sparkline when insights are present", () => {
|
| 125 |
+
const latestRun = createRun({
|
| 126 |
+
started_at: new Date(Date.now() - 10 * 60_000).toISOString(),
|
| 127 |
+
completed_at: new Date(Date.now() - 8 * 60_000).toISOString(),
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
render(
|
| 131 |
+
<AutomationListRow
|
| 132 |
+
automation={automation}
|
| 133 |
+
onToggle={vi.fn()}
|
| 134 |
+
onRunNow={vi.fn()}
|
| 135 |
+
onExport={vi.fn()}
|
| 136 |
+
onDelete={vi.fn()}
|
| 137 |
+
insights={{
|
| 138 |
+
spec: insightsSpec satisfies InterfaceListInsights,
|
| 139 |
+
state: {
|
| 140 |
+
summary: {
|
| 141 |
+
total: 4,
|
| 142 |
+
completedTotal: 4,
|
| 143 |
+
latestRun,
|
| 144 |
+
recentRuns: [latestRun],
|
| 145 |
+
recentSuccessRate: 1,
|
| 146 |
+
averageDurationMs: 120_000,
|
| 147 |
+
},
|
| 148 |
+
isLoading: false,
|
| 149 |
+
isError: false,
|
| 150 |
+
},
|
| 151 |
+
}}
|
| 152 |
+
/>,
|
| 153 |
+
);
|
| 154 |
+
|
| 155 |
+
expect(
|
| 156 |
+
screen.getByTestId("automation-last-run-automation-1"),
|
| 157 |
+
).toHaveTextContent("AUTOMATIONS$DETAIL$TIME_MINUTES_AGO");
|
| 158 |
+
expect(screen.getByTestId("run-status-icon-completed")).toBeInTheDocument();
|
| 159 |
+
expect(
|
| 160 |
+
screen.getByTestId("automation-activity-automation-1"),
|
| 161 |
+
).toBeInTheDocument();
|
| 162 |
+
});
|
| 163 |
+
|
| 164 |
+
it("shows the value statement in the meta line", () => {
|
| 165 |
+
// Arrange — provenance joining back to a catalog entry with an impact
|
| 166 |
+
// declaration, and a summary carrying the lifetime completed count.
|
| 167 |
+
const latestRun = createRun();
|
| 168 |
+
|
| 169 |
+
// Act
|
| 170 |
+
render(
|
| 171 |
+
<AutomationListRow
|
| 172 |
+
automation={{
|
| 173 |
+
...automation,
|
| 174 |
+
preset_metadata: {
|
| 175 |
+
template: { id: "widget-checker", version: "1.0.0", config: {} },
|
| 176 |
+
},
|
| 177 |
+
}}
|
| 178 |
+
onToggle={vi.fn()}
|
| 179 |
+
onRunNow={vi.fn()}
|
| 180 |
+
onExport={vi.fn()}
|
| 181 |
+
onDelete={vi.fn()}
|
| 182 |
+
insights={{
|
| 183 |
+
spec: insightsSpec satisfies InterfaceListInsights,
|
| 184 |
+
state: {
|
| 185 |
+
summary: {
|
| 186 |
+
total: 5,
|
| 187 |
+
completedTotal: 4,
|
| 188 |
+
latestRun,
|
| 189 |
+
recentRuns: [latestRun],
|
| 190 |
+
recentSuccessRate: 1,
|
| 191 |
+
averageDurationMs: 120_000,
|
| 192 |
+
},
|
| 193 |
+
isLoading: false,
|
| 194 |
+
isError: false,
|
| 195 |
+
},
|
| 196 |
+
}}
|
| 197 |
+
/>,
|
| 198 |
+
);
|
| 199 |
+
|
| 200 |
+
// Assert
|
| 201 |
+
expect(
|
| 202 |
+
screen.getByTestId("automation-impact-automation-1"),
|
| 203 |
+
).toHaveTextContent("4 widget checks completed");
|
| 204 |
+
});
|
| 205 |
+
|
| 206 |
+
it("opens the actions menu without triggering row navigation handlers", async () => {
|
| 207 |
+
const user = userEvent.setup();
|
| 208 |
+
|
| 209 |
+
render(
|
| 210 |
+
<AutomationListRow
|
| 211 |
+
automation={automation}
|
| 212 |
+
onToggle={vi.fn()}
|
| 213 |
+
onRunNow={vi.fn()}
|
| 214 |
+
onExport={vi.fn()}
|
| 215 |
+
onDelete={vi.fn()}
|
| 216 |
+
/>,
|
| 217 |
+
);
|
| 218 |
+
|
| 219 |
+
await user.click(
|
| 220 |
+
screen.getByRole("button", { name: "AUTOMATIONS$ACTIONS_MENU" }),
|
| 221 |
+
);
|
| 222 |
+
|
| 223 |
+
expect(screen.getByText("COMMON$VIEW")).toBeInTheDocument();
|
| 224 |
+
});
|
| 225 |
+
});
|
__tests__/components/automations/automation-view-toggle.test.tsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { render, screen } from "@testing-library/react";
|
| 2 |
+
import userEvent from "@testing-library/user-event";
|
| 3 |
+
import { describe, expect, it, vi } from "vitest";
|
| 4 |
+
import { AutomationViewToggle } from "#/components/features/automations/automation-view-toggle";
|
| 5 |
+
|
| 6 |
+
vi.mock("react-i18next", () => ({
|
| 7 |
+
useTranslation: () => ({ t: (key: string) => key }),
|
| 8 |
+
}));
|
| 9 |
+
|
| 10 |
+
describe("AutomationViewToggle", () => {
|
| 11 |
+
it("opens a menu from the icon trigger and switches to list view", async () => {
|
| 12 |
+
const user = userEvent.setup();
|
| 13 |
+
const onChange = vi.fn();
|
| 14 |
+
|
| 15 |
+
render(<AutomationViewToggle view="grid" onChange={onChange} />);
|
| 16 |
+
|
| 17 |
+
const trigger = screen.getByTestId("automations-view-toggle");
|
| 18 |
+
expect(trigger).toHaveClass("size-9");
|
| 19 |
+
expect(trigger).toHaveAttribute("aria-haspopup", "menu");
|
| 20 |
+
|
| 21 |
+
await user.click(trigger);
|
| 22 |
+
await user.click(screen.getByTestId("automations-view-toggle-list"));
|
| 23 |
+
|
| 24 |
+
expect(onChange).toHaveBeenCalledWith("list");
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
it("does not open the menu or fire onChange when disabled", async () => {
|
| 28 |
+
// Arrange
|
| 29 |
+
const user = userEvent.setup();
|
| 30 |
+
const onChange = vi.fn();
|
| 31 |
+
render(
|
| 32 |
+
<AutomationViewToggle view="grid" onChange={onChange} disabled />,
|
| 33 |
+
);
|
| 34 |
+
const trigger = screen.getByTestId("automations-view-toggle");
|
| 35 |
+
|
| 36 |
+
// Act — try to open the menu
|
| 37 |
+
await user.click(trigger);
|
| 38 |
+
|
| 39 |
+
// Assert — menu items never render and onChange stays untouched
|
| 40 |
+
expect(trigger).toBeDisabled();
|
| 41 |
+
expect(
|
| 42 |
+
screen.queryByTestId("automations-view-toggle-list"),
|
| 43 |
+
).not.toBeInTheDocument();
|
| 44 |
+
expect(onChange).not.toHaveBeenCalled();
|
| 45 |
+
});
|
| 46 |
+
});
|
__tests__/components/automations/backend-not-configured.test.tsx
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, vi } from "vitest";
|
| 2 |
+
import { render, screen, fireEvent } from "@testing-library/react";
|
| 3 |
+
import { BackendUnavailable } from "#/components/features/automations/backend-not-configured";
|
| 4 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 5 |
+
|
| 6 |
+
vi.mock("react-i18next", () => ({
|
| 7 |
+
useTranslation: () => ({
|
| 8 |
+
t: (key: string) => {
|
| 9 |
+
const translations: Record<string, string> = {
|
| 10 |
+
[I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_TITLE]:
|
| 11 |
+
"Automations Unavailable",
|
| 12 |
+
[I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_MESSAGE]:
|
| 13 |
+
"The automations backend is not available right now.",
|
| 14 |
+
[I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_RETRY]: "Retry",
|
| 15 |
+
};
|
| 16 |
+
return translations[key] || key;
|
| 17 |
+
},
|
| 18 |
+
}),
|
| 19 |
+
}));
|
| 20 |
+
|
| 21 |
+
describe("BackendUnavailable", () => {
|
| 22 |
+
it("renders the unavailable message", () => {
|
| 23 |
+
const onRetry = vi.fn();
|
| 24 |
+
render(<BackendUnavailable onRetry={onRetry} />);
|
| 25 |
+
|
| 26 |
+
expect(screen.getByText("Automations Unavailable")).toBeInTheDocument();
|
| 27 |
+
expect(
|
| 28 |
+
screen.getByText("The automations backend is not available right now."),
|
| 29 |
+
).toBeInTheDocument();
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
it("displays the retry button", () => {
|
| 33 |
+
const onRetry = vi.fn();
|
| 34 |
+
render(<BackendUnavailable onRetry={onRetry} />);
|
| 35 |
+
|
| 36 |
+
const retryButton = screen.getByRole("button", { name: "Retry" });
|
| 37 |
+
expect(retryButton).toBeInTheDocument();
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
it("calls onRetry when retry button is clicked", () => {
|
| 41 |
+
const onRetry = vi.fn();
|
| 42 |
+
render(<BackendUnavailable onRetry={onRetry} />);
|
| 43 |
+
|
| 44 |
+
const retryButton = screen.getByRole("button", { name: "Retry" });
|
| 45 |
+
fireEvent.click(retryButton);
|
| 46 |
+
|
| 47 |
+
expect(onRetry).toHaveBeenCalledTimes(1);
|
| 48 |
+
});
|
| 49 |
+
});
|
__tests__/components/automations/build-automation-pills.test.tsx
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { render, screen } from "@testing-library/react";
|
| 2 |
+
import { describe, expect, it } from "vitest";
|
| 3 |
+
import { buildAutomationMetadataPills } from "#/components/features/automations/build-automation-pills";
|
| 4 |
+
import type { SkillCardPill } from "#/components/features/skills/skill-card-pill-row";
|
| 5 |
+
import type { Automation } from "#/types/automation";
|
| 6 |
+
|
| 7 |
+
function buildAutomation(overrides: Partial<Automation> = {}): Automation {
|
| 8 |
+
return {
|
| 9 |
+
id: "automation-1",
|
| 10 |
+
name: "Triage",
|
| 11 |
+
prompt: "Triage the issue.",
|
| 12 |
+
enabled: true,
|
| 13 |
+
trigger: { type: "event", on: "issue.updated", source: "linear" },
|
| 14 |
+
created_at: "2026-01-01T00:00:00Z",
|
| 15 |
+
updated_at: "2026-01-01T00:00:00Z",
|
| 16 |
+
...overrides,
|
| 17 |
+
};
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function renderPills(pills: SkillCardPill[]) {
|
| 21 |
+
render(
|
| 22 |
+
<div>
|
| 23 |
+
{pills.map((pill) => (
|
| 24 |
+
<span key={pill.id} data-testid={`pill-${pill.id}`}>
|
| 25 |
+
{pill.node}
|
| 26 |
+
</span>
|
| 27 |
+
))}
|
| 28 |
+
</div>,
|
| 29 |
+
);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
describe("buildAutomationMetadataPills", () => {
|
| 33 |
+
it("puts the event and source on separate pills", () => {
|
| 34 |
+
const pills = buildAutomationMetadataPills(buildAutomation(), "unused");
|
| 35 |
+
|
| 36 |
+
expect(pills.map((pill) => pill.id)).toEqual([
|
| 37 |
+
"event-trigger",
|
| 38 |
+
"event-source",
|
| 39 |
+
]);
|
| 40 |
+
|
| 41 |
+
renderPills(pills);
|
| 42 |
+
|
| 43 |
+
expect(screen.getByTestId("pill-event-trigger")).toHaveTextContent(
|
| 44 |
+
"issue.updated",
|
| 45 |
+
);
|
| 46 |
+
expect(screen.getByTestId("pill-event-trigger")).not.toHaveTextContent(
|
| 47 |
+
"linear",
|
| 48 |
+
);
|
| 49 |
+
expect(screen.getByTestId("pill-event-source")).toHaveTextContent("Linear");
|
| 50 |
+
expect(screen.getByTestId("pill-event-source").firstElementChild).toHaveClass(
|
| 51 |
+
"py-0.5",
|
| 52 |
+
);
|
| 53 |
+
expect(screen.getByTestId("automation-source-logo")).toBeInTheDocument();
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
it("renders a fallback icon when the source is not in the catalog", () => {
|
| 57 |
+
renderPills(
|
| 58 |
+
buildAutomationMetadataPills(
|
| 59 |
+
buildAutomation({
|
| 60 |
+
trigger: { type: "event", on: "alert.fired", source: "custom-pager" },
|
| 61 |
+
}),
|
| 62 |
+
"unused",
|
| 63 |
+
),
|
| 64 |
+
);
|
| 65 |
+
|
| 66 |
+
expect(screen.getByTestId("pill-event-source")).toHaveTextContent(
|
| 67 |
+
"Custom-Pager",
|
| 68 |
+
);
|
| 69 |
+
expect(screen.getByTestId("automation-source-logo")).toBeInTheDocument();
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
it("omits the source pill when the event has no source", () => {
|
| 73 |
+
const pills = buildAutomationMetadataPills(
|
| 74 |
+
buildAutomation({
|
| 75 |
+
trigger: { type: "event", on: "pull_request.opened" },
|
| 76 |
+
}),
|
| 77 |
+
"unused",
|
| 78 |
+
);
|
| 79 |
+
|
| 80 |
+
expect(pills.map((pill) => pill.id)).toEqual(["event-trigger"]);
|
| 81 |
+
});
|
| 82 |
+
});
|
__tests__/components/automations/create-instructions.test.tsx
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { render, screen, waitFor } from "@testing-library/react";
|
| 3 |
+
import userEvent from "@testing-library/user-event";
|
| 4 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 5 |
+
import {
|
| 6 |
+
NavigationProvider,
|
| 7 |
+
type NavigationContextValue,
|
| 8 |
+
} from "#/context/navigation-context";
|
| 9 |
+
import { CreateInstructions } from "#/components/features/automations/create-instructions";
|
| 10 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 11 |
+
import { useConversationStore } from "#/stores/conversation-store";
|
| 12 |
+
import * as telemetry from "#/services/telemetry";
|
| 13 |
+
|
| 14 |
+
vi.mock("#/hooks/query/use-settings", () => ({
|
| 15 |
+
useSettings: () => ({ data: { user_consents_to_analytics: true } }),
|
| 16 |
+
}));
|
| 17 |
+
|
| 18 |
+
vi.mock("react-i18next", () => ({
|
| 19 |
+
useTranslation: () => ({
|
| 20 |
+
t: (key: string) => {
|
| 21 |
+
const translations: Record<string, string> = {
|
| 22 |
+
[I18nKey.AUTOMATIONS$CREATE_AUTOMATION_BUTTON]: "Create Automation",
|
| 23 |
+
[I18nKey.AUTOMATIONS$CREATE_AUTOMATION_PROMPT]: "Create an automation",
|
| 24 |
+
[I18nKey.AUTOMATIONS$CREATE_INSTRUCTIONS_GUIDANCE]:
|
| 25 |
+
"Include what the automation should do, when it should run, and where to send the results.",
|
| 26 |
+
};
|
| 27 |
+
return translations[key] || key;
|
| 28 |
+
},
|
| 29 |
+
}),
|
| 30 |
+
Trans: ({
|
| 31 |
+
i18nKey,
|
| 32 |
+
components,
|
| 33 |
+
}: {
|
| 34 |
+
i18nKey: string;
|
| 35 |
+
components?: Record<string, React.ReactElement>;
|
| 36 |
+
}) => {
|
| 37 |
+
if (i18nKey !== I18nKey.AUTOMATIONS$EMPTY_OPTION_CONVERSATION_DESC) {
|
| 38 |
+
return i18nKey;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<>
|
| 43 |
+
Start a new conversation and tell OpenHands to{" "}
|
| 44 |
+
{components?.example
|
| 45 |
+
? React.cloneElement(
|
| 46 |
+
components.example,
|
| 47 |
+
{},
|
| 48 |
+
<>
|
| 49 |
+
{components.cmd
|
| 50 |
+
? React.cloneElement(
|
| 51 |
+
components.cmd,
|
| 52 |
+
{},
|
| 53 |
+
"Create an automation",
|
| 54 |
+
)
|
| 55 |
+
: null}
|
| 56 |
+
{components.punct
|
| 57 |
+
? React.cloneElement(components.punct, {}, ".")
|
| 58 |
+
: null}
|
| 59 |
+
</>,
|
| 60 |
+
)
|
| 61 |
+
: null}
|
| 62 |
+
</>
|
| 63 |
+
);
|
| 64 |
+
},
|
| 65 |
+
}));
|
| 66 |
+
|
| 67 |
+
function renderCreateInstructions() {
|
| 68 |
+
const value: NavigationContextValue = {
|
| 69 |
+
currentPath: "/automations",
|
| 70 |
+
conversationId: null,
|
| 71 |
+
isNavigating: false,
|
| 72 |
+
navigate: vi.fn(),
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
const result = render(
|
| 76 |
+
<NavigationProvider value={value}>
|
| 77 |
+
<CreateInstructions />
|
| 78 |
+
</NavigationProvider>,
|
| 79 |
+
);
|
| 80 |
+
|
| 81 |
+
return { ...result, navigate: value.navigate };
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
describe("CreateInstructions", () => {
|
| 85 |
+
let captureMock: ReturnType<typeof vi.spyOn>;
|
| 86 |
+
|
| 87 |
+
beforeEach(() => {
|
| 88 |
+
captureMock = vi
|
| 89 |
+
.spyOn(telemetry, "trackEvent")
|
| 90 |
+
.mockResolvedValue(undefined);
|
| 91 |
+
useConversationStore.setState({ messageToSend: null });
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
afterEach(() => {
|
| 95 |
+
captureMock.mockRestore();
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
it("captures automation_created_button with the active backend kind when Create Automation is clicked", async () => {
|
| 99 |
+
const user = userEvent.setup();
|
| 100 |
+
renderCreateInstructions();
|
| 101 |
+
|
| 102 |
+
await user.click(screen.getByTestId("automations-create-automation"));
|
| 103 |
+
|
| 104 |
+
expect(captureMock).toHaveBeenCalledWith(
|
| 105 |
+
"automation_created_button",
|
| 106 |
+
expect.objectContaining({ backend_kind: "local" }),
|
| 107 |
+
);
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
it("navigates to conversations with a prefilled prompt when Create Automation is clicked", async () => {
|
| 111 |
+
const user = userEvent.setup();
|
| 112 |
+
const setMessageToSend = vi.fn();
|
| 113 |
+
useConversationStore.setState({ setMessageToSend });
|
| 114 |
+
const { navigate } = renderCreateInstructions();
|
| 115 |
+
|
| 116 |
+
await user.click(screen.getByTestId("automations-create-automation"));
|
| 117 |
+
|
| 118 |
+
expect(navigate).toHaveBeenCalledWith("/conversations");
|
| 119 |
+
await waitFor(() => {
|
| 120 |
+
expect(setMessageToSend).toHaveBeenCalledWith("Create an automation");
|
| 121 |
+
});
|
| 122 |
+
});
|
| 123 |
+
});
|