mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 00:27:39 +00:00
test(notifications): harden remote replay proof
This commit is contained in:
@@ -4845,7 +4845,7 @@
|
||||
"pnpm exec playwright test tests/e2e/paired-remote-agent-visibility-notifications.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-orphan-owner.test.ts src/main/runtime/terminal-orphan-topology.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --maxWorkers=1",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler-spawn-admission.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/runtime/orca-runtime.test.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-session-tabs-visibility-resume-notifications.test.tsx",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-session-tabs-visibility-resume-notifications.test.tsx src/renderer/src/hooks/agent-hook-completion-notifications-inventory.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler-spawn-admission.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts src/renderer/src/lib/codex-session-restart.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
@@ -4858,6 +4858,7 @@
|
||||
"tests/e2e/remote-agent-completion-authority.unit.test.ts",
|
||||
"tests/e2e/paired-remote-agent-visibility-notifications.spec.ts",
|
||||
"src/renderer/src/runtime/web-session-tabs-visibility-resume-notifications.test.tsx",
|
||||
"src/renderer/src/hooks/agent-hook-completion-notifications-inventory.test.ts",
|
||||
"src/renderer/src/runtime/runtime-terminal-inspection.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts",
|
||||
@@ -4932,6 +4933,13 @@
|
||||
"the active scoped stream may win resume ordering without losing or duplicating attention"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/hooks/agent-hook-completion-notifications-inventory.test.ts",
|
||||
"assertions": [
|
||||
"cold done and blocked inventory seeds notification identity without dispatching",
|
||||
"later done, blocked, and stamped Claude completion identities dispatch once"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/runtime/runtime-terminal-inspection.test.ts",
|
||||
"assertions": [
|
||||
|
||||
+30
@@ -108,6 +108,36 @@ describe('agent completion coordinator', () => {
|
||||
expect(dispatchCompletion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not let a suppressed cold seed silence a later real permission prompt', () => {
|
||||
const dispatchAttention = vi.fn()
|
||||
let suppress = true
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion: vi.fn(),
|
||||
dispatchAttention,
|
||||
isLive: () => true,
|
||||
shouldSuppressHookCompletion: (payload) => suppress && payload.state === 'waiting'
|
||||
})
|
||||
const working = { state: 'working' as const, prompt: 'ship it', agentType: 'codex' as const }
|
||||
const waiting = {
|
||||
state: 'waiting' as const,
|
||||
prompt: 'ship it',
|
||||
agentType: 'codex' as const,
|
||||
stateStartedAt: 100
|
||||
}
|
||||
|
||||
coordinator.observeHookStatus(working)
|
||||
coordinator.seedHookStatus(waiting)
|
||||
suppress = false
|
||||
coordinator.observeHookStatus(waiting)
|
||||
vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS)
|
||||
|
||||
expect(dispatchAttention).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not dispatch completion when a blocked state arrives mid-turn', () => {
|
||||
const dispatchCompletion = vi.fn()
|
||||
const dispatchAttention = vi.fn()
|
||||
|
||||
+23
@@ -8,6 +8,29 @@ import {
|
||||
describe('agent completion coordinator', () => {
|
||||
useAgentCompletionCoordinatorLifecycle()
|
||||
|
||||
it('does not seed a completion identity from an idle session boundary', () => {
|
||||
const dispatchCompletion = vi.fn()
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess: vi.fn(),
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
})
|
||||
|
||||
coordinator.seedHookStatus({
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
agentType: 'claude',
|
||||
sessionBoundary: true,
|
||||
stateStartedAt: 1_700_000_000_000
|
||||
})
|
||||
coordinator.observeClassifiedTitleCompletion('Claude done')
|
||||
|
||||
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('allows later done-only hook completions from the same long-lived process', () => {
|
||||
const dispatchCompletion = vi.fn()
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
|
||||
@@ -1159,6 +1159,11 @@ export function createAgentCompletionCoordinator(
|
||||
return
|
||||
}
|
||||
recordPaneActivity()
|
||||
if (options.shouldSuppressHookCompletion?.(payload)) {
|
||||
clearPendingHookDone()
|
||||
clearPendingCodexAttention()
|
||||
return
|
||||
}
|
||||
if (isRecognizedAgentType(payload.agentType)) {
|
||||
establishAgentEvidence()
|
||||
}
|
||||
@@ -1168,6 +1173,9 @@ export function createAgentCompletionCoordinator(
|
||||
lastAttentionToken = hookAttentionToken(payload)
|
||||
return
|
||||
}
|
||||
if (payload.state === 'done' && payload.sessionBoundary === true) {
|
||||
return
|
||||
}
|
||||
const identity = hookCompletionIdentity(payload)
|
||||
if (!identity) {
|
||||
return
|
||||
|
||||
@@ -60,8 +60,7 @@ describe('agent hook inventory notification baselines', () => {
|
||||
observe({
|
||||
paneKey: PANE_KEY,
|
||||
worktreeId: 'wt-1',
|
||||
payload: status('done', 200),
|
||||
seedOnly: true
|
||||
payload: status('done', 200)
|
||||
})
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -79,8 +78,7 @@ describe('agent hook inventory notification baselines', () => {
|
||||
observe({
|
||||
paneKey: PANE_KEY,
|
||||
worktreeId: 'wt-1',
|
||||
payload: status('blocked', 200),
|
||||
seedOnly: true
|
||||
payload: status('blocked', 200)
|
||||
})
|
||||
vi.advanceTimersByTime(1_500)
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
|
||||
@@ -99,8 +97,7 @@ describe('agent hook inventory notification baselines', () => {
|
||||
observe({
|
||||
paneKey: PANE_KEY,
|
||||
worktreeId: 'wt-1',
|
||||
payload: { ...status('working', 100, 'claude'), turnCompletedAt: 200 },
|
||||
seedOnly: true
|
||||
payload: { ...status('working', 100, 'claude'), turnCompletedAt: 200 }
|
||||
})
|
||||
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -310,7 +310,6 @@ export function observeAgentHookCompletionForNotification({
|
||||
}
|
||||
|
||||
let entry = coordinatorsByPaneKey.get(paneKey)
|
||||
const shouldSeed = seedOnly === true && (!entry || entry.worktreeId !== worktreeId)
|
||||
if (!entry || entry.worktreeId !== worktreeId) {
|
||||
entry?.coordinator.dispose()
|
||||
entry = {
|
||||
@@ -327,7 +326,7 @@ export function observeAgentHookCompletionForNotification({
|
||||
if (payload.state === 'working' && payload.turnCompletedAt === undefined && trackingEnabled) {
|
||||
paneKeysRequiringFreshWorking.delete(paneKey)
|
||||
}
|
||||
if (shouldSeed) {
|
||||
if (seedOnly === true) {
|
||||
entry.coordinator.seedHookStatus(payload)
|
||||
} else {
|
||||
entry.coordinator.observeHookStatus(payload)
|
||||
|
||||
@@ -3734,7 +3734,9 @@ export function applyWebSessionTabsStorePatch(
|
||||
buildPatch: (state: AppState) => WebSessionTabsSyncState | Partial<WebSessionTabsSyncState>,
|
||||
hostMirrorVerdict: HostSessionMirrorPatchVerdict,
|
||||
agentStatusSnapshots?: RuntimeMobileSessionTabsResult | readonly RuntimeMobileSessionTabsResult[],
|
||||
allowCompletionNotification = false
|
||||
allowCompletionNotification:
|
||||
| boolean
|
||||
| ((snapshot: RuntimeMobileSessionTabsResult) => boolean) = false
|
||||
): HostSessionMirrorSettle {
|
||||
let mirroredAgentStatusChanged = false
|
||||
// Why: zustand commits before notifying subscribers, so a producer that ran
|
||||
@@ -3760,6 +3762,10 @@ export function applyWebSessionTabsStorePatch(
|
||||
? agentStatusSnapshots
|
||||
: [agentStatusSnapshots]
|
||||
for (const snapshot of snapshots) {
|
||||
const notificationAllowed =
|
||||
typeof allowCompletionNotification === 'function'
|
||||
? allowCompletionNotification(snapshot)
|
||||
: allowCompletionNotification
|
||||
for (const surface of snapshot.tabs) {
|
||||
if (surface.type !== 'terminal') {
|
||||
continue
|
||||
@@ -3840,7 +3846,7 @@ export function applyWebSessionTabsStorePatch(
|
||||
acceptedNotificationStatuses.push({
|
||||
paneKey: notificationStatus.paneKey,
|
||||
worktreeId: notificationStatus.worktreeId ?? snapshot.worktree,
|
||||
...(!allowCompletionNotification ? { seedOnly: true as const } : {}),
|
||||
...(!notificationAllowed ? { seedOnly: true as const } : {}),
|
||||
payload: {
|
||||
...pickParsedAgentStatusPayload({
|
||||
...notificationStatus,
|
||||
@@ -4021,6 +4027,7 @@ export function useWebSessionTabsSync(): void {
|
||||
) => boolean
|
||||
>(() => true)
|
||||
const visibilityResumeOmissionsByKeyRef = useRef(new Map<string, VisibilityResumeOmission>())
|
||||
const visibilityResumeNotificationBaselinesRef = useRef(new Map<string, ReadonlySet<string>>())
|
||||
const mirroredSessionTabsOwnerRevisionByEnvironmentRef = useRef(
|
||||
new Map<string, number | undefined>()
|
||||
)
|
||||
@@ -4069,6 +4076,7 @@ export function useWebSessionTabsSync(): void {
|
||||
}
|
||||
mirroredSessionTabsOwnerRevisionByEnvironmentRef.current.clear()
|
||||
visibilityResumeOmissionsByKeyRef.current.clear()
|
||||
visibilityResumeNotificationBaselinesRef.current.clear()
|
||||
},
|
||||
[]
|
||||
)
|
||||
@@ -4119,6 +4127,12 @@ export function useWebSessionTabsSync(): void {
|
||||
if (!workspaceSessionReady || environments.length === 0) {
|
||||
return
|
||||
}
|
||||
const notificationBaselinesByEnvironment = new Map(
|
||||
environments.map(({ environmentId }) => [
|
||||
environmentId,
|
||||
new Set(getTrackedWebSessionTabsWorktrees(environmentId).map(({ worktree }) => worktree))
|
||||
])
|
||||
)
|
||||
|
||||
type VisibilityResumeEnvironment = {
|
||||
trackedWorktrees: readonly TrackedWebSessionTabsWorktree[]
|
||||
@@ -4457,6 +4471,7 @@ export function useWebSessionTabsSync(): void {
|
||||
}
|
||||
}
|
||||
const resumedEnvironments = new Map<string, VisibilityResumeEnvironment>()
|
||||
const notificationBaselines = new Map<string, ReadonlySet<string>>()
|
||||
const trackedWorktreeIds = new Set<string>()
|
||||
for (const index of restartingSpecIndexes) {
|
||||
const environmentId = environmentIdBySubscriptionSpec[index]
|
||||
@@ -4468,6 +4483,10 @@ export function useWebSessionTabsSync(): void {
|
||||
for (const { worktree } of trackedWorktrees) {
|
||||
trackedWorktreeIds.add(worktree)
|
||||
}
|
||||
notificationBaselines.set(
|
||||
environmentId,
|
||||
new Set(trackedWorktrees.map(({ worktree }) => worktree))
|
||||
)
|
||||
resumedEnvironments.set(environmentId, {
|
||||
trackedWorktrees,
|
||||
inventoryReceived: false,
|
||||
@@ -4476,6 +4495,7 @@ export function useWebSessionTabsSync(): void {
|
||||
})
|
||||
}
|
||||
}
|
||||
visibilityResumeNotificationBaselinesRef.current = notificationBaselines
|
||||
visibilityResumeBatch =
|
||||
resumedEnvironments.size > 0
|
||||
? {
|
||||
@@ -4640,8 +4660,17 @@ export function useWebSessionTabsSync(): void {
|
||||
).length
|
||||
}
|
||||
},
|
||||
freshSnapshots
|
||||
freshSnapshots,
|
||||
(snapshot) =>
|
||||
notificationBaselinesByEnvironment
|
||||
.get(environmentId)
|
||||
?.has(snapshot.worktree) === true
|
||||
)
|
||||
const notificationBaselines =
|
||||
notificationBaselinesByEnvironment.get(environmentId)
|
||||
for (const snapshot of freshSnapshots) {
|
||||
notificationBaselines?.add(snapshot.worktree)
|
||||
}
|
||||
const freshSnapshotSet = new Set(freshSnapshots)
|
||||
for (const { index, snapshot } of applicable) {
|
||||
if (unchangedVisibilityResumeSnapshots[index]) {
|
||||
@@ -4726,6 +4755,9 @@ export function useWebSessionTabsSync(): void {
|
||||
recovered,
|
||||
event.type === 'updated' && !replayed
|
||||
)
|
||||
notificationBaselinesByEnvironment
|
||||
.get(environmentId)
|
||||
?.add(recovered.worktree)
|
||||
recordVisibilityResumeSnapshot(environmentId, recovered, receivedFrame)
|
||||
} else {
|
||||
settleHydration = hostSessionMirrorSettleForPatchlessFrame(
|
||||
@@ -4812,13 +4844,17 @@ export function useWebSessionTabsSync(): void {
|
||||
|
||||
let requestedInitialTerminal = false
|
||||
let requestedRespawnAfterWake = false
|
||||
let notificationBaselineEstablished = getTrackedWebSessionTabsWorktrees(environmentId).some(
|
||||
({ worktree }) => worktree === activeWorktreeId
|
||||
)
|
||||
/** Resolves the settle receipt of the evidence this frame put (or already
|
||||
* had) in the store, or null when the frame was discarded undecided. */
|
||||
const applyActiveSnapshot = async (
|
||||
event: RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' },
|
||||
response: RuntimeRpcResponse<unknown>,
|
||||
isCurrent: () => boolean,
|
||||
receivedFrame: number
|
||||
receivedFrame: number,
|
||||
allowCompletionNotification: boolean
|
||||
): Promise<HostSessionMirrorSettle | null> => {
|
||||
const recovered = await recoverWebSessionTerminalOrphansBeforeApply(
|
||||
useAppStore.getState(),
|
||||
@@ -4873,8 +4909,9 @@ export function useWebSessionTabsSync(): void {
|
||||
(state) => applyWebSessionTabsSnapshot(state, recovered, environmentId),
|
||||
{ frames: [{ environmentId, worktreeId: recovered.worktree, decision }] },
|
||||
recovered,
|
||||
event.type === 'updated' && !replayed
|
||||
allowCompletionNotification || (event.type === 'updated' && !replayed)
|
||||
)
|
||||
notificationBaselineEstablished = true
|
||||
recordVisibilityResumeSnapshotRef.current(environmentId, recovered, receivedFrame)
|
||||
}
|
||||
if (isCurrent() && shouldBootstrapInitialTerminal) {
|
||||
@@ -4950,7 +4987,16 @@ export function useWebSessionTabsSync(): void {
|
||||
event.worktree,
|
||||
receivedFrame
|
||||
)
|
||||
void applyActiveSnapshot(event, response, isCurrent, receivedFrame)
|
||||
void applyActiveSnapshot(
|
||||
event,
|
||||
response,
|
||||
isCurrent,
|
||||
receivedFrame,
|
||||
notificationBaselineEstablished ||
|
||||
visibilityResumeNotificationBaselinesRef.current
|
||||
.get(environmentId)
|
||||
?.has(activeWorktreeId) === true
|
||||
)
|
||||
.catch((error) => {
|
||||
if (isCurrent()) {
|
||||
console.warn(
|
||||
|
||||
+112
-16
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Mock } from 'vitest'
|
||||
import { tagRuntimeSubscriptionReplayResponse } from '../../../shared/runtime-subscription-replay'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
|
||||
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
|
||||
import { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments'
|
||||
@@ -58,8 +59,11 @@ import {
|
||||
import { WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS } from './window-visibility-subscription-parking'
|
||||
|
||||
const ENVIRONMENT_ID = 'env-a'
|
||||
const ENVIRONMENT_B = 'env-b'
|
||||
const RUNTIME_ID = 'runtime-a'
|
||||
const RUNTIME_B = 'runtime-b'
|
||||
const REVISION = 101
|
||||
const REVISION_B = 202
|
||||
const REPO_ID = 'repo-a'
|
||||
const WORKTREE_ID = `${REPO_ID}::/worktree-a`
|
||||
const OTHER_WORKTREE_ID = `${REPO_ID}::/other`
|
||||
@@ -68,6 +72,8 @@ const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const PANE_KEY = makePaneKey(toWebTerminalSurfaceTabId(HOST_TAB_ID), LEAF_ID)
|
||||
const NOW = 1_700_000_000_000
|
||||
const MIRROR_KEY = `${ENVIRONMENT_ID}\u0001${RUNTIME_ID}\u00011\u0001${REVISION}`
|
||||
const RECONNECTED_MIRROR_KEY = `${ENVIRONMENT_ID}\u0001${RUNTIME_ID}\u00012\u0001${REVISION}`
|
||||
const TWO_ENVIRONMENT_MIRROR_KEY = `${MIRROR_KEY}\u0000${ENVIRONMENT_B}\u0001${RUNTIME_B}\u00011\u0001${REVISION_B}`
|
||||
const initialState = useAppStore.getInitialState()
|
||||
|
||||
type RuntimeSubscribe = typeof window.api.runtimeEnvironments.subscribe
|
||||
@@ -79,12 +85,14 @@ type RuntimeSubscription = {
|
||||
type TerminalState = 'working' | 'done' | 'blocked' | 'waiting'
|
||||
|
||||
const subscriptions: RuntimeSubscription[] = []
|
||||
const runtimeCall = vi.fn(async () => ({
|
||||
id: 'list-all',
|
||||
ok: true as const,
|
||||
result: { snapshots: [] },
|
||||
_meta: { runtimeId: RUNTIME_ID }
|
||||
}))
|
||||
const runtimeCall = vi.fn(
|
||||
async (): Promise<RuntimeRpcResponse<unknown>> => ({
|
||||
id: 'list-all',
|
||||
ok: true as const,
|
||||
result: { snapshots: [] },
|
||||
_meta: { runtimeId: RUNTIME_ID }
|
||||
})
|
||||
)
|
||||
const runtimeSubscribe = vi.fn<RuntimeSubscribe>(async (request, callbacks) => {
|
||||
const unsubscribe = vi.fn()
|
||||
subscriptions.push({ request, callbacks, unsubscribe })
|
||||
@@ -158,6 +166,20 @@ function findSubscription(
|
||||
return subscription
|
||||
}
|
||||
|
||||
function findEnvironmentSubscription(
|
||||
method: 'session.tabs.subscribeAll' | 'session.tabs.subscribe',
|
||||
environmentId: string,
|
||||
occurrence = 0
|
||||
): RuntimeSubscription {
|
||||
const subscription = subscriptions.filter(
|
||||
({ request }) => request.method === method && request.selector === environmentId
|
||||
)[occurrence]
|
||||
if (!subscription) {
|
||||
throw new Error(`Missing ${environmentId} ${method} subscription ${occurrence}`)
|
||||
}
|
||||
return subscription
|
||||
}
|
||||
|
||||
async function publish(
|
||||
subscription: RuntimeSubscription,
|
||||
result: unknown,
|
||||
@@ -229,7 +251,6 @@ async function parkAndReveal(): Promise<void> {
|
||||
describe('paired session-tab visibility-resume notifications', () => {
|
||||
let notificationDispatch: Mock<(kind: 'done' | 'attention') => void>
|
||||
let disposeCoordinator: () => void
|
||||
let hasNotificationBaseline: boolean
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
@@ -249,8 +270,6 @@ describe('paired session-tab visibility-resume notifications', () => {
|
||||
resetWebSessionTabsSnapshotFreshnessForTests()
|
||||
resetRendererOwnedAgentStatusPanesForTests()
|
||||
seedRemoteMirrorState()
|
||||
hasNotificationBaseline = false
|
||||
|
||||
notificationDispatch = vi.fn()
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: PANE_KEY,
|
||||
@@ -270,12 +289,11 @@ describe('paired session-tab visibility-resume notifications', () => {
|
||||
})
|
||||
disposeCoordinator = () => coordinator.dispose()
|
||||
mocks.observeAgentHookCompletionForNotification.mockImplementation(({ payload, seedOnly }) => {
|
||||
if (seedOnly && !hasNotificationBaseline) {
|
||||
if (seedOnly) {
|
||||
coordinator.seedHookStatus(payload)
|
||||
} else {
|
||||
coordinator.observeHookStatus(payload)
|
||||
}
|
||||
hasNotificationBaseline = true
|
||||
})
|
||||
})
|
||||
|
||||
@@ -330,6 +348,26 @@ describe('paired session-tab visibility-resume notifications', () => {
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
it('keeps ordered listAll and subscription cold inventories silent', async () => {
|
||||
runtimeCall.mockResolvedValueOnce({
|
||||
id: 'list-all',
|
||||
ok: true,
|
||||
result: { snapshots: [snapshot(1, 'working', NOW)] },
|
||||
_meta: { runtimeId: RUNTIME_ID }
|
||||
})
|
||||
const hook = renderHook(() => useWebSessionTabsSync())
|
||||
await act(settle)
|
||||
await publish(findSubscription('session.tabs.subscribeAll'), {
|
||||
type: 'snapshots',
|
||||
snapshots: [snapshot(2, 'done', NOW + 1_000)]
|
||||
})
|
||||
vi.advanceTimersByTime(1_500)
|
||||
|
||||
expect(notificationDispatch).not.toHaveBeenCalled()
|
||||
expect(badgeCount()).toBe(0)
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
it.each(['done', 'blocked'] as const)(
|
||||
'keeps cold %s inventory silent when first mounted hidden',
|
||||
async (state) => {
|
||||
@@ -351,6 +389,65 @@ describe('paired session-tab visibility-resume notifications', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['done', 'blocked'] as const)(
|
||||
'keeps a cold active-worktree %s snapshot silent after first hidden mount',
|
||||
async (state) => {
|
||||
seedRemoteMirrorState(true)
|
||||
mocks.getExplicitRuntimeEnvironmentIdForWorktree.mockReturnValue(ENVIRONMENT_ID)
|
||||
setDocumentVisibility('hidden')
|
||||
const hook = renderHook(() => useWebSessionTabsSync())
|
||||
await act(settle)
|
||||
|
||||
act(() => setDocumentVisibility('visible'))
|
||||
await act(settle)
|
||||
await publish(findSubscription('session.tabs.subscribe'), {
|
||||
type: 'snapshot',
|
||||
...snapshot(1, state, NOW)
|
||||
})
|
||||
vi.advanceTimersByTime(1_500)
|
||||
|
||||
expect(notificationDispatch).not.toHaveBeenCalled()
|
||||
expect(badgeCount()).toBe(0)
|
||||
hook.unmount()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not borrow another environment same-id worktree notification baseline', async () => {
|
||||
const hook = renderHook(() => useWebSessionTabsSync())
|
||||
await act(settle)
|
||||
await publish(findEnvironmentSubscription('session.tabs.subscribeAll', ENVIRONMENT_ID), {
|
||||
type: 'updated',
|
||||
...snapshot(1, 'working', NOW)
|
||||
})
|
||||
notificationDispatch.mockClear()
|
||||
|
||||
const runtimeEnvironments = [
|
||||
{ id: ENVIRONMENT_ID, createdAt: 100, pairingRevision: REVISION },
|
||||
{ id: ENVIRONMENT_B, createdAt: 200, pairingRevision: REVISION_B }
|
||||
] as PublicKnownRuntimeEnvironment[]
|
||||
replaceRuntimeEnvironmentRevisions(runtimeEnvironments)
|
||||
useAppStore.setState({
|
||||
runtimeEnvironments,
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
[ENVIRONMENT_ID, { status: { runtimeId: RUNTIME_ID }, connectionGeneration: 1 }],
|
||||
[ENVIRONMENT_B, { status: { runtimeId: RUNTIME_B }, connectionGeneration: 1 }]
|
||||
]) as AppState['runtimeStatusByEnvironmentId']
|
||||
})
|
||||
mocks.runtimeSessionMirrorEnvironmentKey.mockReturnValue(TWO_ENVIRONMENT_MIRROR_KEY)
|
||||
hook.rerender()
|
||||
await act(settle)
|
||||
|
||||
await publish(findEnvironmentSubscription('session.tabs.subscribeAll', ENVIRONMENT_B), {
|
||||
type: 'snapshots',
|
||||
snapshots: [snapshot(1, 'done', NOW + 1_000)]
|
||||
})
|
||||
vi.advanceTimersByTime(1_500)
|
||||
|
||||
expect(notificationDispatch).not.toHaveBeenCalled()
|
||||
expect(badgeCount()).toBe(0)
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
it.each(['done', 'blocked'] as const)(
|
||||
'keeps a metadata-only %s inventory refresh silent',
|
||||
async (state) => {
|
||||
@@ -420,15 +517,14 @@ describe('paired session-tab visibility-resume notifications', () => {
|
||||
})
|
||||
|
||||
it('alerts after a transport resubscription preserves the prior baseline', async () => {
|
||||
const firstHook = renderHook(() => useWebSessionTabsSync())
|
||||
const hook = renderHook(() => useWebSessionTabsSync())
|
||||
await act(settle)
|
||||
await publish(findSubscription('session.tabs.subscribeAll'), {
|
||||
type: 'updated',
|
||||
...snapshot(1, 'working', NOW)
|
||||
})
|
||||
firstHook.unmount()
|
||||
|
||||
const reconnectedHook = renderHook(() => useWebSessionTabsSync())
|
||||
mocks.runtimeSessionMirrorEnvironmentKey.mockReturnValue(RECONNECTED_MIRROR_KEY)
|
||||
hook.rerender()
|
||||
await act(settle)
|
||||
await publish(findSubscription('session.tabs.subscribeAll', 1), {
|
||||
type: 'snapshots',
|
||||
@@ -438,7 +534,7 @@ describe('paired session-tab visibility-resume notifications', () => {
|
||||
|
||||
expect(notificationDispatch).toHaveBeenCalledTimes(1)
|
||||
expect(badgeCount()).toBe(1)
|
||||
reconnectedHook.unmount()
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
it('alerts for a Claude stamped completion while the host row stays working', async () => {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { rmSync } from 'node:fs'
|
||||
|
||||
export async function removeE2EProfile(userDataDir: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
return
|
||||
} catch (error) {
|
||||
if (attempt === 4) {
|
||||
throw error
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@stablyai/playwright-test'
|
||||
|
||||
import { getE2ECompletedOnboardingProfile } from './e2e-completed-onboarding-profile'
|
||||
import { removeE2EProfile } from './e2e-profile-removal'
|
||||
import { getOrcaElectronLaunchArgs } from './electron-launch-args'
|
||||
import { cleanupE2EDaemons, closeElectronAppForE2E } from './electron-process-shutdown'
|
||||
import {
|
||||
@@ -46,6 +47,7 @@ export type PairedWebClient = {
|
||||
dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
type BeforeRendererReady = (app: ElectronApplication) => Promise<void>
|
||||
const DIRECT_SSH_PROBE_CANARY_TARGET_ID = '__orca_e2e_direct_ssh_probe_canary__'
|
||||
|
||||
function readDirectSshAttemptTargetIds(probePath: string): string[] {
|
||||
@@ -59,20 +61,6 @@ function readDirectSshAttemptTargetIds(probePath: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeProfile(userDataDir: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
return
|
||||
} catch (error) {
|
||||
if (attempt === 4) {
|
||||
throw error
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRuntimeDesktopPairingOffer(
|
||||
hubPage: Page
|
||||
): Promise<RuntimeDesktopPairingOffer> {
|
||||
@@ -142,7 +130,7 @@ export async function launchPairedElectronClient(
|
||||
offer: RuntimeDesktopPairingOffer,
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
options: { extraEnv?: Record<string, string> } = {}
|
||||
options: { beforeRendererReady?: BeforeRendererReady; extraEnv?: Record<string, string> } = {}
|
||||
): Promise<PairedElectronClient> {
|
||||
const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-paired-desktop-'))
|
||||
const directSshProbePath = path.join(userDataDir, 'forbidden-local-ssh-connects.jsonl')
|
||||
@@ -170,6 +158,7 @@ export async function launchPairedElectronClient(
|
||||
})
|
||||
|
||||
try {
|
||||
await options.beforeRendererReady?.(app)
|
||||
assertElectronResolvedIsolatedHome(
|
||||
await app.evaluate(({ app: electronApp }) => electronApp.getPath('home')),
|
||||
homeIsolation
|
||||
@@ -241,20 +230,19 @@ export async function launchPairedElectronClient(
|
||||
dispose: async () => {
|
||||
await closeElectronAppForE2E(app)
|
||||
await cleanupE2EDaemons(userDataDir)
|
||||
await removeProfile(userDataDir)
|
||||
await removeE2EProfile(userDataDir)
|
||||
},
|
||||
getDirectSshAttemptTargetIds: async () => {
|
||||
return readDirectSshAttemptTargetIds(directSshProbePath).filter(
|
||||
getDirectSshAttemptTargetIds: async () =>
|
||||
readDirectSshAttemptTargetIds(directSshProbePath).filter(
|
||||
(targetId) => targetId !== DIRECT_SSH_PROBE_CANARY_TARGET_ID
|
||||
)
|
||||
},
|
||||
),
|
||||
installDirectSshAttemptProbe: async () => {},
|
||||
replacePairingInPlace
|
||||
}
|
||||
} catch (error) {
|
||||
await closeElectronAppForE2E(app)
|
||||
await cleanupE2EDaemons(userDataDir)
|
||||
await removeProfile(userDataDir)
|
||||
await removeE2EProfile(userDataDir)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
|
||||
import { CLAUDE_EVENTS } from '../../src/main/claude/hook-settings'
|
||||
import type { AgentHookEndpoint } from '../../src/shared/agent-hook-endpoint-file'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types'
|
||||
import { makePaneKey } from '../../src/shared/stable-pane-id'
|
||||
@@ -18,9 +19,19 @@ import {
|
||||
} from './helpers/paired-electron-client'
|
||||
import { revealPairedClientWindow } from './helpers/paired-client-window-reveal'
|
||||
|
||||
const REQUIRED_CLAUDE_EVENTS = ['UserPromptSubmit', 'Stop', 'PermissionRequest'] as const
|
||||
const NOTIFICATION_BARRIER_SOURCE = 'sta-5244-barrier'
|
||||
type NotificationDispatch = { source?: string; agentState?: string; worktreeId?: string }
|
||||
type AgentStatusSummary = { state: string; prompt: string; agentType?: string }
|
||||
type AgentStatusSummary = {
|
||||
state: string
|
||||
prompt: string
|
||||
agentType?: string
|
||||
toolInput?: unknown
|
||||
}
|
||||
type TerminalSurface = Extract<RuntimeMobileSessionTabsResult['tabs'][number], { type: 'terminal' }>
|
||||
type ClaudeSettings = {
|
||||
hooks?: Record<string, { hooks?: { command?: unknown }[] }[]>
|
||||
}
|
||||
|
||||
async function callEnvironment<TResult>(
|
||||
page: Page,
|
||||
@@ -45,15 +56,18 @@ async function callEnvironment<TResult>(
|
||||
}
|
||||
|
||||
async function installNotificationDispatchSpy(app: ElectronApplication): Promise<void> {
|
||||
await app.evaluate(({ ipcMain }) => {
|
||||
await app.evaluate(({ ipcMain }, barrierSource) => {
|
||||
const state = globalThis as unknown as { __sta5244Dispatches?: NotificationDispatch[] }
|
||||
state.__sta5244Dispatches = []
|
||||
ipcMain.removeHandler('notifications:dispatch')
|
||||
ipcMain.handle('notifications:dispatch', (_event: unknown, input: NotificationDispatch) => {
|
||||
if (input.source === barrierSource) {
|
||||
return { delivered: true }
|
||||
}
|
||||
state.__sta5244Dispatches!.push(input)
|
||||
return { delivered: true }
|
||||
})
|
||||
})
|
||||
}, NOTIFICATION_BARRIER_SOURCE)
|
||||
}
|
||||
|
||||
async function notificationDispatches(app: ElectronApplication): Promise<NotificationDispatch[]> {
|
||||
@@ -63,6 +77,35 @@ async function notificationDispatches(app: ElectronApplication): Promise<Notific
|
||||
})
|
||||
}
|
||||
|
||||
async function flushNotificationDispatches(page: Page): Promise<void> {
|
||||
await page.evaluate(async (source) => {
|
||||
const dispatch = window.api.notifications.dispatch as (input: unknown) => Promise<unknown>
|
||||
await dispatch({ source })
|
||||
}, NOTIFICATION_BARRIER_SOURCE)
|
||||
}
|
||||
|
||||
async function hasManagedClaudeHooks(host: HeadlessPairedRuntimeHost): Promise<boolean> {
|
||||
const home = await host.app.evaluate(({ app }) => app.getPath('home'))
|
||||
const scriptName = process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh'
|
||||
const scriptPath = path.join(home, '.orca', 'agent-hooks', scriptName)
|
||||
const settingsPath = path.join(home, '.claude', 'settings.json')
|
||||
if (!existsSync(scriptPath) || !existsSync(settingsPath)) {
|
||||
return false
|
||||
}
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as ClaudeSettings
|
||||
const requiredEvents = new Set<string>([
|
||||
...REQUIRED_CLAUDE_EVENTS,
|
||||
...CLAUDE_EVENTS.map(({ eventName }) => eventName)
|
||||
])
|
||||
return [...requiredEvents].every((eventName) =>
|
||||
settings.hooks?.[eventName]?.some((definition) =>
|
||||
definition.hooks?.some(
|
||||
({ command }) => typeof command === 'string' && command.includes('claude-hook')
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
async function postClaudeHook(
|
||||
endpoint: AgentHookEndpoint,
|
||||
paneKey: string,
|
||||
@@ -109,7 +152,12 @@ async function clientStatus(page: Page, paneKey: string): Promise<AgentStatusSum
|
||||
return page.evaluate((key) => {
|
||||
const status = window.__store?.getState().agentStatusByPaneKey[key]
|
||||
return status
|
||||
? { state: status.state, prompt: status.prompt, agentType: status.agentType }
|
||||
? {
|
||||
state: status.state,
|
||||
prompt: status.prompt,
|
||||
agentType: status.agentType,
|
||||
toolInput: status.toolInput
|
||||
}
|
||||
: null
|
||||
}, paneKey)
|
||||
}
|
||||
@@ -165,10 +213,34 @@ async function revealAfterSubscriptionsPark(client: PairedElectronClient): Promi
|
||||
await revealPairedClientWindow(client)
|
||||
}
|
||||
|
||||
async function reconnect(client: PairedElectronClient): Promise<void> {
|
||||
async function reconnectAndAwaitReplay(
|
||||
client: PairedElectronClient,
|
||||
paneKey: string,
|
||||
expectedState: string
|
||||
): Promise<void> {
|
||||
const previousGeneration = await client.page.evaluate(
|
||||
(environmentId) =>
|
||||
window.__store?.getState().runtimeStatusByEnvironmentId.get(environmentId)
|
||||
?.connectionGeneration ?? 0,
|
||||
client.environmentId
|
||||
)
|
||||
await client.page.evaluate(async (selector) => {
|
||||
await window.api.runtimeEnvironments.disconnect({ selector })
|
||||
}, client.environmentId)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
await client.page.evaluate(
|
||||
async (environmentId) =>
|
||||
window.__store?.getState().refreshRuntimeEnvironmentStatus(environmentId, 1_000),
|
||||
client.environmentId
|
||||
)
|
||||
return client.page.evaluate((environmentId) => {
|
||||
const entry = window.__store?.getState().runtimeStatusByEnvironmentId.get(environmentId)
|
||||
return entry === undefined || entry.status === null
|
||||
}, client.environmentId)
|
||||
})
|
||||
.toBe(true)
|
||||
await clearClientStatus(client.page, paneKey)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
@@ -179,6 +251,41 @@ async function reconnect(client: PairedElectronClient): Promise<void> {
|
||||
{ timeout: 60_000, message: 'paired client did not reconnect' }
|
||||
)
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
await client.page.evaluate(
|
||||
async (environmentId) =>
|
||||
window.__store?.getState().refreshRuntimeEnvironmentStatus(environmentId, 1_000),
|
||||
client.environmentId
|
||||
)
|
||||
return client.page.evaluate(
|
||||
({ environmentId, previousGeneration }) => {
|
||||
const status = window.__store?.getState().runtimeStatusByEnvironmentId.get(environmentId)
|
||||
return status?.status && (status.connectionGeneration ?? 0) > previousGeneration
|
||||
},
|
||||
{ environmentId: client.environmentId, previousGeneration }
|
||||
)
|
||||
})
|
||||
.toBeTruthy()
|
||||
await expect
|
||||
.poll(() => clientStatus(client.page, paneKey), {
|
||||
timeout: 30_000,
|
||||
message: 'paired client did not apply the reconnect replay'
|
||||
})
|
||||
.toMatchObject({ state: expectedState })
|
||||
await flushNotificationDispatches(client.page)
|
||||
}
|
||||
|
||||
async function clearClientStatus(page: Page, paneKey: string): Promise<void> {
|
||||
await page.evaluate((key) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return
|
||||
}
|
||||
const next = { ...store.getState().agentStatusByPaneKey }
|
||||
delete next[key]
|
||||
store.setState({ agentStatusByPaneKey: next })
|
||||
}, paneKey)
|
||||
}
|
||||
|
||||
async function clearUnread(page: Page, worktreeId: string): Promise<void> {
|
||||
@@ -197,6 +304,7 @@ test('recovers hidden remote completion and permission alerts exactly once', asy
|
||||
let client: PairedElectronClient | null = null
|
||||
let coldClient: PairedElectronClient | null = null
|
||||
let terminal: string | null = null
|
||||
let testError: unknown
|
||||
|
||||
try {
|
||||
await host.client.call('repo.add', { path: testRepoPath, kind: 'git' })
|
||||
@@ -241,6 +349,18 @@ test('recovers hidden remote completion and permission alerts exactly once', asy
|
||||
)
|
||||
const endpoint = await readHookEndpoint(host.app)
|
||||
expect(Number(endpoint.port)).toBeGreaterThan(0)
|
||||
await expect
|
||||
.poll(() => hasManagedClaudeHooks(host), {
|
||||
timeout: 30_000,
|
||||
message: 'isolated host did not install every managed Claude hook'
|
||||
})
|
||||
.toBe(true)
|
||||
await revealPairedClientWindow(client)
|
||||
await expect
|
||||
.poll(() =>
|
||||
client!.app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.isVisible())
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const completionPrompt = `STA-5244 completion ${Date.now()}`
|
||||
await postClaudeHook(endpoint, hostPaneKey, worktreeId, {
|
||||
@@ -364,10 +484,26 @@ test('recovers hidden remote completion and permission alerts exactly once', asy
|
||||
await client.page.screenshot({ path: path.join(screenshots, '03-hidden-permission-pass.png') })
|
||||
|
||||
await hideUntilSubscriptionsPark(client)
|
||||
await postClaudeHook(endpoint, hostPaneKey, worktreeId, {
|
||||
hook_event_name: 'PermissionRequest',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'git status --short' }
|
||||
})
|
||||
await expect
|
||||
.poll(() => hostSurface(host, worktreeId, surface.parentTabId))
|
||||
.toMatchObject({
|
||||
agentStatus: { state: 'waiting', toolInput: 'git status --short' }
|
||||
})
|
||||
await revealAfterSubscriptionsPark(client)
|
||||
await expect.poll(() => notificationDispatches(client!.app)).toHaveLength(2)
|
||||
await reconnect(client)
|
||||
await expect.poll(() => notificationDispatches(client!.app)).toHaveLength(2)
|
||||
await expect
|
||||
.poll(() => clientStatus(client!.page, clientPaneKey), {
|
||||
message: 'same-turn reveal replay did not settle'
|
||||
})
|
||||
.toMatchObject({ state: 'waiting', toolInput: 'git status --short' })
|
||||
await flushNotificationDispatches(client.page)
|
||||
expect(await notificationDispatches(client.app)).toHaveLength(2)
|
||||
await reconnectAndAwaitReplay(client, clientPaneKey, 'waiting')
|
||||
expect(await notificationDispatches(client.app)).toHaveLength(2)
|
||||
|
||||
await clearUnread(client.page, worktreeId)
|
||||
await expect
|
||||
@@ -377,30 +513,54 @@ test('recovers hidden remote completion and permission alerts exactly once', asy
|
||||
unreadCount: 0,
|
||||
dock: process.platform === 'darwin' ? '' : null
|
||||
})
|
||||
coldClient = await launchPairedElectronClient(host.offer, testInfo, 'STA-5244 cold replay')
|
||||
coldClient = await launchPairedElectronClient(host.offer, testInfo, 'STA-5244 cold replay', {
|
||||
beforeRendererReady: installNotificationDispatchSpy
|
||||
})
|
||||
await expect
|
||||
.poll(() => clientStatus(coldClient!.page, clientPaneKey), {
|
||||
timeout: 30_000,
|
||||
message: 'cold client did not hydrate terminal permission state'
|
||||
})
|
||||
.toMatchObject({ state: 'waiting' })
|
||||
await flushNotificationDispatches(coldClient.page)
|
||||
expect(await unreadBadgeState(coldClient, worktreeId)).toMatchObject({
|
||||
unread: false,
|
||||
unreadCount: 0,
|
||||
dock: process.platform === 'darwin' ? '' : null
|
||||
})
|
||||
await installNotificationDispatchSpy(coldClient.app)
|
||||
await reconnect(coldClient)
|
||||
await expect.poll(() => notificationDispatches(coldClient!.app)).toHaveLength(0)
|
||||
expect(await notificationDispatches(coldClient.app)).toHaveLength(0)
|
||||
await reconnectAndAwaitReplay(coldClient, clientPaneKey, 'waiting')
|
||||
expect(await notificationDispatches(coldClient.app)).toHaveLength(0)
|
||||
await coldClient.page.screenshot({
|
||||
path: path.join(screenshots, '02-cold-permission-silent.png')
|
||||
})
|
||||
} finally {
|
||||
await coldClient?.dispose()
|
||||
if (terminal) {
|
||||
await host.client.call('terminal.closeTab', { terminal }).catch(() => undefined)
|
||||
} catch (error) {
|
||||
testError = error
|
||||
}
|
||||
const cleanupErrors: unknown[] = []
|
||||
const cleanupStages = [
|
||||
() => coldClient?.dispose() ?? Promise.resolve(),
|
||||
() =>
|
||||
terminal
|
||||
? host.client.call('terminal.closeTab', { terminal }).then(() => undefined)
|
||||
: Promise.resolve(),
|
||||
() => client?.dispose() ?? Promise.resolve(),
|
||||
() => host.dispose()
|
||||
]
|
||||
for (const cleanup of cleanupStages) {
|
||||
try {
|
||||
await cleanup()
|
||||
} catch (error) {
|
||||
cleanupErrors.push(error)
|
||||
}
|
||||
await client?.dispose()
|
||||
await host.dispose()
|
||||
}
|
||||
if (testError !== undefined && cleanupErrors.length > 0) {
|
||||
throw new AggregateError([testError, ...cleanupErrors], 'STA-5244 test and cleanup failed')
|
||||
}
|
||||
if (testError !== undefined) {
|
||||
throw testError
|
||||
}
|
||||
if (cleanupErrors.length > 0) {
|
||||
throw new AggregateError(cleanupErrors, 'STA-5244 fixture cleanup failed')
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user