Remove visual snapshot tests and bump Docker E2E timeout (#1332)

* Remove visual snapshot tests

Co-authored-by: openhands <openhands@all-hands.dev>

* Bump Docker E2E timeout

Co-authored-by: openhands <openhands@all-hands.dev>

* Align Docker E2E timeout caps

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Rohit Malhotra
2026-06-12 13:03:58 -04:00
committed by GitHub
co-authored by openhands
parent 7537dab99d
commit c7c8862c11
48 changed files with 37 additions and 4752 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

-376
View File
@@ -1,376 +0,0 @@
# Snapshot Test Coverage Plan
Visual regression tests that should be added to this directory. Each proposed
spec file maps to one feature area. Tests follow the existing patterns:
`setupMocks` + `dismissConsentModal` for static pages; `navigateToConversation`
+ `injectEvents` / `window.__OH_EVENT_STORE__` for conversation-page scenarios;
`animations: "disabled"` and `maxDiffPixelRatio: 0.01` on every
`toHaveScreenshot()` call.
Existing coverage is marked **✅ done**. Everything else is proposed.
---
## 1. Home Screen
**File:** `home-screen.snapshot.spec.ts`
**Existing:** ✅ `home-screen.png` in `settings-page.snapshot.spec.ts`
| Snapshot name | State to capture |
|---|---|
| `home-empty-no-workspace` | No workspaces stored |
| `home-with-workspace-selected` | One workspace selected in the dropdown |
| `home-workspace-dropdown-open` | Workspace dropdown expanded showing stored workspaces |
| `home-folder-browser-modal` | `FolderBrowserModal` open with a mocked directory listing |
| `home-manage-workspaces-modal` | `ManageWorkspacesModal` open with two workspaces listed |
| `home-task-suggestions-loading` | Task suggestions skeleton (loading state) |
| `home-task-suggestions-loaded` | Task suggestion cards visible |
---
## 2. Onboarding Modal
**File:** `onboarding.snapshot.spec.ts`
Mock `openhands-onboarded` absent from localStorage so the modal appears. Use
`page.addInitScript` to clear the key. Step navigation is driven by clicking
the "Next" / "Back" buttons between snapshots.
| Snapshot name | State to capture |
|---|---|
| `onboarding-step-0-choose-agent` | Step 0 – agent cards; OpenHands selected, others "coming soon" |
| `onboarding-step-1-check-backend-connecting` | Step 1 – backend form with status banner in connecting state |
| `onboarding-step-1-check-backend-connected` | Step 1 – status banner green (mock `/server_info` 200) |
| `onboarding-step-1-check-backend-error` | Step 1 – status banner red (mock `/server_info` 500) |
| `onboarding-step-2-setup-llm` | Step 2 – LLM settings form embedded (basic view) |
| `onboarding-step-3-say-hello` | Step 3 – pre-filled message input |
| `onboarding-progress-bar-step-2` | Progress bar at step 2 (two segments completed) |
---
## 3. Settings Pages
**File:** `settings.snapshot.spec.ts`
**Existing:** ✅ `settings-page.png`, `settings-app-page.png` in `settings-page.snapshot.spec.ts`
### LLM Settings
| Snapshot name | State to capture |
|---|---|
| `settings-llm-basic-view` | Model picker + API key (no custom base URL) |
| `settings-llm-basic-api-key-set` | API key field showing the "key set" icon |
| `settings-llm-advanced-view` | "All settings" view: custom model string + base URL + API key |
| `settings-llm-openhands-model-help` | OpenHands model selected → help link visible below API key |
| `settings-llm-profiles-list` | LLM profiles manager showing two profile rows |
| `settings-llm-profiles-rename-modal` | Rename profile modal open |
| `settings-llm-profiles-delete-modal` | Delete profile confirmation modal open |
| `settings-llm-dirty-save-button` | Save button enabled after a field change |
### Condenser Settings
| Snapshot name | State to capture |
|---|---|
| `settings-condenser` | Schema-driven condenser form loaded |
### Verification Settings
| Snapshot name | State to capture |
|---|---|
| `settings-verification-confirmation-off` | Confirmation mode toggle off (security analyzer hidden) |
| `settings-verification-confirmation-on` | Confirmation mode toggle on → security analyzer dropdown visible |
### App Settings
| Snapshot name | State to capture |
|---|---|
| `settings-app-default` | ✅ Already captured as `settings-app-page.png`; keep as baseline |
| `settings-app-dirty` | At least one field changed, Save button enabled |
### Secrets Settings
| Snapshot name | State to capture |
|---|---|
| `settings-secrets-empty` | No secrets, only the "Add New Secret" button |
| `settings-secrets-list` | Three secrets listed in the table |
| `settings-secrets-add-form` | Add-secret form open |
| `settings-secrets-edit-form` | Edit-secret form open (secret name pre-populated) |
| `settings-secrets-delete-confirm` | Confirmation modal open with secret name in text |
### Settings Navigation
| Snapshot name | State to capture |
|---|---|
| `settings-nav-llm-active` | Left nav with LLM link highlighted |
| `settings-nav-secrets-active` | Left nav with Secrets link highlighted |
---
## 4. MCP Page
**File:** `mcp-page.snapshot.spec.ts`
| Snapshot name | State to capture |
|---|---|
| `mcp-page-empty` | No installed servers, full marketplace visible |
| `mcp-page-with-installed` | Two installed servers (one SSE, one STDIO) in the installed section |
| `mcp-marketplace-card-uninstalled` | A marketplace card in default state |
| `mcp-marketplace-card-installed` | Same card showing "Installed" badge |
| `mcp-search-filtered` | Search query "slack" – only matching cards visible |
| `mcp-install-modal` | `InstallServerModal` open for a marketplace entry |
| `mcp-custom-server-editor-sse` | Custom server editor showing SSE form |
| `mcp-custom-server-editor-stdio` | Custom server editor showing stdio form |
| `mcp-delete-confirm-modal` | Delete confirmation modal open |
---
## 5. Skills Page
**File:** `skills-page.snapshot.spec.ts`
| Snapshot name | State to capture |
|---|---|
| `skills-loading` | Skeleton pulse placeholders |
| `skills-loaded` | 2-column grid with several skill cards (enabled + disabled) |
| `skills-card-enabled` | Single enabled skill card (close-up via `getByTestId`) |
| `skills-card-disabled` | Single disabled skill card |
| `skills-search-filtered` | Search query applied, subset of cards visible |
| `skills-no-match` | Search query returns zero results – "no match" message |
| `skills-empty-from-server` | Server returns no skills – empty state text |
---
## 6. Automations
**File:** `automations.snapshot.spec.ts`
| Snapshot name | State to capture |
|---|---|
| `automations-loading` | Skeleton cards (health check in-flight) |
| `automations-backend-not-configured` | `BackendNotConfigured` full-page state |
| `automations-empty` | Backend healthy but zero automations |
| `automations-list-active-inactive` | Active group (2 cards) + Inactive group (1 card) |
| `automations-search-filtered` | Search narrows list to one card |
| `automations-delete-modal` | Delete confirmation modal open over the list |
| `automations-detail-active` | Detail page – active automation, all sections visible |
| `automations-detail-activity-log` | Detail page – activity log section with run entries |
---
## 7. Backend Management
**File:** `backends.snapshot.spec.ts`
| Snapshot name | State to capture |
|---|---|
| `backend-selector-single` | Dropdown closed, one backend, green status dot |
| `backend-selector-multiple` | Dropdown open, two backends with status dots |
| `backend-selector-error-dot` | Dropdown open, one backend with red status dot |
| `backend-add-modal` | `BackendFormModal` open in "add" mode |
| `backend-edit-modal` | `BackendFormModal` open in "edit" mode, fields pre-filled |
| `backend-manage-modal` | `ManageBackendsModal` listing two backends |
| `backend-unavailable-recovery` | Full-screen recovery: "Manage Backends" modal over dark backdrop |
| `environment-switch-overlay` | `EnvironmentSwitchOverlay` visible during backend switch |
---
## 8. Analytics Consent Modal
**File:** `settings-page.snapshot.spec.ts` (already exists)
**Existing:** ✅ `analytics-consent-modal.png`
No new snapshots needed; keep existing baseline.
---
## 9. Conversation Page – Happy Path
**File:** `conversation-happy-path.snapshot.spec.ts`
All tests use `navigateToConversation` + `injectEvents` via
`window.__OH_EVENT_STORE__`. Use serial mode.
| Snapshot name | State to capture |
|---|---|
| `conversation-chat-messages-skeleton` | Before events load (store empty, skeleton visible) |
| `conversation-user-message` | Single user message injected |
| `conversation-assistant-message` | User message + assistant text message |
| `conversation-event-group-collapsed` | Two back-to-back action events folded into a group (collapsed) |
| `conversation-event-group-expanded` | Same group after clicking to expand |
| `conversation-finish-event` | `FinishAction` event rendered (green check or finish card) |
| `conversation-typing-indicator` | `RUNNING` state injected – typing indicator visible |
| `conversation-right-panel-closed` | Chat takes full width, right-panel toggle button visible |
| `conversation-right-panel-files` | Right panel open on Files tab |
| `conversation-right-panel-terminal` | Right panel open on Terminal tab |
| `conversation-right-panel-planner` | Right panel open on Planner tab, Build button visible |
| `conversation-tab-nav-full` | All tabs visible including Task List (inject task tracker event) |
| `conversation-btw-message-pending` | BTW message with spinner in "pending" state |
| `conversation-btw-message-answered` | BTW message answered, "Got it" button visible |
---
## 10. Conversation Page – Confirmation Mode
**File:** `conversation-confirmation-mode.snapshot.spec.ts`
| Snapshot name | State to capture |
|---|---|
| `confirmation-mode-locked-icon` | Lock icon visible in chat input row (mock `confirmation_mode: true`) |
| `confirmation-awaiting-normal-risk` | `WAITING_FOR_CONFIRMATION` state + action event → Approve/Reject buttons visible, no risk banner |
| `confirmation-awaiting-high-risk` | Same but action has `security_risk: "high"` → red `RiskAlert` banner above buttons |
---
## 11. Conversation Page – Collapsible Thinking
**File:** `collapsible-thinking.snapshot.spec.ts` (already exists)
**Existing:** ✅ `think-action-collapsed.png`, `think-action-expanded.png`, `reasoning-content-collapsed.png`, `reasoning-content-expanded.png`
No new snapshots needed; keep existing baselines.
---
## 12. Conversation Creation – Error States
**File:** `conversation-creation-errors.snapshot.spec.ts`
These tests mock the relevant API endpoints to trigger each failure path.
| Snapshot name | What to mock | State to capture |
|---|---|---|
| `creation-backend-unavailable` | `GET /server_info` → network error / timeout | Full-screen backend recovery modal rendered over the home route |
| `creation-api-failure-toast` | `POST /api/conversations` → 500 | Home screen + error toast visible in top-right corner |
| `creation-task-polling-error` | `GET /api/conversations/start-tasks/:id` → `{ status: "ERROR", detail: "Sandbox failed to start" }` | Conversation route showing error toast; button returns to idle |
| `creation-conversation-not-found` | `GET /api/conversations/:id` → 404 | Error toast + redirect back to home (capture just before redirect navigates away) |
| `creation-loading-spinner` | `GET /api/conversations/start-tasks/:id` → `{ status: "PENDING" }` (never resolves) | `ConversationLoading` full-screen spinner |
---
## 13. Conversation Page – In-Progress Error States
**File:** `conversation-errors.snapshot.spec.ts`
All tests use `navigateToConversation` + inject agent state or events. Serial mode.
### Agent error events (inline in chat)
| Snapshot name | State to capture |
|---|---|
| `error-event-known-id` | `AgentErrorEvent` with a known i18n `errorId` – translated title shown, details collapsed |
| `error-event-unknown-id` | `AgentErrorEvent` with unknown `errorId` – generic fallback title |
| `error-event-expanded` | Same event after clicking the chevron – raw markdown details visible |
| `error-event-long-message` | Error with a very long `defaultMessage` – details scroll within the card |
### Agent status bar error states
| Snapshot name | State to capture |
|---|---|
| `agent-status-error` | `ExecutionStatus.ERROR` injected → red `CircleErrorIcon` in status button, error status text in pill |
| `agent-status-stuck` | `ExecutionStatus.STUCK` → same error icon path |
| `agent-status-paused-resume` | `ExecutionStatus.PAUSED` → play/resume button visible |
| `agent-status-running-stop` | `ExecutionStatus.RUNNING` → stop button visible, pulsing dot |
| `agent-status-websocket-closed` | WebSocket mock closed after open → `CircleErrorIcon` |
### ErrorMessageBanner (scroll-up pagination / anchor error)
| Snapshot name | State to capture |
|---|---|
| `error-banner-short` | `setErrorMessage` called with a short message (< 220 chars) – no toggle button |
| `error-banner-long-collapsed` | Long message (> 220 chars) – banner shows line-clamp with "View more" |
| `error-banner-long-expanded` | Same banner after clicking "View more" – full text visible |
| `error-banner-dismissed` | Banner after clicking × – banner no longer rendered |
### Session / WebSocket errors (toast)
| Snapshot name | State to capture |
|---|---|
| `ws-session-expired-toast` | Inject `{ error: true, error_code: 401, message: "..." }` event → "Session expired." toast |
| `ws-generic-error-toast` | Inject `{ error: "Something went wrong", message: "..." }` event → generic error toast |
| `ws-max-iterations-paused` | Inject `{ type: "error", message: "Agent reached maximum..." }` → agent transitions to PAUSED state |
### Send-message failure (optimistic pending bubble)
| Snapshot name | State to capture |
|---|---|
| `pending-message-sending` | Message sent but not echoed yet – faded "sending" bubble |
| `pending-message-error` | Mock `send()` throws → bubble shows error treatment + Retry button |
| `pending-message-after-retry` | After clicking Retry – bubble back to "sending" state |
### File upload validation
| Snapshot name | State to capture |
|---|---|
| `file-upload-too-large-toast` | Attach files exceeding 3 MB limit → error toast (no upload starts) |
---
## 14. Conversation Page – Alert Banner
**File:** `alert-banner.snapshot.spec.ts`
Mock `GET /server_info` (or the relevant endpoint) to return alert payload.
| Snapshot name | State to capture |
|---|---|
| `alert-banner-maintenance` | Maintenance start time set → banner with clock/triangle icon and formatted local time |
| `alert-banner-faulty-models` | Faulty models list → banner listing model names |
| `alert-banner-error-message` | Free-form error string → banner with raw text |
| `alert-banner-dismissed` | Banner after clicking × – not rendered, page unobstructed |
---
## 15. Changes Tab – Diff Viewer Edge Cases
**File:** `changes-tab.snapshot.spec.ts`
| Snapshot name | State to capture |
|---|---|
| `diff-viewer-modified-file` | Normal modified file – Monaco diff editor visible |
| `diff-viewer-deleted-file` | File with `type: "D"` selected → "file deleted" placeholder, no Monaco editor |
| `diff-viewer-no-changes` | Empty git changes list – empty state text |
---
## 16. Sidebar
**File:** `sidebar.snapshot.spec.ts`
| Snapshot name | State to capture |
|---|---|
| `sidebar-with-conversations` | Conversation list populated, active conversation highlighted |
| `sidebar-empty` | No conversations |
| `sidebar-new-conversation-popover` | "+ New Conversation" popover open showing workspace entries |
| `sidebar-status-dots` | Conversation list showing multiple status dots (finished, running, error, paused) |
---
## Key User Flow Sequences
The following multi-step flows should each be captured as a `test.step()` series
within a single test, producing a sequence of named snapshots that together tell
the story of the flow.
### Flow A – Modify LLM settings → start conversation
**File:** `flow-llm-settings-to-conversation.snapshot.spec.ts`
1. `flow-llm-settings-initial` — LLM settings page, default model
2. `flow-llm-settings-dirty` — Model changed to a new value, Save button enabled
3. `flow-llm-settings-saved` — After save, success toast, button disabled again
4. `flow-llm-conversation-started` — New conversation page, status indicator shows model in use
### Flow B – Enable confirmation mode → agent action requires approval
**File:** `flow-confirmation-mode.snapshot.spec.ts`
1. `flow-confirmation-settings` — Verification settings page, confirmation mode off
2. `flow-confirmation-settings-toggled` — Toggle turned on, security analyzer dropdown appears
3. `flow-confirmation-settings-saved` — Saved, lock icon now shows in conversation input
4. `flow-confirmation-action-pending` — Conversation page: agent at `WAITING_FOR_CONFIRMATION`, Approve/Reject buttons visible
5. `flow-confirmation-high-risk` — Same with `security_risk: "high"` — red RiskAlert above buttons
### Flow C – Add a secret → confirm it appears in the secrets list
**File:** `flow-secrets.snapshot.spec.ts`
1. `flow-secrets-empty` — Secrets settings, empty table
2. `flow-secrets-add-form` — "Add New Secret" form open
3. `flow-secrets-list-populated` — After save, secret appears in the table
### Flow D – Install MCP server → appears as installed
**File:** `flow-mcp-install.snapshot.spec.ts`
1. `flow-mcp-marketplace` — Marketplace, server shows as uninstalled
2. `flow-mcp-install-modal` — Install modal open with form
3. `flow-mcp-installed` — Modal closed, server now in Installed section
### Flow E – Conversation creation failure recovery
**File:** `flow-creation-failure-recovery.snapshot.spec.ts`
1. `flow-recovery-api-down` — Home screen, `/api/conversations` returning 500
2. `flow-recovery-error-toast` — Toast visible after clicking "New Conversation"
3. `flow-recovery-home-intact` — Toast dismissed, home screen unchanged and ready to retry
@@ -1,123 +0,0 @@
import { test, expect, Page } from "@playwright/test";
import { seedLocalStorage } from "./support/seed-local-storage";
import { stubWebSocket } from "./support/stub-websocket";
/**
* Visual snapshot tests for archived / sandbox-error conversation states.
*
* Mock conversations pre-seeded in src/mocks/conversation-handlers.ts:
* 4. "Archived Project" — sandbox_status: "MISSING"
* 5. "Errored Project" — sandbox_status: "ERROR"
*
* Snapshots:
* 1. conversation-panel-with-archived-badges — archive icon + ERROR pill for MISSING/ERROR
* 2. conversation-view-archived — chat interface for conv 4 with the
* read-only "Sandbox no longer available" banner (no chat input)
* 3. conversation-view-sandbox-error — same for conv 5, "Sandbox error" variant
*
* NOTE: We do NOT inject events into the chat for these tests. In dev mode
* React 18 strict mode double-fires effects in child-before-parent order, so
* ConversationWebSocketProvider's addEvents runs before conversation.tsx's
* clearEvents — any REST-loaded or store-injected events get wiped. The
* tests verify the banner + hidden chat input (the actual feature), not
* event rendering.
*/
const ARCHIVED_CONVERSATION_ID = "4"; // sandbox_status: "MISSING"
const ERROR_CONVERSATION_ID = "5"; // sandbox_status: "ERROR"
/** Dismisses the analytics consent modal if it appears. */
async function dismissConsentModal(page: Page) {
try {
await page
.getByRole("button", { name: "Confirm preferences" })
.click({ timeout: 5_000 });
await page
.getByRole("dialog", { name: "Help improve OpenHands" })
.waitFor({ state: "hidden", timeout: 5_000 });
} catch {
// Modal didn't appear — fine.
}
}
test.describe("Archived Conversation Visual Snapshots", () => {
test.describe.configure({ mode: "serial" });
test.setTimeout(60_000);
// ── 1. Sidebar panel ───────────────────────────────────────────────────
test("conversation panel shows archive icon and error badge for MISSING/ERROR sandboxes", async ({
page,
}) => {
await seedLocalStorage(page);
await page.goto("/conversations");
await dismissConsentModal(page);
await page.waitForLoadState("networkidle");
const conversationPanel = page.getByTestId("conversation-panel");
await expect(conversationPanel).toBeVisible({ timeout: 15_000 });
// 6 mock conversations: 1–3 normal + 4 MISSING + 5 ERROR + pagination-local
await expect(page.getByTestId("conversation-card")).toHaveCount(6, {
timeout: 10_000,
});
await expect(page.getByTestId("conversation-status-archived")).toBeVisible({
timeout: 5_000,
});
await expect(page.getByTestId("error-badge")).toBeVisible({
timeout: 5_000,
});
await expect(conversationPanel).toHaveScreenshot(
"conversation-panel-with-archived-badges.png",
{ animations: "disabled", maxDiffPixelRatio: 0.01 },
);
});
// ── 2. Conversation view — MISSING sandbox (archived) ──────────────────
test("archived conversation view shows read-only banner and hides chat input", async ({
page,
}) => {
await seedLocalStorage(page);
await stubWebSocket(page);
await page.goto(`/conversations/${ARCHIVED_CONVERSATION_ID}`, {
waitUntil: "domcontentloaded",
});
await dismissConsentModal(page);
// Wait for the archived banner — proves useActiveConversation resolved
// with sandbox_status: "MISSING" and the component fully initialized.
await expect(
page.getByTestId("archived-conversation-banner"),
).toBeVisible({ timeout: 20_000 });
await expect(page.getByTestId("interactive-chat-box")).toHaveCount(0);
await expect(page.getByTestId("chat-interface")).toHaveScreenshot(
"conversation-view-archived.png",
{ animations: "disabled", maxDiffPixelRatio: 0.01 },
);
});
// ── 3. Conversation view — ERROR sandbox ──────────────────────────────
test("error sandbox conversation view shows error banner and hides chat input", async ({
page,
}) => {
await seedLocalStorage(page);
await stubWebSocket(page);
await page.goto(`/conversations/${ERROR_CONVERSATION_ID}`, {
waitUntil: "domcontentloaded",
});
await dismissConsentModal(page);
await expect(
page.getByTestId("archived-conversation-banner"),
).toBeVisible({ timeout: 20_000 });
await expect(page.getByTestId("interactive-chat-box")).toHaveCount(0);
await expect(page.getByTestId("chat-interface")).toHaveScreenshot(
"conversation-view-sandbox-error.png",
{ animations: "disabled", maxDiffPixelRatio: 0.01 },
);
});
});
@@ -1,188 +0,0 @@
import { test, expect, Page } from "@playwright/test";
import { seedLocalStorage } from "./support/seed-local-storage";
/**
* Visual snapshot tests for the Automations pages.
*
* Three states are covered:
* 1. Backend not configured (health check returns an error)
* 2. List view with active and inactive automation groups (MSW serves 5 automations)
* 3. Empty list (health OK, list returns zero items)
*
* The test server runs with VITE_MOCK_API=true (npm run dev:mock).
* MSW handles GET /api/automation/v1 with pre-built data from
* src/mocks/automation-handlers.ts. The health endpoint is NOT
* covered by MSW so page.route() owns it in all tests.
*/
/**
* Dismiss the analytics consent modal if it appears (MSW settings return
* user_consents_to_analytics: null so the modal can show on any page).
*/
async function dismissConsentModal(page: Page) {
await page
.getByRole("button", { name: "Confirm preferences" })
.click({ timeout: 3_000 })
.catch(() => undefined);
}
/**
* Wire up the routes that every automations test needs:
* - Skip onboarding so the modal never blocks the page
* - Conversations search (empty – keeps the sidebar quiet)
*
* NOTE: Settings requests go to the same-origin Vite dev server where MSW
* intercepts them before page.route(). We call dismissConsentModal() after
* navigation to handle the MSW consent modal instead of suppressing it here.
*
* Automation health and list requests go cross-origin to :8000 where
* page.route() takes precedence. Health is mocked per-test; list is mocked
* here with INLINE_AUTOMATIONS so all tests have a consistent baseline.
*/
async function setupMocks(page: Page) {
await seedLocalStorage(page);
await page.route("**/api/conversations/search**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ results: [] }),
});
});
}
test.describe("Automations Visual Snapshots", () => {
test.setTimeout(60_000);
// TODO: add automations-backend-not-configured snapshot once window.__MSW_WORKER__
// is exposed in mock mode so tests can call worker.use() to override the health
// handler per-test. See COVERAGE_PLAN.md §6 for the full snapshot list.
test("list with active and inactive groups renders correctly", async ({ page }) => {
// MSW (automation-handlers.ts) serves health OK and the full automations list
// (3 active, 2 inactive) so no page.route() overrides are needed here.
await setupMocks(page);
await page.goto("/automations");
await dismissConsentModal(page);
await page.waitForLoadState("networkidle");
// Wait for at least one automation card to confirm the list loaded
const rootLayout = page.getByTestId("root-layout");
await expect(rootLayout).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("button", { name: "Automation actions" }).first()).toBeVisible({
timeout: 10_000,
});
await expect(rootLayout).toHaveScreenshot(
"automations-list-active-inactive.png",
{ animations: "disabled", maxDiffPixelRatio: 0.01 },
);
});
test("search with no results renders correctly", async ({ page }) => {
// Load the full list then type a query that matches nothing.
await setupMocks(page);
await page.goto("/automations");
await dismissConsentModal(page);
await page.waitForLoadState("networkidle");
await expect(page.getByRole("button", { name: "Automation actions" }).first()).toBeVisible({
timeout: 10_000,
});
// Type a query that matches no automation name, repository, or prompt
const searchInput = page.getByRole("textbox");
await searchInput.fill("zzznomatchquery");
// Wait for the filtered state to settle
await page.waitForTimeout(300);
const rootLayout = page.getByTestId("root-layout");
await expect(rootLayout).toHaveScreenshot("automations-search-no-results.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
test("no automations shows empty state", async ({ page }) => {
// Each Playwright test gets a fresh browser context, so MSW starts with
// the full MOCK_AUTOMATIONS_RESPONSE. We delete every automation via the
// REST API (MSW handles DELETE) then reload so React Query fetches the
// now-empty list and renders the EmptyState component.
await setupMocks(page);
await page.goto("/automations");
await dismissConsentModal(page);
// Wait for initial list to confirm the service worker is active
await expect(
page.getByRole("button", { name: "Automation actions" }).first(),
).toBeVisible({ timeout: 10_000 });
// Delete every automation from the MSW mutable state.
// Important: the `automations` Map lives in PAGE-level JS (the MSW handlers
// are compiled into the client bundle). A page.reload() would re-run the
// module initialiser and reset the Map to 5 items, so instead we:
// 1. Make the DELETE fetches (MSW handles them in the page context)
// 2. Call window.__TEST_INVALIDATE_QUERIES__() to ask React Query to
// refetch without a reload
await page.evaluate(async () => {
const res = await fetch("/api/automation/v1?limit=100");
const data = (await res.json()) as { automations: { id: string }[] };
await Promise.all(
data.automations.map((a) =>
fetch(`/api/automation/v1/${a.id}`, { method: "DELETE" }),
),
);
});
// Trigger React Query to refetch the now-empty list
await page.evaluate(() => {
(
window as Window & { __TEST_INVALIDATE_QUERIES__?: () => void }
).__TEST_INVALIDATE_QUERIES__?.();
});
await page.waitForLoadState("networkidle");
const rootLayout = page.getByTestId("root-layout");
await expect(rootLayout).toBeVisible({ timeout: 15_000 });
// No kebab buttons means the list is truly empty
await expect(
page.getByRole("button", { name: "Automation actions" }),
).toHaveCount(0, { timeout: 10_000 });
await expect(rootLayout).toHaveScreenshot("automations-no-automations.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
test("delete confirmation modal renders correctly", async ({ page }) => {
await setupMocks(page);
await page.goto("/automations");
await dismissConsentModal(page);
await page.waitForLoadState("networkidle");
// Wait for the first kebab button to be visible before interacting
const kebab = page.getByRole("button", { name: "Automation actions" }).first();
await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click();
await page.getByRole("button", { name: "Delete" }).click();
// DeleteConfirmationModal is a plain div (no dialog role).
// Wait for its title to confirm it appeared.
await expect(page.getByText("Delete automation")).toBeVisible({ timeout: 5_000 });
const rootLayout = page.getByTestId("root-layout");
await expect(rootLayout).toHaveScreenshot(
"automations-delete-modal.png",
{ animations: "disabled", maxDiffPixelRatio: 0.01 },
);
});
});
@@ -1,504 +0,0 @@
import { test, expect, type Page } from "@playwright/test";
import type { Backend } from "../../../src/api/backend-registry/types";
import { seedLocalStorage } from "./support/seed-local-storage";
/**
* Extended visual snapshot tests for the backend management UI.
*
* The add-backend modal is a two-column layout:
* Left — Manual connection: name, host, API-key, Connect button.
* Kind is inferred from the host URL (local vs cloud).
* Right — Cloud OAuth: one-click "Login with OpenHands" device flow.
*
* Flow 1 — Add form validation gates (manual connection column)
* Connect is disabled until name + valid host are filled; cloud-inferred
* hosts additionally require an API key.
*
* Flow 2 — Two-column layout renders correctly
* The add modal shows both manual-connection and cloud-login columns
* side by side with an OR divider.
*
* Flow 3 — Cloud login column renders OAuth section
* The right column shows the OpenHands logo, description, and a
* "Login with OpenHands" device-flow button.
*
* Flow 4 — Remove backend with confirmation step
* Clicking "Remove" opens a ConfirmationModal; confirming removes the
* row; cancelling keeps it.
*
* Flow 5 — Edit backend pre-fills form fields
* Opening the edit modal for an existing backend populates name, host,
* and API-key inputs from the stored backend data.
*
* Flow 6 — Switch active backend via dropdown
* Selecting a different backend fires the environment-switch overlay,
* then updates the selector trigger label once the overlay fades.
*
* Flow 7 — Malformed / empty host blocks submission
* A host with only whitespace keeps the Connect button disabled.
* A syntactically invalid URL also keeps Connect disabled.
*
* Flow 8 — Close add form dismisses without saving
* Clicking the close (✕) button closes the modal without altering
* the backend list.
*/
// ── Constants ──────────────────────────────────────────────────────────────
/** Two pre-seeded backends used by multi-backend tests. */
const LOCAL_BACKEND: Backend = {
id: "default-local",
name: "Local",
host: "http://localhost:3000",
apiKey: "",
kind: "local",
};
const CLOUD_BACKEND: Backend = {
id: "test-production",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "sk-test-key",
kind: "cloud",
};
// ── Helpers ────────────────────────────────────────────────────────────────
/**
* Seed localStorage with one or two backends and navigate to the
* conversations list so the BackendSelector is visible in the sidebar.
* Routes file API and cloud-proxy requests so they don't produce
* console errors that could affect timing.
*/
async function setupPage(
page: Page,
{
backends = [LOCAL_BACKEND],
activeBackendId,
}: { backends?: Backend[]; activeBackendId?: string } = {},
) {
const extra: [string, string][] = [
["openhands-backends", JSON.stringify(backends)],
];
if (activeBackendId) {
extra.push([
"openhands-active-backend",
JSON.stringify({ backendId: activeBackendId, orgId: null }),
]);
}
await seedLocalStorage(page, { extra });
// Prevent workspace-scan 404s in the sidebar from cluttering timing.
await page.route("**/api/file/**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ path: "/home", subdirs: [] }),
}),
);
}
async function dismissConsentModal(page: Page) {
await page
.getByRole("button", { name: "Confirm preferences" })
.click({ timeout: 3_000 })
.catch(() => undefined);
}
/**
* Navigate to the conversations list and hover the backend selector to
* open the dropdown. Returns the root-layout locator for snapshots.
*/
async function openDropdown(page: Page) {
await page.goto("/conversations");
await dismissConsentModal(page);
await page.waitForLoadState("networkidle");
const rootLayout = page.getByTestId("root-layout");
await expect(rootLayout).toBeVisible({ timeout: 15_000 });
const selector = page.getByTestId("backend-selector");
await expect(selector).toBeVisible({ timeout: 10_000 });
await selector.hover();
await expect(page.getByTestId("add-backend-menu-item")).toBeVisible({
timeout: 5_000,
});
return rootLayout;
}
/** Open the Add Backend modal via the dropdown footer. */
async function openAddModal(page: Page) {
const rootLayout = await openDropdown(page);
await page.getByTestId("add-backend-menu-item").click();
await expect(page.getByTestId("add-backend-modal")).toBeVisible({
timeout: 5_000,
});
await expect(page.getByTestId("add-backend-name")).toBeVisible({
timeout: 5_000,
});
return rootLayout;
}
/** Open the Manage Backends modal via the dropdown footer. */
async function openManageModal(page: Page) {
const rootLayout = await openDropdown(page);
await page.getByTestId("manage-backends-menu-item").click();
await expect(page.getByTestId("manage-backends-modal")).toBeVisible({
timeout: 5_000,
});
return rootLayout;
}
const SNAP_OPTS = { animations: "disabled" as const, maxDiffPixelRatio: 0.01 };
// ── Test Suite ─────────────────────────────────────────────────────────────
test.describe("Backend Management — Extended Flow Snapshots", () => {
test.setTimeout(90_000);
// ── Flow 1: Add-form validation gates ─────────────────────────────────
test("Flow 1a — add form blank: Connect button disabled until required fields filled", async ({
page,
}) => {
await setupPage(page);
const rootLayout = await openAddModal(page);
// 1. Completely blank form — Connect must be disabled.
await expect(page.getByTestId("add-backend-submit")).toBeDisabled();
await expect(rootLayout).toHaveScreenshot(
"backend-add-blank-disabled.png",
SNAP_OPTS,
);
// 2. Fill only the name; host still empty → Connect still disabled.
await page.getByTestId("add-backend-name").fill("My Backend");
await expect(page.getByTestId("add-backend-submit")).toBeDisabled();
await expect(rootLayout).toHaveScreenshot(
"backend-add-name-only-disabled.png",
SNAP_OPTS,
);
});
test("Flow 1b — local backend becomes Connect-ready with name + host, no API key required", async ({
page,
}) => {
await setupPage(page);
const rootLayout = await openAddModal(page);
// A localhost URL infers local kind — no API key needed.
await page.getByTestId("add-backend-name").fill("Dev Server");
await page.getByTestId("add-backend-host").fill("http://localhost:8080");
// API key left empty — Connect must be enabled for local kind.
await expect(page.getByTestId("add-backend-submit")).not.toBeDisabled();
await expect(rootLayout).toHaveScreenshot(
"backend-add-local-ready.png",
SNAP_OPTS,
);
});
test("Flow 1c — cloud-inferred backend requires API key; Connect stays disabled without it", async ({
page,
}) => {
await setupPage(page);
const rootLayout = await openAddModal(page);
// An all-hands.dev URL infers cloud kind — API key required.
await page.getByTestId("add-backend-name").fill("Cloud Prod");
await page.getByTestId("add-backend-host").fill("https://app.all-hands.dev");
// No API key → Connect disabled.
await expect(page.getByTestId("add-backend-submit")).toBeDisabled();
await expect(rootLayout).toHaveScreenshot(
"backend-add-cloud-no-key-disabled.png",
SNAP_OPTS,
);
// Fill API key → Connect enabled.
await page.getByTestId("add-backend-api-key").fill("sk-live-abc123");
await expect(page.getByTestId("add-backend-submit")).not.toBeDisabled();
await expect(rootLayout).toHaveScreenshot(
"backend-add-cloud-with-key-enabled.png",
SNAP_OPTS,
);
});
// ── Flow 2: Two-column layout ──────────────────────────────────────
test("Flow 2 — add modal shows two-column layout with manual and cloud login", async ({
page,
}) => {
await setupPage(page);
const rootLayout = await openAddModal(page);
// Left column: manual connection form is visible.
await expect(page.getByTestId("add-backend-form")).toBeVisible();
// Right column: cloud login section with device-flow OAuth.
await expect(page.getByTestId("add-backend-cloud-title")).toBeVisible();
await expect(page.getByTestId("add-backend-device-flow")).toBeVisible();
await expect(page.getByTestId("add-backend-login-button")).toBeVisible();
await expect(rootLayout).toHaveScreenshot(
"backend-add-two-column-layout.png",
SNAP_OPTS,
);
});
// ── Flow 3: Cloud login column ────────────────────────────────────────
test("Flow 3 — cloud login column shows advanced host override", async ({
page,
}) => {
await setupPage(page);
const rootLayout = await openAddModal(page);
// Expand advanced settings in the cloud column.
await page.getByTestId("add-backend-advanced-toggle").click();
await expect(page.getByTestId("add-backend-cloud-host")).toBeVisible({
timeout: 3_000,
});
await expect(rootLayout).toHaveScreenshot(
"backend-add-cloud-advanced-open.png",
SNAP_OPTS,
);
});
// ── Flow 4: Remove backend with confirmation ──────────────────────────
test("Flow 4 — removing a backend: confirmation modal then row disappears", async ({
page,
}) => {
await setupPage(page, { backends: [LOCAL_BACKEND, CLOUD_BACKEND] });
const rootLayout = await openManageModal(page);
// Both backend rows visible.
await expect(
page.getByTestId("manage-backends-row-Local"),
).toBeVisible();
await expect(
page.getByTestId("manage-backends-row-Production"),
).toBeVisible();
await expect(rootLayout).toHaveScreenshot(
"backend-manage-two-listed.png",
SNAP_OPTS,
);
// Click Remove on "Production".
await page.getByTestId("manage-backends-remove-Production").click();
// ConfirmationModal should appear with the backend name in the text.
await expect(page.getByTestId("confirmation-modal")).toBeVisible({
timeout: 5_000,
});
await expect(rootLayout).toHaveScreenshot(
"backend-remove-confirmation.png",
SNAP_OPTS,
);
// Click Cancel — Production row should still be present.
await page.getByTestId("cancel-button").click();
await expect(page.getByTestId("confirmation-modal")).not.toBeVisible({
timeout: 3_000,
});
await expect(
page.getByTestId("manage-backends-row-Production"),
).toBeVisible();
await expect(rootLayout).toHaveScreenshot(
"backend-remove-cancelled.png",
SNAP_OPTS,
);
// Remove again and CONFIRM this time.
await page.getByTestId("manage-backends-remove-Production").click();
await expect(page.getByTestId("confirmation-modal")).toBeVisible({
timeout: 5_000,
});
await page.getByTestId("confirm-button").click();
// Row disappears from the manage list.
await expect(
page.getByTestId("manage-backends-row-Production"),
).not.toBeVisible({ timeout: 5_000 });
await expect(rootLayout).toHaveScreenshot(
"backend-manage-after-removal.png",
SNAP_OPTS,
);
});
// ── Flow 5: Edit backend modal pre-fills form ─────────────────────────
test("Flow 5 — edit modal pre-populates existing backend's name, host and key", async ({
page,
}) => {
await setupPage(page, { backends: [LOCAL_BACKEND, CLOUD_BACKEND] });
const rootLayout = await openManageModal(page);
// Open Edit for the Production backend.
await page.getByTestId("manage-backends-edit-Production").click();
await expect(page.getByTestId("edit-backend-modal")).toBeVisible({
timeout: 5_000,
});
// Assert the pre-filled values.
await expect(page.getByTestId("edit-backend-name")).toHaveValue(
CLOUD_BACKEND.name,
);
await expect(page.getByTestId("edit-backend-host")).toHaveValue(
CLOUD_BACKEND.host,
);
await expect(rootLayout).toHaveScreenshot(
"backend-edit-prefilled.png",
SNAP_OPTS,
);
});
// ── Flow 6: Switch active backend ────────────────────────────────────
test("Flow 6 — switching backends shows environment-switch overlay then updates selector", async ({
page,
}) => {
// Start with Local active; Production is a second registered backend.
await setupPage(page, {
backends: [LOCAL_BACKEND, CLOUD_BACKEND],
activeBackendId: LOCAL_BACKEND.id,
});
const rootLayout = await openDropdown(page);
// Both options should be visible in the open dropdown.
await expect(page.getByRole("option", { name: "Local" })).toBeVisible();
await expect(
page.getByRole("option", { name: "Production" }),
).toBeVisible();
await expect(rootLayout).toHaveScreenshot(
"backend-dropdown-two-backends.png",
SNAP_OPTS,
);
// Click Production option — triggers the environment-switch overlay.
// The overlay is rendered via createPortal into document.body, so it
// lives outside the root-layout subtree. Use a full-page screenshot
// to capture it reliably.
//
// body[data-environment-switching="true"] is set synchronously inside
// triggerEnvironmentSwitch before any React re-render, giving us a
// stable early signal that the overlay is imminent even before React
// paints the portal div.
await page.getByRole("option", { name: "Production" }).click();
await page.waitForSelector('body[data-environment-switching="true"]', {
timeout: 2_000,
});
// Now wait for the actual portal div (React needs one render tick).
await page.waitForSelector('[data-testid="environment-switch-overlay"]', {
timeout: 2_000,
});
// The overlay card animates from opacity:0 → 1 over 980ms. Playwright's
// `animations: "disabled"` freezes CSS animations at frame 0, making the
// card invisible in the screenshot. Override that so the card renders
// fully opaque for a deterministic snapshot.
await page.addStyleTag({
content:
".environment-switch-overlay > div { animation: none !important; opacity: 1 !important; transform: none !important; }",
});
await expect(page).toHaveScreenshot("backend-switch-overlay.png", SNAP_OPTS);
// After overlay fades (980 ms), the selector should show "Production".
await page.waitForSelector('[data-testid="environment-switch-overlay"]', {
state: "hidden",
timeout: 3_000,
});
// Re-hover to show the updated active backend in the dropdown.
await page.getByTestId("backend-selector").hover();
await expect(
page.getByRole("option", { name: "Production" }),
).toBeVisible({ timeout: 5_000 });
await expect(rootLayout).toHaveScreenshot(
"backend-after-switch.png",
SNAP_OPTS,
);
});
// ── Flow 7: Malformed/empty host ──────────────────────────────────────
test("Flow 7 — empty or invalid host keeps Connect disabled", async ({
page,
}) => {
await setupPage(page);
const rootLayout = await openAddModal(page);
// Seed just the name, leave host blank.
await page.getByTestId("add-backend-name").fill("Bad URL Test");
// Whitespace-only host → isValidHostUrl returns false → disabled.
await page.getByTestId("add-backend-host").fill(" ");
await expect(page.getByTestId("add-backend-submit")).toBeDisabled();
await expect(rootLayout).toHaveScreenshot(
"backend-add-whitespace-host-disabled.png",
SNAP_OPTS,
);
// A syntactically invalid URL is rejected by isValidHostUrl().
await page.getByTestId("add-backend-host").fill("not://:::a valid url!!!");
await expect(page.getByTestId("add-backend-submit")).toBeDisabled();
await expect(rootLayout).toHaveScreenshot(
"backend-add-invalid-url-disabled.png",
SNAP_OPTS,
);
});
// ── Flow 8: Cancel add form ───────────────────────────────────────────
test("Flow 8 — closing the add form dismisses modal without persisting data", async ({
page,
}) => {
await setupPage(page);
const rootLayout = await openAddModal(page);
// Partially fill the form.
await page.getByTestId("add-backend-name").fill("Temp Backend");
await page.getByTestId("add-backend-host").fill("http://localhost:9999");
await expect(rootLayout).toHaveScreenshot(
"backend-add-form-partially-filled.png",
SNAP_OPTS,
);
// Click the close (✕) button.
await page.getByTestId("add-backend-close").click();
// Modal is dismissed.
await expect(page.getByTestId("add-backend-modal")).not.toBeVisible({
timeout: 5_000,
});
// Open Manage Backends to confirm "Temp Backend" was NOT saved.
await page.getByTestId("backend-selector").hover();
await expect(page.getByTestId("manage-backends-menu-item")).toBeVisible({
timeout: 5_000,
});
await page.getByTestId("manage-backends-menu-item").click();
await expect(page.getByTestId("manage-backends-modal")).toBeVisible({
timeout: 5_000,
});
// Only the original "Local" backend should be present.
await expect(
page.getByTestId("manage-backends-row-Local"),
).toBeVisible();
await expect(
page.locator('[data-testid*="manage-backends-row-Temp"]'),
).not.toBeVisible();
await expect(rootLayout).toHaveScreenshot(
"backend-cancel-nothing-saved.png",
SNAP_OPTS,
);
});
});
@@ -1,138 +0,0 @@
import { test, expect, Page } from "@playwright/test";
import { seedLocalStorage } from "./support/seed-local-storage";
/**
* Visual snapshot tests for the backend management UI.
*
* The BackendSelector lives in the sidebar footer and opens a dropdown on
* hover. Its footer contains two action buttons:
* - data-testid="add-backend-menu-item" → opens BackendFormModal (add)
* - data-testid="manage-backends-menu-item" → opens ManageBackendsModal
*
* Backend state is seeded from the registry's default local backend
* (DEFAULT_LOCAL_BACKEND_NAME = "Local") which is auto-created in
* localStorage on first load.
*
* Three snapshots are captured:
* 1. Selector dropdown open — shows the "Local" backend with status dot
* and the Add / Manage footer actions.
* 2. Add Backend modal — BackendFormModal in "add" mode (empty form).
* 3. Manage Backends modal — ManageBackendsModal listing the default backend.
*/
async function dismissConsentModal(page: Page) {
await page
.getByRole("button", { name: "Confirm preferences" })
.click({ timeout: 3_000 })
.catch(() => undefined);
}
async function setupMocks(page: Page) {
await seedLocalStorage(page);
// Suppress file-API proxy errors emitted when the home page scans the
// workspace directory (same suppression used in sidebar.snapshot.spec.ts).
await page.route("**/api/file/**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ path: "/home", subdirs: [] }),
});
});
}
/**
* Navigate to the home page, wait for it to stabilise, then hover over the
* backend selector to open the dropdown. Returns the rootLayout locator.
*/
async function openBackendDropdown(page: Page) {
await page.goto("/conversations");
await dismissConsentModal(page);
await page.waitForLoadState("networkidle");
// Wait for the sidebar to be fully rendered.
const rootLayout = page.getByTestId("root-layout");
await expect(rootLayout).toBeVisible({ timeout: 15_000 });
// The BackendSelector renders its Dropdown with openOnHover=true in the
// expanded sidebar footer. Hovering over data-testid="backend-selector"
// fires onMouseEnter → openMenu().
const backendSelector = page.getByTestId("backend-selector");
await expect(backendSelector).toBeVisible({ timeout: 10_000 });
await backendSelector.hover();
// Wait for the dropdown footer actions to confirm the menu is open.
await expect(page.getByTestId("add-backend-menu-item")).toBeVisible({
timeout: 5_000,
});
return rootLayout;
}
// ── Tests ─────────────────────────────────────────────────────────────────────
test.describe("Backend Management Visual Snapshots", () => {
test.setTimeout(60_000);
test("backend selector dropdown shows registered backend with status dot", async ({
page,
}) => {
await setupMocks(page);
const rootLayout = await openBackendDropdown(page);
await expect(rootLayout).toHaveScreenshot("backend-selector-open.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
test("add backend modal opens with empty form", async ({ page }) => {
await setupMocks(page);
const rootLayout = await openBackendDropdown(page);
// Click "Add backend" in the dropdown footer.
// onMouseDown has stopPropagation to keep the menu open; onClick opens the modal.
await page.getByTestId("add-backend-menu-item").click();
// BackendFormModal (mode="add") has data-testid="add-backend-modal".
await expect(page.getByTestId("add-backend-modal")).toBeVisible({
timeout: 5_000,
});
// Wait for the name input to confirm the form has rendered.
await expect(page.getByTestId("add-backend-name")).toBeVisible({
timeout: 5_000,
});
await expect(rootLayout).toHaveScreenshot("backend-add-modal.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
test("manage backends modal lists the default local backend", async ({
page,
}) => {
await setupMocks(page);
const rootLayout = await openBackendDropdown(page);
// Click "Manage backends" in the dropdown footer.
await page.getByTestId("manage-backends-menu-item").click();
// ManageBackendsModal has data-testid="manage-backends-modal".
await expect(page.getByTestId("manage-backends-modal")).toBeVisible({
timeout: 5_000,
});
// Confirm at least one backend row is visible (the default "Local" backend).
// Row testids follow the pattern: manage-backends-row-${backend.name}.
await expect(page.getByTestId("manage-backends-row-Local")).toBeVisible({
timeout: 5_000,
});
await expect(rootLayout).toHaveScreenshot("backend-manage-modal.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
});
@@ -1,246 +0,0 @@
import { test, expect, Page } from "@playwright/test";
import { seedLocalStorage } from "./support/seed-local-storage";
import { stubWebSocket } from "./support/stub-websocket";
/**
* Visual snapshot tests for the Changes (diff viewer) UI.
*
* The Changes view is rendered by src/routes/changes-tab.tsx inside the Files
* tab (src/routes/files-tab.tsx) when the "Diff view" toggle is ON. We force
* that toggle by pre-seeding the conversation's localStorage state with
* `filesTabDiffView: true` before navigation.
*
* MSW pre-seeds three git changes in src/mocks/git-repository-handlers.ts:
* - src/components/hello.tsx (M — modified)
* - src/utils/new-helper.ts (A — added)
* - src/old-module.py (D — deleted)
*
* Three snapshots are captured:
* 1. Empty state — no files changed (window.__setMockGitChanges__([]) used
* to clear MSW's in-memory list after boot, then a query invalidation
* triggers a re-fetch that returns []).
* 2. Diff viewer — modified file (hello.tsx) expanded to show Monaco.
* 3. Deleted file placeholder — deleted file (old-module.py) shows the
* "file deleted" message instead of a Monaco editor (the diff query is
* disabled for type "D" per useUnifiedGitDiff).
*
* NOTE on MSW vs page.route():
* MSW 2.x browser-mode handlers run in the page's main thread, not the
* service worker. Playwright's page.route() is blocked by the service worker
* for same-origin requests. We therefore manipulate MSW state via
* page.evaluate() rather than page.route() (same pattern as the automations
* empty-state test).
*/
// Mock conversation IDs "1", "2", "3" are pre-defined in MSW handlers.
const CONVERSATION_ID = "1";
// Pre-enable diff view for conversation 1.
// NOTE: rightPanelShown is intentionally omitted — it is session-only state
// stripped by sanitizeStoredState on read. The right panel is opened
// programmatically via a right-panel-toggle click in navigateAndWaitForFilesTab.
const CONVERSATION_STATE_KEY = `conversation-state-${CONVERSATION_ID}`;
const CONVERSATION_STATE_VALUE = JSON.stringify({
selectedTab: "files",
filesTabDiffView: true,
filesTabContentViewMode: "rich",
unpinnedTabs: [],
conversationMode: "code",
subConversationTaskId: null,
draftMessage: null,
});
/**
* Skip onboarding and pre-enable the diff view for conversation 1.
*/
async function setupMocks(page: Page) {
await seedLocalStorage(page, {
extra: [[CONVERSATION_STATE_KEY, CONVERSATION_STATE_VALUE]],
});
await stubWebSocket(page);
}
async function dismissConsentModal(page: Page) {
await page
.getByRole("button", { name: "Confirm preferences" })
.click({ timeout: 3_000 })
.catch(() => undefined);
}
/**
* Navigate to the conversation and wait for the Files tab (diff view) to
* be rendered. Returns the `data-testid="files-tab"` locator.
*
* `isRightPanelShown` is session-only Zustand state (always false on load;
* `sanitizeStoredState` strips any persisted `rightPanelShown` key).
* We open the right panel by clicking the `right-panel-toggle` button,
* which calls `setHasRightPanelToggled(true)` → synced to
* `setIsRightPanelShown(true)` by `use-chat-input-logic`.
*/
async function navigateAndWaitForFilesTab(page: Page) {
await page.goto(`/conversations/${CONVERSATION_ID}`, {
waitUntil: "domcontentloaded",
});
await dismissConsentModal(page);
// Open the right panel — it always starts closed on page load.
const toggle = page.getByTestId("right-panel-toggle");
await expect(toggle).toBeVisible({ timeout: 15_000 });
await toggle.click();
// The FilesTab is lazy-loaded inside the now-open right panel.
const filesTab = page.getByTestId("files-tab");
await expect(filesTab).toBeVisible({ timeout: 20_000 });
return filesTab;
}
// ── Tests ─────────────────────────────────────────────────────────────────────
test.describe("Changes Tab Visual Snapshots", () => {
// Heavier conversation-page setup — run serially to avoid flakiness.
test.describe.configure({ mode: "serial" });
test.setTimeout(60_000);
test("changes tab shows empty state when no files changed", async ({
page,
}) => {
// MSW pre-seeds MOCK_GIT_CHANGES with M/A/D files. We call the exposed
// window setter (installed by git-repository-handlers.ts) AFTER the app
// boots to replace the list with [], then ask React Query to refetch.
// This avoids a page.reload() which would re-seed the module state.
await setupMocks(page);
await navigateAndWaitForFilesTab(page);
// Pin the RandomTip section to a fixed height so the flex-1 container
// above it is deterministic across runs. RandomTip renders a randomly
// selected tip whose text can vary in line count, causing different
// layout heights between the baseline-generation run and verification run.
// The class combination ".text-m.bg-tertiary.p-4" is unique to this element
// in changes-tab.tsx (confirmed by grep). Hiding the content removes the
// visual variable; the fixed height keeps the surrounding flex layout stable.
await page.addStyleTag({
content: `.text-m.bg-tertiary.p-4 {
height: 80px !important;
overflow: hidden !important;
visibility: hidden !important;
}`,
});
// Wait for the initial (non-empty) render to settle before mutating state.
await expect(
page.locator('[data-testid="file-diff-viewer-outer"]').first(),
).toBeVisible({ timeout: 10_000 });
// Clear the changes via the exposed window helper and refetch.
await page.evaluate(() => {
(
window as unknown as {
__setMockGitChanges__?: (changes: unknown[]) => void;
}
).__setMockGitChanges__?.([]);
});
await page.evaluate(() => {
(
window as unknown as {
__TEST_INVALIDATE_QUERIES__?: (queryKey?: unknown[]) => void;
}
).__TEST_INVALIDATE_QUERIES__?.(["file_changes"]);
});
// Wait for the empty-state message from EmptyChangesMessage component.
await expect(
page.getByText("OpenHands hasn't made any changes yet"),
).toBeVisible({ timeout: 10_000 });
// Screenshot the diff-content div (direct child of files-tab when diff view
// is enabled) rather than the full files-tab. This avoids capturing any
// adjacent-panel artefacts that may bleed into the outer element's bounding
// box during CI rendering, while still showing the full empty-state UI.
//
// DOM path: main[data-testid="files-tab"] > div.flex-1.min-h-0 > main
// The inner <main> rendered by GitChanges is the safest stable target.
const changesContent = page
.getByTestId("files-tab")
.locator("> div")
.last();
await expect(changesContent).toHaveScreenshot("changes-empty.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
test("changes tab shows file list and diff viewer for modified file", async ({
page,
}) => {
// MOCK_GIT_CHANGES is pre-seeded; the file list renders without any override.
await setupMocks(page);
const filesTab = await navigateAndWaitForFilesTab(page);
// Wait for at least one file row to appear.
await expect(
page.locator('[data-testid="file-diff-viewer-outer"]').first(),
).toBeVisible({ timeout: 10_000 });
// Click the modified file (hello.tsx) header row to expand the diff editor.
// The header row is the first child div of file-diff-viewer-outer and has
// the cursor-pointer class; clicking the strong element (file path) is the
// most reliable targeting.
await page
.locator('[data-testid="file-diff-viewer-outer"]')
.filter({ hasText: "hello.tsx" })
.locator("strong")
.click();
// Wait for the EditorContainer (wraps the Monaco DiffEditor) to appear.
await expect(page.getByTestId("editor-container").first()).toBeVisible({
timeout: 10_000,
});
// Mask the Monaco DiffEditor container. Monaco renders text content
// progressively and uses sub-pixel font hinting that varies between OS/CI
// environments. Masking editor-container captures the panel layout (toolbar,
// file list, editor frame) without the volatile text-rendering pixels.
await expect(filesTab).toHaveScreenshot("changes-diff-viewer.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
mask: [page.getByTestId("editor-container")],
});
});
test("changes tab shows deleted-file placeholder instead of diff editor", async ({
page,
}) => {
// src/old-module.py has type "D" (deleted). useUnifiedGitDiff disables the
// query for deleted files; clicking the row expands the file-deleted-message
// placeholder instead of a Monaco editor.
await setupMocks(page);
const filesTab = await navigateAndWaitForFilesTab(page);
// Wait for at least one file row to appear.
await expect(
page.locator('[data-testid="file-diff-viewer-outer"]').first(),
).toBeVisible({ timeout: 10_000 });
// Click the deleted file row to expand it.
await page
.locator('[data-testid="file-diff-viewer-outer"]')
.filter({ hasText: "old-module.py" })
.locator("strong")
.click();
// The deleted-file placeholder (data-testid="file-deleted-message") is
// shown when !isCollapsed && type === "D".
await expect(page.getByTestId("file-deleted-message")).toBeVisible({
timeout: 10_000,
});
await expect(filesTab).toHaveScreenshot("changes-deleted-file.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
});
@@ -1,364 +0,0 @@
import { test, expect, Page } from "@playwright/test";
import { seedLocalStorage } from "./support/seed-local-storage";
import { stubWebSocket } from "./support/stub-websocket";
/**
* Visual snapshot tests for the CollapsibleThinking component.
*
* These tests navigate to a conversation in the mock dev server, inject
* synthetic events into the event store, and capture screenshots of the
* collapsible thinking sections in both collapsed and expanded states.
*
* The mock dev server (VITE_MOCK_API=true) uses MSW service workers to
* handle API requests. We rely on MSW for conversations, settings, etc.
* and only inject events directly into the Zustand store.
*
* To update baselines after intentional UI changes:
* npm run test:e2e:snapshots:update
*/
// Use mock conversation ID "1" which is pre-defined in MSW handlers
const CONVERSATION_ID = "1";
/** ThinkAction event with thinking content */
const THINK_ACTION_EVENT = {
id: "think-event-1",
timestamp: "2026-01-01T00:00:01.000Z",
source: "agent",
thought: [
{
type: "text",
text: 'think: {"thought": "I need to analyze the codebase structure first. Let me look at the directory layout and understand the project architecture before making any changes."}',
},
],
reasoning_content: null,
thinking_blocks: [],
action: {
kind: "ThinkAction",
thought:
"I need to analyze the codebase structure first. Let me look at the directory layout and understand the project architecture before making any changes.",
},
tool_name: "think",
tool_call_id: "call_think_1",
tool_call: {
id: "call_think_1",
type: "function",
function: {
name: "think",
arguments: JSON.stringify({
thought:
"I need to analyze the codebase structure first. Let me look at the directory layout and understand the project architecture before making any changes.",
}),
},
},
llm_response_id: "response_1",
security_risk: "unknown",
};
/** Bash action event with reasoning_content (extended thinking) */
const BASH_WITH_REASONING_EVENT = {
id: "bash-event-1",
timestamp: "2026-01-01T00:00:02.000Z",
source: "agent",
thought: [
{
type: "text",
text: "Let me list the project files to understand the structure.",
},
],
reasoning_content:
"The user wants to understand the project. I should start by listing the files in the root directory. This will give us a high-level overview of the codebase. Then I can dive deeper into specific areas based on what we find.",
thinking_blocks: [],
action: {
kind: "ExecuteBashAction",
command: "ls -la",
is_input: false,
timeout: null,
reset: false,
},
tool_name: "execute_bash",
tool_call_id: "call_bash_1",
tool_call: {
id: "call_bash_1",
type: "function",
function: {
name: "execute_bash",
arguments: JSON.stringify({ command: "ls -la" }),
},
},
llm_response_id: "response_2",
security_risk: "unknown",
};
/** User message event */
const USER_MESSAGE_EVENT = {
id: "user-msg-1",
timestamp: "2026-01-01T00:00:00.000Z",
source: "user",
llm_message: {
role: "user",
content: [{ type: "text", text: "Help me understand this project" }],
},
activated_microagents: [],
extended_content: [],
};
/**
* Dismisses the analytics consent modal if it appears.
* The MSW mock settings don't include user_consents_to_analytics, so
* the consent modal may appear on first load.
*/
async function dismissConsentModal(page: Page) {
const confirmButton = page.getByRole("button", {
name: "Confirm preferences",
});
try {
await confirmButton.waitFor({ state: "visible", timeout: 5000 });
await confirmButton.click();
await page
.getByRole("dialog", { name: "Help improve OpenHands" })
.waitFor({ state: "hidden", timeout: 5000 });
} catch {
// Modal didn't appear — that's fine
}
}
/**
* Inject events into the event store via the exposed Zustand API.
*/
async function injectEvents(page: Page, events: unknown[]) {
await page.waitForFunction(() => {
const store = (
window as unknown as {
__OH_EVENT_STORE__?: {
getState: () => { addEvents?: (e: unknown[]) => void };
};
}
).__OH_EVENT_STORE__;
return Boolean(store?.getState().addEvents);
});
await expect
.poll(
async () =>
page.evaluate((evts) => {
const store = (
window as unknown as {
__OH_EVENT_STORE__?: {
getState: () => {
addEvents: (e: unknown[]) => void;
events: unknown[];
};
};
}
).__OH_EVENT_STORE__;
if (!store) {
return 0;
}
const state = store.getState();
state.addEvents(evts);
return store.getState().events.length;
}, events),
{ timeout: 10000 },
)
.toBeGreaterThanOrEqual(events.length);
// Wait for React to re-render
await page.waitForTimeout(500);
}
/**
* Navigate to a conversation page and seed it with events so the chat
* interface renders instead of the empty launch prompt.
* Uses mock conversation "1" which exists in the MSW handlers.
*/
async function navigateToConversation(page: Page, events: unknown[]) {
await seedLocalStorage(page);
await page.route("**/api/bash/execute_bash_command", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
command: "ls -la",
exit_code: 0,
output: "",
}),
});
});
await page.route("**/api/file/**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ path: "/home", subdirs: [] }),
});
});
// The GUI hides ChatSuggestions (and shows the "LLM not configured" banner)
// unless an active profile with a key exists — see useLlmConfigured. Seed
// one so the empty conversation renders normally and the banner stays out
// of the captured screenshots (matching the baselines).
await page.route(
(url) => url.pathname.endsWith("/api/profiles"),
async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
profiles: [
{
name: "mock",
model: "anthropic/claude-sonnet-4-20250514",
base_url: null,
api_key_set: true,
},
],
active_profile: "mock",
}),
});
},
);
await stubWebSocket(page);
await page.goto(`/conversations/${CONVERSATION_ID}`, {
waitUntil: "domcontentloaded",
});
await dismissConsentModal(page);
// Wait for the conversation route to mount before injecting events.
// ConversationWebSocketContext runs a useLayoutEffect on mount that
// calls clearEventsForConversation(<id>) — if we inject events first,
// that effect wipes them and the UI renders the empty state. Use the
// store's `loadedConversationId` as a route-stable readiness signal:
// it flips from null → CONVERSATION_ID inside that layoutEffect, so
// observing it means the clear-and-set has already happened and any
// subsequent addEvents will survive.
//
// We deliberately don't wait on the home route's "Let's start
// building!" text here — it only renders on the home route, so relying
// on a brief home flash before the conversation hydrates is racy and
// caused the 20s timeout flake tracked in #1200.
await page.waitForFunction(
(expectedId) => {
const store = (
window as unknown as {
__OH_EVENT_STORE__?: {
getState: () => { loadedConversationId: string | null };
};
}
).__OH_EVENT_STORE__;
return store?.getState().loadedConversationId === expectedId;
},
CONVERSATION_ID,
{ timeout: 20000 },
);
await injectEvents(page, events);
const chatInterface = page.getByTestId("chat-interface");
await expect(chatInterface).toBeVisible({ timeout: 20000 });
return chatInterface;
}
test.describe("Collapsible Thinking Visual Snapshots", () => {
// Run serially: the conversation page + WebSocket stub is heavier than
// static pages, and parallel workers hitting the shared dev server can
// cause intermittent load failures.
test.describe.configure({ mode: "serial" });
test.setTimeout(60000);
test("ThinkAction renders as collapsed section", async ({ page }) => {
const chatInterface = await navigateToConversation(page, [
USER_MESSAGE_EVENT,
THINK_ACTION_EVENT,
]);
// Verify the collapsible thinking section is rendered
const collapsibleThinking = page.getByTestId("collapsible-thinking");
await expect(collapsibleThinking).toBeVisible({ timeout: 5000 });
// The content should be collapsed (not visible)
const content = page.getByTestId("collapsible-thinking-content");
await expect(content).toHaveCount(0);
// Snapshot: collapsed state
await expect(chatInterface).toHaveScreenshot("think-action-collapsed.png", {
maxDiffPixelRatio: 0.01,
animations: "disabled",
});
});
test("ThinkAction expands on click", async ({ page }) => {
const chatInterface = await navigateToConversation(page, [
USER_MESSAGE_EVENT,
THINK_ACTION_EVENT,
]);
const toggle = page.getByTestId("collapsible-thinking-toggle");
await expect(toggle).toBeVisible({ timeout: 5000 });
// Click to expand
await toggle.click();
// Content should now be visible
const content = page.getByTestId("collapsible-thinking-content");
await expect(content).toBeVisible({ timeout: 5000 });
// Snapshot: expanded state
await expect(chatInterface).toHaveScreenshot("think-action-expanded.png", {
maxDiffPixelRatio: 0.01,
animations: "disabled",
});
});
test("Reasoning content renders as collapsed section with action", async ({
page,
}) => {
const chatInterface = await navigateToConversation(page, [
USER_MESSAGE_EVENT,
BASH_WITH_REASONING_EVENT,
]);
// Verify both the collapsible section and the action event are present
const collapsibleThinking = page.getByTestId("collapsible-thinking");
await expect(collapsibleThinking).toBeVisible({ timeout: 5000 });
// Snapshot: reasoning content collapsed alongside the bash action
await expect(chatInterface).toHaveScreenshot(
"reasoning-content-collapsed.png",
{
maxDiffPixelRatio: 0.01,
animations: "disabled",
},
);
});
test("Reasoning content expands on click", async ({ page }) => {
const chatInterface = await navigateToConversation(page, [
USER_MESSAGE_EVENT,
BASH_WITH_REASONING_EVENT,
]);
const toggle = page.getByTestId("collapsible-thinking-toggle");
await expect(toggle).toBeVisible({ timeout: 5000 });
// Click to expand
await toggle.click();
const content = page.getByTestId("collapsible-thinking-content");
await expect(content).toBeVisible({ timeout: 5000 });
// Snapshot: reasoning content expanded
await expect(chatInterface).toHaveScreenshot(
"reasoning-content-expanded.png",
{
maxDiffPixelRatio: 0.01,
animations: "disabled",
},
);
});
});
@@ -1,342 +0,0 @@
import { test, expect, Page } from "@playwright/test";
import { seedLocalStorage } from "./support/seed-local-storage";
/**
* Visual snapshot tests for the MCP page (/mcp).
*
* The MCP marketplace catalog is imported from @openhands/extensions/integrations,
* so it never requires an API call. Installed servers are read from
* settings.agent_settings.mcp_config (SDK format: { mcpServers: { ... } }).
*
* Three states are covered:
* 1. No installed servers – empty installed section, full marketplace visible
* 2. Two installed servers (one SSE, one stdio)
* 3. Search query "slack" filtering both sections simultaneously
*/
/**
* Dismiss the analytics consent modal if MSW shows it (settings return
* user_consents_to_analytics: null by default in mock mode).
*/
async function dismissConsentModal(page: Page) {
await page
.getByRole("button", { name: "Confirm preferences" })
.click({ timeout: 3_000 })
.catch(() => undefined);
}
/**
* Wire up the base routes every MCP page test needs.
*
* Settings are owned here rather than by the real local backend. MCP saves do a
* pre-clear PATCH followed by the new config write, so the snapshot flow needs
* a deterministic in-memory settings response.
*/
async function setupMocks(page: Page) {
await seedLocalStorage(page);
let agentSettings: Record<string, unknown> = {};
let conversationSettings: Record<string, unknown> = {};
await page.route("**/api/settings", async (route) => {
const request = route.request();
if (request.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
agent_settings: agentSettings,
conversation_settings: conversationSettings,
llm_api_key_is_set: true,
}),
});
return;
}
if (request.method() === "PATCH") {
const body = request.postDataJSON() as {
agent_settings_diff?: Record<string, unknown>;
conversation_settings_diff?: Record<string, unknown>;
};
agentSettings = {
...agentSettings,
...(body.agent_settings_diff ?? {}),
};
conversationSettings = {
...conversationSettings,
...(body.conversation_settings_diff ?? {}),
};
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
agent_settings: agentSettings,
conversation_settings: conversationSettings,
llm_api_key_is_set: true,
}),
});
return;
}
await route.fallback();
});
await page.route("**/api/mcp/test", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ ok: true, tools: ["mock_tool"] }),
});
});
await page.route("**/api/conversations/search**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ results: [] }),
});
});
}
test.describe("MCP Page Visual Snapshots", () => {
test.setTimeout(60_000);
test("empty installed section with marketplace renders correctly", async ({
page,
}) => {
// MSW settings have no mcp_config → installed section is empty
await setupMocks(page);
await page.goto("/mcp");
await dismissConsentModal(page);
await page.waitForLoadState("networkidle");
const mcpPage = page.getByTestId("mcp-page");
await expect(mcpPage).toBeVisible({ timeout: 15_000 });
await expect(mcpPage).toHaveScreenshot("mcp-empty-installed.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
test("add custom server editor form renders correctly", async ({ page }) => {
// The "Add custom server" modal does not depend on settings state so it
// is reliably testable regardless of MSW's default settings response.
await setupMocks(page);
await page.goto("/mcp");
await dismissConsentModal(page);
await page.waitForLoadState("networkidle");
const mcpPage = page.getByTestId("mcp-page");
await expect(mcpPage).toBeVisible({ timeout: 15_000 });
// Open the custom server editor
await page.getByTestId("mcp-add-custom-server").click();
// Wait for the editor form to appear inside the modal
const modal = page.locator(".fixed.inset-0").last();
await expect(modal).toBeVisible({ timeout: 5_000 });
await expect(page.getByTestId("root-layout")).toHaveScreenshot(
"mcp-custom-server-editor.png",
{ animations: "disabled", maxDiffPixelRatio: 0.01 },
);
});
test("search query filters marketplace", async ({ page }) => {
await setupMocks(page);
await page.goto("/mcp");
await dismissConsentModal(page);
await page.waitForLoadState("networkidle");
const mcpPage = page.getByTestId("mcp-page");
await expect(mcpPage).toBeVisible({ timeout: 15_000 });
// Type "slack" into the unified search box
const searchInput = page.getByTestId("mcp-search-input");
await expect(searchInput).toBeVisible({ timeout: 5_000 });
await searchInput.fill("slack");
// Wait for the filtered results to stabilise
await page.waitForTimeout(300);
await expect(mcpPage).toHaveScreenshot("mcp-search-filtered.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
/**
* Iterative snapshot test: install the Slack server from the marketplace.
*
* Simulates the full user journey with one snapshot per step:
* step 1 – marketplace view with Slack card visible
* step 2 – Slack install modal open with empty fields
* step 3 – Slack install modal with bot-token and team-ID filled in
* step 4 – after clicking Install, Slack card appears in Installed section
*
* The MSW settings PATCH handler persists the new mcp_config so the
* refetch shows the installed server without any page.route() trickery.
*/
test("install Slack from marketplace (iterative snapshots)", async ({
page,
}) => {
await setupMocks(page);
await page.goto("/mcp");
await dismissConsentModal(page);
await page.waitForLoadState("networkidle");
const mcpPage = page.getByTestId("mcp-page");
const rootLayout = page.getByTestId("root-layout");
await expect(mcpPage).toBeVisible({ timeout: 15_000 });
await test.step("step 1 – marketplace before install", async () => {
await expect(page.getByTestId("mcp-marketplace-card-slack")).toBeVisible({
timeout: 5_000,
});
await expect(mcpPage).toHaveScreenshot(
"mcp-slack-install-1-marketplace.png",
{
animations: "disabled",
maxDiffPixelRatio: 0.01,
},
);
});
await test.step("step 2 – Slack install modal open", async () => {
await page.getByTestId("mcp-marketplace-card-slack").click();
await expect(page.getByTestId("mcp-install-modal")).toBeVisible({
timeout: 5_000,
});
await expect(rootLayout).toHaveScreenshot(
"mcp-slack-install-2-modal.png",
{
animations: "disabled",
maxDiffPixelRatio: 0.01,
},
);
});
await test.step("step 3 – fill in bot token and team ID", async () => {
await page
.getByTestId("mcp-install-field-SLACK_BOT_TOKEN")
.fill("xoxb-test-bot-token-1234567890");
await page
.getByTestId("mcp-install-field-SLACK_TEAM_ID")
.fill("T01ABC123");
await expect(rootLayout).toHaveScreenshot(
"mcp-slack-install-3-filled.png",
{
animations: "disabled",
maxDiffPixelRatio: 0.01,
},
);
});
await test.step("step 4 – submit and confirm Slack is installed", async () => {
await page.getByTestId("mcp-install-submit").click();
// Modal should disappear once the mutation completes
await expect(page.getByTestId("mcp-install-modal")).not.toBeVisible({
timeout: 10_000,
});
await page.waitForLoadState("networkidle");
// The installed section should now contain a Slack server card
await expect(page.getByTestId("mcp-server-item")).toBeVisible({
timeout: 10_000,
});
// Brief wait to let the toast and any animations settle
await page.waitForTimeout(400);
await expect(mcpPage).toHaveScreenshot(
"mcp-slack-install-4-installed.png",
{
animations: "disabled",
maxDiffPixelRatio: 0.01,
},
);
});
});
/**
* Iterative snapshot test: manually add a custom SSE server via the editor.
*
* Simulates the full user journey with one snapshot per step:
* step 1 – custom server editor open (SSE type, empty fields)
* step 2 – URL field filled in
* step 3 – API key field filled in (optional field shown)
* step 4 – after clicking Add Server, the custom server appears installed
*
* Uses the same MSW settings PATCH + GET refetch flow as the marketplace
* install test so no page.route() overrides are needed.
*/
test("add custom SSE server via editor (iterative snapshots)", async ({
page,
}) => {
await setupMocks(page);
await page.goto("/mcp");
await dismissConsentModal(page);
await page.waitForLoadState("networkidle");
const mcpPage = page.getByTestId("mcp-page");
const rootLayout = page.getByTestId("root-layout");
await expect(mcpPage).toBeVisible({ timeout: 15_000 });
await test.step("step 1 – custom server editor open (empty)", async () => {
await page.getByTestId("mcp-add-custom-server").click();
await expect(page.getByTestId("mcp-custom-editor")).toBeVisible({
timeout: 5_000,
});
await expect(rootLayout).toHaveScreenshot(
"mcp-custom-server-1-editor-open.png",
{ animations: "disabled", maxDiffPixelRatio: 0.01 },
);
});
await test.step("step 2 – URL filled in", async () => {
await page
.getByTestId("url-input")
.fill("https://api.example-mcp.com/sse");
await expect(rootLayout).toHaveScreenshot(
"mcp-custom-server-2-url-filled.png",
{ animations: "disabled", maxDiffPixelRatio: 0.01 },
);
});
await test.step("step 3 – API key filled in (optional)", async () => {
await page.getByTestId("api-key-input").fill("test-api-key-xyz-123");
await expect(rootLayout).toHaveScreenshot(
"mcp-custom-server-3-all-filled.png",
{ animations: "disabled", maxDiffPixelRatio: 0.01 },
);
});
await test.step("step 4 – save and confirm custom server is installed", async () => {
await page.getByTestId("submit-button").click();
// Editor modal should close
await expect(page.getByTestId("mcp-custom-editor")).not.toBeVisible({
timeout: 10_000,
});
await page.waitForLoadState("networkidle");
// Installed section should now contain the custom SSE server
await expect(page.getByTestId("mcp-server-item")).toBeVisible({
timeout: 10_000,
});
await page.waitForTimeout(400);
await expect(mcpPage).toHaveScreenshot(
"mcp-custom-server-4-installed.png",
{ animations: "disabled", maxDiffPixelRatio: 0.01 },
);
});
});
});
@@ -1,166 +0,0 @@
import { test, expect, Page } from "@playwright/test";
import {
clickOnboardingStepButton,
ONBOARDING_AGENT_STEP,
ONBOARDING_BACKEND_STEP,
ONBOARDING_HELLO_STEP,
ONBOARDING_LLM_STEP,
waitForOnboardingBackendConnected,
waitForOnboardingLlmSettingsReady,
waitForOnboardingStep,
} from "../support/onboarding-helpers";
import { seedLocalStorage } from "./support/seed-local-storage";
/**
* Visual snapshot tests for the 4-step onboarding modal.
*
* The modal is shown automatically on first visit when the
* `openhands-onboarded` key is absent from localStorage.
* It lives on the home route (`routes/home.tsx` → `OnboardingHost`).
*
* Steps:
* 0. Check backend — backend form + connection status banner
* 1. Choose agent — static agent cards; mock mode renders deterministic ACP options
* 2. Setup LLM — LLM settings form (pre-filled with Anthropic/Claude Opus)
* 3. Say hello — pre-filled message input to start a conversation
*
* All four slides are mounted at once; inactive slides are translated
* off-screen and clipped by `overflow: clip`. We wait for the
* `data-current-step` attribute on the slide rail instead of relying on
* `toBeVisible()` for the step container elements, which can be unreliable
* for absolutely-positioned off-screen slides.
*
* In MSW mock mode `/server_info` returns HTTP 200 so the backend health
* probe in step 0 resolves to "connected", enabling the Next button.
*/
test.describe.configure({ mode: "serial" });
async function setupMocks(page: Page) {
// removeOnboarded: true ensures the onboarding modal appears.
// Analytics consent modal is suppressed (separate concern).
await seedLocalStorage(page, { removeOnboarded: true });
}
async function dismissConsentModal(page: Page) {
await page
.getByRole("button", { name: "Confirm preferences" })
.click({ timeout: 3_000 })
.catch(() => undefined);
}
test.describe("Onboarding Modal Visual Snapshots", () => {
test.setTimeout(60_000);
test("onboarding step 0 shows backend connection form", async ({ page }) => {
await setupMocks(page);
await page.goto("/");
await dismissConsentModal(page);
// Modal appears because openhands-onboarded is absent
await expect(page.getByTestId("onboarding-modal")).toBeVisible({
timeout: 10_000,
});
await waitForOnboardingStep(page, ONBOARDING_BACKEND_STEP);
// Wait for the backend connection banner to settle.
// In MSW mode /server_info returns 200, so the health probe should
// quickly resolve to "connected".
await waitForOnboardingBackendConnected(page);
const modal = page.getByTestId("onboarding-modal");
await expect(modal).toHaveScreenshot(
"onboarding-step-0-check-backend.png",
{ animations: "disabled", maxDiffPixelRatio: 0.01 },
);
});
test("onboarding step 1 shows agent selection cards", async ({ page }) => {
await setupMocks(page);
await page.goto("/");
await dismissConsentModal(page);
await expect(page.getByTestId("onboarding-modal")).toBeVisible({
timeout: 10_000,
});
await waitForOnboardingStep(page, ONBOARDING_BACKEND_STEP);
await waitForOnboardingBackendConnected(page);
await clickOnboardingStepButton(page, "onboarding-backend-next");
await waitForOnboardingStep(page, ONBOARDING_AGENT_STEP);
const modal = page.getByTestId("onboarding-modal");
await expect(modal).toHaveScreenshot("onboarding-step-1-choose-agent.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
test("onboarding step 2 shows LLM settings form", async ({ page }) => {
await setupMocks(page);
await page.goto("/");
await dismissConsentModal(page);
await expect(page.getByTestId("onboarding-modal")).toBeVisible({
timeout: 10_000,
});
await waitForOnboardingStep(page, ONBOARDING_BACKEND_STEP);
// Step 0 → 1
await waitForOnboardingBackendConnected(page);
await clickOnboardingStepButton(page, "onboarding-backend-next");
await waitForOnboardingStep(page, ONBOARDING_AGENT_STEP);
// Step 1 → 2
await clickOnboardingStepButton(page, "onboarding-agent-next");
await waitForOnboardingStep(page, ONBOARDING_LLM_STEP);
await waitForOnboardingLlmSettingsReady(page);
const modal = page.getByTestId("onboarding-modal");
await expect(modal).toHaveScreenshot("onboarding-step-2-setup-llm.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
test("onboarding step 3 shows pre-filled message input", async ({ page }) => {
await setupMocks(page);
await page.goto("/");
await dismissConsentModal(page);
await expect(page.getByTestId("onboarding-modal")).toBeVisible({
timeout: 10_000,
});
await waitForOnboardingStep(page, ONBOARDING_BACKEND_STEP);
// Step 0 → 1 (requires backend connected)
await waitForOnboardingBackendConnected(page);
await clickOnboardingStepButton(page, "onboarding-backend-next");
await waitForOnboardingStep(page, ONBOARDING_AGENT_STEP);
// Step 1 → 2
await clickOnboardingStepButton(page, "onboarding-agent-next");
await waitForOnboardingStep(page, ONBOARDING_LLM_STEP);
await waitForOnboardingLlmSettingsReady(page);
// Step 2 → 3:
// If the LLM form is dirty (it is, because ONBOARDING_LLM_OVERRIDES differs
// from the mock default model), clicking Next will trigger a PATCH settings
// mutation. MSW handles the PATCH and resolves onSaveSuccess → onNext.
await clickOnboardingStepButton(page, "onboarding-llm-next");
await waitForOnboardingStep(page, ONBOARDING_HELLO_STEP);
// Wait for the say-hello input to be ready
await expect(page.getByTestId("onboarding-hello-input")).toBeVisible({
timeout: 10_000,
});
const modal = page.getByTestId("onboarding-modal");
await expect(modal).toHaveScreenshot("onboarding-step-3-say-hello.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
});
@@ -1,57 +0,0 @@
import { expect, test } from "@playwright/test";
import { seedLocalStorage } from "./support/seed-local-storage";
test("captures /projects workspace browser state", async ({ page }) => {
test.setTimeout(60_000);
await seedLocalStorage(page, {
extra: [
["analytics-consent", "true"],
["openhands-telemetry-first-use", "true"],
],
});
await page.goto("/conversations", { waitUntil: "domcontentloaded" });
const consentDialog = page.getByRole("dialog", {
name: "Help improve OpenHands",
});
await page
.getByRole("button", { name: "Confirm preferences" })
.click({ timeout: 5000 })
.catch(() => undefined);
await expect(consentDialog).toHaveCount(0, { timeout: 5000 });
// The home screen is now a chat-first launcher (#514) — the workspace
// dropdown lives inside OpenWorkspaceDialog, opened via "Open workspace".
const openWorkspaceButton = page.getByTestId("open-workspace-button");
await expect(openWorkspaceButton).toBeEnabled({ timeout: 15_000 });
await openWorkspaceButton.click();
await expect(page.getByTestId("open-workspace-dialog-body")).toBeVisible();
const workspaceDropdown = page.getByTestId("workspace-dropdown");
await expect(workspaceDropdown).toBeEnabled({ timeout: 15_000 });
await workspaceDropdown.click();
await page.getByTestId("add-workspaces-button").click();
const modal = page.getByTestId("folder-browser-modal");
await expect(modal).toBeVisible();
await expect(page.getByTestId("folder-browser-current-path")).toHaveText(
"/projects",
);
await expect(
page.getByTestId("folder-browser-sidebar-/projects"),
).toBeVisible();
await expect(page.getByTestId("folder-browser-entry-demo-app")).toBeVisible();
await page.mouse.move(5, 5);
await expect(modal).toHaveScreenshot("projects-workspace-browser.png");
await page.getByTestId("folder-browser-entry-demo-app").click();
await expect(page.getByTestId("folder-browser-current-path")).toHaveText(
"/projects/demo-app",
);
await expect(
page.getByTestId("folder-browser-entry-web-client"),
).toBeVisible();
});
@@ -1,510 +0,0 @@
#!/usr/bin/env node
/**
* Post a snapshot test report as a PR comment with embedded images.
*
* Reads environment variables set by the snapshot-tests.yml workflow:
* GH_TOKEN — GitHub token for API calls and git push
* PR_NUMBER — Pull request number
* REPO — "owner/repo"
* RUN_ID — GitHub Actions run ID
* HEAD_REF — PR branch name (used only for the log message)
* MAIN_BASELINES_DIR — Path to the copied main-branch baselines (e.g. /tmp/main-baselines)
* SNAPSHOTS_APPROVED — "true" when the update-snapshots label is set
*
* The script:
* 1. Scans tests/e2e/__snapshots__/ (PR's current snapshots) and MAIN_BASELINES_DIR
* 2. Classifies each snapshot as NEW, CHANGED, or UNCHANGED
* 3. For CHANGED: locates diff/actual/expected images in test-results/
* 4. Creates an orphan commit on snapshot-artifacts/pr-<N> with the images and
* pushes it there (NOT to the PR branch — avoids invalidating required checks)
* 5. Posts or updates a PR comment with inline image tables using raw.githubusercontent.com URLs
*/
import { execSync } from "node:child_process";
import {
appendFileSync,
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
} from "node:fs";
import { basename, dirname, join, relative } from "node:path";
// ── Environment ────────────────────────────────────────────────────────────
const GH_TOKEN = requireEnv("GH_TOKEN");
const PR_NUMBER = requireEnv("PR_NUMBER");
const REPO = requireEnv("REPO");
const RUN_ID = requireEnv("RUN_ID");
const HEAD_REF = requireEnv("HEAD_REF");
const MAIN_BASELINES_DIR =
process.env.MAIN_BASELINES_DIR ?? "/tmp/main-baselines";
const SNAPSHOTS_APPROVED = process.env.SNAPSHOTS_APPROVED === "true";
const GENERATE_OUTCOME = process.env.GENERATE_OUTCOME ?? "success";
const COMPARE_OUTCOME = process.env.COMPARE_OUTCOME ?? "success";
const SNAPSHOTS_DIR = "tests/e2e/__snapshots__";
// The workflow saves comparison test-results to this path before the
// --update-snapshots pass wipes test-results/. Fall back to the default
// Playwright output directory when running outside CI.
const TEST_RESULTS_DIR =
process.env.COMPARISON_RESULTS_DIR ?? "test-results";
// Images are pushed to this dedicated branch, NOT to the PR branch.
// Pushing to the PR branch with [skip ci] was blocking required checks on the HEAD commit.
const ARTIFACTS_BRANCH = `snapshot-artifacts/pr-${PR_NUMBER}`;
const COMMENT_MARKER = "<!-- snapshot-test-report -->";
const GITHUB_API = process.env.GITHUB_API_URL ?? "https://api.github.com";
const RAW_BASE = "https://raw.githubusercontent.com";
const [OWNER, REPO_NAME] = REPO.split("/");
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
// ── File utilities ─────────────────────────────────────────────────────────
/** Recursively find all files with a given extension under a directory. */
function findFiles(dir, ext) {
if (!existsSync(dir)) return [];
const results = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...findFiles(full, ext));
} else if (!ext || entry.name.endsWith(ext)) {
results.push(full);
}
}
return results;
}
/** Copy a file, creating parent directories as needed. */
function copyFile(src, dest) {
mkdirSync(dirname(dest), { recursive: true });
copyFileSync(src, dest);
}
// ── Snapshot classification ────────────────────────────────────────────────
/**
* Classify snapshots as changed, new, or unchanged.
*
* "Changed" is determined by whether Playwright produced a diff file in
* test-results/ — this respects the configured threshold/maxDiffPixels so
* minor rendering noise below the tolerance is not flagged as a change.
*
* "New" means the snapshot exists in the PR but has no baseline on main.
* "Unchanged" means all other snapshots that Playwright accepted.
*/
function classifySnapshots() {
const currentFiles = findFiles(SNAPSHOTS_DIR, ".png");
const baselineFiles = findFiles(MAIN_BASELINES_DIR, ".png");
// Build a set of relative paths from the baselines directory
const baselineRelPaths = new Set(
baselineFiles.map((f) => relative(MAIN_BASELINES_DIR, f)),
);
// Index diff files from test-results by their canonical snapshot name.
// Playwright names diff files "<snapshot-name>-<N>-diff.png" — strip the
// Playwright-appended "-<N>" so "sidebar-filter-menu-1" → "sidebar-filter-menu".
const allDiffFiles = findFiles(TEST_RESULTS_DIR, "-diff.png");
const diffBySnapshotName = new Map();
for (const diffFile of allDiffFiles) {
const key = basename(diffFile, "-diff.png").replace(/-\d+$/, "");
if (!diffBySnapshotName.has(key)) {
diffBySnapshotName.set(key, diffFile);
}
}
const changed = [];
const newSnapshots = [];
const unchanged = [];
for (const currentFile of currentFiles) {
const relPath = relative(SNAPSHOTS_DIR, currentFile);
const snapshotName = basename(relPath, ".png");
if (!baselineRelPaths.has(relPath)) {
newSnapshots.push({ relPath, currentFile });
} else if (diffBySnapshotName.has(snapshotName)) {
const diffFile = diffBySnapshotName.get(snapshotName);
changed.push({
relPath,
currentFile,
baselineFile: join(MAIN_BASELINES_DIR, relPath),
diffFile: existsSync(diffFile) ? diffFile : null,
});
} else {
unchanged.push({ relPath });
}
}
return { changed, newSnapshots, unchanged };
}
// ── Image publishing ───────────────────────────────────────────────────────
/**
* Push snapshot images to a dedicated orphan branch (snapshot-artifacts/pr-<N>)
* so they can be embedded in the PR comment via raw.githubusercontent.com URLs.
*
* Images are intentionally NOT pushed to the PR branch. Pushing to the PR branch
* (even with [skip ci]) invalidates required checks on the HEAD commit and hangs
* the PR. The orphan artifacts branch is invisible to all CI workflows.
*
* Returns the commit SHA on the artifacts branch, or null on failure.
*/
function publishImages(changed, newSnapshots) {
const hasImages = changed.length > 0 || newSnapshots.length > 0;
if (!hasImages) return null;
// Build a temp directory containing only the images, mirroring the layout
// that buildComment expects: changed/<relPath>-{actual,expected,diff}.png
// new/<relPath>.png
const tmpDir = execSync("mktemp -d").toString().trim();
try {
for (const { relPath, currentFile, baselineFile, diffFile } of changed) {
const dest = join(tmpDir, "changed", relPath);
copyFile(currentFile, dest.replace(".png", "-actual.png"));
if (baselineFile && existsSync(baselineFile)) {
copyFile(baselineFile, dest.replace(".png", "-expected.png"));
}
if (diffFile) {
copyFile(diffFile, dest.replace(".png", "-diff.png"));
}
}
for (const { relPath, currentFile } of newSnapshots) {
copyFile(currentFile, join(tmpDir, "new", relPath));
}
// Create an orphan commit in tmpDir and force-push to the artifacts branch.
// Using a fresh git repo avoids touching any tracked files in the PR checkout.
const git = (cmd) => execSync(`git -C "${tmpDir}" ${cmd}`);
git("init");
git(`config user.name "github-actions[bot]"`);
git(`config user.email "41898282+github-actions[bot]@users.noreply.github.com"`);
git("add .");
git(`commit -m "snapshot images for PR #${PR_NUMBER} run ${RUN_ID}"`);
git(
`push --force "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" ` +
`HEAD:refs/heads/${ARTIFACTS_BRANCH}`,
);
return git("rev-parse HEAD").toString().trim();
} catch (err) {
console.error("Warning: failed to push snapshot images:", err.message);
return null;
} finally {
execSync(`rm -rf "${tmpDir}"`);
}
}
// ── Markdown generation ────────────────────────────────────────────────────
function rawUrl(commitSha, filePath) {
return `${RAW_BASE}/${OWNER}/${REPO_NAME}/${commitSha}/${filePath}`;
}
/** Extract the human-readable spec name from a relative snapshot path.
* "snapshots/mcp-page.snapshot.spec.ts/chromium/foo.png" → "mcp-page"
*/
function specFromRelPath(relPath) {
const segment = relPath.replace(/^snapshots\//, "").split("/")[0] ?? "";
return segment.replace(".snapshot.spec.ts", "");
}
/** Group an array of snapshot objects by their spec name. */
function groupBySpec(items) {
const groups = /** @type {Map<string, typeof items>} */ (new Map());
for (const item of items) {
const spec = specFromRelPath(item.relPath);
if (!groups.has(spec)) groups.set(spec, []);
groups.get(spec).push(item);
}
return groups;
}
function buildComment(changed, newSnapshots, unchanged, commitSha) {
const total = changed.length + newSnapshots.length + unchanged.length;
const hasDifferences = changed.length > 0;
let statusIcon;
let statusText;
if (hasDifferences && SNAPSHOTS_APPROVED) {
statusIcon = "✅";
statusText =
`${changed.length} snapshot${changed.length !== 1 ? "s" : ""} changed — ` +
`acknowledged via the \`update-snapshots\` label. New baselines will be uploaded when this PR merges.`;
} else if (hasDifferences) {
statusIcon = "❌";
statusText =
`${changed.length} snapshot${changed.length !== 1 ? "s" : ""} differ from the main branch baseline${changed.length !== 1 ? "s" : ""}. ` +
`Add the \`update-snapshots\` label to acknowledge intentional changes.`;
} else if (unchanged.length === 0) {
statusIcon = "✅";
statusText =
`No baseline found on main — all ${newSnapshots.length} snapshot${newSnapshots.length !== 1 ? "s" : ""} are new ` +
`and will become the baseline once this PR merges.`;
} else {
statusIcon = "✅";
statusText = "All snapshots match the main branch baselines.";
}
const lines = [
COMMENT_MARKER,
`## 📸 Snapshot Test Report`,
"",
];
if (COMPARE_OUTCOME === "failure") {
lines.push(
`> [!WARNING]`,
`> **Snapshot comparison step crashed** (timeout, OOM, or runner error) — diff results below may be incomplete or absent.`,
`> Check the [CI logs](https://github.com/${REPO}/actions/runs/${RUN_ID}) for the full error output (look for the "Run snapshot comparison" step).`,
"",
);
}
if (GENERATE_OUTCOME === "failure") {
lines.push(
`> [!WARNING]`,
`> **One or more snapshot tests crashed during generation** — some snapshots below may be incomplete.`,
`> Check the [CI logs](https://github.com/${REPO}/actions/runs/${RUN_ID}) for the full error output (look for the "Generate current PR snapshots" step).`,
"",
);
}
lines.push(
`${statusIcon} ${statusText}`,
"",
`| Category | Count |`,
`|---|---|`,
`| 🔴 Changed | ${changed.length} |`,
`| 🆕 New | ${newSnapshots.length} |`,
`| ✅ Unchanged | ${unchanged.length} |`,
`| **Total** | **${total}** |`,
"",
);
if (hasDifferences && !SNAPSHOTS_APPROVED) {
lines.push(
`> **How to resolve:**`,
`> - **Unintentional diffs** — the baselines on \`main\` may have moved since this branch was created. Merge the latest \`main\` into this branch and re-run CI.`,
`> - **Intentional changes** — add the \`update-snapshots\` label. CI will pass and the new screenshots become the baseline when this PR merges.`,
"",
);
}
// Changed snapshots — grouped by spec file
if (changed.length > 0) {
lines.push(
`<details>`,
`<summary>🔴 Changed snapshots (${changed.length})</summary>`,
"",
);
for (const [spec, items] of groupBySpec(changed)) {
lines.push(
`### \`${spec}\`${items.length > 1 ? ` — ${items.length} snapshots` : ""}`,
"",
);
for (const { relPath, diffFile } of items) {
const name = basename(relPath, ".png");
lines.push(`**${name}**`, "");
if (commitSha) {
const base = join("changed", relPath);
const expectedUrl = rawUrl(commitSha, base.replace(".png", "-expected.png"));
const actualUrl = rawUrl(commitSha, base.replace(".png", "-actual.png"));
const diffUrl = diffFile
? rawUrl(commitSha, base.replace(".png", "-diff.png"))
: null;
lines.push(
`| Expected (main) | Actual (PR) |${diffUrl ? " Diff |" : ""}`,
`|---|---|${diffUrl ? "---|" : ""}`,
`| ![expected](${expectedUrl}) | ![actual](${actualUrl}) |${diffUrl ? ` ![diff](${diffUrl}) |` : ""}`,
"",
);
} else {
lines.push(
`_Images could not be embedded (fork PR or push failed). ` +
`Download the [\`snapshot-test-results\` artifact](https://github.com/${REPO}/actions/runs/${RUN_ID}) for visual diffs._`,
"",
);
}
}
}
lines.push(`</details>`, "");
}
// New snapshots — grouped by spec file
if (newSnapshots.length > 0) {
lines.push(
`<details>`,
`<summary>🆕 New snapshots (${newSnapshots.length})</summary>`,
"",
`These snapshots have no baseline on main and will become the new baseline once this PR merges.`,
"",
);
if (commitSha) {
for (const [spec, items] of groupBySpec(newSnapshots)) {
lines.push(
`### \`${spec}\`${items.length > 1 ? ` — ${items.length} snapshots` : ""}`,
"",
);
for (const { relPath } of items) {
const name = basename(relPath, ".png");
const actualUrl = rawUrl(commitSha, join("new", relPath));
lines.push(`**${name}**`, "", `![new snapshot](${actualUrl})`, "");
}
}
} else {
lines.push(
`_Images could not be embedded (fork PR or push failed). ` +
`Download the [\`snapshot-test-results\` artifact](https://github.com/${REPO}/actions/runs/${RUN_ID}) for screenshots._`,
"",
);
}
lines.push(`</details>`, "");
}
// Unchanged — compact grouped list by spec file
if (unchanged.length > 0) {
lines.push(
`<details>`,
`<summary>✅ Unchanged snapshots (${unchanged.length})</summary>`,
"",
);
for (const [spec, items] of groupBySpec(unchanged)) {
lines.push(`**\`${spec}\`**`);
for (const { relPath } of items) {
lines.push(`- ${basename(relPath, ".png")}`);
}
lines.push("");
}
lines.push(`</details>`, "");
}
lines.push(
`---`,
`_Generated by the [Snapshot Tests](https://github.com/${REPO}/actions/runs/${RUN_ID}) workflow. ` +
`This comment was created by an AI agent (OpenHands) on behalf of the repo maintainers._`,
);
return lines.join("\n");
}
// ── GitHub API ─────────────────────────────────────────────────────────────
async function githubFetch(path, options = {}) {
const url = path.startsWith("http") ? path : `${GITHUB_API}${path}`;
const res = await fetch(url, {
...options,
headers: {
Authorization: `Bearer ${GH_TOKEN}`,
Accept: "application/vnd.github.v3+json",
"Content-Type": "application/json",
"User-Agent": "agent-canvas-snapshot-bot",
...options.headers,
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(`GitHub API ${res.status} for ${url}: ${text}`);
}
// DELETE returns 204 No Content
if (res.status === 204) return null;
return res.headers.get("content-type")?.includes("json") ? res.json() : res.text();
}
/**
* Delete any existing snapshot report comment and post a fresh one.
*
* We always delete-then-create (rather than edit in-place) so that the new
* comment always references the current run's image URLs. Editing would
* leave stale raw.githubusercontent.com URLs pointing at the previous run's
* .pr/snapshots/<old_run_id>/ images.
*/
async function postFreshComment(body) {
const comments = await githubFetch(
`/repos/${OWNER}/${REPO_NAME}/issues/${PR_NUMBER}/comments`,
);
const existing = comments.find((c) => c.body.includes(COMMENT_MARKER));
if (existing) {
await githubFetch(
`/repos/${OWNER}/${REPO_NAME}/issues/comments/${existing.id}`,
{ method: "DELETE" },
);
console.log(`Deleted stale PR comment ${existing.id}`);
}
await githubFetch(
`/repos/${OWNER}/${REPO_NAME}/issues/${PR_NUMBER}/comments`,
{ method: "POST", body: JSON.stringify({ body }) },
);
console.log("Posted fresh PR comment");
}
// ── Main ───────────────────────────────────────────────────────────────────
async function main() {
console.log(`Classifying snapshots...`);
console.log(` Current snapshots: ${SNAPSHOTS_DIR}`);
console.log(` Main baselines: ${MAIN_BASELINES_DIR}`);
const { changed, newSnapshots, unchanged } = classifySnapshots();
console.log(
` Changed: ${changed.length}, New: ${newSnapshots.length}, Unchanged: ${unchanged.length}`,
);
let commitSha = null;
if (changed.length > 0 || newSnapshots.length > 0) {
console.log(`Publishing images to branch ${ARTIFACTS_BRANCH}...`);
commitSha = publishImages(changed, newSnapshots);
if (commitSha) {
console.log(` Images published at ${commitSha}`);
} else {
console.log(` Image publishing failed — comment will link to artifact download`);
}
}
const body = buildComment(changed, newSnapshots, unchanged, commitSha);
// Commenting requires `pull-requests: write`. GitHub silently downgrades
// that to `read` for `pull_request` runs triggered from a fork, so the
// POST below 403s on cross-repo PRs. Treat the failure the same way
// publishImages already treats its push failure: log it and keep going
// so the workflow can still write `has_changes` and let the
// "Fail if differences" step do its job.
try {
await postFreshComment(body);
} catch (err) {
console.error(
"Warning: failed to post PR comment (expected on fork PRs):",
err.message,
);
}
// Tell the workflow whether there are actual pixel-diff failures so the
// "Fail if differences" step can distinguish changed snapshots (should
// fail CI) from missing baselines (new tests from this PR, should pass).
if (process.env.GITHUB_OUTPUT) {
appendFileSync(
process.env.GITHUB_OUTPUT,
`has_changes=${changed.length > 0}\n`,
);
console.log(` has_changes=${changed.length > 0} written to GITHUB_OUTPUT`);
}
console.log("Done.");
}
main().catch((err) => {
console.error("post-snapshot-comment failed:", err);
process.exit(1);
});
@@ -1,247 +0,0 @@
import { test, expect, Page } from "@playwright/test";
import { seedLocalStorage } from "./support/seed-local-storage";
/**
* Visual snapshot tests for UI pages.
*
* These tests capture screenshots of pages and compare them against
* baseline images to detect unintended visual regressions.
*
* To update baselines after intentional UI changes:
* npm run test:e2e:snapshots:update
*/
/** Mock settings response with analytics consent already given */
const SETTINGS_WITH_CONSENT = {
llm_model: "anthropic/claude-sonnet-4-20250514",
llm_base_url: "",
agent: "CodeActAgent",
language: "en",
llm_api_key: null,
llm_api_key_set: true,
search_api_key_set: false,
confirmation_mode: false,
security_analyzer: "llm",
remote_runtime_resource_factor: 1,
provider_tokens_set: { github: "" },
enable_default_condenser: true,
condenser_max_size: 240,
enable_sound_notifications: false,
// Analytics consent already given - modal won't show
user_consents_to_analytics: false,
enable_proactive_conversation_starters: false,
enable_solvability_analysis: false,
max_budget_per_task: null,
};
/** Mock settings response with analytics consent pending (null = show modal) */
const SETTINGS_WITHOUT_CONSENT = {
...SETTINGS_WITH_CONSENT,
// null means user hasn't made a choice yet - modal will show
user_consents_to_analytics: null,
};
/**
* Sets up common API mocks for snapshot tests.
* @param page - Playwright page
* @param showConsentModal - Whether to show the analytics consent modal
*/
async function setupMocks(page: Page, showConsentModal = false) {
await seedLocalStorage(page, { showConsentModal });
// Mock settings API - consent modal appears when user_consents_to_analytics is null
const settingsResponse = showConsentModal
? SETTINGS_WITHOUT_CONSENT
: SETTINGS_WITH_CONSENT;
await page.route("**/api/settings", async (route) => {
if (route.request().method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(settingsResponse),
});
} else {
await route.continue();
}
});
// Mock settings schemas
await page.route("**/api/settings/agent-schema", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({}),
});
});
await page.route("**/api/settings/conversation-schema", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({}),
});
});
// Mock conversations search for home page
await page.route("**/api/conversations/search**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ results: [] }),
});
});
await page.route("**/api/workspaces**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ workspaces: [], workspaceParents: [] }),
});
});
await page.route("**/api/bash/execute_bash_command", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
command: "",
exit_code: 0,
output: "",
}),
});
});
// Mock file APIs to prevent proxy errors
await page.route("**/api/file/**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ path: "/home", subdirs: [] }),
});
});
}
/**
* Dismisses the analytics consent modal if it appears.
*/
async function dismissConsentModal(page: Page) {
const consentDialog = page.getByRole("dialog", {
name: "Help improve OpenHands",
});
await page
.getByRole("button", { name: "Confirm preferences" })
.click({ timeout: 3000 })
.catch(() => undefined);
await expect(consentDialog).toHaveCount(0, { timeout: 3000 });
}
test.describe("UI Visual Snapshots", () => {
// Increase timeout for this test - modal loading can be slow
test.setTimeout(60000);
test("Analytics consent modal renders correctly", async ({ page }) => {
// Use setupMocks with showConsentModal=true to guarantee modal appears
await setupMocks(page, true);
await page.goto("/conversations", { waitUntil: "networkidle" });
// Wait for the page to stabilize
const rootLayout = page.getByTestId("root-layout");
await expect(rootLayout).toBeVisible({ timeout: 15000 });
// Wait for the consent modal (lazy-loaded) with extended timeout
const consentModal = page.getByRole("dialog", {
name: "Help improve OpenHands",
});
await expect(consentModal).toBeVisible({ timeout: 15000 });
// Snapshot the full page with the consent modal
await expect(rootLayout).toHaveScreenshot("analytics-consent-modal.png", {
maxDiffPixelRatio: 0.01,
animations: "disabled",
});
});
test("Home page renders correctly", async ({ page }) => {
await setupMocks(page, false);
await page.goto("/conversations");
await dismissConsentModal(page);
const homeScreen = page.getByTestId("home-screen");
await expect(homeScreen).toBeVisible({ timeout: 15_000 });
await page.waitForLoadState("networkidle");
const rootLayout = page.getByTestId("root-layout");
await expect(rootLayout).toHaveScreenshot("home-screen.png", {
maxDiffPixelRatio: 0.01,
animations: "disabled",
});
});
test("Settings page renders correctly", async ({ page }) => {
await setupMocks(page, false);
await page.goto("/settings");
await dismissConsentModal(page);
const rootLayout = page.getByTestId("root-layout");
await expect(rootLayout).toBeVisible({ timeout: 15000 });
await page.waitForLoadState("networkidle");
await expect(rootLayout).toHaveScreenshot("settings-page.png", {
maxDiffPixelRatio: 0.01,
animations: "disabled",
});
});
test("Settings app page renders correctly", async ({ page }) => {
await setupMocks(page, false);
await page.goto("/settings/app");
await dismissConsentModal(page);
const rootLayout = page.getByTestId("root-layout");
await expect(rootLayout).toBeVisible({ timeout: 15000 });
await page.waitForLoadState("networkidle");
await expect(rootLayout).toHaveScreenshot("settings-app-page.png", {
maxDiffPixelRatio: 0.01,
animations: "disabled",
});
});
test("Add backend modal renders correctly", async ({ page }) => {
await setupMocks(page, false);
// Mock the server-info health-check endpoint. Without this, the
// periodic health poll may prevent networkidle or cause hangs.
await page.route("**/server_info", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ version: "mock" }),
});
});
await page.goto("/conversations");
await dismissConsentModal(page);
const homeScreen = page.getByTestId("home-screen");
await expect(homeScreen).toBeVisible({ timeout: 15000 });
// The backend selector uses openOnHover, so hovering opens the
// dropdown. Clicking the toggle would close it again, so we hover
// to open and then click the menu item directly.
const backendSelector = page.getByTestId("backend-selector");
await expect(backendSelector).toBeVisible({ timeout: 15_000 });
await backendSelector.hover();
await page.getByTestId("add-backend-menu-item").click();
const addBackendModal = page.getByTestId("add-backend-modal");
await expect(addBackendModal).toBeVisible({ timeout: 15000 });
await expect(addBackendModal).toHaveScreenshot("add-backend-modal.png", {
maxDiffPixelRatio: 0.01,
animations: "disabled",
});
});
});
@@ -1,161 +0,0 @@
import { test, expect, Page } from "@playwright/test";
import { seedLocalStorage } from "./support/seed-local-storage";
/**
* Visual snapshot tests for the Secrets Settings page (/settings/secrets).
*
* MSW pre-seeds two secrets in src/mocks/secrets-handlers.ts:
* - OpenAI_API_Key
* - Google_Maps_API_Key
*
* All five snapshots tell an iterative story:
* 1. Default list (two pre-seeded rows)
* 2. "Add New Secret" form open (empty)
* 3. Form filled in with name + value
* 4. After saving — list shows a third secret
* 5. Delete confirmation modal open
*/
async function dismissConsentModal(page: Page) {
await page
.getByRole("button", { name: "Confirm preferences" })
.click({ timeout: 3_000 })
.catch(() => undefined);
}
async function setupMocks(page: Page) {
await seedLocalStorage(page);
// Keep conversations sidebar quiet (page.route wins for this cross-origin path
// only; for same-origin MSW takes precedence and we rely on MSW data).
await page.route("**/api/conversations/search**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [], next_page_id: null }),
});
});
}
test.describe("Settings – Secrets Visual Snapshots", () => {
test.setTimeout(60_000);
// Run in serial so the MSW secrets Map stays consistent across steps
test.describe.configure({ mode: "serial" });
test("secrets list shows two pre-seeded rows", async ({ page }) => {
await setupMocks(page);
await page.goto("/settings/secrets");
await dismissConsentModal(page);
await page.waitForLoadState("networkidle");
// Both secrets must be visible before taking the snapshot
await expect(
page.getByTestId("secret-item").first(),
).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("secret-item")).toHaveCount(2);
const rootLayout = page.getByTestId("root-layout");
await expect(rootLayout).toHaveScreenshot("secrets-list.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
test("add-new-secret form opens empty", async ({ page }) => {
await setupMocks(page);
await page.goto("/settings/secrets");
await dismissConsentModal(page);
await expect(
page.getByTestId("secret-item").first(),
).toBeVisible({ timeout: 10_000 });
// Click the "Add New Secret" button
await page.getByTestId("add-secret-button").click();
// The inline form should appear
await expect(page.getByTestId("value-input")).toBeVisible({ timeout: 5_000 });
const rootLayout = page.getByTestId("root-layout");
await expect(rootLayout).toHaveScreenshot("secrets-add-form.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
test("add-new-secret form filled with name and value", async ({ page }) => {
await setupMocks(page);
await page.goto("/settings/secrets");
await dismissConsentModal(page);
await expect(
page.getByTestId("secret-item").first(),
).toBeVisible({ timeout: 10_000 });
await page.getByTestId("add-secret-button").click();
await expect(page.getByTestId("value-input")).toBeVisible({ timeout: 5_000 });
// Fill the secret name field
await page.getByTestId("name-input").fill("ANTHROPIC_API_KEY");
// Fill the secret value field
await page.getByTestId("value-input").fill("sk-ant-snapshot-test-key");
const rootLayout = page.getByTestId("root-layout");
await expect(rootLayout).toHaveScreenshot("secrets-add-form-filled.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
test("after saving a secret the list shows three rows", async ({ page }) => {
await setupMocks(page);
await page.goto("/settings/secrets");
await dismissConsentModal(page);
await expect(
page.getByTestId("secret-item").first(),
).toBeVisible({ timeout: 10_000 });
await page.getByTestId("add-secret-button").click();
await expect(page.getByTestId("value-input")).toBeVisible({ timeout: 5_000 });
await page.getByTestId("name-input").fill("ANTHROPIC_API_KEY");
await page.getByTestId("value-input").fill("sk-ant-snapshot-test-key");
// Submit the form ("Add secret" is the i18n text for the submit button in add mode)
await page.getByTestId("submit-button").click();
// Wait for the form to close and the new secret to appear
await expect(page.getByTestId("secret-item")).toHaveCount(3, {
timeout: 10_000,
});
const rootLayout = page.getByTestId("root-layout");
await expect(rootLayout).toHaveScreenshot("secrets-after-save.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
test("delete confirmation modal is shown for a secret", async ({ page }) => {
await setupMocks(page);
await page.goto("/settings/secrets");
await dismissConsentModal(page);
await expect(
page.getByTestId("secret-item").first(),
).toBeVisible({ timeout: 10_000 });
// Click the delete button on the first secret row
await page.getByTestId("delete-secret-button").first().click();
// The ConfirmationModal should appear
await expect(
page.getByTestId("confirmation-modal"),
).toBeVisible({ timeout: 5_000 });
const rootLayout = page.getByTestId("root-layout");
await expect(rootLayout).toHaveScreenshot("secrets-delete-confirm.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
});
@@ -1,173 +0,0 @@
import { test, expect, Page } from "@playwright/test";
import { seedLocalStorage } from "./support/seed-local-storage";
/**
* Visual snapshot tests for:
* - /settings/verification (Confirmation Mode toggle + Security Analyzer)
* - /settings/condenser (Schema-driven condenser form)
*
* The verification page is now fully schema-driven (no hand-written header
* for the confirmation-mode toggle), and `confirmation_mode` is a
* `prominence: "major"` field so it lives in the Advanced/All views, not
* Basic. Each verification test therefore switches to the "All" view
* before snapshotting so both the critic-related and confirmation-mode
* controls are on screen.
*
* MSW provides the default settings (confirmation_mode: false) so the first
* verification snapshot shows the toggle in the OFF position with a dimmed
* Save Changes button. Toggling it ON reveals the Security Analyzer dropdown
* and enables Save Changes — captured in the second snapshot.
*
* There is no separate "dirty" snapshot for confirmation mode because clicking
* the toggle IS the dirty action — the "ON" snapshot already captures the
* dirty/enabled-Save-Changes state.
*/
async function dismissConsentModal(page: Page) {
await page
.getByRole("button", { name: "Confirm preferences" })
.click({ timeout: 3_000 })
.catch(() => undefined);
}
async function setupMocks(page: Page) {
await seedLocalStorage(page);
}
test.describe("Settings – Verification & Condenser Visual Snapshots", () => {
test.setTimeout(60_000);
/**
* Helper: wait for the schema-driven verification page to be ready, then
* switch to the "All" view so `confirmation_mode` (a `major`-prominence
* field) is rendered alongside the `critic_enabled` toggle.
*
* Readiness signal: the "Enable Critic" label is the first critical-
* prominence field and is always visible once the schema is loaded,
* regardless of view. The underlying checkbox is `hidden` in the DOM
* (styled toggle pattern), so we assert on the label text instead.
*/
async function waitForVerificationPage(page: Page) {
await expect(
page.getByTestId("sdk-settings-verification.critic_enabled"),
).toBeAttached({
timeout: 10_000,
});
await page.getByTestId("sdk-section-all-toggle").click();
// The visible label is "Confirmation Mode" — the schema's raw "Confirmation
// mode" goes through the i18n translation table (SCHEMA$CONFIRMATION_MODE$LABEL).
await expect(page.getByText("Confirmation Mode")).toBeVisible({
timeout: 5_000,
});
}
async function ensureCriticEnabled(page: Page) {
const apiKeyInput = page.getByTestId(
"sdk-settings-verification.critic_api_key",
);
if (!(await apiKeyInput.isVisible())) {
await page
.locator(
`label:has([data-testid="sdk-settings-verification.critic_enabled"])`,
)
.click();
}
await expect(apiKeyInput).toBeVisible({ timeout: 5_000 });
}
test("verification settings with confirmation mode OFF (default)", async ({
page,
}) => {
await setupMocks(page);
await page.goto("/settings/verification");
await dismissConsentModal(page);
await page.waitForLoadState("networkidle");
await waitForVerificationPage(page);
// Security Analyzer combobox must NOT be present when confirmation_mode
// is off (it depends_on the toggle). HeroUI Autocomplete does not
// forward data-testid, so match by accessible role + label (case
// insensitive since the schema label is "Security analyzer").
await expect(
page.getByRole("combobox", { name: /security analyzer/i }),
).toHaveCount(0);
const rootLayout = page.getByTestId("root-layout");
await expect(rootLayout).toHaveScreenshot("verification-settings-off.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
test("verification settings with critic enabled shows API key guidance", async ({
page,
}) => {
await setupMocks(page);
await page.goto("/settings/verification");
await dismissConsentModal(page);
await waitForVerificationPage(page);
await ensureCriticEnabled(page);
await expect(
page.getByText(
/Critic API Key is the same as your OpenHands Provider LLM Key/i,
),
).toBeVisible();
const rootLayout = page.getByTestId("root-layout");
await expect(rootLayout).toHaveScreenshot(
"verification-settings-critic-enabled.png",
{ animations: "disabled", maxDiffPixelRatio: 0.01 },
);
});
test("verification settings with confirmation mode ON shows security analyzer", async ({
page,
}) => {
await setupMocks(page);
await page.goto("/settings/verification");
await dismissConsentModal(page);
await waitForVerificationPage(page);
// The schema-rendered SettingsSwitch's underlying <input type="checkbox">
// is `hidden`; clicking the visible label that wraps it activates the
// form control through standard HTML label–control association. The
// testId now comes from SchemaField's `sdk-settings-${field.key}` scheme.
await page
.locator(`label:has([data-testid="sdk-settings-confirmation_mode"])`)
.click();
// Security Analyzer dropdown should now appear.
await expect(
page.getByRole("combobox", { name: /security analyzer/i }),
).toBeVisible({ timeout: 5_000 });
const rootLayout = page.getByTestId("root-layout");
await expect(rootLayout).toHaveScreenshot("verification-settings-on.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
test("condenser settings page renders schema form", async ({ page }) => {
await setupMocks(page);
await page.goto("/settings/condenser");
await dismissConsentModal(page);
await page.waitForLoadState("networkidle");
// The wrapper div with data-testid should be present once the form renders
await expect(page.getByTestId("condenser-settings-screen")).toBeAttached({
timeout: 5_000,
});
await expect(
page.getByText(/Enable (default condenser|Memory Condensation)/i),
).toBeVisible({ timeout: 15_000 });
const rootLayout = page.getByTestId("root-layout");
await expect(rootLayout).toHaveScreenshot("condenser-settings.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
});
@@ -1,156 +0,0 @@
import { test, expect, Page } from "@playwright/test";
import { seedLocalStorage } from "./support/seed-local-storage";
/**
* Visual snapshot tests for the sidebar / conversation panel.
*
* MSW pre-seeds three conversations in src/mocks/conversation-handlers.ts:
* 1. "My New Project" — execution_status: "waiting_for_confirmation"
* 2. "Repo Testing" — execution_status: "idle"
* 3. "Another Project" — execution_status: "idle"
*
* Three snapshots:
* 1. conversation-panel — the panel itself with status dots + relative timestamps
* 2. sidebar-collapsed — after clicking the collapse toggle (thin icon rail)
* 3. new-conversation-popover — popover open showing workspace options
*/
async function dismissConsentModal(page: Page) {
await page
.getByRole("button", { name: "Confirm preferences" })
.click({ timeout: 3_000 })
.catch(() => undefined);
}
async function setupMocks(page: Page) {
await seedLocalStorage(page);
// Suppress proxy errors for file API (home page workspace scan)
await page.route("**/api/file/**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ path: "/home", subdirs: [] }),
});
});
}
test.describe("Sidebar Visual Snapshots", () => {
test.setTimeout(60_000);
test("conversation panel shows three conversations with status dots", async ({
page,
}) => {
await setupMocks(page);
await page.goto("/conversations");
await dismissConsentModal(page);
await page.waitForLoadState("networkidle");
// Wait for all five conversation cards to be present
const conversationPanel = page.getByTestId("conversation-panel");
await expect(conversationPanel).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId("conversation-card")).toHaveCount(6, {
timeout: 10_000,
});
// Scope the screenshot to just the sidebar panel (includes status dots)
await expect(conversationPanel).toHaveScreenshot(
"sidebar-conversation-panel.png",
{ animations: "disabled", maxDiffPixelRatio: 0.01 },
);
});
test("sidebar collapses to an icon rail on toggle", async ({ page }) => {
await setupMocks(page);
await page.goto("/conversations");
await dismissConsentModal(page);
await page.waitForLoadState("networkidle");
await expect(page.getByTestId("conversation-panel")).toBeVisible({
timeout: 15_000,
});
// Click the collapse toggle (visible when sidebar is expanded)
await page.getByTestId("sidebar-collapse-toggle").click();
// Sidebar should now be in collapsed state
const sidebarAside = page.locator("aside[data-collapsed='true']");
await expect(sidebarAside).toBeVisible({ timeout: 5_000 });
const rootLayout = page.getByTestId("root-layout");
await expect(rootLayout).toHaveScreenshot("sidebar-collapsed.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
test("conversations filter menu opens from the filter toggle button", async ({
page,
}) => {
await setupMocks(page);
await page.goto("/conversations");
await dismissConsentModal(page);
await page.waitForLoadState("networkidle");
await expect(page.getByTestId("conversation-panel")).toBeVisible({
timeout: 15_000,
});
await expect(page.getByTestId("conversation-card")).toHaveCount(6, {
timeout: 10_000,
});
// Click the filter toggle in the Conversations header
await page.getByTestId("older-conversations-filter-toggle").click();
// Filter menu should appear
await expect(
page.getByTestId("older-conversations-filter-menu"),
).toBeVisible({ timeout: 5_000 });
const rootLayout = page.getByTestId("root-layout");
await expect(rootLayout).toHaveScreenshot(
"sidebar-filter-menu.png",
{ animations: "disabled", maxDiffPixelRatio: 0.01 },
);
});
test("new conversation popover opens with no-workspace entry", async ({
page,
}) => {
// The `NewConversationButton` component (and its `new-conversation-button`
// testid) is temporarily removed from the sidebar. The component lives at
// src/components/features/conversation-panel/new-conversation-button-local.tsx
// but is commented out in sidebar.tsx (lines 241-244). This test documents
// the intended snapshot once the button is re-wired. Mark as fixme so CI
// stays green while the implementation is pending.
// eslint-disable-next-line playwright/no-skipped-test
test.fixme(
true,
"NewConversationButton is temporarily hidden from the sidebar (sidebar.tsx:241-244)",
);
await setupMocks(page);
await page.goto("/");
await dismissConsentModal(page);
await page.waitForLoadState("networkidle");
// Find and click the new-conversation trigger in the sidebar
const newConvButton = page.getByTestId("new-conversation-button");
await expect(newConvButton).toBeVisible({ timeout: 10_000 });
await newConvButton.click();
// Popover should appear
await expect(page.getByTestId("new-conversation-popover")).toBeVisible({
timeout: 5_000,
});
// "No workspace" entry is always present (no workspaces stored in
// localStorage in this mock setup)
await expect(page.getByTestId("launch-no-workspace")).toBeVisible();
const rootLayout = page.getByTestId("root-layout");
await expect(rootLayout).toHaveScreenshot(
"sidebar-new-conversation-popover.png",
{ animations: "disabled", maxDiffPixelRatio: 0.01 },
);
});
});
@@ -1,237 +0,0 @@
import { test, expect, Page } from "@playwright/test";
import { seedLocalStorage } from "./support/seed-local-storage";
/**
* Visual snapshot tests for the Skills page (/skills).
*
* SkillsService.getSkills() issues POST /api/skills. In mock-API mode
* MSW returns { skills: [] }, so the empty state is free. For the loaded
* and search/filter states we seed the React Query cache directly via
* window.__OH_QUERY_CLIENT__ (exposed in dev/mock mode) to bypass MSW's
* same-origin intercept.
*
* Four states are covered:
* 1. Empty – server returns no skills (MSW default)
* 2. Loaded – four skill cards visible
* 3. Search – filtered to one card after typing "docker"
* 4. No match – empty message after typing an unrecognised term
* 5. Type filter – only agentskills cards visible after clicking filter
*/
const MOCK_SKILLS = [
{
name: "code-review",
type: "agentskills" as const,
source: "github:OpenHands/extensions/skills/codereview",
description:
"Rigorous code review focusing on data structures, simplicity, " +
"security, pragmatism, and risk/safety evaluation.",
triggers: ["/review"],
version: "1.2.0",
license: "MIT",
allowed_tools: ["terminal", "file_editor"],
},
{
name: "docker",
type: "agentskills" as const,
source: "github:OpenHands/extensions/skills/docker",
description:
"Run Docker commands within a container environment, including " +
"starting the Docker daemon and managing containers.",
triggers: ["/docker"],
version: "0.9.1",
license: "Apache-2.0",
allowed_tools: ["terminal"],
},
{
name: "prd",
type: "knowledge" as const,
source: "github:OpenHands/extensions/skills/prd",
description: "Generate a Product Requirements Document for a new feature.",
triggers: ["/prd"],
version: "1.0.0",
license: null,
allowed_tools: null,
},
{
name: "repo-rules",
type: "repo" as const,
source: null,
description: "Project-specific rules for this repository.",
triggers: [],
version: undefined,
license: null,
allowed_tools: ["file_editor", "terminal"],
},
];
/**
* Dismiss the analytics consent modal if MSW shows it.
*/
async function dismissConsentModal(page: Page) {
await page
.getByRole("button", { name: "Confirm preferences" })
.click({ timeout: 3_000 })
.catch(() => undefined);
}
/**
* Wire up the base routes every skills test needs.
*/
async function setupMocks(page: Page) {
await seedLocalStorage(page);
}
/**
* Inject skills data directly into the React Query cache via the
* window.__OH_QUERY_CLIENT__ handle exposed in dev/mock mode.
*/
async function seedSkills(page: Page, skills = MOCK_SKILLS) {
await page.waitForFunction(
() =>
!!(window as unknown as { __OH_QUERY_CLIENT__?: unknown })
.__OH_QUERY_CLIENT__,
{ timeout: 10_000 },
);
await page.evaluate((skillsData) => {
// Matches the `useSkills` queryKey: `["skills", projectDir ?? null]`.
// The global Skills page calls `useSkills()` with no projectDir, so the
// key resolves to `["skills", null]`. Seeding with the bare `["skills"]`
// tuple (as the test originally did) misses the cache entry and the
// skill cards never render.
(
window as unknown as {
__OH_QUERY_CLIENT__: {
setQueryData: (key: unknown[], data: unknown) => void;
};
}
).__OH_QUERY_CLIENT__.setQueryData(["skills", null], skillsData);
}, skills);
await page.waitForTimeout(200);
}
test.describe("Skills Page Visual Snapshots", () => {
test.setTimeout(60_000);
test("empty state renders correctly", async ({ page }) => {
// MSW intercepts POST /api/skills and returns { skills: [] } so the
// "No skills found" empty state is the reliable baseline here.
await setupMocks(page);
await page.goto("/skills");
await dismissConsentModal(page);
await page.waitForLoadState("networkidle");
const skillsScreen = page.getByTestId("skills-settings-screen");
await expect(skillsScreen).toBeVisible({ timeout: 15_000 });
await expect(skillsScreen).toHaveScreenshot("skills-empty.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
test("skills page with loaded cards renders correctly", async ({ page }) => {
await setupMocks(page);
await page.goto("/skills");
await dismissConsentModal(page);
await page.waitForLoadState("networkidle");
const skillsScreen = page.getByTestId("skills-settings-screen");
await expect(skillsScreen).toBeVisible({ timeout: 15_000 });
await seedSkills(page);
await expect(page.getByTestId("skill-card-code-review")).toBeVisible({
timeout: 5_000,
});
await expect(skillsScreen).toHaveScreenshot("skills-loaded.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
test("search narrows cards to matching skill", async ({ page }) => {
await setupMocks(page);
await page.goto("/skills");
await dismissConsentModal(page);
await page.waitForLoadState("networkidle");
const skillsScreen = page.getByTestId("skills-settings-screen");
await expect(skillsScreen).toBeVisible({ timeout: 15_000 });
await seedSkills(page);
await expect(page.getByTestId("skill-card-code-review")).toBeVisible({
timeout: 5_000,
});
await page.getByTestId("skills-search-input").fill("docker");
await page.waitForTimeout(300);
await expect(page.getByTestId("skill-card-docker")).toBeVisible();
await expect(page.getByTestId("skill-card-code-review")).toHaveCount(0);
await expect(skillsScreen).toHaveScreenshot("skills-search-filtered.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
test("search with no results shows empty message", async ({ page }) => {
await setupMocks(page);
await page.goto("/skills");
await dismissConsentModal(page);
await page.waitForLoadState("networkidle");
const skillsScreen = page.getByTestId("skills-settings-screen");
await expect(skillsScreen).toBeVisible({ timeout: 15_000 });
await seedSkills(page);
await expect(page.getByTestId("skill-card-code-review")).toBeVisible({
timeout: 5_000,
});
await page.getByTestId("skills-search-input").fill("xyznonexistent");
await page.waitForTimeout(300);
await expect(page.getByText(/No skills match/i)).toBeVisible();
await expect(skillsScreen).toHaveScreenshot("skills-no-match.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
test("type filter shows only matching skill type", async ({ page }) => {
await setupMocks(page);
await page.goto("/skills");
await dismissConsentModal(page);
await page.waitForLoadState("networkidle");
const skillsScreen = page.getByTestId("skills-settings-screen");
await expect(skillsScreen).toBeVisible({ timeout: 15_000 });
await seedSkills(page);
await expect(page.getByTestId("skill-card-code-review")).toBeVisible({
timeout: 5_000,
});
const filter = page.getByTestId("skills-type-filter");
await filter.getByTestId("dropdown-trigger").click();
await page.getByTestId("skills-type-filter-agentskills").click();
await page.waitForTimeout(300);
await expect(page.getByTestId("skill-card-code-review")).toBeVisible();
await expect(page.getByTestId("skill-card-docker")).toBeVisible();
await expect(page.getByTestId("skill-card-prd")).toHaveCount(0);
await expect(skillsScreen).toHaveScreenshot("skills-type-filter.png", {
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
});
@@ -1,82 +0,0 @@
import type { Page } from "@playwright/test";
export interface SeedLocalStorageOptions {
/**
* Remove openhands-onboarded instead of setting it.
* Use in onboarding modal tests that need the modal to appear.
*/
removeOnboarded?: boolean;
/**
* Skip suppressing the analytics consent modal.
* Use in tests that specifically snapshot the consent modal UI.
*/
showConsentModal?: boolean;
/**
* Additional [key, value] pairs to seed alongside the standard keys.
*/
extra?: [string, string][];
}
/**
* Seeds the standard localStorage keys required by snapshot tests via
* `page.addInitScript`, so the values are present before any app code runs.
*
* Defaults (overridable via options):
* - openhands-onboarded = "1" (suppresses onboarding modal)
* - openhands-telemetry-consent = "denied" (suppresses analytics consent modal)
* - openhands-backends / openhands-active-backend = seeded local backend
*/
export async function seedLocalStorage(
page: Page,
{
removeOnboarded = false,
showConsentModal = false,
extra = [],
}: SeedLocalStorageOptions = {},
) {
await page.addInitScript(
({
removeOnboarded,
showConsentModal,
extra,
}: {
removeOnboarded: boolean;
showConsentModal: boolean;
extra: [string, string][];
}) => {
if (removeOnboarded) {
window.localStorage.removeItem("openhands-onboarded");
} else {
window.localStorage.setItem("openhands-onboarded", "1");
}
if (!showConsentModal) {
window.localStorage.setItem("analytics-consent", "false");
window.localStorage.setItem("openhands-telemetry-consent", "denied");
}
if (!window.localStorage.getItem("openhands-backends")) {
window.localStorage.setItem(
"openhands-backends",
JSON.stringify([
{
id: "default-local",
name: "Local",
host: window.location.origin,
apiKey: "test-session-key",
kind: "local",
},
]),
);
}
if (!window.localStorage.getItem("openhands-active-backend")) {
window.localStorage.setItem(
"openhands-active-backend",
JSON.stringify({ backendId: "default-local", orgId: null }),
);
}
for (const [key, value] of extra) {
window.localStorage.setItem(key, value);
}
},
{ removeOnboarded, showConsentModal, extra },
);
}
@@ -1,51 +0,0 @@
import type { Page } from "@playwright/test";
/**
* Stubs the browser's native `WebSocket` so conversation-page snapshot
* tests don't trigger real connection attempts (which fail in CI with
* "Unable to connect to server" toasts because no agent-server is
* running at the Vite proxy target).
*
* Must be called **before** `page.goto()` — it uses `page.addInitScript`
* so the stub is in place before any app code runs.
*/
export async function stubWebSocket(page: Page): Promise<void> {
await page.addInitScript(() => {
const noop = () => {};
class StubWebSocket extends EventTarget {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
readyState = StubWebSocket.OPEN;
url: string;
protocol = "";
extensions = "";
bufferedAmount = 0;
binaryType: BinaryType = "blob";
onopen: ((ev: Event) => void) | null = null;
onclose: ((ev: CloseEvent) => void) | null = null;
onmessage: ((ev: MessageEvent) => void) | null = null;
onerror: ((ev: Event) => void) | null = null;
constructor(url: string | URL) {
super();
this.url = typeof url === "string" ? url : url.toString();
setTimeout(() => {
const evt = new Event("open");
this.onopen?.(evt);
this.dispatchEvent(evt);
}, 0);
}
send = noop;
close = noop;
CONNECTING = StubWebSocket.CONNECTING;
OPEN = StubWebSocket.OPEN;
CLOSING = StubWebSocket.CLOSING;
CLOSED = StubWebSocket.CLOSED;
}
(window as unknown as { WebSocket: unknown }).WebSocket =
StubWebSocket as unknown as typeof WebSocket;
});
}