- Preserve Codex turn identity while suppressing duplicate diff notifications - Optimize settlement projections and isolate thread visit handling - Add concurrency and regression coverage across server and mobile
Showing the changes to apps/web/src (6 of 167 files). Show all
Unified · Split
| @@ -1186,7 +1186,14 @@ function OpenCommandPaletteDialog(props: { · 1185 unmodified lines | |||
| 1186 | projectTitleById, | 1186 | projectTitleById, |
| 1187 | sortOrder: clientSettings.sidebarThreadSortOrder, | 1187 | sortOrder: clientSettings.sidebarThreadSortOrder, |
| 1188 | icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />, | 1188 | icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />, |
| 1189 | renderLeadingContent: (thread) => <ThreadRowLeadingStatus thread={thread} />, | 1189 | renderLeadingContent: (thread) => ( |
| 1190 | <ThreadRowLeadingStatus | ||
| 1191 | thread={thread} | ||
| 1192 | snapshot={changeRequestSnapshotByKey.get( | ||
| 1193 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | ||
| 1194 | )} | ||
| 1195 | /> | ||
| 1196 | ), | ||
| 1190 | renderTrailingContent: (thread) => <ThreadRowTrailingStatus thread={thread} />, | 1197 | renderTrailingContent: (thread) => <ThreadRowTrailingStatus thread={thread} />, |
| 1191 | renderDescription: (thread, { projectTitle }) => { | 1198 | renderDescription: (thread, { projectTitle }) => { |
| 1192 | const modelInstanceId = | 1199 | const modelInstanceId = |
| @@ -1238,6 +1245,7 @@ function OpenCommandPaletteDialog(props: { · 45 unmodified lines | |||
| 1238 | }), | 1245 | }), |
| 1239 | [ | 1246 | [ |
| 1240 | activeThreadId, | 1247 | activeThreadId, |
| 1248 | changeRequestSnapshotByKey, | ||
| 1241 | clientSettings.sidebarThreadSortOrder, | 1249 | clientSettings.sidebarThreadSortOrder, |
| 1242 | navigate, | 1250 | navigate, |
| 1243 | projectCwdById, | 1251 | projectCwdById, |
| @@ -0,0 +1,180 @@ | |||
| 1 | import { ProjectId, ThreadId, type VcsStatusResult } from "@t3tools/contracts"; | ||
| 2 | import { act, type ComponentProps, type ReactNode } from "react"; | ||
| 3 | import { create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 4 | import { afterEach, expect, it, vi } from "vite-plus/test"; | ||
| 5 | |||
| 6 | import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; | ||
| 7 | import { makeThreadFixture } from "../test-fixtures"; | ||
| 8 | |||
| 9 | const state = vi.hoisted(() => ({ queries: [] as string[] })); | ||
| 10 | |||
| 11 | vi.mock("../state/entities", () => ({ useProject: () => null })); | ||
| 12 | vi.mock("../uiStateStore", () => ({ useUiStateStore: () => undefined })); | ||
| 13 | vi.mock("../state/vcs", async () => { | ||
| 14 | const { AsyncResult, Atom } = await import("effect/unstable/reactivity"); | ||
| 15 | const statuses = Atom.family((cwd: string) => | ||
| 16 | Atom.make(() => { | ||
| 17 | state.queries.push(`vcs:${cwd}`); | ||
| 18 | return AsyncResult.success({ | ||
| 19 | isRepo: true, | ||
| 20 | hasPrimaryRemote: true, | ||
| 21 | isDefaultRef: false, | ||
| 22 | refName: "feature/test", | ||
| 23 | hasWorkingTreeChanges: false, | ||
| 24 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 25 | hasUpstream: true, | ||
| 26 | aheadCount: 0, | ||
| 27 | behindCount: 0, | ||
| 28 | pr: { | ||
| 29 | number: 42, | ||
| 30 | title: "Observed worktree PR", | ||
| 31 | url: "https://github.com/example/repo/pull/42", | ||
| 32 | baseRef: "main", | ||
| 33 | headRef: "feature/test", | ||
| 34 | state: "open", | ||
| 35 | }, | ||
| 36 | } satisfies VcsStatusResult); | ||
| 37 | }), | ||
| 38 | ); | ||
| 39 | return { | ||
| 40 | vcsEnvironment: { status: ({ input }: { input: { cwd: string } }) => statuses(input.cwd) }, | ||
| 41 | }; | ||
| 42 | }); | ||
| 43 | vi.mock("../state/pullRequests", async (importOriginal) => { | ||
| 44 | const original = await importOriginal<typeof import("../state/pullRequests")>(); | ||
| 45 | const { AsyncResult, Atom } = await import("effect/unstable/reactivity"); | ||
| 46 | const { ProjectId } = await import("@t3tools/contracts"); | ||
| 47 | const summaries = Atom.family((number: number) => | ||
| 48 | Atom.make(() => { | ||
| 49 | state.queries.push(`pr:${number}`); | ||
| 50 | return AsyncResult.success({ | ||
| 51 | provider: "github" as const, | ||
| 52 | projectId: ProjectId.make("project-test"), | ||
| 53 | repository: "example/repo", | ||
| 54 | number, | ||
| 55 | title: "Observed linked PR", | ||
| 56 | url: `https://github.com/example/repo/pull/${number}`, | ||
| 57 | state: "open" as const, | ||
| 58 | headBranch: "feature/test", | ||
| 59 | baseBranch: "main", | ||
| 60 | updatedAt: "2026-09-04T00:00:00.000Z", | ||
| 61 | }); | ||
| 62 | }), | ||
| 63 | ); | ||
| 64 | return { | ||
| 65 | ...original, | ||
| 66 | linkedPullRequestDetailAtom: ({ input }: { input: { number: number } }) => | ||
| 67 | summaries(input.number), | ||
| 68 | }; | ||
| 69 | }); | ||
| 70 | vi.mock("./ui/tooltip", async () => { | ||
| 71 | const { cloneElement, isValidElement } = await import("react"); | ||
| 72 | return { | ||
| 73 | Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>, | ||
| 74 | TooltipTrigger({ | ||
| 75 | render, | ||
| 76 | children, | ||
| 77 | }: ComponentProps<typeof import("./ui/tooltip").TooltipTrigger>) { | ||
| 78 | return isValidElement(render) ? cloneElement(render, undefined, children) : children; | ||
| 79 | }, | ||
| 80 | TooltipPopup: () => null, | ||
| 81 | }; | ||
| 82 | }); | ||
| 83 | |||
| 84 | import { ThreadRowLeadingStatus } from "./ThreadStatusIndicators"; | ||
| 85 | |||
| 86 | afterEach(() => { | ||
| 87 | vi.unstubAllGlobals(); | ||
| 88 | }); | ||
| 89 | |||
| 90 | it("leases only visible palette results while retaining observed and cached PR badges", async () => { | ||
| 91 | const observers: Array<{ setVisible: (visible: boolean) => void }> = []; | ||
| 92 | const scrollRoot = {}; | ||
| 93 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 94 | vi.stubGlobal( | ||
| 95 | "IntersectionObserver", | ||
| 96 | class { | ||
| 97 | constructor(callback: (entries: ReadonlyArray<{ isIntersecting: boolean }>) => void) { | ||
| 98 | observers.push({ setVisible: (visible) => callback([{ isIntersecting: visible }]) }); | ||
| 99 | } | ||
| 100 | observe() {} | ||
| 101 | disconnect() {} | ||
| 102 | }, | ||
| 103 | ); | ||
| 104 | const threads = Array.from({ length: 128 }, (_, index) => | ||
| 105 | makeThreadFixture({ | ||
| 106 | id: ThreadId.make(`palette-thread-${index}`), | ||
| 107 | branch: "feature/test", | ||
| 108 | worktreePath: `/test/worktree-${index}`, | ||
| 109 | linkedPullRequest: | ||
| 110 | index % 2 === 0 | ||
| 111 | ? null | ||
| 112 | : { | ||
| 113 | projectId: ProjectId.make("project-test"), | ||
| 114 | repository: "example/repo", | ||
| 115 | number: index, | ||
| 116 | url: `https://github.com/example/repo/pull/${index}`, | ||
| 117 | }, | ||
| 118 | }), | ||
| 119 | ); | ||
| 120 | let renderer: ReactTestRenderer | undefined; | ||
| 121 | state.queries.length = 0; | ||
| 122 | try { | ||
| 123 | await act(async () => { | ||
| 124 | renderer = create( | ||
| 125 | <AppAtomRegistryProvider> | ||
| 126 | {threads.map((thread, index) => ( | ||
| 127 | <span key={thread.id}> | ||
| 128 | <ThreadRowLeadingStatus | ||
| 129 | thread={thread} | ||
| 130 | snapshot={ | ||
| 131 | index === 126 | ||
| 132 | ? { | ||
| 133 | branch: "feature/test", | ||
| 134 | sourceControlProvider: undefined, | ||
| 135 | pr: { | ||
| 136 | number: 126, | ||
| 137 | title: "Cached badge", | ||
| 138 | url: "https://github.com/example/repo/pull/126", | ||
| 139 | baseRef: "main", | ||
| 140 | headRef: "feature/test", | ||
| 141 | state: "open", | ||
| 142 | }, | ||
| 143 | } | ||
| 144 | : undefined | ||
| 145 | } | ||
| 146 | /> | ||
| 147 | {thread.title} | ||
| 148 | </span> | ||
| 149 | ))} | ||
| 150 | </AppAtomRegistryProvider>, | ||
| 151 | { createNodeMock: () => ({ parentElement: { closest: () => scrollRoot } }) }, | ||
| 152 | ); | ||
| 153 | }); | ||
| 154 | const mounted = renderer!; | ||
| 155 | const badgeCount = () => | ||
| 156 | mounted.root.findAll( | ||
| 157 | (node) => node.type === "span" && String(node.props["aria-label"] ?? "").startsWith("PR #"), | ||
| 158 | ).length; | ||
| 159 | expect(observers).toHaveLength(128); | ||
| 160 | expect(state.queries).toEqual([]); | ||
| 161 | expect(badgeCount()).toBe(1); | ||
| 162 | |||
| 163 | await act(async () => { | ||
| 164 | for (const observer of observers.slice(0, 8)) observer.setVisible(true); | ||
| 165 | }); | ||
| 166 | expect(state.queries).toHaveLength(8); | ||
| 167 | expect(state.queries.filter((key) => key.startsWith("vcs:"))).toHaveLength(4); | ||
| 168 | expect(state.queries.filter((key) => key.startsWith("pr:"))).toHaveLength(4); | ||
| 169 | expect(badgeCount()).toBe(9); | ||
| 170 | |||
| 171 | await act(async () => { | ||
| 172 | for (const observer of observers.slice(0, 8)) observer.setVisible(false); | ||
| 173 | for (const observer of observers.slice(8, 16)) observer.setVisible(true); | ||
| 174 | }); | ||
| 175 | expect(state.queries).toHaveLength(16); | ||
| 176 | expect(badgeCount()).toBe(17); | ||
| 177 | } finally { | ||
| 178 | await act(async () => renderer?.unmount()); | ||
| 179 | } | ||
| 180 | }); | ||
| @@ -554,6 +554,25 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { · 553 unmodified lines | |||
| 554 | ).toEqual(mergedPr); | 554 | ).toEqual(mergedPr); |
| 555 | }); | 555 | }); |
| 556 | 556 | ||
| 557 | it("shows the last matching PR snapshot while its live status lease is released", () => { | ||
| 558 | const openPr = { ...mergedPr, state: "open" as const }; | ||
| 559 | const input = { | ||
| 560 | threadBranch: featureBranch, | ||
| 561 | gitStatus: null, | ||
| 562 | snapshot: snapshotFor(featureBranch, openPr, provider), | ||
| 563 | retainTerminalOnBranchMismatch: false, | ||
| 564 | }; | ||
| 565 | expect(resolveDisplayedThreadPr(input)).toEqual(openPr); | ||
| 566 | expect(resolveDisplayedThreadPrProvider(input)).toEqual(provider); | ||
| 567 | expect(resolveDisplayedThreadPr({ ...input, threadBranch: "feature/other" })).toBeNull(); | ||
| 568 | expect( | ||
| 569 | resolveDisplayedThreadPr({ | ||
| 570 | ...input, | ||
| 571 | snapshot: { ...input.snapshot, linkedPullRequest }, | ||
| 572 | }), | ||
| 573 | ).toBeNull(); | ||
| 574 | }); | ||
| 575 | |||
| 557 | it("retains a merged PR after a main checkout", () => { | 576 | it("retains a merged PR after a main checkout", () => { |
| 558 | const matchingStatus = status({ | 577 | const matchingStatus = status({ |
| 559 | refName: featureBranch, | 578 | refName: featureBranch, |
| @@ -12,7 +12,7 @@ import { · 11 unmodified lines | |||
| 12 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 13 | import { Atom } from "effect/unstable/reactivity"; | 13 | import { Atom } from "effect/unstable/reactivity"; |
| 14 | import { FolderGit2Icon, TerminalIcon } from "lucide-react"; | 14 | import { FolderGit2Icon, TerminalIcon } from "lucide-react"; |
| 15 | import { useMemo } from "react"; | 15 | import { useCallback, useMemo } from "react"; |
| 16 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 16 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 17 | import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; | 17 | import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; |
| 18 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; | 18 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| @@ -27,6 +27,8 @@ import { · 8 unmodified lines | |||
| 27 | resolveThreadLastVisitedAt, | 27 | resolveThreadLastVisitedAt, |
| 28 | resolveThreadStatusPill, | 28 | resolveThreadStatusPill, |
| 29 | type ThreadStatusPill, | 29 | type ThreadStatusPill, |
| 30 | useRetainedValue, | ||
| 31 | useSidebarRowSubscriptionLease, | ||
| 30 | } from "./Sidebar.logic"; | 32 | } from "./Sidebar.logic"; |
| 31 | import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; | 33 | import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; |
| 32 | import type { SidebarThreadSummary } from "../types"; | 34 | import type { SidebarThreadSummary } from "../types"; |
| @@ -58,9 +60,10 @@ export interface LinkedThreadPullRequestStatus { · 25 unmodified lines | |||
| 58 | export function useLinkedThreadPullRequest( | 60 | export function useLinkedThreadPullRequest( |
| 59 | environmentId: EnvironmentId | null, | 61 | environmentId: EnvironmentId | null, |
| 60 | linkedPullRequest: ThreadLinkedPullRequest | null | undefined, | 62 | linkedPullRequest: ThreadLinkedPullRequest | null | undefined, |
| 63 | enabled = true, | ||
| 61 | ): LinkedThreadPullRequestStatus | null { | 64 | ): LinkedThreadPullRequestStatus | null { |
| 62 | const queried = useEnvironmentQuery( | 65 | const queried = useEnvironmentQuery( |
| 63 | environmentId === null || linkedPullRequest == null | 66 | !enabled || environmentId === null || linkedPullRequest == null |
| 64 | ? null | 67 | ? null |
| 65 | : linkedPullRequestDetailAtom({ | 68 | : linkedPullRequestDetailAtom({ |
| 66 | environmentId, | 69 | environmentId, |
| @@ -353,7 +356,7 @@ export function nextThreadChangeRequestSnapshot(input: { · 286 unmodified lines | |||
| 353 | * checkouts only, a cached merged/closed PR for the thread. Local thread | 356 | * checkouts only, a cached merged/closed PR for the thread. Local thread |
| 354 | * metadata follows the shared checkout, so the cached branch intentionally | 357 | * metadata follows the shared checkout, so the cached branch intentionally |
| 355 | * survives that metadata changing to the newly checked-out branch. Open PRs | 358 | * survives that metadata changing to the newly checked-out branch. Open PRs |
| 356 | * are never retained — their state can still change. | 359 | * are retained only while live status is absent and the branch still matches. |
| 357 | */ | 360 | */ |
| 358 | export function resolveDisplayedThreadPr(input: { | 361 | export function resolveDisplayedThreadPr(input: { |
| 359 | threadBranch: string | null; | 362 | threadBranch: string | null; |
| @@ -388,6 +391,15 @@ export function resolveDisplayedThreadPr(input: { · 28 unmodified lines | |||
| 388 | return gitStatus.pr; | 391 | return gitStatus.pr; |
| 389 | } | 392 | } |
| 390 | 393 | ||
| 394 | if ( | ||
| 395 | gitStatus === null && | ||
| 396 | threadBranch !== null && | ||
| 397 | snapshot?.branch === threadBranch && | ||
| 398 | snapshot.linkedPullRequest === undefined | ||
| 399 | ) { | ||
| 400 | return snapshot.pr; | ||
| 401 | } | ||
| 402 | |||
| 391 | if ( | 403 | if ( |
| 392 | threadBranch !== null && | 404 | threadBranch !== null && |
| 393 | retainTerminalOnBranchMismatch && | 405 | retainTerminalOnBranchMismatch && |
| @@ -434,6 +446,15 @@ export function resolveDisplayedThreadPrProvider(input: { · 40 unmodified lines | |||
| 434 | return gitStatus.sourceControlProvider; | 446 | return gitStatus.sourceControlProvider; |
| 435 | } | 447 | } |
| 436 | 448 | ||
| 449 | if ( | ||
| 450 | gitStatus === null && | ||
| 451 | threadBranch !== null && | ||
| 452 | snapshot?.branch === threadBranch && | ||
| 453 | snapshot.linkedPullRequest === undefined | ||
| 454 | ) { | ||
| 455 | return snapshot.sourceControlProvider; | ||
| 456 | } | ||
| 457 | |||
| 437 | if ( | 458 | if ( |
| 438 | threadBranch !== null && | 459 | threadBranch !== null && |
| 439 | retainTerminalOnBranchMismatch && | 460 | retainTerminalOnBranchMismatch && |
| @@ -550,7 +571,19 @@ export function ThreadStatusLabel({ · 110 unmodified lines | |||
| 550 | * like the command palette. Shows the change request state icon (if present) and the | 571 | * like the command palette. Shows the change request state icon (if present) and the |
| 551 | * thread status dot, matching the sidebar's leading indicators. | 572 | * thread status dot, matching the sidebar's leading indicators. |
| 552 | */ | 573 | */ |
| 553 | export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) { | 574 | export function ThreadRowLeadingStatus({ |
| 575 | thread, | ||
| 576 | snapshot, | ||
| 577 | }: { | ||
| 578 | thread: SidebarThreadSummary; | ||
| 579 | snapshot?: ThreadChangeRequestSnapshot | undefined; | ||
| 580 | }) { | ||
| 581 | const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(false); | ||
| 582 | // Observe the containing title even when this thread has no badge yet. | ||
| 583 | const statusRef = useCallback( | ||
| 584 | (node: HTMLSpanElement | null) => rowRef(node?.parentElement ?? null), | ||
| 585 | [rowRef], | ||
| 586 | ); | ||
| 554 | const threadRef = scopeThreadRef(thread.environmentId, thread.id); | 587 | const threadRef = scopeThreadRef(thread.environmentId, thread.id); |
| 555 | const localLastVisitedAt = useUiStateStore( | 588 | const localLastVisitedAt = useUiStateStore( |
| 556 | (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], | 589 | (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], |
| @@ -567,9 +600,11 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar · 10 unmodified lines | |||
| 567 | const linkedPullRequest = useLinkedThreadPullRequest( | 600 | const linkedPullRequest = useLinkedThreadPullRequest( |
| 568 | thread.environmentId, | 601 | thread.environmentId, |
| 569 | thread.linkedPullRequest, | 602 | thread.linkedPullRequest, |
| 603 | leaseLiveStatus, | ||
| 570 | ); | 604 | ); |
| 571 | const gitStatus = useEnvironmentQuery( | 605 | const gitStatus = useEnvironmentQuery( |
| 572 | thread.linkedPullRequest == null && | 606 | leaseLiveStatus && |
| 607 | thread.linkedPullRequest == null && | ||
| 573 | (thread.branch != null || thread.worktreePath !== null) && | 608 | (thread.branch != null || thread.worktreePath !== null) && |
| 574 | gitCwd !== null | 609 | gitCwd !== null |
| 575 | ? vcsEnvironment.status({ | 610 | ? vcsEnvironment.status({ |
| @@ -578,14 +613,20 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar · 2 unmodified lines | |||
| 578 | }) | 613 | }) |
| 579 | : null, | 614 | : null, |
| 580 | ); | 615 | ); |
| 581 | const pr = | 616 | const visibleGitStatus = useRetainedValue( |
| 582 | thread.linkedPullRequest == null | 617 | JSON.stringify([thread.environmentId, gitCwd]), |
| 583 | ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data }) | 618 | gitStatus.data, |
| 584 | : (linkedPullRequest?.pr ?? null); | ||
| 585 | const prStatus = prStatusIndicator( | ||
| 586 | pr, | ||
| 587 | linkedPullRequest?.sourceControlProvider ?? gitStatus.data?.sourceControlProvider, | ||
| 588 | ); | 619 | ); |
| 620 | const displayedPrInput = { | ||
| 621 | threadBranch: thread.branch, | ||
| 622 | gitStatus: visibleGitStatus, | ||
| 623 | snapshot, | ||
| 624 | retainTerminalOnBranchMismatch: thread.worktreePath === null, | ||
| 625 | linkedPullRequest: thread.linkedPullRequest, | ||
| 626 | linkedPullRequestStatus: linkedPullRequest, | ||
| 627 | }; | ||
| 628 | const pr = resolveDisplayedThreadPr(displayedPrInput); | ||
| 629 | const prStatus = prStatusIndicator(pr, resolveDisplayedThreadPrProvider(displayedPrInput)); | ||
| 589 | const threadStatus = resolveThreadStatusPill({ | 630 | const threadStatus = resolveThreadStatusPill({ |
| 590 | thread: { | 631 | thread: { |
| 591 | ...thread, | 632 | ...thread, |
| @@ -593,12 +634,13 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar · 1 unmodified line | |||
| 593 | }, | 634 | }, |
| 594 | }); | 635 | }); |
| 595 | 636 | ||
| 596 | if (!prStatus && !threadStatus) { | ||
| 597 | return null; | ||
| 598 | } | ||
| 599 | |||
| 600 | return ( | 637 | return ( |
| 601 | <span className="inline-flex shrink-0 items-center gap-1.5"> | 638 | <span |
| 639 | ref={statusRef} | ||
| 640 | className={ | ||
| 641 | prStatus || threadStatus ? "inline-flex shrink-0 items-center gap-1.5" : "contents" | ||
| 642 | } | ||
| 643 | > | ||
| 602 | {prStatus && pr ? ( | 644 | {prStatus && pr ? ( |
| 603 | <Tooltip> | 645 | <Tooltip> |
| 604 | <TooltipTrigger | 646 | <TooltipTrigger |
| @@ -2,7 +2,11 @@ import { · 1 unmodified line | |||
| 2 | BearerConnectionTarget, | 2 | BearerConnectionTarget, |
| 3 | PrimaryConnectionTarget, | 3 | PrimaryConnectionTarget, |
| 4 | } from "@t3tools/client-runtime/connection"; | 4 | } from "@t3tools/client-runtime/connection"; |
| 5 | import { EnvironmentId, PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; | 5 | import { |
| 6 | type DesktopEnvironmentBootstrap, | ||
| 7 | EnvironmentId, | ||
| 8 | PRIMARY_LOCAL_ENVIRONMENT_ID, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 6 | import { describe, expect, it } from "vite-plus/test"; | 10 | import { describe, expect, it } from "vite-plus/test"; |
| 7 | 11 | ||
| 8 | import { | 12 | import { |
| @@ -38,6 +42,63 @@ describe("desktop local connection identity", () => { · 29 unmodified lines | |||
| 38 | }); | 42 | }); |
| 39 | 43 | ||
| 40 | describe("desktop local topology reads", () => { | 44 | describe("desktop local topology reads", () => { |
| 45 | it("reuses snapshots when polling returns fresh objects for the same topology", () => { | ||
| 46 | const secondary: DesktopEnvironmentBootstrap = { | ||
| 47 | id: "wsl:default", | ||
| 48 | label: "WSL", | ||
| 49 | runningDistro: "Ubuntu", | ||
| 50 | httpBaseUrl: "http://127.0.0.1:4000", | ||
| 51 | wsBaseUrl: "ws://127.0.0.1:4000", | ||
| 52 | bootstrapToken: "bootstrap-1", | ||
| 53 | }; | ||
| 54 | let entries = [secondary]; | ||
| 55 | const reader = createDesktopSecondaryBootstrapsReader(() => ({ | ||
| 56 | getLocalEnvironmentBootstraps: () => entries.map((entry) => ({ ...entry })), | ||
| 57 | })); | ||
| 58 | |||
| 59 | const connected = reader.readSnapshot(); | ||
| 60 | expect(reader.readSnapshot()).toBe(connected); | ||
| 61 | expect(reader.readResult()).toEqual({ _tag: "Success", bootstraps: connected }); | ||
| 62 | expect(reader.readSnapshot()).toBe(connected); | ||
| 63 | |||
| 64 | entries = []; | ||
| 65 | const empty = reader.readSnapshot(); | ||
| 66 | expect(empty).toEqual([]); | ||
| 67 | expect(reader.readSnapshot()).toBe(empty); | ||
| 68 | }); | ||
| 69 | |||
| 70 | it.each<Partial<DesktopEnvironmentBootstrap>>([ | ||
| 71 | { id: "wsl:Debian" }, | ||
| 72 | { label: "Renamed backend" }, | ||
| 73 | { runningDistro: "Debian" }, | ||
| 74 | { httpBaseUrl: "http://127.0.0.1:4001" }, | ||
| 75 | { wsBaseUrl: "ws://127.0.0.1:4001" }, | ||
| 76 | { bootstrapToken: "bootstrap-2" }, | ||
| 77 | ])("publishes bootstrap changes: %j", (change) => { | ||
| 78 | let secondary: DesktopEnvironmentBootstrap = { | ||
| 79 | id: "wsl:default", | ||
| 80 | label: "WSL", | ||
| 81 | runningDistro: "Ubuntu", | ||
| 82 | httpBaseUrl: "http://127.0.0.1:4000", | ||
| 83 | wsBaseUrl: "ws://127.0.0.1:4000", | ||
| 84 | bootstrapToken: "bootstrap-1", | ||
| 85 | }; | ||
| 86 | const reader = createDesktopSecondaryBootstrapsReader(() => ({ | ||
| 87 | getLocalEnvironmentBootstraps: () => [{ ...secondary }], | ||
| 88 | })); | ||
| 89 | const before = reader.readSnapshot(); | ||
| 90 | secondary = { ...secondary, ...change }; | ||
| 91 | const after = reader.readSnapshot(); | ||
| 92 | expect(after).not.toBe(before); | ||
| 93 | expect(after).toEqual([secondary]); | ||
| 94 | expect(reader.readSnapshot()).toBe(after); | ||
| 95 | }); | ||
| 96 | |||
| 97 | it("keeps the empty snapshot stable without a desktop bridge", () => { | ||
| 98 | const reader = createDesktopSecondaryBootstrapsReader(() => undefined); | ||
| 99 | expect(reader.readSnapshot()).toBe(reader.readSnapshot()); | ||
| 100 | }); | ||
| 101 | |||
| 41 | it("distinguishes a successful empty topology from a read failure", () => { | 102 | it("distinguishes a successful empty topology from a read failure", () => { |
| 42 | let readBootstraps = () => []; | 103 | let readBootstraps = () => []; |
| 43 | const reader = createDesktopSecondaryBootstrapsReader(() => ({ | 104 | const reader = createDesktopSecondaryBootstrapsReader(() => ({ |
| @@ -67,13 +67,30 @@ export function createDesktopSecondaryBootstrapsReader( · 66 unmodified lines | |||
| 67 | const readResult = (): DesktopSecondaryBootstrapsRead => { | 67 | const readResult = (): DesktopSecondaryBootstrapsRead => { |
| 68 | const bridge = resolveBridge(); | 68 | const bridge = resolveBridge(); |
| 69 | if (bridge === undefined) { | 69 | if (bridge === undefined) { |
| 70 | snapshot = []; | 70 | if (snapshot.length > 0) snapshot = []; |
| 71 | return { _tag: "Success", bootstraps: snapshot }; | 71 | return { _tag: "Success", bootstraps: snapshot }; |
| 72 | } | 72 | } |
| 73 | try { | 73 | try { |
| 74 | snapshot = bridge | 74 | const next = bridge |
| 75 | .getLocalEnvironmentBootstraps() | 75 | .getLocalEnvironmentBootstraps() |
| 76 | .filter((entry) => entry.id !== PRIMARY_LOCAL_ENVIRONMENT_ID); | 76 | .filter((entry) => entry.id !== PRIMARY_LOCAL_ENVIRONMENT_ID); |
| 77 | if ( | ||
| 78 | next.length !== snapshot.length || | ||
| 79 | next.some((entry, index) => { | ||
| 80 | const previous = snapshot[index]; | ||
| 81 | return ( | ||
| 82 | previous === undefined || | ||
| 83 | entry.id !== previous.id || | ||
| 84 | entry.label !== previous.label || | ||
| 85 | entry.runningDistro !== previous.runningDistro || | ||
| 86 | entry.httpBaseUrl !== previous.httpBaseUrl || | ||
| 87 | entry.wsBaseUrl !== previous.wsBaseUrl || | ||
| 88 | entry.bootstrapToken !== previous.bootstrapToken | ||
| 89 | ); | ||
| 90 | }) | ||
| 91 | ) { | ||
| 92 | snapshot = next; | ||
| 93 | } | ||
| 77 | return { _tag: "Success", bootstraps: snapshot }; | 94 | return { _tag: "Success", bootstraps: snapshot }; |
| 78 | } catch (cause) { | 95 | } catch (cause) { |
| 79 | return { _tag: "Failure", cause }; | 96 | return { _tag: "Failure", cause }; |