diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index d3206cec8..7600d5a0b 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -3,8 +3,9 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; -import { archiveChatSession, buildPtyContinuationLaunchFields, cancelSteerMessage, clearSessionWokeMarker, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, deleteChatSession, deriveClaudeGoalFromEvents, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, enrichChatSessionsWithLifecycle, enrichTerminalSessionsWithLifecycle, getAvailableModels, getChatHistoryPage, getMainTranscript, interruptChat, latestGoal, latestTokenStats, listChatSessions, listLaneDiffStats, listPrsByLane, listSessionSummaries, listTerminalSessions, messageChatSession, recoverCodexTurn, recoverTurn, requestSessionAttention, resolveUnprocessedMessage, restoreCancelledQueue, resumeTerminalSession, runDefaultLaneSetup, sendChatMessage, setSessionSettleOverride, setSessionStatusNote, settleSession, signalTerminal, snoozeSession, startCliTerminalSession, steerChatMessage, trackedCliTerminalProvider, unarchiveChatSession, unsettleSession, wakeSession } from "../adeApi"; +import { archiveChatSession, buildPtyContinuationLaunchFields, cancelSteerMessage, clearSessionWokeMarker, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, deleteChatSession, deriveClaudeGoalFromEvents, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, enrichChatSessionsWithLifecycle, enrichTerminalSessionsWithLifecycle, getAvailableModels, getChatHistoryPage, getMainTranscript, interruptChat, latestGoal, latestTokenStats, listChatSessions, listLaneDiffStats, listPrsByLane, listSessionSummaries, listTerminalSessions, mergeLaneStatusSnapshots, messageChatSession, recoverCodexTurn, recoverTurn, requestSessionAttention, resolveUnprocessedMessage, restoreCancelledQueue, resumeTerminalSession, runDefaultLaneSetup, sendChatMessage, setSessionSettleOverride, setSessionStatusNote, settleSession, signalTerminal, snoozeSession, startCliTerminalSession, steerChatMessage, trackedCliTerminalProvider, unarchiveChatSession, unsettleSession, wakeSession } from "../adeApi"; import type { ChatTerminalSession, TerminalSessionSummary } from "../../../../desktop/src/shared/types/sessions"; +import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; import type { AdeCodeConnection } from "../types"; const tmpPaths: string[] = []; @@ -34,6 +35,43 @@ function envelope( }; } +function laneSummary(id: string, dirty: boolean): LaneSummary { + return { + id, + name: id, + laneType: "worktree", + baseRef: "main", + branchRef: id, + worktreePath: `/repo/${id}`, + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { dirty, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false }, + color: null, + icon: null, + tags: [], + createdAt: "2026-01-01T00:00:00.000Z", + }; +} + +describe("lane status polling", () => { + it("keeps cached statuses while applying the focused lane refresh", () => { + const cheapLanes = [laneSummary("lane-1", false), laneSummary("lane-2", false), laneSummary("lane-3", false)]; + const cachedLanes = [laneSummary("lane-1", true), laneSummary("lane-2", true)]; + const focusedLane = { + ...laneSummary("lane-1", false), + worktreeAvailable: true, + }; + + const merged = mergeLaneStatusSnapshots(cheapLanes, cachedLanes, focusedLane); + + expect(merged.map((lane) => lane.status.dirty)).toEqual([false, true, false]); + expect(merged[0]?.worktreeAvailable).toBe(true); + }); +}); + describe("listLaneDiffStats", () => { it("calls the bulk diff stats ADE action with lane ids", async () => { const calls: Array<{ domain: string; action: string; args: Record | undefined }> = []; diff --git a/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx index 0d1055f3d..4086a0d26 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx @@ -72,7 +72,7 @@ vi.mock("node:fs", async () => { }; }); -import { AdeCodeApp, BACKGROUND_REFRESH_DEBOUNCE_MS, isLaneWorktreeAvailable, MENTION_REMOTE_DEBOUNCE_MS, shouldHydrateRefreshHistory } from "../app"; +import { AdeCodeApp, BACKGROUND_REFRESH_DEBOUNCE_MS, isLaneWorktreeAvailable, LANE_STATUS_REFRESH_MS, MENTION_REMOTE_DEBOUNCE_MS, shouldHydrateRefreshHistory } from "../app"; const reactActGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; let previousReactActEnvironment: boolean | undefined; @@ -267,11 +267,33 @@ describe("AdeCodeApp polling", () => { await flushAsyncEffects(); expect(mocks.listChatSessions).toHaveBeenCalledTimes(2); + expect(mocks.listLanes).toHaveBeenLastCalledWith( + connection, + { includeStatus: false }, + ); + expect(connection.action).toHaveBeenCalledWith( + "lane", + "getSummary", + { laneId: "lane-1", includeStatus: true }, + ); expect(mocks.getChatHistory).toHaveBeenCalledTimes(0); await unmountApp(instance); }); + it("refreshes every lane status on the slower cadence", async () => { + const instance = await renderApp(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(LANE_STATUS_REFRESH_MS); + }); + await flushAsyncEffects(); + + expect(mocks.listLanes.mock.calls.filter(([, options]) => options?.includeStatus === true)) + .toHaveLength(2); + await unmountApp(instance); + }); + it("does not emit analytics for polling or background event streams", async () => { const instance = await renderApp(); const analyticsCalls = () => vi.mocked(connection.action).mock.calls.filter( diff --git a/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts b/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts index 9b5e1ff31..d16cb22e9 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts @@ -89,9 +89,9 @@ describe("ade code remote launcher", () => { it("filters explicit paths without changing automatic LAN-first ordering", () => { const candidates = [ - { endpoint: "ws://studio.local:8787/", kind: "lan" as const, lastSucceededAt: null }, - { endpoint: "ws://studio.example.ts.net:8787/", kind: "tailnet" as const, lastSucceededAt: null }, - { endpoint: "wss://relay.example/connect/machine", kind: "relay" as const, lastSucceededAt: null }, + { endpoint: "ws://studio.local:8787/", kind: "lan" as const, lastSucceededAt: null, recentlyFailing: false }, + { endpoint: "ws://studio.example.ts.net:8787/", kind: "tailnet" as const, lastSucceededAt: null, recentlyFailing: false }, + { endpoint: "wss://relay.example/connect/machine", kind: "relay" as const, lastSucceededAt: null, recentlyFailing: false }, ]; expect(pairedEndpointCandidatesForPreference(candidates, "auto")).toEqual(candidates); diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index 68bd2c6d3..d6342b8ba 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -86,14 +86,45 @@ export { buildPtyContinuationLaunchFields }; export async function listLanes( connection: AdeCodeConnection, - options: { includeArchived?: boolean } = {}, + options: { includeArchived?: boolean; includeStatus?: boolean } = {}, ): Promise { return await connection.action("lane", "list", { includeArchived: options.includeArchived ?? false, + includeStatus: options.includeStatus ?? true, + }); +} + +export async function getLaneSummary( + connection: AdeCodeConnection, + laneId: string, +): Promise { + return await connection.action("lane", "getSummary", { + laneId, includeStatus: true, }); } +export function mergeLaneStatusSnapshots( + lanes: LaneSummary[], + cachedLanes: LaneSummary[], + refreshedLane: LaneSummary | null = null, +): LaneSummary[] { + const cachedById = new Map(cachedLanes.map((lane) => [lane.id, lane])); + return lanes.map((lane) => { + const statusSource = refreshedLane?.id === lane.id + ? refreshedLane + : cachedById.get(lane.id); + if (!statusSource) return lane; + return { + ...lane, + status: statusSource.status, + parentStatus: statusSource.parentStatus, + worktreeAvailable: statusSource.worktreeAvailable, + branchDrift: statusSource.branchDrift, + }; + }); +} + export type DefaultLaneSetupResult = { progress: LaneEnvInitProgress; templateId: string | null; diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 999ea9816..b6be16cc5 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -71,6 +71,7 @@ import { getAiSettingsStatus, getChatHistory, getChatHistoryPage, + getLaneSummary, getMainTranscript, getContextUsage, getModelCatalog, @@ -98,6 +99,7 @@ import { listPrsByLane, listSessionSummaries, messageChatSession, + mergeLaneStatusSnapshots, navigateDesktop, newestSession, normalizeChatTerminalSession, @@ -1086,8 +1088,11 @@ type AdeCodeAppProps = { type RefreshStateOptions = { hydrateHistory?: boolean; + includeLaneStatus?: boolean; }; +export const LANE_STATUS_REFRESH_MS = 30_000; + export function shouldHydrateRefreshHistory(args: { hydrateHistory?: boolean; currentSessionId: string | null; @@ -3097,6 +3102,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, // the reconnect probe below and a one-shot "reconnecting…" notice. const [connectionLost, setConnectionLost] = useState(false); const [lanes, setLanes] = useState([]); + const lanesRef = useRef([]); const [prByLaneId, setPrByLaneId] = useState>({}); const [diffByLaneId, setDiffByLaneId] = useState>({}); const [sessions, setSessions] = useState([]); @@ -7352,12 +7358,20 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, refreshGenerationRef.current = generation; const isCurrentRefresh = () => refreshGenerationRef.current === generation && connectionRef.current === conn; - const [nextLanes, listedSessions, listedTerminalSessions, sessionSummaries] = await Promise.all([ - listLanes(conn), + const includeLaneStatus = options.includeLaneStatus !== false; + const activeLaneStatusPromise = includeLaneStatus || !activeLaneIdRef.current + ? Promise.resolve(null) + : getLaneSummary(conn, activeLaneIdRef.current).catch(() => null); + const [listedLanes, activeLaneStatus, listedSessions, listedTerminalSessions, sessionSummaries] = await Promise.all([ + listLanes(conn, { includeStatus: includeLaneStatus }), + activeLaneStatusPromise, listChatSessions(conn), listTerminalSessions(conn).catch(() => []), listSessionSummaries(conn).catch(() => []), ]); + const nextLanes = includeLaneStatus + ? listedLanes + : mergeLaneStatusSnapshots(listedLanes, lanesRef.current, activeLaneStatus); const nextSessions = mergeOptimisticChatSessions( enrichChatSessionsWithLifecycle(listedSessions, sessionSummaries), optimisticChatSessionsRef.current, @@ -7505,6 +7519,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const activeModel = nextModels.find((model) => model.modelId === configSession?.modelId || model.id === configSession?.modelId) ?? nextModels.find((model) => model.isDefault) ?? null; + lanesRef.current = nextLanes; setLanes(nextLanes); sessionsRef.current = nextSessions; setSessions(nextSessions); @@ -8384,13 +8399,23 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, if (!connection) return; const intervalMs = chatRefreshPollActive ? 1_000 : 15_000; const timer = setInterval(() => { - void refreshState({ hydrateHistory: false }).catch((err) => { + void refreshState({ hydrateHistory: false, includeLaneStatus: false }).catch((err) => { setError(err instanceof Error ? err.message : String(err)); }); }, intervalMs); return () => clearInterval(timer); }, [chatRefreshPollActive, connection, refreshState]); + useEffect(() => { + if (!connection) return; + const timer = setInterval(() => { + void refreshState({ hydrateHistory: false, includeLaneStatus: true }).catch((err) => { + setError(err instanceof Error ? err.message : String(err)); + }); + }, LANE_STATUS_REFRESH_MS); + return () => clearInterval(timer); + }, [connection, refreshState]); + useEffect(() => { if (!connection) { setDiffByLaneId({}); diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 43e4c0253..9504932ee 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -1636,14 +1636,15 @@ describe("runtime session actions", () => { expect(clearWokeMarker).toHaveBeenCalledWith("session-1"); }); - it("validates lane branch-drift args and forwards the resolution", async () => { + it("validates lane status and branch-drift args before forwarding them", async () => { + const getSummary = vi.fn(async () => ({ id: "lane-1", status: { dirty: true } })); const getBranchDrift = vi.fn(async () => ({ expectedBranchRef: "ade/feature", headBranchRef: "hotfix-auth", })); const resolveBranchDrift = vi.fn(async () => ({ resolution: "switch-back" })); const runtime = { - laneService: { getBranchDrift, resolveBranchDrift }, + laneService: { getSummary, getBranchDrift, resolveBranchDrift }, } as unknown as Parameters[0]; const laneActions = getAdeActionDomainServices(runtime).lane as Record< string, @@ -1652,9 +1653,16 @@ describe("runtime session actions", () => { // `ade lane actions --text` reads this list, so drift must appear in it. expect(listAllowedAdeActionNames("lane", laneActions)).toEqual( - expect.arrayContaining(["getBranchDrift", "resolveBranchDrift"]), + expect.arrayContaining(["getSummary", "getBranchDrift", "resolveBranchDrift"]), ); + await expect(laneActions.getSummary({ laneId: "lane-1", includeStatus: true })).resolves.toEqual({ + id: "lane-1", + status: { dirty: true }, + }); + expect(getSummary).toHaveBeenCalledWith("lane-1", { includeStatus: true }); + await expect(laneActions.getSummary({})).rejects.toThrow(/laneId/); + await expect(laneActions.getBranchDrift({ laneId: "lane-1" })).resolves.toEqual({ expectedBranchRef: "ade/feature", headBranchRef: "hotfix-auth", diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index bff0c6d7a..f86372663 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -337,6 +337,7 @@ export const ADE_ACTION_ALLOWLIST: Partial { + const record = readObjectActionArg(args, "lane.getSummary"); + const laneId = requireNonEmptyString(record.laneId, "laneId"); + return runtime.laneService.getSummary(laneId, { + includeStatus: record.includeStatus !== false, + }); + }, listSnapshots: async (args?: ListLanesArgs): Promise => { const lanes = await runtime.laneService.list({ includeArchived: Boolean(args?.includeArchived), diff --git a/apps/desktop/src/main/services/files/fileService.test.ts b/apps/desktop/src/main/services/files/fileService.test.ts index ef06150c3..7aec5b330 100644 --- a/apps/desktop/src/main/services/files/fileService.test.ts +++ b/apps/desktop/src/main/services/files/fileService.test.ts @@ -16,6 +16,15 @@ function createLaneServiceStub(rootPath: string) { } as any; } +function removeTestTree(rootPath: string): void { + fs.rmSync(rootPath, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 100, + }); +} + describe("fileService", () => { afterEach(() => { vi.restoreAllMocks(); @@ -47,7 +56,7 @@ describe("fileService", () => { ).rejects.toThrow(permissionError); } finally { spy.mockRestore(); - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -76,7 +85,7 @@ describe("fileService", () => { }); expect(result.dataUrl).toBeUndefined(); } finally { - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -123,7 +132,7 @@ describe("fileService", () => { }); expect(image.dataUrl).toBeUndefined(); } finally { - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -163,7 +172,7 @@ describe("fileService", () => { expect(Buffer.byteLength(assembled, "utf8")).toBe(totalBytes); expect(assembled).toBe(body); } finally { - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -199,7 +208,7 @@ describe("fileService", () => { expect(Buffer.concat(chunks)).toEqual(pdfBytes); } finally { - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -234,7 +243,7 @@ describe("fileService", () => { expect(Buffer.concat(chunks)).toEqual(bytes); } } finally { - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -267,7 +276,7 @@ describe("fileService", () => { expect(Buffer.concat(chunks)).toEqual(bytes); } finally { - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -318,8 +327,8 @@ describe("fileService", () => { expect(directoryOpen.workspace.id).toBe(fileOpen.workspace.id); expect(service.listWorkspaces().some((workspace) => workspace.id === fileOpen.workspace.id)).toBe(true); } finally { - fs.rmSync(projectRoot, { recursive: true, force: true }); - fs.rmSync(externalRoot, { recursive: true, force: true }); + removeTestTree(projectRoot); + removeTestTree(externalRoot); } }); @@ -345,7 +354,7 @@ describe("fileService", () => { expect(blame.lines[0].sha).toMatch(/^[0-9a-f]{40}$/); expect(blame.lines[0].authorTime).toBeGreaterThan(0); } finally { - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -374,7 +383,7 @@ describe("fileService", () => { }); expect(result.dataUrl).toBeUndefined(); } finally { - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -417,7 +426,7 @@ describe("fileService", () => { expect(searchDefault).toEqual([]); expect(searchIgnored.map((item) => item.path)).toContain(".ade/notes/project.md"); } finally { - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -462,7 +471,7 @@ describe("fileService", () => { expect(notes.map((item) => item.path)).toEqual([".ade/notes/project.md"]); expect(adeChildren.map((node) => node.path)).toEqual([".ade/notes"]); } finally { - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -493,7 +502,7 @@ describe("fileService", () => { expect(quickOpen).toEqual([]); expect(search.map((item) => item.path)).toEqual(["src/index.ts"]); } finally { - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -525,7 +534,7 @@ describe("fileService", () => { readdirSync.mockRestore(); } } finally { - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -543,7 +552,7 @@ describe("fileService", () => { await expect(service.warmQuickOpenIndex({ workspaceId: "missing" })).resolves.toBeUndefined(); expect(laneService.resolveWorkspaceById).toHaveBeenCalledWith("missing"); } finally { - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -599,7 +608,7 @@ describe("fileService", () => { ); expect(nestedNodes[0]).not.toHaveProperty("size"); } finally { - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -627,7 +636,7 @@ describe("fileService", () => { expect(rootNodes.find((node) => node.path === "package-renamed.json")?.changeStatus).toBe("renamed"); expect(rootNodes.find((node) => node.path === "scratch.ts")?.changeStatus).toBe("untracked"); } finally { - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -676,7 +685,7 @@ describe("fileService", () => { const collected = [...page1.children, ...page2.children, ...page3.children].map((node) => node.path); expect(collected).toEqual(names.map((name) => `data/${name}`)); } finally { - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -712,7 +721,7 @@ describe("fileService", () => { } } } finally { - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -731,7 +740,7 @@ describe("fileService", () => { ).toThrow(/\.git/i); expect(fs.existsSync(path.join(path.dirname(rootPath), "outside.txt"))).toBe(false); } finally { - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -756,7 +765,7 @@ describe("fileService", () => { ]); expect(page.nextOffset).toBeNull(); } finally { - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -788,7 +797,7 @@ describe("fileService", () => { expect(dirPaths).toContain("src/nested"); expect(event.directories.every((entry) => entry.changeStatus === "modified")).toBe(true); } finally { - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -813,7 +822,7 @@ describe("fileService", () => { expect(event.files).toEqual([]); expect(event.directories).toEqual([]); } finally { - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -864,7 +873,7 @@ describe("fileService", () => { ]); expect(workspaces.every((workspace) => workspace.mobileReadOnly === true)).toBe(true); } finally { - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -909,7 +918,7 @@ describe("fileService", () => { fs.mkdirSync(laneRoot, { recursive: true }); expect(service.listWorkspaces().map((workspace) => workspace.id)).toEqual(["primary", "lane-existing"]); } finally { - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); }); @@ -978,7 +987,7 @@ describe("fileSearchIndexService", () => { expect(cached).toHaveLength(10); } finally { service.dispose(); - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -1007,7 +1016,7 @@ describe("fileSearchIndexService", () => { expect(readFileSync).not.toHaveBeenCalled(); } finally { service.dispose(); - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -1058,7 +1067,7 @@ describe("fileSearchIndexService", () => { expect(readFileSync).not.toHaveBeenCalled(); } finally { service.dispose(); - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -1133,7 +1142,7 @@ describe("fileSearchIndexService", () => { expect(afterDelete.map((item) => item.path)).toEqual(["alpha-two.ts"]); } finally { service.dispose(); - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); @@ -1189,7 +1198,7 @@ describe("fileSearchIndexService", () => { })).resolves.toEqual([]); } finally { service.dispose(); - fs.rmSync(rootPath, { recursive: true, force: true }); + removeTestTree(rootPath); } }); }); diff --git a/apps/desktop/src/main/services/remoteRuntime/pairedRuntimeBootstrap.test.ts b/apps/desktop/src/main/services/remoteRuntime/pairedRuntimeBootstrap.test.ts index a2b4872eb..ccc57b8e0 100644 --- a/apps/desktop/src/main/services/remoteRuntime/pairedRuntimeBootstrap.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/pairedRuntimeBootstrap.test.ts @@ -322,6 +322,7 @@ describe("bootstrapPairedRuntime", () => { pairedStore: { getForReference: vi.fn(() => routedCredentials), save: vi.fn(), + markEndpointFailed: vi.fn(), markEndpointSucceeded: vi.fn(), } as any, appVersion: "1.0.0", diff --git a/apps/desktop/src/main/services/remoteRuntime/pairedRuntimeBootstrap.ts b/apps/desktop/src/main/services/remoteRuntime/pairedRuntimeBootstrap.ts index 638790a6a..604215340 100644 --- a/apps/desktop/src/main/services/remoteRuntime/pairedRuntimeBootstrap.ts +++ b/apps/desktop/src/main/services/remoteRuntime/pairedRuntimeBootstrap.ts @@ -4,6 +4,7 @@ import type { } from "../../../shared/types/pairedRuntime"; import type { RemoteRuntimeConnectResult, + RemoteRuntimeConnectionAttemptFailure, RemoteRuntimeTarget, } from "../../../shared/types/remoteRuntime"; import { @@ -28,6 +29,7 @@ import { MAX_ROUTE_ATTEMPTS, orderPairedCandidates, pairedRuntimeRouteHost, + type PairedRuntimeEndpointCandidate, } from "./pairedRuntimeRoutes"; import { PairedRuntimeCompatibilityError, @@ -131,6 +133,16 @@ export async function bootstrapPairedRuntime(args: { // Keep the phases explicit even if a future candidate-builder change // accidentally reorders endpoints. const orderedCandidates = orderPairedCandidates(candidates); + const markEndpointFailed = ( + candidate: PairedRuntimeEndpointCandidate, + failure: RemoteRuntimeConnectionAttemptFailure, + ): void => { + if (failure === "authentication") return; + args.pairedStore.markEndpointFailed( + credentials.hostIdentity.deviceId, + candidate.endpoint, + ); + }; for (const candidate of orderedCandidates) { const attemptStartedAt = Date.now(); const safeHost = pairedRuntimeRouteHost(candidate.endpoint); @@ -213,6 +225,7 @@ export async function bootstrapPairedRuntime(args: { continue; } const failure = classifyPairedRuntimeFailure(error); + markEndpointFailed(candidate, failure); recordAttempt({ kind: candidate.kind, host: safeHost, @@ -251,6 +264,7 @@ export async function bootstrapPairedRuntime(args: { client.close(); if (isPairedTransportFailure(error)) { const failure = classifyPairedRuntimeFailure(error); + markEndpointFailed(candidate, failure); recordAttempt({ kind: candidate.kind, host: safeHost, @@ -284,6 +298,7 @@ export async function bootstrapPairedRuntime(args: { client.close(); if (isPairedTransportFailure(error)) { const failure = classifyPairedRuntimeFailure(error); + markEndpointFailed(candidate, failure); recordAttempt({ kind: candidate.kind, host: safeHost, diff --git a/apps/desktop/src/main/services/remoteRuntime/pairedRuntimeRoutes.test.ts b/apps/desktop/src/main/services/remoteRuntime/pairedRuntimeRoutes.test.ts index 7f5a74643..9d1dbaa9e 100644 --- a/apps/desktop/src/main/services/remoteRuntime/pairedRuntimeRoutes.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/pairedRuntimeRoutes.test.ts @@ -70,6 +70,46 @@ describe("paired runtime endpoint routes", () => { ]); }); + it("demotes a route after two recent failures, keeps it eligible, and expires the demotion", () => { + const endpoints = [ + "ws://studio.local:8787", + "ws://studio.example.ts.net:8787", + "wss://relay.example/connect/machine", + ]; + const endpointStates = [{ + endpoint: "ws://studio.local:8787", + lastSucceededAt: 900, + lastFailedAt: 1_000, + consecutiveFailures: 2, + }]; + + const demoted = buildPairedEndpointCandidates({ + endpoints, + relayUrl: endpoints[2], + endpointStates, + nowMs: 1_001, + }); + expect(orderPairedCandidates(demoted).map((candidate) => candidate.endpoint)) + .toEqual([ + "ws://studio.example.ts.net:8787/", + "wss://relay.example/connect/machine", + "ws://studio.local:8787/", + ]); + + const expired = buildPairedEndpointCandidates({ + endpoints, + relayUrl: endpoints[2], + endpointStates, + nowMs: 121_001, + }); + expect(orderPairedCandidates(expired).map((candidate) => candidate.endpoint)) + .toEqual([ + "ws://studio.local:8787/", + "ws://studio.example.ts.net:8787/", + "wss://relay.example/connect/machine", + ]); + }); + it("classifies normalized CGNAT and ts.net hostnames as tailnet", () => { expect(classifyPairedRuntimeEndpoint("ws://100.127.255.254:8787")).toBe( "tailnet", diff --git a/apps/desktop/src/main/services/remoteRuntime/pairedRuntimeRoutes.ts b/apps/desktop/src/main/services/remoteRuntime/pairedRuntimeRoutes.ts index c6f510111..18e14bcfb 100644 --- a/apps/desktop/src/main/services/remoteRuntime/pairedRuntimeRoutes.ts +++ b/apps/desktop/src/main/services/remoteRuntime/pairedRuntimeRoutes.ts @@ -11,10 +11,13 @@ export type PairedRuntimeEndpointCandidate = { endpoint: string; kind: Exclude; lastSucceededAt: number | null; + recentlyFailing: boolean; lastDiscoveredAt?: number | null; }; export const MAX_ROUTE_ATTEMPTS = 8; +export const PAIRED_ENDPOINT_FAILURE_THRESHOLD = 2; +export const PAIRED_ENDPOINT_RECENT_FAILURE_WINDOW_MS = 120_000; export type PairedRouteAttemptRecorder = { attempts: RemoteRuntimeConnectionAttempt[]; @@ -54,11 +57,26 @@ export function orderPairedCandidates( candidates: readonly PairedRuntimeEndpointCandidate[], ): PairedRuntimeEndpointCandidate[] { return [ - ...candidates.filter((candidate) => candidate.kind !== "relay"), - ...candidates.filter((candidate) => candidate.kind === "relay"), + ...candidates.filter( + (candidate) => !candidate.recentlyFailing && candidate.kind !== "relay", + ), + ...candidates.filter( + (candidate) => !candidate.recentlyFailing && candidate.kind === "relay", + ), + ...candidates.filter((candidate) => candidate.recentlyFailing), ]; } +export function pairedEndpointIsRecentlyFailing( + state: DesktopPairedMachineEndpointState | null | undefined, + nowMs = Date.now(), +): boolean { + return state?.lastFailedAt != null + && Number.isFinite(state.lastFailedAt) + && (state.consecutiveFailures ?? 0) >= PAIRED_ENDPOINT_FAILURE_THRESHOLD + && nowMs - state.lastFailedAt <= PAIRED_ENDPOINT_RECENT_FAILURE_WINDOW_MS; +} + function normalizedEndpointOrNull( value: string | null | undefined, ): string | null { @@ -104,13 +122,22 @@ export function buildPairedEndpointCandidates(args: { relayUrl?: string | null; endpointStates?: DesktopPairedMachineEndpointState[] | null; additionalEndpoints?: string[]; + nowMs?: number; }): PairedRuntimeEndpointCandidate[] { const relayUrl = normalizedEndpointOrNull(args.relayUrl); const successByEndpoint = new Map(); const discoveryByEndpoint = new Map(); + const stateByEndpoint = new Map(); for (const state of args.endpointStates ?? []) { const endpoint = normalizedEndpointOrNull(state.endpoint); if (!endpoint) continue; + const previous = stateByEndpoint.get(endpoint); + if ( + !previous + || (state.lastFailedAt ?? 0) >= (previous.lastFailedAt ?? 0) + ) { + stateByEndpoint.set(endpoint, state); + } if ( state.lastSucceededAt != null && Number.isFinite(state.lastSucceededAt) @@ -147,6 +174,10 @@ export function buildPairedEndpointCandidates(args: { endpoint, kind: classifyPairedRuntimeEndpoint(endpoint, relayUrl), lastSucceededAt: successByEndpoint.get(endpoint) ?? null, + recentlyFailing: pairedEndpointIsRecentlyFailing( + stateByEndpoint.get(endpoint), + args.nowMs, + ), ...(discoveryByEndpoint.has(endpoint) ? { lastDiscoveredAt: discoveryByEndpoint.get(endpoint)! } : {}), @@ -162,6 +193,7 @@ export function buildPairedEndpointCandidates(args: { return candidates .sort( (left, right) => + Number(left.recentlyFailing) - Number(right.recentlyFailing) || rank[left.kind] - rank[right.kind] || (right.lastDiscoveredAt ?? 0) - (left.lastDiscoveredAt ?? 0) || (right.lastSucceededAt ?? 0) - (left.lastSucceededAt ?? 0) || diff --git a/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts b/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts index 070646241..ec5713a8c 100644 --- a/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts @@ -317,6 +317,38 @@ describe("DesktopPairedMachineStore", () => { expect(new DesktopPairedMachineStore().get("mac-studio-host")) .toEqual(marked); + const firstFailure = store.markEndpointFailed( + "mac-studio-host", + "wss://relay.example/connect/machine-123", + 1_700_000_000_100, + ); + expect(firstFailure.endpointStates).toContainEqual({ + endpoint: "wss://relay.example/connect/machine-123", + lastSucceededAt: 1_700_000_000_000, + lastFailedAt: 1_700_000_000_100, + consecutiveFailures: 1, + }); + const secondFailure = store.markEndpointFailed( + "mac-studio-host", + "wss://relay.example/connect/machine-123", + 1_700_000_000_200, + ); + expect(secondFailure.endpointStates).toContainEqual({ + endpoint: "wss://relay.example/connect/machine-123", + lastSucceededAt: 1_700_000_000_000, + lastFailedAt: 1_700_000_000_200, + consecutiveFailures: 2, + }); + const recovered = store.markEndpointSucceeded( + "mac-studio-host", + "wss://relay.example/connect/machine-123", + 1_700_000_000_300, + ); + expect(recovered.endpointStates).toContainEqual({ + endpoint: "wss://relay.example/connect/machine-123", + lastSucceededAt: 1_700_000_000_300, + }); + const discovered = store.markEndpointsDiscovered( "mac-studio-host", ["ws://studio.local:8805"], diff --git a/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.ts b/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.ts index 887b2d121..e283537f5 100644 --- a/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.ts +++ b/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.ts @@ -274,6 +274,8 @@ function coerceEndpointStates( ): DesktopPairedMachineEndpointState[] { const stateByEndpoint = new Map(); if (Array.isArray(value)) { @@ -295,12 +297,37 @@ function coerceEndpointStates( && Number.isFinite(entry.lastDiscoveredAt) ? entry.lastDiscoveredAt : null; + const lastFailedAt = typeof entry.lastFailedAt === "number" + && Number.isFinite(entry.lastFailedAt) + ? entry.lastFailedAt + : null; + const consecutiveFailures = typeof entry.consecutiveFailures === "number" + && Number.isInteger(entry.consecutiveFailures) + && entry.consecutiveFailures > 0 + ? entry.consecutiveFailures + : 0; const current = stateByEndpoint.get(endpoint); + const mergedLastSucceededAt = Math.max( + current?.lastSucceededAt ?? 0, + lastSucceededAt ?? 0, + ) || null; + const useIncomingFailure = (lastFailedAt ?? 0) >= (current?.lastFailedAt ?? 0); + const mergedLastFailedAt = useIncomingFailure + ? lastFailedAt + : current?.lastFailedAt ?? null; + const mergedConsecutiveFailures = useIncomingFailure + ? consecutiveFailures + : current?.consecutiveFailures ?? 0; stateByEndpoint.set(endpoint, { - lastSucceededAt: Math.max( - current?.lastSucceededAt ?? 0, - lastSucceededAt ?? 0, - ) || null, + lastSucceededAt: mergedLastSucceededAt, + lastFailedAt: mergedLastSucceededAt != null + && mergedLastSucceededAt >= (mergedLastFailedAt ?? 0) + ? null + : mergedLastFailedAt, + consecutiveFailures: mergedLastSucceededAt != null + && mergedLastSucceededAt >= (mergedLastFailedAt ?? 0) + ? 0 + : mergedConsecutiveFailures, lastDiscoveredAt: Math.max( current?.lastDiscoveredAt ?? 0, lastDiscoveredAt ?? 0, @@ -312,6 +339,8 @@ function coerceEndpointStates( if (!stateByEndpoint.has(endpoint)) { stateByEndpoint.set(endpoint, { lastSucceededAt: null, + lastFailedAt: null, + consecutiveFailures: 0, lastDiscoveredAt: null, }); } @@ -319,6 +348,12 @@ function coerceEndpointStates( return [...stateByEndpoint].map(([endpoint, state]) => ({ endpoint, lastSucceededAt: state.lastSucceededAt, + ...(state.lastFailedAt != null + ? { + lastFailedAt: state.lastFailedAt, + consecutiveFailures: state.consecutiveFailures, + } + : {}), ...(state.lastDiscoveredAt != null ? { lastDiscoveredAt: state.lastDiscoveredAt } : {}), @@ -548,6 +583,34 @@ export class DesktopPairedMachineStore { }); } + markEndpointFailed( + hostDeviceIdOrMachineKey: string, + endpointValue: string, + nowMs = Date.now(), + ): DesktopPairedMachineCredentials { + const machine = this.get(hostDeviceIdOrMachineKey); + if (!machine) throw new Error("Paired machine was not found."); + const endpoint = normalizeSyncEndpoint(endpointValue); + const endpoints = uniqueEndpoints(endpoint, ...machine.endpoints); + const previous = machine.endpointStates?.find( + (state) => state.endpoint === endpoint, + ); + return this.save({ + ...machine, + endpoints, + endpointStates: mergeEndpointStates( + endpoints, + machine.endpointStates, + [{ + endpoint, + lastSucceededAt: previous?.lastSucceededAt ?? null, + lastFailedAt: nowMs, + consecutiveFailures: (previous?.consecutiveFailures ?? 0) + 1, + }], + ), + }); + } + markEndpointsDiscovered( hostDeviceIdOrMachineKey: string, endpointValues: string[], diff --git a/apps/desktop/src/shared/types/pairedRuntime.ts b/apps/desktop/src/shared/types/pairedRuntime.ts index 7a9046aa3..b77f332eb 100644 --- a/apps/desktop/src/shared/types/pairedRuntime.ts +++ b/apps/desktop/src/shared/types/pairedRuntime.ts @@ -132,6 +132,9 @@ export type DesktopPairedMachineCredentials = { export type DesktopPairedMachineEndpointState = { endpoint: string; lastSucceededAt: number | null; + /** Recent consecutive dial failures demote, but never remove, this route. */ + lastFailedAt?: number | null; + consecutiveFailures?: number; /** Fresh discovery wins within a route kind before historical success. */ lastDiscoveredAt?: number | null; }; diff --git a/apps/ios/ADE/App/ADEApp.swift b/apps/ios/ADE/App/ADEApp.swift index 9a14f4199..04b61dfd8 100644 --- a/apps/ios/ADE/App/ADEApp.swift +++ b/apps/ios/ADE/App/ADEApp.swift @@ -51,6 +51,7 @@ struct ADEApp: App { if newPhase == .background { didEnterBackground = true ProductAnalytics.shared.flush() + Task { await accountService.updateAttentionAppForeground(false) } return } guard newPhase == .active else { return } @@ -61,6 +62,7 @@ struct ADEApp: App { // Clear the badge on every foreground, independent of the sync // throttle below — a lingering count after re-entry reads as stale. Task { await PushNotificationService.shared.clearAppBadge() } + Task { await accountService.updateAttentionAppForeground(true) } // Defense-in-depth: drain intent commands queued by an extension // process while the bridge wasn't reachable (cold launch drains via // register(); this covers warm foregrounds). diff --git a/apps/ios/ADE/App/DeepLinkRouter.swift b/apps/ios/ADE/App/DeepLinkRouter.swift index 9e933022f..77d1c5652 100644 --- a/apps/ios/ADE/App/DeepLinkRouter.swift +++ b/apps/ios/ADE/App/DeepLinkRouter.swift @@ -63,6 +63,7 @@ final class DeepLinkRouter { // `ade://pr/` (compact local link) // `ade://pr///` (repo-scoped local link) // Anything else is ignored so a malformed link can't crash navigation. + guard hasValidOptionalPrScope(url) else { return } if pathComponents.count >= 3 { let owner = pathComponents[0] let repo = pathComponents[1] @@ -434,7 +435,7 @@ final class DeepLinkRouter { return nil } let value = ADEDeepLinkURLParsing.adeQueryValues(from: components)["accountmachinekey"] - guard ADEDeepLinkURLParsing.isValidOpaqueId(value) else { return nil } + guard ADEDeepLinkURLParsing.isValidScopeComponent(value) else { return nil } return value } @@ -443,10 +444,26 @@ final class DeepLinkRouter { return nil } let value = ADEDeepLinkURLParsing.adeQueryValues(from: components)["event"] - guard ADEDeepLinkURLParsing.isValidOpaqueId(value) else { return nil } + guard ADEDeepLinkURLParsing.isValidScopeComponent(value) else { return nil } return value } + private func hasValidOptionalPrScope(_ url: URL) -> Bool { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return false + } + let query = ADEDeepLinkURLParsing.adeQueryValues(from: components) + if let accountMachineKey = query["accountmachinekey"], + !ADEDeepLinkURLParsing.isValidScopeComponent(accountMachineKey) { + return false + } + if let eventId = query["event"], + !ADEDeepLinkURLParsing.isValidScopeComponent(eventId) { + return false + } + return true + } + private func prDetailTab(from rawValue: String?) -> PrDetailTab? { switch rawValue?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { case "overview", "activity": @@ -501,18 +518,18 @@ final class DeepLinkRouter { return nil } if let accountMachineKey = query["accountmachinekey"], - !ADEDeepLinkURLParsing.isValidOpaqueId(accountMachineKey) { + !ADEDeepLinkURLParsing.isValidScopeComponent(accountMachineKey) { return nil } if let itemId = query["item"], - !ADEDeepLinkURLParsing.isValidOpaqueId(itemId) { + !ADEDeepLinkURLParsing.isValidScopeComponent(itemId) { return nil } let rawEvent = query["event"] let event = ADEDeepLinkURLParsing.nonNegativeInteger(rawEvent) let eventId: String? if event == nil, let rawEvent { - guard ADEDeepLinkURLParsing.isValidOpaqueId(rawEvent) else { return nil } + guard ADEDeepLinkURLParsing.isValidScopeComponent(rawEvent) else { return nil } eventId = rawEvent } else { eventId = nil diff --git a/apps/ios/ADE/App/DeepLinkURLParsing.swift b/apps/ios/ADE/App/DeepLinkURLParsing.swift index aa3481fe0..0c10a366b 100644 --- a/apps/ios/ADE/App/DeepLinkURLParsing.swift +++ b/apps/ios/ADE/App/DeepLinkURLParsing.swift @@ -109,6 +109,20 @@ enum ADEDeepLinkURLParsing { return !containsControlCharacter(trimmed) } + static func isValidScopeComponent(_ value: String?) -> Bool { + guard let value else { return false } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + trimmed.count <= 512, + trimmed == value, + !trimmed.contains(where: \.isWhitespace), + !trimmed.contains("/"), + !trimmed.contains("\\") else { + return false + } + return !containsControlCharacter(trimmed) + } + static func isValidRepoRelativePath(_ value: String?) -> Bool { guard let value, !value.isEmpty, value.count <= 1024 else { return false } if value.hasPrefix("/") || value.hasSuffix("/") || value.contains("\\") { return false } diff --git a/apps/ios/ADE/Services/AccountService.swift b/apps/ios/ADE/Services/AccountService.swift index 48e993a78..52faf75b9 100644 --- a/apps/ios/ADE/Services/AccountService.swift +++ b/apps/ios/ADE/Services/AccountService.swift @@ -91,6 +91,32 @@ struct AccountDeviceOwnershipState: Codable, Equatable { let ownerId: String? } +struct AccountAttentionPresenceState: Equatable { + private(set) var appForeground = true + private(set) var centerVisible = false + private(set) var visibleItemIds: [String] = [] + + mutating func updateAppForeground(_ foreground: Bool) { + appForeground = foreground + } + + mutating func updateSurface( + centerVisible: Bool, + visibleItemIds: [String] + ) { + self.centerVisible = centerVisible + self.visibleItemIds = Array(visibleItemIds.prefix(64)) + } + + var reportedCenterVisible: Bool { + appForeground && centerVisible + } + + var reportedVisibleItemIds: [String] { + appForeground ? visibleItemIds : [] + } +} + struct AccountDeviceOwnershipStore { private let defaults: UserDefaults private let key: String @@ -494,6 +520,7 @@ final class AccountService: ObservableObject { private var lastRelayCredential: (ownerId: String, token: String)? private var attentionRefreshTask: Task? private var attentionRefreshId: UUID? + private var attentionPresenceState = AccountAttentionPresenceState() private var isEndingAccountOwnership = false private let accountRegistrationQueue = LatestAccountRegistrationQueue() @@ -1066,6 +1093,21 @@ final class AccountService: ObservableObject { func updateAttentionPresence( centerVisible: Bool, visibleItemIds: [String] + ) async { + attentionPresenceState.updateSurface( + centerVisible: centerVisible, + visibleItemIds: visibleItemIds + ) + await postAttentionPresence(attentionPresenceState) + } + + func updateAttentionAppForeground(_ foreground: Bool) async { + attentionPresenceState.updateAppForeground(foreground) + await postAttentionPresence(attentionPresenceState) + } + + private func postAttentionPresence( + _ presence: AccountAttentionPresenceState ) async { guard isSignedIn, let requestedOwnerId = identity?.userId, @@ -1080,9 +1122,9 @@ final class AccountService: ObservableObject { token: initialSession.token, deviceId: attentionDeviceId, deviceName: UIDevice.current.name, - foreground: true, - attentionVisible: centerVisible, - visibleItemIds: visibleItemIds, + foreground: presence.appForeground, + attentionVisible: presence.reportedCenterVisible, + visibleItemIds: presence.reportedVisibleItemIds, refreshToken: { [weak self] in guard let self, self.isPairingCommitAuthorized(initialSession.authorization) else { diff --git a/apps/ios/ADETests/PairingAndDpopTests.swift b/apps/ios/ADETests/PairingAndDpopTests.swift index 84d035b6a..3537c6816 100644 --- a/apps/ios/ADETests/PairingAndDpopTests.swift +++ b/apps/ios/ADETests/PairingAndDpopTests.swift @@ -109,6 +109,27 @@ private actor AccountDirectoryRefreshRecorder { /// and the DPoP challenge/signature contract (parity with /// `apps/ade-cli/src/services/sync/syncDpop.ts`). final class PairingAndDpopTests: XCTestCase { + func testBackgroundPresenceHidesVisibleAttentionUntilForegrounded() { + var state = AccountAttentionPresenceState() + state.updateSurface( + centerVisible: true, + visibleItemIds: ["approval-1", "question-2"] + ) + + XCTAssertTrue(state.appForeground) + XCTAssertTrue(state.reportedCenterVisible) + XCTAssertEqual(state.reportedVisibleItemIds, ["approval-1", "question-2"]) + + state.updateAppForeground(false) + XCTAssertFalse(state.appForeground) + XCTAssertFalse(state.reportedCenterVisible) + XCTAssertEqual(state.reportedVisibleItemIds, []) + + state.updateAppForeground(true) + XCTAssertTrue(state.reportedCenterVisible) + XCTAssertEqual(state.reportedVisibleItemIds, ["approval-1", "question-2"]) + } + // A canonical smart URL produced by the TS `encodePairingQrUrl` (includes an // unknown extra field to prove lenient forward-compat parsing). private let canonicalPairingUrl = "https://ade-app.dev/pair#eyJ2ZXJzaW9uIjozLCJob3N0SWRlbnRpdHkiOnsiZGV2aWNlSWQiOiJkZXYtYWJjMTIzIiwic2l0ZUlkIjoic2l0ZS14eXoiLCJuYW1lIjoiQXJ1bCBNYWNCb29rIiwicGxhdGZvcm0iOiJtYWNPUyIsImRldmljZVR5cGUiOiJkZXNrdG9wIn0sInBvcnQiOjg3ODcsImFkZHJlc3NDYW5kaWRhdGVzIjpbeyJob3N0IjoiMTkyLjE2OC4xLjQyIiwia2luZCI6ImxhbiJ9LHsiaG9zdCI6IjEwMC4xMDEuMTAyLjEwMyIsImtpbmQiOiJ0YWlsc2NhbGUifSx7Imhvc3QiOiJ3c3M6Ly9yZWxheS5hZGUtYXBwLmRldi9jb25uZWN0L21hY2hpbmVrZXkxMjMiLCJraW5kIjoicmVsYXkifV0sInJlbGF5VXJsIjoid3NzOi8vcmVsYXkuYWRlLWFwcC5kZXYvY29ubmVjdC9tYWNoaW5la2V5MTIzIiwiZXh0cmFGdXR1cmVGaWVsZCI6Imlnbm9yZWQifQ"