mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
test: remove 158 low-value test declarations (#38177)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -1074,7 +1074,7 @@
|
||||
2 test-no-wall-clock:src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts
|
||||
2 test-no-wall-clock:src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx
|
||||
2 test-no-wall-clock:src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx
|
||||
2 test-no-wall-clock:src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.single-reference-details.test.tsx
|
||||
1 test-no-wall-clock:src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.single-reference-details.test.tsx
|
||||
2 test-no-wall-clock:src/renderer/src/components/status-bar/status-bar-antigravity-segment.test.tsx
|
||||
2 test-no-wall-clock:src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts
|
||||
2 test-no-wall-clock:src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -7101,7 +7101,6 @@
|
||||
"src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx": 1178,
|
||||
"src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts": 3346,
|
||||
"src/renderer/src/components/dashboard/useRetainedAgents.test.ts": 2028,
|
||||
"src/renderer/src/components/dashboard/useRetainedAgentsSync.test.ts": 1736,
|
||||
"src/renderer/src/components/dictation/DictationIndicator.test.tsx": 386,
|
||||
"src/renderer/src/components/dictation/dictation-insertion-target.test.ts": 323,
|
||||
"src/renderer/src/components/dictation/dictation-meter-store.test.tsx": 270,
|
||||
@@ -7341,7 +7340,6 @@
|
||||
"src/renderer/src/components/editor/rich-markdown/clipboard/rich-markdown-paste-fallback.test.ts": 1931,
|
||||
"src/renderer/src/components/editor/rich-markdown/code-blocks/rich-markdown-plain-code.test.ts": 307,
|
||||
"src/renderer/src/components/editor/rich-markdown/review/rich-markdown-range-bounds.test.ts": 70,
|
||||
"src/renderer/src/components/editor/rich-markdown/review/rich-markdown-review-rail-blocks.test.ts": 2561,
|
||||
"src/renderer/src/components/editor/rich-markdown/review/rich-markdown-review-text-ranges.test.ts": 53,
|
||||
"src/renderer/src/components/editor/rich-markdown/search/rich-markdown-search-focus.adversarial.test.ts": 301,
|
||||
"src/renderer/src/components/editor/rich-markdown/search/rich-markdown-search-focus.test.ts": 238,
|
||||
@@ -7481,7 +7479,6 @@
|
||||
"src/renderer/src/components/linear-issue-text-draft-state.test.ts": 49,
|
||||
"src/renderer/src/components/linear-issue-text-save-plan.test.ts": 47,
|
||||
"src/renderer/src/components/linear-issue-view-storage.test.ts": 207,
|
||||
"src/renderer/src/components/linear-project-view-surfaces.test.tsx": 781,
|
||||
"src/renderer/src/components/linear-scope-selector.test.ts": 281,
|
||||
"src/renderer/src/components/linear-workspace-picker.test.tsx": 693,
|
||||
"src/renderer/src/components/link-actions/LinkActionPopover.subscriptions.test.tsx": 4808,
|
||||
@@ -7501,7 +7498,6 @@
|
||||
"src/renderer/src/components/native-chat-resume-failure-guidance.test.ts": 240,
|
||||
"src/renderer/src/components/native-chat-resume-finished-run.test.tsx": 4173,
|
||||
"src/renderer/src/components/native-chat-resume-on-restart-grouping.test.ts": 117,
|
||||
"src/renderer/src/components/native-chat-resume-on-restart-outcome-toast.test.ts": 1930,
|
||||
"src/renderer/src/components/native-chat-resume-result-view.test.tsx": 2723,
|
||||
"src/renderer/src/components/native-chat-resume-run.test.ts": 65,
|
||||
"src/renderer/src/components/native-chat-resume-skipped-progress.test.tsx": 3009,
|
||||
@@ -7941,7 +7937,6 @@
|
||||
"src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-original-pane-index.test.ts": 86,
|
||||
"src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-original-pane.test.ts": 64,
|
||||
"src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-panel-session-list-request.test.tsx": 3044,
|
||||
"src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-scan-issue-state.test.ts": 222,
|
||||
"src/renderer/src/components/right-sidebar/ai-vault/search/ai-vault-search-session.test.ts": 201,
|
||||
"src/renderer/src/components/right-sidebar/ai-vault/accounts/ai-vault-session-account-render.test.tsx": 2885,
|
||||
"src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-conditional-refresh-hook.test.tsx": 2370,
|
||||
@@ -7953,7 +7948,6 @@
|
||||
"src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-refresh.test.ts": 2034,
|
||||
"src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-resume-in-chat-launch.test.ts": 1260,
|
||||
"src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-resume-in-chat-workspace.test.ts": 746,
|
||||
"src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-resume-in-chat.test.ts": 98,
|
||||
"src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-resume.test.ts": 1753,
|
||||
"src/renderer/src/components/right-sidebar/ai-vault/session-list/ai-vault-session-worktree-map.test.tsx": 2000,
|
||||
"src/renderer/src/components/right-sidebar/ai-vault/session-list/ai-vault-session-worktree.test.ts": 307,
|
||||
@@ -8081,7 +8075,6 @@
|
||||
"src/renderer/src/components/settings/appearance/chat/AppearanceChatSection.test.tsx": 1633,
|
||||
"src/renderer/src/components/settings/appearance/AppearancePane.test.tsx": 2066,
|
||||
"src/renderer/src/components/settings/artifacts/ArtifactsSettingsPane.test.tsx": 862,
|
||||
"src/renderer/src/components/settings/browser/BrowserPane.search-catalog.test.tsx": 774,
|
||||
"src/renderer/src/components/settings/browser/profiles/BrowserProfileRow.google-cookie-clear.test.tsx": 27636,
|
||||
"src/renderer/src/components/settings/browser/BrowserSitePermissionsSetting.test.tsx": 739,
|
||||
"src/renderer/src/components/settings/browser/BrowserUserAgentSetting.test.tsx": 2325,
|
||||
@@ -9195,7 +9188,6 @@
|
||||
"src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts": 2279,
|
||||
"src/renderer/src/hooks/agent-hook-completion-grok-notifications.test.ts": 679,
|
||||
"src/renderer/src/hooks/agent-hook-completion-notifications.test.ts": 1131,
|
||||
"src/renderer/src/hooks/agent-hook-completion-store-sync.test.ts": 52,
|
||||
"src/renderer/src/hooks/automation-dispatch-folder-loss.test.ts": 575,
|
||||
"src/renderer/src/hooks/automation-dispatch-legacy-owner.test.ts": 1914,
|
||||
"src/renderer/src/hooks/automation-dispatch-unverifiable-loss.test.ts": 182,
|
||||
@@ -9334,7 +9326,6 @@
|
||||
"src/renderer/src/hooks/useVirtualizedScrollAnchor.test.tsx": 311,
|
||||
"src/renderer/src/hooks/virtualized-hidden-row-measurement.test.ts": 199,
|
||||
"src/renderer/src/hooks/worktree-change-refresh-queue.test.ts": 59,
|
||||
"src/renderer/src/hooks/zoom-routing.test.ts": 842,
|
||||
"src/renderer/src/i18n/I18nProvider.test.tsx": 2101,
|
||||
"src/renderer/src/i18n/language-catalog-timeout.test.tsx": 2025,
|
||||
"src/renderer/src/i18n/language-detection-reconciliation.test.tsx": 2927,
|
||||
|
||||
@@ -125,39 +125,10 @@ function runRename(state: AppShortcutState = shortcutState()): boolean | undefin
|
||||
describe('tab.rename shortcut', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('leaves activeTabId on a background terminal while a structured tab is active', () => {
|
||||
// Prove the stale-terminal premise before testing structured-tab rename.
|
||||
mocks.store = storeForActiveTab(CHAT_UNIFIED_ID)
|
||||
expect(mocks.store.activeTabType).toBe('agent-session')
|
||||
expect(mocks.store.activeTabId).toBe(TERMINAL_ENTITY_ID)
|
||||
})
|
||||
|
||||
it('renames the structured chat tab, not the stale background terminal', () => {
|
||||
mocks.store = storeForActiveTab(CHAT_UNIFIED_ID)
|
||||
expect(runRename()).toBe(true)
|
||||
expect(mocks.requestTerminalTabRename).toHaveBeenCalledWith(CHAT_UNIFIED_ID)
|
||||
expect(mocks.requestTerminalTabRename).not.toHaveBeenCalledWith(TERMINAL_ENTITY_ID)
|
||||
})
|
||||
|
||||
it('still renames the terminal tab by its backing terminal id', () => {
|
||||
mocks.store = storeForActiveTab(TERMINAL_UNIFIED_ID)
|
||||
expect(mocks.store.activeTabType).toBe('terminal')
|
||||
expect(runRename()).toBe(true)
|
||||
expect(mocks.requestTerminalTabRename).toHaveBeenCalledWith(TERMINAL_ENTITY_ID)
|
||||
})
|
||||
|
||||
it('does not claim the chord for a tab type that has no inline rename', () => {
|
||||
mocks.store = {
|
||||
...storeForActiveTab(CHAT_UNIFIED_ID),
|
||||
activeTabType: 'browser'
|
||||
} as AppState
|
||||
expect(runRename()).toBe(false)
|
||||
expect(mocks.requestTerminalTabRename).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not claim the chord for a structured tab with no active worktree', () => {
|
||||
mocks.store = storeForActiveTab(CHAT_UNIFIED_ID)
|
||||
expect(runRename({ ...shortcutState(), activeWorktreeId: null })).toBe(false)
|
||||
expect(mocks.requestTerminalTabRename).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,15 +10,9 @@ import { WORKSPACE_LAYOUT_PROFILES } from '../../../shared/workspace-layout/work
|
||||
import { saveWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-save'
|
||||
import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness'
|
||||
import { createTestStore } from '../store/slices/store-test-helpers'
|
||||
import {
|
||||
ACCEPTED_DIFFERENCES,
|
||||
GIT,
|
||||
KNOWN_DIFFERENCES,
|
||||
sweptAtStartup
|
||||
} from './startup-window-parity-differences.test-fixture'
|
||||
import { GIT, sweptAtStartup } from './startup-window-parity-differences.test-fixture'
|
||||
import {
|
||||
bootWindowFromPartitions,
|
||||
diffStartupWindowViews,
|
||||
projectStartupWindowView
|
||||
} from './startup-window-view.test-fixture'
|
||||
|
||||
@@ -55,48 +49,6 @@ function normalize(hostId: ExecutionHostId, session: WorkspaceSessionState): Wor
|
||||
const PROFILES = [...WORKSPACE_LAYOUT_PROFILES, ...LEGACY_WORKSPACE_LAYOUT_PROFILES]
|
||||
|
||||
describe('the window shows the same session before and after the shared Loader rewrites it', () => {
|
||||
it.each(PROFILES.map((profile) => [profile.name, profile] as const))(
|
||||
'%s',
|
||||
async (name, { hostId, build }) => {
|
||||
const stored = build()
|
||||
const storedView = projectStartupWindowView(
|
||||
await bootWindowFromPartitions(createTestStore(), [{ hostId, session: stored }])
|
||||
)
|
||||
const normalizedView = projectStartupWindowView(
|
||||
await bootWindowFromPartitions(createTestStore(), [
|
||||
{ hostId, session: normalize(hostId, stored) }
|
||||
])
|
||||
)
|
||||
// The window must show the stored workspaces at all, or an empty view hides differences.
|
||||
for (const [key, rows] of Object.entries(stored.tabsByWorktree)) {
|
||||
if (rows.length > 0) {
|
||||
expect(storedView, key).toHaveProperty([`${key} group 0 tabs`])
|
||||
}
|
||||
}
|
||||
const differences = diffStartupWindowViews(storedView, normalizedView)
|
||||
const known = KNOWN_DIFFERENCES.filter((entry) => entry.fixture === name)
|
||||
const accepted = ACCEPTED_DIFFERENCES.filter((entry) => entry.fixture === name)
|
||||
expect(
|
||||
differences.map((difference) => difference.path).sort(),
|
||||
`window differences (stored vs normalized):\n${JSON.stringify(differences, null, 2)}`
|
||||
).toEqual(
|
||||
[
|
||||
...known.flatMap((entry) => entry.paths),
|
||||
...accepted.flatMap((entry) => Object.keys(entry.values))
|
||||
].sort()
|
||||
)
|
||||
// An accepted difference stays accepted only while it shows exactly the recorded values.
|
||||
const acceptedValues = Object.assign({}, ...accepted.map((entry) => entry.values))
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
differences
|
||||
.filter((difference) => difference.path in acceptedValues)
|
||||
.map(({ path, stored, normalized }) => [path, { stored, normalized }])
|
||||
)
|
||||
).toEqual(acceptedValues)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the tab-bar twin selected when a selected legacy copy is dropped', async () => {
|
||||
const profile = PROFILES.find(
|
||||
(entry) => entry.name === 'legacy row repeating a tab-bar tab’s terminal, selected'
|
||||
@@ -122,11 +74,4 @@ describe('the window shows the same session before and after the shared Loader r
|
||||
expect(view[`${GIT} group 0 tabs`]).toEqual(['tab-a', 'tab-b'])
|
||||
expect(view).not.toHaveProperty([`${GIT} tab /Users/dev/orca/src/app.ts`])
|
||||
})
|
||||
|
||||
it('lists differences only for fixtures that exist', () => {
|
||||
const names = new Set(PROFILES.map((profile) => profile.name))
|
||||
expect(
|
||||
[...KNOWN_DIFFERENCES, ...ACCEPTED_DIFFERENCES].filter((entry) => !names.has(entry.fixture))
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -135,31 +135,4 @@ describe('NewWorkspaceComposerCard set location', () => {
|
||||
expect(storeMocks.openModal).not.toHaveBeenCalled()
|
||||
expect(storeMocks.openSettingsPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Flush independently so this test cannot rely on an earlier test warming the chunk.
|
||||
it('closes the nested dialog before publishing the ready run target', async () => {
|
||||
const nestedOpenChanges: boolean[] = []
|
||||
const setupChanges: string[] = []
|
||||
container = await renderCard({
|
||||
onNestedDialogOpenChange: (open) => nestedOpenChanges.push(open),
|
||||
onProjectHostSetupChange: (setupId) => setupChanges.push(setupId)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
container?.querySelector<HTMLElement>('div[data-run-target-combobox-root="true"]')?.click()
|
||||
})
|
||||
const setLocation = [...document.body.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||
(button) => button.textContent?.includes('Set project location')
|
||||
)
|
||||
act(() => setLocation?.click())
|
||||
await act(async () => {})
|
||||
const complete = [...document.body.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||
(button) => button.textContent === 'Complete location'
|
||||
)
|
||||
act(() => complete?.click())
|
||||
|
||||
expect(nestedOpenChanges).toEqual([true, false])
|
||||
expect(setupChanges).toEqual(['setup-remote'])
|
||||
expect(document.body.querySelector('[data-testid="set-project-location-dialog"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -135,20 +135,6 @@ afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('preserves the pending pagehide flush through HMR and removes the listener when drained', () => {
|
||||
const remove = vi.spyOn(domWindow, 'removeEventListener')
|
||||
try {
|
||||
clearCompletedActivity([doneThread])
|
||||
disposePendingClearCompletedEvictionListener()
|
||||
expect(remove).not.toHaveBeenCalledWith('pagehide', flushPendingClearCompletedEvictions)
|
||||
domWindow.dispatchEvent(new Event('pagehide'))
|
||||
expect(drop).toHaveBeenCalledOnce()
|
||||
expect(remove).toHaveBeenCalledWith('pagehide', flushPendingClearCompletedEvictions)
|
||||
} finally {
|
||||
remove.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves Undo after HMR without dropping the restored persisted row', () => {
|
||||
clearCompletedActivity([doneThread])
|
||||
disposePendingClearCompletedEvictionListener()
|
||||
@@ -159,20 +145,3 @@ it('preserves Undo after HMR without dropping the restored persisted row', () =>
|
||||
expect(drop).not.toHaveBeenCalled()
|
||||
expect(mockStore.retainedAgentsByPaneKey['t-done:1']).toBeDefined()
|
||||
})
|
||||
|
||||
it('releases the retired listener when its fallback settles and supports a late stale handler', () => {
|
||||
const remove = vi.spyOn(domWindow, 'removeEventListener')
|
||||
try {
|
||||
clearCompletedActivity([doneThread])
|
||||
disposePendingClearCompletedEvictionListener()
|
||||
vi.advanceTimersByTime(CLEAR_COMPLETED_EVICTION_FALLBACK_MS)
|
||||
expect(drop).toHaveBeenCalledOnce()
|
||||
expect(remove).toHaveBeenCalledWith('pagehide', flushPendingClearCompletedEvictions)
|
||||
mockStore.retainedAgentsByPaneKey = { 't-done:1': makeRetained('t-done:1') }
|
||||
clearCompletedActivity([doneThread])
|
||||
domWindow.dispatchEvent(new Event('pagehide'))
|
||||
expect(drop).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
remove.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,8 +3,6 @@ import type { AgentStatusIpcPayload } from '../../../../shared/agent-status/cont
|
||||
import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot'
|
||||
import { patchDashboardSnapshotFromAgentStatus } from './dashboard-agent-status-patch'
|
||||
|
||||
import { dashboardCardDisplayState } from '../../../../shared/dashboard-snapshot'
|
||||
|
||||
function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
|
||||
return {
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
@@ -46,118 +44,6 @@ function snapshot(cards: DashboardCard[] = [card()]): DashboardSnapshot {
|
||||
}
|
||||
|
||||
describe('patchDashboardSnapshotFromAgentStatus', () => {
|
||||
it.each(['cancellation', 'failure', 'unconfirmed'] as const)(
|
||||
'projects a newer %s verdict and clears it on the next full status',
|
||||
(outcome) => {
|
||||
const patched = patchDashboardSnapshotFromAgentStatus(
|
||||
snapshot(),
|
||||
event({
|
||||
state: 'done',
|
||||
mainAgent: { state: 'done', outcome, stateStartedAt: 250 }
|
||||
})
|
||||
).snapshot
|
||||
expect(dashboardCardDisplayState(patched.cards[0])).toBe(
|
||||
outcome === 'cancellation'
|
||||
? 'interrupted'
|
||||
: outcome === 'failure'
|
||||
? 'failed'
|
||||
: 'unconfirmed'
|
||||
)
|
||||
expect(patched.cards[0].bucket).toBe('done')
|
||||
for (const interrupted of [undefined, false]) {
|
||||
const cleared = patchDashboardSnapshotFromAgentStatus(
|
||||
patched,
|
||||
event({ state: 'working', interrupted, receivedAt: 400, stateStartedAt: 350 })
|
||||
).snapshot
|
||||
expect(dashboardCardDisplayState(cleared.cards[0])).toBe('working')
|
||||
}
|
||||
expect(
|
||||
patchDashboardSnapshotFromAgentStatus(
|
||||
patched,
|
||||
event({ receivedAt: 400, programStatusOnly: true })
|
||||
).snapshot
|
||||
).toBe(patched)
|
||||
expect(
|
||||
patchDashboardSnapshotFromAgentStatus(
|
||||
patched,
|
||||
event({ receivedAt: 400, providerSessionOnly: true })
|
||||
).snapshot
|
||||
).toBe(patched)
|
||||
}
|
||||
)
|
||||
|
||||
it('patches one known card without rebuilding the dashboard topology', () => {
|
||||
const original = snapshot([card(), card({ paneKey: 'tab-2:leaf-2' })])
|
||||
const result = patchDashboardSnapshotFromAgentStatus(original, event())
|
||||
|
||||
expect(result.matched).toBe(true)
|
||||
expect(result.snapshot.cards[0]).toMatchObject({
|
||||
bucket: 'attention',
|
||||
dotState: 'blocked',
|
||||
task: 'Need a decision',
|
||||
lastUserMessage: 'Need a decision',
|
||||
askSummary: '{"question":"Continue?"}',
|
||||
stateChangedAt: 250,
|
||||
statusUpdatedAt: 300,
|
||||
unseen: true
|
||||
})
|
||||
expect(result.snapshot.cards[1]).toBe(original.cards[1])
|
||||
})
|
||||
|
||||
it('preserves cached fields omitted from same-state hook pings', () => {
|
||||
const original = snapshot([
|
||||
card({
|
||||
bucket: 'attention',
|
||||
dotState: 'waiting',
|
||||
askSummary: 'Pick one',
|
||||
lastAgentMessage: 'Waiting',
|
||||
stateChangedAt: 250,
|
||||
unseen: true
|
||||
})
|
||||
])
|
||||
const result = patchDashboardSnapshotFromAgentStatus(
|
||||
original,
|
||||
event({ state: 'waiting', prompt: '', interactivePrompt: undefined, stateStartedAt: 250 })
|
||||
)
|
||||
|
||||
expect(result.snapshot.cards[0]).toMatchObject({
|
||||
task: 'old task',
|
||||
askSummary: 'Pick one',
|
||||
lastAgentMessage: 'Waiting',
|
||||
unseen: true
|
||||
})
|
||||
})
|
||||
|
||||
it('patches same-state working into passive monitoring', () => {
|
||||
const result = patchDashboardSnapshotFromAgentStatus(
|
||||
snapshot(),
|
||||
event({ state: 'working', workingMode: 'monitoring', stateStartedAt: 100 })
|
||||
)
|
||||
|
||||
expect(result.snapshot.cards[0]).toMatchObject({
|
||||
bucket: 'working',
|
||||
dotState: 'working',
|
||||
workingMode: 'monitoring'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an unverifiable child compatible with the pop-out wire vocabulary', () => {
|
||||
const result = patchDashboardSnapshotFromAgentStatus(
|
||||
snapshot(),
|
||||
event({
|
||||
subagents: [{ id: 'child-1', state: 'unverifiable', startedAt: 100 }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.snapshot.cards[0].subagents).toEqual([
|
||||
{
|
||||
id: 'tab-1:leaf-1\u0000subagent:child-1',
|
||||
name: 'unknown',
|
||||
dotState: 'idle'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores stale, wrong-workspace, and session-only events', () => {
|
||||
const original = snapshot()
|
||||
expect(
|
||||
@@ -170,14 +56,4 @@ describe('patchDashboardSnapshotFromAgentStatus', () => {
|
||||
patchDashboardSnapshotFromAgentStatus(original, event({ providerSessionOnly: true })).snapshot
|
||||
).toBe(original)
|
||||
})
|
||||
|
||||
it('asks the caller for topology only when the pane is unknown', () => {
|
||||
const original = snapshot()
|
||||
const result = patchDashboardSnapshotFromAgentStatus(
|
||||
original,
|
||||
event({ paneKey: 'tab-new:leaf-new' })
|
||||
)
|
||||
|
||||
expect(result).toEqual({ matched: false, snapshot: original })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status/contracts/agent-status-freshness'
|
||||
import type {
|
||||
AgentStatusEntry,
|
||||
AgentStatusState
|
||||
} from '../../../../shared/agent-status/contracts/agent-status-types'
|
||||
import type { FolderWorkspace } from '../../../../shared/folder-workspaces/folder-workspace-types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { TerminalTab } from '../../../../shared/workspace-layout/terminal-tab-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id'
|
||||
import { buildRetainedAgentsSyncSnapshot } from './useRetainedAgents'
|
||||
|
||||
const ACTIVE_PANE_KEY = makePaneKey('tab-active', '22222222-2222-4222-8222-222222222222')
|
||||
const ARCHIVED_PANE_KEY = makePaneKey('tab-archived', '33333333-3333-4333-8333-333333333333')
|
||||
const FOLDER_PANE_KEY = makePaneKey('tab-folder', '44444444-4444-4444-8444-444444444444')
|
||||
const ARCHIVED_FOLDER_PANE_KEY = makePaneKey(
|
||||
'tab-folder-archived',
|
||||
'55555555-5555-4555-8555-555555555555'
|
||||
)
|
||||
|
||||
function makeFolderWorkspace(overrides?: Partial<FolderWorkspace>): FolderWorkspace {
|
||||
return {
|
||||
id: 'folder-1',
|
||||
projectGroupId: 'group-1',
|
||||
name: 'Folder',
|
||||
folderPath: '/repo/folder',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeRepo(): Repo {
|
||||
return {
|
||||
id: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function makeWorktree(overrides?: Partial<Worktree>): Worktree {
|
||||
return {
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo/wt-1',
|
||||
head: 'abc123',
|
||||
branch: 'feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: 'feature',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeTab(overrides?: Partial<TerminalTab>): TerminalTab {
|
||||
return {
|
||||
id: 'tab-1',
|
||||
ptyId: null,
|
||||
worktreeId: 'wt-1',
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeEntry(args: {
|
||||
paneKey: string
|
||||
state: AgentStatusState
|
||||
updatedAt: number
|
||||
stateStartedAt?: number
|
||||
prompt?: string
|
||||
toolName?: string
|
||||
}): AgentStatusEntry {
|
||||
return {
|
||||
state: args.state,
|
||||
prompt: args.prompt ?? 'Fix it',
|
||||
updatedAt: args.updatedAt,
|
||||
stateStartedAt: args.stateStartedAt ?? args.updatedAt,
|
||||
paneKey: args.paneKey,
|
||||
terminalTitle: 'Claude',
|
||||
stateHistory: [],
|
||||
agentType: 'claude',
|
||||
toolName: args.toolName
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildRetainedAgentsSyncSnapshot', () => {
|
||||
it('builds live rows for non-archived worktrees and stale-decays active states', () => {
|
||||
const repo = makeRepo()
|
||||
const activeWorktree = makeWorktree({ id: 'wt-active' })
|
||||
const archivedWorktree = makeWorktree({ id: 'wt-archived', isArchived: true })
|
||||
const activeTab = makeTab({ id: 'tab-active', worktreeId: 'wt-active' })
|
||||
const archivedTab = makeTab({ id: 'tab-archived', worktreeId: 'wt-archived' })
|
||||
|
||||
const snapshot = buildRetainedAgentsSyncSnapshot({
|
||||
repos: [repo],
|
||||
worktreesByRepo: { [repo.id]: [activeWorktree, archivedWorktree] },
|
||||
folderWorkspaces: [],
|
||||
tabsByWorktree: {
|
||||
[activeWorktree.id]: [activeTab],
|
||||
[archivedWorktree.id]: [archivedTab]
|
||||
},
|
||||
agentStatusByPaneKey: {
|
||||
[ACTIVE_PANE_KEY]: makeEntry({
|
||||
paneKey: ACTIVE_PANE_KEY,
|
||||
state: 'working',
|
||||
updatedAt: 10_000,
|
||||
stateStartedAt: 10_000
|
||||
}),
|
||||
[ARCHIVED_PANE_KEY]: makeEntry({
|
||||
paneKey: ARCHIVED_PANE_KEY,
|
||||
state: 'done',
|
||||
updatedAt: 20_000,
|
||||
stateStartedAt: 20_000
|
||||
})
|
||||
},
|
||||
now: 10_000 + AGENT_STATUS_STALE_AFTER_MS + 1
|
||||
})
|
||||
|
||||
expect([...snapshot.existingWorktreeIds]).toEqual(['wt-active'])
|
||||
expect(snapshot.currentAgents.get(ACTIVE_PANE_KEY)?.row.state).toBe('idle')
|
||||
expect(snapshot.currentAgents.get(ARCHIVED_PANE_KEY)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('builds live rows for non-archived folder workspaces and skips archived ones', () => {
|
||||
const folderWorkspace = makeFolderWorkspace()
|
||||
const archivedFolderWorkspace = makeFolderWorkspace({ id: 'folder-2', isArchived: true })
|
||||
const workspaceKey = folderWorkspaceKey(folderWorkspace.id)
|
||||
const archivedWorkspaceKey = folderWorkspaceKey(archivedFolderWorkspace.id)
|
||||
const tab = makeTab({ id: 'tab-folder', worktreeId: workspaceKey })
|
||||
const archivedTab = makeTab({ id: 'tab-folder-archived', worktreeId: archivedWorkspaceKey })
|
||||
|
||||
const snapshot = buildRetainedAgentsSyncSnapshot({
|
||||
repos: [],
|
||||
worktreesByRepo: {},
|
||||
folderWorkspaces: [folderWorkspace, archivedFolderWorkspace],
|
||||
tabsByWorktree: { [workspaceKey]: [tab], [archivedWorkspaceKey]: [archivedTab] },
|
||||
agentStatusByPaneKey: {
|
||||
[FOLDER_PANE_KEY]: makeEntry({
|
||||
paneKey: FOLDER_PANE_KEY,
|
||||
state: 'done',
|
||||
updatedAt: 10_000
|
||||
}),
|
||||
[ARCHIVED_FOLDER_PANE_KEY]: makeEntry({
|
||||
paneKey: ARCHIVED_FOLDER_PANE_KEY,
|
||||
state: 'done',
|
||||
updatedAt: 10_000
|
||||
})
|
||||
},
|
||||
now: 10_000
|
||||
})
|
||||
|
||||
expect([...snapshot.existingWorktreeIds]).toEqual([workspaceKey])
|
||||
expect(snapshot.currentAgents.get(FOLDER_PANE_KEY)?.worktreeId).toBe(workspaceKey)
|
||||
expect(snapshot.currentAgents.get(ARCHIVED_FOLDER_PANE_KEY)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -62,25 +62,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('DiagramViewerModal', () => {
|
||||
it('shows the diagram fitted and centered, and Escape returns focus to the opener', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Host />)
|
||||
const opener = screen.getByRole('button', { name: 'open' })
|
||||
await user.click(opener)
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Diagram' })).toBeInTheDocument()
|
||||
sizeCanvas()
|
||||
const image = screen.getByRole('img', { name: 'Diagram' })
|
||||
expect(image.getAttribute('src')).toMatch(/^data:image\/svg\+xml/)
|
||||
expect(image.style.width).toBe('400px')
|
||||
expect(image.style.transform).toBe('translate(300px, 200px)')
|
||||
expect(screen.getByRole('button', { name: 'Fit to screen' })).toBeDisabled()
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
await waitFor(() => expect(document.activeElement).toBe(opener))
|
||||
})
|
||||
|
||||
it('zooms from the toolbar, pans on Alt-drag, and fits again', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Host />)
|
||||
@@ -134,17 +115,6 @@ describe('DiagramViewerModal', () => {
|
||||
await waitFor(() => expect(document.activeElement).toBe(opener))
|
||||
})
|
||||
|
||||
it('pans by the wheel distance whatever unit the device reports', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Host />)
|
||||
await user.click(screen.getByRole('button', { name: 'open' }))
|
||||
const canvas = sizeCanvas()
|
||||
const image = screen.getByRole('img', { name: 'Diagram' })
|
||||
|
||||
fireEvent.wheel(canvas, { deltaY: 3, deltaMode: WheelEvent.DOM_DELTA_LINE })
|
||||
expect(image.style.transform).toBe('translate(300px, 152px)')
|
||||
})
|
||||
|
||||
it('leaves focus alone when another modal replaced the viewer', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Host />)
|
||||
|
||||
-55
@@ -51,25 +51,6 @@ const expectedTasks = [
|
||||
]
|
||||
|
||||
describe('task children of ordered list items', () => {
|
||||
it.each([
|
||||
['4. Parent', 3, '-'],
|
||||
['10) Parent', 4, '-']
|
||||
] as const)('keeps sibling task states under %s at indent %s', (parent, indent, marker) => {
|
||||
const source = [
|
||||
parent,
|
||||
`${' '.repeat(indent)}${marker} [ ] First`,
|
||||
`${' '.repeat(indent)}${marker} [x] Middle`,
|
||||
`${' '.repeat(indent)}${marker} [X] Last`
|
||||
].join('\n')
|
||||
const editor = openMarkdown(source)
|
||||
try {
|
||||
editor.state.doc.check()
|
||||
expect(taskItems(editor)).toEqual(expectedTasks)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves checked siblings when Enter splits a task before save and reopen', () => {
|
||||
const source = '4. Parent\n - [ ] First\n - [x] Middle\n - [x] Last'
|
||||
const editor = openMarkdown(source)
|
||||
@@ -119,21 +100,6 @@ describe('task children of ordered list items', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a four-space continuation paragraph free of leftover indent', () => {
|
||||
const editor = openMarkdown('1. Parent\n - [ ] a\n more text')
|
||||
try {
|
||||
const paragraphs: string[] = []
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === 'paragraph') {
|
||||
paragraphs.push(node.textContent)
|
||||
}
|
||||
})
|
||||
expect(paragraphs).toEqual(['Parent', 'a', 'more text'])
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps an intentionally nested task under its owning sibling', () => {
|
||||
const editor = openMarkdown(
|
||||
'4. Parent\n - [ ] First\n - [x] Child\n - [x] Middle\n - [x] Last'
|
||||
@@ -149,25 +115,4 @@ describe('task children of ordered list items', () => {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps following plain bullets and the next ordered item in their own lists', () => {
|
||||
const editor = openMarkdown(
|
||||
'4. Parent\n - [ ] First\n - Plain\n - [x] Last\n5. Following'
|
||||
)
|
||||
try {
|
||||
editor.state.doc.check()
|
||||
expect(taskItems(editor)).toEqual([expectedTasks[0], expectedTasks[2]])
|
||||
expect(editor.state.doc.firstChild?.childCount).toBe(2)
|
||||
expect(editor.state.doc.firstChild?.lastChild?.textContent).toBe('Following')
|
||||
const plainItems: string[] = []
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === 'listItem' && node.firstChild?.textContent === 'Plain') {
|
||||
plainItems.push(node.type.name)
|
||||
}
|
||||
})
|
||||
expect(plainItems).toEqual(['listItem'])
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
-143
@@ -1,143 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Editor, type JSONContent } from '@tiptap/core'
|
||||
import type { DiffComment } from '../../../../../../shared/diff-comment-types'
|
||||
import { createRichMarkdownExtensions } from '../schema/rich-markdown-extensions'
|
||||
import { createRichMarkdownEditorCodec } from '../document/rich-markdown-source-transport'
|
||||
import './rich-markdown-review-annotations'
|
||||
import {
|
||||
buildRichMarkdownCommentBlocks,
|
||||
getRichMarkdownCommentBlocks as getRichMarkdownReviewRailBlocks
|
||||
} from './rich-markdown-comment-blocks'
|
||||
|
||||
import { measureRichMarkdownReviewNotePositions } from './rich-markdown-review-note-positioning'
|
||||
|
||||
const editors: Editor[] = []
|
||||
|
||||
function createEditor(
|
||||
content: string | JSONContent = '# Heading\n\nFirst paragraph\n\n- One\n- Two'
|
||||
) {
|
||||
const editor = new Editor({
|
||||
element: document.createElement('div'),
|
||||
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
|
||||
content,
|
||||
...(typeof content === 'string' ? { contentType: 'markdown' as const } : {})
|
||||
})
|
||||
editors.push(editor)
|
||||
// Settle the trailing-node plugin before measuring selection-only transactions.
|
||||
editor.view.dispatch(editor.state.tr.setMeta('addToHistory', false))
|
||||
return editor
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const editor of editors.splice(0)) {
|
||||
editor.destroy()
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('review rail source block reuse', () => {
|
||||
it.each([
|
||||
'```ts\nconst value = 1\n\nvalue++\n```\n\nAfter code',
|
||||
'| First | Second |\n| --- | --- |\n| one | two |\n\nAfter table',
|
||||
'<details><summary>Toggle</summary>\n\nInside\n\n</details>\n\nAfter toggle'
|
||||
])('preserves multiline block boundaries for %s', (source) => {
|
||||
const editor = createEditor(source)
|
||||
expect(getRichMarkdownReviewRailBlocks(editor)).toEqual(buildRichMarkdownCommentBlocks(editor))
|
||||
})
|
||||
|
||||
it('preserves block lines and reuses them after selection-only transactions', () => {
|
||||
const editor = createEditor()
|
||||
const expected = buildRichMarkdownCommentBlocks(editor)
|
||||
const serialize = vi.spyOn(editor.markdown!, 'serialize')
|
||||
const blocks = getRichMarkdownReviewRailBlocks(editor)
|
||||
expect(blocks).toEqual(expected)
|
||||
expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1)
|
||||
serialize.mockClear()
|
||||
editor.commands.setTextSelection(3)
|
||||
expect(getRichMarkdownReviewRailBlocks(editor)).toBe(blocks)
|
||||
expect(serialize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rebuilds after edits and undo while keeping editors isolated', () => {
|
||||
const editor = createEditor()
|
||||
const original = getRichMarkdownReviewRailBlocks(editor)
|
||||
editor.commands.insertContentAt(1, 'changed\ntext')
|
||||
const changed = getRichMarkdownReviewRailBlocks(editor)
|
||||
expect(changed).not.toBe(original)
|
||||
expect(changed).toEqual(buildRichMarkdownCommentBlocks(editor))
|
||||
editor.commands.undo()
|
||||
expect(getRichMarkdownReviewRailBlocks(editor)).toEqual(original)
|
||||
expect(getRichMarkdownReviewRailBlocks(createEditor())).not.toBe(original)
|
||||
})
|
||||
|
||||
it('invalidates an in-place serializer replacement and a manager replacement', () => {
|
||||
const editor = createEditor()
|
||||
const original = getRichMarkdownReviewRailBlocks(editor)
|
||||
const serialize = editor.markdown!.serialize.bind(editor.markdown)
|
||||
vi.spyOn(editor.markdown!, 'serialize').mockImplementation(
|
||||
(content) => `${serialize(content)}\nextra line`
|
||||
)
|
||||
const changed = getRichMarkdownReviewRailBlocks(editor)
|
||||
expect(changed).not.toEqual(original)
|
||||
expect(changed).toEqual(buildRichMarkdownCommentBlocks(editor))
|
||||
editor.markdown = createEditor().markdown
|
||||
expect(getRichMarkdownReviewRailBlocks(editor)).toEqual(original)
|
||||
})
|
||||
|
||||
it('does not reuse fallback lines after a missing serializer becomes available', () => {
|
||||
const editor = createEditor()
|
||||
const markdown = editor.markdown
|
||||
editor.markdown = undefined
|
||||
const fallback = getRichMarkdownReviewRailBlocks(editor)
|
||||
expect(fallback).toEqual(buildRichMarkdownCommentBlocks(editor))
|
||||
editor.markdown = markdown
|
||||
expect(getRichMarkdownReviewRailBlocks(editor)).not.toEqual(fallback)
|
||||
expect(getRichMarkdownReviewRailBlocks(editor)).toEqual(buildRichMarkdownCommentBlocks(editor))
|
||||
})
|
||||
|
||||
it('avoids serialization for every comment and repeated scroll while refreshing geometry', () => {
|
||||
const editor = createEditor()
|
||||
let sourceTop = 100
|
||||
const coords = vi.spyOn(editor.view, 'coordsAtPos').mockImplementation(() => ({
|
||||
top: sourceTop,
|
||||
bottom: sourceTop + 20,
|
||||
left: 0,
|
||||
right: 10
|
||||
}))
|
||||
const container = document.createElement('div')
|
||||
const comment: DiffComment = {
|
||||
id: 'note',
|
||||
worktreeId: 'workspace',
|
||||
filePath: 'notes.md',
|
||||
source: 'markdown',
|
||||
lineNumber: 1,
|
||||
body: 'Review',
|
||||
createdAt: 1,
|
||||
side: 'modified'
|
||||
}
|
||||
const markdownComments = Array.from({ length: 5 }, (_, index) => ({
|
||||
...comment,
|
||||
id: `note-${index}`,
|
||||
selectedText: index === 0 ? 'Heading' : undefined
|
||||
}))
|
||||
const serialize = vi.spyOn(editor.markdown!, 'serialize')
|
||||
const measure = () =>
|
||||
measureRichMarkdownReviewNotePositions({
|
||||
editor,
|
||||
container,
|
||||
markdownComments,
|
||||
markdownSourceLineOffset: 0
|
||||
})
|
||||
expect(measure()[0]?.top).toBe(100)
|
||||
expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1)
|
||||
serialize.mockClear()
|
||||
sourceTop = 200
|
||||
container.scrollTop = 30
|
||||
for (let index = 0; index < 60; index++) {
|
||||
expect(measure()[0]?.top).toBe(230)
|
||||
}
|
||||
expect(serialize).not.toHaveBeenCalled()
|
||||
expect(coords).toHaveBeenCalledTimes(61 * markdownComments.length)
|
||||
})
|
||||
})
|
||||
+1
-24
@@ -1,13 +1,12 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { expect, it } from 'vitest'
|
||||
import { encodeRawMarkdownHtmlForRichEditor } from '../raw-source/raw-markdown-html'
|
||||
import { createRichMarkdownEditorCodec } from '../document/rich-markdown-source-transport'
|
||||
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
|
||||
import { getMarkdownRichModeUnsupportedReason } from '../../markdown/render-mode/markdown-rich-mode'
|
||||
import { commitRichMarkdownSerialization } from '../document/rich-markdown-serialization-commit'
|
||||
import { createRichMarkdownHtmlSuperscriptLinkContext } from '../citations/rich-markdown-html-superscript-link-context'
|
||||
import * as fenceScanner from '../../markdown/source-scanning/markdown-fence-scanner'
|
||||
|
||||
function createEditor(source: string): Editor {
|
||||
const codec = createRichMarkdownEditorCodec()
|
||||
@@ -81,17 +80,6 @@ it.each([200, 50_000, 50_001])(
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
' <span>indented</span>',
|
||||
'[link](https://example.com "<span>title</span>")',
|
||||
'[link](<span>)'
|
||||
])('blocks markup that the parser keeps as transport text: %s', (markup) => {
|
||||
for (const length of [200, 50_000, 50_001, 120_000]) {
|
||||
const source = `${'a'.repeat(length)}\n\n${markup}\n`
|
||||
expect(getMarkdownRichModeUnsupportedReason(source)).toBe('html-or-jsx')
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves markup in the edited paragraph through successive saves and reopening', () => {
|
||||
const markup = '<span title="keep">text</span> <sup><a href="https://example.com">1</a></sup>'
|
||||
const source = `${'a'.repeat(50_001)}\n\nBefore ${markup} after\n`
|
||||
@@ -142,14 +130,3 @@ it('preserves markup in the edited paragraph through successive saves and reopen
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('scans whole-document fence ranges once for thousands of passthrough details blocks', () => {
|
||||
const source = '<details class="custom"><summary>Toggle</summary>Body</details>\n\n'.repeat(4_000)
|
||||
const scan = vi.spyOn(fenceScanner, 'getMarkdownFenceRanges')
|
||||
try {
|
||||
expect(getMarkdownRichModeUnsupportedReason(source)).toBeNull()
|
||||
expect(scan.mock.calls.filter(([content]) => content === source)).toHaveLength(1)
|
||||
} finally {
|
||||
scan.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
-56
@@ -79,28 +79,6 @@ describe('rich markdown search focus handoff', () => {
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['button', 'input', 'textarea', 'select', 'noneditable', 'task-label'])(
|
||||
'leaves embedded %s controls in charge of focus',
|
||||
(kind) => {
|
||||
const { editorDom, findInput, focus } = createSurface()
|
||||
const control = document.createElement(
|
||||
kind === 'noneditable' ? 'div' : kind === 'task-label' ? 'label' : kind
|
||||
)
|
||||
if (kind === 'noneditable' || kind === 'task-label') {
|
||||
control.setAttribute('contenteditable', 'false')
|
||||
}
|
||||
const child = document.createElement('span')
|
||||
control.append(child)
|
||||
editorDom.append(control)
|
||||
findInput.focus()
|
||||
|
||||
child.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
|
||||
|
||||
expect(document.activeElement).toBe(findInput)
|
||||
expect(focus).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not steal focus from a different editor find widget or unrelated field', () => {
|
||||
const first = createSurface()
|
||||
const second = createSurface()
|
||||
@@ -116,36 +94,6 @@ describe('rich markdown search focus handoff', () => {
|
||||
expect(first.focus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves ordinary editor clicks and blank surface clicks untouched', () => {
|
||||
const { root, editorDom, paragraph, findInput, focus } = createSurface()
|
||||
editorDom.focus()
|
||||
focus.mockClear()
|
||||
paragraph.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
|
||||
findInput.focus()
|
||||
root.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
|
||||
|
||||
expect(focus).not.toHaveBeenCalled()
|
||||
expect(document.activeElement).toBe(findInput)
|
||||
})
|
||||
|
||||
it('leaves canceled and non-primary presses untouched', () => {
|
||||
const { paragraph, findInput, focus } = createSurface()
|
||||
findInput.focus()
|
||||
const canceled = new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 })
|
||||
canceled.preventDefault()
|
||||
|
||||
for (const event of [
|
||||
canceled,
|
||||
new MouseEvent('mousedown', { bubbles: true, button: 1 }),
|
||||
new MouseEvent('mousedown', { bubbles: true, button: 2 })
|
||||
]) {
|
||||
paragraph.dispatchEvent(event)
|
||||
}
|
||||
|
||||
expect(focus).not.toHaveBeenCalled()
|
||||
expect(document.activeElement).toBe(findInput)
|
||||
})
|
||||
|
||||
it('allows editable content nested inside an unrelated noneditable ancestor', () => {
|
||||
const { root, paragraph, findInput, focus } = createSurface()
|
||||
root.setAttribute('contenteditable', 'false')
|
||||
@@ -155,8 +103,4 @@ describe('rich markdown search focus handoff', () => {
|
||||
|
||||
expect(focus).toHaveBeenCalledExactlyOnceWith({ preventScroll: true })
|
||||
})
|
||||
|
||||
it('does nothing when the editor has not mounted', () => {
|
||||
focusRichMarkdownEditorFromSearch(new MouseEvent('mousedown', { button: 0 }), null)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,53 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildWheelGesturePoints,
|
||||
mapClientPointToSimulatorScreen,
|
||||
resolveEmulatorWheelDelta,
|
||||
resolveEmulatorPointerAction
|
||||
} from './emulator-screen-gesture'
|
||||
import { resolveVisualStreamGeometry } from './emulator-device-frame-layout'
|
||||
|
||||
const rect = {
|
||||
left: 10,
|
||||
top: 20,
|
||||
width: 200,
|
||||
height: 400
|
||||
}
|
||||
|
||||
describe('emulator screen gestures', () => {
|
||||
it.each([
|
||||
{ orientation: 'portrait', display: { x: 0.2, y: 0.7 } },
|
||||
{ orientation: 'landscape_left', display: { x: 0.3, y: 0.2 } },
|
||||
{ orientation: 'landscape_right', display: { x: 0.7, y: 0.8 } },
|
||||
{ orientation: 'portrait_upside_down', display: { x: 0.8, y: 0.3 } }
|
||||
] as const)(
|
||||
'round-trips a raw panel point through $orientation display geometry',
|
||||
({ orientation, display }) => {
|
||||
const geometry = resolveVisualStreamGeometry(
|
||||
{ width: 2007, height: 2853 },
|
||||
'portrait',
|
||||
orientation
|
||||
)
|
||||
const displayRect = {
|
||||
left: 10,
|
||||
top: 20,
|
||||
width: geometry.size?.width ?? 0,
|
||||
height: geometry.size?.height ?? 0
|
||||
}
|
||||
const point = mapClientPointToSimulatorScreen(
|
||||
{
|
||||
clientX: displayRect.left + display.x * displayRect.width,
|
||||
clientY: displayRect.top + display.y * displayRect.height
|
||||
},
|
||||
displayRect,
|
||||
geometry.size,
|
||||
geometry.streamRotation
|
||||
)
|
||||
expect(point?.x).toBeCloseTo(0.2)
|
||||
expect(point?.y).toBeCloseTo(0.7)
|
||||
}
|
||||
)
|
||||
|
||||
it('inverse rotates fallback taps, drag phases and the home edge into raw panel space', () => {
|
||||
const displayRect = { left: 0, top: 0, width: 400, height: 200 }
|
||||
expect(
|
||||
@@ -77,123 +35,6 @@ describe('emulator screen gestures', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ rotation: 90, x: -0.2, y: 0.1 },
|
||||
{ rotation: -90, x: 0.2, y: -0.1 },
|
||||
{ rotation: 180, x: 0.1, y: 0.2 }
|
||||
] as const)('inverse rotates wheel vectors for $rotation degrees', ({ rotation, x, y }) => {
|
||||
const delta = resolveEmulatorWheelDelta(
|
||||
{ clientX: 110, clientY: 220, deltaX: 20, deltaY: 80 },
|
||||
rect,
|
||||
null,
|
||||
1,
|
||||
rotation
|
||||
)
|
||||
expect(delta?.delta.x).toBeCloseTo(x)
|
||||
expect(delta?.delta.y).toBeCloseTo(y)
|
||||
})
|
||||
|
||||
it('maps client coordinates into normalized simulator coordinates', () => {
|
||||
expect(mapClientPointToSimulatorScreen({ clientX: 110, clientY: 220 }, rect, null)).toEqual({
|
||||
x: 0.5,
|
||||
y: 0.5
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps small movement as a tap', () => {
|
||||
const action = resolveEmulatorPointerAction(
|
||||
[
|
||||
{ clientX: 100, clientY: 200 },
|
||||
{ clientX: 103, clientY: 204 }
|
||||
],
|
||||
rect,
|
||||
null
|
||||
)
|
||||
|
||||
expect(action).toEqual({
|
||||
kind: 'tap',
|
||||
point: { x: 0.465, y: 0.46 }
|
||||
})
|
||||
})
|
||||
|
||||
it('turns a drag into a begin/move/end gesture sequence', () => {
|
||||
const action = resolveEmulatorPointerAction(
|
||||
[
|
||||
{ clientX: 110, clientY: 320 },
|
||||
{ clientX: 110, clientY: 260 },
|
||||
{ clientX: 110, clientY: 180 }
|
||||
],
|
||||
rect,
|
||||
null
|
||||
)
|
||||
|
||||
expect(action).toEqual({
|
||||
kind: 'gesture',
|
||||
points: [
|
||||
{ type: 'begin', x: 0.5, y: 0.75 },
|
||||
{ type: 'move', x: 0.5, y: 0.6 },
|
||||
{ type: 'end', x: 0.5, y: 0.4 }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('marks drags that start at the home indicator as bottom-edge gestures', () => {
|
||||
const action = resolveEmulatorPointerAction(
|
||||
[
|
||||
{ clientX: 110, clientY: 396 },
|
||||
{ clientX: 110, clientY: 260 },
|
||||
{ clientX: 110, clientY: 120 }
|
||||
],
|
||||
rect,
|
||||
null
|
||||
)
|
||||
|
||||
expect(action).toEqual({
|
||||
kind: 'gesture',
|
||||
points: [
|
||||
{ type: 'begin', x: 0.5, y: 0.94, edge: 3 },
|
||||
{ type: 'move', x: 0.5, y: 0.6, edge: 3 },
|
||||
{ type: 'end', x: 0.5, y: 0.25, edge: 3 }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('maps wheel deltas into opposite-direction touch movement', () => {
|
||||
const delta = resolveEmulatorWheelDelta(
|
||||
{ clientX: 110, clientY: 220, deltaX: 0, deltaY: 80 },
|
||||
rect,
|
||||
null,
|
||||
1
|
||||
)
|
||||
|
||||
expect(delta?.start).toEqual({ x: 0.5, y: 0.5 })
|
||||
expect(delta?.delta.x).toBeCloseTo(0)
|
||||
expect(delta?.delta.y).toBeCloseTo(-0.2)
|
||||
})
|
||||
|
||||
it('builds a wheel gesture with a move point', () => {
|
||||
expect(buildWheelGesturePoints({ x: 0.5, y: 0.5 }, { x: 0.5, y: 0.25 })).toEqual([
|
||||
{ type: 'begin', x: 0.5, y: 0.5 },
|
||||
{ type: 'move', x: 0.5, y: 0.375 },
|
||||
{ type: 'end', x: 0.5, y: 0.25 }
|
||||
])
|
||||
})
|
||||
|
||||
it('accounts for letterboxed stream content', () => {
|
||||
expect(
|
||||
mapClientPointToSimulatorScreen({ clientX: 110, clientY: 220 }, rect, {
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
).toEqual({ x: 0.5, y: 0.5 })
|
||||
expect(
|
||||
mapClientPointToSimulatorScreen({ clientX: 110, clientY: 30 }, rect, {
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('maps the full landscape screen while the stream canvas shape settles', () => {
|
||||
const landscapeRect = {
|
||||
left: 0,
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { GitHubWorkItem } from '../../../../shared/github/work-item-types'
|
||||
import type { TaskSourceContext } from '../../../../shared/task-source-context'
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
import { notifyWorkItemDetailsMutation } from './github-work-item-comment-mutations'
|
||||
import { runGHEditLabelToggle } from '../github-item-dialog/edit-item-fields/gh-edit-section-mutations'
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: vi.fn() }))
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: vi.fn(() => ({ repos: [] })) } }))
|
||||
@@ -100,24 +99,6 @@ describe('issue edits retain the displayed repository', () => {
|
||||
})
|
||||
expectProjectUpdate(sourceContext, { body: 'Project edit' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ state: 'closed' as const },
|
||||
{ addLabels: ['bug'] },
|
||||
{ addAssignees: ['upstream-assignee'] }
|
||||
])('keeps the Project field edit on the same exact actor: %j', async (updates) => {
|
||||
vi.mocked(window.api.gh.updateIssueBySlug).mockResolvedValue({ ok: true })
|
||||
await runIssueUpdate({
|
||||
repoPath: '/home/fixture/fork-widgets',
|
||||
repoId: item.repoId,
|
||||
sourceContext,
|
||||
projectOrigin,
|
||||
issueRepo: UPSTREAM,
|
||||
number: 12,
|
||||
updates
|
||||
})
|
||||
expectProjectUpdate(sourceContext, updates)
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -143,36 +124,4 @@ describe('issue edits retain the displayed repository', () => {
|
||||
)
|
||||
expect(window.api.gh.updateIssueBySlug).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ localLabels: [], updates: { addLabels: ['bug'] } },
|
||||
{ localLabels: ['bug'], updates: { removeLabels: ['bug'] } }
|
||||
])(
|
||||
'retains the target for both label toggle directions: %j',
|
||||
async ({ localLabels, updates }) => {
|
||||
let mutation: Promise<unknown> = Promise.resolve()
|
||||
runGHEditLabelToggle({
|
||||
item,
|
||||
itemNumber: item.number,
|
||||
itemRepoId: item.repoId,
|
||||
repoPath: 'C:\\workspace\\widgets',
|
||||
projectOrigin: undefined,
|
||||
issueRepo: ORIGIN,
|
||||
label: 'bug',
|
||||
localLabels,
|
||||
run: async (_key, options) => {
|
||||
mutation = options.mutate()
|
||||
await mutation
|
||||
},
|
||||
onLabelsChange: vi.fn(),
|
||||
patchProjectRowIfNeeded: vi.fn(),
|
||||
onMutated: vi.fn()
|
||||
})
|
||||
await mutation
|
||||
|
||||
expect(window.api.gh.updateIssue).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ownerRepo: ORIGIN, updates })
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { credentialDecryptionMessage } from '../../../shared/integration-credential-errors'
|
||||
import React, { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import type {
|
||||
LinearCustomViewSummary,
|
||||
LinearProjectSummary
|
||||
} from '../../../shared/linear/project-types'
|
||||
import {
|
||||
LinearCollectionNotice,
|
||||
LinearCustomViewTable,
|
||||
LinearProjectOverview,
|
||||
LinearProjectTable
|
||||
} from './linear-project-view-surfaces'
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
||||
|
||||
const roots: Root[] = []
|
||||
|
||||
afterEach(() => {
|
||||
roots.splice(0).forEach((root) => act(() => root.unmount()))
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
function render(element: React.ReactNode): HTMLElement {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
roots.push(root)
|
||||
act(() => root.render(<TooltipProvider>{element}</TooltipProvider>))
|
||||
return container
|
||||
}
|
||||
|
||||
const project: LinearProjectSummary = {
|
||||
id: 'project-1',
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceName: 'Acme',
|
||||
name: 'Compiler',
|
||||
url: 'https://linear.app/project/project-1',
|
||||
labels: [
|
||||
{ id: 'label-1', name: 'Alpha' },
|
||||
{ id: 'label-2', name: 'Beta' },
|
||||
{ id: 'label-3', name: 'Hidden' }
|
||||
],
|
||||
progress: 0.5,
|
||||
priority: 2,
|
||||
targetDate: 'invalid-date'
|
||||
}
|
||||
|
||||
describe('Linear project view surfaces', () => {
|
||||
it.each([
|
||||
['Reconnect Linear', 'Unable to load Linear issues. Try again.'],
|
||||
[
|
||||
credentialDecryptionMessage('Linear'),
|
||||
'Unable to read saved Linear login. Reconnect it in Settings.'
|
||||
]
|
||||
])('renders safe collection failure %s and controlled pagination', (message, expected) => {
|
||||
const onLoadMore = vi.fn()
|
||||
const container = render(
|
||||
<LinearCollectionNotice
|
||||
errors={[
|
||||
{
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceName: 'Acme',
|
||||
type: 'auth',
|
||||
message
|
||||
}
|
||||
]}
|
||||
hasMore
|
||||
count={50}
|
||||
kind="projects"
|
||||
onLoadMore={onLoadMore}
|
||||
/>
|
||||
)
|
||||
expect(container.textContent).toContain(expected)
|
||||
const button = container.querySelector('button')
|
||||
act(() => button?.dispatchEvent(new MouseEvent('click', { bubbles: true })))
|
||||
expect(onLoadMore).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps project row keyboard selection and nested actions independent', () => {
|
||||
const onSelectProject = vi.fn()
|
||||
const onOpenProject = vi.fn()
|
||||
const onUseProjectIssues = vi.fn()
|
||||
const container = render(
|
||||
<LinearProjectTable
|
||||
projects={[project]}
|
||||
loading={false}
|
||||
selectedProjectId="project-1"
|
||||
workspaceSelection="all"
|
||||
onSelectProject={onSelectProject}
|
||||
onOpenProject={onOpenProject}
|
||||
onUseProjectIssues={onUseProjectIssues}
|
||||
/>
|
||||
)
|
||||
const row = container.querySelector('[role="button"]') as HTMLElement
|
||||
expect(row.getAttribute('aria-current')).toBe('true')
|
||||
expect(row.dataset.current).toBe('true')
|
||||
expect(container.textContent).toContain('Acme')
|
||||
expect(container.textContent).toContain('Alpha')
|
||||
expect(container.textContent).toContain('Beta')
|
||||
expect(container.textContent).not.toContain('Hidden')
|
||||
act(() => row.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
|
||||
expect(onSelectProject).toHaveBeenCalledWith(project)
|
||||
|
||||
const issuesButton = container.querySelector('button[aria-label="Open Compiler issues"]')
|
||||
act(() => issuesButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })))
|
||||
expect(onUseProjectIssues).toHaveBeenCalledWith(project)
|
||||
expect(onSelectProject).toHaveBeenCalledTimes(1)
|
||||
|
||||
const openButton = container.querySelector('button[aria-label="Open Compiler in Linear"]')
|
||||
act(() => openButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })))
|
||||
expect(onOpenProject).toHaveBeenCalledWith(project)
|
||||
expect(onSelectProject).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('preserves custom-view metadata and overview action callbacks', () => {
|
||||
const view: LinearCustomViewSummary = {
|
||||
id: 'view-1',
|
||||
name: 'Triage',
|
||||
model: 'issue',
|
||||
shared: true,
|
||||
owner: { id: 'user-1', displayName: 'Ada' }
|
||||
}
|
||||
const onSelectView = vi.fn()
|
||||
const onOpenView = vi.fn()
|
||||
const views = render(
|
||||
<LinearCustomViewTable
|
||||
views={[view]}
|
||||
loading={false}
|
||||
onSelectView={onSelectView}
|
||||
onOpenView={onOpenView}
|
||||
/>
|
||||
)
|
||||
expect(views.textContent).toContain('Shared')
|
||||
expect(views.textContent).toContain('Ada')
|
||||
const row = views.querySelector('[role="button"]') as HTMLElement
|
||||
act(() => row.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })))
|
||||
expect(onSelectView).toHaveBeenCalledWith(view)
|
||||
|
||||
const onBack = vi.fn()
|
||||
const onRefresh = vi.fn()
|
||||
const onOpenIssues = vi.fn()
|
||||
const onOpenProject = vi.fn()
|
||||
const overview = render(
|
||||
<LinearProjectOverview
|
||||
project={project}
|
||||
loading={false}
|
||||
onBack={onBack}
|
||||
onRefresh={onRefresh}
|
||||
onOpenIssues={onOpenIssues}
|
||||
onOpenProject={onOpenProject}
|
||||
/>
|
||||
)
|
||||
expect(overview.textContent).toContain('Compiler')
|
||||
act(() =>
|
||||
overview
|
||||
.querySelector('button[aria-label="Back to projects"]')
|
||||
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
)
|
||||
expect(onBack).toHaveBeenCalledOnce()
|
||||
const refresh = [...overview.querySelectorAll('button')].find((button) =>
|
||||
button.textContent?.includes('Refresh')
|
||||
)
|
||||
act(() => refresh?.dispatchEvent(new MouseEvent('click', { bubbles: true })))
|
||||
expect(onRefresh).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -86,36 +86,6 @@ afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('re-reads once when the user replies in a failed chat, then lets the stream go', async () => {
|
||||
await listFailure()
|
||||
expect(mocks.subscribeStatus).toHaveBeenCalledOnce()
|
||||
const later = Date.now() + 10_000
|
||||
hostEmit()({ type: 'status', session: summary('working', 'Carry on please', later) })
|
||||
hostEmit()({ type: 'status', session: summary('idle', 'Carry on please', later + 1) })
|
||||
expect(offerReads()).toBe(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
expect(offerReads()).toBe(2)
|
||||
expect(getNativeChatRestartOffer().failed).toEqual([])
|
||||
expect(mocks.unsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not re-read for an agent streaming in a failed chat or for news older than the list', async () => {
|
||||
await listFailure({ failed: [failure] })
|
||||
const later = Date.now() + 10_000
|
||||
// Already reflected in the listing the store holds.
|
||||
hostEmit()({ type: 'snapshot', sessions: [summary('working', 'Replayed note', 1)] })
|
||||
hostEmit()({ type: 'status', session: summary('working', 'Replayed note', later) })
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(offerReads()).toBe(1)
|
||||
|
||||
hostEmit()({ type: 'status', session: summary('idle', 'Replayed note', later + 1) })
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(offerReads()).toBe(2)
|
||||
expect(mocks.unsubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Known chat changes are news despite older host timestamps because list and status delivery can race.
|
||||
it('re-reads for a known failed chat that changes even when its stamp predates the list', async () => {
|
||||
await listFailure()
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import { toast } from 'sonner'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping'
|
||||
import { lastToastShow } from './native-chat-resume-toast.test-support'
|
||||
import {
|
||||
consumeNativeChatResumeOnRestartDialogRequest,
|
||||
getNativeChatResumeOnRestartDialogRequest
|
||||
} from './native-chat-resume-on-restart-dialog'
|
||||
import {
|
||||
_resetNativeChatRestartOffer,
|
||||
continueNativeChatRestartOffer,
|
||||
getNativeChatRestartOffer,
|
||||
refreshNativeChatRestartOffer
|
||||
} from './native-chat-resume-on-restart-store'
|
||||
|
||||
const rpc = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: rpc,
|
||||
// A failed row opens the status feed; these cases never drive it.
|
||||
subscribeStructuredAgentSessionStatus: () => new Promise(() => {})
|
||||
}))
|
||||
vi.mock('sonner', async () =>
|
||||
(await import('./native-chat-resume-sonner-mock.test-support')).resumeSonnerMock()
|
||||
)
|
||||
|
||||
const offered: ResumeCandidate[] = ['a', 'b'].map((sessionId) => ({
|
||||
sessionId,
|
||||
workspaceId: 'workspace',
|
||||
agent: 'codex',
|
||||
trigger: 'quit',
|
||||
latestPrompt: `Prompt ${sessionId}`,
|
||||
recordedAt: 1_800_000_000_000
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
rpc.mockReset()
|
||||
_resetNativeChatRestartOffer()
|
||||
consumeNativeChatResumeOnRestartDialogRequest()
|
||||
vi.mocked(toast).mockClear()
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(console.warn).mockRestore()
|
||||
_resetNativeChatRestartOffer()
|
||||
})
|
||||
|
||||
/** The host lists both chats, then loses every later call. */
|
||||
function hostLostAfterListing(): void {
|
||||
let reachable = true
|
||||
rpc.mockImplementation(async (_target, method) => {
|
||||
if (method === 'agentSession.restartResumable' && reachable) {
|
||||
return { sessions: offered, failed: [] }
|
||||
}
|
||||
reachable = false
|
||||
throw new Error('host unreachable')
|
||||
})
|
||||
}
|
||||
|
||||
const resumes = {
|
||||
click: () => continueNativeChatRestartOffer(['a', 'b']),
|
||||
launch: () => continueNativeChatRestartOffer(offered.map((candidate) => candidate.sessionId))
|
||||
}
|
||||
|
||||
// An unavailable host cannot settle delivery; Review still opens the retained action summary.
|
||||
it.each(['click', 'launch'] as const)(
|
||||
'keeps every chat of a lost %s resume unconfirmed when the host cannot be read either',
|
||||
async (caller) => {
|
||||
hostLostAfterListing()
|
||||
await refreshNativeChatRestartOffer()
|
||||
await resumes[caller]()
|
||||
expect(vi.mocked(toast).mock.calls).toEqual([
|
||||
[
|
||||
'Couldn’t confirm 2 chats were resumed',
|
||||
expect.objectContaining({ action: expect.objectContaining({ label: 'Review' }) })
|
||||
]
|
||||
])
|
||||
expect(lastToastShow()).toBeDefined()
|
||||
lastToastShow()?.()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(getNativeChatResumeOnRestartDialogRequest()).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
// A fresh host offer is an observation, so the lost request still needs Review.
|
||||
it('answers an opted-in launch that loses its resume request with one unconfirmed toast', async () => {
|
||||
rpc.mockImplementation(async (_target, method) => {
|
||||
if (method === 'agentSession.restartResumable') {
|
||||
return { sessions: offered, failed: [] }
|
||||
}
|
||||
throw new Error('response lost')
|
||||
})
|
||||
await refreshNativeChatRestartOffer()
|
||||
await resumes.launch()
|
||||
expect(getNativeChatRestartOffer().failed.map((entry) => entry.sessionId)).toEqual(['a', 'b'])
|
||||
expect(vi.mocked(toast).mock.calls.map(([title]) => title)).toEqual([
|
||||
'Couldn’t confirm 2 chats were resumed'
|
||||
])
|
||||
expect(lastToastShow()).toBeDefined()
|
||||
})
|
||||
|
||||
it('answers a mixed opted-in launch with one toast', async () => {
|
||||
const failure = { ...offered[1]!, failedAt: 1, outcome: 'refused', reason: 'unknown' }
|
||||
let acted = false
|
||||
rpc.mockImplementation(async (_target, method, params: { sessionIds: string[] } | undefined) => {
|
||||
const failed = acted ? [failure] : []
|
||||
if (method === 'agentSession.restartResumable') {
|
||||
return { sessions: acted ? [] : offered, failed }
|
||||
}
|
||||
acted = true
|
||||
return {
|
||||
continued: (params?.sessionIds ?? []).map((sessionId) => ({
|
||||
sessionId,
|
||||
outcome: sessionId === 'b' ? 'refused' : 'continued'
|
||||
})),
|
||||
sessions: [],
|
||||
failed: [failure]
|
||||
}
|
||||
})
|
||||
await refreshNativeChatRestartOffer()
|
||||
await resumes.launch()
|
||||
expect(vi.mocked(toast).mock.calls).toEqual([
|
||||
['1 chat couldn’t be resumed', expect.objectContaining({ description: 'Resumed 1 chat' })]
|
||||
])
|
||||
expect(lastToastShow()).toBeDefined()
|
||||
})
|
||||
|
||||
it('raises no toast when the host had nothing left for an opted-in launch to resume', async () => {
|
||||
rpc.mockImplementation(async (_target, method) =>
|
||||
method === 'agentSession.restartResumable'
|
||||
? { sessions: offered, failed: [] }
|
||||
: { continued: [], sessions: [], failed: [] }
|
||||
)
|
||||
await refreshNativeChatRestartOffer()
|
||||
await resumes.launch()
|
||||
expect(toast).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the retained summary from Show after the host withdraws a failure', async () => {
|
||||
let failed = [{ ...offered[0]!, failedAt: 1, outcome: 'unconfirmed', reason: 'unknown' }]
|
||||
rpc.mockImplementation(async (_target, method) =>
|
||||
method === 'agentSession.restartResumable'
|
||||
? { sessions: [], failed }
|
||||
: { continued: [{ sessionId: 'a', outcome: 'unknown' }], sessions: [], failed }
|
||||
)
|
||||
await refreshNativeChatRestartOffer()
|
||||
await continueNativeChatRestartOffer(['a'])
|
||||
expect(vi.mocked(toast).mock.calls.map(([title]) => title)).toEqual([
|
||||
'Couldn’t confirm 1 chat was resumed'
|
||||
])
|
||||
failed = []
|
||||
lastToastShow()?.()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(rpc.mock.calls.at(-1)?.[1]).toBe('agentSession.restartResumable')
|
||||
expect(getNativeChatRestartOffer().failed).toEqual([])
|
||||
expect(getNativeChatResumeOnRestartDialogRequest()).toBe(true)
|
||||
})
|
||||
@@ -3,8 +3,8 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { loadSyntaxLanguage } from '@/lib/syntax-highlighting/syntax-highlighter'
|
||||
import { NativeChatCodeBlock, NativeChatPlainCodeBlock } from './NativeChatCodeBlock'
|
||||
|
||||
import { NativeChatCodeBlock } from './NativeChatCodeBlock'
|
||||
|
||||
vi.mock('@/lib/syntax-highlighting/oniguruma', async () => ({
|
||||
loadOniguruma: (await import('@/lib/syntax-highlighting/oniguruma-test-harness'))
|
||||
@@ -38,64 +38,6 @@ describe('NativeChatCodeBlock', () => {
|
||||
expect(second.container.querySelector('pre')?.textContent).toBe(code)
|
||||
})
|
||||
|
||||
it('leaves the code of a dimmed row uncolored', async () => {
|
||||
const code = <code>{'const dimmed = 1\n'}</code>
|
||||
await loadSyntaxLanguage('typescript')
|
||||
const colored = render(<NativeChatCodeBlock language="typescript">{code}</NativeChatCodeBlock>)
|
||||
expect(colored.container.querySelector('pre span')).not.toBeNull()
|
||||
|
||||
const plain = render(
|
||||
<NativeChatPlainCodeBlock language="typescript">{code}</NativeChatPlainCodeBlock>
|
||||
)
|
||||
|
||||
expect(plain.container.querySelector('pre span')).toBeNull()
|
||||
expect(plain.container.querySelector('pre')?.textContent).toBe('const dimmed = 1\n')
|
||||
})
|
||||
|
||||
it('leaves a fence in an unknown language as plain text', async () => {
|
||||
const { container } = render(
|
||||
<NativeChatCodeBlock language="not-a-real-language">
|
||||
<code>{'plain words\n'}</code>
|
||||
</NativeChatCodeBlock>
|
||||
)
|
||||
const pre = container.querySelector('pre')
|
||||
|
||||
await waitFor(() => expect(pre?.textContent).toBe('plain words\n'))
|
||||
expect(pre?.querySelector('span')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['with a language header', 'typescript'],
|
||||
['without a language header', undefined]
|
||||
])('wraps long lines only after the reader turns wrapping on (%s)', (_name, language) => {
|
||||
const { container } = render(
|
||||
<NativeChatCodeBlock language={language}>
|
||||
<code>{'const longLine = 1\n'}</code>
|
||||
</NativeChatCodeBlock>
|
||||
)
|
||||
const pre = container.querySelector('pre')
|
||||
|
||||
const wrapOn = screen.getByRole('button', { name: 'Wrap lines' })
|
||||
expect(wrapOn).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(pre).toHaveClass('overflow-x-auto')
|
||||
expect(pre).not.toHaveClass('whitespace-pre-wrap')
|
||||
|
||||
fireEvent.click(wrapOn)
|
||||
|
||||
const wrapOff = screen.getByRole('button', { name: 'Disable line wrap' })
|
||||
expect(wrapOff).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(pre).toHaveClass('whitespace-pre-wrap', '[overflow-wrap:anywhere]')
|
||||
expect(pre).not.toHaveClass('overflow-x-auto')
|
||||
|
||||
fireEvent.click(wrapOff)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Wrap lines' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'false'
|
||||
)
|
||||
expect(pre).toHaveClass('overflow-x-auto')
|
||||
})
|
||||
|
||||
it('colors and wraps fenced code, copies its text, and confirms success', async () => {
|
||||
const writeClipboardText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.assign(window, { api: { ui: { writeClipboardText } } })
|
||||
@@ -120,28 +62,4 @@ describe('NativeChatCodeBlock', () => {
|
||||
await waitFor(() => expect(writeClipboardText).toHaveBeenCalledWith(code))
|
||||
expect(await screen.findByRole('button', { name: 'Copied' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps wrapping to the block the reader toggled', () => {
|
||||
render(
|
||||
<>
|
||||
<NativeChatCodeBlock language="typescript">
|
||||
<code>{'first\n'}</code>
|
||||
</NativeChatCodeBlock>
|
||||
<NativeChatCodeBlock language="typescript">
|
||||
<code>{'second\n'}</code>
|
||||
</NativeChatCodeBlock>
|
||||
</>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Wrap lines' })[0])
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Disable line wrap' })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button', { name: 'Wrap lines' })).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows no wrap control for an empty block', () => {
|
||||
render(<NativeChatCodeBlock language="typescript" />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Wrap lines' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { useAppStore } from '@/store'
|
||||
@@ -82,60 +82,3 @@ it('offers chat actions without a duplicate close button and targets Resume at t
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('changes the saved chat width from the standalone chat toolbar', async () => {
|
||||
const settings = {
|
||||
...getDefaultSettings('/tmp'),
|
||||
nativeChatAppearance: { fontSize: 16, width: 'wide' as const }
|
||||
}
|
||||
const updateSettings = vi.fn(async (updates) => {
|
||||
useAppStore.setState({ settings: { ...settings, ...updates } })
|
||||
})
|
||||
useAppStore.setState({ settings, updateSettings })
|
||||
renderHeader()
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: 'Chat settings' }), { key: 'Enter' })
|
||||
expect(screen.getByRole('menuitemradio', { name: 'Wide' }).getAttribute('aria-checked')).toBe(
|
||||
'true'
|
||||
)
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: 'Full' }))
|
||||
await waitFor(() =>
|
||||
expect(updateSettings).toHaveBeenCalledWith({
|
||||
nativeChatAppearance: { fontSize: 16, width: 'full' }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
async function splitTooltipText(): Promise<string | null> {
|
||||
const trigger = screen.getByRole('button', { name: 'Split Chat Right' })
|
||||
fireEvent.pointerMove(trigger, { pointerType: 'mouse' })
|
||||
return (await screen.findByRole('tooltip')).textContent
|
||||
}
|
||||
|
||||
it('shows the effective split-right shortcut in the Split Chat Right tooltip', async () => {
|
||||
useAppStore.setState({ keybindings: { 'terminal.splitRight': ['Mod+Alt+K'] } })
|
||||
renderHeader()
|
||||
const shortcut = navigator.userAgent.includes('Mac') ? '⌘⌥K' : 'Ctrl+Alt+K'
|
||||
expect(await splitTooltipText()).toBe(`Split Chat Right: ${shortcut}`)
|
||||
expect(screen.getByRole('button', { name: 'Split Chat Right' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Mac', '⌘D, ⌘K'],
|
||||
['Windows', 'Ctrl+D, Ctrl+K'],
|
||||
['Linux', 'Ctrl+D, Ctrl+K']
|
||||
])('separates alternative split shortcuts on %s', async (userAgent, shortcuts) => {
|
||||
const userAgentSpy = vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(userAgent)
|
||||
try {
|
||||
useAppStore.setState({ keybindings: { 'terminal.splitRight': ['Mod+D', 'Mod+K'] } })
|
||||
renderHeader()
|
||||
expect(await splitTooltipText()).toBe(`Split Chat Right: ${shortcuts}`)
|
||||
} finally {
|
||||
userAgentSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('omits the shortcut from the Split Chat Right tooltip when split-right is unbound', async () => {
|
||||
useAppStore.setState({ keybindings: { 'terminal.splitRight': [] } })
|
||||
renderHeader()
|
||||
expect(await splitTooltipText()).toBe('Split Chat Right')
|
||||
})
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { useMemo } from 'react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import type { AgentSessionStatusSummary } from '../../../../shared/agent-session/agent-session-wire'
|
||||
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
|
||||
import { NATIVE_CHAT_USAGE_LIMIT_MAX_OVERDUE_MS } from '../../../../shared/native-chat/native-chat-usage-limit-cut'
|
||||
import { NativeChatNoticeRow } from './NativeChatNoticeRow'
|
||||
import { NativeChatOrcaStopContext } from './native-chat-orca-stop-context'
|
||||
import { useNativeChatUsageLimitAutoContinue } from './use-native-chat-usage-limit-auto-continue'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state: {
|
||||
capability: string
|
||||
summary: Pick<AgentSessionStatusSummary, 'usageLimitContinue'> | undefined
|
||||
} = { capability: 'supported', summary: undefined }
|
||||
return Object.assign(state, {
|
||||
call: vi.fn(async () => ({ ok: true })),
|
||||
feed: { activate: () => () => {}, subscribe: () => () => {}, getSnapshot: () => new Map() }
|
||||
})
|
||||
})
|
||||
vi.mock('@/runtime/structured-agent-session-host-capability', () => ({
|
||||
useStructuredAgentSessionHostCapabilityState: () => mocks.capability
|
||||
}))
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: mocks.call
|
||||
}))
|
||||
vi.mock('@/runtime/structured-agent-session-status-feed', () => ({
|
||||
getStructuredAgentSessionStatusFeed: () => mocks.feed
|
||||
}))
|
||||
vi.mock('./NativeChatUsageLimitActions', () => ({ NativeChatUsageLimitActions: () => null }))
|
||||
|
||||
const reset = 1_800_000_000_000
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(reset - 60_000)
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
const LOCAL: RuntimeClientTarget = { kind: 'local' }
|
||||
function Row({ target = LOCAL, known = true }: { target?: RuntimeClientTarget; known?: boolean }) {
|
||||
const state = useNativeChatUsageLimitAutoContinue(target, 'chat', {
|
||||
turnItemId: 'cut',
|
||||
turnEndedAt: reset - 120_000,
|
||||
...(known ? { resetsAt: reset } : {})
|
||||
})
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
hostLabel: null,
|
||||
remoteHost: target.kind !== 'local',
|
||||
continueAvailable: false,
|
||||
offeredTurnItemId: null,
|
||||
continueNow: () => {},
|
||||
usageLimitTurnItemId: 'cut',
|
||||
continueUsageLimit: () => {},
|
||||
usageLimitAutoContinue: state
|
||||
}),
|
||||
[state, target.kind]
|
||||
)
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<NativeChatOrcaStopContext.Provider value={value}>
|
||||
<NativeChatNoticeRow
|
||||
block={{
|
||||
type: 'text',
|
||||
text: 'Usage limit',
|
||||
usageLimitTurnItemId: 'cut',
|
||||
failure: { kind: 'usageLimitReached', usageLimit: { resetsAt: reset } }
|
||||
}}
|
||||
/>
|
||||
</NativeChatOrcaStopContext.Provider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
function setState(state: NonNullable<AgentSessionStatusSummary['usageLimitContinue']>['state']) {
|
||||
mocks.capability = 'supported'
|
||||
mocks.summary = { usageLimitContinue: { turnItemId: 'cut', resetsAt: reset, state } }
|
||||
const snapshot = new Map([['chat', mocks.summary]])
|
||||
mocks.feed.getSnapshot = () => snapshot
|
||||
}
|
||||
|
||||
it('shows the setting link, scheduled cancel action, and cancellation in the usage-limit row', async () => {
|
||||
setState('off')
|
||||
const view = render(<Row />)
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Continue automatically when limits reset' })
|
||||
).toBeInTheDocument()
|
||||
setState('scheduled')
|
||||
view.rerender(<Row />)
|
||||
expect(screen.getByText(/Orca will continue this chat at/)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: "Don't continue" }))
|
||||
await waitFor(() =>
|
||||
expect(mocks.call).toHaveBeenCalledWith(
|
||||
{ kind: 'local' },
|
||||
'agentSession.cancelUsageLimitContinue',
|
||||
{ sessionId: 'chat', turnItemId: 'cut', resetsAt: reset }
|
||||
)
|
||||
)
|
||||
setState('cancelled')
|
||||
view.rerender(<Row />)
|
||||
expect(screen.getByText("Orca won't continue this chat automatically.")).toBeInTheDocument()
|
||||
setState('failed')
|
||||
view.rerender(<Row />)
|
||||
expect(screen.getByText("Orca won't continue this chat automatically.")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides new controls on old hosts, missing resets, stale summaries, and remote off states', () => {
|
||||
setState('scheduled')
|
||||
mocks.capability = 'unsupported'
|
||||
const view = render(<Row />)
|
||||
expect(screen.queryByText(/Orca will continue/)).toBeNull()
|
||||
mocks.capability = 'supported'
|
||||
view.rerender(<Row known={false} />)
|
||||
expect(screen.queryByText(/Orca will continue/)).toBeNull()
|
||||
setState('off')
|
||||
view.rerender(<Row target={{ kind: 'environment', environmentId: 'server' }} />)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Continue automatically when limits reset' })
|
||||
).toBeNull()
|
||||
mocks.summary!.usageLimitContinue!.turnItemId = 'old-cut'
|
||||
view.rerender(<Row />)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Continue automatically when limits reset' })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('shows only the manual actions once a cut is more than a day past its reset, setting on or off', () => {
|
||||
vi.setSystemTime(reset + NATIVE_CHAT_USAGE_LIMIT_MAX_OVERDUE_MS + 1_000)
|
||||
// A summary from before the cutoff: 'off' with the setting off, 'scheduled' with it on.
|
||||
for (const state of ['off', 'scheduled'] as const) {
|
||||
setState(state)
|
||||
const view = render(<Row />)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Continue automatically when limits reset' })
|
||||
).toBeNull()
|
||||
expect(screen.queryByText(/Orca will continue/)).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: "Don't continue" })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Continue Now' })).toBeInTheDocument()
|
||||
view.unmount()
|
||||
}
|
||||
})
|
||||
-180
@@ -1,180 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AiVaultListResult } from '../../../../../../shared/ai-vault/contracts/ai-vault-types'
|
||||
import {
|
||||
aiVaultScanNoticeIssues,
|
||||
blockingAiVaultScanIssue,
|
||||
skippedAiVaultTranscriptCount,
|
||||
skippedAiVaultTranscriptReasons
|
||||
} from './ai-vault-scan-issue-state'
|
||||
|
||||
describe('blockingAiVaultScanIssue', () => {
|
||||
it('surfaces the cause when a scan returns no sessions', () => {
|
||||
const issue = {
|
||||
executionHostId: 'ssh:dev-box' as const,
|
||||
agent: 'codex' as const,
|
||||
kind: 'host' as const,
|
||||
path: 'dev-box',
|
||||
message: 'Remote connection dropped. Reconnect the SSH target.'
|
||||
}
|
||||
|
||||
expect(blockingAiVaultScanIssue(result([], [issue]))).toEqual(issue)
|
||||
})
|
||||
|
||||
it('leaves partial-result issues as a skipped transcript count', () => {
|
||||
expect(
|
||||
blockingAiVaultScanIssue(
|
||||
result(
|
||||
[{ id: 'session' }],
|
||||
[{ agent: 'codex', path: '/bad.jsonl', message: 'Malformed transcript' }]
|
||||
)
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not block an empty scan for a skipped transcript', () => {
|
||||
expect(
|
||||
blockingAiVaultScanIssue(
|
||||
result([], [{ agent: 'codex', path: '/bad.jsonl', message: 'Malformed transcript' }])
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiVaultScanNoticeIssues', () => {
|
||||
it('surfaces scope truncation without counting it as a skipped transcript', () => {
|
||||
const scopeIssue = {
|
||||
agent: 'codex' as const,
|
||||
kind: 'scope' as const,
|
||||
path: '/home/ada',
|
||||
message: 'Only the first 64 project paths were scanned.'
|
||||
}
|
||||
const truncated = result([], [scopeIssue])
|
||||
|
||||
expect(blockingAiVaultScanIssue(truncated)).toBeNull()
|
||||
expect(aiVaultScanNoticeIssues(truncated)).toEqual([scopeIssue])
|
||||
expect(skippedAiVaultTranscriptCount(truncated)).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps kinded issues as notices and counts only transcripts as skipped', () => {
|
||||
const hostIssue = {
|
||||
executionHostId: 'ssh:dev-box' as const,
|
||||
agent: 'codex' as const,
|
||||
kind: 'host' as const,
|
||||
path: 'dev-box',
|
||||
message: 'Remote connection dropped.'
|
||||
}
|
||||
const scopeIssue = {
|
||||
agent: 'codex' as const,
|
||||
kind: 'scope' as const,
|
||||
path: '/home/ada',
|
||||
message: 'Only the first 64 project paths were scanned.'
|
||||
}
|
||||
const partial = result(
|
||||
[{ id: 'session' }],
|
||||
[hostIssue, scopeIssue, { agent: 'codex', path: '/bad.jsonl', message: 'Malformed' }]
|
||||
)
|
||||
|
||||
expect(aiVaultScanNoticeIssues(partial)).toEqual([hostIssue, scopeIssue])
|
||||
expect(skippedAiVaultTranscriptCount(partial)).toBe(1)
|
||||
})
|
||||
|
||||
// An unreadable OpenCode database hides every session; report the source failure, not one skipped transcript (#15036).
|
||||
it('gives an unreadable OpenCode database its own row instead of a skipped count', () => {
|
||||
const sourceIssue = {
|
||||
agent: 'opencode' as const,
|
||||
kind: 'scope' as const,
|
||||
path: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\opencode\\opencode.db',
|
||||
message:
|
||||
'OpenCode is writing to opencode.db right now, so its history was skipped. It is read again on the next refresh.'
|
||||
}
|
||||
const empty = result([], [sourceIssue])
|
||||
|
||||
expect(skippedAiVaultTranscriptCount(empty)).toBe(0)
|
||||
expect(skippedAiVaultTranscriptReasons(empty)).toEqual([])
|
||||
expect(aiVaultScanNoticeIssues(empty)).toEqual([sourceIssue])
|
||||
})
|
||||
|
||||
it('does not repeat the blocking issue as a notice row', () => {
|
||||
const hostIssue = {
|
||||
executionHostId: 'ssh:dev-box' as const,
|
||||
agent: 'codex' as const,
|
||||
kind: 'host' as const,
|
||||
path: 'dev-box',
|
||||
message: 'Remote connection dropped.'
|
||||
}
|
||||
|
||||
expect(aiVaultScanNoticeIssues(result([], [hostIssue]))).toEqual([])
|
||||
})
|
||||
|
||||
it('reports nothing before the first scan', () => {
|
||||
expect(aiVaultScanNoticeIssues(null)).toEqual([])
|
||||
expect(skippedAiVaultTranscriptCount(null)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('skippedAiVaultTranscriptReasons', () => {
|
||||
it('surfaces the file-too-large reason behind a skipped transcript', () => {
|
||||
expect(
|
||||
skippedAiVaultTranscriptReasons(
|
||||
result(
|
||||
[{ id: 'session' }],
|
||||
[
|
||||
{
|
||||
agent: 'claude',
|
||||
path: '/home/dev/.claude/projects/a/huge.jsonl',
|
||||
message: 'File too large: 12.4MB exceeds 10MB limit'
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
).toEqual(['File too large: 12.4MB exceeds 10MB limit'])
|
||||
})
|
||||
|
||||
it('leaves host and scope notices to their own rows', () => {
|
||||
expect(
|
||||
skippedAiVaultTranscriptReasons(
|
||||
result(
|
||||
[],
|
||||
[
|
||||
{
|
||||
agent: 'codex',
|
||||
kind: 'scope',
|
||||
path: '/home/dev',
|
||||
message: 'Only the first 64 project paths were scanned.'
|
||||
},
|
||||
{ agent: 'codex', kind: 'host', path: 'dev-box', message: 'Reconnect the SSH target.' }
|
||||
]
|
||||
)
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('dedupes repeats and caps the list so a 500-issue scan stays readable', () => {
|
||||
const issues = Array.from({ length: 40 }, (_unused, index) => ({
|
||||
agent: 'codex' as const,
|
||||
path: `/transcripts/${index}.jsonl`,
|
||||
message: `Unreadable transcript ${index % 5}`
|
||||
}))
|
||||
|
||||
expect(skippedAiVaultTranscriptReasons(result([], issues))).toEqual([
|
||||
'Unreadable transcript 0',
|
||||
'Unreadable transcript 1',
|
||||
'Unreadable transcript 2'
|
||||
])
|
||||
})
|
||||
|
||||
it('reports nothing before the first scan', () => {
|
||||
expect(skippedAiVaultTranscriptReasons(null)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
function result(
|
||||
sessions: { id: string }[],
|
||||
issues: AiVaultListResult['issues']
|
||||
): AiVaultListResult {
|
||||
return {
|
||||
sessions: sessions as AiVaultListResult['sessions'],
|
||||
issues,
|
||||
scannedAt: '2026-07-26T00:00:00.000Z'
|
||||
}
|
||||
}
|
||||
-177
@@ -1,177 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AiVaultSession } from '../../../../../../shared/ai-vault/contracts/ai-vault-types'
|
||||
import { resolveAiVaultSessionResumeInChatEligibility } from './ai-vault-session-resume-in-chat'
|
||||
|
||||
type ResumeInChatSession = Parameters<
|
||||
typeof resolveAiVaultSessionResumeInChatEligibility
|
||||
>[0]['session']
|
||||
|
||||
const WORKSPACE_PATH = '/repo/orca'
|
||||
|
||||
function session(overrides: Partial<ResumeInChatSession> = {}): ResumeInChatSession {
|
||||
return {
|
||||
agent: 'claude',
|
||||
cwd: WORKSPACE_PATH,
|
||||
filePath: '/home/dev/.claude/projects/-repo-orca/session-1.jsonl',
|
||||
executionHostId: 'local',
|
||||
messageCount: 12,
|
||||
previewMessages: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function eligibility(
|
||||
overrides: Partial<Parameters<typeof resolveAiVaultSessionResumeInChatEligibility>[0]> = {}
|
||||
) {
|
||||
return resolveAiVaultSessionResumeInChatEligibility({
|
||||
session: session(),
|
||||
targetWorkspaceId: 'repo-1::/repo/orca',
|
||||
targetWorkspacePath: WORKSPACE_PATH,
|
||||
structuredRouteAvailable: true,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('resolveAiVaultSessionResumeInChatEligibility', () => {
|
||||
it('refuses a row already adopted into a chat before any other check', () => {
|
||||
expect(
|
||||
eligibility({
|
||||
session: {
|
||||
...session(),
|
||||
structuredSession: { sessionId: 'claude_1', workspaceId: 'repo-1::/repo/orca' }
|
||||
}
|
||||
})
|
||||
).toEqual({ available: false, reason: 'already-structured' })
|
||||
})
|
||||
|
||||
it.each(['ssh:build-box', 'runtime:unresolved-owner'] as const)(
|
||||
'refuses a row recorded on %s',
|
||||
(executionHostId) => {
|
||||
expect(eligibility({ session: session({ executionHostId }) })).toEqual({
|
||||
available: false,
|
||||
reason: 'remote'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.each<AiVaultSession['agent']>(['claude', 'codex'])(
|
||||
'offers a paired-runtime %s conversation when its route is admitted',
|
||||
(agent) => {
|
||||
expect(
|
||||
eligibility({ session: session({ agent, executionHostId: 'runtime:server-1' }) })
|
||||
).toMatchObject({ available: true })
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects WSL transcripts on a paired runtime too', () => {
|
||||
expect(
|
||||
eligibility({
|
||||
session: session({
|
||||
executionHostId: 'runtime:server-1',
|
||||
filePath: '//wsl.localhost/Ubuntu/home/dev/.claude/projects/p/session.jsonl'
|
||||
})
|
||||
})
|
||||
).toEqual({ available: false, reason: 'remote' })
|
||||
})
|
||||
|
||||
it('refuses when the same pair could not take the structured route for a fresh chat', () => {
|
||||
expect(eligibility({ structuredRouteAvailable: false })).toEqual({
|
||||
available: false,
|
||||
reason: 'workspace'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace matching, which binds the agents that store conversations per folder', () => {
|
||||
it('accepts host correspondence for cwd from recorded Claude output', () => {
|
||||
const capture = readFileSync(
|
||||
new URL(
|
||||
'../../../../../../main/claude/__fixtures__/claude-adapter-capture-early-steer.jsonl',
|
||||
import.meta.url
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
const cwds = capture
|
||||
.trim()
|
||||
.split('\n')
|
||||
.flatMap((line) => {
|
||||
const event: unknown = JSON.parse(line)
|
||||
if (typeof event !== 'object' || !event || !('frame' in event)) {
|
||||
return []
|
||||
}
|
||||
const frame = event.frame
|
||||
return typeof frame === 'object' && frame && 'cwd' in frame && typeof frame.cwd === 'string'
|
||||
? [frame.cwd]
|
||||
: []
|
||||
})
|
||||
expect(cwds).toEqual(['/tmp/scrubbed-cwd'])
|
||||
const cwd = cwds[0]
|
||||
expect(
|
||||
eligibility({
|
||||
session: session({ cwd, cwdAliases: [`/private${cwd}`] }),
|
||||
targetWorkspacePath: `/private${cwd}`
|
||||
}).available
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it.each<AiVaultSession['agent']>(['claude', 'grok', 'omp', 'opencode', 'opencode2', 'pi'])(
|
||||
'offers a %s row when its host proves the registered spelling of cwd',
|
||||
(agent) => {
|
||||
const row = {
|
||||
...session({ agent, cwd: '/private/var/example' }),
|
||||
cwdAliases: ['/var/example']
|
||||
}
|
||||
expect(eligibility({ session: row, targetWorkspacePath: '/var/example' })).toMatchObject({
|
||||
available: true
|
||||
})
|
||||
expect(eligibility({ session: row, targetWorkspacePath: '/var' })).toEqual({
|
||||
available: false,
|
||||
reason: 'workspace'
|
||||
})
|
||||
expect(eligibility({ session: row, structuredRouteAvailable: false })).toEqual({
|
||||
available: false,
|
||||
reason: 'workspace'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.each<AiVaultSession['agent']>(['claude', 'grok', 'omp', 'opencode', 'opencode2', 'pi'])(
|
||||
'refuses a %s row whose conversation was recorded in another workspace',
|
||||
(agent) => {
|
||||
// These key conversations by launch cwd, so resuming elsewhere finds or loads nothing.
|
||||
expect(
|
||||
eligibility({
|
||||
session: session({ agent, cwd: '/repo/other' }),
|
||||
targetWorkspacePath: WORKSPACE_PATH
|
||||
})
|
||||
).toEqual({ available: false, reason: 'workspace' })
|
||||
}
|
||||
)
|
||||
|
||||
it('refuses a Claude row that recorded no cwd', () => {
|
||||
expect(eligibility({ session: session({ cwd: null }) })).toEqual({
|
||||
available: false,
|
||||
reason: 'workspace'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps Codex available in a different workspace, and with no recorded cwd', () => {
|
||||
// Codex is handed the rollout file and a cwd, so it resumes anywhere.
|
||||
expect(eligibility({ session: session({ agent: 'codex', cwd: '/repo/other' }) })).toMatchObject(
|
||||
{ available: true }
|
||||
)
|
||||
expect(eligibility({ session: session({ agent: 'codex', cwd: null }) })).toMatchObject({
|
||||
available: true
|
||||
})
|
||||
})
|
||||
|
||||
it('treats Windows spellings of one directory as the same workspace', () => {
|
||||
expect(
|
||||
eligibility({
|
||||
session: session({ cwd: 'C:\\Users\\Dev\\repo\\Orca\\' }),
|
||||
targetWorkspacePath: 'c:/users/dev/repo/orca'
|
||||
})
|
||||
).toMatchObject({ available: true })
|
||||
})
|
||||
})
|
||||
-53
@@ -44,59 +44,6 @@ function fixture(root = '/repo') {
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('linked-directory watcher invalidation', () => {
|
||||
it.each(['/repo', 'C:\\Repo', '\\\\host\\share\\repo'])(
|
||||
'refreshes a collapsed cached alias even when canonical target is not cached (%s)',
|
||||
(root) => {
|
||||
const f = fixture(root)
|
||||
f.process([{ kind: 'create', absolutePath: joinPath(root, 'target/new.ts') }])
|
||||
expect(f.refreshTree).toHaveBeenCalledOnce()
|
||||
expect(f.refreshDir).not.toHaveBeenCalled()
|
||||
expect(f.cache[f.link].children).toHaveLength(1)
|
||||
expect(f.selected()).toBe(joinPath(f.link, 'old.ts'))
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ kind: 'update' as const, absolutePath: '/repo/target/new.ts', isDirectory: false },
|
||||
{ kind: 'update' as const, absolutePath: '/repo/target', isDirectory: true },
|
||||
{ kind: 'update' as const, absolutePath: '/repo/link', isDirectory: false },
|
||||
{ kind: 'delete' as const, absolutePath: '/repo/target/deleted.ts' },
|
||||
{
|
||||
kind: 'rename' as const,
|
||||
absolutePath: '/repo/target/new.ts',
|
||||
oldAbsolutePath: '/repo/target/old.ts'
|
||||
}
|
||||
])('refreshes structural uncertainty including link retarget: %j', (event) => {
|
||||
const f = fixture()
|
||||
f.process([event])
|
||||
expect(f.refreshTree).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps known regular content edits cheap and honors node directory/symlink flags', () => {
|
||||
const f = fixture('C:\\Repo')
|
||||
const target = 'C:\\Repo\\Target'
|
||||
f.cache[target] = { children: [node(`${target}\\file.ts`), node(`${target}\\dir`, true)] }
|
||||
f.process([{ kind: 'update', absolutePath: 'c:/repo/target/FILE.ts', isDirectory: false }])
|
||||
expect(f.refreshTree).not.toHaveBeenCalled()
|
||||
expect(f.refreshDir).not.toHaveBeenCalled()
|
||||
f.process([{ kind: 'update', absolutePath: 'c:/repo/target/DIR', isDirectory: false }])
|
||||
expect(f.refreshTree).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps cached historical aliases refreshed with the Quick Open preference disabled', () => {
|
||||
const f = fixture()
|
||||
const event = { kind: 'create' as const, absolutePath: '/repo/target/new.ts' }
|
||||
f.process([event], false)
|
||||
expect(f.refreshTree).toHaveBeenCalledOnce()
|
||||
f.refreshTree.mockClear()
|
||||
f.process([{ ...event, absolutePath: '/other/new.ts' }])
|
||||
f.process([event], true, '/other')
|
||||
expect(f.refreshTree).not.toHaveBeenCalled()
|
||||
delete f.cache[f.link]
|
||||
f.process([event])
|
||||
expect(f.refreshTree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still clears a lexical deleted selection after a prior event requested full refresh', () => {
|
||||
const f = fixture()
|
||||
f.process([
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../../../shared/constants'
|
||||
import { BrowserPane } from './BrowserPane'
|
||||
import { getBrowserPaneSearchEntries } from './browser-search'
|
||||
import type * as BrowserSearch from './browser-search'
|
||||
|
||||
const fake = vi.hoisted(() => ({
|
||||
query: '',
|
||||
locale: { language: 'en' },
|
||||
translate: vi.fn((_key: string, fallback: string) => fallback),
|
||||
setHost: vi.fn(),
|
||||
profiles: [],
|
||||
repos: [],
|
||||
map: new Map(),
|
||||
environments: [],
|
||||
hostOptions: [{ id: 'local', kind: 'local', label: 'Local' }]
|
||||
}))
|
||||
vi.mock('@/i18n/i18n', () => ({ i18n: fake.locale, translate: fake.translate }))
|
||||
vi.mock('./browser-search', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof BrowserSearch>()
|
||||
return { ...original, getBrowserPaneSearchEntries: vi.fn(original.getBrowserPaneSearchEntries) }
|
||||
})
|
||||
vi.mock('@/store/index', () => ({
|
||||
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
settingsSearchQuery: fake.query,
|
||||
browserDefaultUrl: '',
|
||||
browserSessionProfiles: fake.profiles,
|
||||
repos: fake.repos,
|
||||
sshTargetLabels: fake.map,
|
||||
sshConnectionStates: fake.map,
|
||||
runtimeEnvironments: fake.environments,
|
||||
runtimeStatusByEnvironmentId: fake.map,
|
||||
browserSessionHostIdOverride: null,
|
||||
setBrowserSessionHostId: fake.setHost
|
||||
})
|
||||
}))
|
||||
vi.mock('@/components/terminal-pane/pane-helpers', () => ({ isMacUserAgent: () => false }))
|
||||
vi.mock('../../sidebar/worktree-list/controls/sidebar-host-options', () => ({
|
||||
buildSidebarHostOptions: () => fake.hostOptions
|
||||
}))
|
||||
vi.mock('./BrowserHomePageSetting', () => ({
|
||||
BrowserHomePageSetting: ({
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}) => (
|
||||
<input
|
||||
aria-label="Home page draft"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
)
|
||||
}))
|
||||
vi.mock('./use/BrowserUsePane', () => ({ BrowserUseSetup: () => <span>Browser use row</span> }))
|
||||
vi.mock('./BrowserDefaultZoomSetting', () => ({
|
||||
BrowserDefaultZoomSetting: () => <span>Zoom row</span>
|
||||
}))
|
||||
vi.mock('./BrowserSearchEngineSetting', () => ({
|
||||
BrowserSearchEngineSetting: () => <span>Search row</span>
|
||||
}))
|
||||
vi.mock('./routing/BrowserLinkRoutingSetting', () => ({
|
||||
BrowserLinkRoutingSetting: () => <span>Routing row</span>
|
||||
}))
|
||||
vi.mock('./routing/BrowserLinkRoutingModifierSetting', () => ({
|
||||
BrowserLinkRoutingModifierSetting: () => <span>Modifier row</span>
|
||||
}))
|
||||
vi.mock('./routing/BrowserLinkRoutingBackgroundSetting', () => ({
|
||||
BrowserLinkRoutingBackgroundSetting: () => <span>Background links row</span>
|
||||
}))
|
||||
vi.mock('./routing/BrowserTerminalLinkActionsSetting', () => ({
|
||||
BrowserTerminalLinkActionsSetting: () => <span>Terminal links row</span>
|
||||
}))
|
||||
vi.mock('./routing/BrowserLocalhostWorktreeLabelsSetting', () => ({
|
||||
BrowserLocalhostWorktreeLabelsSetting: () => <span>Localhost row</span>
|
||||
}))
|
||||
vi.mock('./BrowserSitePermissionsSetting', () => ({
|
||||
BrowserSitePermissionsSetting: () => <span>Site permissions row</span>
|
||||
}))
|
||||
vi.mock('./routing/BrowserClientHostedRemoteSetting', () => ({
|
||||
BrowserClientHostedRemoteSetting: () => <span>Remote row</span>
|
||||
}))
|
||||
vi.mock('./routing/BrowserSshWorkspaceRoutingSetting', () => ({
|
||||
BrowserSshWorkspaceRoutingSetting: () => <span>SSH row</span>
|
||||
}))
|
||||
vi.mock('./profiles/BrowserProfileScopeSetting', () => ({
|
||||
BrowserProfileScopeSetting: () => <span>Profile scope row</span>
|
||||
}))
|
||||
vi.mock('./BrowserUserAgentSetting', () => ({
|
||||
BrowserUserAgentSetting: () => <span>Identity row</span>
|
||||
}))
|
||||
vi.mock('../form-controls/SettingsFormControls', () => ({ SettingsSubsectionHeader: () => null }))
|
||||
vi.mock('./profiles/BrowserSessionCookiesSection', () => ({
|
||||
BrowserSessionCookiesSection: () => <span>Cookies row</span>
|
||||
}))
|
||||
vi.mock('./profiles/BrowserNewProfileDialog', () => ({ BrowserNewProfileDialog: () => null }))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
fake.query = ''
|
||||
fake.locale.language = 'en'
|
||||
fake.translate.mockImplementation((_key, fallback) => fallback)
|
||||
})
|
||||
afterEach(cleanup)
|
||||
it('builds one catalog per render while typing a home page draft', () => {
|
||||
render(<BrowserPane settings={getDefaultSettings('/synthetic')} updateSettings={vi.fn()} />)
|
||||
vi.clearAllMocks()
|
||||
for (let index = 1; index <= 20; index++) {
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'x'.repeat(index) } })
|
||||
}
|
||||
expect(getBrowserPaneSearchEntries).toHaveBeenCalledTimes(20)
|
||||
})
|
||||
it('keeps targeted search visibility', () => {
|
||||
fake.query = 'Default Home Page'
|
||||
render(<BrowserPane settings={getDefaultSettings('/synthetic')} updateSettings={vi.fn()} />)
|
||||
expect(screen.getByRole('textbox')).toBeTruthy()
|
||||
expect(screen.queryByText('Cookies row')).toBeNull()
|
||||
fake.query = 'google'
|
||||
cleanup()
|
||||
render(<BrowserPane settings={getDefaultSettings('/synthetic')} updateSettings={vi.fn()} />)
|
||||
expect(screen.getByText('Cookies row')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rebuilds metadata in the next render after the language changes', () => {
|
||||
fake.query = 'Default Home Page'
|
||||
const settings = getDefaultSettings('/synthetic')
|
||||
const view = render(<BrowserPane settings={settings} updateSettings={vi.fn()} />)
|
||||
expect(screen.getByRole('textbox')).toBeTruthy()
|
||||
fake.locale.language = 'synthetic-locale'
|
||||
fake.query = 'Translated home'
|
||||
fake.translate.mockImplementation((_key, fallback) =>
|
||||
fallback === 'Default Home Page' ? 'Translated home' : fallback
|
||||
)
|
||||
vi.mocked(getBrowserPaneSearchEntries).mockClear()
|
||||
view.rerender(<BrowserPane settings={settings} updateSettings={vi.fn()} />)
|
||||
expect(screen.getByRole('textbox')).toBeTruthy()
|
||||
expect(screen.queryByText('Cookies row')).toBeNull()
|
||||
})
|
||||
-12
@@ -83,18 +83,6 @@ function openRepoCard(repoId: string) {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Configure Codex' }))
|
||||
}
|
||||
|
||||
it('a local repository card lists the models this machine reports when its picker opens', async () => {
|
||||
useAppStore.setState({ repos: [TEST_REPO] })
|
||||
openRepoCard(TEST_REPO.id)
|
||||
expect(await screen.findByRole('option', { name: 'GPT-6.1 Sol' })).toBeDefined()
|
||||
expect(discover).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ target: { kind: 'local' }, worktreeId: null }),
|
||||
'codex'
|
||||
)
|
||||
const saved = useAppStore.getState().settings?.sourceControlAi
|
||||
expect(saved?.discoveredModelsByAgentByHost?.local?.codex).toEqual(SOL)
|
||||
})
|
||||
|
||||
it('a local repository card in WSL refreshes the local catalog generation reads, as the global card does', async () => {
|
||||
discover.mockImplementation(actualGenerationClient.discoverRuntimeCommitMessageModels)
|
||||
const list = vi.fn(async (args: { worktreePath?: string }) => ({
|
||||
|
||||
+1
-112
@@ -1,38 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getFocusedDropRuntimeId,
|
||||
getSidebarProjectDropAffordance,
|
||||
isSidebarProjectDropRefusedByFocus,
|
||||
resolveSidebarProjectDropPath,
|
||||
routeSidebarProjectDrop
|
||||
} from './sidebar-project-drop'
|
||||
import { routeSidebarProjectDrop } from './sidebar-project-drop'
|
||||
import { wslDistroWindowsPathAccess } from '../../../../../../shared/windows-path-access'
|
||||
|
||||
describe('resolveSidebarProjectDropPath', () => {
|
||||
it('accepts exactly one dropped path', () => {
|
||||
expect(resolveSidebarProjectDropPath(['/Users/alice/repo'])).toEqual({
|
||||
status: 'ready',
|
||||
path: '/Users/alice/repo'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects empty and multi-path drops before routing', () => {
|
||||
expect(resolveSidebarProjectDropPath([])).toEqual({ status: 'empty' })
|
||||
expect(resolveSidebarProjectDropPath(['/repo/a', '/repo/b'])).toEqual({
|
||||
status: 'multiple',
|
||||
count: 2
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFocusedDropRuntimeId', () => {
|
||||
it('distinguishes local runtime from active server runtime', () => {
|
||||
expect(getFocusedDropRuntimeId(null)).toBeNull()
|
||||
expect(getFocusedDropRuntimeId({ activeRuntimeEnvironmentId: ' ' })).toBeNull()
|
||||
expect(getFocusedDropRuntimeId({ activeRuntimeEnvironmentId: 'server-1' })).toBe('server-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('routeSidebarProjectDrop', () => {
|
||||
const wslHosts = [
|
||||
{ environmentId: 'wsl:Ubuntu', access: wslDistroWindowsPathAccess('Ubuntu') },
|
||||
@@ -67,17 +36,6 @@ describe('routeSidebarProjectDrop', () => {
|
||||
).toEqual({ kind: 'windows-drive-on-wsl-host', distro: 'Ubuntu', path: 'C:\\src\\app' })
|
||||
})
|
||||
|
||||
it('with WSL hosts off, routes a drvfs UNC drop exactly as before', () => {
|
||||
const path = '\\\\wsl.localhost\\Ubuntu\\mnt\\c\\proj'
|
||||
expect(routeSidebarProjectDrop({ path, focusedRuntimeId: null, wslHosts: [] })).toEqual({
|
||||
kind: 'local',
|
||||
path
|
||||
})
|
||||
expect(routeSidebarProjectDrop({ path, focusedRuntimeId: 'server', wslHosts: [] })).toEqual({
|
||||
kind: 'runtime-focused'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'\\\\wsl.localhost\\Ubuntu\\mnt\\c\\src\\app',
|
||||
// Any distro's drvfs mount, as the picker treats it.
|
||||
@@ -94,73 +52,4 @@ describe('routeSidebarProjectDrop', () => {
|
||||
path: 'C:\\src\\app'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps refusing other drops while a server is focused', () => {
|
||||
expect(
|
||||
routeSidebarProjectDrop({ path: 'C:\\src', focusedRuntimeId: 'paired-server', wslHosts })
|
||||
).toEqual({ kind: 'runtime-focused' })
|
||||
expect(
|
||||
routeSidebarProjectDrop({
|
||||
path: '\\\\wsl.localhost\\Arch\\home',
|
||||
focusedRuntimeId: 'wsl:Ubuntu',
|
||||
wslHosts
|
||||
})
|
||||
).toEqual({ kind: 'runtime-focused' })
|
||||
expect(
|
||||
routeSidebarProjectDrop({ path: '/Users/me/app', focusedRuntimeId: 'server', wslHosts: [] })
|
||||
).toEqual({ kind: 'runtime-focused' })
|
||||
})
|
||||
|
||||
it('accepts the drag over a focused server only when a WSL host could own the folder', () => {
|
||||
expect(isSidebarProjectDropRefusedByFocus(null, [])).toBe(false)
|
||||
expect(isSidebarProjectDropRefusedByFocus('server', [])).toBe(true)
|
||||
expect(isSidebarProjectDropRefusedByFocus('server', wslHosts)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSidebarProjectDropAffordance', () => {
|
||||
it('hides when the sidebar is not in a drop interaction', () => {
|
||||
expect(
|
||||
getSidebarProjectDropAffordance({
|
||||
isDragOver: false,
|
||||
isHandlingDrop: false,
|
||||
remoteRuntimeActive: false
|
||||
})
|
||||
).toEqual({ visible: false })
|
||||
})
|
||||
|
||||
it('shows ready, busy, and blocked states', () => {
|
||||
expect(
|
||||
getSidebarProjectDropAffordance({
|
||||
isDragOver: true,
|
||||
isHandlingDrop: false,
|
||||
remoteRuntimeActive: false
|
||||
})
|
||||
).toMatchObject({ visible: true, tone: 'ready' })
|
||||
|
||||
expect(
|
||||
getSidebarProjectDropAffordance({
|
||||
isDragOver: false,
|
||||
isHandlingDrop: true,
|
||||
remoteRuntimeActive: false
|
||||
})
|
||||
).toMatchObject({ visible: true, tone: 'busy' })
|
||||
|
||||
expect(
|
||||
getSidebarProjectDropAffordance({
|
||||
isDragOver: true,
|
||||
isHandlingDrop: false,
|
||||
remoteRuntimeActive: true
|
||||
})
|
||||
).toMatchObject({ visible: true, tone: 'blocked' })
|
||||
|
||||
expect(
|
||||
getSidebarProjectDropAffordance({
|
||||
isDragOver: true,
|
||||
isHandlingDrop: false,
|
||||
remoteRuntimeActive: false,
|
||||
wslDropsOnly: true
|
||||
})
|
||||
).toMatchObject({ visible: true, tone: 'ready', label: 'Drop a WSL folder to add project' })
|
||||
})
|
||||
})
|
||||
|
||||
+3
-113
@@ -1,12 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Tab } from '../../../../../../shared/tab-types'
|
||||
|
||||
import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import { structuredAgentSessionPaneKey } from '../../../../../../shared/agent-session/view/structured-agent-session-projection'
|
||||
import {
|
||||
getWorkspaceLineageMenuActions,
|
||||
hasSleepableWorkspaceActivity
|
||||
} from './workspace-lineage-menu-actions'
|
||||
|
||||
import { getWorkspaceLineageMenuActions } from './workspace-lineage-menu-actions'
|
||||
|
||||
function makeWorktree(id: string): Worktree {
|
||||
return {
|
||||
@@ -44,42 +41,6 @@ function makeLineage(child: Worktree, parent: Worktree): WorktreeLineage {
|
||||
}
|
||||
|
||||
describe('workspace lineage menu actions', () => {
|
||||
it('collects recursive descendants and only targets workspaces with active panels for sleep', () => {
|
||||
const parent = makeWorktree('parent')
|
||||
const child = makeWorktree('child')
|
||||
const grandchild = makeWorktree('grandchild')
|
||||
const actions = getWorkspaceLineageMenuActions({
|
||||
parent,
|
||||
worktrees: [parent, child, grandchild],
|
||||
lineageById: {
|
||||
[child.id]: makeLineage(child, parent),
|
||||
[grandchild.id]: makeLineage(grandchild, child)
|
||||
},
|
||||
activity: {
|
||||
tabsByWorktree: {
|
||||
[parent.id]: [{ id: 'parent-tab' }],
|
||||
[child.id]: [{ id: 'child-tab' }],
|
||||
[grandchild.id]: [{ id: 'grandchild-tab' }]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'parent-tab': [],
|
||||
'child-tab': ['child-pty'],
|
||||
'grandchild-tab': []
|
||||
},
|
||||
browserTabsByWorktree: {
|
||||
[grandchild.id]: [{ id: 'grandchild-browser' }]
|
||||
},
|
||||
unifiedTabsByWorktree: {},
|
||||
agentStatusByPaneKey: {},
|
||||
structuredChatSleptAtByPaneKey: {}
|
||||
}
|
||||
})
|
||||
|
||||
expect(actions.descendants.map((target) => target.id)).toEqual(['child', 'grandchild'])
|
||||
expect(actions.targets.map((target) => target.id)).toEqual(['parent', 'child', 'grandchild'])
|
||||
expect(actions.sleepableTargets.map((target) => target.id)).toEqual(['child', 'grandchild'])
|
||||
})
|
||||
|
||||
it('does not expose stale descendants through the recursive action scope', () => {
|
||||
const parent = makeWorktree('parent')
|
||||
const child = makeWorktree('child')
|
||||
@@ -107,74 +68,3 @@ describe('workspace lineage menu actions', () => {
|
||||
expect(actions.sleepableTargets).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
function hostHeld(tab: Tab): Record<string, { structuredHostOwned: true }> {
|
||||
return { [structuredAgentSessionPaneKey(tab.id, tab.entityId)]: { structuredHostOwned: true } }
|
||||
}
|
||||
|
||||
describe('hasSleepableWorkspaceActivity', () => {
|
||||
it('treats preserved empty PTY arrays as slept, not live', () => {
|
||||
expect(
|
||||
hasSleepableWorkspaceActivity('wt-1', {
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] },
|
||||
ptyIdsByTabId: { 'tab-1': [] },
|
||||
browserTabsByWorktree: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
agentStatusByPaneKey: {},
|
||||
structuredChatSleptAtByPaneKey: {}
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('detects live terminal and browser activity', () => {
|
||||
expect(
|
||||
hasSleepableWorkspaceActivity('wt-1', {
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] },
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
|
||||
browserTabsByWorktree: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
agentStatusByPaneKey: {},
|
||||
structuredChatSleptAtByPaneKey: {}
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
hasSleepableWorkspaceActivity('wt-1', {
|
||||
tabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
browserTabsByWorktree: { 'wt-1': [{ id: 'browser-1' }] },
|
||||
unifiedTabsByWorktree: {},
|
||||
agentStatusByPaneKey: {},
|
||||
structuredChatSleptAtByPaneKey: {}
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('offers Sleep for a chat-only workspace while its host holds the chat', () => {
|
||||
const chat: Tab = {
|
||||
id: 'chat-1',
|
||||
entityId: 'session-1',
|
||||
groupId: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
contentType: 'agent-session',
|
||||
agentSessionAgent: 'claude',
|
||||
label: 'Claude',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
const activity = (agentStatusByPaneKey: Record<string, { structuredHostOwned?: true }>) =>
|
||||
hasSleepableWorkspaceActivity('wt-1', {
|
||||
tabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
browserTabsByWorktree: {},
|
||||
unifiedTabsByWorktree: { 'wt-1': [chat] },
|
||||
agentStatusByPaneKey,
|
||||
structuredChatSleptAtByPaneKey: {
|
||||
[structuredAgentSessionPaneKey(chat.id, chat.entityId)]: null
|
||||
}
|
||||
})
|
||||
expect(activity(hostHeld(chat))).toBe(true)
|
||||
expect(activity({})).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
+1
-23
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import React, { createRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
@@ -134,16 +134,6 @@ function mountedStatusIds(container: HTMLElement): string[] {
|
||||
)
|
||||
}
|
||||
|
||||
function flushNextAnimationFrame(): void {
|
||||
const next = animationFrames.entries().next().value as [number, FrameRequestCallback] | undefined
|
||||
expect(next).toBeDefined()
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
animationFrames.delete(next[0])
|
||||
act(() => next[1](performance.now()))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
animationFrames.clear()
|
||||
nextAnimationFrameId = 1
|
||||
@@ -181,16 +171,4 @@ describe('WorkspaceKanbanLaneGrid', () => {
|
||||
'state-21'
|
||||
])
|
||||
})
|
||||
|
||||
it('hydrates at most one mounted lane per animation frame', () => {
|
||||
const { container } = renderGrid()
|
||||
const renderedLaneCount = (): number =>
|
||||
container.querySelectorAll('[data-render-cards="true"]').length
|
||||
|
||||
expect(renderedLaneCount()).toBe(0)
|
||||
flushNextAnimationFrame()
|
||||
expect(renderedLaneCount()).toBe(1)
|
||||
flushNextAnimationFrame()
|
||||
expect(renderedLaneCount()).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
+1
-85
@@ -2,14 +2,11 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
import { createVisibleHostedReviewRefreshScheduler } from '@/store/github/visible-hosted-review-refresh-scheduler'
|
||||
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity'
|
||||
import { getWorkspaceReferenceRefreshTargets } from './workspace-reference-refresh-targets'
|
||||
import {
|
||||
referenceAttachment,
|
||||
referenceRepo,
|
||||
referenceWorkspace,
|
||||
referenceReview
|
||||
referenceWorkspace
|
||||
} from './workspace-reference-fixtures.test-support'
|
||||
vi.mock('@/store', async () => {
|
||||
const browserWindow = globalThis.window
|
||||
@@ -63,87 +60,6 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
it('leaves the detached primary review to its visible target while retaining sibling reviews', () => {
|
||||
useAppStore.setState({
|
||||
worktreesByRepo: {
|
||||
repo: [
|
||||
{
|
||||
...workspace,
|
||||
branch: '',
|
||||
linkedPR: 7,
|
||||
linkedItems: [referenceAttachment(7), referenceAttachment(8)]
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
const targets = update()
|
||||
expect(targets).toHaveLength(1)
|
||||
expect(targets[0]?.key).toContain('8')
|
||||
})
|
||||
|
||||
it('pauses disconnected SSH owners and only admits the owning reconnect', () => {
|
||||
useAppStore.setState({ repos: [{ ...referenceRepo, connectionId: 'ssh-a' }] })
|
||||
expect(update()).toEqual([])
|
||||
for (const [targetId, status, count] of [
|
||||
['other', 'connected', 0],
|
||||
['ssh-a', 'connected', 1],
|
||||
['ssh-a', 'connecting', 0]
|
||||
] as const) {
|
||||
useAppStore
|
||||
.getState()
|
||||
.sshConnectionStates.set(targetId, { targetId, status, error: null, reconnectAttempt: 0 })
|
||||
expect(update()).toHaveLength(count)
|
||||
}
|
||||
})
|
||||
it('shares one scheduler timer and removes queued work when the workspace leaves the viewport', async () => {
|
||||
useAppStore.setState({
|
||||
worktreesByRepo: {
|
||||
repo: [
|
||||
{
|
||||
...workspace,
|
||||
linkedItems: Array.from({ length: 12 }, (_, i) => referenceAttachment(i + 1))
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
const scheduler = createVisibleHostedReviewRefreshScheduler()
|
||||
try {
|
||||
scheduler.update(update())
|
||||
scheduler.setVisible(true)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(read).toHaveBeenCalledTimes(12)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
useAppStore.setState({ visibleReviewWorktreeIds: [] })
|
||||
scheduler.update(update())
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await vi.advanceTimersByTimeAsync(600_000)
|
||||
expect(read).toHaveBeenCalledTimes(12)
|
||||
} finally {
|
||||
scheduler.dispose()
|
||||
}
|
||||
})
|
||||
it('uses the known branch provider for sparse enterprise references', () => {
|
||||
const repo = {
|
||||
...referenceRepo,
|
||||
gitRemoteIdentity: {
|
||||
canonicalKey: 'enterprise.test/acme/orca',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'https://enterprise.test/acme/orca.git'
|
||||
}
|
||||
}
|
||||
const key = getHostedReviewCacheKey(repo.path, workspace.branch, null, repo.id, null, null, true)
|
||||
useAppStore.setState({
|
||||
repos: [repo],
|
||||
worktreesByRepo: {
|
||||
repo: [{ ...workspace, linkedItems: [{ provider: 'github', type: 'pr', number: 42 }] }]
|
||||
}
|
||||
})
|
||||
expect(update()).toHaveLength(0)
|
||||
useAppStore.setState({
|
||||
hostedReviewCache: { [key]: { data: referenceReview(), fetchedAt: Date.now() } }
|
||||
})
|
||||
expect(update()).toHaveLength(1)
|
||||
})
|
||||
it('retains both explicit owners when equal visible workspace IDs exist on two hosts', () => {
|
||||
const remoteRepo = {
|
||||
...referenceRepo,
|
||||
|
||||
+1
-57
@@ -10,7 +10,7 @@ import {
|
||||
referenceRepo,
|
||||
referenceLinearIssue
|
||||
} from '../review/workspace-reference-fixtures.test-support'
|
||||
import { getWorkspaceReferenceRequest } from '../review/workspace-reference-details'
|
||||
|
||||
import { normalizeTaskSourceContext } from '../../../../../../shared/task-source-context'
|
||||
import { getLinearReadScope, scopedLinearCacheKey } from '@/store/slices/linear/linear-slice-scope'
|
||||
|
||||
@@ -43,62 +43,6 @@ vi.mock('../../workspace-actions/deletion/workspace-delete-quick-action', () =>
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('single linked reference rich hover details', () => {
|
||||
it.each([
|
||||
{
|
||||
data: {
|
||||
provider: 'gitlab' as const,
|
||||
number: 8,
|
||||
title: 'Fix detached review',
|
||||
state: 'open' as const,
|
||||
status: 'success' as const,
|
||||
updatedAt: '',
|
||||
mergeable: 'MERGEABLE' as const,
|
||||
url: 'https://gitlab.example.com/team/repo/-/merge_requests/8'
|
||||
},
|
||||
title: 'Fix detached review'
|
||||
},
|
||||
{ data: null, title: 'MR details unavailable' }
|
||||
])(
|
||||
'reads detached MR details from the same exact cache as Linked work: $title',
|
||||
({ data, title }) => {
|
||||
const repo = {
|
||||
...referenceRepo,
|
||||
gitRemoteIdentity: {
|
||||
canonicalKey: 'gitlab.example.com/team/repo',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'https://gitlab.example.com/team/repo.git'
|
||||
}
|
||||
}
|
||||
const item = {
|
||||
provider: 'gitlab' as const,
|
||||
type: 'mr' as const,
|
||||
number: 8,
|
||||
url: 'https://gitlab.example.com/team/repo/-/merge_requests/8'
|
||||
}
|
||||
const workspace = {
|
||||
...referenceWorkspace,
|
||||
branch: '',
|
||||
linkedPR: null,
|
||||
linkedGitLabMR: 8,
|
||||
linkedItems: [item]
|
||||
}
|
||||
const key = getWorkspaceReferenceRequest(item, workspace, repo).key
|
||||
cache.hosted = { [key]: { data, fetchedAt: Date.now(), linkedReviewHintKey: 'gitlab:8' } }
|
||||
const { result } = renderHook(() =>
|
||||
useWorktreeCardReviewDetails({
|
||||
worktree: workspace,
|
||||
repo,
|
||||
settings: null,
|
||||
projectGroups: [],
|
||||
cardProps: ['pr'],
|
||||
newCardStyle: false
|
||||
})
|
||||
)
|
||||
expect(result.current.hostedReviewCacheKey).toBe(key)
|
||||
expect(result.current.prDisplay?.title).toBe(title)
|
||||
}
|
||||
)
|
||||
|
||||
it('reuses source-bound Linear details when an exact workspace read is outside the legacy all-workspaces cache slot', () => {
|
||||
const source = normalizeTaskSourceContext({
|
||||
provider: 'linear',
|
||||
|
||||
-60
@@ -1,4 +1,3 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
CARD_TEMPLATE_LIMITS,
|
||||
@@ -23,66 +22,7 @@ function template(root: CardTemplate['root'], extra: Partial<CardTemplate> = {})
|
||||
return { version: 1, facts: 1, root, ...extra }
|
||||
}
|
||||
|
||||
function draw(t: CardTemplate, facts: Record<string, unknown> = {}): string {
|
||||
const { content } = compileCardTemplate(t, registry).render(facts, '')
|
||||
return renderToStaticMarkup(<>{content}</>)
|
||||
}
|
||||
|
||||
describe('card template compiler', () => {
|
||||
it('draws layout as host elements and evaluates props, vars and when', () => {
|
||||
const t = template(
|
||||
{
|
||||
el: 'Row',
|
||||
props: { gap: 'sm', align: '=vars.wide ? "center" : "start"' },
|
||||
children: [
|
||||
{ el: 'Echo', props: { text: '=ws.name' } },
|
||||
{ el: 'Echo', when: 'ws.hidden', props: { text: 'never' } }
|
||||
]
|
||||
},
|
||||
{ vars: { wide: 'len(ws.name) > 3' } }
|
||||
)
|
||||
expect(draw(t, { ws: { name: 'card plugins', hidden: false } })).toBe(
|
||||
'<div class="flex min-w-0 gap-1 items-center"><i>card plugins</i></div>'
|
||||
)
|
||||
expect(draw(t, { ws: { name: 'abc' } })).toContain('items-start')
|
||||
})
|
||||
|
||||
it('expands defs and reports frame answers', () => {
|
||||
const t = template(
|
||||
{ el: 'Stack', children: [{ el: 'Label' }, { el: 'Label', when: 'false' }] },
|
||||
{
|
||||
defs: { Label: { el: 'Echo', props: { text: 'hi' } } },
|
||||
frame: { singleLine: '=true', statusLane: false }
|
||||
}
|
||||
)
|
||||
const compiled = compileCardTemplate(t, registry)
|
||||
const { frame, content } = compiled.render({}, '!')
|
||||
expect(frame).toEqual({ singleLine: true, statusLane: false })
|
||||
expect(renderToStaticMarkup(<>{content}</>)).toBe(
|
||||
'<div class="flex min-w-0 flex-col"><i>hi!</i></div>'
|
||||
)
|
||||
})
|
||||
|
||||
it('lets the frame add attributes to the root', () => {
|
||||
const { content } = compileCardTemplate(template({ el: 'Row' }), registry).render(
|
||||
{},
|
||||
'',
|
||||
() => ({
|
||||
style: { marginLeft: '-4px' }
|
||||
})
|
||||
)
|
||||
expect(renderToStaticMarkup(<>{content}</>)).toContain('margin-left:-4px')
|
||||
})
|
||||
|
||||
it('passes frame attributes through a def used as the root, and honours its when', () => {
|
||||
const t = template({ el: 'Body', when: 'ws.show' }, { defs: { Body: { el: 'Row' } } })
|
||||
const compiled = compileCardTemplate(t, registry)
|
||||
const attrs = (): Record<string, unknown> => ({ style: { marginLeft: '-4px' } })
|
||||
const shown = compiled.render({ ws: { show: true } }, '', attrs).content
|
||||
expect(renderToStaticMarkup(<>{shown}</>)).toContain('margin-left:-4px')
|
||||
expect(compiled.render({ ws: { show: false } }, '', attrs).content).toBeNull()
|
||||
})
|
||||
|
||||
it('bounds the tree defs expand to', () => {
|
||||
// Each def holds `width` copies of the next: tiny as written, width^levels once inlined.
|
||||
const fanOut = (levels: number, width: number): CardTemplate => {
|
||||
|
||||
+1
-72
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computeVisibleWorktreeIds } from '../listing/compute-visible-worktrees'
|
||||
import { isDefaultBranchWorkspace } from '../../../../../../shared/worktree/workspace-kinds'
|
||||
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../../../../shared/execution-host'
|
||||
@@ -59,25 +59,6 @@ function visibleOptions(overrides: Partial<VisibleOptions> = {}): VisibleOptions
|
||||
}
|
||||
}
|
||||
|
||||
describe('#8873 default-branch workspace under "Hide sleeping"', () => {
|
||||
it('is genuinely the default-branch row the "Hide default branch" toggle targets', () => {
|
||||
expect(isDefaultBranchWorkspace(makeDefaultBranchWorktree(), repoMap.get('repo1'))).toBe(true)
|
||||
})
|
||||
|
||||
it('stays in the sidebar when it is sleeping and "Hide sleeping" is on', () => {
|
||||
const worktree = makeDefaultBranchWorktree()
|
||||
|
||||
const visible = computeVisibleWorktreeIds({ repo1: [worktree] }, [worktree.id], {
|
||||
...visibleOptions({
|
||||
showSleepingWorkspaces: false,
|
||||
hideDefaultBranchWorkspace: false
|
||||
})
|
||||
})
|
||||
|
||||
expect(visible).toEqual([worktree.id])
|
||||
})
|
||||
})
|
||||
|
||||
describe('the "Hide sleeping" exemption for project entry-point rows', () => {
|
||||
function visible(worktrees: Worktree[], overrides: Partial<VisibleOptions>): string[] {
|
||||
return computeVisibleWorktreeIds(
|
||||
@@ -93,34 +74,6 @@ describe('the "Hide sleeping" exemption for project entry-point rows', () => {
|
||||
expect(visible([worktree], { alwaysShowDefaultBranchWorkspace: false })).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the sleeping default branch when the option is explicitly on', () => {
|
||||
const worktree = makeDefaultBranchWorktree()
|
||||
|
||||
expect(visible([worktree], { alwaysShowDefaultBranchWorkspace: true })).toEqual([worktree.id])
|
||||
})
|
||||
|
||||
it('lets an explicit "Hide default branch" still win over the exemption', () => {
|
||||
const worktree = makeDefaultBranchWorktree()
|
||||
|
||||
expect(
|
||||
visible([worktree], {
|
||||
hideDefaultBranchWorkspace: true,
|
||||
alwaysShowDefaultBranchWorkspace: true
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('still sweeps sleeping non-main workspaces', () => {
|
||||
const feature: Worktree = {
|
||||
...makeDefaultBranchWorktree(),
|
||||
id: 'wt-feature',
|
||||
branch: 'refs/heads/feature',
|
||||
isMainWorktree: false
|
||||
}
|
||||
|
||||
expect(visible([feature], { alwaysShowDefaultBranchWorkspace: true })).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a sleeping folder workspace, which has no sibling row to fall back to', () => {
|
||||
// Folder roots have no head/branch; sweeping the sole root hides the entire project.
|
||||
const folder: Worktree = {
|
||||
@@ -132,14 +85,6 @@ describe('the "Hide sleeping" exemption for project entry-point rows', () => {
|
||||
expect(visible([folder], { alwaysShowDefaultBranchWorkspace: true })).toEqual([folder.id])
|
||||
})
|
||||
|
||||
it('keeps a sleeping detached-HEAD main worktree', () => {
|
||||
const detachedMain: Worktree = { ...makeDefaultBranchWorktree(), id: 'wt-detached', branch: '' }
|
||||
|
||||
expect(visible([detachedMain], { alwaysShowDefaultBranchWorkspace: true })).toEqual([
|
||||
detachedMain.id
|
||||
])
|
||||
})
|
||||
|
||||
it('lets "Hide detached HEAD" still win over the exemption', () => {
|
||||
const detachedMain: Worktree = { ...makeDefaultBranchWorktree(), id: 'wt-detached', branch: '' }
|
||||
|
||||
@@ -150,20 +95,4 @@ describe('the "Hide sleeping" exemption for project entry-point rows', () => {
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps every project’s entry point, not just the first', () => {
|
||||
const mainA = makeDefaultBranchWorktree()
|
||||
const mainB: Worktree = { ...makeDefaultBranchWorktree(), id: 'wt-main-b' }
|
||||
|
||||
expect(visible([mainA, mainB], {})).toEqual([mainA.id, mainB.id])
|
||||
})
|
||||
|
||||
it('does not flicker out while an SSH host is offline', () => {
|
||||
// Offline SSH synthesis preserves main-worktree identity but empties head/branch and loses live PTYs.
|
||||
const awake = makeDefaultBranchWorktree()
|
||||
const offline: Worktree = { ...awake, head: '', branch: '' }
|
||||
|
||||
expect(visible([offline], {})).toEqual([offline.id])
|
||||
expect(visible([awake], {})).toEqual([awake.id])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -121,21 +121,3 @@ it.each([
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('disables sprint filtering in linked mode while retaining its checked preference', () => {
|
||||
const current = model(source)
|
||||
current.jiraHasWorkspace = true
|
||||
const { rerender } = render(<TaskPageJiraContent model={current} />)
|
||||
expect(screen.getByRole('switch', { name: 'Current sprint' }).hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.getByRole('switch', { name: 'Current sprint' }).getAttribute('aria-checked')).toBe(
|
||||
'true'
|
||||
)
|
||||
current.jiraHasWorkspace = false
|
||||
rerender(<TaskPageJiraContent model={current} />)
|
||||
expect(screen.getByRole('switch', { name: 'Current sprint' }).hasAttribute('disabled')).toBe(
|
||||
false
|
||||
)
|
||||
expect(screen.getByRole('switch', { name: 'Current sprint' }).getAttribute('aria-checked')).toBe(
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -120,28 +120,6 @@ describe('remote runtime recovery input hold', () => {
|
||||
expect(sent).toEqual([])
|
||||
})
|
||||
|
||||
it('accepts new input right after a release drained the previous hold', async () => {
|
||||
const hold = createRemoteRuntimeRecoveryInputHold()
|
||||
const { sent, writer } = createWriter()
|
||||
hold.enqueue(endpoint, 'a', 'driving')
|
||||
hold.release(endpoint, writer)
|
||||
await vi.waitFor(() => expect(sent).toEqual(['a']))
|
||||
|
||||
expect(hold.enqueue(endpoint, 'b', 'driving')).toBe(true)
|
||||
hold.release(endpoint, writer)
|
||||
await vi.waitFor(() => expect(sent).toEqual(['a', 'b']))
|
||||
})
|
||||
|
||||
it('resolves held acknowledged input as undelivered on discard', async () => {
|
||||
const hold = createRemoteRuntimeRecoveryInputHold()
|
||||
const accepted = hold.enqueueAccepted(endpoint, 'x', 'driving')
|
||||
|
||||
hold.discard()
|
||||
|
||||
await expect(accepted).resolves.toBe(false)
|
||||
expect(hold.isHolding()).toBe(false)
|
||||
})
|
||||
|
||||
it('lets only the latest connect settle typing held before the pane bound a terminal', async () => {
|
||||
const hold = createRemoteRuntimeRecoveryInputHold({ holdBeforeFirstConnect: true })
|
||||
const { sent, writer } = createWriter()
|
||||
|
||||
@@ -130,44 +130,4 @@ describe('terminal surface ownership', () => {
|
||||
).resolves.toBe(false)
|
||||
expect(mocks.remountTerminalTabForRecovery).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Round-trip through disk/Zod: undeclared fields vanish only at load, invisible to same-process assertions.
|
||||
it('still refuses a chat-owned tab after a real persist/parse round trip', async () => {
|
||||
const loaded = parseWorkspaceSession({
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: WORKTREE_ID,
|
||||
activeTabId: 'tab-1',
|
||||
tabsByWorktree: {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
id: 'tab-1',
|
||||
ptyId: null,
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Terminal 1',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0,
|
||||
viewMode: 'chat'
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {}
|
||||
})
|
||||
expect(loaded.ok).toBe(true)
|
||||
setTerminalTabs(
|
||||
(loaded.ok ? loaded.value.tabsByWorktree[WORKTREE_ID] : []) as StoredTerminalTab[]
|
||||
)
|
||||
// Blind on purpose: only the reloaded row can refuse this.
|
||||
mocks.getTab.mockReturnValue(null)
|
||||
|
||||
await expect(
|
||||
requestTerminalPaneRecovery({
|
||||
tabId: 'tab-1',
|
||||
ptyId: 'pty-1',
|
||||
reason: 'write-stalled'
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
expect(mocks.remountTerminalTabForRecovery).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
_measureAgentHookCompletionStoreSyncForTest,
|
||||
shouldSyncAgentHookCompletionForStoreUpdate,
|
||||
type AgentHookCompletionStoreSnapshot
|
||||
} from './agent-hook-completion-store-sync'
|
||||
|
||||
function createState(
|
||||
overrides: Partial<AgentHookCompletionStoreSnapshot> = {}
|
||||
): AgentHookCompletionStoreSnapshot {
|
||||
return {
|
||||
settings: {
|
||||
experimentalTerminalAttention: false,
|
||||
notifications: { enabled: true, agentTaskComplete: true }
|
||||
},
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', ptyId: 'pty-1', title: 'Terminal 1' }]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
|
||||
terminalLayoutsByTabId: {},
|
||||
suppressedPtyExitIds: {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('agent hook completion store sync', () => {
|
||||
it('ignores unrelated and title-only writes at accumulated-workspace scale', () => {
|
||||
const tabCount = 100
|
||||
const coordinatorCount = 100
|
||||
const updateCount = 600
|
||||
const tabsByWorktree = Object.fromEntries(
|
||||
Array.from({ length: tabCount }, (_, index) => [
|
||||
`wt-${index}`,
|
||||
[{ id: `tab-${index}`, ptyId: `pty-${index}`, title: `Terminal ${index}` }]
|
||||
])
|
||||
)
|
||||
let previous = createState({ tabsByWorktree })
|
||||
let syncPasses = 0
|
||||
let tabVisits = 0
|
||||
|
||||
for (let index = 0; index < updateCount; index += 1) {
|
||||
const current = { ...previous }
|
||||
const measurement = _measureAgentHookCompletionStoreSyncForTest(current, previous)
|
||||
syncPasses += Number(measurement.shouldSync)
|
||||
tabVisits += measurement.tabVisits
|
||||
previous = current
|
||||
}
|
||||
|
||||
for (let index = 0; index < updateCount; index += 1) {
|
||||
const targetTabs = previous.tabsByWorktree['wt-99']
|
||||
if (!targetTabs) {
|
||||
throw new Error('Expected title-update fixture tab')
|
||||
}
|
||||
const current = createState({
|
||||
...previous,
|
||||
tabsByWorktree: {
|
||||
...previous.tabsByWorktree,
|
||||
'wt-99': targetTabs.map((tab) => ({ ...tab, title: `Agent frame ${index}` }))
|
||||
}
|
||||
})
|
||||
const measurement = _measureAgentHookCompletionStoreSyncForTest(current, previous)
|
||||
syncPasses += Number(measurement.shouldSync)
|
||||
tabVisits += measurement.tabVisits
|
||||
previous = current
|
||||
}
|
||||
|
||||
const previousFullPassCost = {
|
||||
tabVisits: updateCount * 2 * tabCount,
|
||||
coordinatorVisits: updateCount * 2 * coordinatorCount
|
||||
}
|
||||
const gatedCost = {
|
||||
tabVisits,
|
||||
coordinatorVisits: syncPasses * coordinatorCount
|
||||
}
|
||||
expect(previousFullPassCost).toEqual({ tabVisits: 120_000, coordinatorVisits: 120_000 })
|
||||
expect(gatedCost).toEqual({ tabVisits: 600, coordinatorVisits: 0 })
|
||||
})
|
||||
|
||||
it('keeps every coordinator-liveness input reactive', () => {
|
||||
const previous = createState()
|
||||
const titleOnly = createState({
|
||||
...previous,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', ptyId: 'pty-1', title: 'Codex working' }]
|
||||
}
|
||||
})
|
||||
expect(shouldSyncAgentHookCompletionForStoreUpdate(titleOnly, previous)).toBe(false)
|
||||
|
||||
const tabCreated = createState({
|
||||
...previous,
|
||||
tabsByWorktree: {
|
||||
...previous.tabsByWorktree,
|
||||
'wt-2': [{ id: 'tab-2', ptyId: null }]
|
||||
}
|
||||
})
|
||||
expect(shouldSyncAgentHookCompletionForStoreUpdate(tabCreated, previous)).toBe(true)
|
||||
|
||||
const tabRemoved = createState({ ...previous, tabsByWorktree: {} })
|
||||
expect(shouldSyncAgentHookCompletionForStoreUpdate(tabRemoved, previous)).toBe(true)
|
||||
|
||||
const tabPtyChanged = createState({
|
||||
...previous,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-2' }] }
|
||||
})
|
||||
expect(shouldSyncAgentHookCompletionForStoreUpdate(tabPtyChanged, previous)).toBe(true)
|
||||
|
||||
expect(
|
||||
shouldSyncAgentHookCompletionForStoreUpdate(
|
||||
createState({ ...previous, ptyIdsByTabId: { 'tab-1': ['pty-2'] } }),
|
||||
previous
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldSyncAgentHookCompletionForStoreUpdate(
|
||||
createState({ ...previous, terminalLayoutsByTabId: { 'tab-1': { root: null } } }),
|
||||
previous
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldSyncAgentHookCompletionForStoreUpdate(
|
||||
createState({ ...previous, suppressedPtyExitIds: { 'pty-1': true } }),
|
||||
previous
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
const trackingDisabled = createState({
|
||||
...previous,
|
||||
settings: {
|
||||
experimentalTerminalAttention: false,
|
||||
notifications: { enabled: false, agentTaskComplete: false }
|
||||
}
|
||||
})
|
||||
expect(shouldSyncAgentHookCompletionForStoreUpdate(trackingDisabled, previous)).toBe(false)
|
||||
expect(
|
||||
shouldSyncAgentHookCompletionForStoreUpdate(
|
||||
createState({ ...previous, settings: null }),
|
||||
previous
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('treats tab order and duplicate-id worktree precedence as liveness inputs', () => {
|
||||
const previous = createState({
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-shared', ptyId: 'pty-first' }],
|
||||
'wt-2': [{ id: 'tab-shared', ptyId: 'pty-second' }]
|
||||
}
|
||||
})
|
||||
const reorderedWorktrees = createState({
|
||||
...previous,
|
||||
tabsByWorktree: {
|
||||
'wt-2': previous.tabsByWorktree['wt-2'] ?? [],
|
||||
'wt-1': previous.tabsByWorktree['wt-1'] ?? []
|
||||
}
|
||||
})
|
||||
expect(shouldSyncAgentHookCompletionForStoreUpdate(reorderedWorktrees, previous)).toBe(true)
|
||||
|
||||
const twoTabPrevious = createState({
|
||||
tabsByWorktree: {
|
||||
'wt-1': [
|
||||
{ id: 'tab-1', ptyId: 'pty-1' },
|
||||
{ id: 'tab-2', ptyId: 'pty-2' }
|
||||
]
|
||||
}
|
||||
})
|
||||
const reorderedTabs = createState({
|
||||
...twoTabPrevious,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [
|
||||
{ id: 'tab-2', ptyId: 'pty-2' },
|
||||
{ id: 'tab-1', ptyId: 'pty-1' }
|
||||
]
|
||||
}
|
||||
})
|
||||
expect(shouldSyncAgentHookCompletionForStoreUpdate(reorderedTabs, twoTabPrevious)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../shared/constants'
|
||||
|
||||
import { resolveAutoAckTabTargets } from './agent-auto-ack-targets'
|
||||
import { acknowledgeViewedAutoAckTarget } from './agent-auto-ack-surfaces'
|
||||
import {
|
||||
@@ -128,34 +128,3 @@ describe('the on-screen read in a workspace holding both a terminal and a struct
|
||||
expect(workspaceStillHeld(store)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
function markChatGroupUnread(store: TestStore, groupId: string): void {
|
||||
// Why: tab markers for completions sit behind the experimental attention setting.
|
||||
store.setState({
|
||||
settings: { ...getDefaultSettings('/home/test'), experimentalTerminalAttention: true }
|
||||
})
|
||||
store.getState().markAgentAttentionUnread({
|
||||
workspaceId: WORKSPACE,
|
||||
subjectKey: CHAT_SUBJECT,
|
||||
groupId,
|
||||
reason: 'agent-completion'
|
||||
})
|
||||
}
|
||||
|
||||
describe('tab marker container addressing', () => {
|
||||
it('accepts a structured chat addressed by its unified tab id', () => {
|
||||
const { store } = seedMixedWorkspace({ visible: 'chat', unreadSubjectKeys: [] })
|
||||
|
||||
markChatGroupUnread(store, CHAT_TAB)
|
||||
|
||||
expect(store.getState().unreadTerminalTabs[CHAT_TAB]).toBe('agent-completion')
|
||||
})
|
||||
|
||||
it('still refuses an id no open tab owns', () => {
|
||||
const { store } = seedMixedWorkspace({ visible: 'chat', unreadSubjectKeys: [] })
|
||||
|
||||
markChatGroupUnread(store, 'tab-that-never-existed')
|
||||
|
||||
expect(store.getState().unreadTerminalTabs['tab-that-never-existed']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import type { NativeChatAppearanceSettings } from '../../../shared/native-chat/native-chat-appearance-settings'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function makeTarget(args: {
|
||||
hasXtermClass?: boolean
|
||||
editorClosest?: boolean
|
||||
chatClosest?: boolean
|
||||
browserPageId?: string
|
||||
/** The page's <webview> sits in a viewport shell outside its pane. */
|
||||
viewportPageId?: string
|
||||
}): {
|
||||
classList: { contains: (token: string) => boolean }
|
||||
closest: (selector: string) => object | null
|
||||
} {
|
||||
const {
|
||||
hasXtermClass = false,
|
||||
editorClosest = false,
|
||||
chatClosest = false,
|
||||
browserPageId,
|
||||
viewportPageId
|
||||
} = args
|
||||
return {
|
||||
classList: {
|
||||
contains: (token: string) => hasXtermClass && token === 'xterm-helper-textarea'
|
||||
},
|
||||
closest: (selector) => {
|
||||
if (selector === '[data-browser-page-pane-id]') {
|
||||
return browserPageId ? { getAttribute: () => browserPageId } : null
|
||||
}
|
||||
if (selector === '[data-browser-page-viewport-id]') {
|
||||
return viewportPageId ? { getAttribute: () => viewportPageId } : null
|
||||
}
|
||||
return (selector === '[data-native-chat-root]' ? chatClosest : editorClosest) ? {} : null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('registerZoomIpcBridge', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// Test the zoom bridge directly: useIpcEvents adds an unrelated import graph that timed out under parallel load.
|
||||
async function mountZoomBridge(
|
||||
args: {
|
||||
activeView?: string
|
||||
activeTabType?: string
|
||||
activeElement?: ReturnType<typeof makeTarget>
|
||||
uiZoomLevel?: number
|
||||
nativeChatAppearance?: NativeChatAppearanceSettings
|
||||
editorFontZoomLevel?: number
|
||||
viewerTakesZoom?: boolean
|
||||
} = {}
|
||||
) {
|
||||
const {
|
||||
activeView = 'terminal',
|
||||
activeTabType = 'browser',
|
||||
activeElement = makeTarget({ editorClosest: true, hasXtermClass: true }),
|
||||
uiZoomLevel = 0,
|
||||
editorFontZoomLevel = 0,
|
||||
nativeChatAppearance,
|
||||
viewerTakesZoom = false
|
||||
} = args
|
||||
|
||||
const applyUIZoom = vi.fn()
|
||||
const dispatchZoomLevelChanged = vi.fn()
|
||||
const setEditorFontZoomLevel = vi.fn()
|
||||
const setUI = vi.fn()
|
||||
const settings = { terminalFontSize: 13, nativeChatAppearance }
|
||||
const updateSettings = vi.fn(async (updates: Partial<typeof settings>) => {
|
||||
Object.assign(settings, updates)
|
||||
})
|
||||
|
||||
const requestViewerZoom = vi.fn(() => viewerTakesZoom)
|
||||
const dispatchBrowserPageZoom = vi.fn()
|
||||
vi.doMock('@/components/browser-pane/host-guest/browser-page-zoom', () => ({
|
||||
dispatchBrowserPageZoom
|
||||
}))
|
||||
vi.doMock('@/components/editor/viewer-zoom-request', () => ({
|
||||
requestViewerZoom,
|
||||
requestFrontDialogViewerZoom: () => false
|
||||
}))
|
||||
vi.doMock('@/lib/ui-zoom', () => ({ applyUIZoom }))
|
||||
vi.doMock('@/lib/zoom-events', () => ({ dispatchZoomLevelChanged }))
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => ({
|
||||
activeView,
|
||||
activeTabType,
|
||||
editorFontZoomLevel,
|
||||
setEditorFontZoomLevel,
|
||||
settings,
|
||||
updateSettings
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
const listenerRef: { current: ((direction: 'in' | 'out' | 'reset') => void) | null } = {
|
||||
current: null
|
||||
}
|
||||
vi.stubGlobal('document', { activeElement })
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
ui: {
|
||||
onTerminalZoom: (listener: (direction: 'in' | 'out' | 'reset') => void) => {
|
||||
listenerRef.current = listener
|
||||
return () => {}
|
||||
},
|
||||
getZoomLevel: () => uiZoomLevel,
|
||||
set: setUI
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const { registerZoomIpcBridge } = await import('./ipc-events/zoom-ipc-bridge')
|
||||
const unsubs: (() => void)[] = []
|
||||
registerZoomIpcBridge(unsubs)
|
||||
|
||||
expect(unsubs).toHaveLength(1)
|
||||
const fire = listenerRef.current
|
||||
if (!fire) {
|
||||
throw new Error('Expected the terminal-zoom listener to be registered')
|
||||
}
|
||||
return {
|
||||
fire,
|
||||
dispatchBrowserPageZoom,
|
||||
requestViewerZoom,
|
||||
applyUIZoom,
|
||||
dispatchZoomLevelChanged,
|
||||
setEditorFontZoomLevel,
|
||||
setUI,
|
||||
settings,
|
||||
updateSettings
|
||||
}
|
||||
}
|
||||
|
||||
it('routes keyboard and menu zoom directions to persisted chat size, never app or editor zoom', async () => {
|
||||
const zoom = await mountZoomBridge({
|
||||
activeTabType: 'terminal',
|
||||
activeElement: makeTarget({ chatClosest: true }),
|
||||
nativeChatAppearance: { fontSize: 19, codeFontSize: 16, width: 'wide' }
|
||||
})
|
||||
zoom.fire('in')
|
||||
zoom.fire('in')
|
||||
zoom.fire('out')
|
||||
await vi.waitFor(() => expect(zoom.updateSettings).toHaveBeenCalledTimes(2))
|
||||
expect(zoom.settings.nativeChatAppearance).toEqual({
|
||||
fontSize: 19,
|
||||
codeFontSize: 16,
|
||||
width: 'wide'
|
||||
})
|
||||
zoom.fire('reset')
|
||||
await vi.waitFor(() =>
|
||||
expect(zoom.settings.nativeChatAppearance).toEqual({ codeFontSize: 16, width: 'wide' })
|
||||
)
|
||||
expect(zoom.applyUIZoom).not.toHaveBeenCalled()
|
||||
expect(zoom.setUI).not.toHaveBeenCalled()
|
||||
expect(zoom.setEditorFontZoomLevel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('consumes menu chat zoom while matching without changing saved size or app zoom', async () => {
|
||||
const zoom = await mountZoomBridge({
|
||||
activeTabType: 'terminal',
|
||||
activeElement: makeTarget({ chatClosest: true }),
|
||||
nativeChatAppearance: { fontSize: 18, matchTerminalInterface: true }
|
||||
})
|
||||
zoom.fire('in')
|
||||
zoom.fire('out')
|
||||
zoom.fire('reset')
|
||||
await vi.waitFor(() => expect(zoom.settings.nativeChatAppearance?.fontSize).toBe(18))
|
||||
expect(zoom.updateSettings).not.toHaveBeenCalled()
|
||||
expect(zoom.applyUIZoom).not.toHaveBeenCalled()
|
||||
expect(zoom.setEditorFontZoomLevel).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
revealStructuredSession
|
||||
} from './activate-ai-vault-structured-session'
|
||||
import { STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY } from '../../../shared/agent-session/structured-agent-session-surface-capabilities'
|
||||
import { RUNTIME_COMPAT_BLOCK_CODE } from '@/runtime/runtime-protocol-compat'
|
||||
|
||||
const target = { worktreeId: 'workspace-1', sessionId: 'session-1' }
|
||||
|
||||
@@ -40,37 +39,6 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('revealStructuredSession', () => {
|
||||
it('asks a local host without a capability round trip', async () => {
|
||||
// The local host shares this build; probing it would only add a round trip.
|
||||
await expect(revealStructuredSession(target)).resolves.toEqual({ ...target, agent: 'codex' })
|
||||
|
||||
expect(mocks.supports).not.toHaveBeenCalled()
|
||||
expect(mocks.call).toHaveBeenCalledWith(
|
||||
{ kind: 'local' },
|
||||
'agentSession.reveal',
|
||||
{ sessionId: 'session-1' },
|
||||
expect.objectContaining({ timeoutMs: expect.any(Number) })
|
||||
)
|
||||
})
|
||||
|
||||
it('negotiates against the host that owns the workspace, not the local one', async () => {
|
||||
mocks.environmentIdFor.mockReturnValue('env-1')
|
||||
|
||||
await expect(revealStructuredSession(target)).resolves.toEqual({ ...target, agent: 'codex' })
|
||||
|
||||
expect(mocks.supports).toHaveBeenCalledWith(
|
||||
'env-1',
|
||||
STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY,
|
||||
expect.any(Number)
|
||||
)
|
||||
expect(mocks.call).toHaveBeenCalledWith(
|
||||
{ kind: 'environment', environmentId: 'env-1' },
|
||||
'agentSession.reveal',
|
||||
{ sessionId: 'session-1' },
|
||||
expect.objectContaining({ timeoutMs: expect.any(Number) })
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a known recipient target when the active workspace would resolve locally', async () => {
|
||||
const host = { kind: 'environment', environmentId: 'recipient-host' } as const
|
||||
await expect(revealStructuredSession({ ...target, target: host })).resolves.toEqual({
|
||||
@@ -99,17 +67,6 @@ describe('revealStructuredSession', () => {
|
||||
|
||||
expect(mocks.call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads a version block as a host too old, not as a lost connection', async () => {
|
||||
mocks.environmentIdFor.mockReturnValue('env-1')
|
||||
const blocked = Object.assign(new Error('Update the Orca server'), {
|
||||
code: RUNTIME_COMPAT_BLOCK_CODE
|
||||
})
|
||||
mocks.supports.mockRejectedValue(blocked)
|
||||
|
||||
await expect(revealStructuredSession(target)).resolves.toBe('host-cannot-open')
|
||||
expect(mocks.call).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the actual revealed identity/workspace/provider for reconciliation', async () => {
|
||||
|
||||
@@ -34,25 +34,6 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('doc preview grant lifetime', () => {
|
||||
it('mints once for repeated mounts of the same preview tab', async () => {
|
||||
const request = {
|
||||
owner: { kind: 'ssh' as const, connectionId: 'ssh-1' },
|
||||
requestBase: '/d',
|
||||
root: '/d',
|
||||
entryRelativePath: 'a.html'
|
||||
}
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
ensureDocPreviewGrant('preview-1', request),
|
||||
ensureDocPreviewGrant('preview-1', request)
|
||||
])
|
||||
|
||||
// StrictMode effect replay must neither mint twice nor revoke the surviving webview’s grant.
|
||||
expect(mocks.mintGrant).toHaveBeenCalledOnce()
|
||||
expect(first).toBe(second)
|
||||
expect(mocks.revokeGrant).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: Share reads and keys from the handle's location, so a later host change must not move it.
|
||||
it('keeps the minted location on the handle until the grant is released', async () => {
|
||||
const minted = {
|
||||
@@ -85,22 +66,6 @@ describe('doc preview grant lifetime', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('revokes on release and mints fresh afterwards', async () => {
|
||||
const request = {
|
||||
owner: { kind: 'ssh' as const, connectionId: 'ssh-1' },
|
||||
requestBase: '/d',
|
||||
root: '/d',
|
||||
entryRelativePath: 'a.html'
|
||||
}
|
||||
await ensureDocPreviewGrant('preview-2', request)
|
||||
|
||||
releaseDocPreviewGrant('preview-2')
|
||||
await vi.waitFor(() => expect(mocks.revokeGrant).toHaveBeenCalledWith('grant-1'))
|
||||
|
||||
await ensureDocPreviewGrant('preview-2', request)
|
||||
expect(mocks.mintGrant).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
// Late rejection from a released tab must not evict its reopened tab’s new, otherwise unrevokable grant.
|
||||
it('leaves the entry of a later mint alone when an earlier one rejects', async () => {
|
||||
const request = {
|
||||
@@ -129,21 +94,6 @@ describe('doc preview grant lifetime', () => {
|
||||
releaseDocPreviewGrant('preview-4')
|
||||
await vi.waitFor(() => expect(mocks.revokeGrant).toHaveBeenCalledWith('grant-2'))
|
||||
})
|
||||
|
||||
it('does not cache a failed mint', async () => {
|
||||
mocks.mintGrant.mockRejectedValueOnce(new Error('runtime offline'))
|
||||
const request = {
|
||||
owner: { kind: 'ssh' as const, connectionId: 'ssh-1' },
|
||||
requestBase: '/d',
|
||||
root: '/d',
|
||||
entryRelativePath: 'a.html'
|
||||
}
|
||||
|
||||
await expect(ensureDocPreviewGrant('preview-3', request)).rejects.toThrow('runtime offline')
|
||||
await expect(ensureDocPreviewGrant('preview-3', request)).resolves.toMatchObject({
|
||||
grantId: 'grant-1'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('outside-workspace document grants', () => {
|
||||
|
||||
@@ -98,28 +98,6 @@ describe('useImeEnterGestureOwnership', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Only bare confirm redispatch is suppressed; held Cmd/Ctrl still requests deliberate submit.
|
||||
it.each([
|
||||
['meta', { metaKey: true }],
|
||||
['ctrl', { ctrlKey: true }],
|
||||
['alt', { altKey: true }]
|
||||
])('lets the %s+Enter chord through the carried redispatch', (_label, modifier) => {
|
||||
const { result } = renderHook(() => useImeEnterGestureOwnership())
|
||||
result.current.setComposing(true)
|
||||
|
||||
// The IME still owns the confirming keydown even with the modifier held.
|
||||
expect(
|
||||
result.current.ownsKeyDown(
|
||||
gestureEvent({ key: 'Enter', keyCode: 13, isComposing: true, ...modifier })
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
result.current.setComposing(false)
|
||||
const chord = gestureEvent({ key: 'Enter', keyCode: 13, ...modifier })
|
||||
expect(result.current.ownsKeyDown(chord)).toBe(false)
|
||||
expect(chord.prevented).toBe(false)
|
||||
})
|
||||
|
||||
// Chorded redispatch must spend carry without suppressing submit or the next Enter would be swallowed.
|
||||
it('spends the carry on the chorded redispatch so the next Enter is not eaten', () => {
|
||||
const { result } = renderHook(() => useImeEnterGestureOwnership())
|
||||
|
||||
@@ -71,23 +71,6 @@ describe('openMobileEmulatorTab', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('does not start a duplicate attach while a manual launch is already pending', async () => {
|
||||
let resolveAttach: (value: typeof mockAttachResult) => void = () => {}
|
||||
vi.mocked(callRuntimeRpc).mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveAttach = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const firstLaunch = openMobileEmulatorTab('wt-1', { targetGroupId: 'group-1' })
|
||||
await expect(openMobileEmulatorTab('wt-1', { targetGroupId: 'group-1' })).resolves.toBe('sim-1')
|
||||
|
||||
expect(callRuntimeRpc).toHaveBeenCalledTimes(1)
|
||||
resolveAttach(mockAttachResult)
|
||||
await firstLaunch
|
||||
})
|
||||
|
||||
it('shuts down the managed emulator if the tab closes before attach resolves', async () => {
|
||||
let resolveAttach: (value: typeof mockAttachResult) => void = () => {}
|
||||
vi.mocked(callRuntimeRpc).mockImplementation(async (_target, method) => {
|
||||
@@ -115,23 +98,6 @@ describe('openMobileEmulatorTab', () => {
|
||||
expect(consumePrelaunchedSimulatorSession('wt-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('allows a successful retry after simulator tab creation returns null', async () => {
|
||||
vi.mocked(ensureSimulatorTab).mockReturnValueOnce(null).mockReturnValueOnce('sim-retry')
|
||||
vi.mocked(callRuntimeRpc).mockResolvedValue(mockAttachResult)
|
||||
|
||||
await expect(openMobileEmulatorTab('wt-1', { targetGroupId: 'group-1' })).resolves.toBeNull()
|
||||
await expect(openMobileEmulatorTab('wt-1', { targetGroupId: 'group-1' })).resolves.toBe(
|
||||
'sim-retry'
|
||||
)
|
||||
|
||||
expect(ensureSimulatorTab).toHaveBeenCalledTimes(2)
|
||||
const attachCalls = vi
|
||||
.mocked(callRuntimeRpc)
|
||||
.mock.calls.filter(([, method]) => method === 'emulator.attach')
|
||||
expect(attachCalls).toHaveLength(1)
|
||||
expect(isManualSimulatorLaunchPending('wt-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not release another in-flight launch when duplicate tab creation throws', async () => {
|
||||
let resolveAttach: (value: typeof mockAttachResult) => void = () => {}
|
||||
vi.mocked(callRuntimeRpc).mockImplementation(
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ManagedPaneInternal, ScrollState } from './pane-manager-types'
|
||||
import {
|
||||
attachPaneFitResizeObserver,
|
||||
detachPaneFitResizeObserver,
|
||||
requestStablePaneFit
|
||||
} from './pane-fit-resize-observer'
|
||||
import { requestStablePaneFit } from './pane-fit-resize-observer'
|
||||
import {
|
||||
beginTerminalScrollIntentBufferRebuild,
|
||||
endTerminalScrollIntentBufferRebuild
|
||||
@@ -113,21 +109,6 @@ describe('attachPaneFitResizeObserver', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('coalesces repeated observer callbacks and fits once the grid is stable', () => {
|
||||
const pane = createPane()
|
||||
|
||||
attachPaneFitResizeObserver(pane)
|
||||
mockResizeObservers[0]?.trigger()
|
||||
mockResizeObservers[0]?.trigger()
|
||||
|
||||
expect(requestAnimationFrame).toHaveBeenCalledTimes(1)
|
||||
expect(pane.fitAddon.fit).not.toHaveBeenCalled()
|
||||
|
||||
flushAnimationFrames()
|
||||
|
||||
expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('releases a hidden reattach continuation when the stable grid already matches', async () => {
|
||||
const pane = createPane()
|
||||
vi.mocked(pane.fitAddon.proposeDimensions).mockReturnValue(undefined)
|
||||
@@ -160,38 +141,4 @@ describe('attachPaneFitResizeObserver', () => {
|
||||
expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1)
|
||||
expect(onSettled).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throttles an endlessly unstable grid instead of fitting every frame', () => {
|
||||
let cols = 80
|
||||
const pane = createPane(() => {
|
||||
cols = cols === 80 ? 81 : 80
|
||||
return { cols, rows: 24 }
|
||||
})
|
||||
|
||||
attachPaneFitResizeObserver(pane)
|
||||
mockResizeObservers[0]?.trigger()
|
||||
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
flushAnimationFrames()
|
||||
}
|
||||
|
||||
expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1)
|
||||
expect(pane.pendingObservedFitRafId).toBeNull()
|
||||
})
|
||||
|
||||
it('disconnects the observer and cancels any queued fit', () => {
|
||||
const pane = createPane()
|
||||
|
||||
attachPaneFitResizeObserver(pane)
|
||||
mockResizeObservers[0]?.trigger()
|
||||
const scheduledRafId = pane.pendingObservedFitRafId
|
||||
|
||||
detachPaneFitResizeObserver(pane)
|
||||
flushAnimationFrames()
|
||||
|
||||
expect(mockResizeObservers[0]?.disconnect).toHaveBeenCalledTimes(1)
|
||||
expect(cancelAnimationFrame).toHaveBeenCalledWith(scheduledRafId)
|
||||
expect(pane.fitAddon.fit).not.toHaveBeenCalled()
|
||||
expect(pane.pendingObservedFitRafId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,22 +57,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('terminal scroll-intent input resync', () => {
|
||||
it('does not apply input resync after tracking is disposed', () => {
|
||||
vi.stubGlobal('requestAnimationFrame', () => 0)
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100)
|
||||
const host = new TestElement() as unknown as HTMLElement
|
||||
const disposable = attachTerminalScrollIntentTracking(terminal, host)
|
||||
|
||||
markTerminalPinnedViewport(terminal)
|
||||
capturedUserInput.listener?.()
|
||||
disposable.dispose()
|
||||
terminal.buffer.active.viewportY = terminal.buffer.active.baseY
|
||||
capturedInput.listener?.('a')
|
||||
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
|
||||
})
|
||||
|
||||
it('defers real typing intent until a snapshot rebuild completes', () => {
|
||||
vi.stubGlobal('requestAnimationFrame', () => 0)
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
|
||||
@@ -61,40 +61,6 @@ describe('a finished Claude turn reaches the hibernation planner', () => {
|
||||
resetHibernationPaneAgeForTests()
|
||||
})
|
||||
|
||||
// Pin planner/anchor seam: broad resumable-agent recovery must match sleep exemption, formerly Pi-only (#10238).
|
||||
it('writes a live resume anchor that no longer blocks it', () => {
|
||||
const store = createTestStore()
|
||||
seedWorkspace(store)
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
PANE,
|
||||
{ state: 'working', prompt: 'summarize the diff', agentType: 'claude' },
|
||||
'Claude',
|
||||
{ updatedAt: DONE_AT - 5_000, stateStartedAt: DONE_AT - 5_000 },
|
||||
ROUTING,
|
||||
{ providerSession: PROVIDER_SESSION }
|
||||
)
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
PANE,
|
||||
{ state: 'done', prompt: 'summarize the diff', agentType: 'claude' },
|
||||
'Claude',
|
||||
{ updatedAt: DONE_AT, stateStartedAt: DONE_AT },
|
||||
ROUTING,
|
||||
{ providerSession: PROVIDER_SESSION }
|
||||
)
|
||||
|
||||
const state = store.getState()
|
||||
expect(state.agentStatusByPaneKey[PANE]?.state).toBe('done')
|
||||
expect(state.sleepingAgentSessionsByPaneKey[PANE]).toMatchObject({
|
||||
agent: 'claude',
|
||||
origin: 'live'
|
||||
})
|
||||
expect(planFrom(state, DONE_AT)).toEqual([PANE])
|
||||
})
|
||||
|
||||
it('is not restarted by a done→done repaint carrying a fresh updatedAt', () => {
|
||||
const store = createTestStore()
|
||||
seedWorkspace(store)
|
||||
|
||||
@@ -35,30 +35,6 @@ async function createAnnotatedPage() {
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('saved browser note lifetime and marker eligibility', () => {
|
||||
it('retires same-URL geometry without changing the saved array or object references', async () => {
|
||||
const { store, pageId } = await createAnnotatedPage()
|
||||
const saved = store.getState().browserAnnotationsByPageId[pageId]
|
||||
const note = saved[0]
|
||||
expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toEqual([note.id])
|
||||
|
||||
store.getState().setBrowserPageUrl(pageId, 'https://example.com')
|
||||
expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toEqual([note.id])
|
||||
store.getState().invalidateBrowserPageAnnotationGeometry(pageId)
|
||||
|
||||
expect(store.getState().browserAnnotationsByPageId[pageId]).toBe(saved)
|
||||
expect(store.getState().browserAnnotationsByPageId[pageId][0]).toBe(note)
|
||||
expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toBeUndefined()
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = store.subscribe(listener)
|
||||
store.getState().invalidateBrowserPageAnnotationGeometry(pageId)
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
unsubscribe()
|
||||
|
||||
store.getState().addBrowserPageAnnotation(makeAnnotation(pageId, 'fresh'))
|
||||
expect(store.getState().browserAnnotationsByPageId[pageId][0]).toBe(note)
|
||||
expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toEqual(['fresh'])
|
||||
})
|
||||
|
||||
it('removes an accepted delivery snapshot after navigation while preserving later edits and additions', async () => {
|
||||
const { store, pageId } = await createAnnotatedPage()
|
||||
store.getState().addBrowserPageAnnotation(makeAnnotation(pageId, 'edited'))
|
||||
|
||||
+2
-67
@@ -1,17 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision'
|
||||
import { assertEditorBufferInvariant } from '../../../editor-buffer-record-lifecycle'
|
||||
import { makeWorktree } from '../../worktrees-slice-test-fixtures'
|
||||
import type { OpenFile } from '../types/open-file'
|
||||
import type * as identityModule from './editor-buffer-identity'
|
||||
import { editorBufferIdentityOf } from './editor-buffer-identity'
|
||||
|
||||
// Counts identity resolutions, so the scale test can prove the index never re-resolves per keystroke.
|
||||
vi.mock('./editor-buffer-identity', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof identityModule>()
|
||||
return { ...actual, editorBufferIdentityOf: vi.fn(actual.editorBufferIdentityOf) }
|
||||
})
|
||||
import type { OpenFile } from '../types/open-file'
|
||||
|
||||
function tab(id: string, patch: Partial<OpenFile> = {}): OpenFile {
|
||||
return {
|
||||
@@ -37,7 +29,6 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
replaceRuntimeEnvironmentRevisions([])
|
||||
vi.mocked(editorBufferIdentityOf).mockClear()
|
||||
})
|
||||
|
||||
describe('a retired runtime', () => {
|
||||
@@ -95,59 +86,3 @@ describe('SSH identity through the record index', () => {
|
||||
expect(state().editorDrafts.w2).toBe('edits')
|
||||
})
|
||||
})
|
||||
|
||||
describe('scale', () => {
|
||||
const FILES = 20
|
||||
const WORKSPACES = 10
|
||||
|
||||
it('indexes 200 tabs across 20 files with one resolution per tab, and none per keystroke', () => {
|
||||
const worktrees = Array.from({ length: WORKSPACES }, (_, w) =>
|
||||
makeWorktree({ id: `wt-${w}`, repoId: 'repo-1', path: '/repo', hostId: 'local' })
|
||||
)
|
||||
const tabs = Array.from({ length: FILES * WORKSPACES }, (_, i) => {
|
||||
const file = i % FILES
|
||||
return tab(`t${i}`, {
|
||||
filePath: `/repo/f${file}.ts`,
|
||||
relativePath: `f${file}.ts`,
|
||||
worktreeId: `wt-${Math.floor(i / FILES)}`
|
||||
})
|
||||
})
|
||||
const resolutions = vi.mocked(editorBufferIdentityOf)
|
||||
useAppStore.setState({ worktreesByRepo: { 'repo-1': worktrees } })
|
||||
resolutions.mockClear()
|
||||
|
||||
expect(() => useAppStore.setState({ openFiles: tabs })).not.toThrow()
|
||||
expect(resolutions).toHaveBeenCalledTimes(tabs.length)
|
||||
expect(Object.keys(state().editorBuffers)).toHaveLength(FILES)
|
||||
for (const viewIds of Object.values(state().editorBufferViews)) {
|
||||
expect(viewIds).toHaveLength(WORKSPACES)
|
||||
}
|
||||
expect(() => assertEditorBufferInvariant(state())).not.toThrow()
|
||||
|
||||
// Going dirty rewrites only the record's own views.
|
||||
resolutions.mockClear()
|
||||
state().setEditorDraft('t0', 'e')
|
||||
state().markFileDirty('t0', true)
|
||||
expect(resolutions).toHaveBeenCalledTimes(WORKSPACES)
|
||||
resolutions.mockClear()
|
||||
state().setEditorDraft('t0', 'edits')
|
||||
expect(resolutions).not.toHaveBeenCalled()
|
||||
const dirty = state().openFiles.filter((file) => file.isDirty)
|
||||
expect(dirty.map((file) => file.id)).toEqual(
|
||||
Array.from({ length: WORKSPACES }, (_, w) => `t${w * FILES}`)
|
||||
)
|
||||
|
||||
// One more tab resolves only itself.
|
||||
useAppStore.setState((s) => ({
|
||||
openFiles: [...s.openFiles, tab('late', { worktreeId: 'wt-3', filePath: '/repo/f0.ts' })]
|
||||
}))
|
||||
expect(resolutions).toHaveBeenCalledTimes(1)
|
||||
expect(recordOf('late')).toBe(recordOf('t0'))
|
||||
expect(view('late')?.isDirty).toBe(true)
|
||||
|
||||
// A route change re-resolves each tab once.
|
||||
resolutions.mockClear()
|
||||
state().reconcileEditorBufferIdentities()
|
||||
expect(resolutions).toHaveBeenCalledTimes(tabs.length + 1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,10 +12,8 @@ import {
|
||||
import { createTestStore, makeWorktree } from './store-test-helpers'
|
||||
import { buildWorktreePurgeState } from './worktrees/teardown/worktree-purge-state'
|
||||
import { applyRemoveWorktreeSuccessState } from './worktrees/teardown/remove-worktree-store-cleanup'
|
||||
import { buildWorktreeRenameState } from './worktrees/session/worktree-identity-rename-state'
|
||||
|
||||
const removed = 'repo::/workspace/deleted'
|
||||
const kept = 'repo::/workspace/kept'
|
||||
|
||||
function mountDiff(worktreeId: string): { key: string; lease: CombinedDiffViewLease } {
|
||||
const key = `${worktreeId}::all-diffs::uncommitted::pane`
|
||||
@@ -59,44 +57,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('removed workspace diff memory', () => {
|
||||
it('preserves cached bodies and scroll through rename, then releases them on deletion', () => {
|
||||
const key = seedDiff(removed)
|
||||
const keptKey = seedDiff(kept)
|
||||
const renamed = 'repo::/workspace/renamed'
|
||||
const store = createTestStore()
|
||||
const sections = readCombinedDiffViewMemory(key)?.view?.sections
|
||||
store.setState(buildWorktreeRenameState(store.getState(), removed, renamed))
|
||||
expect(readCombinedDiffViewMemory(key)).toMatchObject({
|
||||
worktreeId: renamed,
|
||||
scrollTop: 42,
|
||||
scrollAnchor: { key: 'source', offset: 0 }
|
||||
})
|
||||
expect(readCombinedDiffViewMemory(key)?.view?.sections).toBe(sections)
|
||||
buildWorktreePurgeState(store.getState(), [removed])
|
||||
expect(readCombinedDiffViewMemory(key)).toBeDefined()
|
||||
buildWorktreePurgeState(store.getState(), [renamed])
|
||||
expect(readCombinedDiffViewMemory(key)).toBeUndefined()
|
||||
expect(readCombinedDiffViewMemory(keptKey)).toBeDefined()
|
||||
})
|
||||
|
||||
it.each(['listing', 'explicit'] as const)(
|
||||
'releases cached file bodies on %s removal and preserves another workspace',
|
||||
(path) => {
|
||||
const removedKey = seedDiff(removed)
|
||||
const keptKey = seedDiff(kept)
|
||||
const overlappingKey = seedDiff(`${removed}::all-diffs::nested-directory`)
|
||||
const store = createTestStore()
|
||||
if (path === 'listing') {
|
||||
store.setState(buildWorktreePurgeState(store.getState(), [removed]))
|
||||
} else {
|
||||
applyRemoveWorktreeSuccessState(store.setState, removed, new Set())
|
||||
}
|
||||
expect(readCombinedDiffViewMemory(removedKey)).toBeUndefined()
|
||||
expect(readCombinedDiffViewMemory(keptKey)).toBeDefined()
|
||||
expect(readCombinedDiffViewMemory(overlappingKey)).toBeDefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('ignores the unmount write of a viewer whose workspace was already removed', () => {
|
||||
const { key, lease } = mountDiff(removed)
|
||||
const store = createTestStore()
|
||||
@@ -109,23 +69,6 @@ describe('removed workspace diff memory', () => {
|
||||
expect(readCombinedDiffViewMemory(key)).toEqual({ worktreeId: removed, scrollTop: 7 })
|
||||
})
|
||||
|
||||
it('protects a mounted viewer from LRU eviction until release', () => {
|
||||
const { key, lease } = mountDiff(kept)
|
||||
const memory = readCombinedDiffViewMemory(key)
|
||||
expect(memory).toBeDefined()
|
||||
const oldestInactiveKey = seedDiff('repo::/workspace/other-0')
|
||||
for (let index = 1; index < 20; index += 1) {
|
||||
seedDiff(`repo::/workspace/other-${index}`)
|
||||
}
|
||||
expect(readCombinedDiffViewMemory(key)).toBe(memory)
|
||||
expect(readCombinedDiffViewMemory(oldestInactiveKey)).toBeUndefined()
|
||||
releaseCombinedDiffView(lease)
|
||||
seedDiff('repo::/workspace/other-20')
|
||||
expect(readCombinedDiffViewMemory(key)).toBeUndefined()
|
||||
rememberCombinedDiffView(lease, { scrollTop: 9 })
|
||||
expect(readCombinedDiffViewMemory(key)).toEqual({ worktreeId: kept, scrollTop: 9 })
|
||||
})
|
||||
|
||||
it('keeps cached views when a different host still owns the same workspace id', () => {
|
||||
const key = seedDiff(removed)
|
||||
const store = createTestStore()
|
||||
|
||||
Reference in New Issue
Block a user