mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 09:57:48 +00:00
test: remove low-value renderer policy and inventory tests (#37474)
This commit is contained in:
@@ -20,7 +20,6 @@ on:
|
||||
- 'src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts'
|
||||
- 'src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts'
|
||||
- 'src/main/runtime/hook-status-resume-sleep-ordering.test.ts'
|
||||
- 'src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts'
|
||||
- 'src/main/sqlite/sync-database.test.ts'
|
||||
- 'src/main/runtime/orchestration/db/row-column-lists.test.ts'
|
||||
|
||||
@@ -10353,13 +10353,11 @@
|
||||
"src/renderer/src/hooks/installed-agent-skill-discovery-cache.test.ts": 53,
|
||||
"src/renderer/src/hooks/installed-agent-skill-discovery.test.ts": 74,
|
||||
"src/renderer/src/hooks/installed-agent-skill-runtime-eviction.test.ts": 54,
|
||||
"src/renderer/src/hooks/installed-agent-skill-verdict.test.ts": 195,
|
||||
"src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index-retention.test.ts": 4878,
|
||||
"src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.test.ts": 1892,
|
||||
"src/renderer/src/hooks/ipc-events/agent-status-pending-retry-gate.test.ts": 466,
|
||||
"src/renderer/src/hooks/ipc-events/agent-status-program-status-only.test.ts": 398,
|
||||
"src/renderer/src/hooks/ipc-events/agent-status-reconnect-replay-notification.test.ts": 352,
|
||||
"src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts": 1851,
|
||||
"src/renderer/src/hooks/ipc-events/agent-status-terminal-title-tab-write.test.ts": 2247,
|
||||
"src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.fallback.test.ts": 776,
|
||||
"src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts": 623,
|
||||
|
||||
@@ -10,7 +10,6 @@ const contracts = [
|
||||
'src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts',
|
||||
'src/main/runtime/hook-status-resume-sleep-ordering.test.ts',
|
||||
'src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts',
|
||||
'src/shared/tui-agent-permission-choice.test.ts',
|
||||
'src/renderer/src/components/settings/agent-permission-conversion-persistence.test.tsx',
|
||||
'src/main/runtime/rpc/methods/repo.test.ts',
|
||||
|
||||
@@ -163,15 +163,6 @@ it.each(['worktree', 'folder'] as const)(
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['worktree', 'folder'] as const)(
|
||||
'preserves the %s watcher snapshot when selection and watched roots stay unchanged',
|
||||
(workspace) => {
|
||||
const state = makeState(workspace)
|
||||
const first = selectEditorExternalWatchTargets(state)
|
||||
expect(selectEditorExternalWatchTargets({ ...state })).toBe(first)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['worktree', 'folder'] as const)(
|
||||
"watches the selected host's %s root when hosts publish the same id at different roots",
|
||||
(workspace) => {
|
||||
|
||||
@@ -16,7 +16,6 @@ vi.mock('@/runtime/runtime-skills-client', () => ({ discoverSkillsForRuntimeTarg
|
||||
const {
|
||||
discoverInstalledAgentSkills,
|
||||
getRuntimeScopedSkillDiscoveryKey,
|
||||
getSkillDiscoveryTargetKey,
|
||||
invalidateInstalledAgentSkillDiscovery,
|
||||
resetSkillDiscoveryCacheForTests
|
||||
} = await import('./installed-agent-skill-discovery')
|
||||
@@ -217,39 +216,6 @@ describe('installed agent skill discovery lifecycle', () => {
|
||||
expect(discoverSkillsForRuntimeTarget).toHaveBeenCalledTimes(targets.length)
|
||||
})
|
||||
|
||||
it('keys targets by runtime and project identity', () => {
|
||||
expect(getSkillDiscoveryTargetKey(undefined)).toBe('host')
|
||||
expect(getSkillDiscoveryTargetKey({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe('wsl:Ubuntu')
|
||||
expect(getSkillDiscoveryTargetKey({ projectRuntime: resolvedHostProjectRuntime })).toBe(
|
||||
'repo-1:windows-host'
|
||||
)
|
||||
expect(getSkillDiscoveryTargetKey({ projectRuntime: repairProjectRuntime })).toBe(
|
||||
'repo-1:repair:wsl-distro-required:default'
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes effective filters into local, project, and environment keys', () => {
|
||||
const target = {
|
||||
projectRuntime: resolvedWslProjectRuntime,
|
||||
names: [' Computer-Use ', 'orchestration', 'computer-use'],
|
||||
sourceKinds: ['home' as const]
|
||||
}
|
||||
const normalizedFilters = [['computer-use', 'orchestration'], ['home']]
|
||||
|
||||
expect(getSkillDiscoveryTargetKey(target)).toBe(
|
||||
JSON.stringify(['repo-1:wsl:Ubuntu', ...normalizedFilters])
|
||||
)
|
||||
expect(getRuntimeScopedSkillDiscoveryKey(remote('env-a'), target)).toBe(
|
||||
JSON.stringify(['runtime:env-a', ...normalizedFilters])
|
||||
)
|
||||
expect(getSkillDiscoveryTargetKey(target, [], [])).toBe(
|
||||
JSON.stringify(['repo-1:wsl:Ubuntu', ...normalizedFilters])
|
||||
)
|
||||
expect(getSkillDiscoveryTargetKey(target, ['ORCHESTRATION'], [])).toBe(
|
||||
JSON.stringify(['repo-1:wsl:Ubuntu', ['orchestration'], ['home']])
|
||||
)
|
||||
})
|
||||
|
||||
it('keys the bounded cache by runtime scope, not by the client target', async () => {
|
||||
// Keep runtime-scoped remote keys through cache bounding; host-only target keys collapse environments (#6887).
|
||||
expect(getRuntimeScopedSkillDiscoveryKey(remote('env-a'), undefined)).toBe('runtime:env-a')
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SkillDiscoverySource, SkillSourceKind } from '../../../shared/skills'
|
||||
|
||||
import {
|
||||
getInstalledAgentSkillVerdict,
|
||||
type InstalledAgentSkillScan
|
||||
} from './installed-agent-skill-verdict'
|
||||
|
||||
function source(
|
||||
sourceKind: SkillSourceKind,
|
||||
skippedReason?: SkillDiscoverySource['skippedReason']
|
||||
): SkillDiscoverySource {
|
||||
return {
|
||||
id: `${sourceKind}-root`,
|
||||
label: sourceKind,
|
||||
path: `/roots/${sourceKind}`,
|
||||
sourceKind,
|
||||
providers: ['claude'],
|
||||
owner: null,
|
||||
// An unread root reports `exists`: the host could not prove otherwise.
|
||||
exists: true,
|
||||
...(skippedReason ? { skippedReason } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function scan(overrides: Partial<InstalledAgentSkillScan> = {}): InstalledAgentSkillScan {
|
||||
return {
|
||||
enabled: true,
|
||||
installed: false,
|
||||
settled: true,
|
||||
error: null,
|
||||
sources: [],
|
||||
sourceKinds: ['home'],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const unverifiable = (overrides: Partial<InstalledAgentSkillScan>): boolean =>
|
||||
getInstalledAgentSkillVerdict(scan(overrides)).installedUnverifiable
|
||||
|
||||
describe('getInstalledAgentSkillVerdict', () => {
|
||||
it('ignores an unreadable root outside the queried source kinds', () => {
|
||||
expect(unverifiable({ sources: [source('repo', 'unavailable')] })).toBe(false)
|
||||
})
|
||||
|
||||
// Pre-answer failure leaves sources empty, so missing results need a separate unknown check.
|
||||
it('cannot vouch for a negative when the scan errored before ever answering', () => {
|
||||
expect(unverifiable({ settled: false, error: 'scan failed' })).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps an answer it already holds when a later refresh fails', () => {
|
||||
expect(unverifiable({ settled: true, error: 'scan failed' })).toBe(false)
|
||||
})
|
||||
|
||||
it('stays silent while a first scan is still pending with no error', () => {
|
||||
expect(unverifiable({ settled: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('takes finding the skill as proof, whatever else failed', () => {
|
||||
expect(unverifiable({ installed: true, settled: false, error: 'scan failed' })).toBe(false)
|
||||
})
|
||||
|
||||
it('says nothing about a query that is switched off', () => {
|
||||
expect(unverifiable({ enabled: false, sources: [source('home', 'unavailable')] })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('installed-skill verdict while a WSL distro is stopped (E9)', () => {
|
||||
// What main answers for a blocked WSL target or a blocked repo root: never read, never "missing".
|
||||
const stoppedDistroSource = (sourceKind: SkillSourceKind): SkillDiscoverySource => ({
|
||||
id: `wsl-unavailable-${sourceKind}-ubuntu`,
|
||||
label: 'WSL Ubuntu',
|
||||
path: '',
|
||||
sourceKind,
|
||||
providers: [],
|
||||
owner: null,
|
||||
exists: false,
|
||||
skippedReason: 'unavailable'
|
||||
})
|
||||
|
||||
it('doubts a negative answer instead of confirming the skill is not installed', () => {
|
||||
const verdict = getInstalledAgentSkillVerdict(
|
||||
scan({ sources: [stoppedDistroSource('home'), stoppedDistroSource('repo')] })
|
||||
)
|
||||
expect(verdict.installedUnverifiable).toBe(true)
|
||||
expect(verdict.error).toBe('A skill folder did not respond, so this status may be incomplete.')
|
||||
})
|
||||
})
|
||||
+1
-13
@@ -46,13 +46,10 @@ function createApplicator(): (data: AgentStatusIpcPayload) => unknown {
|
||||
|
||||
// A relay reconnect re-sends every cached row; each replayed `done` used to notify again.
|
||||
describe('live agent-status replay notifications', () => {
|
||||
let setAgentStatus: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.observe.mockReset()
|
||||
setAgentStatus = vi.fn()
|
||||
mocks.state.current = buildStoreState({
|
||||
setAgentStatus,
|
||||
setAgentStatus: vi.fn(),
|
||||
workspaceSessionReady: true,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Claude' }]
|
||||
@@ -63,15 +60,6 @@ describe('live agent-status replay notifications', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('updates status from a replayed done without observing it as a completion', () => {
|
||||
const apply = createApplicator()
|
||||
|
||||
expect(apply(status({ isReplay: true }))).toBe('applied')
|
||||
|
||||
expect(setAgentStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.observe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
function seedRow(row: { state: string; stateStartedAt: number }): void {
|
||||
const current = mocks.state.current
|
||||
if (current) {
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentStatusIpcPayload,
|
||||
RefusedPaneResume
|
||||
} from '../../../../shared/agent-status-types'
|
||||
import { createTestStore } from '@/store/slices/store-test-helpers'
|
||||
import { createStoreSessionMockApi } from '@/store/slices/store-session-test-harness'
|
||||
import { registerAgentStatusListeners } from './agent-status-listeners'
|
||||
|
||||
const bridge = vi.hoisted(() => ({ getState: vi.fn(), setState: vi.fn() }))
|
||||
vi.mock('@/store', () => ({ useAppStore: bridge }))
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it.each([10, 100])(
|
||||
'counts refused-clear queue moves and real store notifications at %i entries',
|
||||
(size) => {
|
||||
vi.stubGlobal('window', undefined)
|
||||
createStoreSessionMockApi()
|
||||
const store = createTestStore()
|
||||
bridge.getState.mockImplementation(store.getState)
|
||||
bridge.setState.mockImplementation(store.setState)
|
||||
const refused: RefusedPaneResume = {
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'conversation' },
|
||||
expectedStatus: { paneKey: 'tab:leaf', receivedAt: 10, stateStartedAt: 1 },
|
||||
expectedSavedRecord: { paneKey: 'tab:leaf', capturedAt: 1, updatedAt: 1 }
|
||||
}
|
||||
const event = (index: number): AgentStatusIpcPayload => ({
|
||||
paneKey: index % 2 ? `other-${index}` : 'tab:leaf',
|
||||
connectionId: null,
|
||||
agentType: 'codex',
|
||||
providerSession: refused.providerSession,
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
receivedAt: 10,
|
||||
stateStartedAt: 1
|
||||
})
|
||||
let reads = 0
|
||||
let writes = 0
|
||||
const countQueue = <T>(entries: T[]): T[] => {
|
||||
entries.forEach((entry, index) =>
|
||||
Object.defineProperty(entries, index, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
reads += 1
|
||||
return entry
|
||||
},
|
||||
set: (next: T) => {
|
||||
writes += 1
|
||||
entry = next
|
||||
}
|
||||
})
|
||||
)
|
||||
return entries
|
||||
}
|
||||
let copied = 0
|
||||
const countMap = <T extends object>(entries: T): T =>
|
||||
new Proxy(entries, {
|
||||
ownKeys(target) {
|
||||
const keys = Reflect.ownKeys(target)
|
||||
copied += keys.length
|
||||
return keys
|
||||
}
|
||||
})
|
||||
store.setState({
|
||||
agentStatusByPaneKey: countMap(
|
||||
Object.fromEntries(
|
||||
Array.from({ length: size }, (_, index) => {
|
||||
const row = event(index)
|
||||
return [
|
||||
index === 0 ? 'tab:leaf' : `other-${index}`,
|
||||
{ ...row, updatedAt: 10, stateHistory: [] }
|
||||
]
|
||||
})
|
||||
)
|
||||
),
|
||||
sleepingAgentSessionsByPaneKey: countMap(
|
||||
Object.fromEntries(
|
||||
Array.from({ length: size }, (_, index) => [
|
||||
index === 0 ? 'tab:leaf' : `other-${index}`,
|
||||
{
|
||||
paneKey: index === 0 ? 'tab:leaf' : `other-${index}`,
|
||||
worktreeId: 'wt',
|
||||
agent: 'codex',
|
||||
providerSession: refused.providerSession,
|
||||
prompt: '',
|
||||
state: 'done' as const,
|
||||
capturedAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
])
|
||||
)
|
||||
)
|
||||
})
|
||||
const accepted = vi.fn()
|
||||
const disposeAccepted = store.getState().subscribeAgentStatusChanges(accepted)
|
||||
const previous = store.getState().agentStatusByPaneKey['tab:leaf']
|
||||
const notifications = vi.fn()
|
||||
const unsubscribe = store.subscribe(notifications)
|
||||
const pending = countQueue(
|
||||
Array.from({ length: size }, (_, index) => ({
|
||||
data: event(index),
|
||||
firstSeenAt: 0,
|
||||
replay: false
|
||||
}))
|
||||
)
|
||||
const burst = countQueue(Array.from({ length: size }, (_, index) => event(index)))
|
||||
// Newer same-conversation work must survive the old refusal too.
|
||||
burst[size - 1] = { ...event(0), receivedAt: 11 }
|
||||
const onClear = vi.fn<NonNullable<typeof window.api.agentStatus.onClear>>(() => () => {})
|
||||
vi.stubGlobal('window', {
|
||||
...window,
|
||||
api: {
|
||||
...window.api,
|
||||
agentStatus: {
|
||||
onSet: vi.fn(() => () => {}),
|
||||
onClear
|
||||
}
|
||||
}
|
||||
})
|
||||
const unsubs: (() => void)[] = []
|
||||
registerAgentStatusListeners({
|
||||
unsubs,
|
||||
pendingAgentStatusEvents: pending,
|
||||
liveAgentStatusBurstQueue: burst,
|
||||
transientClearWatermarkByConnectionId: new Map(),
|
||||
enqueueLiveAgentStatus: vi.fn(),
|
||||
drainQueuedLiveAgentStatusesForPane: vi.fn()
|
||||
})
|
||||
const clear = onClear.mock.calls[0]![0]
|
||||
reads = 0
|
||||
writes = 0
|
||||
copied = 0
|
||||
try {
|
||||
clear({ paneKey: 'tab:leaf', refusedResume: refused })
|
||||
expect(accepted).toHaveBeenCalledExactlyOnceWith([
|
||||
{ paneKey: 'tab:leaf', previous, current: undefined }
|
||||
])
|
||||
const removed = size / 2
|
||||
// Reverse splice shifts 1+…+m surviving entries in EACH queue, besides one predicate read per row.
|
||||
expect([reads, writes]).toEqual([3 * size + removed * (removed + 1), removed * (removed + 1)])
|
||||
expect(copied).toBe(2 * size)
|
||||
expect(notifications).toHaveBeenCalledTimes(2)
|
||||
expect([pending.length, burst.length]).toEqual([removed, removed])
|
||||
expect(burst.at(-1)?.receivedAt).toBe(11)
|
||||
expect(store.getState().agentStatusByPaneKey['tab:leaf']).toBeUndefined()
|
||||
expect(Object.keys(store.getState().sleepingAgentSessionsByPaneKey)).toHaveLength(size - 1)
|
||||
notifications.mockClear()
|
||||
copied = 0
|
||||
clear({ paneKey: 'tab:leaf', refusedResume: refused })
|
||||
expect(notifications).not.toHaveBeenCalled()
|
||||
expect(copied).toBe(0)
|
||||
} finally {
|
||||
unsubs.forEach((dispose) => dispose())
|
||||
disposeAccepted()
|
||||
unsubscribe()
|
||||
onClear.mockRestore()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1,13 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { useAppStore } from '@/store'
|
||||
import { createTestStore } from '@/store/slices/store-test-helpers'
|
||||
import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title'
|
||||
import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
import { buildWindowApi } from '../ipc-events-agent-status-window-test-fixtures'
|
||||
import type { AgentStatusSetData } from '../ipc-events-agent-status-store-test-fixtures'
|
||||
import { resolvePaneKey, shouldApplyResolvedAgentTerminalTitleToTab } from './agent-status-routing'
|
||||
|
||||
vi.mock('../agent-hook-completion-notifications', () => ({
|
||||
observeAgentHookCompletionForNotification: vi.fn(),
|
||||
@@ -50,53 +48,6 @@ function storeWithDivergedTitleSlots(args: {
|
||||
} as unknown as ReturnType<typeof useAppStore.getState>
|
||||
}
|
||||
|
||||
describe('hook-driven tab title writes', () => {
|
||||
it('exposes the tab record title separately from the pane slot title', () => {
|
||||
const store = storeWithDivergedTitleSlots({
|
||||
tabTitle: 'Codex - action required',
|
||||
paneSlotTitle: 'Codex ready'
|
||||
})
|
||||
|
||||
const resolved = resolvePaneKey(store, PANE_KEY)
|
||||
|
||||
expect(resolved.title).toBe('Codex ready')
|
||||
expect(resolved.tabTitle).toBe('Codex - action required')
|
||||
})
|
||||
|
||||
// Unmounted panes kept old titles, causing the former pane-slot guard to skip clearing Orca’s stale action-required tab title.
|
||||
it('rewrites a stale action-required tab title once the agent reports done', () => {
|
||||
const store = storeWithDivergedTitleSlots({
|
||||
tabTitle: 'Codex - action required',
|
||||
paneSlotTitle: 'Codex ready'
|
||||
})
|
||||
const resolved = resolvePaneKey(store, PANE_KEY)
|
||||
const nextTitle = resolveAgentStatusTerminalTitle(
|
||||
{ agentType: 'codex', state: 'done' },
|
||||
resolved.title
|
||||
)
|
||||
|
||||
expect(nextTitle).toBe('Codex ready')
|
||||
expect(
|
||||
shouldApplyResolvedAgentTerminalTitleToTab(store, PANE_KEY, resolved.title, nextTitle)
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldApplyResolvedAgentTerminalTitleToTab(store, PANE_KEY, resolved.tabTitle, nextTitle)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('still skips the write when the tab record already holds the resolved title', () => {
|
||||
const store = storeWithDivergedTitleSlots({
|
||||
tabTitle: 'Codex ready',
|
||||
paneSlotTitle: 'Codex ready'
|
||||
})
|
||||
const resolved = resolvePaneKey(store, PANE_KEY)
|
||||
|
||||
expect(
|
||||
shouldApplyResolvedAgentTerminalTitleToTab(store, PANE_KEY, resolved.tabTitle, 'Codex ready')
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hook-driven tab title IPC integration', () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock('../../store')
|
||||
|
||||
@@ -15,10 +15,8 @@ const mocks = vi.hoisted(() => {
|
||||
return {
|
||||
...listeners,
|
||||
getState: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
replyTabCreate: vi.fn(),
|
||||
createBrowserTab: vi.fn(),
|
||||
browserTabRequestsReady: vi.fn()
|
||||
createBrowserTab: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -26,16 +24,13 @@ vi.mock('@/components/browser-pane/host-guest/webview-registry', () => ({
|
||||
destroyPersistentWebview: vi.fn()
|
||||
}))
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: { getState: mocks.getState, subscribe: mocks.subscribe }
|
||||
useAppStore: { getState: mocks.getState }
|
||||
}))
|
||||
vi.mock('./browser-automation-bootstrap-lease', () => ({
|
||||
acquireBrowserAutomationBootstrapLease: vi.fn()
|
||||
}))
|
||||
|
||||
import {
|
||||
registerBrowserRequestIpcBridge,
|
||||
signalBrowserTabRequestsReadyAfterRestore
|
||||
} from './browser-request-ipc-bridge'
|
||||
import { registerBrowserRequestIpcBridge } from './browser-request-ipc-bridge'
|
||||
|
||||
const ACTIVE_WT = 'repo-local::/Users/me/app'
|
||||
const RECENT_WT = 'repo-local::/Users/me/app-2'
|
||||
@@ -89,7 +84,6 @@ describe('an extension tab with no active workspace', () => {
|
||||
return () => {}
|
||||
},
|
||||
replyTabCreate: mocks.replyTabCreate,
|
||||
browserTabRequestsReady: mocks.browserTabRequestsReady,
|
||||
onRequestRemoteCliBrowserScope: () => () => {},
|
||||
onRequestTabSetProfile: () => () => {},
|
||||
onRequestTabClose: () => () => {}
|
||||
@@ -100,24 +94,6 @@ describe('an extension tab with no active workspace', () => {
|
||||
registerBrowserRequestIpcBridge([])
|
||||
})
|
||||
|
||||
it('tells main it can take tab requests once workspaces are restored', () => {
|
||||
signalBrowserTabRequestsReadyAfterRestore([])
|
||||
expect(mocks.browserTabRequestsReady).toHaveBeenCalledTimes(1)
|
||||
vi.clearAllMocks()
|
||||
const listeners: ((state: { workspaceSessionReady: boolean }) => void)[] = []
|
||||
mocks.subscribe.mockImplementation(
|
||||
(listener: (state: { workspaceSessionReady: boolean }) => void) => {
|
||||
listeners.push(listener)
|
||||
return () => {}
|
||||
}
|
||||
)
|
||||
mocks.getState.mockReturnValue({ ...storeState(null), workspaceSessionReady: false })
|
||||
signalBrowserTabRequestsReadyAfterRestore([])
|
||||
expect(mocks.browserTabRequestsReady).not.toHaveBeenCalled()
|
||||
listeners.forEach((listener) => listener({ workspaceSessionReady: true }))
|
||||
expect(mocks.browserTabRequestsReady).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("opens in the workspace the profile's tabs were last used in", () => {
|
||||
mocks.getState.mockReturnValue(storeState(null))
|
||||
mocks.createListener?.({
|
||||
|
||||
@@ -231,17 +231,4 @@ describe('a managed SSH host reaches only its own workspaces on this desktop (#2
|
||||
{ requestId: 'p4', workspaceId: null }
|
||||
])
|
||||
})
|
||||
|
||||
it('answers no when the store cannot resolve the owner', () => {
|
||||
mocks.getState.mockImplementation(() => {
|
||||
throw new Error('store unavailable')
|
||||
})
|
||||
|
||||
mocks.scopeListener?.({ requestId: 's5', environmentId: 'env-remote', workspaceId: REMOTE_WT })
|
||||
|
||||
expect(mocks.replyRemoteCliBrowserScope).toHaveBeenCalledWith({
|
||||
requestId: 's5',
|
||||
workspaceId: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -105,43 +105,6 @@ describe('link-opened Orca tabs', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Baseline defect: remove .fails when the captured-claim handoff rejects this event.
|
||||
it.fails('rejects a missing source tab without default-profile fallback', () => {
|
||||
storeState.value = {
|
||||
browserPagesByWorkspace: {
|
||||
'workspace-1': [{ id: 'page-1', workspaceId: 'missing', worktreeId: 'worktree-1' }]
|
||||
},
|
||||
browserTabsByWorktree: {},
|
||||
createBrowserTab: createBrowserTabMock
|
||||
}
|
||||
|
||||
captureOpenLinkHandler()({ browserPageId: 'page-1', url: 'https://docs.example.com/guide' })
|
||||
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates modifier-click links in the background', () => {
|
||||
storeState.value = {
|
||||
browserPagesByWorkspace: {
|
||||
'workspace-1': [{ id: 'page-1', workspaceId: 'workspace-1', worktreeId: 'worktree-1' }]
|
||||
},
|
||||
browserTabsByWorktree: {},
|
||||
createBrowserTab: createBrowserTabMock
|
||||
}
|
||||
|
||||
captureOpenLinkHandler()({
|
||||
browserPageId: 'page-1',
|
||||
url: 'https://docs.example.com/background',
|
||||
activate: false
|
||||
})
|
||||
|
||||
expect(createBrowserTabMock).toHaveBeenCalledWith(
|
||||
'worktree-1',
|
||||
'https://docs.example.com/background',
|
||||
expect.objectContaining({ activate: false })
|
||||
)
|
||||
})
|
||||
|
||||
// Move focus to the opened tab or Cmd/Ctrl+W closes its opener (#18832).
|
||||
it('moves keyboard focus into a foreground link tab but not a background one', () => {
|
||||
storeState.value = {
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
SshConnectionState,
|
||||
SshProviderEpoch
|
||||
} from '../../../../shared/ssh-types'
|
||||
import { makeTab, makeWorktree } from '@/store/slices/store-test-helpers'
|
||||
import { registerDirectSshStateIpcBridge } from './direct-ssh-state-ipc-bridge'
|
||||
|
||||
function connectedState(
|
||||
@@ -40,46 +39,6 @@ async function settle(): Promise<void> {
|
||||
const originalStore = useAppStore.getState()
|
||||
const cleanups: (() => void)[] = []
|
||||
|
||||
const WORKTREE_ID = 'repo-ssh::/work/demo'
|
||||
const TAB_ID = 'tab-ssh'
|
||||
const PTY_ID = 'ssh:target-a@@pty-1'
|
||||
|
||||
function seedSshTab(): void {
|
||||
useAppStore.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-ssh',
|
||||
path: '/work/demo',
|
||||
displayName: 'demo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 1,
|
||||
connectionId: 'target-a',
|
||||
executionHostId: 'ssh:target-a'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'repo-ssh': [
|
||||
makeWorktree({
|
||||
id: WORKTREE_ID,
|
||||
repoId: 'repo-ssh',
|
||||
path: '/work/demo',
|
||||
hostId: 'ssh:target-a'
|
||||
})
|
||||
]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
[WORKTREE_ID]: [makeTab({ id: TAB_ID, worktreeId: WORKTREE_ID, ptyId: PTY_ID })]
|
||||
},
|
||||
ptyIdsByTabId: { [TAB_ID]: [PTY_ID] }
|
||||
})
|
||||
}
|
||||
|
||||
function expectSshTabBound(): void {
|
||||
const state = useAppStore.getState()
|
||||
expect(state.tabsByWorktree[WORKTREE_ID][0].ptyId).toBe(PTY_ID)
|
||||
expect(state.ptyIdsByTabId[TAB_ID]).toEqual([PTY_ID])
|
||||
}
|
||||
|
||||
async function createHarness(
|
||||
initialStates: readonly SshConnectionState[] = [],
|
||||
reconciledStates: readonly SshConnectionState[] = []
|
||||
@@ -159,42 +118,6 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('direct SSH connected-state routing', () => {
|
||||
it.each(['reply-first', 'push-first'] as const)(
|
||||
'keeps the target bindings on connected pushes when ordering is %s',
|
||||
async (ordering) => {
|
||||
seedSshTab()
|
||||
const harness = await createHarness()
|
||||
const state = connectedState()
|
||||
if (ordering === 'reply-first') {
|
||||
harness.applyConnectReply(state)
|
||||
}
|
||||
harness.emit(state)
|
||||
if (ordering === 'push-first') {
|
||||
harness.applyConnectReply(state)
|
||||
}
|
||||
await settle()
|
||||
harness.emit({ ...state })
|
||||
|
||||
expectSshTabBound()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['disconnected', 'error'] as const)(
|
||||
'keeps bindings on %s and on the next connected push',
|
||||
async (status) => {
|
||||
seedSshTab()
|
||||
const state = connectedState()
|
||||
const harness = await createHarness([state])
|
||||
harness.emit({ ...state, status })
|
||||
// Loss of contact is not evidence the remote PTY exited.
|
||||
expectSshTabBound()
|
||||
|
||||
harness.applyConnectReply(state)
|
||||
harness.emit(state)
|
||||
expectSshTabBound()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['providerEpoch', 'connectionGeneration'] as const)(
|
||||
'reconciles a partial %s on initial hydration',
|
||||
async (missingField) => {
|
||||
|
||||
@@ -51,7 +51,6 @@ vi.mock('@/lib/focus-terminal-tab-surface', () => ({
|
||||
|
||||
import { registerTerminalUiRoutingIpcBridge } from './terminal-ui-routing-ipc-bridge'
|
||||
import { registerTerminalPresentationIpcBridge } from './terminal-presentation-ipc-bridge'
|
||||
import { registerTerminalRequestIpcBridge } from './terminal-request-ipc-bridge'
|
||||
import { registerMobileAndTerminalCloseIpcBridge } from './mobile-terminal-close-ipc-bridge'
|
||||
|
||||
const MAIN = 'repo1::/repo1/main'
|
||||
@@ -101,7 +100,6 @@ function setup(): {
|
||||
const unsubs: (() => void)[] = []
|
||||
registerTerminalUiRoutingIpcBridge(unsubs)
|
||||
registerTerminalPresentationIpcBridge(unsubs)
|
||||
registerTerminalRequestIpcBridge(unsubs)
|
||||
registerMobileAndTerminalCloseIpcBridge(unsubs, vi.fn())
|
||||
dispatchEvent.mockClear()
|
||||
return {
|
||||
@@ -173,21 +171,6 @@ describe('taking the user to a floating workspace terminal', () => {
|
||||
expect(testStore().getState().activeTabIdByWorktree[FLOATING]).toBe(tabs.at(-1)?.id)
|
||||
})
|
||||
|
||||
it('a background create into the floating workspace does not open the panel', () => {
|
||||
const { emit, dispatchEvent } = setup()
|
||||
const before = mainWindow()
|
||||
|
||||
emit('onCreateTerminal', { worktreeId: FLOATING, presentation: 'background' })
|
||||
emit('onRequestTerminalCreate', {
|
||||
requestId: 'req-2',
|
||||
worktreeId: FLOATING,
|
||||
presentation: 'background'
|
||||
})
|
||||
|
||||
expect(mainWindow()).toEqual(before)
|
||||
expect(panelToggles(dispatchEvent)).toBe(0)
|
||||
})
|
||||
|
||||
it('focusing a floating editor tab opens the panel on it', async () => {
|
||||
const { emit, dispatchEvent } = setup()
|
||||
const main = await drawTabsFromMain(testStore())
|
||||
|
||||
@@ -393,19 +393,6 @@ describe('runtime file opens on the host desktop', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['caller', 'clients'] as const)(
|
||||
'keeps the desktop still when navigation %s does not target the host',
|
||||
async (navigation) => {
|
||||
const { openFile, store } = await setup('terminal')
|
||||
const before = screenState(store)
|
||||
|
||||
await openFile({ worktreeId: VIEWED, ...VIEWED_APP_TS, navigation })
|
||||
await openFile({ worktreeId: BACKGROUND, ...APP_TS, navigation })
|
||||
|
||||
expect(screenState(store)).toEqual(before)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['all', 'host'] as const)(
|
||||
'brings the user to the file with navigation %s',
|
||||
async (navigation) => {
|
||||
|
||||
@@ -79,15 +79,6 @@ describe('orca profile auth IPC bridge', () => {
|
||||
unsubs.forEach((dispose) => dispose())
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('skips registration when the preload bridge does not expose the event', () => {
|
||||
vi.stubGlobal('window', { api: { orcaProfiles: { authStatus } } })
|
||||
const unsubs: (() => void)[] = []
|
||||
|
||||
registerOrcaProfileAuthIpcBridge(unsubs)
|
||||
|
||||
expect(unsubs).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('orca profile sign-in progress IPC bridge', () => {
|
||||
|
||||
@@ -313,16 +313,4 @@ describe('registerOsMarkdownFileOpenBridge', () => {
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
expect(unhandledRejections).toEqual([])
|
||||
})
|
||||
|
||||
it('tolerates a preload without the markdown open channel', async () => {
|
||||
stubPreload({})
|
||||
|
||||
const unsubs: (() => void)[] = []
|
||||
expect(() => registerOsMarkdownFileOpenBridge(unsubs)).not.toThrow()
|
||||
await settle()
|
||||
|
||||
expect(unsubs).toEqual([])
|
||||
expect(mocks.openFile).not.toHaveBeenCalled()
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -125,24 +125,6 @@ describe('a host that just moved to its managed server', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the server copies only, under the server name', async () => {
|
||||
applySshManagedServerTransition('ssh-1', undefined, {
|
||||
kind: 'managed',
|
||||
environmentId: 'env-1'
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(store.getState().repos.map((entry) => entry.executionHostId)).toEqual([
|
||||
'runtime:env-1'
|
||||
])
|
||||
)
|
||||
const state = store.getState()
|
||||
expect(state.worktreesByRepo['repo-1']).toEqual([])
|
||||
expect(state.runtimeEnvironments.map((entry) => entry.name)).toEqual(['Box server'])
|
||||
expect(state.projectGroups.map((entry) => entry.executionHostId)).toEqual(['runtime:env-1'])
|
||||
expect(state.folderWorkspaces.map((entry) => entry.executionHostId)).toEqual(['runtime:env-1'])
|
||||
})
|
||||
|
||||
it('carries the host’s visit recency to the server instead of leaving it for the next prune', async () => {
|
||||
store.setState({
|
||||
lastVisitedAtByWorktreeId: { [`ssh:ssh-1|${worktreeId}`]: 500, [`local|${worktreeId}`]: 7 }
|
||||
|
||||
@@ -39,11 +39,9 @@ const offer: Status = {
|
||||
reason: 'relay_terminals_live',
|
||||
terminals: 3
|
||||
}
|
||||
const moveToManagedServer = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.assign(window, { api: { ssh: { moveToManagedServer } } })
|
||||
})
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'api')
|
||||
@@ -88,21 +86,3 @@ describe('a host kept connected for the relay bridge', () => {
|
||||
expect(listPanes).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('a host the managed server does not serve', () => {
|
||||
it('shows only for a newly marked offer, never for a plain live-terminals status', () => {
|
||||
applySshManagedServerTransition('ssh-1', offer, offer)
|
||||
applySshManagedServerTransition('ssh-1', undefined, {
|
||||
kind: 'relay',
|
||||
reason: 'relay_terminals_live',
|
||||
terminals: 3
|
||||
})
|
||||
expect(mocks.toast).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stays silent where the move is not available', () => {
|
||||
Object.assign(window, { api: { ssh: {} } })
|
||||
applySshManagedServerTransition('ssh-1', undefined, offer)
|
||||
expect(mocks.toast).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -74,17 +74,6 @@ describe('registerTerminalPaneRevealIpcBridge', () => {
|
||||
expect(cleanup).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('finds the owning server from the workspace when the caller names no host', () => {
|
||||
const { deliver } = installBridge()
|
||||
mocks.ownerEnvironmentIdForWorkspace.mockReturnValue('env-1')
|
||||
|
||||
deliver({ ...request, executionHostId: null })
|
||||
|
||||
expect(mocks.revealTerminalPane).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tabId: toWebTerminalSurfaceTabId('host-tab-1') })
|
||||
)
|
||||
})
|
||||
|
||||
it.each<[string, Partial<TerminalPaneRevealRequest>]>([
|
||||
['a local workspace', { executionHostId: null }],
|
||||
['an SSH workspace this app runs', { executionHostId: 'ssh:box' }],
|
||||
|
||||
@@ -76,11 +76,6 @@ describe('a pane title from main', () => {
|
||||
expect(layout()).toEqual({ ...rootless, titlesByLeafId: { [LEAF_ID]: 'REVIEWER' } })
|
||||
})
|
||||
|
||||
it('drops the field once the last title is cleared', () => {
|
||||
applyRuntimePaneTitle({ tabId: 'tab-1', leafId: SIBLING_ID, title: '' })
|
||||
expect(layout()).not.toHaveProperty('titlesByLeafId')
|
||||
})
|
||||
|
||||
it('ignores unknown tabs, leaves outside the layout, and non-leaf ids', () => {
|
||||
applyRuntimePaneTitle({ tabId: 'other-tab', leafId: LEAF_ID, title: 'WRONG' })
|
||||
applyRuntimePaneTitle({
|
||||
|
||||
@@ -147,13 +147,6 @@ describe('a revealed terminal’s title, in a layout-owner build', () => {
|
||||
expect(state.terminalLayoutsByTabId[TAB]?.ptyIdsByLeafId).toEqual({ [LEAF]: 'pty-1' })
|
||||
expect(state.terminalLayoutsByTabId[TAB]?.titlesByLeafId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('writes no pane title into a tab the window already holds', async () => {
|
||||
const state = await setup(true)
|
||||
|
||||
expect(state.terminalLayoutsByTabId[TAB]?.ptyIdsByLeafId).toEqual({ [LEAF]: 'pty-1' })
|
||||
expect(state.terminalLayoutsByTabId[TAB]?.titlesByLeafId).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('a revealed terminal’s title, while the window owns the layout', () => {
|
||||
|
||||
@@ -44,10 +44,6 @@ function storeWith(state: Parameters<typeof seedStore>[1]) {
|
||||
}
|
||||
|
||||
describe('selectWslHostsWithVisibleWork', () => {
|
||||
it('is empty when no project or terminal on a WSL host is open', () => {
|
||||
expect(selectWslHostsWithVisibleWork(storeWith({}).getState())).toEqual([])
|
||||
})
|
||||
|
||||
it('names the WSL host of the active workspace, routed by the workspace’s own repo', () => {
|
||||
const store = storeWith({ activeWorktreeId: 'wsl-repo::/home/me/app' })
|
||||
expect(selectWslHostsWithVisibleWork(store.getState())).toEqual([WSL_ENV])
|
||||
|
||||
@@ -101,13 +101,6 @@ describe('keyboard tab switches in a server workspace', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('stays local for a workspace no server owns', () => {
|
||||
mocks.ownerEnvironmentId.mockReturnValue(null)
|
||||
mocks.getState.mockReturnValue(serverWorkspaceStore())
|
||||
expect(handleSwitchTabAcrossAllTypes(-1)).toBe(true)
|
||||
expect(mocks.activateOnOwner).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tells the server about a browser tab it hosts, cancelable by later navigation', () => {
|
||||
mocks.getState.mockReturnValue(serverWorkspaceStore())
|
||||
mocks.navOrder.mockReturnValue([
|
||||
@@ -141,14 +134,4 @@ describe('keyboard tab switches in a server workspace', () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a switch to a chat this machine hosts local', () => {
|
||||
mocks.getState.mockReturnValue(serverWorkspaceStore([agentTab('local')]))
|
||||
mocks.navOrder.mockReturnValue([
|
||||
{ type: 'agent-session', id: 'session-1', tabId: 'agent-tab' },
|
||||
{ type: 'terminal', id: 'web-terminal-b', tabId: 'web-terminal-b' }
|
||||
])
|
||||
expect(handleSwitchTabAcrossAllTypes(-1)).toBe(true)
|
||||
expect(mocks.runtimeRpc).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -138,16 +138,6 @@ describe('handleSwitchTerminalTab', () => {
|
||||
expect(store.setActiveTabType).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns false when only one terminal tab exists', () => {
|
||||
const store = makeStore('terminal')
|
||||
getStateMock.mockReturnValue(store)
|
||||
getActiveTabNavOrderMock.mockReturnValue([{ type: 'terminal', id: 'term-1' }])
|
||||
|
||||
expect(handleSwitchTerminalTab(1)).toBe(false)
|
||||
expect(store.setActiveTab).not.toHaveBeenCalled()
|
||||
expect(store.setActiveTabType).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('jumps to the first terminal when an editor tab is active', () => {
|
||||
const store = makeStore('editor')
|
||||
store.activeFileId = 'editor-2'
|
||||
@@ -420,16 +410,6 @@ describe('handleSwitchTabAcrossAllTypes', () => {
|
||||
expect(store.setActiveTab).toHaveBeenCalledWith('term-1')
|
||||
expect(store.setActiveTabType).toHaveBeenCalledWith('terminal', 'wt-1')
|
||||
})
|
||||
|
||||
it('returns false when only one tab exists total', () => {
|
||||
const store = makeStore('terminal')
|
||||
getStateMock.mockReturnValue(store)
|
||||
getActiveTabNavOrderMock.mockReturnValue([{ type: 'terminal', id: 'term-1' }])
|
||||
|
||||
expect(handleSwitchTabAcrossAllTypes(1)).toBe(false)
|
||||
expect(store.setActiveTab).not.toHaveBeenCalled()
|
||||
expect(store.setActiveTabType).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleSwitchRecentTab', () => {
|
||||
@@ -462,26 +442,4 @@ describe('handleSwitchRecentTab', () => {
|
||||
expect(store.activateTab).toHaveBeenCalledWith('tab-b')
|
||||
expect(store.setActiveTabType).toHaveBeenCalledWith('browser', 'wt-1')
|
||||
})
|
||||
|
||||
it('returns false when the MRU stack has no previous visible tab', () => {
|
||||
const store = makeStore('terminal')
|
||||
store.groupsByWorktree = {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'group-1',
|
||||
activeTabId: 'tab-term-1',
|
||||
tabOrder: ['tab-term-1'],
|
||||
recentTabIds: ['tab-term-1']
|
||||
}
|
||||
]
|
||||
}
|
||||
getStateMock.mockReturnValue(store)
|
||||
getActiveTabNavOrderMock.mockReturnValue([
|
||||
{ type: 'terminal', id: 'term-1', tabId: 'tab-term-1' }
|
||||
])
|
||||
|
||||
expect(handleSwitchRecentTab()).toBe(false)
|
||||
expect(store.setActiveTab).not.toHaveBeenCalled()
|
||||
expect(store.setActiveTabType).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user