diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index 9cddb75a0f3..488cff6f737 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -1295,7 +1295,6 @@ 2 test-timeout-needs-reason:src/renderer/src/components/sidebar/WorktreeCard.host-badge-toggle.test.tsx 7 test-timeout-needs-reason:src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx 1 test-timeout-needs-reason:src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx -1 test-timeout-needs-reason:src/renderer/src/components/tab-bar/BrowserTab.test.tsx 1 test-timeout-needs-reason:src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts 1 test-timeout-needs-reason:src/renderer/src/components/terminal-pane/issue-4631-terminal-hover-link-provider.repro.test.ts 1 test-timeout-needs-reason:src/renderer/src/hooks/agent-hook-completion-notifications.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index f16b5cde095..b21d14a9b73 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -10732,10 +10732,10 @@ "invariant": "Only an explicit user change in Settings > Remote Orca Servers > Advanced > Active Server may mutate activeRuntimeEnvironmentId. Connecting, pairing, disconnecting, selecting or revealing a workspace or terminal, browser/mobile handoff, remote navigation, and reconnect must use transient or target-owner routing and must never rewrite the durable preference. Every Add Project selected host must satisfy canSelectAddRepoHost; paired web has no local filesystem fallback, so no valid host means Browse, Clone, and Create remain disabled. Every operation stays on the host selected when it began, even if preference or catalog state changes. Generic settings mutation cannot bypass the dedicated preference IPC.", "oracle": "Start with Active Server=Local desktop, connect and navigate Windows 2, then reveal a local terminal and assert it succeeds while the persisted preference remains local before and after restart. Repeat transient routing and selected-runtime Add Project operations with exact-ID host decoys and completion reordering. Separately pair the real web client to an isolated headed host, then make its sole still-listed runtime compatibility-blocked and unavailable. Require a null authoritative selection, a disabled host option, and disabled Browse, Clone, and Create actions with no local fallback. Repeat against headless serve. Run the byte-identical selector/action contract on latest main, candidate, and candidate with the eligibility seam disabled.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments/runtime-environments-pairing.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/hooks/useIpcEvents-terminal-create-surfacing.test.ts src/renderer/src/web/web-preload-api-runtime-environment.test.ts --maxWorkers=1", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-catalog-reconciliation.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts --maxWorkers=1", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx src/renderer/src/store/slices/repos-paired-runtime-add.test.ts --maxWorkers=1", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-catalog-reconciliation.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts src/renderer/src/runtime/web-runtime-session.test.ts --maxWorkers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments/runtime-environments-pairing.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/hooks/useIpcEvents-terminal-create-surfacing.test.ts src/renderer/src/web/web-preload-api-runtime-environment.test.ts --maxWorkers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-catalog-reconciliation.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts --maxWorkers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx src/renderer/src/store/slices/repos-paired-runtime-add.test.ts --maxWorkers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-catalog-reconciliation.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts src/renderer/src/runtime/web-runtime-session.test.ts --maxWorkers=1", "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-web-add-project-unavailable-host.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", @@ -10748,7 +10748,6 @@ "src/renderer/src/store/slices/browser.test.ts", "src/renderer/src/components/settings/browser-session-host-selection.test.ts", "src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts", - "src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts", "src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts", "src/renderer/src/hooks/useIpcEvents-terminal-create-surfacing.test.ts", "src/renderer/src/web/web-preload-api-runtime-environment.test.ts", @@ -10756,11 +10755,7 @@ "src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx", "src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts", "src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx", - "src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts", "src/renderer/src/components/sidebar/project-added-default-checkout.test.ts", - "src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts", - "src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts", - "src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts", "src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts", "src/renderer/src/lib/resolved-worktree-execution-host.test.ts", "src/renderer/src/lib/worktree-runtime-owner.test.ts", @@ -10776,13 +10771,6 @@ "tests/e2e/paired-web-add-project-unavailable-host.spec.ts" ], "assertionRefs": [ - { - "file": "src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts", - "assertions": [ - "paired web hides the viewing computer as an Add Project filesystem authority and selects only an eligible paired runtime", - "a sole blocked or errored runtime remains listed but produces no selected Add Project host" - ] - }, { "file": "src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts", "assertions": [ @@ -10842,12 +10830,6 @@ "the dedicated web preference setter rejects unknown server identities without corrupting the saved choice" ] }, - { - "file": "src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts", - "assertions": [ - "stamps Create responses with the captured runtime or SSH owner before store upsert and Git or folder worktree refresh" - ] - }, { "file": "src/renderer/src/store/slices/repos-selected-owner-routing.test.ts", "assertions": [ @@ -10934,8 +10916,8 @@ "date": "2026-07-22", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments/runtime-environments-pairing.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/components/status-bar/SshStatusSegment.test.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/hooks/useIpcEvents-terminal-create-surfacing.test.ts src/renderer/src/web/web-preload-api-runtime-environment.test.ts --maxWorkers=1", - "currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments/runtime-environments-pairing.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/hooks/useIpcEvents-terminal-create-surfacing.test.ts src/renderer/src/web/web-preload-api-runtime-environment.test.ts --maxWorkers=1", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments/runtime-environments-pairing.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/components/status-bar/SshStatusSegment.test.ts src/renderer/src/hooks/useIpcEvents-terminal-create-surfacing.test.ts src/renderer/src/web/web-preload-api-runtime-environment.test.ts --maxWorkers=1", + "currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments/runtime-environments-pairing.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/hooks/useIpcEvents-terminal-create-surfacing.test.ts src/renderer/src/web/web-preload-api-runtime-environment.test.ts --maxWorkers=1", "result": "passed", "durationSeconds": 6.55, "summary": "Ten files and 282 tests passed, including the composed Local -> Windows 2 navigation -> local reveal -> restart regression, dedicated-only preference persistence, removed transient-host fallback, multi-client browser routing, late host-operation suppression, and web pairing/preference separation." @@ -10944,11 +10926,11 @@ "date": "2026-07-30", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts --maxWorkers=1", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts --maxWorkers=1", "result": "passed", "durationSeconds": 8.77, "summary": "Nineteen files and 386 tests passed after rebasing onto 94cf2f1422f30fc309cb47c5e864a831d516fa8b, including exact-ID repo/worktree/group/folder host identity in both catalog orders, fail-closed active-repo selection when a runtime row is unavailable, selected runtime and SSH owner capture through final Add/Clone/Create/folder activation, direct-SSH authoritative pruning, reversed catalog responses, reconnect generation fencing, local-after-runtime isolation, and missing-catalog worktree routing.", - "currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-catalog-reconciliation.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts --maxWorkers=1" + "currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-catalog-reconciliation.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts --maxWorkers=1" }, { "date": "2026-07-30", @@ -10972,11 +10954,11 @@ "date": "2026-07-31", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts src/renderer/src/runtime/web-runtime-session.test.ts --maxWorkers=1", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts src/renderer/src/runtime/web-runtime-session.test.ts --maxWorkers=1", "result": "passed", "durationSeconds": 7.1, "summary": "Twenty files and 430 tests passed on current main integration, including explicit runtime-owner preservation for terminal and browser session activation.", - "currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-catalog-reconciliation.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts src/renderer/src/runtime/web-runtime-session.test.ts --maxWorkers=1" + "currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-catalog-reconciliation.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts src/renderer/src/runtime/web-runtime-session.test.ts --maxWorkers=1" }, { "date": "2026-07-31", diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index eee801b4894..ae014905319 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -9112,7 +9112,6 @@ "src/renderer/src/components/sidebar/AddRepoDialogChrome.test.tsx": 714, "src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx": 635, "src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx": 822, - "src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts": 374, "src/renderer/src/components/sidebar/AddWslHostFormPanel.test.tsx": 291, "src/renderer/src/components/sidebar/AutoRenameFailedDialog.test.tsx": 2254, "src/renderer/src/components/sidebar/CommentMarkdown.code-block-renderer.test.tsx": 2109, @@ -9279,7 +9278,6 @@ "src/renderer/src/components/sidebar/ssh-host-remove-resolution.test.ts": 46, "src/renderer/src/components/sidebar/stale-agent-row-unverifiable.test.ts": 2160, "src/renderer/src/components/sidebar/truncated-sidebar-label.test.tsx": 227, - "src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts": 404, "src/renderer/src/components/sidebar/use-add-repo-windows-drive-refusal.test.tsx": 470, "src/renderer/src/components/sidebar/use-add-repo-wsl-host-paths.test.tsx": 660, "src/renderer/src/components/sidebar/use-feedback-image-drop.test.tsx": 511, @@ -9297,9 +9295,6 @@ "src/renderer/src/components/sidebar/use-worktree-agent-rows-subscriptions.test.tsx": 3635, "src/renderer/src/components/sidebar/use-worktree-card-secondary-details.store-subscriptions.test.tsx": 2446, "src/renderer/src/components/sidebar/use-wsl-distro-list.test.ts": 542, - "src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts": 286, - "src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts": 262, - "src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts": 429, "src/renderer/src/components/sidebar/useRenderedSetupScriptPromptState.test.ts": 623, "src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.test.tsx": 2035, "src/renderer/src/components/sidebar/useSidebarProjectDrop.os-file-drop.test.tsx": 777, @@ -9516,19 +9511,15 @@ "src/renderer/src/components/status-bar/workspace-space-layout.test.ts": 34, "src/renderer/src/components/status-bar/workspace-space-presentation.test.ts": 448, "src/renderer/src/components/status-bar/workspace-space-worktree-row.test.tsx": 2849, - "src/renderer/src/components/tab-bar/BrowserTab.test.tsx": 1937, "src/renderer/src/components/tab-bar/ClientHostedBrowserTabRows.test.tsx": 1089, "src/renderer/src/components/tab-bar/CopyTabIdMenuItem.test.tsx": 428, - "src/renderer/src/components/tab-bar/EditorFileTab.test.tsx": 1824, "src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx": 1976, "src/renderer/src/components/tab-bar/NativeChatNamingFailure.test.tsx": 1062, "src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx": 3000, "src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts": 1019, "src/renderer/src/components/tab-bar/RecentTabSwitcher.test.tsx": 788, - "src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx": 1136, "src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx": 1257, "src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx": 977, - "src/renderer/src/components/tab-bar/TabBar.client-hosted-row-active-state.test.ts": 8232, "src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts": 8259, "src/renderer/src/components/tab-bar/TabBar.os-file-drop.test.tsx": 1297, "src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts": 3772, @@ -9543,7 +9534,6 @@ "src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx": 930, "src/renderer/src/components/tab-bar/TabBarItemRow.test.tsx": 2116, "src/renderer/src/components/tab-bar/TabBarQuickCommandItem.test.tsx": 384, - "src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.keyboard.test.ts": 159, "src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.virtualization.test.tsx": 1644, "src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx": 1683, "src/renderer/src/components/tab-bar/TabStripScrollIndicator.test.tsx": 356, @@ -9625,8 +9615,6 @@ "src/renderer/src/components/tab-group/tab-moves-drawn-from-frames.test.ts": 2105, "src/renderer/src/components/tab-group/useTabDragSplit.test.ts": 2120, "src/renderer/src/components/tab-group/useTabGroupCloseScopeCommands.test.tsx": 2023, - "src/renderer/src/components/tab-group/useTabGroupCreationCommands.local-shell.test.ts": 1714, - "src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts": 505, "src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.test.tsx": 2004, "src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts": 1714, "src/renderer/src/components/tab-group/workspace-tab-close-commands.floating-reclaim.test.ts": 1716, @@ -9696,7 +9684,6 @@ "src/renderer/src/components/task-source-provider-availability.test.ts": 52, "src/renderer/src/components/terminal-agent-tab-shortcut.test.ts": 7612, "src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx": 484, - "src/renderer/src/components/terminal-pane/MobileDriverOverlay.test.tsx": 257, "src/renderer/src/components/terminal-pane/PinnedTabCloseDialog.test.tsx": 2221, "src/renderer/src/components/terminal-pane/RunningTerminalCloseDialog.test.tsx": 2303, "src/renderer/src/components/terminal-pane/TerminalAgentSessionForkDialog.test.tsx": 266, @@ -13826,7 +13813,6 @@ "tests/e2e/terminal-cold-activation-deferral.spec.ts": 22200, "tests/e2e/terminal-column-desync-repro.spec.ts": 149400, "tests/e2e/terminal-context-menu-session-id.spec.ts": 20800, - "tests/e2e/terminal-cursor-inactive-style.spec.ts": 15700, "tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts": 23000, "tests/e2e/terminal-foreground-redraw-freeze.spec.ts": 14800, "tests/e2e/terminal-hangul-wrap-boundary-bytes.spec.ts": 107100, diff --git a/src/renderer/src/components/editor/ImageViewer.test.tsx b/src/renderer/src/components/editor/ImageViewer.test.tsx index c3bfd84e030..eb7ddc5ce27 100644 --- a/src/renderer/src/components/editor/ImageViewer.test.tsx +++ b/src/renderer/src/components/editor/ImageViewer.test.tsx @@ -158,49 +158,6 @@ function pngBase64(width: number): string { return bytes.toString('base64') } -async function renderPdfViewerProps( - scrollCacheKey?: string | null -): Promise> { - reactHookRuntime.index = 0 - const module = await import('./ImageViewer') - const rendered = expandNode( - module.default({ - content: 'JVBERi0xLjQK', - filePath: '/repo/report.pdf', - mimeType: 'application/pdf', - ...(scrollCacheKey === undefined ? {} : { scrollCacheKey }) - }) - ) - const [pdf] = findElementsByType(rendered, 'PdfViewer') - if (!pdf) { - throw new Error('PdfViewer not rendered') - } - return pdf.props -} - -describe('ImageViewer PDF scroll cache key', () => { - beforeEach(() => { - reactHookRuntime.states = [] - reactHookRuntime.index = 0 - vi.clearAllMocks() - }) - - it('forwards the scroll cache key to PdfViewer', async () => { - expect(await renderPdfViewerProps('/repo/report.pdf::tab-2:pdf')).toMatchObject({ - filePath: '/repo/report.pdf', - scrollCacheKey: '/repo/report.pdf::tab-2:pdf' - }) - }) - - it('passes null when no key is supplied, so unkeyed callers stay opted out', async () => { - expect((await renderPdfViewerProps()).scrollCacheKey).toBeNull() - }) - - it('passes an explicit null through unchanged', async () => { - expect((await renderPdfViewerProps(null)).scrollCacheKey).toBeNull() - }) -}) - describe('ImageViewer preview source retry', () => { beforeEach(() => { reactHookRuntime.states = [] @@ -263,20 +220,4 @@ describe('ImageViewer reading its bytes by URL', () => { expect(findPreviewImage(rendered).props.src).toBe('orca-media://file/token?revision=r1') expect(JSON.stringify(rendered)).toContain('2.0 KB') }) - - it("hands a PDF's URL to PdfViewer instead of base64", async () => { - reactHookRuntime.index = 0 - const module = await import('./ImageViewer') - const [pdf] = findElementsByType( - expandNode( - module.default({ - src: 'orca-media://file/doc?revision=r1', - filePath: '/repo/report.pdf', - mimeType: 'application/pdf' - }) - ), - 'PdfViewer' - ) - expect(pdf?.props).toMatchObject({ src: 'orca-media://file/doc?revision=r1', content: '' }) - }) }) diff --git a/src/renderer/src/components/editor/NotesSendMenu.test.tsx b/src/renderer/src/components/editor/NotesSendMenu.test.tsx index 878359ab1ab..9633fe1f339 100644 --- a/src/renderer/src/components/editor/NotesSendMenu.test.tsx +++ b/src/renderer/src/components/editor/NotesSendMenu.test.tsx @@ -252,28 +252,11 @@ function renderMenu( } describe('buildNotesSendTargetModeId', () => { - it('keeps note-send target ids stable for the same parts', () => { - expect(buildNotesSendTargetModeId(['markdown-notes', 'wt-1', 'README.md', 'rail'])).toBe( - buildNotesSendTargetModeId(['markdown-notes', 'wt-1', 'README.md', 'rail']) - ) - }) - it('uses part boundaries so adjacent values cannot collide', () => { expect(buildNotesSendTargetModeId(['markdown-notes', 'ab', 'c'])).not.toBe( buildNotesSendTargetModeId(['markdown-notes', 'a', 'bc']) ) }) - - it('separates markdown rail, panel, per-note, and diff send targets', () => { - const ids = new Set([ - buildNotesSendTargetModeId(['markdown-notes', 'wt-1', 'README.md', 'rail']), - buildNotesSendTargetModeId(['markdown-notes', 'wt-1', 'README.md', 'preview-panel']), - buildNotesSendTargetModeId(['markdown-notes', 'wt-1', 'README.md', 'note', 'note-1']), - buildNotesSendTargetModeId(['diff-notes', 'wt-1', 'group-1', 'README.md']) - ]) - - expect(ids.size).toBe(4) - }) }) describe('NotesSendMenu', () => { @@ -295,15 +278,6 @@ describe('NotesSendMenu', () => { expect(storeMocks.openAgentSendPopoverTargetMode).not.toHaveBeenCalled() }) - it('uses caller-provided disabled tooltip copy for disabled note actions', () => { - const tree = renderMenu({ - scopes: [{ id: 'note', label: 'This note', notes: [], formatPrompt: () => '' }], - disabledTooltip: 'Note already sent' - }) - - expect(findByType(tree, 'button').props.title).toBe('Note already sent') - }) - it('opens and closes target mode with the default scope', () => { const onDelivered = vi.fn() const tree = renderMenu({ onDelivered }) @@ -334,18 +308,6 @@ describe('NotesSendMenu', () => { ) }) - it('passes the default scope to review note send content', () => { - const tree = renderMenu() - - expect(findByType(tree, 'ReviewNotesSendMenuContent').props).toMatchObject({ - worktreeId: 'wt-1', - groupId: 'group-1', - prompt: 'prompt-all', - promptDelivery: 'submit-after-ready', - launchSource: 'notes_send' - }) - }) - it('switches running-agent target mode when a different scope is focused', () => { const tree = renderMenu({ defaultScopeId: 'file', diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx index 04f302be74c..3bfbfaad858 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx @@ -363,48 +363,6 @@ describe('ReviewNotesSendMenuContent', () => { setStore() }) - it('enumerates each running agent of the worktree as a send target', () => { - const statusPaneKey = makePaneKey(TAB_A, LEAF_A) - setStore({ - tabsByWorktree: { - 'wt-1': [ - tab(TAB_A, { title: 'Terminal 1' }), - tab(TAB_B, { title: 'Codex', launchAgent: 'codex' }) - ] - }, - terminalLayoutsByTabId: { - [TAB_A]: leafLayout(LEAF_A, 'pty-a'), - [TAB_B]: leafLayout(LEAF_B, 'pty-b') - } - }) - harness.noteTargets = [ - { - paneKey: statusPaneKey, - tabId: TAB_A, - messageTarget: { kind: 'terminal', tabId: TAB_A, leafId: LEAF_A }, - agentType: 'claude', - tabTitle: 'Terminal 1', - status: 'eligible' - }, - { - paneKey: makePaneKey(TAB_B, LEAF_B), - tabId: TAB_B, - messageTarget: { kind: 'terminal', tabId: TAB_B, leafId: LEAF_B }, - agentType: 'codex', - tabTitle: 'Codex', - status: 'eligible' - } - ] - - const tree = render() - const items = findAllByType(tree, 'DropdownMenuItem') - - expect(items).toHaveLength(2) - expect(items.every((item) => item.props.disabled === false)).toBe(true) - expect(collectText(items[0])).toContain('Claude') - expect(collectText(items[1])).toContain('Codex') - }) - it('orders send targets by the current worktree agent rows and shows status timing', () => { const paneKeyA = makePaneKey(TAB_A, LEAF_A) const paneKeyB = makePaneKey(TAB_B, LEAF_B) @@ -818,19 +776,6 @@ describe('ReviewNotesSendMenuContent', () => { }) }) - it('always offers the new-agent launcher', () => { - const onPromptHandedOff = vi.fn() - const tree = render({ onPromptHandedOff }) - - expect(findByType(tree, 'QuickLaunchAgentMenuItems').props).toMatchObject({ - worktreeId: 'wt-1', - groupId: 'group-1', - prompt: 'my notes', - launchSource: 'notes_send', - onPromptHandedOff - }) - }) - // Notes held by other sends leave no prompt; do not launch an empty agent. it('disables the new-agent launcher when there is nothing left to send', () => { expect(findByType(render({ prompt: '' }), 'QuickLaunchAgentMenuItems').props.disabled).toBe( diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.spec.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.spec.tsx index eeef66e9e0a..8659539eb88 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.spec.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.spec.tsx @@ -20,14 +20,11 @@ import { findByProp, findByTypeName, getMockedLocalStorage, - getPanelClassName, getPanelStyleBounds, renderPanel, runEffects, setViewport, - collectPropValues, - flushAsyncWork, - type ReactElementLike + flushAsyncWork } from './floating-terminal-panel-render-probe' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { @@ -68,12 +65,6 @@ describe('FloatingTerminalPanel close behavior', () => { expect(getPanelStyleBounds(element)).toEqual(savedBounds) }) - it('layers above root notification cards but below the modal layer', async () => { - const element = await renderPanel(true) - - expect(getPanelClassName(element)).toContain('z-[45]') - }) - it('falls back to default bounds when persisted geometry is malformed', async () => { getMockedLocalStorage().getItem.mockImplementation((key: string) => key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY @@ -357,18 +348,6 @@ describe('FloatingTerminalPanel close behavior', () => { ) expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) }) - - it('does not maximize on open when no intent is set', async () => { - const savedBounds = { left: 120, top: 96, width: 760, height: 420 } - getMockedLocalStorage().getItem.mockImplementation((key: string) => - key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY ? JSON.stringify(savedBounds) : null - ) - - const element = await renderPanel(true) - runEffects() - - expect(getPanelStyleBounds(element)).toEqual(savedBounds) - }) }) describe('FloatingTerminalPanel empty state', () => { @@ -401,97 +380,6 @@ describe('FloatingTerminalPanel empty state', () => { expect(mocks.createTab).not.toHaveBeenCalled() }) - it('requests the floating workspace tour only when the panel is open', async () => { - const persisted = Promise.resolve() - - await renderPanel(false, vi.fn(), { - wasPreviouslyInteracted: false, - persisted, - recordFeatureInteractionForTour: false - }) - - expect(mocks.useContextualTour).toHaveBeenLastCalledWith( - 'floating-workspace', - false, - 'floating_workspace_visible', - { - recordFeatureInteraction: false, - featureInteractionPersisted: persisted, - wasFeaturePreviouslyInteracted: false - } - ) - - await renderPanel(true, vi.fn(), { - wasPreviouslyInteracted: true, - persisted, - recordFeatureInteractionForTour: false - }) - - expect(mocks.useContextualTour).toHaveBeenLastCalledWith( - 'floating-workspace', - true, - 'floating_workspace_visible', - { - recordFeatureInteraction: false, - featureInteractionPersisted: persisted, - wasFeaturePreviouslyInteracted: true - } - ) - }) - - it('records the floating workspace tour interaction when the open snapshot deferred persistence', async () => { - await renderPanel(true, vi.fn(), { - wasPreviouslyInteracted: false, - recordFeatureInteractionForTour: true - }) - - expect(mocks.useContextualTour).toHaveBeenLastCalledWith( - 'floating-workspace', - true, - 'floating_workspace_visible', - { - recordFeatureInteraction: true, - featureInteractionPersisted: undefined, - wasFeaturePreviouslyInteracted: false - } - ) - }) - - it('targets the empty-state actions without co-mounting the surface fallback', async () => { - const element = await renderPanel(true) - const emptyState = findByTypeName(element, 'FloatingTerminalEmptyState') - const renderedEmptyState = ( - emptyState.type as (props: Record) => ReactElementLike - )(emptyState.props) - - expect(collectPropValues(element, 'data-contextual-tour-target')).not.toContain( - 'floating-workspace-surface' - ) - expect(collectPropValues(renderedEmptyState, 'data-contextual-tour-target')).toEqual([ - 'floating-workspace-new-terminal', - 'floating-workspace-new-markdown' - ]) - }) - - it('targets the non-empty panel surface when the empty-state actions are absent', async () => { - setFloatingTabs([makeTab({ id: 'tab-1' })]) - - const element = await renderPanel(true) - - expect(() => findByTypeName(element, 'FloatingTerminalEmptyState')).toThrow( - 'FloatingTerminalEmptyState not found' - ) - expect(collectPropValues(element, 'data-contextual-tour-target')).toContain( - 'floating-workspace-surface' - ) - expect(collectPropValues(element, 'data-contextual-tour-target')).not.toContain( - 'floating-workspace-new-terminal' - ) - expect(collectPropValues(element, 'data-contextual-tour-target')).not.toContain( - 'floating-workspace-new-markdown' - ) - }) - it('keeps the split surface mounted when its focused group has no tabs', async () => { setFloatingTabs([makeTab({ id: 'tab-1' })]) const state = storeBox.state as FloatingPanelStoreState @@ -609,25 +497,6 @@ describe('FloatingTerminalPanel markdown editor', () => { ) }) - it('disables markdown annotations in floating editor tabs', async () => { - setFloatingEditorTabs([makeFile({ id: 'notes' })]) - - const element = await renderPanel(true) - // Assert the shared tree’s policy: floating scratch Markdown must not expose review annotations. - const tree = findByTypeName(element, 'TabGroupSplitNodeTree') - - expect(tree.props.markdownAnnotationsEnabled).toBe(false) - expect(tree.props.isWorktreeActive).toBe(true) - }) - - it('marks the retained floating editor hidden when the panel is closed', async () => { - setFloatingEditorTabs([makeFile({ id: 'notes' })]) - - const element = await renderPanel(false) - - expect(findByTypeName(element, 'TabGroupSplitNodeTree').props.isWorktreeActive).toBe(false) - }) - it('routes every dirty editor from close-all-files to the central save queue', async () => { setFloatingEditorTabs([ makeFile({ id: 'file-a', isDirty: true }), diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.focus.spec.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.focus.spec.tsx index 59935c8267b..2d86090b312 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.focus.spec.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.focus.spec.tsx @@ -190,41 +190,6 @@ describe('FloatingTerminalPanel close behavior', () => { expect(() => runEffects()).not.toThrow() }) - it('reclaims panel keyboard focus after a focused last-pane close empties the panel', async () => { - setFloatingTabs([makeTab({ id: 'tab-1' })]) - const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() } - const activeElement = { closest: vi.fn().mockReturnValue(panelElement) } - Object.setPrototypeOf(activeElement, HTMLElement.prototype) - vi.stubGlobal('document', { - activeElement, - addEventListener: vi.fn(), - removeEventListener: vi.fn() - }) - // A second render pass is needed before the active terminal pane mounts. - await renderPanel(true) - runEffects() - await Promise.resolve() - await renderPanel(true) - runEffects() - await Promise.resolve() - const element = await renderPanel(true) - attachRef(findByProp(element, 'data-floating-terminal-panel').props.ref, panelElement) - runEffects() - - const tabBar = findByTypeName(element, 'TabBar') - ;(tabBar.props.onClose as (tabId: string) => void)('tab-1') - expect(mocks.closeTerminalTab).toHaveBeenCalledWith( - 'tab-1', - expect.objectContaining({ onClosed: expect.any(Function) }) - ) - - setFloatingTabs([]) - const emptyElement = await renderPanel(true) - attachRef(findByProp(emptyElement, 'data-floating-terminal-panel').props.ref, panelElement) - runEffects() - expect(panelElement.focus).toHaveBeenCalledWith({ preventScroll: true }) - }) - it('cancels the pending reclaim frame when the panel root unmounts before it runs', async () => { const cancelAnimationFrame = vi.fn() vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.spec.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.spec.tsx index 99af4def331..9bdaae09a85 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.spec.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.spec.tsx @@ -12,7 +12,6 @@ import { } from './floating-terminal-panel-test-fixtures' import { mocks, setupFloatingTerminalPanelTest } from './floating-terminal-panel-test-harness' import { - findByProp, findByTypeName, flushAsyncWork, renderPanel, @@ -27,23 +26,6 @@ describe('FloatingTerminalPanel close behavior', () => { afterEach(() => { vi.unstubAllGlobals() }) - it('creates new floating terminal tabs with workspace-owned activation', async () => { - setFloatingTabs([makeTab({ id: 'tab-1' })]) - - const element = await renderPanel(true) - const tabBar = findByTypeName(element, 'TabBar') - ;(tabBar.props.onNewTerminalTab as () => void)() - await flushAsyncWork() - - expect(mocks.createTab).toHaveBeenCalledWith( - FLOATING_TERMINAL_WORKTREE_ID, - 'floating-group', - undefined, - { id: expect.any(String) } - ) - expect(mocks.activateTab).not.toHaveBeenCalled() - expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith(mocks.createTab.mock.calls[0][3]?.id) - }) it('keeps floating browser create and duplicate local during active web runtime sessions', async () => { setFloatingTabs([makeTab({ id: 'tab-1' })]) @@ -122,27 +104,6 @@ describe('FloatingTerminalPanel close behavior', () => { expect(openLayers.props.worktreePath).toBe('/tmp/orca') }) - it('wires the shared pane overlay with the floating parking and shortcut policy', async () => { - setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })]) - - await renderPanel(true) - runEffects() - await Promise.resolve() - const element = await renderPanel(true) - - expect(findByTypeName(element, 'WorkspacePaneOverlayLayers').props).toEqual( - expect.objectContaining({ - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - shouldColdParkTerminalPanes: false, - isForceParked: false, - shouldMeasureHiddenWorktree: false, - backgroundMountTabIds: null, - activationDeferredMountTabIds: null, - ownsNativeChatToggleShortcut: false - }) - ) - }) - it('keeps the panel open when the explicit close action removes the last tab', async () => { const onOpenChange = vi.fn() setFloatingTabs([makeTab({ id: 'tab-1' })]) @@ -159,24 +120,6 @@ describe('FloatingTerminalPanel close behavior', () => { expect(onOpenChange).not.toHaveBeenCalled() }) - it('keeps the panel open when the explicit close action leaves another tab', async () => { - const onOpenChange = vi.fn() - setFloatingTabs([ - makeTab({ id: 'tab-1', sortOrder: 0 }), - makeTab({ id: 'tab-2', sortOrder: 1 }) - ]) - - const element = await renderPanel(true, onOpenChange) - const tabBar = findByTypeName(element, 'TabBar') - ;(tabBar.props.onClose as (tabId: string) => void)('tab-2') - - expect(mocks.closeTerminalTab).toHaveBeenCalledWith( - 'tab-2', - expect.objectContaining({ onClosed: expect.any(Function) }) - ) - expect(onOpenChange).not.toHaveBeenCalled() - }) - it('renders and closes simulator tabs in the floating workspace', async () => { const tab = setFloatingSimulatorTab() @@ -404,44 +347,3 @@ describe('FloatingTerminalPanel close behavior', () => { expect(mocks.closeTerminalTab).not.toHaveBeenCalledWith('tab-c', expect.anything()) }) }) - -describe('FloatingTerminalPanel tab drag wiring', () => { - beforeEach(setupFloatingTerminalPanelTest) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('hosts the tab strip and body tree in one shared drag scope', async () => { - setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })]) - - const element = await renderPanel(true) - const dragLayer = findByTypeName(element, 'WorkspaceTabDragLayer') - - expect(dragLayer.props.worktreeId).toBe(FLOATING_TERMINAL_WORKTREE_ID) - expect(dragLayer.props.enabled).toBe(true) - expect(findByTypeName(element, 'TabBar')).toBeDefined() - expect(findByTypeName(element, 'TabGroupSplitNodeTree')).toBeDefined() - }) - - it('mounts the tree and pane overlays inside the absolute flex surface frame', async () => { - setFloatingTabs([makeTab({ id: 'tab-1' })]) - - const element = await renderPanel(true) - const frame = findByProp(element, 'data-floating-workspace-surface-frame') - - // The tree needs a flex host or pane heights collapse to zero; this probe cannot measure layout. - expect(frame.props.className).toBe('absolute inset-0 flex') - expect(findByTypeName(frame, 'TabGroupSplitNodeTree')).toBeDefined() - // Keep overlays in the group bodies’ containing block so anchor/fallback geometry agrees. - expect(findByTypeName(frame, 'WorkspacePaneOverlayLayers')).toBeDefined() - }) - - it('leaves the drag scope inactive while the closed panel stays mounted', async () => { - setFloatingTabs([makeTab({ id: 'tab-1' })]) - - const element = await renderPanel(false) - - expect(findByTypeName(element, 'WorkspaceTabDragLayer').props.enabled).toBe(false) - }) -}) diff --git a/src/renderer/src/components/settings/TerminalAppearanceSection.config-import.test.ts b/src/renderer/src/components/settings/TerminalAppearanceSection.config-import.test.ts index d245ed010fe..8ef157e63f8 100644 --- a/src/renderer/src/components/settings/TerminalAppearanceSection.config-import.test.ts +++ b/src/renderer/src/components/settings/TerminalAppearanceSection.config-import.test.ts @@ -378,24 +378,6 @@ describe('TerminalAppearanceSection terminal config import wiring', () => { expect(configImportMock.handleClick).toHaveBeenCalled() }) - it('passes shared theme import controls into the theme catalog on desktop', () => { - const element = TerminalAppearanceSection({ - settings: {} as never, - updateSettings: () => {}, - systemPrefersDark: true, - terminalFontSuggestions: [], - terminalConfigImport: configImportMock, - warpThemes: warpThemesMock - }) - - const buttons = findButtons(element) - expect(buttons.some((button) => button.text === 'Import from Warp')).toBe(false) - - const catalog = findTerminalThemeCatalogSection(element) - expect(catalog?.props.warpThemes).toBe(warpThemesMock) - expect(catalog?.props.showThemeImport).toBe(true) - }) - it('routes dark and light theme searches to the matching catalog target', () => { mockSettingsSearchQuery = 'Light Divider Color' const lightElement = TerminalAppearanceSection({ @@ -557,44 +539,4 @@ describe('TerminalAppearanceSection terminal config import wiring', () => { } } ) - - it('passes hook state to TerminalConfigImportModal', () => { - const element = TerminalAppearanceSection({ - settings: {} as never, - updateSettings: () => {}, - systemPrefersDark: true, - terminalFontSuggestions: [], - terminalConfigImport: configImportMock, - warpThemes: warpThemesMock - }) - - const modal = findTerminalConfigImportModal(element) - expect(modal).not.toBeNull() - expect(modal?.props.open).toBe(configImportMock.open) - expect(modal?.props.preview).toEqual(configImportMock.preview) - expect(modal?.props.loading).toBe(configImportMock.loading) - expect(modal?.props.applied).toBe(configImportMock.applied) - expect(modal?.props.onApply).toBe(configImportMock.handleApply) - expect(modal?.props.onOpenChange).toBe(configImportMock.handleOpenChange) - }) - - it('passes hook state to WarpThemeImportModal', () => { - const element = TerminalAppearanceSection({ - settings: {} as never, - updateSettings: () => {}, - systemPrefersDark: true, - terminalFontSuggestions: [], - terminalConfigImport: configImportMock, - warpThemes: warpThemesMock - }) - - const modal = findWarpThemeImportModal(element) - expect(modal).not.toBeNull() - expect(modal?.props.open).toBe(warpThemesMock.open) - expect(modal?.props.preview).toEqual(warpThemesMock.preview) - expect(modal?.props.loading).toBe(warpThemesMock.loading) - expect(modal?.props.desktopOnly).toBe(false) - expect(modal?.props.handleApply).toBe(warpThemesMock.handleApply) - expect(modal?.props.handleOpenChange).toBe(warpThemesMock.handleOpenChange) - }) }) diff --git a/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts b/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts index b4a48a0a760..90be9883fb2 100644 --- a/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts +++ b/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts @@ -213,40 +213,6 @@ function collectText(node: unknown): string { return getPropNodes(el).map(collectText).join('') } -function findAnchorByText(node: unknown, text: string): ReactElementLike | null { - if (node == null) { - return null - } - if (Array.isArray(node)) { - for (const child of node) { - const found = findAnchorByText(child, text) - if (found) { - return found - } - } - return null - } - if (typeof node === 'string' || typeof node === 'number') { - return null - } - const el = node as ReactElementLike - const typeName = typeof el.type === 'function' ? el.type.name : String(el.type) - if (typeName === 'a' && collectText(el.props.children).includes(text)) { - return el - } - const rendered = renderFunctionElement(el) - if (rendered !== undefined) { - return findAnchorByText(rendered, text) - } - for (const child of getPropNodes(el)) { - const found = findAnchorByText(child, text) - if (found) { - return found - } - } - return null -} - function hasShellIconFor(node: unknown, shell: string): boolean { if (node == null || typeof node === 'string' || typeof node === 'number') { return false @@ -273,28 +239,6 @@ describe('TerminalPane PowerShell version setting', () => { vi.clearAllMocks() }) - it('shows the PowerShell 7+ download link when pwsh is unavailable', () => { - const element = TerminalPane({ - settings: { - terminalScrollbackRows: 5_000, - terminalWindowsShell: 'powershell.exe', - terminalWindowsPowerShellImplementation: 'powershell.exe', - terminalWordSeparator: '' - } as never, - updateSettings: () => {}, - scrollbackMode: 'preset', - setScrollbackMode: () => {}, - wslAvailable: false, - pwshAvailable: false, - gitBashAvailable: false - }) - - expect(collectText(element)).toContain('Auto uses Windows PowerShell now') - const link = findAnchorByText(element, 'Download PowerShell 7+') - expect(link).not.toBeNull() - expect(link?.props.href).toBe('https://github.com/PowerShell/PowerShell/releases/latest') - }) - it('does not show WSL as a Windows default shell option when available', () => { const element = TerminalPane({ settings: { @@ -318,49 +262,6 @@ describe('TerminalPane PowerShell version setting', () => { expect(text).not.toContain('WSL') }) - it('shows Windows shell controls for a remote Windows host on a non-Windows client', () => { - const element = TerminalPane({ - settings: { - terminalScrollbackRows: 5_000, - terminalWindowsShell: 'powershell.exe', - terminalWindowsPowerShellImplementation: 'auto', - terminalWordSeparator: '' - } as never, - updateSettings: () => {}, - scrollbackMode: 'preset', - setScrollbackMode: () => {}, - wslAvailable: true, - wslDistros: ['Ubuntu'], - pwshAvailable: false, - gitBashAvailable: false, - isWindowsTerminalHost: true - }) - - const text = collectText(element) - expect(text).toContain('Default shell for new terminal panes on Windows') - expect(text).toContain('Command Prompt') - expect(text).not.toContain('WSL') - }) - - it('hides WSL as a Windows default shell option when unavailable', () => { - const element = TerminalPane({ - settings: { - terminalScrollbackRows: 5_000, - terminalWindowsShell: 'powershell.exe', - terminalWindowsPowerShellImplementation: 'auto', - terminalWordSeparator: '' - } as never, - updateSettings: () => {}, - scrollbackMode: 'preset', - setScrollbackMode: () => {}, - wslAvailable: false, - pwshAvailable: false, - gitBashAvailable: false - }) - - expect(collectText(element)).not.toContain('WSL') - }) - it('shows a persisted WSL default without PowerShell or distro sub-settings', () => { const element = TerminalPane({ settings: { diff --git a/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts b/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts index 3cd6ccf84fb..260940ae930 100644 --- a/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts +++ b/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts @@ -161,24 +161,6 @@ function findElementByClassSubstring( return findElementByClassSubstring(element.props?.children, classNameSubstring) } -function findButtonTexts(node: unknown): string[] { - if (node == null || typeof node === 'string' || typeof node === 'number') { - return [] - } - if (Array.isArray(node)) { - return node.flatMap(findButtonTexts) - } - const element = node as ReactElementLike - const typeName = getTypeName(element) - if (typeName === 'WarpThemeImportButton') { - return ['Import from Warp'] - } - if (typeName === 'YamlThemeImportButton') { - return ['Import from YAML'] - } - return [...findButtonTexts(element.props?.children), ...findButtonTexts(element.props?.action)] -} - describe('TerminalThemeCatalogSection', () => { beforeEach(() => { themeTarget = 'dark' @@ -186,16 +168,6 @@ describe('TerminalThemeCatalogSection', () => { vi.clearAllMocks() }) - it('renders one theme picker and one preview for the active target', () => { - const element = renderCatalog(makeSettings({ terminalUseSeparateLightTheme: false }), vi.fn()) - - expect(countElementsByTypeName(element, 'ThemePicker')).toBe(1) - expect(countElementsByTypeName(element, 'TerminalSettingsPreview')).toBe(1) - expect(findElementByTypeName(element, 'TerminalSettingsPreview')?.props?.modeOverride).toBe( - 'dark' - ) - }) - it('opens on the active light theme when the system appearance is light', () => { const updateSettings = vi.fn() const element = renderCatalog( @@ -301,17 +273,6 @@ describe('TerminalThemeCatalogSection', () => { expect(preview?.props?.modeOverride).toBe('light') }) - it('keeps the light target enabled while separate light theme is disabled', () => { - const element = renderCatalog(makeSettings({ terminalUseSeparateLightTheme: false }), vi.fn()) - const targetControl = findElementByTypeName(element, 'SettingsSegmentedControl') - const options = targetControl?.props?.options as readonly { - value: string - disabled?: boolean - }[] - - expect(options.find((option) => option.value === 'light')?.disabled).toBeUndefined() - }) - it('uses the preferred target when opened from a light-specific search', () => { const element = renderCatalog( makeSettings({ terminalUseSeparateLightTheme: true }), @@ -435,19 +396,4 @@ describe('TerminalThemeCatalogSection', () => { expect(updateSettings).toHaveBeenCalledWith({ terminalDividerColorDark: '#000000' }) }) - - it('passes imported theme highlight signals into the shared picker', () => { - const element = renderCatalog() - const picker = findElementByTypeName(element, 'ThemePicker') - - expect(picker?.props?.importedHighlightSignal).toBe(7) - }) -}) - -describe('Terminal theme imports', () => { - it('renders the Warp and YAML import buttons inside the combined theme catalog', () => { - const buttonTexts = findButtonTexts(renderCatalog()) - - expect(buttonTexts).toEqual(['Import from Warp', 'Import from YAML']) - }) }) diff --git a/src/renderer/src/components/settings/useWarpThemeImport.test.ts b/src/renderer/src/components/settings/useWarpThemeImport.test.ts index 1bf0981cbe5..479ee78f85e 100644 --- a/src/renderer/src/components/settings/useWarpThemeImport.test.ts +++ b/src/renderer/src/components/settings/useWarpThemeImport.test.ts @@ -199,47 +199,6 @@ describe('useWarpThemeImport', () => { expect(warp.selectedThemeIds.has('warp:two')).toBe(true) }) - it('reports desktop-only preview responses', async () => { - const previewResponse: WarpThemeImportPreview = { - found: false, - desktopOnly: true, - themes: [], - skippedFiles: [], - error: 'Warp theme import is available in the desktop app.' - } - vi.stubGlobal('window', { - api: { settings: { previewWarpThemeImport: vi.fn().mockResolvedValue(previewResponse) } } - }) - - let warp = useWarpThemeImport(vi.fn(), baseSettings) - await warp.handleClick() - resetMockState() - warp = useWarpThemeImport(vi.fn(), baseSettings) - - expect(warp.desktopOnly).toBe(true) - }) - - it('keeps an empty errorless preview unselected and not found', async () => { - const previewResponse: WarpThemeImportPreview = { - found: false, - sourceLabel: 'Warp themes', - skippedFiles: [], - themes: [] - } - vi.stubGlobal('window', { - api: { settings: { previewWarpThemeImport: vi.fn().mockResolvedValue(previewResponse) } } - }) - - let warp = useWarpThemeImport(vi.fn(), baseSettings) - await warp.handleClick() - resetMockState() - warp = useWarpThemeImport(vi.fn(), baseSettings) - - expect(warp.selectedThemeIds.size).toBe(0) - expect(warp.preview?.found).toBe(false) - expect(warp.preview?.error).toBeUndefined() - }) - it('opens the modal in yaml mode once the picker returns a selection', async () => { const previewResponse: WarpThemeImportPreview = { found: true, diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts b/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts deleted file mode 100644 index 30d0e1f604e..00000000000 --- a/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as ReactModule from 'react' -import type { Repo } from '../../../../shared/repo-types' - -const mocks = vi.hoisted(() => ({ - stateValues: [] as unknown[], - stateSetters: [] as ReturnType[], - stateIndex: 0, - storeState: { - repos: [] as Repo[], - projects: [], - projectHostSetups: [], - clearOrcaHookTrustForRepo: vi.fn(), - openModal: vi.fn(), - sshStateByEnvironment: new Map() - }, - listServerTargets: vi.fn(), - addServerRepo: vi.fn(), - connectServerTarget: vi.fn(), - addRemote: vi.fn(), - listTargets: vi.fn(), - getState: vi.fn(), - onStateChanged: vi.fn(() => vi.fn()), - fetchWorktrees: vi.fn(), - onGitRepoReady: vi.fn() -})) - -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T) => fn, - useMemo: (factory: () => T) => factory(), - useEffect: (effect: () => void | (() => void)) => { - effect() - }, - useRef: (value: T) => ({ current: value }), - useState: (initial: T | (() => T)) => { - const index = mocks.stateIndex++ - const value = - index in mocks.stateValues - ? mocks.stateValues[index] - : typeof initial === 'function' - ? (initial as () => T)() - : initial - const setter = vi.fn() - mocks.stateSetters[index] = setter - return [value as T, setter] - } - } -}) - -vi.mock('@/hooks/useMountedRef', () => ({ - useMountedRef: () => ({ current: true }) -})) - -vi.mock('@/store', () => { - const useAppStore = Object.assign( - (selector: (state: typeof mocks.storeState) => unknown) => selector(mocks.storeState), - { - getState: () => mocks.storeState, - setState: (next: Partial) => { - Object.assign(mocks.storeState, next) - } - } - ) - return { useAppStore } -}) - -vi.mock('@/runtime/runtime-ssh-target-management', () => ({ - listRuntimeEditableSshTargets: mocks.listServerTargets, - addRuntimeSshRepo: mocks.addServerRepo -})) - -vi.mock('@/runtime/runtime-environment-ssh-state', () => ({ - connectRuntimeEnvironmentSshTarget: mocks.connectServerTarget -})) - -vi.mock('sonner', () => ({ - toast: { - error: vi.fn(), - success: vi.fn(), - info: vi.fn() - } -})) - -function makeRepo(overrides: Partial = {}): Repo { - return { - id: 'repo-remote', - path: '/srv/repo', - displayName: 'remote-repo', - badgeColor: '#999999', - addedAt: 1, - kind: 'git', - ...overrides - } -} - -describe('useRemoteRepo', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.stateIndex = 0 - mocks.stateSetters = [] - mocks.stateValues = [[], 'ssh-1', '/srv/repo', null, false] - mocks.storeState.repos = [] - mocks.storeState.projects = [] - mocks.storeState.projectHostSetups = [] - mocks.listTargets.mockResolvedValue([ - { id: 'ssh-1', label: 'Builder 1' }, - { id: 'ssh-2', label: 'Builder 2' } - ]) - mocks.getState.mockResolvedValue({ status: 'connected' }) - vi.stubGlobal('window', { - api: { - ssh: { - listTargets: mocks.listTargets, - getState: mocks.getState, - onStateChanged: mocks.onStateChanged - }, - repos: { - addRemote: mocks.addRemote - } - } - }) - }) - - it("without a paired server, never lists, connects or adds through this client's SSH", async () => { - const setStep = vi.fn() - const sshConnect = vi.fn() - Object.assign(window.api.ssh, { connect: sshConnect }) - const { useRemoteRepo } = await import('./AddRepoSteps') - - const result = useRemoteRepo(mocks.fetchWorktrees, setStep, vi.fn(), mocks.onGitRepoReady, null) - await result.handleOpenRemoteStep('ssh-1') - await result.handleConnectTarget('ssh-1') - await result.handleAddRemoteRepo() - - expect(setStep).not.toHaveBeenCalled() - expect(mocks.listTargets).not.toHaveBeenCalled() - expect(sshConnect).not.toHaveBeenCalled() - expect(mocks.connectServerTarget).not.toHaveBeenCalled() - expect(mocks.addRemote).not.toHaveBeenCalled() - expect(mocks.addServerRepo).not.toHaveBeenCalled() - expect(mocks.fetchWorktrees).not.toHaveBeenCalled() - }) - - // Adapted from the community PR #8492 by @jae-heo. - describe("a paired server's own SSH hosts (#8489)", () => { - function serverRemoteRepo() { - return import('./AddRepoSteps').then(({ useRemoteRepo }) => - useRemoteRepo(mocks.fetchWorktrees, vi.fn(), vi.fn(), mocks.onGitRepoReady, 'env-linux') - ) - } - - it("lists the server's hosts, never this client's", async () => { - mocks.stateValues = [[], null, '~/', null, false] - mocks.listServerTargets.mockResolvedValue([ - { - id: 'ssh-p8', - label: 'p8', - host: 'p8', - port: 22, - username: 'me', - connectionStatus: 'connected' - } - ]) - - const result = await serverRemoteRepo() - await result.handleOpenRemoteStep('ssh-p8') - - expect(mocks.listServerTargets).toHaveBeenCalledWith('env-linux') - expect(mocks.listTargets).not.toHaveBeenCalled() - expect(mocks.getState).not.toHaveBeenCalled() - expect(mocks.stateSetters[0]).toHaveBeenCalledWith([ - expect.objectContaining({ - id: 'ssh-p8', - state: expect.objectContaining({ status: 'connected' }) - }) - ]) - expect(mocks.stateSetters[1]).toHaveBeenCalledWith('ssh-p8') - }) - - it('shows why when the server cannot list them, with no local fallback', async () => { - mocks.stateValues = [[], null, '~/', null, false] - mocks.listServerTargets.mockRejectedValue( - new Error('Update this Orca server to manage its SSH hosts from here.') - ) - - const result = await serverRemoteRepo() - await result.handleOpenRemoteStep() - - expect(mocks.listTargets).not.toHaveBeenCalled() - expect(mocks.stateSetters[3]).toHaveBeenCalledWith( - 'Update this Orca server to manage its SSH hosts from here.' - ) - }) - - it('adds the project through the server and refreshes it as server-owned', async () => { - const repo = makeRepo({ connectionId: 'ssh-p8', executionHostId: 'ssh:ssh-p8' }) - mocks.stateValues = [[], 'ssh-p8', '/srv/repo', null, false] - mocks.addServerRepo.mockResolvedValue(repo) - mocks.fetchWorktrees.mockResolvedValue(true) - - const result = await serverRemoteRepo() - await result.handleAddRemoteRepo() - - expect(mocks.addServerRepo).toHaveBeenCalledWith('env-linux', { - connectionId: 'ssh-p8', - remotePath: '/srv/repo' - }) - expect(mocks.addRemote).not.toHaveBeenCalled() - expect(mocks.storeState.repos).toContainEqual({ - ...repo, - executionHostId: 'runtime:env-linux' - }) - expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, { - requireAuthoritative: true, - executionHostId: 'runtime:env-linux' - }) - expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id, 'runtime:env-linux') - }) - - it('hands a non-git folder to the confirm dialog bound to the server', async () => { - mocks.stateValues = [[], 'ssh-p8', '/srv/notes', null, false] - mocks.addServerRepo.mockRejectedValue(new Error('Not a valid git repository: /srv/notes')) - - const result = await serverRemoteRepo() - await result.handleAddRemoteRepo() - - expect(mocks.storeState.openModal).toHaveBeenCalledWith('confirm-non-git-folder', { - folderPath: '/srv/notes', - connectionId: 'ssh-p8', - runtimeEnvironmentId: 'env-linux' - }) - }) - - it('connects the host through the server', async () => { - mocks.stateValues = [[], null, '~/', null, false] - mocks.connectServerTarget.mockResolvedValue({ - targetId: 'ssh-p8', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - const sshConnect = vi.fn() - Object.assign(window.api.ssh, { connect: sshConnect }) - - const result = await serverRemoteRepo() - await result.handleConnectTarget('ssh-p8') - - expect(mocks.connectServerTarget).toHaveBeenCalledWith('env-linux', 'ssh-p8') - expect(sshConnect).not.toHaveBeenCalled() - }) - }) -}) diff --git a/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts b/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts deleted file mode 100644 index b027af2a710..00000000000 --- a/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts +++ /dev/null @@ -1,439 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as ReactModule from 'react' -import type { SidebarHostOption } from './sidebar-host-options' - -const mocks = vi.hoisted(() => ({ - stateValues: [] as unknown[], - stateSetters: [] as ReturnType[], - stateIndex: 0, - refValues: [] as unknown[], - refIndex: 0, - hostOptions: [] as SidebarHostOption[], - storeState: { - settings: { activeRuntimeEnvironmentId: null as string | null }, - setActiveRuntimeEnvironmentPreference: vi.fn(), - setSshConnectionState: vi.fn(), - sshConnectionStates: new Map(), - runtimeEnvironments: [] as { id: string; name: string; source?: 'manual' | 'ephemeral-vm' }[] - }, - sshConnect: vi.fn(), - sshGetState: vi.fn(), - isWebClient: false -})) - -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T) => fn, - useMemo: (factory: () => T) => factory(), - useEffect: (effect: () => void | (() => void)) => { - effect() - }, - useRef: (value: T) => { - const index = mocks.refIndex++ - return { - current: index in mocks.refValues ? (mocks.refValues[index] as T) : value - } - }, - useState: (initial: T | (() => T)) => { - const index = mocks.stateIndex++ - const value = - index in mocks.stateValues - ? mocks.stateValues[index] - : typeof initial === 'function' - ? (initial as () => T)() - : initial - const setter = vi.fn() - mocks.stateSetters[index] = setter - return [value as T, setter] - } - } -}) - -vi.mock('@/store', () => ({ - useAppStore: Object.assign( - (selector: (state: typeof mocks.storeState) => unknown) => selector(mocks.storeState), - { getState: () => mocks.storeState } - ) -})) - -vi.mock('sonner', () => ({ - toast: { - error: vi.fn() - } -})) - -vi.mock('./use-sidebar-host-scope-options', () => ({ - useSidebarHostScopeOptions: () => ({ hostOptions: mocks.hostOptions }) -})) - -vi.mock('@/lib/web-client-location', () => ({ - isWebClientLocation: () => mocks.isWebClient -})) - -describe('useAddRepoHostSelection', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.stateIndex = 0 - mocks.stateSetters = [] - mocks.refIndex = 0 - mocks.refValues = [] - mocks.hostOptions = [ - { - id: 'local', - label: 'Local Mac', - detail: 'This computer', - kind: 'local', - health: 'local', - presence: 'local' - }, - { - id: 'ssh:ssh-1', - label: 'Builder', - detail: 'SSH', - kind: 'ssh', - health: 'available', - presence: 'configured' - }, - { - id: 'runtime:env-1', - label: 'Server', - detail: 'Runtime', - kind: 'runtime', - health: 'available', - presence: 'active' - } - ] - mocks.storeState.settings = { activeRuntimeEnvironmentId: null } - mocks.storeState.setActiveRuntimeEnvironmentPreference.mockResolvedValue(true) - mocks.storeState.sshConnectionStates = new Map() - mocks.storeState.runtimeEnvironments = [] - mocks.storeState.setSshConnectionState.mockReset() - mocks.sshConnect.mockReset() - mocks.sshGetState.mockReset() - mocks.isWebClient = false - vi.stubGlobal('window', { - api: { - ssh: { - connect: mocks.sshConnect, - getState: mocks.sshGetState - } - } - }) - }) - - it('never selects a connected SSH row; it holds the actions until the server row stands for it', async () => { - // Why: before the catalog hydrates, a connected SSH host is still its own row. - mocks.stateValues = ['ssh:ssh-1', false] - const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') - - const result = useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() }) - - expect(result.selectedHostId).toBeNull() - expect(result.selectedParsedHost).toBeNull() - expect(result.displayedHostId).toBe('ssh:ssh-1') - }) - - it('selects a runtime host without changing the durable active server', async () => { - mocks.stateValues = ['local', false] - const setStep = vi.fn() - const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') - - const result = useAddRepoHostSelection({ isOpen: true, setStep }) - await result.handleSelectAddProjectHost('runtime:env-1') - - expect(mocks.storeState.setActiveRuntimeEnvironmentPreference).not.toHaveBeenCalled() - expect(mocks.stateSetters[0]).toHaveBeenCalledWith('runtime:env-1') - expect(setStep).toHaveBeenCalledWith('add') - }) - - it('uses the paired runtime as the only local filesystem authority in web clients', async () => { - mocks.isWebClient = true - mocks.stateValues = ['local', false] - const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') - - const result = useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() }) - - expect(result.hostOptions.map((host) => host.id)).toEqual(['ssh:ssh-1', 'runtime:env-1']) - expect(result.selectedHostId).toBe('runtime:env-1') - expect(result.selectedParsedHost).toMatchObject({ - kind: 'runtime', - environmentId: 'env-1' - }) - }) - - it('has no local fallback while a paired web runtime is loading', async () => { - mocks.isWebClient = true - mocks.stateValues = ['local', false] - mocks.hostOptions = [mocks.hostOptions[0]] - const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') - - const result = useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() }) - - expect(result.hostOptions).toEqual([]) - expect(result.selectedHostId).toBeNull() - expect(result.selectedParsedHost).toBeNull() - expect(mocks.stateSetters[0]).not.toHaveBeenCalledWith('local') - }) - - it.each(['error', 'blocked'] as const)( - 'has no paired-web fallback when the only host is %s', - async (health) => { - mocks.isWebClient = true - mocks.stateValues = ['runtime:env-1', false] - mocks.hostOptions = [ - { - ...mocks.hostOptions[2], - health - } - ] - const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') - - const result = useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() }) - - expect(result.hostOptions).toHaveLength(1) - expect(result.selectedHostId).toBeNull() - expect(result.selectedParsedHost).toBeNull() - } - ) - - it('selects local without changing the durable active server, but not an SSH row', async () => { - mocks.stateValues = ['runtime:env-1', false] - mocks.storeState.settings = { activeRuntimeEnvironmentId: 'env-1' } - const setStep = vi.fn() - const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') - - const result = useAddRepoHostSelection({ isOpen: true, setStep }) - await result.handleSelectAddProjectHost('ssh:ssh-1') - expect(mocks.stateSetters[0]).not.toHaveBeenCalledWith('ssh:ssh-1') - expect(setStep).not.toHaveBeenCalled() - - await result.handleSelectAddProjectHost('local') - expect(mocks.storeState.setActiveRuntimeEnvironmentPreference).not.toHaveBeenCalled() - expect(mocks.stateSetters[0]).toHaveBeenCalledWith('local') - expect(setStep).toHaveBeenCalledWith('add') - }) - - it('keeps a disconnected SSH host it was asked for instead of falling back to Local Mac', async () => { - mocks.stateValues = ['ssh:ssh-1', false] - mocks.hostOptions[1] = { - ...mocks.hostOptions[1], - health: 'disconnected' - } - const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') - - const result = useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() }) - - expect(result.selectedHostId).toBeNull() - expect(result.displayedHostId).toBe('ssh:ssh-1') - }) - - it('does not select a disconnected SSH host', async () => { - mocks.stateValues = ['local', false] - mocks.hostOptions[1] = { - ...mocks.hostOptions[1], - health: 'disconnected' - } - const setStep = vi.fn() - const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') - - const result = useAddRepoHostSelection({ isOpen: true, setStep }) - await result.handleSelectAddProjectHost('ssh:ssh-1') - - expect(mocks.storeState.setActiveRuntimeEnvironmentPreference).not.toHaveBeenCalled() - expect(mocks.stateSetters[0]).not.toHaveBeenCalledWith('ssh:ssh-1') - expect(setStep).not.toHaveBeenCalled() - }) - - it('connects and selects a disconnected SSH host from Add Project', async () => { - mocks.stateValues = ['local', true] - mocks.hostOptions[1] = { - ...mocks.hostOptions[1], - health: 'disconnected' - } - mocks.sshConnect.mockResolvedValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - const setStep = vi.fn() - const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') - - const result = useAddRepoHostSelection({ isOpen: true, setStep }) - await result.handleConnectAddProjectHost('ssh:ssh-1') - - expect(mocks.storeState.setSshConnectionState).toHaveBeenCalledWith( - 'ssh-1', - expect.objectContaining({ status: 'connecting' }) - ) - expect(mocks.sshConnect).toHaveBeenCalledWith({ targetId: 'ssh-1' }) - expect(mocks.storeState.setSshConnectionState).toHaveBeenCalledWith( - 'ssh-1', - expect.objectContaining({ status: 'connected' }) - ) - expect(mocks.stateSetters[0]).toHaveBeenCalledWith('ssh:ssh-1') - expect(mocks.stateSetters[1]).toHaveBeenCalledWith(false) - expect(setStep).toHaveBeenCalledWith('add') - }) - - it('keeps the failure main published for the host, and toasts its reason (#29648)', async () => { - mocks.stateValues = ['local', true] - mocks.hostOptions[1] = { ...mocks.hostOptions[1], health: 'disconnected' } - const published = { - targetId: 'ssh-1', - status: 'auth-failed' as const, - error: 'All configured authentication methods failed', - reconnectAttempt: 0 - } - mocks.sshConnect.mockImplementation(async () => { - mocks.storeState.sshConnectionStates = new Map([['ssh-1', published]]) - throw new Error( - "Error invoking remote method 'ssh:connect': Error: All configured authentication methods failed" - ) - }) - const { toast } = await import('sonner') - const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') - - await useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() }).handleConnectAddProjectHost( - 'ssh:ssh-1' - ) - - expect(mocks.storeState.setSshConnectionState).toHaveBeenCalledTimes(1) - expect(toast.error).toHaveBeenCalledWith('All configured authentication methods failed') - }) - - it('marks the host failed with the error when connect rejects before main publishes', async () => { - mocks.stateValues = ['local', true] - mocks.hostOptions[1] = { ...mocks.hostOptions[1], health: 'disconnected' } - mocks.storeState.sshConnectionStates = new Map([ - [ - 'ssh-1', - { - targetId: 'ssh-1', - status: 'error', - error: 'old', - reconnectAttempt: 0, - managedServer: { kind: 'relay', reason: 'refused', detail: 'an earlier refusal' } - } - ] - ]) - mocks.storeState.setSshConnectionState.mockImplementation((id: string, state: unknown) => { - mocks.storeState.sshConnectionStates = new Map([[id, state]]) - }) - mocks.sshConnect.mockRejectedValue( - new Error( - "Error invoking remote method 'ssh:connect': Error: connect ECONNREFUSED 10.0.0.1:22" - ) - ) - const { toast } = await import('sonner') - const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') - - await useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() }).handleConnectAddProjectHost( - 'ssh:ssh-1' - ) - - expect(mocks.storeState.sshConnectionStates.get('ssh-1')).toEqual({ - targetId: 'ssh-1', - status: 'error', - error: 'connect ECONNREFUSED 10.0.0.1:22', - reconnectAttempt: 0, - remotePlatform: undefined - }) - expect(toast.error).toHaveBeenCalledWith('connect ECONNREFUSED 10.0.0.1:22') - }) - - it('does not auto-select the active runtime host while it is unavailable', async () => { - mocks.stateValues = ['local', false] - mocks.hostOptions[2] = { - ...mocks.hostOptions[2], - health: 'blocked' - } - mocks.storeState.settings = { activeRuntimeEnvironmentId: 'env-1' } - const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') - - useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() }) - - expect(mocks.stateSetters[0]).toHaveBeenCalledWith('local') - }) - - it('hides ephemeral VM runtime hosts from Add Project selection', async () => { - mocks.stateValues = ['runtime:env-vm', false] - mocks.hostOptions.push({ - id: 'runtime:env-vm', - label: 'orca VM abc12345', - detail: 'Runtime', - kind: 'runtime', - health: 'available', - presence: 'project' - }) - mocks.storeState.runtimeEnvironments = [ - { id: 'env-vm', name: 'orca VM abc12345', source: 'ephemeral-vm' } - ] - const setStep = vi.fn() - const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') - - const result = useAddRepoHostSelection({ isOpen: true, setStep }) - - expect(result.hostOptions.map((host) => host.id)).not.toContain('runtime:env-vm') - expect(result.selectedHostId).toBe('local') - await result.handleSelectAddProjectHost('runtime:env-vm') - expect(mocks.storeState.setActiveRuntimeEnvironmentPreference).not.toHaveBeenCalledWith( - 'env-vm' - ) - expect(setStep).not.toHaveBeenCalled() - }) - - describe('an SSH host merged with its managed server', () => { - function mergedHosts(serverHealth: SidebarHostOption['health']): SidebarHostOption[] { - return [ - mocks.hostOptions[0], - { - id: 'runtime:omarchy-server', - label: 'Omarchy', - detail: 'SSH', - kind: 'runtime', - health: serverHealth, - presence: 'project', - aliasHostIds: ['ssh:omarchy-target'] - }, - { - id: 'ssh:omarchy-target', - label: 'Omarchy', - detail: 'SSH', - kind: 'ssh', - health: 'available', - presence: 'configured', - mergedIntoHostId: 'runtime:omarchy-server' - } - ] - } - - it('keeps the SSH id a connect saved on its server row once the server is up', async () => { - mocks.hostOptions = mergedHosts('available') - mocks.stateValues = ['ssh:omarchy-target', false] - mocks.refValues = [true] - const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') - - const result = useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() }) - - expect(result.hostOptions.map((host) => host.id)).toEqual(['local', 'runtime:omarchy-server']) - expect(result.selectedHostId).toBe('runtime:omarchy-server') - expect(result.selectedParsedHost).toMatchObject({ kind: 'runtime' }) - }) - - it('blocks the actions instead of falling back to local while the server comes up', async () => { - mocks.hostOptions = mergedHosts('connecting') - mocks.stateValues = ['ssh:omarchy-target', false] - mocks.refValues = [true] - const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') - - const result = useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() }) - - expect(result.selectedHostId).toBeNull() - expect(result.selectedParsedHost).toBeNull() - expect(result.displayedHostId).toBe('runtime:omarchy-server') - }) - }) -}) diff --git a/src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts b/src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts deleted file mode 100644 index 8029bf64be6..00000000000 --- a/src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as ReactModule from 'react' -import type { NestedRepoScanResult } from '../../../../shared/project-group-types' -import type { Repo } from '../../../../shared/repo-types' - -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T) => fn, - useEffect: vi.fn(), - useRef: (value: T) => ({ current: value }) - } -}) - -vi.mock('sonner', () => ({ - toast: { - error: vi.fn(), - info: vi.fn() - } -})) - -vi.mock('@/lib/telemetry', () => ({ - track: vi.fn() -})) - -function makeScan( - path: string, - overrides: Partial = {} -): NestedRepoScanResult { - return { - selectedPath: path, - selectedPathKind: 'git_repo', - repos: [], - truncated: false, - timedOut: false, - stopped: false, - durationMs: 1, - maxDepth: 3, - maxRepos: 100, - timeoutMs: null, - ...overrides - } -} - -function makeRepo(path: string): Repo { - const id = path.split('/').pop() ?? path - return { - id, - path, - displayName: id, - badgeColor: '#999999', - addedAt: 1, - kind: 'git' - } -} - -describe('useAddRepoLocalFolderFlow', () => { - const addRepoPath = vi.fn() - const closeModal = vi.fn() - const openModal = vi.fn() - const fetchWorktrees = vi.fn() - const scanNestedRepos = vi.fn() - const setActiveNestedScanId = vi.fn() - const setNestedScanInProgress = vi.fn() - const showNestedRepoReview = vi.fn() - const onGitRepoReady = vi.fn() - const setIsAdding = vi.fn() - const setAddProjectBusyLabel = vi.fn() - const pickFolders = vi.fn() - - beforeEach(() => { - vi.clearAllMocks() - vi.stubGlobal('window', { - api: { - repos: { - pickFolders - } - } - }) - addRepoPath.mockImplementation(async (path: string) => makeRepo(path)) - fetchWorktrees.mockResolvedValue(true) - scanNestedRepos.mockImplementation(async (path: string) => makeScan(path)) - onGitRepoReady.mockResolvedValue(undefined) - }) - - it('adds every selected local folder and completes one default-checkout handoff', async () => { - pickFolders.mockResolvedValue(['/projects/alpha', '/projects/beta']) - const { useAddRepoLocalFolderFlow } = await import('./useAddRepoLocalFolderFlow') - - const { handleBrowse } = useAddRepoLocalFolderFlow({ - isOpen: true, - droppedLocalPath: '', - activeRuntimeEnvironmentId: null, - addRepoPath, - openModal, - closeModal, - fetchWorktrees, - scanNestedRepos, - setActiveNestedScanId, - setNestedScanInProgress, - showNestedRepoReview, - onGitRepoReady, - setIsAdding, - setAddProjectBusyLabel - }) - - await handleBrowse() - - expect(pickFolders).toHaveBeenCalledTimes(1) - expect(addRepoPath).toHaveBeenCalledTimes(2) - expect(addRepoPath).toHaveBeenNthCalledWith(1, '/projects/alpha', undefined, { - runtimeEnvironmentId: null - }) - expect(addRepoPath).toHaveBeenNthCalledWith(2, '/projects/beta', undefined, { - runtimeEnvironmentId: null - }) - expect(scanNestedRepos).toHaveBeenCalledWith( - '/projects/alpha', - undefined, - expect.objectContaining({ runtimeEnvironmentId: null }) - ) - expect(fetchWorktrees).toHaveBeenCalledWith('alpha', { - requireAuthoritative: true, - executionHostId: 'local' - }) - expect(fetchWorktrees).toHaveBeenCalledWith('beta', { - requireAuthoritative: true, - executionHostId: 'local' - }) - expect(onGitRepoReady).toHaveBeenCalledTimes(1) - expect(onGitRepoReady).toHaveBeenCalledWith('alpha', 'local_folder_picker', 'local') - }) - - it('holds a single browsed folder the WSL host offer takes, and adds nothing', async () => { - const path = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' - pickFolders.mockResolvedValue([path]) - const offerWslHost = vi.fn(() => true) - const { useAddRepoLocalFolderFlow } = await import('./useAddRepoLocalFolderFlow') - - const { handleBrowse } = useAddRepoLocalFolderFlow({ - isOpen: true, - droppedLocalPath: '', - activeRuntimeEnvironmentId: null, - addRepoPath, - openModal, - closeModal, - fetchWorktrees, - scanNestedRepos, - setActiveNestedScanId, - setNestedScanInProgress, - showNestedRepoReview, - onGitRepoReady, - setIsAdding, - setAddProjectBusyLabel, - offerWslHost - }) - - await handleBrowse() - - expect(offerWslHost).toHaveBeenCalledWith(path, 'local_folder_picker') - expect(scanNestedRepos).not.toHaveBeenCalled() - expect(addRepoPath).not.toHaveBeenCalled() - expect(setIsAdding).toHaveBeenLastCalledWith(false) - }) - - it('skips nested-review folders in a multi-folder add and continues with git folders', async () => { - pickFolders.mockResolvedValue(['/projects/monorepo', '/projects/later']) - scanNestedRepos.mockImplementationOnce(async (_path, _connectionId, controls) => { - const scan = makeScan('/projects/monorepo', { - selectedPathKind: 'non_git_folder', - repos: [{ path: '/projects/monorepo/app', displayName: 'app', depth: 1 }] - }) - controls?.onProgress?.(scan) - return scan - }) - const { useAddRepoLocalFolderFlow } = await import('./useAddRepoLocalFolderFlow') - - const { handleBrowse } = useAddRepoLocalFolderFlow({ - isOpen: true, - droppedLocalPath: '', - activeRuntimeEnvironmentId: null, - addRepoPath, - openModal, - closeModal, - fetchWorktrees, - scanNestedRepos, - setActiveNestedScanId, - setNestedScanInProgress, - showNestedRepoReview, - onGitRepoReady, - setIsAdding, - setAddProjectBusyLabel - }) - - await handleBrowse() - - expect(showNestedRepoReview).not.toHaveBeenCalled() - expect(addRepoPath).toHaveBeenCalledTimes(1) - expect(addRepoPath).toHaveBeenCalledWith('/projects/later', undefined, { - runtimeEnvironmentId: null - }) - expect(scanNestedRepos).toHaveBeenCalledTimes(2) - expect(onGitRepoReady).toHaveBeenCalledWith('later', 'local_folder_picker', 'local') - }) - - it('still completes handoff when a later selected folder is skipped', async () => { - pickFolders.mockResolvedValue(['/projects/git', '/projects/monorepo']) - scanNestedRepos.mockResolvedValueOnce(makeScan('/projects/git')).mockResolvedValueOnce( - makeScan('/projects/monorepo', { - selectedPathKind: 'non_git_folder', - repos: [{ path: '/projects/monorepo/app', displayName: 'app', depth: 1 }] - }) - ) - const { useAddRepoLocalFolderFlow } = await import('./useAddRepoLocalFolderFlow') - - const { handleBrowse } = useAddRepoLocalFolderFlow({ - isOpen: true, - droppedLocalPath: '', - activeRuntimeEnvironmentId: null, - addRepoPath, - openModal, - closeModal, - fetchWorktrees, - scanNestedRepos, - setActiveNestedScanId, - setNestedScanInProgress, - showNestedRepoReview, - onGitRepoReady, - setIsAdding, - setAddProjectBusyLabel - }) - - await handleBrowse() - - expect(showNestedRepoReview).not.toHaveBeenCalled() - expect(addRepoPath).toHaveBeenCalledTimes(1) - expect(addRepoPath).toHaveBeenCalledWith('/projects/git', undefined, { - runtimeEnvironmentId: null - }) - expect(onGitRepoReady).toHaveBeenCalledWith('git', 'local_folder_picker', 'local') - }) - - it('ignores stale scans and offers Open as Folder only for the current plain directory', async () => { - pickFolders.mockResolvedValue(['/projects/stale']) - let resolveScan!: (scan: NestedRepoScanResult) => void - scanNestedRepos.mockReturnValueOnce( - new Promise((resolve) => { - resolveScan = resolve - }) - ) - const { useAddRepoLocalFolderFlow } = await import('./useAddRepoLocalFolderFlow') - const flow = useAddRepoLocalFolderFlow({ - isOpen: true, - droppedLocalPath: '', - activeRuntimeEnvironmentId: null, - addRepoPath, - openModal, - closeModal, - fetchWorktrees, - scanNestedRepos, - setActiveNestedScanId, - setNestedScanInProgress, - showNestedRepoReview, - onGitRepoReady, - setIsAdding, - setAddProjectBusyLabel - }) - - const adding = flow.handleBrowse() - await vi.waitFor(() => expect(scanNestedRepos).toHaveBeenCalled()) - flow.resetLocalFolderFlow() - resolveScan(makeScan('/projects/stale')) - await adding - - expect(addRepoPath).not.toHaveBeenCalled() - expect(onGitRepoReady).not.toHaveBeenCalled() - scanNestedRepos.mockResolvedValueOnce( - makeScan('/projects/notes', { selectedPathKind: 'non_git_folder' }) - ) - await flow.handleAddLocalPath('/projects/notes', 'local_folder_picker') - expect(openModal).toHaveBeenCalledWith('confirm-non-git-folder', { - folderPath: '/projects/notes' - }) - expect(closeModal.mock.invocationCallOrder[0]).toBeLessThan( - openModal.mock.invocationCallOrder[0] - ) - expect(addRepoPath).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts deleted file mode 100644 index 9b4ed9ef1e0..00000000000 --- a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as ReactModule from 'react' -import type { - NestedRepoScanResult, - ProjectGroupImportResult -} from '../../../../shared/project-group-types' -import type { Repo } from '../../../../shared/repo-types' - -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T) => fn, - useRef: (value: T) => ({ current: value }) - } -}) - -const folderRepo: Repo = { - id: 'platform', - path: '/workspace/platform', - displayName: 'platform', - badgeColor: '#999999', - addedAt: 1, - kind: 'folder' -} - -const mocks = vi.hoisted(() => ({ - state: { - repos: [] as Repo[], - addNonGitFolder: vi.fn(), - closeModal: vi.fn(), - openModal: vi.fn() - } -})) - -vi.mock('@/store', () => { - const useAppStore = Object.assign( - (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state), - { - getState: () => mocks.state - } - ) - return { useAppStore } -}) - -vi.mock('sonner', () => ({ - toast: { - error: vi.fn(), - warning: vi.fn() - } -})) - -vi.mock('@/lib/telemetry', () => ({ - track: vi.fn() -})) - -import { track } from '@/lib/telemetry' -import { useAddRepoNestedImportFlow } from './useAddRepoNestedImportFlow' - -const scan: NestedRepoScanResult = { - selectedPath: '/workspace/platform', - selectedPathKind: 'non_git_folder', - repos: [{ path: '/workspace/platform/app', displayName: 'app', depth: 1 }], - truncated: false, - timedOut: false, - stopped: false, - durationMs: 3, - maxDepth: 3, - maxRepos: 100, - timeoutMs: null -} - -function useTestAddRepoNestedImportFlow( - overrides: Partial[0]> = {} -): ReturnType { - return useAddRepoNestedImportFlow({ - nestedAttemptId: 'attempt-1', - nestedScan: scan, - nestedSelectedPaths: new Set(), - nestedRuntimeKind: 'local', - nestedConnectionId: null, - nestedGroupName: 'platform', - nestedImportScanId: 'scan-1', - nestedRuntimeEnvironmentId: null, - activeRuntimeEnvironmentId: null, - closeModal: mocks.state.closeModal, - fetchWorktrees: vi.fn(), - importNestedRepos: vi.fn<() => Promise>(), - getNestedRepoRuntimeKind: vi.fn(() => 'local' as const), - onGitRepoReady: vi.fn(), - setIsAdding: vi.fn(), - ...overrides - }) -} - -describe('useAddRepoNestedImportFlow open folder fallback', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.state.repos = [] - mocks.state.addNonGitFolder.mockResolvedValue(folderRepo) - }) - - it('opens the scanned local root through the existing non-git folder flow', async () => { - const setIsAdding = vi.fn() - const { handleOpenNestedRootFolder } = useTestAddRepoNestedImportFlow({ setIsAdding }) - - await handleOpenNestedRootFolder() - - expect(mocks.state.addNonGitFolder).toHaveBeenCalledWith('/workspace/platform', { - runtimeEnvironmentId: null - }) - expect(mocks.state.closeModal).toHaveBeenCalledTimes(1) - expect(setIsAdding).toHaveBeenNthCalledWith(1, true) - expect(setIsAdding).toHaveBeenNthCalledWith(2, false) - }) - - it('keeps runtime folder opens on the runtime that produced the scan', async () => { - const { handleOpenNestedRootFolder } = useTestAddRepoNestedImportFlow({ - activeRuntimeEnvironmentId: 'env-1', - nestedRuntimeEnvironmentId: 'env-1' - }) - - await handleOpenNestedRootFolder() - - expect(mocks.state.addNonGitFolder).toHaveBeenCalledWith('/workspace/platform', { - runtimeEnvironmentId: 'env-1' - }) - }) - - it('names the folder project after the edited group name', async () => { - const { handleOpenNestedRootFolder } = useTestAddRepoNestedImportFlow({ - nestedGroupName: ' inf-오케스트레이터 ' - }) - - await handleOpenNestedRootFolder() - - expect(mocks.state.addNonGitFolder).toHaveBeenCalledWith('/workspace/platform', { - runtimeEnvironmentId: null, - displayName: 'inf-오케스트레이터' - }) - }) - - it('leaves host basename naming alone when the group name is untouched', async () => { - const { handleOpenNestedRootFolder } = useTestAddRepoNestedImportFlow({ - nestedGroupName: ' platform ' - }) - - await handleOpenNestedRootFolder() - - expect(mocks.state.addNonGitFolder).toHaveBeenCalledWith('/workspace/platform', { - runtimeEnvironmentId: null - }) - }) - - it('carries the edited group name into the SSH folder confirmation', async () => { - const { handleOpenNestedRootFolder } = useTestAddRepoNestedImportFlow({ - nestedConnectionId: 'ssh-builder', - nestedRuntimeKind: 'ssh', - nestedGroupName: 'inf-오케스트레이터' - }) - - await handleOpenNestedRootFolder() - - expect(mocks.state.openModal).toHaveBeenCalledWith( - 'confirm-non-git-folder', - expect.objectContaining({ displayName: 'inf-오케스트레이터' }) - ) - }) - - it('tracks the open-as-folder recovery action with zero selection', async () => { - const { handleOpenNestedRootFolder } = useTestAddRepoNestedImportFlow() - - await handleOpenNestedRootFolder() - - expect(track).toHaveBeenCalledWith( - 'add_repo_nested_import_action', - expect.objectContaining({ - action: 'open_as_folder', - surface: 'sidebar', - runtime_kind: 'local', - found_count: 1, - selected_count: 0 - }) - ) - }) - - it('uses the existing SSH non-git folder confirmation for SSH scans', async () => { - const { handleOpenNestedRootFolder } = useTestAddRepoNestedImportFlow({ - nestedConnectionId: 'ssh-builder', - nestedRuntimeKind: 'ssh' - }) - - await handleOpenNestedRootFolder() - - expect(mocks.state.addNonGitFolder).not.toHaveBeenCalled() - expect(mocks.state.closeModal).toHaveBeenCalledTimes(1) - expect(mocks.state.openModal).toHaveBeenCalledWith('confirm-non-git-folder', { - folderPath: '/workspace/platform', - connectionId: 'ssh-builder', - runtimeEnvironmentId: null - }) - }) - - it('keeps SSH import and completion pinned when repo hydration is missing', async () => { - const importedRepo: Repo = { - ...folderRepo, - id: 'ssh-app', - path: scan.repos[0].path, - kind: 'git', - connectionId: 'ssh-builder' - } - const importNestedRepos = vi.fn().mockResolvedValue({ - projects: [ - { path: importedRepo.path, projectId: importedRepo.id, status: 'imported' as const } - ], - importedCount: 1, - alreadyKnownCount: 0, - failedCount: 0 - }) - const fetchWorktrees = vi.fn() - const onGitRepoReady = vi.fn() - mocks.state.repos = [] - const { handleImportNestedRepos } = useTestAddRepoNestedImportFlow({ - activeRuntimeEnvironmentId: null, - nestedConnectionId: 'ssh-builder', - nestedRuntimeEnvironmentId: null, - nestedRuntimeKind: 'ssh', - nestedSelectedPaths: new Set([importedRepo.path]), - importNestedRepos, - fetchWorktrees, - onGitRepoReady - }) - - await handleImportNestedRepos('group') - - expect(importNestedRepos).toHaveBeenCalledWith({ - parentPath: scan.selectedPath, - groupName: 'platform', - projectPaths: [importedRepo.path], - connectionId: 'ssh-builder', - scanId: 'scan-1', - runtimeEnvironmentId: null, - mode: 'group' - }) - expect(fetchWorktrees).toHaveBeenCalledWith(importedRepo.id, { - requireAuthoritative: true, - executionHostId: 'ssh:ssh-builder' - }) - expect(onGitRepoReady).not.toHaveBeenCalled() - }) - it.each(['group', 'separate'] as const)( - 'carries grouped import outcome without losing SSH owner (%s)', - async (mode) => { - const importedRepo: Repo = { - ...folderRepo, - id: 'app', - kind: 'git', - path: scan.repos[0].path, - connectionId: 'ssh-builder' - } - mocks.state.repos = [importedRepo] - const onGitRepoReady = vi.fn() - const result: ProjectGroupImportResult = { - group: { - id: 'group', - name: 'platform', - parentPath: scan.selectedPath, - connectionId: 'ssh-builder', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - }, - projects: [{ path: importedRepo.path, projectId: importedRepo.id, status: 'imported' }], - importedCount: 1, - alreadyKnownCount: 0, - failedCount: 0 - } - const flow = useTestAddRepoNestedImportFlow({ - nestedConnectionId: 'ssh-builder', - nestedRuntimeKind: 'ssh', - nestedSelectedPaths: new Set([importedRepo.path]), - importNestedRepos: vi.fn().mockResolvedValue(result), - onGitRepoReady - }) - await flow.handleImportNestedRepos(mode) - expect(onGitRepoReady).toHaveBeenCalledWith( - importedRepo.id, - 'ssh_remote_path', - 'ssh:ssh-builder', - mode === 'group' ? { importedProjectGroupId: 'group' } : undefined - ) - } - ) -}) diff --git a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts deleted file mode 100644 index 58677cbd3a1..00000000000 --- a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as ReactModule from 'react' -import type { Repo } from '../../../../shared/repo-types' - -const mocks = vi.hoisted(() => ({ - stateValues: [] as unknown[], - stateSetters: [] as ReturnType[], - stateIndex: 0, - storeState: { - settings: { activeRuntimeEnvironmentId: null as string | null }, - repos: [] as Repo[], - projects: [], - projectHostSetups: [], - worktreesByRepo: {} as Record - }, - createRepo: vi.fn(), - callRuntimeRpc: vi.fn(), - fetchWorktrees: vi.fn(), - onGitRepoReady: vi.fn(), - activateAndRevealWorktree: vi.fn(), - markOnboardingProjectAdded: vi.fn() -})) - -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T) => fn, - useRef: (value: T) => ({ current: value }), - useState: (initial: T | (() => T)) => { - const index = mocks.stateIndex++ - const value = - index in mocks.stateValues - ? mocks.stateValues[index] - : typeof initial === 'function' - ? (initial as () => T)() - : initial - const setter = vi.fn() - mocks.stateSetters[index] = setter - return [value as T, setter] - } - } -}) - -vi.mock('@/hooks/useMountedRef', () => ({ - useMountedRef: () => ({ current: true }) -})) - -vi.mock('@/lib/worktree-activation', () => ({ - activateAndRevealWorktree: mocks.activateAndRevealWorktree -})) - -vi.mock('@/lib/onboarding-project-checklist', () => ({ - markOnboardingProjectAdded: mocks.markOnboardingProjectAdded -})) - -vi.mock('@/store', () => { - const useAppStore = Object.assign( - (selector: (state: typeof mocks.storeState) => unknown) => selector(mocks.storeState), - { - getState: () => mocks.storeState, - setState: (next: Partial) => { - Object.assign(mocks.storeState, next) - } - } - ) - return { useAppStore } -}) - -vi.mock('sonner', () => ({ - toast: { - error: vi.fn(), - success: vi.fn(), - info: vi.fn() - } -})) - -vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) - -const STATE_NAME = 0 -const STATE_PARENT_PATH = 1 -const STATE_ERROR_MESSAGE = 2 -const STATE_IS_CREATING = 3 - -function makeRepo(overrides: Partial = {}): Repo { - return { - id: 'repo-created', - path: '/projects/created', - displayName: 'created', - badgeColor: '#999999', - addedAt: 1, - kind: 'git', - ...overrides - } -} - -describe('useCreateRepo default-checkout handoff', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.stateIndex = 0 - mocks.stateSetters = [] - mocks.stateValues = [] - mocks.stateValues[STATE_NAME] = 'created' - mocks.stateValues[STATE_PARENT_PATH] = '/projects' - mocks.stateValues[STATE_ERROR_MESSAGE] = null - mocks.stateValues[STATE_IS_CREATING] = false - mocks.storeState.repos = [] - mocks.storeState.projects = [] - mocks.storeState.projectHostSetups = [] - mocks.storeState.worktreesByRepo = {} - mocks.createRepo.mockReset() - mocks.storeState.settings.activeRuntimeEnvironmentId = null - vi.stubGlobal('window', { - api: { - repos: { - create: mocks.createRepo, - pickDirectory: vi.fn() - } - } - }) - }) - - it('requests an authoritative worktree refresh before handoff', async () => { - const repo = makeRepo() - mocks.createRepo.mockResolvedValue({ repo }) - mocks.fetchWorktrees.mockResolvedValue(true) - const { useCreateRepo } = await import('./useCreateRepo') - - const result = useCreateRepo(mocks.fetchWorktrees, vi.fn(), mocks.onGitRepoReady) - await result.handleCreate() - - expect(mocks.createRepo).toHaveBeenCalledWith({ - parentPath: '/projects', - name: 'created', - kind: 'git' - }) - expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, { - requireAuthoritative: true - }) - expect(mocks.storeState.projects).toEqual( - expect.arrayContaining([expect.objectContaining({ sourceRepoIds: [repo.id] })]) - ) - expect(mocks.storeState.projectHostSetups).toEqual( - expect.arrayContaining([expect.objectContaining({ repoId: repo.id, path: repo.path })]) - ) - expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id, undefined) - }) - - it('returns the selected parent directory after the local picker applies it', async () => { - const pickedDir = '/Users/alice/custom-projects' - vi.mocked(window.api.repos.pickDirectory).mockResolvedValue(pickedDir) - const { useCreateRepo } = await import('./useCreateRepo') - - const result = useCreateRepo(mocks.fetchWorktrees, vi.fn(), mocks.onGitRepoReady) - await expect(result.handlePickParent()).resolves.toBe(pickedDir) - - expect(mocks.stateSetters[STATE_PARENT_PATH]).toHaveBeenCalledWith(pickedDir) - }) - - it('does not return a parent path when the runtime target blocks the local picker', async () => { - const { useCreateRepo } = await import('./useCreateRepo') - - const result = useCreateRepo(mocks.fetchWorktrees, vi.fn(), mocks.onGitRepoReady, { - runtimeEnvironmentId: 'env-1' - }) - await expect(result.handlePickParent()).resolves.toBeNull() - - expect(window.api.repos.pickDirectory).not.toHaveBeenCalled() - expect(mocks.stateSetters[STATE_PARENT_PATH]).not.toHaveBeenCalled() - }) - - it('continues to completion when refresh is not authoritative after create', async () => { - const repo = makeRepo() - mocks.createRepo.mockResolvedValue({ repo }) - mocks.fetchWorktrees.mockResolvedValue(false) - const { useCreateRepo } = await import('./useCreateRepo') - - const result = useCreateRepo(mocks.fetchWorktrees, vi.fn(), mocks.onGitRepoReady) - await result.handleCreate() - - expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, { - requireAuthoritative: true - }) - expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id, undefined) - expect(mocks.stateSetters[STATE_ERROR_MESSAGE]).not.toHaveBeenCalledWith( - 'Could not refresh project worktrees. Try again.' - ) - }) - - it('opens an existing folder project returned by create dedupe', async () => { - const repo = makeRepo({ kind: 'folder' }) - const worktree = { id: `${repo.id}::/projects/created` } - const closeModal = vi.fn() - mocks.createRepo.mockResolvedValue({ repo }) - mocks.fetchWorktrees.mockImplementation(async (repoId: string) => { - mocks.storeState.worktreesByRepo = { [repoId]: [worktree] } - return true - }) - const { useCreateRepo } = await import('./useCreateRepo') - - const result = useCreateRepo(mocks.fetchWorktrees, closeModal, mocks.onGitRepoReady) - await result.handleCreate() - - expect(mocks.createRepo).toHaveBeenCalledWith({ - parentPath: '/projects', - name: 'created', - kind: 'git' - }) - expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id) - expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith(worktree.id, { - sidebarRevealBehavior: 'auto' - }) - expect(mocks.markOnboardingProjectAdded).toHaveBeenCalledWith('addedFolder') - expect(closeModal).toHaveBeenCalled() - expect(mocks.onGitRepoReady).not.toHaveBeenCalled() - }) - - it('creates projects through the selected runtime environment', async () => { - const repo = makeRepo({ path: '/srv/created' }) - mocks.callRuntimeRpc.mockResolvedValue({ repo }) - mocks.fetchWorktrees.mockResolvedValue(true) - const { useCreateRepo } = await import('./useCreateRepo') - - const result = useCreateRepo(mocks.fetchWorktrees, vi.fn(), mocks.onGitRepoReady, { - hostId: 'runtime:env-1', - runtimeEnvironmentId: 'env-1' - }) - await result.handleCreate() - - expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( - { kind: 'environment', environmentId: 'env-1' }, - 'repo.create', - { - parentPath: '/projects', - name: 'created', - kind: 'git' - }, - { timeoutMs: 60_000 } - ) - expect(mocks.createRepo).not.toHaveBeenCalled() - expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, { - requireAuthoritative: true, - executionHostId: 'runtime:env-1' - }) - expect(mocks.storeState.repos).toContainEqual({ - ...repo, - executionHostId: 'runtime:env-1' - }) - expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id, 'runtime:env-1') - }) - - it('never creates on this computer while the chosen host is unresolved', async () => { - const { useCreateRepo } = await import('./useCreateRepo') - - const result = useCreateRepo(mocks.fetchWorktrees, vi.fn(), mocks.onGitRepoReady, { - hostId: null, - runtimeEnvironmentId: null - }) - await result.handleCreate() - - expect(mocks.createRepo).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx deleted file mode 100644 index 51fa45d829a..00000000000 --- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx +++ /dev/null @@ -1,392 +0,0 @@ -import type * as ReactModule from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { isValidElement } from 'react' -import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' -import type * as StoreModule from '@/store' - -const reactHookRuntime = vi.hoisted(() => ({ - states: [] as unknown[], - index: 0 -})) - -// Why: the shallow harness has no React dispatcher, so the store hook reads state directly. -vi.mock('@/store', async () => { - const actual = await vi.importActual('@/store') - const store = actual.useAppStore - return { - ...actual, - useAppStore: Object.assign( - (selector: (state: ReturnType) => T): T => - selector(store.getState()), - store - ) - } -}) - -vi.mock('react', async () => { - const actual = await vi.importActual('react') - return { - ...actual, - useEffect: () => {}, - // This shallow harness has no React dispatcher; favicon tests do not need persistent refs or event handlers. - useRef: (initial: T) => ({ current: initial }), - useCallback: (fn: T) => fn, - useSyncExternalStore: (_subscribe: unknown, getSnapshot: () => T) => getSnapshot(), - useState(initial: T | (() => T)) { - const stateIndex = reactHookRuntime.index++ - if (!(stateIndex in reactHookRuntime.states)) { - reactHookRuntime.states[stateIndex] = - typeof initial === 'function' ? (initial as () => T)() : initial - } - const setState = (next: T | ((previous: T) => T)): void => { - reactHookRuntime.states[stateIndex] = - typeof next === 'function' - ? (next as (previous: T) => T)(reactHookRuntime.states[stateIndex] as T) - : next - } - return [reactHookRuntime.states[stateIndex] as T, setState] as const - } - } -}) - -vi.mock('./use-tab-strip-slot-props', () => ({ - useTabStripSlotProps: () => ({ className: '', 'data-tab-strip-slot': '' }) -})) - -vi.mock('@dnd-kit/sortable', () => ({ - useSortable: () => ({ - attributes: {}, - listeners: { onPointerDown: vi.fn() }, - setNodeRef: vi.fn() - }) -})) - -vi.mock('lucide-react', () => ({ - ArrowDown: function ArrowDown(props: Record) { - return { type: 'ArrowDown', props } - }, - ArrowLeft: function ArrowLeft(props: Record) { - return { type: 'ArrowLeft', props } - }, - ArrowRight: function ArrowRight(props: Record) { - return { type: 'ArrowRight', props } - }, - ArrowUp: function ArrowUp(props: Record) { - return { type: 'ArrowUp', props } - }, - Columns2: function Columns2(props: Record) { - return { type: 'Columns2', props } - }, - Copy: function Copy(props: Record) { - return { type: 'Copy', props } - }, - CopyX: function CopyX(props: Record) { - return { type: 'CopyX', props } - }, - ExternalLink: function ExternalLink(props: Record) { - return { type: 'ExternalLink', props } - }, - Globe: function Globe(props: Record) { - return { type: 'Globe', props } - }, - Pin: function Pin(props: Record) { - return { type: 'Pin', props } - }, - PinOff: function PinOff(props: Record) { - return { type: 'PinOff', props } - }, - PanelLeftClose: function PanelLeftClose(props: Record) { - return { type: 'PanelLeftClose', props } - }, - PanelRightClose: function PanelRightClose(props: Record) { - return { type: 'PanelRightClose', props } - }, - Rows2: function Rows2(props: Record) { - return { type: 'Rows2', props } - }, - Volume2: function Volume2(props: Record) { - return { type: 'Volume2', props } - }, - VolumeX: function VolumeX(props: Record) { - return { type: 'VolumeX', props } - }, - X: function X(props: Record) { - return { type: 'X', props } - } -})) - -vi.mock('@/components/ui/context-menu', () => ({ - ContextMenu: function ContextMenu(props: { children?: unknown }) { - return { type: 'ContextMenu', props } - }, - ContextMenuContent: function ContextMenuContent(props: { children?: unknown }) { - return { type: 'ContextMenuContent', props } - }, - ContextMenuItem: function ContextMenuItem(props: { children?: unknown }) { - return { type: 'ContextMenuItem', props } - }, - ContextMenuSeparator: function ContextMenuSeparator() { - return { type: 'ContextMenuSeparator', props: {} } - }, - ContextMenuShortcut: function ContextMenuShortcut(props: { children?: unknown }) { - return { type: 'ContextMenuShortcut', props } - }, - ContextMenuLabel: function ContextMenuLabel(props: { children?: unknown }) { - return { type: 'ContextMenuLabel', props } - }, - ContextMenuSub: function ContextMenuSub(props: { children?: unknown }) { - return { type: 'ContextMenuSub', props } - }, - ContextMenuSubContent: function ContextMenuSubContent(props: { children?: unknown }) { - return { type: 'ContextMenuSubContent', props } - }, - ContextMenuSubTrigger: function ContextMenuSubTrigger(props: { children?: unknown }) { - return { type: 'ContextMenuSubTrigger', props } - }, - ContextMenuTrigger: function ContextMenuTrigger(props: { children?: unknown }) { - return { type: 'ContextMenuTrigger', props } - } -})) - -vi.mock('./TabHoverCard', () => ({ - TabHoverCard: function TabHoverCard(props: { children?: unknown }) { - return props.children - } -})) - -vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: function Tooltip(props: { children?: unknown }) { - return { type: 'Tooltip', props } - }, - TooltipContent: function TooltipContent(props: { children?: unknown }) { - return { type: 'TooltipContent', props } - }, - TooltipTrigger: function TooltipTrigger(props: { children?: unknown }) { - return props.children - } -})) - -vi.mock('../browser-pane/describe-page/live-browser-url-registry', () => ({ - getLiveBrowserUrl: () => null -})) - -type ReactElementLike = { - type: unknown - props: Record -} - -function baseBrowserTab(overrides: Partial = {}): BrowserTabState { - return { - id: 'browser-1', - worktreeId: 'wt-1', - label: 'Browser 1', - sessionProfileId: null, - activePageId: 'page-1', - pageIds: ['page-1'], - url: 'https://example.com/', - title: 'Example', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 0, - ...overrides - } -} - -async function renderBrowserTab(tab: BrowserTabState, unifiedTabId = tab.id): Promise { - reactHookRuntime.index = 0 - const module = await import('./BrowserTab') - return module.default({ - tab, - isActive: true, - isPinned: false, - hasTabsToRight: false, - hasTabsToLeft: false, - tabCount: 1, - onActivate: () => {}, - onClose: () => {}, - onCloseOthers: () => {}, - onCloseToRight: () => {}, - onCloseToLeft: () => {}, - onDuplicate: () => {}, - onTogglePin: () => {}, - dragData: { - kind: 'tab', - worktreeId: tab.worktreeId, - groupId: 'group-1', - unifiedTabId, - visibleTabId: tab.id, - tabType: 'browser', - label: tab.title - }, - dropIndicator: undefined - }) -} - -function expandNode(node: unknown): unknown { - if (node == null || typeof node === 'string' || typeof node === 'number') { - return node - } - if (Array.isArray(node)) { - return node.map(expandNode) - } - const el = node as ReactElementLike - if (typeof el.type === 'function') { - return expandNode(el.type(el.props)) - } - return { - ...el, - props: { - ...el.props, - children: expandNode(el.props?.children) - } - } -} - -function findElementsByType(node: unknown, typeName: string): ReactElementLike[] { - const results: ReactElementLike[] = [] - const visit = (current: unknown): void => { - if (current == null || typeof current === 'string' || typeof current === 'number') { - return - } - if (Array.isArray(current)) { - for (const child of current) { - visit(child) - } - return - } - const el = current as ReactElementLike - if (el.type === typeName) { - results.push(el) - } - visit(el.props?.children) - } - visit(node) - return results -} - -async function renderExpandedBrowserTab(tab: BrowserTabState): Promise { - return expandNode(await renderBrowserTab(tab)) -} - -describe('BrowserTab favicon', { timeout: 30_000 }, () => { - beforeEach(() => { - reactHookRuntime.states = [] - reactHookRuntime.index = 0 - vi.clearAllMocks() - }) - - it('passes the workspace ID to Copy Tab ID instead of the browser content ID', async () => { - const { CopyTabIdMenuItem } = await import('./CopyTabIdMenuItem') - const tree = await renderBrowserTab(baseBrowserTab(), 'workspace-browser-1') - const visit = (node: unknown): ReactElementLike | undefined => { - if (Array.isArray(node)) { - return node.map(visit).find(Boolean) - } - if (!isValidElement<{ children?: unknown; unifiedTabId?: string }>(node)) { - return undefined - } - if (node.type === CopyTabIdMenuItem) { - return { type: node.type, props: node.props } - } - return visit(node.props.children) - } - expect(visit(tree)?.props.unifiedTabId).toBe('workspace-browser-1') - }) - - it('renders the favicon image when faviconUrl is present', async () => { - const iconUrl = 'https://example.com/favicon.ico' - const element = await renderExpandedBrowserTab(baseBrowserTab({ faviconUrl: iconUrl })) - - const images = findElementsByType(element, 'img') - expect(images).toHaveLength(1) - expect(images[0].props.src).toBe(iconUrl) - expect(images[0].props.alt).toBe('') - expect(images[0].props['aria-hidden']).toBe(true) - expect(images[0].props.draggable).toBe(false) - expect(images[0].props.className).toContain('size-3') - expect(images[0].props.className).toContain('mr-1') - expect(images[0].props.className).toContain('shrink-0') - expect(images[0].props.className).toContain('object-contain') - expect(images[0].props.className).not.toContain('drop-shadow') - expect(findElementsByType(element, 'Globe')).toHaveLength(0) - }) - - it('keeps the globe fallback for blank tabs without faviconUrl', async () => { - const element = await renderExpandedBrowserTab( - baseBrowserTab({ - url: 'about:blank', - title: 'about:blank', - faviconUrl: null - }) - ) - - expect(findElementsByType(element, 'img')).toHaveLength(0) - const globes = findElementsByType(element, 'Globe') - expect(globes).toHaveLength(1) - expect(globes[0].props.className).toContain('size-3') - expect(globes[0].props.className).toContain('mr-1') - expect(globes[0].props.className).toContain('shrink-0') - expect(globes[0].props.className).toContain('text-blue-500') - }) - - it('falls back to the globe after the favicon image errors', async () => { - const tab = baseBrowserTab({ faviconUrl: 'https://example.com/favicon.ico' }) - const firstRender = await renderExpandedBrowserTab(tab) - const image = findElementsByType(firstRender, 'img')[0] - - ;(image.props.onError as () => void)() - const secondRender = await renderExpandedBrowserTab(tab) - - expect(findElementsByType(secondRender, 'img')).toHaveLength(0) - expect(findElementsByType(secondRender, 'Globe')).toHaveLength(1) - }) - - it('resets the image-error fallback when faviconUrl changes', async () => { - const tab = baseBrowserTab({ faviconUrl: 'https://example.com/favicon.ico' }) - const firstRender = await renderExpandedBrowserTab(tab) - const image = findElementsByType(firstRender, 'img')[0] - - ;(image.props.onError as () => void)() - const failedRender = await renderExpandedBrowserTab(tab) - expect(findElementsByType(failedRender, 'Globe')).toHaveLength(1) - - const nextIconUrl = 'data:image/png;base64,abc123' - const resetRender = await renderExpandedBrowserTab( - baseBrowserTab({ id: tab.id, faviconUrl: nextIconUrl }) - ) - - const images = findElementsByType(resetRender, 'img') - expect(images).toHaveLength(1) - expect(images[0].props.src).toBe(nextIconUrl) - expect(findElementsByType(resetRender, 'Globe')).toHaveLength(0) - }) - - it('retries a failed favicon after a navigation clears and restores the same url', async () => { - const iconUrl = 'https://example.com/favicon.ico' - const tab = baseBrowserTab({ faviconUrl: iconUrl }) - const firstRender = await renderExpandedBrowserTab(tab) - const image = findElementsByType(firstRender, 'img')[0] - - ;(image.props.onError as () => void)() - const failedRender = await renderExpandedBrowserTab(tab) - expect(findElementsByType(failedRender, 'Globe')).toHaveLength(1) - - // A load clears the favicon, then the same site reports it again. - const loadingRender = await renderExpandedBrowserTab( - baseBrowserTab({ id: tab.id, faviconUrl: null }) - ) - expect(findElementsByType(loadingRender, 'Globe')).toHaveLength(1) - - const retryRender = await renderExpandedBrowserTab( - baseBrowserTab({ id: tab.id, faviconUrl: iconUrl }) - ) - - const images = findElementsByType(retryRender, 'img') - expect(images).toHaveLength(1) - expect(images[0].props.src).toBe(iconUrl) - expect(findElementsByType(retryRender, 'Globe')).toHaveLength(0) - }) -}) diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx deleted file mode 100644 index 6f1e8184cf1..00000000000 --- a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx +++ /dev/null @@ -1,590 +0,0 @@ -import type * as ReactModule from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { OpenFile } from '../../store/slices/editor' -import type * as LucideModule from 'lucide-react' -import type * as FileTypeIconsModule from '@/lib/file-type-icons' -import type { ActivePluginIconTheme } from '@/store/plugin-icon-themes' - -const reactHookRuntime = vi.hoisted(() => ({ - states: [] as unknown[], - index: 0 -})) - -const appStoreMocks = vi.hoisted(() => ({ - openMarkdownPreview: vi.fn(), - getState: vi.fn(() => ({ - settings: {}, - unifiedTabsByWorktree: { - 'wt-1': [{ id: '/repo/untitled-5.md', groupId: 'group-1' }] - }, - groupsByWorktree: { - 'wt-1': [{ id: 'group-1', tabOrder: ['/repo/untitled-5.md', 'tab-2'] }] - } - })) -})) - -const renameFileOnDiskMock = vi.hoisted(() => vi.fn()) - -vi.mock('react', async () => { - const actual = await vi.importActual('react') - return { - ...actual, - useEffect: () => {}, - useCallback unknown>(callback: T) { - return callback - }, - useRef(initial: T) { - return { current: initial } - }, - useState(initial: T | (() => T)) { - const stateIndex = reactHookRuntime.index++ - if (!(stateIndex in reactHookRuntime.states)) { - reactHookRuntime.states[stateIndex] = - typeof initial === 'function' ? (initial as () => T)() : initial - } - const setState = (next: T | ((previous: T) => T)): void => { - reactHookRuntime.states[stateIndex] = - typeof next === 'function' - ? (next as (previous: T) => T)(reactHookRuntime.states[stateIndex] as T) - : next - } - return [reactHookRuntime.states[stateIndex] as T, setState] as const - } - } -}) - -vi.mock('@/store/plugin-custom-editors', () => ({ useTabCustomEditor: () => null })) - -vi.mock('./use-tab-strip-slot-props', () => ({ - useTabStripSlotProps: () => ({ className: '', 'data-tab-strip-slot': '' }) -})) - -vi.mock('@dnd-kit/sortable', () => ({ - useSortable: () => ({ - attributes: {}, - listeners: { onPointerDown: vi.fn() }, - setNodeRef: vi.fn() - }) -})) - -// Why: the real file-type-icons module (theme lookup) loads every icon in its tables. -vi.mock('lucide-react', async (importOriginal) => ({ - ...(await importOriginal()), - ArrowDown: function ArrowDown(props: Record) { - return { type: 'ArrowDown', props } - }, - ArrowLeft: function ArrowLeft(props: Record) { - return { type: 'ArrowLeft', props } - }, - ArrowRight: function ArrowRight(props: Record) { - return { type: 'ArrowRight', props } - }, - ArrowUp: function ArrowUp(props: Record) { - return { type: 'ArrowUp', props } - }, - Columns2: function Columns2(props: Record) { - return { type: 'Columns2', props } - }, - CopyX: function CopyX(props: Record) { - return { type: 'CopyX', props } - }, - PanelLeftClose: function PanelLeftClose(props: Record) { - return { type: 'PanelLeftClose', props } - }, - Copy: function Copy(props: Record) { - return { type: 'Copy', props } - }, - ExternalLink: function ExternalLink(props: Record) { - return { type: 'ExternalLink', props } - }, - Eye: function Eye(props: Record) { - return { type: 'Eye', props } - }, - ListX: function ListX(props: Record) { - return { type: 'ListX', props } - }, - PanelRightClose: function PanelRightClose(props: Record) { - return { type: 'PanelRightClose', props } - }, - GitCompareArrows: function GitCompareArrows(props: Record) { - return { type: 'GitCompareArrows', props } - }, - Pencil: function Pencil(props: Record) { - return { type: 'Pencil', props } - }, - Pin: function Pin(props: Record) { - return { type: 'Pin', props } - }, - PinOff: function PinOff(props: Record) { - return { type: 'PinOff', props } - }, - Rows2: function Rows2(props: Record) { - return { type: 'Rows2', props } - }, - ShieldAlert: function ShieldAlert(props: Record) { - return { type: 'ShieldAlert', props } - }, - Smartphone: function Smartphone(props: Record) { - return { type: 'Smartphone', props } - }, - X: function X(props: Record) { - return { type: 'X', props } - } -})) - -vi.mock('@/components/ui/context-menu', () => ({ - ContextMenu: function ContextMenu(props: { children?: unknown }) { - return { type: 'ContextMenu', props } - }, - ContextMenuContent: function ContextMenuContent(props: { children?: unknown }) { - return { type: 'ContextMenuContent', props } - }, - ContextMenuItem: function ContextMenuItem(props: { children?: unknown }) { - return { type: 'ContextMenuItem', props } - }, - ContextMenuSeparator: function ContextMenuSeparator() { - return { type: 'ContextMenuSeparator', props: {} } - }, - ContextMenuShortcut: function ContextMenuShortcut(props: { children?: unknown }) { - return { type: 'ContextMenuShortcut', props } - }, - ContextMenuLabel: function ContextMenuLabel(props: { children?: unknown }) { - return { type: 'ContextMenuLabel', props } - }, - ContextMenuSub: function ContextMenuSub(props: { children?: unknown }) { - return { type: 'ContextMenuSub', props } - }, - ContextMenuSubContent: function ContextMenuSubContent(props: { children?: unknown }) { - return { type: 'ContextMenuSubContent', props } - }, - ContextMenuSubTrigger: function ContextMenuSubTrigger(props: { children?: unknown }) { - return { type: 'ContextMenuSubTrigger', props } - }, - ContextMenuTrigger: function ContextMenuTrigger(props: { children?: unknown }) { - return { type: 'ContextMenuTrigger', props } - } -})) - -vi.mock('@/components/ui/input', () => ({ - Input: function Input(props: Record) { - return { type: 'input', props } - } -})) - -vi.mock('@/components/ui/tooltip', () => ({ - TooltipProvider: function TooltipProvider(props: { children?: unknown }) { - return props.children - }, - Tooltip: function Tooltip(props: { children?: unknown }) { - return { type: 'Tooltip', props } - }, - TooltipContent: function TooltipContent(props: { children?: unknown }) { - return { type: 'TooltipContent', props } - }, - TooltipTrigger: function TooltipTrigger(props: { children?: unknown }) { - return props.children - } -})) - -vi.mock('./TabHoverCard', () => ({ - TabHoverCard: function TabHoverCard(props: { children?: unknown }) { - return props.children - } -})) - -vi.mock('@/components/editor/editor-labels', () => ({ - getEditorDisplayLabel: (file: OpenFile) => file.relativePath -})) - -vi.mock('@/lib/rename-file', () => ({ - renameFileOnDisk: renameFileOnDiskMock -})) - -const activeIconTheme = vi.hoisted((): { theme: ActivePluginIconTheme | null } => ({ theme: null })) - -vi.mock('@/lib/file-type-icons', async (importOriginal) => ({ - getFileTypeIcon: () => - function FileIcon(props: Record) { - return { type: 'FileIcon', props } - }, - getPluginFileTypeIconIndex: (await importOriginal()) - .getPluginFileTypeIconIndex -})) - -vi.mock('@/store/plugin-icon-themes', () => ({ - PLUGIN_ICON_ATTRIBUTE: 'data-plugin-icon', - useActivePluginIconTheme: ( - selector: (state: { theme: ActivePluginIconTheme | null }) => unknown - ) => selector({ theme: activeIconTheme.theme }) -})) - -vi.mock('@/store/selectors', () => ({ - useRepoById: () => ({ connectionId: null }), - useWorktreeById: () => ({ path: '/repo', repoId: 'repo-1' }) -})) - -vi.mock('@/store', () => { - const useAppStore = (selector: (state: { openMarkdownPreview: typeof vi.fn }) => unknown) => - selector({ openMarkdownPreview: appStoreMocks.openMarkdownPreview }) - useAppStore.getState = appStoreMocks.getState - return { useAppStore } -}) - -vi.mock('../right-sidebar/status-display', () => ({ - STATUS_COLORS: {}, - STATUS_LABELS: {} -})) - -vi.mock('./drop-indicator', () => ({ - ACTIVE_TAB_INDICATOR_CLASSES: 'active-tab-indicator', - getDropIndicatorClasses: () => '', - getTabStripBorderClasses: () => '', - getTabRootStateClasses: () => '' -})) - -vi.mock('@/components/editor/markdown-preview-controls', () => ({ - canOpenMarkdownPreview: () => false -})) - -type ReactElementLike = { - type: unknown - props: Record -} - -function baseFile(overrides: Partial = {}): OpenFile { - return { - id: '/repo/untitled-5.md', - filePath: '/repo/untitled-5.md', - relativePath: 'untitled-5.md', - worktreeId: 'wt-1', - language: 'markdown', - isDirty: false, - mode: 'edit', - ...overrides - } -} - -async function renderEditorFileTab( - file: OpenFile, - onActivate = vi.fn(), - onMakePermanent = vi.fn() -): Promise<{ - element: unknown - onActivate: ReturnType - onMakePermanent: ReturnType -}> { - reactHookRuntime.index = 0 - const module = await import('./EditorFileTab') - const element = module.default({ - file, - isActive: true, - isPinned: false, - hasTabsToRight: false, - hasTabsToLeft: false, - tabCount: 1, - gitStatus: null, - onActivate, - onClose: () => {}, - onCloseOthers: () => {}, - onCloseToRight: () => {}, - onCloseToLeft: () => {}, - onCloseAll: () => {}, - onMakePermanent, - onTogglePin: () => {}, - dragData: { - kind: 'tab', - worktreeId: file.worktreeId, - groupId: 'group-1', - unifiedTabId: file.id, - visibleTabId: file.id, - tabType: 'editor', - label: file.relativePath, - iconPath: file.filePath - } - }) - return { element, onActivate, onMakePermanent } -} - -function expandNode(node: unknown): unknown { - if (node == null || typeof node === 'string' || typeof node === 'number') { - return node - } - if (Array.isArray(node)) { - return node.map(expandNode) - } - const el = node as ReactElementLike - if (typeof el.type === 'function') { - return expandNode(el.type(el.props)) - } - return { - ...el, - props: { - ...el.props, - children: expandNode(el.props?.children) - } - } -} - -function getText(node: unknown): string { - if (node == null) { - return '' - } - if (typeof node === 'string' || typeof node === 'number') { - return String(node) - } - if (Array.isArray(node)) { - return node.map(getText).join('') - } - const el = node as ReactElementLike - return getText(el.props?.children) -} - -function findElementsByType(node: unknown, typeName: string): ReactElementLike[] { - const results: ReactElementLike[] = [] - const visit = (current: unknown): void => { - if (current == null || typeof current === 'string' || typeof current === 'number') { - return - } - if (Array.isArray(current)) { - for (const child of current) { - visit(child) - } - return - } - const el = current as ReactElementLike - if (el.type === typeName) { - results.push(el) - } - visit(el.props?.children) - } - visit(node) - return results -} - -function findMenuItemByText(node: unknown, label: string): ReactElementLike { - const item = findElementsByType(node, 'ContextMenuItem').find((candidate) => - getText(candidate).includes(label) - ) - if (!item) { - throw new Error(`Missing menu item: ${label}`) - } - return item -} - -/** Picks Rename, then fires the close-autofocus that actually opens the input. */ -function selectRenameFromMenu(node: unknown): void { - ;(findMenuItemByText(node, 'Rename').props.onSelect as () => void)() - const content = findElementsByType(node, 'ContextMenuContent')[0]! - ;(content.props.onCloseAutoFocus as (event: { preventDefault: () => void }) => void)({ - preventDefault: vi.fn() - }) -} - -function findSpanByText(node: unknown, label: string): ReactElementLike { - const span = findElementsByType(node, 'span').find( - (candidate) => - getText(candidate) === label && typeof candidate.props.onDoubleClick === 'function' - ) - if (!span) { - throw new Error(`Missing span: ${label}`) - } - return span -} - -function pressInputKey( - input: ReactElementLike, - key: string, - options?: { isComposing?: boolean; keyCode?: number } -): { - preventDefault: ReturnType - stopPropagation: ReturnType -} { - const event = { - key, - nativeEvent: { - isComposing: options?.isComposing ?? false, - keyCode: options?.keyCode ?? 13 - }, - preventDefault: vi.fn(), - stopPropagation: vi.fn() - } - ;(input.props.onKeyDown as (nextEvent: typeof event) => void)(event) - return event -} - -describe('EditorFileTab rename menu', () => { - beforeEach(() => { - reactHookRuntime.states = [] - reactHookRuntime.index = 0 - vi.clearAllMocks() - vi.resetModules() - vi.stubGlobal('navigator', { userAgent: 'Mac' }) - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { - callback(0) - return 1 - }) - vi.stubGlobal('cancelAnimationFrame', vi.fn()) - }) - - it('turns the tab filename into an inline input from the Rename context-menu item', async () => { - const onActivate = vi.fn() - const file = baseFile({ isUntitled: true }) - const firstRender = expandNode((await renderEditorFileTab(file, onActivate)).element) - const renameItem = findMenuItemByText(firstRender, 'Rename') - - // Untitled Markdown tabs already exist on disk and must remain renameable. - expect(renameItem.props.disabled).toBe(false) - selectRenameFromMenu(firstRender) - - const secondRender = expandNode((await renderEditorFileTab(file, onActivate)).element) - const inputs = findElementsByType(secondRender, 'input') - expect(inputs).toHaveLength(1) - expect(inputs[0].props.defaultValue).toBe('untitled-5.md') - expect(inputs[0].props['data-tab-rename-input']).toBe('true') - expect(onActivate).toHaveBeenCalledTimes(1) - - const focus = vi.fn() - const select = vi.fn() - const setSelectionRange = vi.fn() - const setInputRef = inputs[0].props.ref as (input: HTMLInputElement | null) => void - - setInputRef({ focus, select, setSelectionRange } as unknown as HTMLInputElement) - - expect(focus).toHaveBeenCalledTimes(1) - expect(setSelectionRange).toHaveBeenCalledWith(0, 'untitled-5'.length) - expect(select).not.toHaveBeenCalled() - }) - - it('ignores IME composition Enter before renaming the editor file tab', async () => { - const file = baseFile() - const firstRender = expandNode((await renderEditorFileTab(file)).element) - - selectRenameFromMenu(firstRender) - - const secondRender = expandNode((await renderEditorFileTab(file)).element) - const input = findElementsByType(secondRender, 'input')[0] - const setInputRef = input.props.ref as (input: HTMLInputElement | null) => void - setInputRef({ - focus: vi.fn(), - select: vi.fn(), - setSelectionRange: vi.fn(), - value: '日本語.md' - } as unknown as HTMLInputElement) - - const composingEvent = pressInputKey(input, 'Enter', { isComposing: true }) - - expect(composingEvent.preventDefault).not.toHaveBeenCalled() - expect(renameFileOnDiskMock).not.toHaveBeenCalled() - - pressInputKey(input, 'Enter') - - expect(renameFileOnDiskMock).toHaveBeenCalledWith({ - oldPath: '/repo/untitled-5.md', - newName: '日本語.md', - worktreeId: 'wt-1', - worktreePath: '/repo', - documentScoped: false - }) - }) - - it('does not re-commit when unmounting the rename input emits multiple blur events', async () => { - const file = baseFile() - const firstRender = expandNode((await renderEditorFileTab(file)).element) - - selectRenameFromMenu(firstRender) - - const secondRender = expandNode((await renderEditorFileTab(file)).element) - const input = findElementsByType(secondRender, 'input')[0] - const setInputRef = input.props.ref as (input: HTMLInputElement | null) => void - setInputRef({ - focus: vi.fn(), - select: vi.fn(), - setSelectionRange: vi.fn(), - value: 'renamed.md' - } as unknown as HTMLInputElement) - - pressInputKey(input, 'Enter') - ;(input.props.onBlur as () => void)() - ;(input.props.onBlur as () => void)() - - expect(renameFileOnDiskMock).toHaveBeenCalledTimes(1) - }) - - it('disables Rename for diff tabs that do not map to one writable file', async () => { - const file = baseFile({ - mode: 'diff', - diffSource: 'unstaged' - }) - const element = expandNode((await renderEditorFileTab(file)).element) - const renameItem = findMenuItemByText(element, 'Rename') - - expect(renameItem.props.disabled).toBe(true) - }) - - it('makes a preview tab permanent when double-clicking the filename label', async () => { - const onActivate = vi.fn() - const onMakePermanent = vi.fn() - const file = baseFile({ isPreview: true }) - const element = expandNode( - (await renderEditorFileTab(file, onActivate, onMakePermanent)).element - ) - const label = findSpanByText(element, 'untitled-5.md') - const stopPropagation = vi.fn() - - ;(label.props.onDoubleClick as (event: { stopPropagation: () => void }) => void)({ - stopPropagation - }) - - expect(onMakePermanent).toHaveBeenCalledTimes(1) - expect(stopPropagation).toHaveBeenCalledTimes(1) - - const secondRender = expandNode( - (await renderEditorFileTab(file, onActivate, onMakePermanent)).element - ) - expect(findElementsByType(secondRender, 'input')).toHaveLength(0) - }) -}) - -describe('EditorFileTab icon', () => { - const theme: ActivePluginIconTheme = { - icons: { file: 3 }, - fileNames: {}, - fileExtensions: { md: 7 }, - folderNames: {}, - folderNamesExpanded: {} - } - - beforeEach(() => { - activeIconTheme.theme = theme - }) - - afterEach(() => { - activeIconTheme.theme = null - }) - - function themedIconIndexes(node: unknown): unknown[] { - return findElementsByType(node, 'span') - .map((span) => span.props['data-plugin-icon']) - .filter((index) => index !== undefined) - } - - it('uses the active plugin file-icon theme like the explorer (#36326)', async () => { - const expanded = expandNode((await renderEditorFileTab(baseFile())).element) - expect(themedIconIndexes(expanded)).toContain(7) - expect(findElementsByType(expanded, 'FileIcon')).toHaveLength(0) - }) - - it('uses built-in icons when no icon theme is active', async () => { - activeIconTheme.theme = null - const expanded = expandNode((await renderEditorFileTab(baseFile())).element) - expect(themedIconIndexes(expanded)).toHaveLength(0) - expect(findElementsByType(expanded, 'FileIcon').length).toBeGreaterThan(0) - }) - - it('keeps the phone icon on simulator tabs when an icon theme is active', async () => { - const simulator = baseFile({ filePath: 'Mobile Emulator', language: 'simulator' }) - const expanded = expandNode((await renderEditorFileTab(simulator)).element) - expect(themedIconIndexes(expanded)).toHaveLength(0) - expect(findElementsByType(expanded, 'Smartphone').length).toBeGreaterThan(0) - }) -}) diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx deleted file mode 100644 index 8979c49383c..00000000000 --- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx +++ /dev/null @@ -1,436 +0,0 @@ -import type * as ReactModule from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { requestTerminalTabRename } from './terminal-tab-rename-request' - -const windowListeners = new Map void>>() - -const reactHookRuntime = vi.hoisted(() => ({ - states: [] as unknown[], - index: 0 -})) - -const storeState = vi.hoisted( - (): { - agentStatusByPaneKey: Record - clearTabLaunchAgent: ReturnType - ptyIdsByTabId: Record - retainedAgentsByPaneKey: Record - keybindings: Record - repos: unknown[] - terminalLayoutsByTabId: Record - worktreesByRepo: Record - unreadTerminalTabs: Record - } => ({ - agentStatusByPaneKey: {}, - clearTabLaunchAgent: vi.fn(), - ptyIdsByTabId: {} as Record, - retainedAgentsByPaneKey: {}, - keybindings: {}, - repos: [], - terminalLayoutsByTabId: {}, - worktreesByRepo: {}, - unreadTerminalTabs: {} as Record - }) -) - -vi.mock('react', async () => { - const actual = await vi.importActual('react') - return { - ...actual, - useCallback(callback: T) { - return callback - }, - useEffect(effect: () => void | (() => void)) { - effect() - }, - useRef(initial: T) { - return { current: initial } - }, - useState(initial: T | (() => T)) { - const stateIndex = reactHookRuntime.index++ - if (!(stateIndex in reactHookRuntime.states)) { - reactHookRuntime.states[stateIndex] = - typeof initial === 'function' ? (initial as () => T)() : initial - } - const setState = (next: T | ((previous: T) => T)): void => { - reactHookRuntime.states[stateIndex] = - typeof next === 'function' - ? (next as (previous: T) => T)(reactHookRuntime.states[stateIndex] as T) - : next - } - return [reactHookRuntime.states[stateIndex] as T, setState] as const - } - } -}) - -// The menu's session lookup is covered by its own tests; this file only drives rename. - -vi.mock('./TabSessionSurfaceSwitchMenuItems', () => ({ - TabSessionSurfaceSwitchMenuItems: () => null -})) - -vi.mock('./use-tab-strip-slot-props', () => ({ - useTabStripSlotProps: () => ({ className: '', 'data-tab-strip-slot': '' }) -})) - -// Naming failure has its own tests; its hooks need a real React dispatcher. -vi.mock('./use-native-chat-naming-failure', () => ({ - useNativeChatNamingFailure: () => null -})) - -vi.mock('@dnd-kit/sortable', () => ({ - useSortable: () => ({ - attributes: {}, - listeners: { onPointerDown: vi.fn() }, - setNodeRef: vi.fn() - }) -})) - -vi.mock('lucide-react', () => ({ - ArrowDown: function ArrowDown(props: Record) { - return { type: 'ArrowDown', props } - }, - ArrowLeft: function ArrowLeft(props: Record) { - return { type: 'ArrowLeft', props } - }, - ArrowRight: function ArrowRight(props: Record) { - return { type: 'ArrowRight', props } - }, - ArrowUp: function ArrowUp(props: Record) { - return { type: 'ArrowUp', props } - }, - Copy: function Copy(props: Record) { - return { type: 'Copy', props } - }, - Columns2: function Columns2(props: Record) { - return { type: 'Columns2', props } - }, - Minimize2: function Minimize2(props: Record) { - return { type: 'Minimize2', props } - }, - PanelBottomClose: function PanelBottomClose(props: Record) { - return { type: 'PanelBottomClose', props } - }, - PanelLeftClose: function PanelLeftClose(props: Record) { - return { type: 'PanelLeftClose', props } - }, - PanelRightClose: function PanelRightClose(props: Record) { - return { type: 'PanelRightClose', props } - }, - ListX: function ListX(props: Record) { - return { type: 'ListX', props } - }, - MessageSquare: function MessageSquare(props: Record) { - return { type: 'MessageSquare', props } - }, - Pencil: function Pencil(props: Record) { - return { type: 'Pencil', props } - }, - Pin: function Pin(props: Record) { - return { type: 'Pin', props } - }, - PinOff: function PinOff(props: Record) { - return { type: 'PinOff', props } - }, - Rows2: function Rows2(props: Record) { - return { type: 'Rows2', props } - }, - X: function X(props: Record) { - return { type: 'X', props } - }, - SquareTerminal: function SquareTerminal(props: Record) { - return { type: 'SquareTerminal', props } - } -})) - -vi.mock('@/hooks/useShortcutLabel', () => ({ - formatShortcutLabel: () => '⌘⇧\\', - useOptionalShortcutLabel: () => '⌘W', - useShortcutKeyDetails: () => ({ keys: ['⌘', 'W'], doubleTap: false }) -})) - -vi.mock('@/components/ui/context-menu', () => ({ - ContextMenu: function ContextMenu(props: { children?: unknown }) { - return { type: 'ContextMenu', props } - }, - ContextMenuContent: function ContextMenuContent(props: { children?: unknown }) { - return { type: 'ContextMenuContent', props } - }, - ContextMenuItem: function ContextMenuItem(props: { children?: unknown }) { - return { type: 'ContextMenuItem', props } - }, - ContextMenuShortcut: function ContextMenuShortcut(props: { children?: unknown }) { - return { type: 'ContextMenuShortcut', props } - }, - ContextMenuSeparator: function ContextMenuSeparator() { - return { type: 'ContextMenuSeparator', props: {} } - }, - ContextMenuLabel: function ContextMenuLabel(props: { children?: unknown }) { - return { type: 'ContextMenuLabel', props } - }, - ContextMenuSub: function ContextMenuSub(props: { children?: unknown }) { - return { type: 'ContextMenuSub', props } - }, - ContextMenuSubContent: function ContextMenuSubContent(props: { children?: unknown }) { - return { type: 'ContextMenuSubContent', props } - }, - ContextMenuSubTrigger: function ContextMenuSubTrigger(props: { children?: unknown }) { - return { type: 'ContextMenuSubTrigger', props } - }, - ContextMenuTrigger: function ContextMenuTrigger(props: { children?: unknown }) { - return props.children - } -})) - -vi.mock('@/components/ui/input', () => ({ - Input: function Input(props: Record) { - return { type: 'input', props } - } -})) - -vi.mock('./TabHoverCard', () => ({ - TabHoverCard: function TabHoverCard(props: { children?: unknown }) { - return props.children - } -})) - -vi.mock('@/components/ui/tooltip', () => ({ - TooltipProvider: function TooltipProvider(props: { children?: unknown }) { - return props.children - }, - Tooltip: function Tooltip(props: { children?: unknown }) { - return { type: 'Tooltip', props } - }, - TooltipContent: function TooltipContent(props: { children?: unknown }) { - return { type: 'TooltipContent', props } - }, - TooltipTrigger: function TooltipTrigger(props: { children?: unknown }) { - return props.children - } -})) - -vi.mock('./shell-icons', () => ({ - ShellIcon: function ShellIcon(props: Record) { - return { type: 'ShellIcon', props } - } -})) - -vi.mock('../sidebar/WorktreeCardHelpers', () => ({ - FilledBellIcon: function FilledBellIcon(props: Record) { - return { type: 'FilledBellIcon', props } - } -})) - -vi.mock('./drop-indicator', () => ({ - ACTIVE_TAB_INDICATOR_CLASSES: 'active-tab-indicator', - getDropIndicatorClasses: () => '', - getTabStripBorderClasses: () => '', - getTabRootStateClasses: () => '' -})) - -vi.mock('./middle-button-default-guard', () => ({ - preventMiddleButtonDefault: vi.fn() -})) - -const useAppStoreExport = (selector: (state: typeof storeState) => unknown) => selector(storeState) -useAppStoreExport.getState = () => ({ - unifiedTabsByWorktree: { - 'wt-1': [{ id: 'terminal-tab-1', groupId: 'group-1' }] - }, - groupsByWorktree: { - 'wt-1': [{ id: 'group-1', tabOrder: ['terminal-tab-1', 'tab-2'] }] - } -}) - -vi.mock('@/store', () => ({ - useAppStore: useAppStoreExport -})) - -type ReactElementLike = { - type: unknown - props: Record -} - -function makeTerminalTab() { - return { - id: 'terminal-tab-1', - title: 'Runtime terminal title', - customTitle: null, - type: 'terminal', - worktreeId: 'wt-1', - createdAt: 0 - } -} - -async function renderSortableTab({ - onSetCustomTitle = vi.fn() -}: { - onSetCustomTitle?: (tabId: string, title: string | null) => void -} = {}): Promise { - reactHookRuntime.index = 0 - const module = await import('./SortableTab') - return module.default({ - tab: makeTerminalTab() as never, - agent: null, - unifiedTabId: 'terminal-tab-1', - groupId: 'group-1', - tabCount: 1, - hasTabsToRight: false, - hasTabsToLeft: false, - isActive: true, - isPinned: false, - isExpanded: false, - onActivate: vi.fn(), - onClose: vi.fn(), - onCloseOthers: vi.fn(), - onCloseToRight: vi.fn(), - onCloseToLeft: vi.fn(), - onSetCustomTitle, - onSetTabColor: vi.fn(), - onTogglePin: vi.fn(), - onToggleExpand: vi.fn(), - dragData: { - kind: 'tab', - worktreeId: 'wt-1', - groupId: 'group-1', - unifiedTabId: 'terminal-tab-1', - visibleTabId: 'terminal-tab-1', - tabType: 'terminal', - label: 'Runtime terminal title' - } - }) -} - -function expandNode(node: unknown): unknown { - if (node == null || typeof node === 'string' || typeof node === 'number') { - return node - } - if (Array.isArray(node)) { - return node.map(expandNode) - } - const el = node as ReactElementLike - if (typeof el.type === 'function') { - return expandNode(el.type(el.props)) - } - return { - ...el, - props: { - ...el.props, - children: expandNode(el.props?.children) - } - } -} - -function findElementsByType(node: unknown, typeName: string): ReactElementLike[] { - const results: ReactElementLike[] = [] - const visit = (current: unknown): void => { - if (current == null || typeof current === 'string' || typeof current === 'number') { - return - } - if (Array.isArray(current)) { - for (const child of current) { - visit(child) - } - return - } - const el = current as ReactElementLike - if (el.type === typeName) { - results.push(el) - } - visit(el.props?.children) - } - visit(node) - return results -} - -function pressInputKey( - input: ReactElementLike, - key: string, - options?: { isComposing?: boolean; keyCode?: number } -): { - preventDefault: ReturnType -} { - const event = { - key, - nativeEvent: { - isComposing: options?.isComposing ?? false, - keyCode: options?.keyCode ?? 13 - }, - preventDefault: vi.fn() - } - ;(input.props.onKeyDown as (nextEvent: typeof event) => void)(event) - return event -} - -describe('SortableTab rename shortcut signal', () => { - beforeEach(() => { - reactHookRuntime.states = [] - reactHookRuntime.index = 0 - storeState.unreadTerminalTabs = {} - storeState.clearTabLaunchAgent.mockClear() - windowListeners.clear() - vi.stubGlobal('window', { - addEventListener: vi.fn((type: string, listener: (event: Event) => void) => { - const listeners = windowListeners.get(type) ?? new Set<(event: Event) => void>() - listeners.add(listener) - windowListeners.set(type, listeners) - }), - removeEventListener: vi.fn((type: string, listener: (event: Event) => void) => { - windowListeners.get(type)?.delete(listener) - }), - dispatchEvent: vi.fn((event: Event) => { - for (const listener of windowListeners.get(event.type) ?? []) { - listener(event) - } - return true - }) - }) - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { - callback(0) - return 1 - }) - vi.stubGlobal('cancelAnimationFrame', vi.fn()) - }) - - it('opens the inline rename input for a request that targets this tab', async () => { - await renderSortableTab() - requestTerminalTabRename('terminal-tab-1') - const rerender = expandNode(await renderSortableTab()) - const inputs = findElementsByType(rerender, 'input') - - expect(inputs).toHaveLength(1) - expect(inputs[0].props.value).toBe('Runtime terminal title') - expect(inputs[0].props['data-tab-rename-input']).toBe('true') - }) - - it('ignores a rename request aimed at a different tab', async () => { - await renderSortableTab() - requestTerminalTabRename('terminal-tab-2') - const rerender = expandNode(await renderSortableTab()) - - expect(findElementsByType(rerender, 'input')).toHaveLength(0) - }) - - it('ignores IME composition Enter before committing the custom tab title', async () => { - const onSetCustomTitle = vi.fn() - - await renderSortableTab({ onSetCustomTitle }) - requestTerminalTabRename('terminal-tab-1') - let rerender = expandNode(await renderSortableTab({ onSetCustomTitle })) - let input = findElementsByType(rerender, 'input')[0] - ;(input.props.onChange as (event: { target: { value: string } }) => void)({ - target: { value: '日本語 terminal' } - }) - rerender = expandNode(await renderSortableTab({ onSetCustomTitle })) - input = findElementsByType(rerender, 'input')[0] - - const composingEvent = pressInputKey(input, 'Enter', { isComposing: true }) - - expect(composingEvent.preventDefault).not.toHaveBeenCalled() - expect(onSetCustomTitle).not.toHaveBeenCalled() - - pressInputKey(input, 'Enter') - - expect(onSetCustomTitle).toHaveBeenCalledWith('terminal-tab-1', '日本語 terminal') - }) -}) diff --git a/src/renderer/src/components/tab-bar/TabBar.client-hosted-row-active-state.test.ts b/src/renderer/src/components/tab-bar/TabBar.client-hosted-row-active-state.test.ts deleted file mode 100644 index a820c825e5b..00000000000 --- a/src/renderer/src/components/tab-bar/TabBar.client-hosted-row-active-state.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - stubBrowserTab, - stubDropdownMenu, - stubEditorFileTab, - stubEditorLabels, - stubFocusTerminalTabSurface, - stubHeadlessReact, - stubQuickLaunchButton, - stubShallowSelector, - stubShellIcons, - stubSortableContext, - stubSortableTab, - stubStatusDisplay, - stubTabInsertion, - stubTabStripDragScroll -} from './tab-bar-windows-shell-launch-render-stubs' -import { - resetAppStoreSnapshot, - useAppStoreExport -} from './tab-bar-windows-shell-launch-test-harness' -import type { ClientHostedBrowserRowSelection } from '@/lib/pane-manager/client-hosted-browser-row-state' - -vi.mock('react', async () => await stubHeadlessReact()) -vi.mock('@/store/plugin-new-files', () => ({ usePluginNewFiles: () => [] })) -vi.mock('zustand/react/shallow', () => stubShallowSelector()) -vi.mock('lucide-react', async () => (await import('./lucide-icon-stub-fixture')).stubEveryIcon()) -vi.mock('@dnd-kit/sortable', () => stubSortableContext()) -vi.mock('./tab-strip-drag-scroll', () => stubTabStripDragScroll()) -vi.mock('../../store', () => ({ useAppStore: useAppStoreExport })) -vi.mock('../right-sidebar/status-display', () => stubStatusDisplay()) -vi.mock('../tab-group/tab-insertion', () => stubTabInsertion()) -vi.mock('@/components/editor/editor-labels', () => stubEditorLabels()) -vi.mock('./SortableTab', () => stubSortableTab()) -vi.mock('./EditorFileTab', () => stubEditorFileTab()) -vi.mock('./BrowserTab', () => stubBrowserTab()) -vi.mock('./QuickLaunchButton', () => stubQuickLaunchButton()) -vi.mock('./shell-icons', () => stubShellIcons()) -vi.mock('@/lib/focus-terminal-tab-surface', () => stubFocusTerminalTabSurface()) -vi.mock('@/components/ui/dropdown-menu', () => stubDropdownMenu()) - -const TERMINAL_TAB = { - id: 'term-1', - unifiedTabId: 'unified-term-1', - ptyId: null, - worktreeId: 'wt-1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 0 -} - -function findByComponentName(node: unknown, name: string): Record | null { - if (node == null || typeof node === 'string' || typeof node === 'number') { - return null - } - if (Array.isArray(node)) { - for (const child of node) { - const found = findByComponentName(child, name) - if (found) { - return found - } - } - return null - } - const el = node as { type?: unknown; props?: Record } - const type = el.type as { name?: string } | string | undefined - if ((typeof type === 'string' ? type : type?.name) === name) { - return el.props ?? {} - } - return findByComponentName(el.props?.children, name) -} - -// Import after resetModules so selection writes reach the registry instance read by TabBar. -async function selectRow(selection: ClientHostedBrowserRowSelection): Promise { - const state = await import('@/lib/pane-manager/client-hosted-browser-row-state') - state.selectClientHostedBrowserRow(selection) -} - -async function renderTerminalStrip(): Promise | null> { - const tabBarModule = await import('./TabBar') - const candidate = tabBarModule.default as unknown as - | ((props: Record) => unknown) - | { type: (props: Record) => unknown } - const TabBar = typeof candidate === 'function' ? candidate : candidate.type - return findByComponentName( - TabBar({ - tabs: [TERMINAL_TAB], - editorFiles: [], - browserTabs: [], - tabBarOrder: ['term-1'], - worktreeId: 'wt-1', - groupId: 'group-1', - activeTabId: 'term-1', - activeTabType: 'terminal', - groupActiveTabId: 'unified-term-1', - expandedPaneByTabId: {}, - onActivate: () => {}, - onClose: () => {}, - onCloseOthers: () => {}, - onCloseToRight: () => {}, - onCloseToLeft: () => {}, - onNewTerminalTab: () => {}, - onNewBrowserTab: () => {}, - onSetCustomTitle: () => {}, - onSetTabColor: () => {}, - onTogglePaneExpand: () => {} - }), - 'TabBarItemRow' - ) -} - -/** Render the shipping TabBar so derived-row wiring is covered beyond row/helper tests. */ -describe('TabBar client-hosted row active state', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.resetModules() - resetAppStoreSnapshot() - vi.stubGlobal('navigator', { userAgent: 'Mac' }) - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { - callback(0) - return 1 - }) - vi.stubGlobal('cancelAnimationFrame', vi.fn()) - vi.stubGlobal('window', { - setTimeout, - clearTimeout, - addEventListener: vi.fn(), - removeEventListener: vi.fn() - }) - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('underlines the terminal tab the group is showing when no row is selected', async () => { - expect((await renderTerminalStrip())?.isActive).toBe(true) - }) - - it('drops the terminal tab underline while a client-hosted row owns the strip', async () => { - await selectRow({ - worktreeId: 'wt-1', - browserPageId: 'page-1', - groupId: 'group-1', - groupActiveTabIdAtSelection: 'unified-term-1' - }) - - expect((await renderTerminalStrip())?.isActive).toBe(false) - }) - - it('leaves a strip in another group alone', async () => { - await selectRow({ - worktreeId: 'wt-1', - browserPageId: 'page-1', - groupId: 'group-2', - groupActiveTabIdAtSelection: 'unified-term-1' - }) - - expect((await renderTerminalStrip())?.isActive).toBe(true) - }) -}) diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index 03715948aa2..86442645cfb 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -5,8 +5,6 @@ import { stubShallowSelector, stubTooltip } from './tab-bar-windows-shell-launch-render-stubs' -import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' -import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' // Main's answer to the window's layout commands, as a switch-on build sends them. vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => @@ -299,14 +297,6 @@ const TERMINAL_TAB = { createdAt: 0 } -const EDITOR_FILE = { - id: 'file-1', - tabId: 'unified-editor-1', - worktreeId: 'wt-1', - relativePath: 'foo.ts', - isDirty: false -} - describe('TabBar context menu wiring', () => { beforeEach(() => { vi.clearAllMocks() @@ -335,131 +325,6 @@ describe('TabBar context menu wiring', () => { vi.unstubAllGlobals() }) - it('counts every tab kind for SortableTab.tabCount', async () => { - // Count all tab kinds so Close Others stays enabled with non-terminal siblings. - const element = await renderTabBar({ - tabs: [TERMINAL_TAB], - editorFiles: [EDITOR_FILE], - browserTabs: [], - tabBarOrder: ['term-1', 'unified-editor-1'] - }) - const sortable = findChildrenByType(element, 'SortableTab') - expect(sortable).toHaveLength(1) - expect(sortable[0].props.tabCount).toBe(2) - }) - - it('sizes the tab strip from its tabs while allowing horizontal scrolling', async () => { - const element = await renderTabBar({ - tabs: [TERMINAL_TAB], - editorFiles: [EDITOR_FILE], - browserTabs: [], - tabBarOrder: ['term-1', 'unified-editor-1'] - }) - const divs = findChildrenByType(element, 'div') - const stripWrapper = divs.find((candidate) => - String(candidate.props.className ?? '').includes('group/tab-strip') - ) - const strip = divs.find((candidate) => - String(candidate.props.className ?? '').includes('terminal-tab-strip') - ) - - expect(stripWrapper).toBeTruthy() - expect(stripWrapper?.props.className).toContain('min-w-0') - expect(stripWrapper?.props.className).toContain('max-w-full') - expect(stripWrapper?.props.className).toContain('w-max') - expect(stripWrapper?.props.className).toContain('shrink') - expect(strip).toBeTruthy() - expect(strip?.props.className).toContain('min-w-0') - expect(strip?.props.className).toContain('flex-1') - expect(strip?.props.className).toContain('overflow-x-auto') - expect(strip?.props.className).not.toContain('scrollbar-sleek') - }) - - it('passes the editor unifiedTabId when EditorFileTab triggers onCloseToRight', async () => { - // Editor close-to-right emits its unified tab ID; downstream routing must accept that ID instead of only the file entity. - const onCloseToRight = vi.fn() - const element = await renderTabBar({ - tabs: [TERMINAL_TAB], - editorFiles: [EDITOR_FILE], - browserTabs: [], - tabBarOrder: ['term-1', 'unified-editor-1'], - onCloseToRight - }) - const editorTabs = findChildrenByType(element, 'EditorFileTab') - expect(editorTabs).toHaveLength(1) - const onClose = editorTabs[0].props.onCloseToRight as () => void - onClose() - expect(onCloseToRight).toHaveBeenCalledWith('unified-editor-1') - }) - - it('wires onCloseToLeft/onCloseOthers and hasTabsToLeft by strip position', async () => { - const onCloseToLeft = vi.fn() - const onCloseOthers = vi.fn() - const element = await renderTabBar({ - tabs: [TERMINAL_TAB], - editorFiles: [EDITOR_FILE], - browserTabs: [], - tabBarOrder: ['term-1', 'unified-editor-1'], - onCloseToLeft, - onCloseOthers - }) - - const sortable = findChildrenByType(element, 'SortableTab') - expect(sortable).toHaveLength(1) - expect(sortable[0].props.hasTabsToLeft).toBe(false) - - const editorTabs = findChildrenByType(element, 'EditorFileTab') - expect(editorTabs).toHaveLength(1) - expect(editorTabs[0].props.hasTabsToLeft).toBe(true) - expect(editorTabs[0].props.tabCount).toBe(2) - ;(editorTabs[0].props.onCloseToLeft as () => void)() - expect(onCloseToLeft).toHaveBeenCalledWith('unified-editor-1') - ;(editorTabs[0].props.onCloseOthers as () => void)() - expect(onCloseOthers).toHaveBeenCalledWith('unified-editor-1') - }) - - it('passes pinned state and toggles unpin through the unified tab id', async () => { - appStoreSnapshot.unifiedTabsByWorktree = { - 'wt-1': [ - { - id: 'unified-term-1', - entityId: 'term-1', - groupId: 'wt-1', - worktreeId: 'wt-1', - contentType: 'terminal', - label: 'Terminal', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 0, - isPinned: true - } - ] - } - - const element = await renderTabBar({ - tabs: [TERMINAL_TAB], - editorFiles: [], - browserTabs: [], - tabBarOrder: ['term-1'] - }) - - const sortable = findChildrenByType(element, 'SortableTab') - expect(sortable).toHaveLength(1) - expect(sortable[0].props.isPinned).toBe(true) - - ;(sortable[0].props.onTogglePin as () => void)() - await layoutApplyTestTransport.settle() - - // Main-owned layouts draw the host’s unpin response; the window must not duplicate the mutation. - if (ORCA_LAYOUT_OWNER_IS_MAIN) { - expect(unpinTabMock).not.toHaveBeenCalled() - } else { - expect(unpinTabMock).toHaveBeenCalledWith('unified-term-1') - } - expect(pinTabMock).not.toHaveBeenCalled() - }) - it('waits for async menu-created terminals before focusing xterm', async () => { vi.useFakeTimers() Object.assign(window, { setTimeout, clearTimeout }) @@ -495,24 +360,6 @@ describe('TabBar context menu wiring', () => { expect(focusTerminalTabSurface).not.toHaveBeenCalledWith('old-terminal') }) - it('can put markdown actions before terminal actions in the new-tab menu', async () => { - const element = await renderTabBar({ - tabs: [TERMINAL_TAB], - onNewFileTab: () => {}, - onOpenFileTab: () => {}, - newTabMenuOrder: 'markdown-first' - }) - - const menuLabels = findChildrenByType(element, 'DropdownMenuItem').map((item) => - extractText(item.props.children) - ) - - expect(menuLabels[0]).toContain('New Markdown') - expect(menuLabels[1]).toContain('Open Markdown...') - expect(menuLabels[2]).toContain('New Terminal') - expect(menuLabels[3]).toContain('New Browser Tab') - }) - it('omits impossible paired-web actions while keeping terminal and markdown', async () => { vi.stubGlobal('__ORCA_WEB_CLIENT__', true) const element = await renderTabBar({ @@ -533,49 +380,6 @@ describe('TabBar context menu wiring', () => { expect(menuLabels.some((label) => label.includes('Mobile Emulator'))).toBe(false) }) - it('turns New Mobile Emulator into a go-to action when the workspace already has one', async () => { - const onNewSimulatorTab = vi.fn() - appStoreSnapshot.unifiedTabsByWorktree = { - 'wt-1': [ - { - id: 'sim-1', - entityId: 'sim-1', - groupId: 'group-2', - worktreeId: 'wt-1', - contentType: 'simulator', - label: 'Mobile Emulator', - customLabel: null, - color: null, - sortOrder: 1, - createdAt: 0 - } - ] - } - - const element = await renderTabBar({ - tabs: [TERMINAL_TAB], - groupId: 'group-1', - onNewSimulatorTab - }) - - const emulatorItem = findChildrenByType(element, 'DropdownMenuItem').find((item) => - extractText(item.props.children).includes('Go to Mobile Emulator') - ) - expect(emulatorItem).toBeTruthy() - if (!emulatorItem) { - throw new Error('Go to Mobile Emulator menu item not rendered') - } - expect(emulatorItem.props.disabled).toBeUndefined() - expect(emulatorItem.props.onSelect).toBeTypeOf('function') - ;(emulatorItem.props.onSelect as () => void)() - expect(onNewSimulatorTab).toHaveBeenCalledTimes(1) - - const tooltip = findChildrenByType(element, 'TooltipContent').find((item) => - extractText(item.props.children).includes('Open the existing emulator tab.') - ) - expect(tooltip).toBeTruthy() - }) - it('cancels delayed menu focus when the tab bar root unmounts', async () => { vi.useFakeTimers() Object.assign(window, { setTimeout, clearTimeout }) diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.history.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.history.test.tsx index 52d57ec57d7..d649c5f017c 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.history.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.history.test.tsx @@ -230,26 +230,6 @@ describe('TabBarCreateEntry browser history rows', () => { ) }) - it('keeps arrow-key selection across a background history write', () => { - historyStoreMock.entries = [linear, historyEntry({ url: 'https://linear.app/acme/inbox' })] - mount() - setQuery('linear') - pressKey('ArrowDown') - pressKey('ArrowDown') - const selectedBefore = container.querySelector('[aria-selected="true"]')?.textContent - - // A committed navigation elsewhere replaces the store array mid-session. - act(() => { - historyStoreMock.entries = [ - historyEntry({ url: 'https://unrelated.example/page' }), - ...historyStoreMock.entries - ] - }) - setQuery('linear ') - - expect(container.querySelector('[aria-selected="true"]')?.textContent).toBe(selectedBefore) - }) - it('refuses to submit a row the visible query no longer matches', () => { mount() setQuery('linear') diff --git a/src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.keyboard.test.ts b/src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.keyboard.test.ts deleted file mode 100644 index 40d5dfcd985..00000000000 --- a/src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.keyboard.test.ts +++ /dev/null @@ -1,426 +0,0 @@ -import type * as ReactModule from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const windowListeners = vi.hoisted(() => new Map void>()) - -const keybindingsMock = vi.hoisted(() => ({ - matchAction: vi.fn().mockReturnValue(false) -})) - -const appStoreMock = vi.hoisted(() => ({ - state: { - activeView: 'terminal' as 'terminal' | 'settings', - keybindings: {} as Record, - settings: { - terminalShortcutPolicy: 'orca-first' as 'orca-first' | 'terminal-first' - } - } -})) - -const reactRuntime = vi.hoisted(() => ({ - states: [] as unknown[], - index: 0, - effects: [] as (() => void | (() => void))[] -})) - -vi.mock('react', async () => { - const actual = await vi.importActual('react') - return { - ...actual, - useState(initial: T | (() => T)) { - const i = reactRuntime.index++ - if (!(i in reactRuntime.states)) { - reactRuntime.states[i] = typeof initial === 'function' ? (initial as () => T)() : initial - } - const setState = (next: T | ((prev: T) => T)): void => { - reactRuntime.states[i] = - typeof next === 'function' ? (next as (p: T) => T)(reactRuntime.states[i] as T) : next - } - return [reactRuntime.states[i] as T, setState] as const - }, - useEffect(effect: () => void | (() => void)) { - reactRuntime.effects.push(effect) - }, - useCallback(fn: T): T { - return fn - }, - useMemo(fn: () => T): T { - return fn() - }, - useRef(init: T) { - return { current: init } - } - } -}) - -vi.mock('../../../../shared/keybindings', () => ({ - keybindingMatchesAction: keybindingsMock.matchAction -})) - -// Why: the stubbed window stands in for the app keyboard router's capture stage. -vi.mock('@/lib/app-keyboard-router', () => ({ - addAppKeyListener: (type: 'keydown' | 'keyup', handler: (e: KeyboardEvent) => void) => - window.addEventListener(type, handler, { capture: true }), - removeAppKeyListener: (type: 'keydown' | 'keyup', handler: (e: KeyboardEvent) => void) => - window.removeEventListener(type, handler, { capture: true }) -})) - -vi.mock('@/lib/shortcut-platform', () => ({ - getShortcutPlatform: () => 'darwin' as const -})) - -vi.mock('@/store', () => ({ - useAppStore: (selector: (s: typeof appStoreMock.state) => unknown) => selector(appStoreMock.state) -})) - -vi.mock('@/hooks/useShortcutLabel', () => ({ - useShortcutKeyComboDetails: () => [] -})) - -vi.mock('@/lib/terminal-quick-command-search', () => ({ - searchTerminalQuickCommands: (_cmds: unknown[], _q: string) => [], - getTerminalQuickCommandPickerValue: () => null -})) - -vi.mock('../../../../shared/terminal-quick-commands', () => ({ - isTerminalAgentQuickCommand: () => false, - getTerminalQuickCommandBody: () => '' -})) - -vi.mock('@/lib/agent-catalog', () => ({ - getAgentLabel: () => '', - AgentIcon: () => null -})) - -vi.mock('./TabBarQuickCommandItem', () => ({ - TabBarQuickCommandItem: () => null -})) - -vi.mock('@/lib/utils', () => ({ - cn: (...args: string[]) => args.filter(Boolean).join(' ') -})) - -vi.mock('@/i18n/i18n', () => ({ - translate: (_key: string, fallback: string) => fallback -})) - -vi.mock('lucide-react', () => ({ - ChevronDown: () => null, - Play: () => null, - Plus: () => null -})) - -vi.mock('@/components/ui/command', () => ({ - Command: () => null, - CommandEmpty: () => null, - CommandInput: () => null, - CommandList: () => null, - CommandSeparator: () => null -})) - -vi.mock('@/components/ui/dropdown-menu', () => ({ - DropdownMenu: () => null, - DropdownMenuContent: () => null, - DropdownMenuTrigger: () => null -})) - -vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: () => null, - TooltipContent: () => null, - TooltipTrigger: () => null -})) - -function makeProps() { - return { - isFocused: true, - repoCommands: [] as never[], - globalCommands: [] as never[], - mostRecent: null, - addHosts: [], - hostLoadFailed: false, - hostOwnershipPending: false, - onAddCommand: vi.fn(), - onDeleteCommand: vi.fn(), - onEditCommand: vi.fn(), - onMenuOpen: vi.fn(), - onRunCommand: vi.fn() - } -} - -function makeKeyEvent(overrides: Partial = {}): KeyboardEvent { - return { - preventDefault: vi.fn(), - stopImmediatePropagation: vi.fn(), - key: 'q', - code: 'KeyQ', - metaKey: true, - ctrlKey: false, - altKey: false, - shiftKey: true, - ...overrides - } as unknown as KeyboardEvent -} - -beforeEach(() => { - reactRuntime.states = [] - reactRuntime.index = 0 - reactRuntime.effects = [] - windowListeners.clear() - keybindingsMock.matchAction.mockClear() - keybindingsMock.matchAction.mockReturnValue(false) - appStoreMock.state.activeView = 'terminal' - appStoreMock.state.keybindings = {} - appStoreMock.state.settings.terminalShortcutPolicy = 'orca-first' - vi.stubGlobal('window', { - addEventListener: vi.fn((type: 'keydown' | 'keyup', handler: (e: KeyboardEvent) => void) => { - windowListeners.set(type, handler) - }), - removeEventListener: vi.fn() - }) - vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { - cb(0) - return 1 - }) - vi.stubGlobal('cancelAnimationFrame', vi.fn()) -}) - -describe('TabBarQuickCommandsMenu keyboard shortcut', () => { - it('does not register either shortcut path for an unfocused group', async () => { - const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') - TabBarQuickCommandsMenu({ ...makeProps(), isFocused: false }) - - reactRuntime.effects[0]() - reactRuntime.effects[1]() - - expect(window.addEventListener).not.toHaveBeenCalled() - }) - - it('closes an open menu when its group loses focus', async () => { - const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') - reactRuntime.states[0] = true - TabBarQuickCommandsMenu({ ...makeProps(), isFocused: false }) - - // Effect index 2 closes the menu for an inactive group. - reactRuntime.effects[2]() - - expect(reactRuntime.states[0]).toBe(false) - }) - - it('keeps an open menu for the focused group', async () => { - const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') - reactRuntime.states[0] = true - TabBarQuickCommandsMenu(makeProps()) - - reactRuntime.effects[2]() - - expect(reactRuntime.states[0]).toBe(true) - }) - - it('registers a capturing keydown listener on mount', async () => { - reactRuntime.index = 0 - const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') - TabBarQuickCommandsMenu(makeProps()) - - // Effect index 0 is the keyboard toggle effect. - reactRuntime.effects[0]() - - expect(window.addEventListener).toHaveBeenCalledWith('keydown', expect.any(Function), { - capture: true - }) - expect(window.addEventListener).toHaveBeenCalledWith('keyup', expect.any(Function), { - capture: true - }) - }) - - it('does not register keyboard listeners while the terminal workbench is hidden', async () => { - appStoreMock.state.activeView = 'settings' - reactRuntime.index = 0 - const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') - TabBarQuickCommandsMenu(makeProps()) - - reactRuntime.effects[0]() - - expect(window.addEventListener).not.toHaveBeenCalledWith('keydown', expect.any(Function), { - capture: true - }) - expect(window.addEventListener).not.toHaveBeenCalledWith('keyup', expect.any(Function), { - capture: true - }) - }) - - it('toggles menuOpen to true when a matching key is pressed', async () => { - reactRuntime.index = 0 - const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') - TabBarQuickCommandsMenu(makeProps()) - - reactRuntime.effects[0]() - - keybindingsMock.matchAction.mockReturnValue(true) - const handler = windowListeners.get('keydown') - expect(handler).toBeDefined() - handler!(makeKeyEvent()) - - expect(reactRuntime.states[0]).toBe(true) - }) - - it('toggles menuOpen closed when pressed again', async () => { - reactRuntime.index = 0 - const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') - TabBarQuickCommandsMenu(makeProps()) - reactRuntime.effects[0]() - - keybindingsMock.matchAction.mockReturnValue(true) - windowListeners.get('keydown')!(makeKeyEvent()) - expect(reactRuntime.states[0]).toBe(true) - - // Rebuild effects after toggling so the new handler closes over the open state. - reactRuntime.index = 0 - reactRuntime.effects = [] - TabBarQuickCommandsMenu(makeProps()) - reactRuntime.effects[0]() - - windowListeners.get('keydown')!(makeKeyEvent()) - expect(reactRuntime.states[0]).toBe(false) - }) - - it('does not toggle when the event is a repeat (key held down)', async () => { - reactRuntime.index = 0 - const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') - TabBarQuickCommandsMenu(makeProps()) - - reactRuntime.effects[0]() - - keybindingsMock.matchAction.mockReturnValue(true) - const handler = windowListeners.get('keydown')! - handler(makeKeyEvent({ repeat: true })) - - expect(reactRuntime.states[0]).toBe(false) - }) - - it('does not toggle when the key does not match the action', async () => { - reactRuntime.index = 0 - const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') - TabBarQuickCommandsMenu(makeProps()) - - reactRuntime.effects[0]() - - keybindingsMock.matchAction.mockReturnValue(false) - const handler = windowListeners.get('keydown')! - handler(makeKeyEvent()) - - expect(reactRuntime.states[0]).toBe(false) - }) - - it('prevents default and stops propagation for matching keys', async () => { - reactRuntime.index = 0 - const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') - TabBarQuickCommandsMenu(makeProps()) - - reactRuntime.effects[0]() - - keybindingsMock.matchAction.mockReturnValue(true) - const event = makeKeyEvent() - windowListeners.get('keydown')!(event) - - expect(event.preventDefault).toHaveBeenCalled() - expect(event.stopImmediatePropagation).toHaveBeenCalled() - }) - - it('passes terminal context and terminal-first policy to shortcut matching', async () => { - appStoreMock.state.settings.terminalShortcutPolicy = 'terminal-first' - reactRuntime.index = 0 - const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') - TabBarQuickCommandsMenu(makeProps()) - - reactRuntime.effects[0]() - - const terminalTarget = { - classList: { contains: (className: string) => className === 'xterm-helper-textarea' }, - closest: () => null - } as unknown as EventTarget - const handler = windowListeners.get('keydown')! - handler(makeKeyEvent({ target: terminalTarget })) - - expect(keybindingsMock.matchAction).toHaveBeenCalledWith( - 'tab.openQuickCommandsMenu', - expect.objectContaining({ key: 'q', code: 'KeyQ' }), - 'darwin', - appStoreMock.state.keybindings, - { context: 'terminal', terminalShortcutPolicy: 'terminal-first' } - ) - expect(reactRuntime.states[0]).toBe(false) - }) - - it('ignores shortcut presses while the shortcut recorder is active', async () => { - reactRuntime.index = 0 - const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') - TabBarQuickCommandsMenu(makeProps()) - - reactRuntime.effects[0]() - - const recorderTarget = { - closest: (selector: string) => (selector === '[data-shortcut-recorder-active]' ? {} : null) - } as unknown as EventTarget - const handler = windowListeners.get('keydown')! - handler(makeKeyEvent({ target: recorderTarget })) - - expect(keybindingsMock.matchAction).not.toHaveBeenCalled() - expect(reactRuntime.states[0]).toBe(false) - }) - - it('toggles from a matching double-tap binding in the renderer path', async () => { - reactRuntime.index = 0 - const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') - TabBarQuickCommandsMenu(makeProps()) - - reactRuntime.effects[0]() - - keybindingsMock.matchAction.mockImplementation( - (_actionId, input: { doubleTapModifier?: string }) => input.doubleTapModifier === 'Shift' - ) - const keyDown = windowListeners.get('keydown')! - const keyUp = windowListeners.get('keyup')! - const firstDown = makeKeyEvent({ - key: 'Shift', - code: 'ShiftLeft', - metaKey: false, - shiftKey: true - }) - const firstUp = makeKeyEvent({ - key: 'Shift', - code: 'ShiftLeft', - metaKey: false, - shiftKey: true - }) - const secondDown = makeKeyEvent({ - key: 'Shift', - code: 'ShiftLeft', - metaKey: false, - shiftKey: true - }) - - keyDown(firstDown) - keyUp(firstUp) - keyDown(secondDown) - - expect(reactRuntime.states[0]).toBe(true) - expect(secondDown.preventDefault).toHaveBeenCalled() - expect(secondDown.stopImmediatePropagation).toHaveBeenCalled() - }) - - it('removes the listener when the effect is cleaned up', async () => { - reactRuntime.index = 0 - const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') - TabBarQuickCommandsMenu(makeProps()) - - const cleanup = reactRuntime.effects[0]() - cleanup?.() - - expect(window.removeEventListener).toHaveBeenCalledWith('keydown', expect.any(Function), { - capture: true - }) - expect(window.removeEventListener).toHaveBeenCalledWith('keyup', expect.any(Function), { - capture: true - }) - }) -}) diff --git a/src/renderer/src/components/tab-group/useTabGroupCreationCommands.local-shell.test.ts b/src/renderer/src/components/tab-group/useTabGroupCreationCommands.local-shell.test.ts deleted file mode 100644 index 6eac106178e..00000000000 --- a/src/renderer/src/components/tab-group/useTabGroupCreationCommands.local-shell.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { stubHeadlessReact } from '../tab-bar/tab-bar-windows-shell-launch-render-stubs' -import { stubRuntimeEnvironmentBridge } from '../../runtime/runtime-environment-bridge-fixture' - -const mocks = vi.hoisted(() => ({ - createTab: vi.fn(), - setActiveTab: vi.fn(), - setActiveTabType: vi.fn(), - setActiveWorktree: vi.fn(), - focusGroup: vi.fn(), - createBrowserTab: vi.fn(), - createEmptySplitGroup: vi.fn(), - openNewBrowserTabInActiveWorkspace: vi.fn(), - openNewMarkdownInActiveWorkspace: vi.fn(), - openNewTerminalTabInActiveWorkspace: vi.fn(), - runtimeEnvironmentIdOrLocalUnlessTwin: vi.fn(), - focusTerminalTabSurface: vi.fn(), - runtimeCall: vi.fn() -})) - -vi.mock('react', async () => await stubHeadlessReact()) - -vi.mock('@/lib/worktree-runtime-owner', () => ({ - runtimeEnvironmentIdOrLocalUnlessTwin: mocks.runtimeEnvironmentIdOrLocalUnlessTwin -})) -vi.mock('@/lib/runtime-session-mirror-owners', () => ({ - getRuntimeSessionMirrorEnvironmentIds: () => [] -})) - -vi.mock('../../lib/focus-terminal-tab-surface', () => ({ - focusTerminalTabSurface: mocks.focusTerminalTabSurface -})) - -const WORKTREE_ID = 'repo::C:/Users/neil/orca/workspaces/orca/aug23-triage' -const GROUP_ID = 'group-1' -const FOCUSED_ENVIRONMENT_ID = 'arch-dev' - -const storeState = { - settings: { activeRuntimeEnvironmentId: FOCUSED_ENVIRONMENT_ID }, - activeWorktreeId: WORKTREE_ID, - activeWorkspaceExecutionHostId: 'local' as string | null, - browserTabsByWorktree: {}, - browserPagesByWorkspace: {}, - remoteBrowserPageHandlesByPageId: {}, - createTab: mocks.createTab, - setActiveTab: mocks.setActiveTab, - setActiveTabType: mocks.setActiveTabType, - setActiveWorktree: mocks.setActiveWorktree, - focusGroup: mocks.focusGroup, - createBrowserTab: mocks.createBrowserTab, - createEmptySplitGroup: mocks.createEmptySplitGroup, - openNewBrowserTabInActiveWorkspace: mocks.openNewBrowserTabInActiveWorkspace, - openNewMarkdownInActiveWorkspace: mocks.openNewMarkdownInActiveWorkspace, - openNewTerminalTabInActiveWorkspace: mocks.openNewTerminalTabInActiveWorkspace -} - -const useAppStore = Object.assign( - (selector?: (state: typeof storeState) => unknown) => - selector ? selector(storeState) : storeState, - { - getState: () => storeState, - setState: vi.fn(), - subscribe: vi.fn() - } -) - -vi.mock('../../store', () => ({ useAppStore })) - -/** An unowned workspace must launch locally even while a remote runtime is focused; that runtime has no workspace selector. */ -describe('tab group "+" menu shell launch on a locally-owned workspace', () => { - beforeEach(() => { - vi.clearAllMocks() - storeState.activeWorkspaceExecutionHostId = 'local' - mocks.runtimeEnvironmentIdOrLocalUnlessTwin.mockReturnValue(null) - stubRuntimeEnvironmentBridge({ call: mocks.runtimeCall }) - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('opens the shell locally without consulting the focused runtime environment', async () => { - const { useTabGroupCreationCommands } = await import('./useTabGroupCreationCommands') - const commands = useTabGroupCreationCommands({ - groupId: GROUP_ID, - worktreeId: WORKTREE_ID, - worktreeState: { mobileEmulatorEnabled: false } as never - }) - - commands.newTerminalWithShell('powershell.exe') - await vi.waitFor(() => expect(mocks.createTab).toHaveBeenCalled()) - - expect(mocks.runtimeCall).not.toHaveBeenCalled() - expect(mocks.createTab).toHaveBeenCalledWith(WORKTREE_ID, GROUP_ID, 'powershell.exe', { - id: expect.any(String) - }) - expect(mocks.setActiveTab).toHaveBeenCalledWith(mocks.createTab.mock.calls[0][3].id) - }) - - it('leaves the workspace on its own execution host', async () => { - const { useTabGroupCreationCommands } = await import('./useTabGroupCreationCommands') - const commands = useTabGroupCreationCommands({ - groupId: GROUP_ID, - worktreeId: WORKTREE_ID, - worktreeState: { mobileEmulatorEnabled: false } as never - }) - - commands.newTerminalWithShell('powershell.exe') - await vi.waitFor(() => expect(mocks.createTab).toHaveBeenCalled()) - - // Latching the workspace onto the focused runtime is what silently broke the next Ctrl+T. - expect(mocks.setActiveWorktree).not.toHaveBeenCalled() - expect(storeState.activeWorkspaceExecutionHostId).toBe('local') - }) -}) diff --git a/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts b/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts deleted file mode 100644 index f4cdb08ca6f..00000000000 --- a/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as ReactModule from 'react' - -const mocks = vi.hoisted(() => ({ - callRuntimeRpc: vi.fn(), - cancelStructuredAgentLaunch: vi.fn(), - closeBrowserTab: vi.fn(), - closeFile: vi.fn(), - closeStructuredAgentSession: vi.fn(), - closeTerminalTab: vi.fn(), - closeUnifiedTab: vi.fn(), - clearNativeChatLaunchDraft: vi.fn(), - setActiveWorktree: vi.fn(), - toastError: vi.fn() -})) - -const store = vi.hoisted(() => ({ - activeWorktreeId: 'wt-1', - browserPagesByWorkspace: {}, - browserTabsByWorktree: {}, - closeBrowserTab: mocks.closeBrowserTab, - closeFile: mocks.closeFile, - closeUnifiedTab: mocks.closeUnifiedTab, - clearNativeChatLaunchDraft: mocks.clearNativeChatLaunchDraft, - openFiles: [], - reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 1 })), - setActiveWorktree: mocks.setActiveWorktree, - tabsByWorktree: {}, - unifiedTabsByWorktree: {} as Record -})) - -vi.mock('@/lib/agent-session/structured-agent-session-busy', async () => ({ - admitStructuredAgentSession: ( - await import('@/store/slices/store-session-test-harness') - ).admissionFromBusy(() => false) -})) - -vi.mock('react', async () => { - const actual = await vi.importActual('react') - return { - ...actual, - useCallback: (callback: T) => callback, - useMemo: (factory: () => T) => factory() - } -}) - -vi.mock('../../store', () => ({ - useAppStore: Object.assign((selector: (state: typeof store) => unknown) => selector(store), { - getState: () => store - }) -})) - -vi.mock('../../store/slices/browser-webview-cleanup', () => ({ - destroyWorkspaceWebviews: vi.fn() -})) - -vi.mock('../editor/editor-autosave', () => ({ - requestEditorFileClose: vi.fn() -})) - -vi.mock('../terminal/terminal-tab-actions', () => ({ - closeTerminalTab: mocks.closeTerminalTab -})) - -vi.mock('../../runtime/web-runtime-session-tab-lifecycle', () => ({ - closeWebRuntimeSessionTab: vi.fn() -})) -vi.mock('../../runtime/web-runtime-session-environment', () => ({ - isWebRuntimeSessionActive: vi.fn(() => false) -})) - -vi.mock('@/runtime/remote-browser-tab-ownership', () => ({ - browserWorkspaceHasRemoteOwner: () => false -})) - -vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) - -vi.mock('@/runtime/structured-agent-session-close', () => ({ - closeStructuredAgentSession: mocks.closeStructuredAgentSession -})) - -vi.mock('@/lib/agent-session/launch/structured-agent-session-launch', () => ({ - cancelStructuredAgentLaunch: mocks.cancelStructuredAgentLaunch -})) - -vi.mock('@/runtime/runtime-worktree-selector', () => ({ - toRuntimeWorktreeSelector: (worktreeId: string) => `id:${worktreeId}` -})) - -vi.mock('@/i18n/i18n', () => ({ - translate: (_key: string, fallback: string) => fallback -})) - -vi.mock('sonner', () => ({ - toast: { error: mocks.toastError } -})) - -import { useTabGroupTabCloseCommands } from './useTabGroupTabCloseCommands' - -const AGENT_TAB = { - id: 'agent-tab-1', - entityId: 'session-1', - groupId: 'group-1', - worktreeId: 'wt-1', - contentType: 'agent-session' as const, - label: 'Codex Chat', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 1 -} - -beforeEach(() => { - vi.clearAllMocks() - store.unifiedTabsByWorktree = { 'wt-1': [AGENT_TAB] } - mocks.closeStructuredAgentSession.mockResolvedValue('closed') - mocks.callRuntimeRpc.mockResolvedValue({ ok: true }) -}) - -describe('structured agent-session close ordering', () => { - it('dispatches local structured-session tab removal synchronously', async () => { - const { closeItem } = useTabGroupTabCloseCommands({ worktreeId: 'wt-1' }) - closeItem(AGENT_TAB.id) - - expect(mocks.closeUnifiedTab).toHaveBeenCalledWith(AGENT_TAB.id) - }) - - it('dispatches local structured-session tab removal synchronously during bulk close', async () => { - const { closeMany } = useTabGroupTabCloseCommands({ worktreeId: 'wt-1' }) - - closeMany([AGENT_TAB.id]) - - expect(mocks.closeUnifiedTab).toHaveBeenCalledWith(AGENT_TAB.id) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/MobileDriverOverlay.test.tsx b/src/renderer/src/components/terminal-pane/MobileDriverOverlay.test.tsx deleted file mode 100644 index 9541777e03a..00000000000 --- a/src/renderer/src/components/terminal-pane/MobileDriverOverlay.test.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import type React from 'react' -import type * as ReactModule from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { MobileDriverOverlay } from './MobileDriverOverlay' - -type OverlayProps = { - actionLabel: string - actionPending: boolean - allActionLabel?: string - allActionPending?: boolean - onAction: () => void | Promise - onAllAction?: () => void | Promise - rootRef: (node: HTMLDivElement | null) => void -} - -type OverlayElement = React.ReactElement - -const hookRuntime = vi.hoisted(() => ({ - states: [] as unknown[], - stateIndex: 0, - refs: [] as { current: unknown }[], - refIndex: 0, - id: 0 -})) - -vi.mock('react', async () => { - const actual = await vi.importActual('react') - return { - ...actual, - useCallback unknown>(callback: T): T { - return callback - }, - useId(): string { - hookRuntime.id += 1 - return `test-id-${hookRuntime.id}` - }, - useRef(initial: T) { - const refIndex = hookRuntime.refIndex++ - if (!(refIndex in hookRuntime.refs)) { - hookRuntime.refs[refIndex] = { current: initial } - } - return hookRuntime.refs[refIndex] as { current: T } - }, - useState(initial: T | (() => T)) { - const stateIndex = hookRuntime.stateIndex++ - if (!(stateIndex in hookRuntime.states)) { - hookRuntime.states[stateIndex] = - typeof initial === 'function' ? (initial as () => T)() : initial - } - const setState = (next: T | ((previous: T) => T)): void => { - hookRuntime.states[stateIndex] = - typeof next === 'function' - ? (next as (previous: T) => T)(hookRuntime.states[stateIndex] as T) - : next - } - return [hookRuntime.states[stateIndex] as T, setState] as const - } - } -}) - -function renderOverlay( - onAction: () => void | Promise, - onAllAction?: () => void | Promise -): OverlayElement { - hookRuntime.stateIndex = 0 - hookRuntime.refIndex = 0 - return MobileDriverOverlay({ - driver: { kind: 'mobile', clientId: 'phone-1' } as never, - hasFitOverride: false, - onAction, - onAllAction - }) as OverlayElement -} - -describe('MobileDriverOverlay', () => { - beforeEach(() => { - hookRuntime.states = [] - hookRuntime.stateIndex = 0 - hookRuntime.refs = [] - hookRuntime.refIndex = 0 - hookRuntime.id = 0 - }) - - it('clears stale pending action state when the overlay root reattaches', async () => { - let currentRootRef: OverlayProps['rootRef'] | null = null - const onAction = vi.fn(async () => { - currentRootRef?.(null) - }) - - let overlay = renderOverlay(onAction) - currentRootRef = overlay.props.rootRef - currentRootRef({} as HTMLDivElement) - - await overlay.props.onAction() - - overlay = renderOverlay(onAction) - expect(overlay.props.actionPending).toBe(true) - - currentRootRef = overlay.props.rootRef - currentRootRef({} as HTMLDivElement) - overlay = renderOverlay(onAction) - - expect(overlay.props.actionPending).toBe(false) - }) - - it('exposes held-fit restore labels for single and all terminals', () => { - hookRuntime.stateIndex = 0 - hookRuntime.refIndex = 0 - - const overlay = MobileDriverOverlay({ - driver: { kind: 'idle' } as never, - hasFitOverride: true, - onAction: vi.fn(), - onAllAction: vi.fn() - }) as OverlayElement - - expect(overlay.props.actionLabel).toBe('Restore') - expect(overlay.props.allActionLabel).toBe('Restore all') - }) - - it('exposes an all-terminals restore action when provided', async () => { - const onAction = vi.fn() - const onAllAction = vi.fn() - - const overlay = renderOverlay(onAction, onAllAction) - - expect(overlay.props.actionLabel).toBe('Take back') - expect(overlay.props.allActionLabel).toBe('Take back all') - expect(overlay.props.allActionPending).toBe(false) - await overlay.props.onAllAction?.() - - expect(onAction).not.toHaveBeenCalled() - expect(onAllAction).toHaveBeenCalledOnce() - }) -}) diff --git a/src/renderer/src/components/terminal-pane/agent-pre-ready-typed-draft.test.ts b/src/renderer/src/components/terminal-pane/agent-pre-ready-typed-draft.test.ts index dae4141200a..6c52f427727 100644 --- a/src/renderer/src/components/terminal-pane/agent-pre-ready-typed-draft.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-pre-ready-typed-draft.test.ts @@ -330,37 +330,6 @@ describe('agent pane keys typed before its PTY binds', () => { pane.dispose() }) - // Why: a trust prompt during startup must take the user's answer, never trap it in the draft. - it('lets the user answer a startup trust prompt and never pastes the draft into it', async () => { - let ready!: (value: boolean) => void - waitForAgentDraftInputReady.mockReturnValue( - new Promise((resolve) => (ready = resolve)) - ) - const pane = connectAgentPane() - await flushAsyncTicks(20) - - pane.type('make the tests pass') - pane.bind('pty-1') - mockStoreState.agentStatusByPaneKey[pane.paneKey] = { - state: 'blocked', - prompt: 'Do you trust the files in this folder?', - agentType: 'claude', - paneKey: pane.paneKey, - updatedAt: 1_000, - stateStartedAt: 1_000, - stateHistory: [] - } - pane.type('1') - pane.type('\r') - - expect(pane.transport.sendInput).toHaveBeenCalledWith('1', 'driving') - expect(pane.transport.sendInput).toHaveBeenCalledWith('\r', 'driving') - ready(true) - await flushAsyncTicks(20) - expect(sendAgentDraftPasteContent).not.toHaveBeenCalled() - pane.dispose() - }) - it('drops the draft when the agent is blocked on a prompt at ready time', async () => { waitForAgentDraftInputReady.mockResolvedValue(true) const pane = connectAgentPane() diff --git a/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts b/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts index d6eefc90fa9..d2f9782dc9b 100644 --- a/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts +++ b/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts @@ -208,7 +208,6 @@ async function waitUntil(predicate: () => boolean, timeoutMs: number): Promise void): { accept: (chunk: string) => void - getCarryLength: () => number } { let carry = '' return { @@ -228,92 +227,10 @@ function createTerminalQueryResponder(write: (reply: string) => void): { break } } - }, - getCarryLength: () => carry.length - } -} - -function allChunkPartitions(input: string): string[][] { - if (input.length === 0) { - return [[]] - } - const partitions: string[][] = [] - for (let end = 1; end <= input.length; end += 1) { - for (const suffix of allChunkPartitions(input.slice(end))) { - partitions.push([input.slice(0, end), ...suffix]) } } - return partitions } -function collectTerminalQueryReplies(chunks: readonly string[]): string[] { - const replies: string[] = [] - const responder = createTerminalQueryResponder((reply) => replies.push(reply)) - chunks.forEach(responder.accept) - return replies -} - -describe('terminal query responder', () => { - it.each([ - ['OSC 10 BEL', '\x1b]10;?\x07', OSC_10_REPLY], - ['OSC 10 ST', '\x1b]10;?\x1b\\', OSC_10_REPLY], - ['OSC 11 BEL', '\x1b]11;?\x07', OSC_11_REPLY], - ['OSC 11 ST', '\x1b]11;?\x1b\\', OSC_11_REPLY] - ])('answers each complete %s query once across every partition', (_label, query, reply) => { - for (const chunks of allChunkPartitions(query)) { - expect(collectTerminalQueryReplies(chunks)).toEqual([reply]) - } - }) - - it.each([ - ['DA1 with omitted parameter', '\x1b[c', DA1_REPLY], - ['DA1 with zero parameter', '\x1b[0c', DA1_REPLY], - ['DSR cursor position', '\x1b[6n', DSR_REPLY] - ])('preserves %s across every partition', (_label, query, reply) => { - for (const chunks of allChunkPartitions(query)) { - expect(collectTerminalQueryReplies(chunks)).toEqual([reply]) - } - }) - - it.each([ - ['unterminated OSC 10', '\x1b]10;?'], - ['unterminated OSC 11 ST', '\x1b]11;?\x1b'], - ['OSC 10 extra query marker', '\x1b]10;??\x07'], - ['OSC 11 stacked query', '\x1b]11;?;?\x07'], - ['OSC 10 malformed body', '\x1b]10;?x\x07'], - ['OSC 11 malformed ST body', '\x1b]11;?x\x1b\\'], - ['OSC slot lookalike', '\x1b]110;?\x07'] - ])('rejects %s across every partition', (_label, input) => { - for (const chunks of allChunkPartitions(input)) { - expect(collectTerminalQueryReplies(chunks)).toEqual([]) - } - }) - - it('answers concatenated queries once each in source order across every partition', () => { - const input = '\x1b]10;?\x07\x1b]11;?\x07' - for (const chunks of allChunkPartitions(input)) { - expect(collectTerminalQueryReplies(chunks)).toEqual([OSC_10_REPLY, OSC_11_REPLY]) - } - }) - - it('bounds carry while rejecting a long unterminated query lookalike', () => { - const replies: string[] = [] - const responder = createTerminalQueryResponder((reply) => replies.push(reply)) - let maxCarryLength = 0 - for (const fragment of `\x1b]10;?${'x'.repeat(10_000)}`) { - responder.accept(fragment) - maxCarryLength = Math.max(maxCarryLength, responder.getCarryLength()) - } - for (const fragment of '\x1b]10;?\x07') { - responder.accept(fragment) - } - - expect(maxCarryLength).toBe(QUERY_CARRY_LENGTH) - expect(responder.getCarryLength()).toBeLessThanOrEqual(QUERY_CARRY_LENGTH) - expect(replies).toEqual([OSC_10_REPLY]) - }) -}) - describe('fish never receives a color-scheme report it did not query (#9993)', () => { let configHome: string | null = null let statusSnapshot: ReturnType | undefined diff --git a/tests/e2e/floating-workspace-pane-height.spec.ts b/tests/e2e/floating-workspace-pane-height.spec.ts index 3146aad0a00..9c826735194 100644 --- a/tests/e2e/floating-workspace-pane-height.spec.ts +++ b/tests/e2e/floating-workspace-pane-height.spec.ts @@ -148,7 +148,7 @@ test('floating panel group bodies and panes own real pixels, single group and sp await expect(openPanel.locator(`[data-tab-id="${first.id}"]`)).toBeVisible() }) -test('single floating title and multiple tabs fill the header before overflowing', async ({ +test('floating tabs keep their preferred width and share constrained space before overflowing', async ({ orcaPage }) => { const titles = [ @@ -191,7 +191,7 @@ test('single floating title and multiple tabs fill the header before overflowing const firstSlot = panel.locator(`[data-tab-strip-slot="${first}"]`) const width = (): Promise => firstSlot.evaluate((element) => element.getBoundingClientRect().width) - await expect.poll(width).toBeGreaterThan(384) + await expect.poll(width).toBeCloseTo(330, 0) const singleWidth = await width() const strip = panel.locator('.terminal-tab-strip').first() diff --git a/tests/e2e/terminal-cursor-inactive-style.spec.ts b/tests/e2e/terminal-cursor-inactive-style.spec.ts deleted file mode 100644 index c704544771c..00000000000 --- a/tests/e2e/terminal-cursor-inactive-style.spec.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { Page } from '@stablyai/playwright-test' -import { test, expect } from './helpers/orca-app' -import { - splitActiveTerminalPane, - waitForActiveTerminalManager, - waitForPaneCount -} from './helpers/terminal' -import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' - -type InactiveCursorRender = { - cursorStyle: unknown - cursorInactiveStyle: unknown - terminalFocused: boolean - cursorClassName: string -} - -type XtermCursorInactiveStyle = 'outline' | 'block' | 'bar' | 'underline' | 'none' - -async function placeInactiveCursorAtPrompt(page: Page): Promise { - await page.evaluate(() => { - const store = window.__store - if (!store) { - throw new Error('window.__store is not available') - } - const state = store.getState() - const worktreeId = state.activeWorktreeId - const tabId = worktreeId - ? (state.activeTabIdByWorktree?.[worktreeId] ?? state.activeTabId) - : state.activeTabId - if (!tabId) { - throw new Error('No active terminal tab') - } - const manager = window.__paneManagers?.get(tabId) - if (!manager) { - throw new Error('Active terminal PaneManager is not mounted') - } - const panes = manager.getPanes?.() ?? [] - const activePane = manager.getActivePane?.() ?? panes.at(-1) ?? null - const inactivePane = panes.find((pane) => pane.id !== activePane?.id) ?? null - if (!inactivePane || !activePane) { - throw new Error('Need a split inactive pane to position the cursor') - } - - manager.setActivePane(activePane.id, { focus: true }) - inactivePane.terminal.write('\r\n$ ') - inactivePane.terminal.blur() - inactivePane.terminal.refresh(0, inactivePane.terminal.rows - 1) - }) -} - -async function renderInactiveCursor( - page: Page, - forcedInactiveStyle?: XtermCursorInactiveStyle -): Promise { - return page.evaluate(async (forcedInactiveStyle) => { - const store = window.__store - if (!store) { - throw new Error('window.__store is not available') - } - const state = store.getState() - const worktreeId = state.activeWorktreeId - const tabId = worktreeId - ? (state.activeTabIdByWorktree?.[worktreeId] ?? state.activeTabId) - : state.activeTabId - if (!tabId) { - throw new Error('No active terminal tab') - } - const manager = window.__paneManagers?.get(tabId) - if (!manager) { - throw new Error('Active terminal PaneManager is not mounted') - } - const panes = manager.getPanes?.() ?? [] - const activePane = manager.getActivePane?.() ?? panes.at(-1) ?? null - const inactivePane = panes.find((pane) => pane.id !== activePane?.id) ?? null - if (!inactivePane || !activePane) { - throw new Error('Need a split inactive pane to inspect cursor rendering') - } - - manager.setActivePane(activePane.id, { focus: true }) - if (forcedInactiveStyle) { - inactivePane.terminal.options.cursorInactiveStyle = forcedInactiveStyle - } - inactivePane.terminal.blur() - inactivePane.terminal.refresh(0, inactivePane.terminal.rows - 1) - await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))) - - const terminalCore = inactivePane.terminal as unknown as { - _core?: { _coreBrowserService?: { isFocused?: boolean } } - } - const cursor = inactivePane.container.querySelector('.xterm-cursor') - return { - cursorStyle: inactivePane.terminal.options.cursorStyle, - cursorInactiveStyle: inactivePane.terminal.options.cursorInactiveStyle, - terminalFocused: terminalCore._core?._coreBrowserService?.isFocused ?? true, - cursorClassName: - cursor?.className ?? - `(canvas renderer: ${inactivePane.terminal.options.cursorInactiveStyle})` - } - }, forcedInactiveStyle) -} - -test.describe('Terminal inactive cursor rendering', () => { - test.beforeEach(async ({ orcaPage }) => { - await waitForSessionReady(orcaPage) - await waitForActiveWorktree(orcaPage) - await ensureTerminalVisible(orcaPage) - await waitForActiveTerminalManager(orcaPage, 30_000) - await waitForPaneCount(orcaPage, 1, 30_000) - }) - - test('keeps an unfocused prompt cursor rendered as one block outline', async ({ orcaPage }) => { - await splitActiveTerminalPane(orcaPage, 'vertical') - await waitForPaneCount(orcaPage, 2) - await placeInactiveCursorAtPrompt(orcaPage) - - const fixedBehavior = await renderInactiveCursor(orcaPage) - expect(fixedBehavior.terminalFocused).toBe(false) - expect(fixedBehavior.cursorStyle).toBe('block') - expect(fixedBehavior.cursorInactiveStyle).toBe('outline') - expect(fixedBehavior.cursorClassName).toMatch(/xterm-cursor-outline|canvas renderer: outline/) - - const oldBehavior = await renderInactiveCursor(orcaPage, 'outline') - expect(oldBehavior.terminalFocused).toBe(false) - expect(oldBehavior.cursorStyle).toBe('block') - expect(oldBehavior.cursorInactiveStyle).toBe('outline') - }) -}) diff --git a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts index f03b07e05cd..f22fff4692a 100644 --- a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts +++ b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts @@ -389,98 +389,6 @@ test.describe('Hidden terminal TUI visual restore', () => { rmSync(scriptPath, { force: true }) }) - test('restores rich synchronized TUI output from the headless model', async ({ - orcaPage, - testRepoPath - }, testInfo: TestInfo) => { - await waitForSessionReady(orcaPage) - const firstWorktreeId = await waitForActiveWorktree(orcaPage) - const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( - (id) => id !== firstWorktreeId - ) - test.skip(!secondWorktreeId, 'hidden TUI restore needs the seeded secondary worktree') - if (!secondWorktreeId) { - return - } - - await switchToWorktree(orcaPage, secondWorktreeId) - await ensureTerminalVisible(orcaPage) - await waitForActiveTerminalManager(orcaPage, 30_000) - const hiddenSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1) - const hiddenPane = hiddenSnapshot.panes[0] - if (!hiddenPane?.ptyId) { - throw new Error('hidden rich model pane did not bind a PTY') - } - await switchToWorktree(orcaPage, firstWorktreeId) - await expect - .poll(() => getActiveWorktreeId(orcaPage), { - timeout: 10_000, - message: 'first worktree did not become active before hidden rich model restore' - }) - .toBe(firstWorktreeId) - - const runId = randomUUID() - const finalMarker = `VISUAL_RESTORE_FINAL_${runId}_24` - const scriptPath = path.join(testRepoPath, `.orca-hidden-rich-model-${runId}.mjs`) - writeHiddenFrameScript(scriptPath, runId) - await resetHiddenDebug(orcaPage) - try { - await writeHiddenFrames(orcaPage, hiddenPane.ptyId, scriptPath) - await resetHiddenDebug(orcaPage) - - // Rich synchronized frames are withheld in main; restore from the headless model snapshot. - await expect - .poll(() => readMainHiddenDeliveryDroppedChars(orcaPage), { - timeout: 10_000, - message: 'rich hidden TUI output was not withheld from the renderer' - }) - .toBeGreaterThan(0) - await expect - .poll(() => readMainSnapshotSource(orcaPage, hiddenPane.ptyId!), { - timeout: 10_000, - message: 'rich hidden TUI source did not come from headless model' - }) - .toBe('headless') - - await switchToWorktree(orcaPage, secondWorktreeId) - await ensureTerminalVisible(orcaPage) - await waitForActiveTerminalManager(orcaPage, 30_000) - - await expect - .poll(() => getTerminalContent(orcaPage, 12_000), { - timeout: 10_000, - message: 'rich headless TUI frame did not restore when visible' - }) - .toContain(finalMarker) - - const content = await getTerminalContent(orcaPage, 12_000) - expect(content).toContain(`Frame 024`) - expect(content).toContain('╭') - expect(content).toContain('├') - expect(content).toContain('█') - expect(content).not.toContain('Orca skipped hidden terminal output') - await expect - .poll(() => readTuiCursorState(orcaPage), { - timeout: 5_000, - message: 'rich headless TUI cursor stayed hidden after restore' - }) - .toMatchObject({ - hidden: false, - initialized: true - }) - - const screenshotPath = testInfo.outputPath('hidden-rich-model-restore-final.png') - await orcaPage.screenshot({ path: screenshotPath, fullPage: true }) - await testInfo.attach('hidden-rich-model-restore-final.png', { - path: screenshotPath, - contentType: 'image/png' - }) - } finally { - await sendToTerminal(orcaPage, hiddenPane.ptyId, '\x03').catch(() => undefined) - rmSync(scriptPath, { force: true }) - } - }) - test('keeps hidden terminal side effects live while hidden output may restore', async ({ orcaPage }) => {