From 772cd48705a5b9933e02dff84380311ebc56401e Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 7 Oct 2026 22:33:28 -0700 Subject: [PATCH] test(desktop): one DOM for the whole run and no module mocks Two things made desktop tests depend on file order without --isolate. mock.module is process-wide in bun. Fakes of renderer/lib/cloud-trpc, host-service-client, @superset/workspace-client, ../env and others reached later files. Tests now use real providers with fake links, or the code takes its dependency as an optional parameter. 58 files registered happy-dom and 52 unregistered it. After the first unregister, that window's timers never fire again, and react-dom captured them at import, so React updates outside act() never rendered in later files. happy-dom is now registered once in the preload. A test also left the ja locale active, and six files called a bare mock.restore() that tore down the preload's electron mock. --- .../electron-app/factories/app/setup.test.ts | 5 +- .../routers/auth/utils/auth-functions.test.ts | 7 - .../changes/utils/merge-pull-request.test.ts | 105 ++--- .../permissions/native-permissions.test.ts | 18 +- .../routers/workspaces/utils/ai-name.test.ts | 56 +-- .../trpc/routers/workspaces/utils/ai-name.ts | 8 +- .../lib/trpc/workers/WorkerTaskRunner.test.ts | 18 +- .../src/lib/trpc/workers/WorkerTaskRunner.ts | 6 +- .../main/lib/browser/browser-manager.test.ts | 33 +- .../main/lib/host-service-coordinator.test.ts | 117 ++--- .../src/main/lib/host-service-coordinator.ts | 93 ++-- .../src/main/lib/host-service-lock.test.ts | 35 +- .../desktop/src/main/lib/host-service-lock.ts | 50 ++- .../terminal/daemon/daemon-manager.test.ts | 124 ++---- .../lib/terminal/daemon/daemon-manager.ts | 52 ++- .../src/main/lib/terminal/index.test.ts | 33 +- apps/desktop/src/main/lib/terminal/index.ts | 32 +- .../window-state/bounds-validation.test.ts | 1 - .../LanguageAwareI18nProvider.test.tsx | 258 ++++------- .../LanguageAwareI18nProvider.tsx | 6 +- .../RecordMention/RecordMentionNode.test.ts | 11 +- .../createMarkdownExtensions.test.ts | 14 +- .../mergeMarkdownEdits.roundtrip.test.ts | 14 +- .../resolveLinkClick.test.ts | 11 +- .../CompareBaseBranchPickerInline.test.tsx | 9 +- .../components/Paywall/usePaywall.test.tsx | 175 +++++--- .../renderer/components/Paywall/usePaywall.ts | 8 +- .../PluginMention/PluginMentionNode.test.ts | 11 +- .../restorePluginMentions.test.ts | 11 +- .../components/Redirect/Redirect.test.tsx | 17 +- .../serializeEditorToText.test.ts | 10 +- .../WorkspaceNameMarquee.test.tsx | 14 +- .../useGitStatus/invalidateGitQueries.test.ts | 82 ++++ .../useGitStatus/invalidateGitQueries.ts | 41 ++ .../useGitStatus/useGitStatus.test.tsx | 233 +++++----- .../host-service/useGitStatus/useGitStatus.ts | 27 +- .../confirmClosePorts.test.ts | 31 +- .../usePortKillActions/killPortTarget.test.ts | 41 +- .../usePortKillActions/killPortTarget.ts | 9 +- .../useAgentLaunchPreferences.test.ts | 9 +- .../useAgentSessionPlacement.test.tsx | 9 +- .../hooks/useIsV2CloudEnabled.test.tsx | 247 +++++------ .../src/renderer/hooks/useIsV2CloudEnabled.ts | 36 +- .../useOpenNewWorkspace.test.ts | 117 ++--- .../useOpenNewWorkspace.ts | 116 +++-- .../agent-session-orchestrator.test.ts | 15 +- .../src/renderer/lib/api-trpc-client.test.ts | 25 +- .../persistent-hash-history.test.ts | 26 +- .../bodyPointerEventsLock.test.tsx | 14 +- .../radix-layers/inputGroupPopover.test.tsx | 9 +- .../terminal-runtime-registry.test.ts | 31 +- .../terminal/terminal-ws-transport.test.ts | 35 +- .../lib/terminal/terminal-ws-transport.ts | 9 +- .../lib/tiptap/markdown-attributes.test.ts | 11 +- .../lib/v1-migration/v1-migration.test.ts | 17 +- apps/desktop/src/renderer/routes/-layout.tsx | 2 + .../useAutomationDraft.test.ts | 11 +- .../components/ScopeChip/ScopeChip.test.tsx | 22 +- .../TriggerMenuItems.test.tsx | 11 +- .../RuntimeWarnings/RuntimeWarnings.test.tsx | 11 +- .../components/TokenField/TokenField.test.tsx | 15 +- .../providers/github/github.test.tsx | 11 +- .../providers/schedule/schedule.test.tsx | 11 +- .../EmojiNameChip/EmojiNameChip.test.tsx | 11 +- .../components/providers/slack/slack.test.tsx | 11 +- ...port.test.ts => importFolderFirst.test.ts} | 111 ++--- .../useFolderFirstImport/importFolderFirst.ts | 121 ++++++ .../useFolderFirstImport.ts | 153 ++----- .../AgentSessionPicker.test.tsx | 117 +++++ .../AgentSessionPicker/AgentSessionPicker.tsx | 152 +------ .../AgentSessionSelect/AgentSessionSelect.tsx | 168 ++++++++ .../components/AgentSessionSelect/index.ts | 1 + .../components/AgentSessionPicker/index.ts | 1 + .../DestroyConfirmPane.test.tsx | 14 +- .../fixtures/checks.test.fixture.tsx | 2 - ...dSidebarWorkspaceHoverCardContent.test.tsx | 14 +- .../useProjectFilterExpanded.test.ts | 11 +- .../fixtures/navigation.ts | 2 +- .../components/PagesView/PagesView.test.tsx | 401 ------------------ .../pages/components/PagesView/PagesView.tsx | 307 ++------------ .../components/PagesList/PagesList.test.tsx | 388 +++++++++++++++++ .../components/PagesList/PagesList.tsx | 301 +++++++++++++ .../PagesView/components/PagesList/index.ts | 1 + .../AuthorFilter/AuthorFilter.test.tsx | 9 +- .../components/CommentRow/CommentRow.test.tsx | 9 +- .../useFilesTabActions.test.tsx | 55 ++- .../useDiffPaneTarget.test.ts | 11 +- .../browserRuntimeRegistry.test.ts | 43 +- .../BrowserPane/browserRuntimeRegistry.ts | 21 +- .../useComposerDraft.test.tsx | 9 +- .../PageLinkCard/PageLinkCard.test.tsx | 9 +- .../ChatPageCard/ChatPageCard.test.tsx | 9 +- .../hooks/useStableList/useStableList.test.ts | 9 +- .../useScrollAnchorKey.test.ts | 9 +- .../CommentThread/CommentThread.test.tsx | 172 ++++---- .../useDiffCodeViewItems.test.tsx | 57 ++- .../useDiffCodeViewItems.ts | 4 +- .../usePendingFilePosition.test.tsx | 9 +- .../PageWatcherMenu/PageWatcherMenu.tsx | 300 +------------ .../PageWatcherPopover.test.tsx} | 277 ++++++------ .../PageWatcherPopover/PageWatcherPopover.tsx | 324 ++++++++++++++ .../components/PageWatcherPopover/index.ts | 4 + .../PullRequestCommentCard.test.tsx | 81 ++-- .../useHostReachability.test.ts | 4 - .../v2WorkspacesFilterStore.test.ts | 12 +- .../useProjectPreselection.test.ts | 9 +- .../CollectionsProvider.tsx | 6 +- .../HostWorkspacesProvider.tsx | 5 +- .../providers/HostWorkspacesProvider/index.ts | 1 + .../LocalHostServiceProvider.tsx | 2 +- .../LocalHostServiceProvider/index.ts | 1 + .../SandboxAccessProvider.tsx | 4 +- .../providers/SandboxAccessProvider/index.ts | 1 + .../ExperimentalSettings.test.tsx | 92 ---- .../ExperimentalSettings.tsx | 215 +--------- .../ExperimentalSettingsList.test.tsx | 44 ++ .../ExperimentalSettingsList.tsx | 224 ++++++++++ .../ExperimentalSettingsList/index.ts | 1 + .../LeaderboardRank/LeaderboardRank.test.tsx | 11 +- .../V2ProjectSettings.test.tsx | 188 -------- .../V2ProjectSettings/V2ProjectSettings.tsx | 302 ++----------- .../IconUploadField/IconUploadField.test.tsx | 29 ++ .../NamingInstructionsSection.test.tsx | 29 ++ .../SparseCheckoutSection.test.tsx | 19 + .../V2ProjectSettingsBody.test.tsx | 118 ++++++ .../V2ProjectSettingsBody.tsx | 281 ++++++++++++ .../components/V2ProjectSettingsBody/index.ts | 1 + .../useFileContent/useFileContent.test.ts | 164 ++++--- .../hooks/useTerminalLifecycle.attach.test.ts | 11 +- .../useGitChangesStatus.test.ts | 212 +++++---- .../useGitChangesStatus.ts | 46 +- .../theme/utils/terminal-theme-type.test.ts | 19 +- apps/desktop/src/xterm-ime-patch.test.ts | 11 - .../src/xterm-render-debouncer-patch.test.ts | 10 +- apps/desktop/test-setup.ts | 116 ++--- 135 files changed, 4221 insertions(+), 4321 deletions(-) create mode 100644 apps/desktop/src/renderer/hooks/host-service/useGitStatus/invalidateGitQueries.test.ts create mode 100644 apps/desktop/src/renderer/hooks/host-service/useGitStatus/invalidateGitQueries.ts rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/{useFolderFirstImport.test.ts => importFolderFirst.test.ts} (52%) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/importFolderFirst.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AgentSessionPicker/AgentSessionPicker.test.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AgentSessionPicker/components/AgentSessionSelect/AgentSessionSelect.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AgentSessionPicker/components/AgentSessionSelect/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/components/PagesList/PagesList.test.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/components/PagesList/PagesList.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/components/PagesList/index.ts rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneHeaderExtras/components/PageWatcherMenu/{PageWatcherMenu.test.tsx => components/PageWatcherPopover/PageWatcherPopover.test.tsx} (75%) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneHeaderExtras/components/PageWatcherMenu/components/PageWatcherPopover/PageWatcherPopover.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneHeaderExtras/components/PageWatcherMenu/components/PageWatcherPopover/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/ExperimentalSettings.test.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/components/ExperimentalSettingsList/ExperimentalSettingsList.test.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/components/ExperimentalSettingsList/ExperimentalSettingsList.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/components/ExperimentalSettingsList/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.test.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/IconUploadField/IconUploadField.test.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/NamingInstructionsSection/NamingInstructionsSection.test.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SparseCheckoutSection/SparseCheckoutSection.test.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ProjectSettingsBody/V2ProjectSettingsBody.test.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ProjectSettingsBody/V2ProjectSettingsBody.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ProjectSettingsBody/index.ts diff --git a/apps/desktop/src/lib/electron-app/factories/app/setup.test.ts b/apps/desktop/src/lib/electron-app/factories/app/setup.test.ts index 76ce6ad0399..c78f75b8aed 100644 --- a/apps/desktop/src/lib/electron-app/factories/app/setup.test.ts +++ b/apps/desktop/src/lib/electron-app/factories/app/setup.test.ts @@ -1,6 +1,5 @@ -// Reads setup.ts as source rather than importing it: importing links its named -// electron imports against whichever `mock.module("electron")` an earlier test -// file installed, and Bun cannot add an export the linked mock lacks. +// Reads setup.ts as source: importing it applies Chromium switches through +// app.commandLine, which the electron fake in test-setup.ts does not have. import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; diff --git a/apps/desktop/src/lib/trpc/routers/auth/utils/auth-functions.test.ts b/apps/desktop/src/lib/trpc/routers/auth/utils/auth-functions.test.ts index 9c30c2c75df..9d23938e728 100644 --- a/apps/desktop/src/lib/trpc/routers/auth/utils/auth-functions.test.ts +++ b/apps/desktop/src/lib/trpc/routers/auth/utils/auth-functions.test.ts @@ -18,13 +18,6 @@ const testSupersetHomeDir = fs.mkdtempSync( process.env.SUPERSET_HOME_DIR = testSupersetHomeDir; const tokenFile = path.join(testSupersetHomeDir, "auth-token.enc"); -// Keep this unit test independent from suite-global host-info mocks. The -// persistence behavior under test only needs a reversible storage boundary. -mock.module("./crypto-storage", () => ({ - encrypt: (plaintext: string) => Buffer.from(plaintext), - decrypt: (data: Buffer) => data.toString("utf8"), -})); - const { authEvents, clearToken, diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/merge-pull-request.test.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/merge-pull-request.test.ts index 68c4506db62..bb02fe674e0 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/utils/merge-pull-request.test.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/utils/merge-pull-request.test.ts @@ -70,6 +70,7 @@ const openPullRequest = { checks: [], }; let mergePullRequest: typeof import("./merge-pull-request").mergePullRequest; +let spies: { mockRestore: () => void }[] = []; describe("mergePullRequest", () => { beforeAll(async () => { @@ -80,61 +81,65 @@ describe("mergePullRequest", () => { const gitUtilsModule = await import("../git-utils"); const worktreeStatusCachesModule = await import("./worktree-status-caches"); - spyOn(gitModule, "getCurrentBranch").mockImplementation((( - ...args: Parameters - ) => getCurrentBranchMock(...args)) as typeof gitModule.getCurrentBranch); - spyOn(gitModule, "isUnbornHeadError").mockImplementation( - ((error: unknown) => - error instanceof Error && - error.message.includes( - "ambiguous argument 'HEAD'", - )) as typeof gitModule.isUnbornHeadError, - ); - spyOn(gitClientModule, "execGitWithShellPath").mockImplementation((( - ...args: Parameters - ) => - execGitWithShellPathMock( - ...args, - )) as typeof gitClientModule.execGitWithShellPath); - spyOn(githubModule, "getPRForBranch").mockImplementation((( - ...args: Parameters - ) => getPRForBranchMock(...args)) as typeof githubModule.getPRForBranch); - spyOn(githubModule, "getPullRequestRepoArgs").mockImplementation((( - ...args: Parameters - ) => - getPullRequestRepoArgsMock( - ...args, - )) as typeof githubModule.getPullRequestRepoArgs); - spyOn(githubModule, "getRepoContext").mockImplementation((( - ...args: Parameters - ) => getRepoContextMock(...args)) as typeof githubModule.getRepoContext); - spyOn(shellEnvModule, "execWithShellEnv").mockImplementation((( - ...args: Parameters - ) => - execWithShellEnvMock(...args)) as typeof shellEnvModule.execWithShellEnv); - spyOn(gitUtilsModule, "isNoPullRequestFoundMessage").mockImplementation((( - ...args: Parameters - ) => - isNoPullRequestFoundMessageMock( - ...args, - )) as typeof gitUtilsModule.isNoPullRequestFoundMessage); - spyOn( - worktreeStatusCachesModule, - "clearWorktreeStatusCaches", - ).mockImplementation((( - ...args: Parameters< - typeof worktreeStatusCachesModule.clearWorktreeStatusCaches - > - ) => - clearWorktreeStatusCachesMock( - ...args, - )) as typeof worktreeStatusCachesModule.clearWorktreeStatusCaches); + spies = [ + spyOn(gitModule, "getCurrentBranch").mockImplementation((( + ...args: Parameters + ) => getCurrentBranchMock(...args)) as typeof gitModule.getCurrentBranch), + spyOn(gitModule, "isUnbornHeadError").mockImplementation( + ((error: unknown) => + error instanceof Error && + error.message.includes( + "ambiguous argument 'HEAD'", + )) as typeof gitModule.isUnbornHeadError, + ), + spyOn(gitClientModule, "execGitWithShellPath").mockImplementation((( + ...args: Parameters + ) => + execGitWithShellPathMock( + ...args, + )) as typeof gitClientModule.execGitWithShellPath), + spyOn(githubModule, "getPRForBranch").mockImplementation((( + ...args: Parameters + ) => getPRForBranchMock(...args)) as typeof githubModule.getPRForBranch), + spyOn(githubModule, "getPullRequestRepoArgs").mockImplementation((( + ...args: Parameters + ) => + getPullRequestRepoArgsMock( + ...args, + )) as typeof githubModule.getPullRequestRepoArgs), + spyOn(githubModule, "getRepoContext").mockImplementation((( + ...args: Parameters + ) => getRepoContextMock(...args)) as typeof githubModule.getRepoContext), + spyOn(shellEnvModule, "execWithShellEnv").mockImplementation((( + ...args: Parameters + ) => + execWithShellEnvMock( + ...args, + )) as typeof shellEnvModule.execWithShellEnv), + spyOn(gitUtilsModule, "isNoPullRequestFoundMessage").mockImplementation((( + ...args: Parameters + ) => + isNoPullRequestFoundMessageMock( + ...args, + )) as typeof gitUtilsModule.isNoPullRequestFoundMessage), + spyOn( + worktreeStatusCachesModule, + "clearWorktreeStatusCaches", + ).mockImplementation((( + ...args: Parameters< + typeof worktreeStatusCachesModule.clearWorktreeStatusCaches + > + ) => + clearWorktreeStatusCachesMock( + ...args, + )) as typeof worktreeStatusCachesModule.clearWorktreeStatusCaches), + ]; ({ mergePullRequest } = await import("./merge-pull-request")); }); afterAll(() => { - mock.restore(); + for (const spy of spies) spy.mockRestore(); }); beforeEach(() => { diff --git a/apps/desktop/src/lib/trpc/routers/permissions/native-permissions.test.ts b/apps/desktop/src/lib/trpc/routers/permissions/native-permissions.test.ts index ece3a05482a..e914abff612 100644 --- a/apps/desktop/src/lib/trpc/routers/permissions/native-permissions.test.ts +++ b/apps/desktop/src/lib/trpc/routers/permissions/native-permissions.test.ts @@ -1,17 +1,5 @@ -import { describe, expect, it, mock } from "bun:test"; - -mock.module("electron", () => ({ - shell: { - openExternal: mock(async () => {}), - }, - systemPreferences: { - askForMediaAccess: mock(async () => false), - getMediaAccessStatus: mock(() => "not-determined"), - isTrustedAccessibilityClient: mock(() => false), - }, -})); - -const { +import { describe, expect, it } from "bun:test"; +import { checkAccessibility, checkMicrophone, PERMISSION_SETTINGS_URLS, @@ -20,7 +8,7 @@ const { requestFullDiskAccess, requestLocalNetwork, requestMicrophone, -} = await import("./native-permissions"); +} from "./native-permissions"; function createShellRecorder() { const openedUrls: string[] = []; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-name.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-name.test.ts index 287b7b45c81..8da417a30fa 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-name.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-name.test.ts @@ -1,4 +1,8 @@ -import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { + attemptWorkspaceAutoRenameFromPrompt, + generateWorkspaceNameFromPrompt, +} from "./ai-name"; type SelectedWorkspace = | { @@ -16,15 +20,9 @@ type SelectedWorkspace = } | null; -mock.module("drizzle-orm", () => ({ - and: mock(() => null), - eq: mock(() => null), - isNull: mock(() => null), -})); - const selectGetMock = mock((): SelectedWorkspace => null); const updateRunMock = mock(() => ({ changes: 1 })); -const localDbMock = { +const fakeDb = { select: mock(() => ({ from: () => ({ where: () => ({ @@ -41,25 +39,9 @@ const localDbMock = { })), }; -mock.module("main/lib/local-db", () => ({ - localDb: localDbMock, -})); - -mock.module("@superset/local-db", () => ({ - workspaces: { - id: "id", - branch: "branch", - name: "name", - isUnnamed: "isUnnamed", - deletingAt: "deletingAt", - updatedAt: "updatedAt", - }, -})); - -const { - attemptWorkspaceAutoRenameFromPrompt, - generateWorkspaceNameFromPrompt, -} = await import("./ai-name"); +const db = fakeDb as unknown as Parameters< + typeof attemptWorkspaceAutoRenameFromPrompt +>[0]["db"]; describe("generateWorkspaceNameFromPrompt", () => { it("derives a title from the prompt text", () => { @@ -79,8 +61,8 @@ describe("attemptWorkspaceAutoRenameFromPrompt", () => { selectGetMock.mockReturnValue(null); updateRunMock.mockReset(); updateRunMock.mockReturnValue({ changes: 1 }); - localDbMock.select.mockClear(); - localDbMock.update.mockClear(); + fakeDb.select.mockClear(); + fakeDb.update.mockClear(); }); it("renames an unnamed workspace to the derived title", async () => { @@ -96,12 +78,13 @@ describe("attemptWorkspaceAutoRenameFromPrompt", () => { attemptWorkspaceAutoRenameFromPrompt({ workspaceId: "workspace-1", prompt: " fix the login redirect ", + db, }), ).resolves.toEqual({ status: "renamed", name: "fix the login redirect", }); - expect(localDbMock.update).toHaveBeenCalled(); + expect(fakeDb.update).toHaveBeenCalled(); }); it("skips already named workspaces before deriving anything", async () => { @@ -117,12 +100,13 @@ describe("attemptWorkspaceAutoRenameFromPrompt", () => { attemptWorkspaceAutoRenameFromPrompt({ workspaceId: "workspace-1", prompt: "rename me", + db, }), ).resolves.toEqual({ status: "skipped", reason: "workspace-named", }); - expect(localDbMock.update).not.toHaveBeenCalled(); + expect(fakeDb.update).not.toHaveBeenCalled(); }); it("skips a workspace that is being deleted", async () => { @@ -138,12 +122,13 @@ describe("attemptWorkspaceAutoRenameFromPrompt", () => { attemptWorkspaceAutoRenameFromPrompt({ workspaceId: "workspace-1", prompt: "rename me", + db, }), ).resolves.toEqual({ status: "skipped", reason: "workspace-deleting", }); - expect(localDbMock.update).not.toHaveBeenCalled(); + expect(fakeDb.update).not.toHaveBeenCalled(); }); it("skips an empty prompt without touching the database", async () => { @@ -151,15 +136,12 @@ describe("attemptWorkspaceAutoRenameFromPrompt", () => { attemptWorkspaceAutoRenameFromPrompt({ workspaceId: "workspace-1", prompt: " ", + db, }), ).resolves.toEqual({ status: "skipped", reason: "empty-prompt", }); - expect(localDbMock.select).not.toHaveBeenCalled(); + expect(fakeDb.select).not.toHaveBeenCalled(); }); }); - -afterAll(() => { - mock.restore(); -}); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-name.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-name.ts index 51c9ee26205..7dc2e80164f 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-name.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-name.ts @@ -33,16 +33,18 @@ export function generateWorkspaceNameFromPrompt(prompt: string): string | null { export async function attemptWorkspaceAutoRenameFromPrompt({ workspaceId, prompt, + db = localDb, }: { workspaceId: string; prompt?: string | null; + db?: Pick; }): Promise { const cleanedPrompt = prompt?.trim(); if (!cleanedPrompt) { return { status: "skipped", reason: "empty-prompt" }; } - const workspace = localDb + const workspace = db .select({ id: workspaces.id, branch: workspaces.branch, @@ -80,7 +82,7 @@ export async function attemptWorkspaceAutoRenameFromPrompt({ return { status: "skipped", reason: decision.reason }; } - const renameResult = localDb + const renameResult = db .update(workspaces) .set({ name: decision.name, @@ -100,7 +102,7 @@ export async function attemptWorkspaceAutoRenameFromPrompt({ return { status: "renamed", name: decision.name }; } - const latestWorkspace = localDb + const latestWorkspace = db .select({ branch: workspaces.branch, name: workspaces.name, diff --git a/apps/desktop/src/lib/trpc/workers/WorkerTaskRunner.test.ts b/apps/desktop/src/lib/trpc/workers/WorkerTaskRunner.test.ts index 2818eeb104c..c458f8bcfbb 100644 --- a/apps/desktop/src/lib/trpc/workers/WorkerTaskRunner.test.ts +++ b/apps/desktop/src/lib/trpc/workers/WorkerTaskRunner.test.ts @@ -1,5 +1,7 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { EventEmitter } from "node:events"; +import type { Worker } from "node:worker_threads"; +import { WorkerTaskError, WorkerTaskRunner } from "./WorkerTaskRunner"; type WorkerBehavior = | "boot-fail" @@ -117,13 +119,8 @@ class MockWorker extends EventEmitter { } } -mock.module("node:worker_threads", () => ({ - Worker: MockWorker, -})); - -const { WorkerTaskError, WorkerTaskRunner } = await import( - "./WorkerTaskRunner" -); +const createWorker = (scriptPath: string) => + new MockWorker(scriptPath) as unknown as Worker; const flushMicrotasks = async (times = 5): Promise => { for (let i = 0; i < times; i++) { @@ -149,6 +146,7 @@ describe("WorkerTaskRunner failure handling", () => { const runner = new WorkerTaskRunner({ workerScriptPath: "/missing/worker-script.js", concurrency: 1, + createWorker, }); const taskPromise = runner.runTask("status", {}); @@ -164,6 +162,7 @@ describe("WorkerTaskRunner failure handling", () => { const runner = new WorkerTaskRunner({ workerScriptPath: "/mock/worker.js", concurrency: 1, + createWorker, }); const first = runner.runTask("status", { id: "first" }); @@ -182,6 +181,7 @@ describe("WorkerTaskRunner failure handling", () => { const runner = new WorkerTaskRunner({ workerScriptPath: "/mock/worker.js", concurrency: 1, + createWorker, }); const first = runner.runTask( @@ -222,6 +222,7 @@ describe("WorkerTaskRunner failure handling", () => { const runner = new WorkerTaskRunner({ workerScriptPath: "/mock/worker.js", concurrency: 1, + createWorker, cancelGraceMs: 1_000, }); @@ -245,6 +246,7 @@ describe("WorkerTaskRunner failure handling", () => { const runner = new WorkerTaskRunner({ workerScriptPath: "/mock/worker.js", concurrency: 1, + createWorker, cancelGraceMs: 20, }); diff --git a/apps/desktop/src/lib/trpc/workers/WorkerTaskRunner.ts b/apps/desktop/src/lib/trpc/workers/WorkerTaskRunner.ts index f94161415fb..a8b018a45f6 100644 --- a/apps/desktop/src/lib/trpc/workers/WorkerTaskRunner.ts +++ b/apps/desktop/src/lib/trpc/workers/WorkerTaskRunner.ts @@ -47,6 +47,7 @@ interface WorkerTaskRunnerOptions { debug?: boolean; /** How long a worker gets to report a cancelled task before it is terminated. */ cancelGraceMs?: number; + createWorker?: (workerScriptPath: string) => Worker; } interface WorkerSlot { @@ -84,6 +85,7 @@ export class WorkerTaskRunner { private readonly name: string; private readonly debug: boolean; private readonly cancelGraceMs: number; + private readonly createWorker: (workerScriptPath: string) => Worker; private readonly workerSlots = new Map(); private readonly queue: string[] = []; private readonly tasks = new Map(); @@ -98,6 +100,8 @@ export class WorkerTaskRunner { this.name = options.name ?? "worker-runner"; this.debug = options.debug ?? false; this.cancelGraceMs = options.cancelGraceMs ?? DEFAULT_CANCEL_GRACE_MS; + this.createWorker = + options.createWorker ?? ((scriptPath) => new Worker(scriptPath)); } runTask( @@ -209,7 +213,7 @@ export class WorkerTaskRunner { private spawnWorker(): void { const slotId = ++this.workerCounter; - const worker = new Worker(this.workerScriptPath); + const worker = this.createWorker(this.workerScriptPath); const slot: WorkerSlot = { id: slotId, worker, diff --git a/apps/desktop/src/main/lib/browser/browser-manager.test.ts b/apps/desktop/src/main/lib/browser/browser-manager.test.ts index 8ae043edb68..fa23ec45c8f 100644 --- a/apps/desktop/src/main/lib/browser/browser-manager.test.ts +++ b/apps/desktop/src/main/lib/browser/browser-manager.test.ts @@ -1,21 +1,28 @@ -import { afterEach, describe, expect, mock, test } from "bun:test"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + mock, + test, +} from "bun:test"; +import { webContents } from "electron"; +import { PROTOCOL_SCHEME } from "shared/constants"; +import { browserManager } from "./browser-manager"; const wcById = new Map(); -mock.module("electron", () => ({ - clipboard: { writeImage: mock(() => {}), writeText: mock(() => {}) }, - Menu: { buildFromTemplate: mock(() => ({ popup: mock(() => {}) })) }, - webContents: { - fromId: (id: number) => wcById.get(id) ?? null, - }, -})); +const fromIdMock = webContents.fromId as unknown as ReturnType; +const preloadFromId = fromIdMock.getMockImplementation(); -mock.module("main/lib/safe-url", () => ({ - safeOpenExternal: mock(async () => {}), -})); +beforeAll(() => { + fromIdMock.mockImplementation((id: number) => wcById.get(id) ?? null); +}); -const { browserManager } = await import("./browser-manager"); -const { PROTOCOL_SCHEME } = await import("shared/constants"); +afterAll(() => { + fromIdMock.mockImplementation(preloadFromId ?? (() => null)); +}); interface FakeImage { isEmpty: () => boolean; diff --git a/apps/desktop/src/main/lib/host-service-coordinator.test.ts b/apps/desktop/src/main/lib/host-service-coordinator.test.ts index a737a753d6c..004fd70f493 100644 --- a/apps/desktop/src/main/lib/host-service-coordinator.test.ts +++ b/apps/desktop/src/main/lib/host-service-coordinator.test.ts @@ -1,6 +1,7 @@ import { afterAll, afterEach, + beforeAll, beforeEach, describe, expect, @@ -13,8 +14,10 @@ import { EventEmitter } from "node:events"; import * as fs from "node:fs"; import * as os from "node:os"; import path from "node:path"; +import log from "electron-log/main"; +import { HostServiceCoordinator } from "./host-service-coordinator"; +import { HOST_SERVICE_RESPAWN_MAX_ATTEMPTS } from "./host-service-respawn"; -const APP_VERSION = "1.2.3"; let killedPids: Array<{ pid: number; signal: NodeJS.Signals | number }> = []; let killProcessError: NodeJS.ErrnoException | null = null; @@ -44,84 +47,40 @@ const killProcessMock = mock((pid: number, signal: NodeJS.Signals | number) => { killedPids.push({ pid, signal }); }); -const realHostServiceManifest = await import("./host-service-manifest"); -mock.module("./host-service-manifest", () => ({ - ...realHostServiceManifest, - readManifest: readManifestMock, - removeManifest: removeManifestMock, - isProcessAlive: isProcessAliveMock, - killProcess: killProcessMock, - manifestDir: (orgId: string) => path.join(testManifestRoot, orgId), -})); - const pollHealthCheckMock = mock(() => Promise.resolve(true)); -const realHostServiceUtils = await import("./host-service-utils"); -mock.module("./host-service-utils", () => ({ - ...realHostServiceUtils, - HEALTH_POLL_TIMEOUT_MS: 10_000, - MAX_HOST_LOG_BYTES: 1024, - findFreePort: mock(() => Promise.resolve(40000)), - openRotatingLogFd: mock(() => -1), - pollHealthCheck: pollHealthCheckMock, -})); - const showAlertMock = mock(async () => ({ response: 0, checkboxChecked: false, })); -// Keep every electron export name other suite files link against (e.g. -// browser-manager's webContents/clipboard/Menu): bun's mock.module can swap -// export values but cannot add names to an already-instantiated module -// record, so a narrower shape here breaks later files' imports. -mock.module("electron", () => ({ - app: { - getVersion: () => APP_VERSION, - isPackaged: false, - getAppPath: () => "/tmp/app", - }, - dialog: { +const createCoordinator = () => + new HostServiceCoordinator({ + readManifest: readManifestMock, + removeManifest: removeManifestMock, + isProcessAlive: isProcessAliveMock, + killProcess: killProcessMock, + manifestDir: (orgId) => path.join(testManifestRoot, orgId), + findFreePort: async () => 40000, + openRotatingLogFd: () => -1, + pollHealthCheck: pollHealthCheckMock, + getHostId: () => "host-1", showMessageBox: showAlertMock, - }, - webContents: { - fromId: mock(() => null), - }, - clipboard: { - writeText: mock(() => {}), - writeImage: mock(() => {}), - }, - Menu: { - buildFromTemplate: mock(() => ({ popup: mock(() => {}) })), - }, -})); + }); -mock.module("electron-log/main", () => ({ - default: { - info: () => {}, - warn: () => {}, - error: () => {}, - }, -})); +let logSpies: { mockRestore: () => void }[] = []; -const realHostInfo = await import("@superset/shared/host-info"); -mock.module("@superset/shared/host-info", () => ({ - ...realHostInfo, - getHostId: () => "host-1", - getHostName: () => "host", -})); -mock.module("./local-db", () => ({ - localDb: { - select: () => ({ - from: () => ({ get: () => null, where: () => ({ get: () => null }) }), - }), - }, -})); +beforeAll(() => { + logSpies = [ + spyOn(log, "info").mockImplementation(() => {}), + spyOn(log, "warn").mockImplementation(() => {}), + spyOn(log, "error").mockImplementation(() => {}), + ]; +}); -const { HOST_SERVICE_RESPAWN_MAX_ATTEMPTS } = await import( - "./host-service-respawn" -); -const { HostServiceCoordinator } = await import("./host-service-coordinator"); +afterAll(() => { + for (const spy of logSpies) spy.mockRestore(); +}); const baseManifest = (pid: number, endpoint = "http://127.0.0.1:55555") => ({ pid, @@ -139,7 +98,7 @@ test.each([ ])("a failed launcher handles its asynchronous %s after startup rejects", async (code) => { resetMocks(); testManifestRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hsc-spawn-test-")); - const coordinator = new HostServiceCoordinator(); + const coordinator = createCoordinator(); const internals = coordinator as unknown as { buildEnv: () => Promise>; spawn: (org: string, config: typeof spawnConfig) => Promise; @@ -199,7 +158,7 @@ describe("HostServiceCoordinator preferred ports", () => { beforeEach(() => { resetMocks(); testManifestRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hsc-test-")); - coordinator = new HostServiceCoordinator(); + coordinator = createCoordinator(); }); afterEach(() => { @@ -245,7 +204,7 @@ describe("HostServiceCoordinator stable secret", () => { beforeEach(() => { resetMocks(); testManifestRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hsc-test-")); - coordinator = new HostServiceCoordinator(); + coordinator = createCoordinator(); internals = coordinator as unknown as StableSecretInternals; }); @@ -297,7 +256,7 @@ describe("HostServiceCoordinator.reconcile", () => { beforeEach(() => { resetMocks(); testManifestRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hsc-test-")); - coordinator = new HostServiceCoordinator(); + coordinator = createCoordinator(); internals = coordinator as unknown as ReconcileInternals; }); @@ -534,7 +493,7 @@ describe("HostServiceCoordinator.reset", () => { resetMocks(); testManifestRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hsc-test-")); - coordinator = new HostServiceCoordinator(); + coordinator = createCoordinator(); spawnMock = mock(async () => ({ port: 60000, secret: "fresh-secret", @@ -638,7 +597,7 @@ describe("HostServiceCoordinator single-flight / adoption", () => { beforeEach(() => { resetMocks(); testManifestRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hsc-test-")); - coordinator = new HostServiceCoordinator(); + coordinator = createCoordinator(); spawnMock = mock(async () => ({ port: 60000, secret: "fresh-secret", @@ -952,7 +911,7 @@ describe("HostServiceCoordinator respawn after a crash", () => { beforeEach(() => { resetMocks(); testManifestRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hsc-test-")); - coordinator = new HostServiceCoordinator(); + coordinator = createCoordinator(); internals = coordinator as unknown as typeof internals; pendingRespawns = []; ( @@ -1223,7 +1182,7 @@ describe("HostServiceCoordinator.stop manifest ownership", () => { beforeEach(() => { resetMocks(); testManifestRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hsc-test-")); - coordinator = new HostServiceCoordinator(); + coordinator = createCoordinator(); internals = coordinator as unknown as typeof internals; coordinator.setConfigProvider(async () => spawnConfig); }); @@ -1334,7 +1293,7 @@ describe("HostServiceCoordinator.stop SIGKILL escalation", () => { beforeEach(() => { resetMocks(); testManifestRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hsc-test-")); - coordinator = new HostServiceCoordinator(); + coordinator = createCoordinator(); internals = coordinator as unknown as typeof internals; pendingTimers = []; ( @@ -1421,7 +1380,3 @@ describe("parseEtime", () => { expect(parseEtime("")).toBeNull(); }); }); - -afterAll(() => { - mock.restore(); -}); diff --git a/apps/desktop/src/main/lib/host-service-coordinator.ts b/apps/desktop/src/main/lib/host-service-coordinator.ts index 8407662084a..451fcf6047f 100644 --- a/apps/desktop/src/main/lib/host-service-coordinator.ts +++ b/apps/desktop/src/main/lib/host-service-coordinator.ts @@ -274,6 +274,34 @@ function isValidPort(port: number | null | undefined): port is number { ); } +export interface HostServiceCoordinatorDeps { + isProcessAlive: typeof isProcessAlive; + killProcess: typeof killProcess; + manifestDir: typeof manifestDir; + readManifest: typeof readManifest; + removeManifest: typeof removeManifest; + findFreePort: typeof findFreePort; + openRotatingLogFd: typeof openRotatingLogFd; + pollHealthCheck: typeof pollHealthCheck; + getHostId: typeof getHostId; + showMessageBox: ( + options: Electron.MessageBoxOptions, + ) => Promise; +} + +const defaultDeps: HostServiceCoordinatorDeps = { + isProcessAlive, + killProcess, + manifestDir, + readManifest, + removeManifest, + findFreePort, + openRotatingLogFd, + pollHealthCheck, + getHostId, + showMessageBox: (options) => dialog.showMessageBox(options), +}; + /** * Coupled to Electron: each child is spawned attached and SIGTERMed on * before-quit. PTYs survive across Electron restarts via the pty-daemon @@ -286,7 +314,7 @@ export class HostServiceCoordinator extends EventEmitter { private lastKnownPorts = new Map(); private stableSecrets = new Map(); private scriptPath = path.join(__dirname, "host-service.js"); - private machineId = getHostId(); + private machineId: string; private devReloadWatcher: fs.FSWatcher | null = null; private respawns = new Map(); private desiredOrganizationIds = new Set(); @@ -314,6 +342,13 @@ export class HostServiceCoordinator extends EventEmitter { */ private inspectProcess: (pid: number) => Promise = inspectProcessWithPs; + private readonly deps: HostServiceCoordinatorDeps; + + constructor(deps: Partial = {}) { + super(); + this.deps = { ...defaultDeps, ...deps }; + this.machineId = this.deps.getHostId(); + } /** * Supplies fresh spawn config for automatic respawns. A respawn must not @@ -344,7 +379,7 @@ export class HostServiceCoordinator extends EventEmitter { // An adopted entry points at a foreign instance's child we don't // supervise (no exit handler). Re-validate it's still alive before // handing it back; if the owner died, drop it and start fresh. - if (existing.owned || isProcessAlive(existing.pid)) { + if (existing.owned || this.deps.isProcessAlive(existing.pid)) { return { port: existing.port, secret: existing.secret, @@ -433,7 +468,7 @@ export class HostServiceCoordinator extends EventEmitter { private getOrCreateSecret(organizationId: string): string { const existing = this.stableSecrets.get(organizationId) ?? - readManifest(organizationId)?.authToken; + this.deps.readManifest(organizationId)?.authToken; const secret = existing ?? randomBytes(32).toString("hex"); this.stableSecrets.set(organizationId, secret); return secret; @@ -460,7 +495,7 @@ export class HostServiceCoordinator extends EventEmitter { if (instance.owned) { try { if (instance.pid > 0) { - killProcess(instance.pid, "SIGTERM"); + this.deps.killProcess(instance.pid, "SIGTERM"); signalledPid = instance.pid; this.scheduleKillEscalation(organizationId, instance.pid); } @@ -481,8 +516,8 @@ export class HostServiceCoordinator extends EventEmitter { * writer's claim must not read as license to delete. */ private removeManifestIfHeldBy(organizationId: string, pid: number): void { - if (readManifest(organizationId)?.pid !== pid) return; - removeManifest(organizationId); + if (this.deps.readManifest(organizationId)?.pid !== pid) return; + this.deps.removeManifest(organizationId); } /** Returns the pids of the children it sent SIGTERM to. */ @@ -530,13 +565,13 @@ export class HostServiceCoordinator extends EventEmitter { // for tracked instances and only sends SIGTERM, which a wedged process // can ignore. We escalate to SIGKILL on whatever pid the manifest named. const preferredPorts = this.getPreferredPorts(organizationId); - const manifestPid = readManifest(organizationId)?.pid; + const manifestPid = this.deps.readManifest(organizationId)?.pid; this.stop(organizationId); - if (manifestPid != null && isProcessAlive(manifestPid)) { + if (manifestPid != null && this.deps.isProcessAlive(manifestPid)) { try { - killProcess(manifestPid, "SIGKILL"); + this.deps.killProcess(manifestPid, "SIGKILL"); } catch (error) { log.warn( `[host-service:${organizationId}] reset: SIGKILL of pid=${manifestPid} failed`, @@ -545,7 +580,7 @@ export class HostServiceCoordinator extends EventEmitter { } } - removeManifest(organizationId); + this.deps.removeManifest(organizationId); return this.startWithPreferredPorts(organizationId, config, preferredPorts); } @@ -765,9 +800,11 @@ export class HostServiceCoordinator extends EventEmitter { const deadline = Date.now() + START_OR_ADOPT_DEADLINE_MS; for (;;) { if (!isStartAllowed()) throw new Error("Host service start cancelled"); - const lock = acquireSpawnLock(organizationId, { - staleMs: SPAWN_LOCK_STALE_MS, - }); + const lock = acquireSpawnLock( + organizationId, + { staleMs: SPAWN_LOCK_STALE_MS }, + this.deps, + ); if (lock) { try { // A peer may have finished spawning between our first adopt @@ -810,7 +847,7 @@ export class HostServiceCoordinator extends EventEmitter { isStartAllowed: () => boolean, ): Promise { if (!isStartAllowed()) throw new Error("Host service start cancelled"); - const manifest = readManifest(organizationId); + const manifest = this.deps.readManifest(organizationId); if (!manifest) return null; let port: number; @@ -821,7 +858,7 @@ export class HostServiceCoordinator extends EventEmitter { } if (!isValidPort(port)) return null; - const healthy = await pollHealthCheck( + const healthy = await this.deps.pollHealthCheck( manifest.endpoint, manifest.authToken, ADOPT_HEALTH_TIMEOUT_MS, @@ -863,7 +900,7 @@ export class HostServiceCoordinator extends EventEmitter { isStartAllowed: () => boolean = () => true, ): Promise { if (!isStartAllowed()) throw new Error("Host service start cancelled"); - const port = await findFreePort(preferredPorts); + const port = await this.deps.findFreePort(preferredPorts); if (!isStartAllowed()) throw new Error("Host service start cancelled"); this.rememberPort(organizationId, port); const secret = this.getOrCreateSecret(organizationId); @@ -894,8 +931,8 @@ export class HostServiceCoordinator extends EventEmitter { } throw new Error("Host service start cancelled"); } - const logFd = openRotatingLogFd( - path.join(manifestDir(organizationId), "host-service.log"), + const logFd = this.deps.openRotatingLogFd( + path.join(this.deps.manifestDir(organizationId), "host-service.log"), MAX_HOST_LOG_BYTES, ); // Output is piped rather than handing the log fd straight to the child so @@ -978,7 +1015,7 @@ export class HostServiceCoordinator extends EventEmitter { child.unref(); const endpoint = `http://127.0.0.1:${port}`; - const healthy = await pollHealthCheck( + const healthy = await this.deps.pollHealthCheck( endpoint, secret, HEALTH_POLL_TIMEOUT_MS, @@ -1017,7 +1054,7 @@ export class HostServiceCoordinator extends EventEmitter { secret: string, config: SpawnConfig, ): Promise> { - const organizationDir = manifestDir(organizationId); + const organizationDir = this.deps.manifestDir(organizationId); const row = localDb.select().from(settings).get(); const exposeViaRelay = row?.exposeHostServiceViaRelay ?? false; const browserBridge = getBrowserBridgeInfo(); @@ -1029,7 +1066,7 @@ export class HostServiceCoordinator extends EventEmitter { ? "production" : (process.env.NODE_ENV ?? "development"), ORGANIZATION_ID: organizationId, - HOST_CLIENT_ID: getHostId(), + HOST_CLIENT_ID: this.deps.getHostId(), HOST_NAME: getHostName(), HOST_SERVICE_SECRET: secret, HOST_SERVICE_PORT: String(port), @@ -1350,12 +1387,12 @@ export class HostServiceCoordinator extends EventEmitter { // no longer the registered one; never signal a pid we know is gone. if (this.killEscalations.get(pid) !== timer) return; this.killEscalations.delete(pid); - if (!isProcessAlive(pid)) return; + if (!this.deps.isProcessAlive(pid)) return; log.warn( `[host-service:${organizationId}] pid=${pid} still alive ${STOP_KILL_ESCALATION_MS}ms after SIGTERM; escalating to SIGKILL`, ); try { - killProcess(pid, "SIGKILL"); + this.deps.killProcess(pid, "SIGKILL"); } catch (error) { log.warn( `[host-service:${organizationId}] SIGKILL of pid=${pid} failed`, @@ -1394,8 +1431,8 @@ export class HostServiceCoordinator extends EventEmitter { private async reapWedgedManifestHolder( organizationId: string, ): Promise { - const manifest = readManifest(organizationId); - if (!manifest || !isProcessAlive(manifest.pid)) return; + const manifest = this.deps.readManifest(organizationId); + if (!manifest || !this.deps.isProcessAlive(manifest.pid)) return; // tryAdopt() returns null without probing when the endpoint is // unparsable; only a probed miss is evidence of a wedge. let port: number | null = null; @@ -1426,7 +1463,7 @@ export class HostServiceCoordinator extends EventEmitter { `[host-service:${organizationId}] manifest pid=${manifest.pid} at ${manifest.endpoint} is alive but unhealthy; SIGKILLing it before spawning`, ); try { - killProcess(manifest.pid, "SIGKILL"); + this.deps.killProcess(manifest.pid, "SIGKILL"); } catch (error) { log.warn( `[host-service:${organizationId}] reap: SIGKILL of pid=${manifest.pid} failed`, @@ -1447,7 +1484,7 @@ export class HostServiceCoordinator extends EventEmitter { private async waitForExit(pid: number, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; - while (isProcessAlive(pid)) { + while (this.deps.isProcessAlive(pid)) { if (Date.now() >= deadline) return false; await new Promise((r) => setTimeout(r, REAP_EXIT_POLL_MS)); } @@ -1461,7 +1498,7 @@ export class HostServiceCoordinator extends EventEmitter { */ private alertChildCrashed(organizationId: string, cause: string): void { const orgName = this.getOrganizationName(organizationId); - void dialog.showMessageBox({ + void this.deps.showMessageBox({ type: "error", title: i18n._( msg({ diff --git a/apps/desktop/src/main/lib/host-service-lock.test.ts b/apps/desktop/src/main/lib/host-service-lock.test.ts index 698be3a1c4e..96c76646c31 100644 --- a/apps/desktop/src/main/lib/host-service-lock.test.ts +++ b/apps/desktop/src/main/lib/host-service-lock.test.ts @@ -1,32 +1,25 @@ -import { - afterAll, - afterEach, - beforeEach, - describe, - expect, - mock, - test, -} from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import path from "node:path"; +import { + acquireSpawnLock as acquireSpawnLockWith, + readSpawnLock as readSpawnLockWith, + type SpawnLockDeps, +} from "./host-service-lock"; let testRoot = ""; const isProcessAliveMock = mock((_pid: number) => true); -const realManifest = await import("./host-service-manifest"); -mock.module("./host-service-manifest", () => ({ - ...realManifest, +const deps: SpawnLockDeps = { + manifestDir: (orgId) => path.join(testRoot, orgId), isProcessAlive: isProcessAliveMock, - manifestDir: (orgId: string) => path.join(testRoot, orgId), -})); - -mock.module("@superset/shared/host-info", () => ({ getHostId: () => "host-1", - getHostName: () => "host", -})); +}; -const { acquireSpawnLock, readSpawnLock } = await import("./host-service-lock"); +const acquireSpawnLock = (orgId: string, options: { staleMs: number }) => + acquireSpawnLockWith(orgId, options, deps); +const readSpawnLock = (orgId: string) => readSpawnLockWith(orgId, deps); const ORG = "org-1"; const lockFile = () => path.join(testRoot, ORG, "spawn.lock"); @@ -118,7 +111,3 @@ describe("acquireSpawnLock", () => { expect(readSpawnLock(ORG)?.ownerPid).toBe(process.pid); }); }); - -afterAll(() => { - mock.restore(); -}); diff --git a/apps/desktop/src/main/lib/host-service-lock.ts b/apps/desktop/src/main/lib/host-service-lock.ts index 8638e2b845a..fc17bb95266 100644 --- a/apps/desktop/src/main/lib/host-service-lock.ts +++ b/apps/desktop/src/main/lib/host-service-lock.ts @@ -31,13 +31,24 @@ export interface SpawnLockHandle { release(): void; } -function lockPath(organizationId: string): string { - return join(manifestDir(organizationId), "spawn.lock"); +export interface SpawnLockDeps { + manifestDir: (organizationId: string) => string; + isProcessAlive: (pid: number) => boolean; + getHostId: () => string; } -export function readSpawnLock(organizationId: string): SpawnLock | null { +const defaultDeps: SpawnLockDeps = { manifestDir, isProcessAlive, getHostId }; + +function lockPath(organizationId: string, deps: SpawnLockDeps): string { + return join(deps.manifestDir(organizationId), "spawn.lock"); +} + +export function readSpawnLock( + organizationId: string, + deps: SpawnLockDeps = defaultDeps, +): SpawnLock | null { try { - const raw = readFileSync(lockPath(organizationId), "utf-8"); + const raw = readFileSync(lockPath(organizationId, deps), "utf-8"); const data = JSON.parse(raw); if ( typeof data.ownerPid !== "number" || @@ -52,18 +63,24 @@ export function readSpawnLock(organizationId: string): SpawnLock | null { } } -function removeLock(organizationId: string): void { +function removeLock(organizationId: string, deps: SpawnLockDeps): void { try { - unlinkSync(lockPath(organizationId)); + unlinkSync(lockPath(organizationId, deps)); } catch { // Already gone — fine. } } -function tryCreateLock(organizationId: string): SpawnLockHandle | null { - const path = lockPath(organizationId); +function tryCreateLock( + organizationId: string, + deps: SpawnLockDeps, +): SpawnLockHandle | null { + const path = lockPath(organizationId, deps); try { - mkdirSync(manifestDir(organizationId), { recursive: true, mode: 0o700 }); + mkdirSync(deps.manifestDir(organizationId), { + recursive: true, + mode: 0o700, + }); } catch { // Best-effort; openSync below surfaces a real failure. } @@ -79,7 +96,7 @@ function tryCreateLock(organizationId: string): SpawnLockHandle | null { try { const lock: SpawnLock = { ownerPid: process.pid, - machineId: getHostId(), + machineId: deps.getHostId(), acquiredAt: Date.now(), }; writeSync(fd, JSON.stringify(lock)); @@ -92,7 +109,7 @@ function tryCreateLock(organizationId: string): SpawnLockHandle | null { return { release() { - removeLock(organizationId); + removeLock(organizationId, deps); }, }; } @@ -105,20 +122,21 @@ function tryCreateLock(organizationId: string): SpawnLockHandle | null { export function acquireSpawnLock( organizationId: string, { staleMs }: { staleMs: number }, + deps: SpawnLockDeps = defaultDeps, ): SpawnLockHandle | null { - const handle = tryCreateLock(organizationId); + const handle = tryCreateLock(organizationId, deps); if (handle) return handle; // Lock exists — decide whether the holder is dead/wedged and stealable. - const existing = readSpawnLock(organizationId); + const existing = readSpawnLock(organizationId, deps); const stealable = !existing || // garbage / partial write - !isProcessAlive(existing.ownerPid) || // owner crashed mid-spawn + !deps.isProcessAlive(existing.ownerPid) || // owner crashed mid-spawn Date.now() - existing.acquiredAt > staleMs; // owner wedged if (!stealable) return null; - removeLock(organizationId); + removeLock(organizationId, deps); // One retry after stealing; if a third party grabbed it first, back off. - return tryCreateLock(organizationId); + return tryCreateLock(organizationId, deps); } diff --git a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.test.ts b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.test.ts index e3ce361b08d..c3ca03010f7 100644 --- a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.test.ts +++ b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.test.ts @@ -1,9 +1,11 @@ -import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { beforeEach, describe, expect, it } from "bun:test"; import { EventEmitter } from "node:events"; +import type { TerminalHostClient } from "../../terminal-host/client"; import { TERMINAL_ATTACH_CANCELED_MESSAGE, TerminalAttachCanceledError, } from "../errors"; +import { DaemonTerminalManager } from "./daemon-manager"; import type { SessionInfo } from "./types"; class MockTerminalHostClient extends EventEmitter { @@ -166,90 +168,40 @@ class MockTerminalHostClient extends EventEmitter { let mockClient = new MockTerminalHostClient(); -mock.module("../../terminal-host/client", () => ({ - getTerminalHostClient: () => mockClient, - disposeTerminalHostClient: () => {}, -})); - -mock.module("main/lib/analytics", () => ({ - track: () => {}, -})); - -mock.module("../env", () => ({ - buildTerminalEnv: () => ({}), - getDefaultShell: () => "/bin/zsh", -})); - -mock.module("main/lib/app-state", () => ({ - appState: { data: null }, -})); - -mock.module("main/lib/local-db", () => ({ - localDb: { - select: () => ({ - from: () => ({ - all: () => [], - get: () => undefined, - }), +const noop = () => {}; +const resolved = () => Promise.resolve(); + +const createManager = () => + new DaemonTerminalManager({ + getTerminalHostClient: () => mockClient as unknown as TerminalHostClient, + disposeTerminalHostClient: noop, + buildTerminalEnv: () => ({}), + getDefaultShell: () => "/bin/zsh", + portManager: { + upsertSession: noop, + unregisterSession: noop, + checkOutputForHint: noop, + }, + createHistoryManager: () => ({ + initHistoryWriter: resolved, + writeToHistory: noop, + closeHistoryWriter: noop, + cleanupHistory: resolved, + getHistoryWriter: () => undefined, + resetAll: resolved, + cleanup: resolved, + forceCloseAll: resolved, + closeAllSync: noop, }), - }, -})); - -mock.module("@superset/local-db", () => ({ - workspaces: { id: "id" }, -})); - -mock.module("../port-manager", () => ({ - portManager: { - upsertSession: () => {}, - unregisterSession: () => {}, - checkOutputForHint: () => {}, - }, -})); - -mock.module("./history-manager", () => ({ - HistoryManager: class { - cleanupHistory() { - return Promise.resolve(); - } - - cleanup() { - return Promise.resolve(); - } - - forceCloseAll() { - return Promise.resolve(); - } - - initHistoryWriter() { - return Promise.resolve(); - } - - writeToHistory() {} - - closeHistoryWriter() {} - - closeAllSync() {} - - reset() { - return Promise.resolve(); - } - }, -})); - -const { DaemonTerminalManager } = await import("./daemon-manager"); + }); describe("DaemonTerminalManager kill tracking", () => { beforeEach(() => { mockClient = new MockTerminalHostClient(); }); - afterAll(() => { - mock.restore(); - }); - it("waits for daemon exit and labels killed sessions", async () => { - const manager = new DaemonTerminalManager(); + const manager = createManager(); const paneId = "pane-kill-1"; const sessions = ( manager as unknown as { sessions: Map } @@ -279,7 +231,7 @@ describe("DaemonTerminalManager kill tracking", () => { }); it("labels exit as killed even if session is missing", async () => { - const manager = new DaemonTerminalManager(); + const manager = createManager(); const paneId = "pane-kill-2"; let exitReason: string | undefined; @@ -293,7 +245,7 @@ describe("DaemonTerminalManager kill tracking", () => { }); it("defaults exit reason to exited when no kill tombstone exists", () => { - const manager = new DaemonTerminalManager(); + const manager = createManager(); const paneId = "pane-exit-1"; let exitReason: string | undefined; @@ -306,7 +258,7 @@ describe("DaemonTerminalManager kill tracking", () => { }); it("supersedes older createOrAttach requests for the same pane", async () => { - const manager = new DaemonTerminalManager(); + const manager = createManager(); const paneId = "pane-attach-1"; const managerInternals = manager as unknown as { daemonSessionIdsHydrated: boolean; @@ -359,7 +311,7 @@ describe("DaemonTerminalManager kill tracking", () => { }); it("reuses a helper joinPending attach when a request-scoped attach starts later", async () => { - const manager = new DaemonTerminalManager(); + const manager = createManager(); const paneId = "pane-attach-helper-first"; const managerInternals = manager as unknown as { daemonSessionIdsHydrated: boolean; @@ -403,7 +355,7 @@ describe("DaemonTerminalManager kill tracking", () => { }); it("does not dispatch stale daemon work after canceling before dispatch", async () => { - const manager = new DaemonTerminalManager(); + const manager = createManager(); const paneId = "pane-attach-blocked"; const managerInternals = manager as unknown as { daemonSessionIdsHydrated: boolean; @@ -434,7 +386,7 @@ describe("DaemonTerminalManager kill tracking", () => { }); it("aborts pending attaches during reset", async () => { - const manager = new DaemonTerminalManager(); + const manager = createManager(); const paneId = "pane-reset-attach"; const managerInternals = manager as unknown as { daemonSessionIdsHydrated: boolean; @@ -476,7 +428,7 @@ describe("DaemonTerminalManager kill tracking", () => { * to pass joinPending: true so the two calls share one pending promise. */ it("cancels first attach when two non-joinPending calls race for the same pane (#2748)", async () => { - const manager = new DaemonTerminalManager(); + const manager = createManager(); const paneId = "pane-worktree-race"; const managerInternals = manager as unknown as { daemonSessionIdsHydrated: boolean; @@ -517,7 +469,7 @@ describe("DaemonTerminalManager kill tracking", () => { }); it("joinPending avoids cancellation when WorkspaceInitEffects and lifecycle race (#2748 fix)", async () => { - const manager = new DaemonTerminalManager(); + const manager = createManager(); const paneId = "pane-worktree-fix"; const managerInternals = manager as unknown as { daemonSessionIdsHydrated: boolean; @@ -558,7 +510,7 @@ describe("DaemonTerminalManager kill tracking", () => { }); it("propagates probe failures from forceKillAll instead of silently no-oping", async () => { - const manager = new DaemonTerminalManager(); + const manager = createManager(); mockClient.listSessionsIfRunningError = new Error("probe failed"); await expect(manager.forceKillAll()).rejects.toThrow("probe failed"); diff --git a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts index 8aeea32d9ed..51609371f33 100644 --- a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts +++ b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts @@ -33,6 +33,27 @@ interface PendingCreateOrAttach { promise: Promise; } +export interface DaemonTerminalManagerDeps { + getTerminalHostClient: typeof getTerminalHostClient; + disposeTerminalHostClient: typeof disposeTerminalHostClient; + buildTerminalEnv: typeof buildTerminalEnv; + getDefaultShell: typeof getDefaultShell; + portManager: Pick< + typeof portManager, + "upsertSession" | "unregisterSession" | "checkOutputForHint" + >; + createHistoryManager: () => Pick; +} + +const defaultDeps: DaemonTerminalManagerDeps = { + getTerminalHostClient, + disposeTerminalHostClient, + buildTerminalEnv, + getDefaultShell, + portManager, + createHistoryManager: () => new HistoryManager(), +}; + export class DaemonTerminalManager extends EventEmitter { private client!: TerminalHostClient; private sessions = new Map(); @@ -44,13 +65,16 @@ export class DaemonTerminalManager extends EventEmitter { private daemonAliveSessionIds = new Set(); private daemonSessionIdsHydrated = false; - private historyManager = new HistoryManager(); + private readonly deps: DaemonTerminalManagerDeps; + private historyManager: Pick; private coldRestoreInfo = new Map(); private cleanupTimeouts = new Map(); - constructor() { + constructor(deps: Partial = {}) { super(); + this.deps = { ...defaultDeps, ...deps }; + this.historyManager = this.deps.createHistoryManager(); this.initializeClient(); } @@ -80,7 +104,7 @@ export class DaemonTerminalManager extends EventEmitter { } private initializeClient(): void { - this.client = getTerminalHostClient(); + this.client = this.deps.getTerminalHostClient(); this.setupClientEventHandlers(); } @@ -138,7 +162,7 @@ export class DaemonTerminalManager extends EventEmitter { // Enable port scanning before user opens terminal tabs for (const session of preservedSessions) { - portManager.upsertSession( + this.deps.portManager.upsertSession( session.paneId, session.workspaceId, session.pid, @@ -191,7 +215,7 @@ export class DaemonTerminalManager extends EventEmitter { session.lastActive = Date.now(); } - portManager.checkOutputForHint(paneId, data); + this.deps.portManager.checkOutputForHint(paneId, data); this.historyManager.writeToHistory(paneId, data, () => this.sessions.get(paneId), ); @@ -210,7 +234,7 @@ export class DaemonTerminalManager extends EventEmitter { session.pid = null; } - portManager.unregisterSession(paneId); + this.deps.portManager.unregisterSession(paneId); this.historyManager.closeHistoryWriter(paneId, exitCode); const reason = session?.exitReason ?? @@ -427,8 +451,8 @@ export class DaemonTerminalManager extends EventEmitter { throwIfAborted(signal); } - const shell = getDefaultShell(); - const env = buildTerminalEnv({ + const shell = this.deps.getDefaultShell(); + const env = this.deps.buildTerminalEnv({ shell, paneId, tabId, @@ -511,7 +535,7 @@ export class DaemonTerminalManager extends EventEmitter { rows: effectiveRows, }); - portManager.upsertSession(paneId, workspaceId, response.pid); + this.deps.portManager.upsertSession(paneId, workspaceId, response.pid); const snapshotAnsi = response.snapshot.snapshotAnsi || ""; const snapshotAnsiBytes = Buffer.byteLength(snapshotAnsi, "utf8"); @@ -714,7 +738,7 @@ export class DaemonTerminalManager extends EventEmitter { session.pid = null; } - portManager.unregisterSession(paneId); + this.deps.portManager.unregisterSession(paneId); if (deleteHistory && session) { await this.historyManager.cleanupHistory(paneId, session.workspaceId); @@ -847,7 +871,7 @@ export class DaemonTerminalManager extends EventEmitter { session.pid = null; } - portManager.unregisterSession(paneId); + this.deps.portManager.unregisterSession(paneId); await this.historyManager.cleanupHistory(paneId, workspaceId); await this.client.kill({ sessionId: paneId, deleteHistory: true }); }), @@ -935,7 +959,7 @@ export class DaemonTerminalManager extends EventEmitter { this.coldRestoreInfo.clear(); this.killedSessionTombstones.clear(); this.removeAllListeners(); - disposeTerminalHostClient(); + this.deps.disposeTerminalHostClient(); } async forceKillAll(): Promise { @@ -966,7 +990,7 @@ export class DaemonTerminalManager extends EventEmitter { await this.client.killAll({}); } for (const paneId of sessionIds) { - portManager.unregisterSession(paneId); + this.deps.portManager.unregisterSession(paneId); } this.daemonAliveSessionIds.clear(); this.daemonSessionIdsHydrated = true; @@ -993,7 +1017,7 @@ export class DaemonTerminalManager extends EventEmitter { this.historyManager.closeAllSync(); this.createOrAttachLimiter.reset(); - disposeTerminalHostClient(); + this.deps.disposeTerminalHostClient(); this.initializeClient(); console.log("[DaemonTerminalManager] Reset complete"); diff --git a/apps/desktop/src/main/lib/terminal/index.test.ts b/apps/desktop/src/main/lib/terminal/index.test.ts index afb2e10a198..61458c7f06c 100644 --- a/apps/desktop/src/main/lib/terminal/index.test.ts +++ b/apps/desktop/src/main/lib/terminal/index.test.ts @@ -5,10 +5,14 @@ import { describe, expect, it, - mock, spyOn, } from "bun:test"; import type { ListSessionsResponse } from "main/lib/terminal-host/types"; +import { + restartDaemon as restartDaemonWith, + type TerminalDaemonDeps, + tryListExistingDaemonSessions as tryListExistingDaemonSessionsWith, +} from "./index"; let listSessionsIfRunningResult: ListSessionsResponse | null = null; let listSessionsIfRunningError: Error | null = null; @@ -30,7 +34,7 @@ function makeSession( }; } -mock.module("main/lib/terminal-host/client", () => ({ +const deps: TerminalDaemonDeps = { getTerminalHostClient: () => ({ listSessionsIfRunning: async () => { if (listSessionsIfRunningError) { @@ -45,33 +49,30 @@ mock.module("main/lib/terminal-host/client", () => ({ } return { wasRunning: true }; }, - ensureConnected: async () => {}, }), - disposeTerminalHostClient: () => {}, -})); - -mock.module("./daemon", () => ({ - DaemonTerminalManager: class {}, getDaemonTerminalManager: () => ({ reset: () => { resetCalls++; }, - reconcileOnStartup: async () => {}, }), -})); +}; -const { restartDaemon, tryListExistingDaemonSessions } = await import( - "./index" -); +const restartDaemon = () => restartDaemonWith(deps); +const tryListExistingDaemonSessions = () => + tryListExistingDaemonSessionsWith(deps); describe("terminal index", () => { + let consoleSpies: { mockRestore: () => void }[] = []; + beforeAll(() => { - spyOn(console, "log").mockImplementation(() => {}); - spyOn(console, "warn").mockImplementation(() => {}); + consoleSpies = [ + spyOn(console, "log").mockImplementation(() => {}), + spyOn(console, "warn").mockImplementation(() => {}), + ]; }); afterAll(() => { - mock.restore(); + for (const spy of consoleSpies) spy.mockRestore(); }); beforeEach(() => { diff --git a/apps/desktop/src/main/lib/terminal/index.ts b/apps/desktop/src/main/lib/terminal/index.ts index 7d3e61eb9fc..7b40dae232d 100644 --- a/apps/desktop/src/main/lib/terminal/index.ts +++ b/apps/desktop/src/main/lib/terminal/index.ts @@ -1,4 +1,7 @@ -import { getTerminalHostClient } from "main/lib/terminal-host/client"; +import { + getTerminalHostClient, + type TerminalHostClient, +} from "main/lib/terminal-host/client"; import type { ListSessionsResponse } from "main/lib/terminal-host/types"; import { DaemonTerminalManager, getDaemonTerminalManager } from "./daemon"; import { prewarmTerminalEnv } from "./env"; @@ -12,6 +15,19 @@ export type { TerminalExitEvent, } from "./types"; +export interface TerminalDaemonDeps { + getTerminalHostClient: () => Pick< + TerminalHostClient, + "listSessionsIfRunning" | "shutdownIfRunning" + >; + getDaemonTerminalManager: () => Pick; +} + +const defaultDeps: TerminalDaemonDeps = { + getTerminalHostClient, + getDaemonTerminalManager, +}; + const DEBUG_TERMINAL = process.env.SUPERSET_TERMINAL_DEBUG === "1"; let prewarmInFlight: Promise | null = null; @@ -36,10 +52,12 @@ export async function reconcileDaemonSessions(): Promise { * Restart the terminal daemon. Kills all sessions, shuts down the daemon, * and resets the manager so a fresh daemon spawns on next use. */ -export async function restartDaemon(): Promise<{ success: boolean }> { +export async function restartDaemon( + deps: TerminalDaemonDeps = defaultDeps, +): Promise<{ success: boolean }> { console.log("[restartDaemon] Starting daemon restart..."); - const client = getTerminalHostClient(); + const client = deps.getTerminalHostClient(); try { const existingSessions = await client.listSessionsIfRunning(); @@ -60,7 +78,7 @@ export async function restartDaemon(): Promise<{ success: boolean }> { throw error; } - const manager = getDaemonTerminalManager(); + const manager = deps.getDaemonTerminalManager(); manager.reset(); console.log("[restartDaemon] Complete"); @@ -68,11 +86,13 @@ export async function restartDaemon(): Promise<{ success: boolean }> { return { success: true }; } -export async function tryListExistingDaemonSessions(): Promise<{ +export async function tryListExistingDaemonSessions( + deps: Pick = defaultDeps, +): Promise<{ sessions: ListSessionsResponse["sessions"]; }> { try { - const client = getTerminalHostClient(); + const client = deps.getTerminalHostClient(); const result = await client.listSessionsIfRunning(); if (!result) { return { sessions: [] }; diff --git a/apps/desktop/src/main/lib/window-state/bounds-validation.test.ts b/apps/desktop/src/main/lib/window-state/bounds-validation.test.ts index 5234b31634c..98dcf78e5d9 100644 --- a/apps/desktop/src/main/lib/window-state/bounds-validation.test.ts +++ b/apps/desktop/src/main/lib/window-state/bounds-validation.test.ts @@ -40,7 +40,6 @@ beforeEach(() => { afterAll(() => { setScreenForTesting(null); - mock.restore(); }); describe("isVisibleOnAnyDisplay", () => { diff --git a/apps/desktop/src/renderer/components/LanguageAwareI18nProvider/LanguageAwareI18nProvider.test.tsx b/apps/desktop/src/renderer/components/LanguageAwareI18nProvider/LanguageAwareI18nProvider.test.tsx index f85696904ea..e03dd418c05 100644 --- a/apps/desktop/src/renderer/components/LanguageAwareI18nProvider/LanguageAwareI18nProvider.test.tsx +++ b/apps/desktop/src/renderer/components/LanguageAwareI18nProvider/LanguageAwareI18nProvider.test.tsx @@ -1,211 +1,125 @@ -import { - afterAll, - afterEach, - beforeEach, - describe, - expect, - mock, - test, -} from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; - -const realAuthClientModule = { ...(await import("renderer/lib/auth-client")) }; -const realPostHogModule = { ...(await import("renderer/lib/posthog")) }; -const registerLocale = mock(); -const people = { set: mock() }; - -// happy-dom is process-wide; unregister in afterAll so the shared mock -// document is restored for the other renderer suites. -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); +import { afterEach, describe, expect, test } from "bun:test"; +import type { TRPCLink } from "@trpc/client"; +import type { AppRouter } from "lib/trpc/routers"; + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; -let languageQueryResult: { - data?: string | null; - isPending: boolean; - isError: boolean; -} = { data: undefined, isPending: true, isError: false }; -const getLanguageUseQuery = mock( - (_input: unknown, _options?: unknown) => languageQueryResult, +const { DEFAULT_LOCALE, initI18nAsync } = await import("@superset/i18n"); +const { QueryClient } = await import("@tanstack/react-query"); +const { TRPCClientError } = await import("@trpc/client"); +const { observable } = await import("@trpc/server/observable"); +const { cleanup, render, waitFor } = await import("@testing-library/react"); +const { electronTrpc } = await import("renderer/lib/electron-trpc"); +const { LanguageAwareI18nProvider } = await import( + "./LanguageAwareI18nProvider" ); -const setLanguageData = mock(); - -mock.module("renderer/lib/electron-trpc", () => ({ - electronTrpc: { - settings: { - getLanguage: { useQuery: getLanguageUseQuery }, - onLanguageChange: { useSubscription: mock() }, - }, - useUtils: () => ({ - settings: { getLanguage: { setData: setLanguageData } }, - }), - }, -})); - -// The tagger renders alongside the provider's children and pulls in auth and -// PostHog — irrelevant to locale resolution, stubbed out here. -mock.module("renderer/lib/auth-client", () => ({ - ...realAuthClientModule, - authClient: new Proxy(realAuthClientModule.authClient, { - get(target, property, receiver) { - if (property === "useSession") return () => ({ data: null }); - return Reflect.get(target, property, receiver); - }, - }), -})); -mock.module("renderer/lib/posthog", () => ({ - ...realPostHogModule, - posthog: new Proxy(realPostHogModule.posthog, { - get(target, property, receiver) { - if (property === "register") return registerLocale; - if (property === "people") return people; - return Reflect.get(target, property, receiver); - }, - }), -})); - -const originalNavigator = Object.getOwnPropertyDescriptor( - globalThis, - "navigator", + +const originalLanguages = Object.getOwnPropertyDescriptor( + navigator, + "languages", ); + function setNavigatorLanguages(languages: string[]) { - Object.defineProperty(globalThis, "navigator", { - value: { ...globalThis.navigator, languages }, + Object.defineProperty(navigator, "languages", { + value: languages, configurable: true, - writable: true, }); } -const { act, cleanup, render, waitFor } = await import( - "@testing-library/react" -); -const { LanguageAwareI18nProvider } = await import( - "./LanguageAwareI18nProvider" -); - -async function flush() { - // Lets the locale-activation effect's dynamic catalog import and its - // .finally(() => setReady(true)) settle across a couple of microtask/ - // macrotask turns. - for (let i = 0; i < 5; i++) { - // eslint-disable-next-line no-await-in-loop - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); +const NEVER = Symbol("never answers"); + +function renderProvider(readLanguage: (attempt: number) => unknown) { + let attempts = 0; + const link: TRPCLink = () => (call) => + observable((observer) => { + if (call.op.path !== "settings.getLanguage") return; + attempts += 1; + try { + const language = readLanguage(attempts); + if (language === NEVER) return; + observer.next({ result: { data: language } }); + observer.complete(); + } catch (error) { + observer.error(TRPCClientError.from(error as Error)); + } }); - } + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const view = render( + + +
+ + , + ); + return { view, attempts: () => attempts }; } -beforeEach(() => { - getLanguageUseQuery.mockClear(); - setLanguageData.mockClear(); - languageQueryResult = { data: undefined, isPending: true, isError: false }; -}); -afterEach(() => { +function failRead(): never { + throw new Error("IPC channel not ready"); +} + +afterEach(async () => { cleanup(); - if (originalNavigator) { - Object.defineProperty(globalThis, "navigator", originalNavigator); + if (originalLanguages) { + Object.defineProperty(navigator, "languages", originalLanguages); + } else { + Reflect.deleteProperty(navigator, "languages"); } + await initI18nAsync(DEFAULT_LOCALE); + document.documentElement.lang = ""; }); -afterAll(async () => { - mock.module("renderer/lib/auth-client", () => realAuthClientModule); - mock.module("renderer/lib/posthog", () => realPostHogModule); - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - describe("LanguageAwareI18nProvider", () => { - test("configures getLanguage to retry instead of failing on the first attempt (#7415)", async () => { - // A CMD+R reload re-establishes the electron-trpc IPC channel in a - // fresh JS realm, making the very first getLanguage fetch after a - // reload the one most likely to race it transiently. Retrying keeps - // React Query's `isPending` true across that race so it self-heals - // before ever reaching the isPending:false branches below, instead of - // settling into "no preference" on a single flaky attempt. - await act(async () => { - render( - -
- , - ); - }); + test("retries a failed first read instead of settling on no preference (#7415)", async () => { + setNavigatorLanguages(["ja-JP"]); + const { view, attempts } = renderProvider((attempt) => + attempt === 1 ? failRead() : "en", + ); - const options = getLanguageUseQuery.mock.calls.at(-1)?.[1] as { - retry: number; - retryDelay: (attempt: number) => number; - }; - expect(options.retry).toBeGreaterThan(0); - expect(options.retryDelay(0)).toBeGreaterThan(0); + await view.findByTestId("marker"); + expect(attempts()).toBe(2); + expect(document.documentElement.lang).toBe("en"); }); - test("stays deferred while the query is pending, even after a prior failed attempt", async () => { - // Mid-retry: React Query reports isPending: true throughout, no - // matter how many attempts have already failed. + test("stays deferred while the read is pending, even after a failed attempt", async () => { setNavigatorLanguages(["ja-JP"]); - languageQueryResult = { data: undefined, isPending: true, isError: true }; - - await act(async () => { - render( - -
- , - ); - }); - await flush(); + const { view, attempts } = renderProvider((attempt) => + attempt === 1 ? failRead() : NEVER, + ); + await waitFor(() => expect(attempts()).toBe(2)); + expect(view.queryByTestId("marker")).toBeNull(); expect(document.documentElement.lang).not.toBe("ja"); }); test("falls back to the inferred locale once retries are exhausted, instead of staying blank forever", async () => { - // The retry budget configured above is what makes this branch rare in - // practice — a transient reload-time race self-heals before ever - // reaching it. Only a genuinely stuck IPC channel settles here, and a - // stuck channel would break the rest of the app too, so falling back - // to the inferred locale beats leaving the window blank forever. setNavigatorLanguages(["ja-JP"]); - languageQueryResult = { data: undefined, isPending: false, isError: true }; - - await act(async () => { - render( - -
- , - ); - }); - await flush(); + const { view, attempts } = renderProvider(failRead); - await waitFor(() => expect(document.documentElement.lang).toBe("ja")); + await view.findByTestId("marker"); + expect(attempts()).toBe(4); + expect(document.documentElement.lang).toBe("ja"); }); - test("activates the persisted locale once the query succeeds", async () => { + test("activates the persisted locale once the read succeeds", async () => { setNavigatorLanguages(["ja-JP"]); - languageQueryResult = { data: "en", isPending: false, isError: false }; - - await act(async () => { - render( - -
- , - ); - }); - await flush(); + const { view } = renderProvider(() => "en"); - await waitFor(() => expect(document.documentElement.lang).toBe("en")); + await view.findByTestId("marker"); + expect(document.documentElement.lang).toBe("en"); }); - test("infers the OS locale only once the query genuinely resolves to no preference", async () => { + test("infers the OS locale only once the read resolves to no preference", async () => { setNavigatorLanguages(["ja-JP"]); - languageQueryResult = { data: null, isPending: false, isError: false }; - - await act(async () => { - render( - -
- , - ); - }); - await flush(); + const { view } = renderProvider(() => null); - await waitFor(() => expect(document.documentElement.lang).toBe("ja")); + await view.findByTestId("marker"); + expect(document.documentElement.lang).toBe("ja"); }); }); diff --git a/apps/desktop/src/renderer/components/LanguageAwareI18nProvider/LanguageAwareI18nProvider.tsx b/apps/desktop/src/renderer/components/LanguageAwareI18nProvider/LanguageAwareI18nProvider.tsx index bdec9646b9e..4717fc75b55 100644 --- a/apps/desktop/src/renderer/components/LanguageAwareI18nProvider/LanguageAwareI18nProvider.tsx +++ b/apps/desktop/src/renderer/components/LanguageAwareI18nProvider/LanguageAwareI18nProvider.tsx @@ -1,6 +1,5 @@ import { I18nProvider } from "@superset/i18n/react"; import type { ReactNode } from "react"; -import { PostHogLocaleTagger } from "renderer/components/PostHogLocaleTagger"; import { electronTrpc } from "renderer/lib/electron-trpc"; // The electron-trpc IPC channel can still be settling right after a CMD+R @@ -13,8 +12,10 @@ const GET_LANGUAGE_RETRY_DELAY_MS = 250; export function LanguageAwareI18nProvider({ children, + languageRetryDelayMs = GET_LANGUAGE_RETRY_DELAY_MS, }: { children: ReactNode; + languageRetryDelayMs?: number; }) { // Persisted setting wins; undefined falls back to first-load inference. // React Query keeps `isPending` true across every configured retry, so a @@ -24,7 +25,7 @@ export function LanguageAwareI18nProvider({ const { data: language, isPending } = electronTrpc.settings.getLanguage.useQuery(undefined, { retry: GET_LANGUAGE_MAX_RETRIES, - retryDelay: (attempt) => GET_LANGUAGE_RETRY_DELAY_MS * (attempt + 1), + retryDelay: (attempt) => languageRetryDelayMs * (attempt + 1), }); const utils = electronTrpc.useUtils(); electronTrpc.settings.onLanguageChange.useSubscription(undefined, { @@ -38,7 +39,6 @@ export function LanguageAwareI18nProvider({ // wrong language beats showing nothing. return ( - {children} ); diff --git a/apps/desktop/src/renderer/components/MarkdownEditor/components/RecordMention/RecordMentionNode.test.ts b/apps/desktop/src/renderer/components/MarkdownEditor/components/RecordMention/RecordMentionNode.test.ts index 20fe99c3c66..430ab32d4a5 100644 --- a/apps/desktop/src/renderer/components/MarkdownEditor/components/RecordMention/RecordMentionNode.test.ts +++ b/apps/desktop/src/renderer/components/MarkdownEditor/components/RecordMention/RecordMentionNode.test.ts @@ -1,9 +1,4 @@ -import { GlobalRegistrator } from "@happy-dom/global-registrator"; - -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); - -const { afterAll, describe, expect, it } = await import("bun:test"); +const { describe, expect, it } = await import("bun:test"); const { Editor } = await import("@tiptap/core"); const { default: Document } = await import("@tiptap/extension-document"); const { default: Paragraph } = await import("@tiptap/extension-paragraph"); @@ -12,10 +7,6 @@ const { default: Link } = await import("@tiptap/extension-link"); const { Markdown } = await import("tiptap-markdown"); const { RecordMentionNode } = await import("./RecordMentionNode"); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - function load(markdown: string) { const editor = new Editor({ extensions: [ diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/createMarkdownExtensions.test.ts b/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/createMarkdownExtensions.test.ts index 55388bae89d..bdd9408a6bf 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/createMarkdownExtensions.test.ts +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/createMarkdownExtensions.test.ts @@ -1,19 +1,7 @@ -import { GlobalRegistrator } from "@happy-dom/global-registrator"; - -// happy-dom over the preloaded plain-object document — TipTap's Editor needs -// real DOM APIs. bun runs test files sequentially in one process and -// happy-dom's globals are process-wide, so register once and unregister after. -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); - -const { afterAll, describe, expect, it, spyOn } = await import("bun:test"); +const { describe, expect, it, spyOn } = await import("bun:test"); const { Editor } = await import("@tiptap/core"); const { createMarkdownExtensions } = await import("./createMarkdownExtensions"); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - function getMarkdown(editor: InstanceType): string { const storage = editor.storage as unknown as Record< string, diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/mergeMarkdownEdits.roundtrip.test.ts b/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/mergeMarkdownEdits.roundtrip.test.ts index 8ee97895247..95e3a0ac682 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/mergeMarkdownEdits.roundtrip.test.ts +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/mergeMarkdownEdits.roundtrip.test.ts @@ -1,21 +1,9 @@ -import { GlobalRegistrator } from "@happy-dom/global-registrator"; - -// happy-dom over the preloaded plain-object document — TipTap's Editor needs -// real DOM APIs. bun runs test files sequentially in one process and -// happy-dom's globals are process-wide, so register once and unregister after. -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); - -const { afterAll, describe, expect, it } = await import("bun:test"); +const { describe, expect, it } = await import("bun:test"); const { Editor } = await import("@tiptap/core"); const { createMarkdownExtensions } = await import("./createMarkdownExtensions"); const { mergeMarkdownEdits } = await import("./mergeMarkdownEdits"); const { applyExternalMarkdown } = await import("./TipTapMarkdownRenderer"); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - function getMarkdown(editor: InstanceType): string { const storage = editor.storage as unknown as Record< string, diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/resolveLinkClick.test.ts b/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/resolveLinkClick.test.ts index 3a9e15b2603..b71cce8a04b 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/resolveLinkClick.test.ts +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/resolveLinkClick.test.ts @@ -1,9 +1,4 @@ -import { GlobalRegistrator } from "@happy-dom/global-registrator"; - -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); - -const { afterAll, describe, expect, it } = await import("bun:test"); +const { describe, expect, it } = await import("bun:test"); const { Editor } = await import("@tiptap/core"); const { tierFor } = await import("renderer/lib/clickPolicy/tiers"); const { createMarkdownExtensions } = await import("./createMarkdownExtensions"); @@ -12,10 +7,6 @@ const { resolveLinkClick } = await import("./resolveLinkClick"); type LinkTierMap = import("renderer/lib/clickPolicy").LinkTierMap; type ModifierEvent = import("renderer/lib/clickPolicy").ModifierEvent; -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - const DEFAULT_URL_LINKS: LinkTierMap = { plain: null, shift: "newTab", diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/CompareBaseBranchPickerInline/CompareBaseBranchPickerInline.test.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/CompareBaseBranchPickerInline/CompareBaseBranchPickerInline.test.tsx index a7fab1b2a86..e3e1480b7f3 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/CompareBaseBranchPickerInline/CompareBaseBranchPickerInline.test.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/CompareBaseBranchPickerInline/CompareBaseBranchPickerInline.test.tsx @@ -1,8 +1,5 @@ -import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, describe, expect, mock, test } from "bun:test"; -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -14,10 +11,6 @@ const { CompareBaseBranchPickerInline } = await import( "./CompareBaseBranchPickerInline" ); afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - describe("legacy branch picker", () => { test.each([ "row", diff --git a/apps/desktop/src/renderer/components/Paywall/usePaywall.test.tsx b/apps/desktop/src/renderer/components/Paywall/usePaywall.test.tsx index e79c1f481dc..0397c6134e4 100644 --- a/apps/desktop/src/renderer/components/Paywall/usePaywall.test.tsx +++ b/apps/desktop/src/renderer/components/Paywall/usePaywall.test.tsx @@ -1,13 +1,28 @@ import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; -import { toast } from "@superset/ui/sonner"; +import type { AppRouter } from "@superset/trpc"; +import type { TRPCLink } from "@trpc/client"; +import type { ReactNode } from "react"; -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; +const { toast } = await import("@superset/ui/sonner"); +const { QueryClient } = await import("@tanstack/react-query"); +const { TRPCClientError } = await import("@trpc/client"); +const { getQueryKey } = await import("@trpc/react-query"); +const { observable } = await import("@trpc/server/observable"); +const { act, cleanup, renderHook, waitFor } = await import( + "@testing-library/react" +); +const { cloudTrpc } = await import("renderer/lib/cloud-trpc"); +const { CollectionsContext } = await import( + "renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider" +); +type CollectionsContextType = + import("renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider").CollectionsContextType; +const { usePaywall } = await import("./usePaywall"); + type ActivePlan = { organizationId: string | null; plan: string; @@ -26,127 +41,147 @@ const freePlanFor = (organizationId: string): ActivePlan => ({ status: null, }); -// What the billing query already holds when the hook renders. -let cachedPlan: ActivePlan | undefined; -// A plan fetch the test resolves by hand, so two clicks can land while the -// gate is still waiting on it. -let rejectPlan: (error: Error) => void = () => {}; -let resolvePlan: (plan: ActivePlan) => void = () => {}; -const ensureData = mock( - () => - new Promise((resolve, reject) => { - rejectPlan = reject; - resolvePlan = resolve; - }), -); -const fetch = mock(async () => freePlanFor(WINDOW_ORG)); -const paywall = mock(() => {}); +interface PlanRequest { + resolve: (plan: ActivePlan) => void; + reject: (error: Error) => void; +} +let planRequestCount = 0; +const unansweredPlanRequests: PlanRequest[] = []; const toastErrors: string[] = []; +const showPaywall = mock(() => {}); -// Patched rather than mocked as a module: the implementation binds the real -// `toast` object, so the patch holds whichever file imported it first. const realToastError = toast.error; toast.error = ((title: string) => { toastErrors.push(title); }) as typeof toast.error; -const actualCloudTrpc = await import("renderer/lib/cloud-trpc"); -mock.module("renderer/lib/cloud-trpc", () => ({ - ...actualCloudTrpc, - cloudTrpc: { - useUtils: () => ({ billing: { activePlan: { ensureData, fetch } } }), - billing: { activePlan: { useQuery: () => ({ data: cachedPlan }) } }, - }, -})); -mock.module("renderer/hooks/useActiveOrganizationId", () => ({ - useActiveOrganizationId: () => WINDOW_ORG, -})); -mock.module("./Paywall", () => ({ paywall })); - -const { act, cleanup, renderHook } = await import("@testing-library/react"); -const { usePaywall } = await import("./usePaywall"); +const planLink: TRPCLink = () => () => + observable((observer) => { + planRequestCount++; + unansweredPlanRequests.push({ + resolve: (plan) => { + observer.next({ result: { data: plan } }); + observer.complete(); + }, + reject: (error) => observer.error(TRPCClientError.from(error)), + }); + }); + +function renderPaywall({ cachedPlan }: { cachedPlan?: ActivePlan } = {}) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + if (cachedPlan) { + queryClient.setQueryData( + getQueryKey(cloudTrpc.billing.activePlan, undefined, "query"), + cachedPlan, + ); + } + const client = cloudTrpc.createClient({ links: [planLink] }); + const wrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + + ); + return renderHook(() => usePaywall({ showPaywall }), { wrapper }); +} + +async function nextPlanRequest(): Promise { + await waitFor(() => expect(unansweredPlanRequests.length).toBeGreaterThan(0)); + return unansweredPlanRequests.shift() as PlanRequest; +} + +async function answerPlan(plan: ActivePlan) { + const request = await nextPlanRequest(); + await act(async () => request.resolve(plan)); +} + +const settle = () => act(async () => {}); afterEach(() => { cleanup(); - cachedPlan = undefined; - ensureData.mockClear(); - fetch.mockClear(); - paywall.mockClear(); + planRequestCount = 0; + unansweredPlanRequests.length = 0; toastErrors.length = 0; + showPaywall.mockClear(); }); afterAll(async () => { toast.error = realToastError; - if (!alreadyRegistered) await GlobalRegistrator.unregister(); }); -const settle = () => act(async () => {}); - describe("gateFeature while the plan is still resolving", () => { // The caller's own pending flag only flips once its callback has started, // so the gate itself has to drop the second click. test("runs the callback once for two immediate clicks", async () => { - const { result } = renderHook(() => usePaywall()); + const { result } = renderPaywall(); const callback = mock(() => {}); act(() => { result.current.gateFeature("automations", callback); result.current.gateFeature("automations", callback); }); - expect(ensureData).toHaveBeenCalledTimes(1); - resolvePlan(proPlanFor(WINDOW_ORG)); + await answerPlan(proPlanFor(WINDOW_ORG)); await settle(); expect(callback).toHaveBeenCalledTimes(1); + expect(planRequestCount).toBe(1); }); test("accepts a new click once the first has settled", async () => { - const { result } = renderHook(() => usePaywall()); + const { result } = renderPaywall(); const callback = mock(() => {}); act(() => result.current.gateFeature("automations", callback)); - resolvePlan(proPlanFor(WINDOW_ORG)); + await answerPlan(proPlanFor(WINDOW_ORG)); await settle(); act(() => result.current.gateFeature("automations", callback)); - resolvePlan(proPlanFor(WINDOW_ORG)); await settle(); expect(callback).toHaveBeenCalledTimes(2); }); test("shows the paywall once, not per click, on a free plan", async () => { - const { result } = renderHook(() => usePaywall()); + const { result } = renderPaywall(); const callback = mock(() => {}); act(() => { result.current.gateFeature("automations", callback); result.current.gateFeature("automations", callback); }); - resolvePlan(freePlanFor(WINDOW_ORG)); + await answerPlan(freePlanFor(WINDOW_ORG)); await settle(); expect(callback).not.toHaveBeenCalled(); - expect(paywall).toHaveBeenCalledTimes(1); + expect(showPaywall).toHaveBeenCalledTimes(1); + expect(planRequestCount).toBe(1); }); test("gates different features independently", async () => { - const { result } = renderHook(() => usePaywall()); + const { result } = renderPaywall(); const callback = mock(() => {}); act(() => { result.current.gateFeature("automations", callback); result.current.gateFeature("tasks", callback); }); - expect(ensureData).toHaveBeenCalledTimes(2); + await answerPlan(proPlanFor(WINDOW_ORG)); + await settle(); + expect(callback).toHaveBeenCalledTimes(2); }); test("says so instead of guessing when the plan cannot be fetched", async () => { - const { result } = renderHook(() => usePaywall()); + const { result } = renderPaywall(); const callback = mock(() => {}); act(() => result.current.gateFeature("automations", callback)); - rejectPlan(new Error("offline")); + const request = await nextPlanRequest(); + await act(async () => request.reject(new Error("offline"))); await settle(); expect(callback).not.toHaveBeenCalled(); - expect(paywall).not.toHaveBeenCalled(); + expect(showPaywall).not.toHaveBeenCalled(); expect(toastErrors).toEqual([ "Could not check your plan. Check your connection and try again.", ]); @@ -155,49 +190,49 @@ describe("gateFeature while the plan is still resolving", () => { describe("whose plan the window is holding", () => { test("a cached plan for this organization is ready without a fetch", async () => { - cachedPlan = proPlanFor(WINDOW_ORG); - const { result } = renderHook(() => usePaywall()); + const { result } = renderPaywall({ cachedPlan: proPlanFor(WINDOW_ORG) }); expect(result.current.isReady).toBe(true); expect(result.current.hasAccess("automations")).toBe(true); const callback = mock(() => {}); act(() => result.current.gateFeature("automations", callback)); await settle(); + // The background refresh of the cached plan is still unanswered. expect(callback).toHaveBeenCalledTimes(1); - expect(ensureData).not.toHaveBeenCalled(); + expect(unansweredPlanRequests).toHaveLength(planRequestCount); }); // The query is keyed without the organization, so for one render after a // switch the cache still holds the previous organization's answer. test("a cached Pro plan for another organization is neither ready nor access", () => { - cachedPlan = proPlanFor("other-org"); - const { result } = renderHook(() => usePaywall()); + const { result } = renderPaywall({ cachedPlan: proPlanFor("other-org") }); expect(result.current.isReady).toBe(false); expect(result.current.hasAccess("automations")).toBe(false); }); test("does not grant access on a refetch that still answers for another organization", async () => { - fetch.mockImplementationOnce(async () => proPlanFor("other-org")); - const { result } = renderHook(() => usePaywall()); + const { result } = renderPaywall(); const callback = mock(() => {}); act(() => result.current.gateFeature("automations", callback)); - resolvePlan(proPlanFor("other-org")); + await answerPlan(proPlanFor("other-org")); + await answerPlan(proPlanFor("other-org")); await settle(); expect(callback).not.toHaveBeenCalled(); - expect(paywall).not.toHaveBeenCalled(); + expect(showPaywall).not.toHaveBeenCalled(); expect(toastErrors).toHaveLength(1); }); test("refetches when the awaited plan belongs to another organization", async () => { - const { result } = renderHook(() => usePaywall()); + const { result } = renderPaywall(); const callback = mock(() => {}); act(() => result.current.gateFeature("automations", callback)); - resolvePlan(proPlanFor("other-org")); + await answerPlan(proPlanFor("other-org")); + await answerPlan(freePlanFor(WINDOW_ORG)); await settle(); - expect(fetch).toHaveBeenCalledWith(undefined, { staleTime: 0 }); + expect(planRequestCount).toBe(2); expect(callback).not.toHaveBeenCalled(); - expect(paywall).toHaveBeenCalledTimes(1); + expect(showPaywall).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/desktop/src/renderer/components/Paywall/usePaywall.ts b/apps/desktop/src/renderer/components/Paywall/usePaywall.ts index 8e127980ed2..60454696b45 100644 --- a/apps/desktop/src/renderer/components/Paywall/usePaywall.ts +++ b/apps/desktop/src/renderer/components/Paywall/usePaywall.ts @@ -7,7 +7,11 @@ import { useCurrentPlan } from "renderer/hooks/useCurrentPlan"; import type { GatedFeature } from "./constants"; import { paywall } from "./Paywall"; -export function usePaywall() { +export function usePaywall({ + showPaywall = paywall, +}: { + showPaywall?: typeof paywall; +} = {}) { const { t } = useLingui(); const { plan: userPlan, isReady, resolvePlanWhenKnown } = useCurrentPlan(); // Read at the top level, not inside gateFeature: hooks may not be called @@ -59,7 +63,7 @@ export function usePaywall() { } return; } - paywall(feature, { + showPaywall(feature, { organizationId, userPlan: plan, ...context, diff --git a/apps/desktop/src/renderer/components/PluginMention/PluginMentionNode.test.ts b/apps/desktop/src/renderer/components/PluginMention/PluginMentionNode.test.ts index 39f025bb9e8..4cc175e32cb 100644 --- a/apps/desktop/src/renderer/components/PluginMention/PluginMentionNode.test.ts +++ b/apps/desktop/src/renderer/components/PluginMention/PluginMentionNode.test.ts @@ -1,9 +1,4 @@ -import { GlobalRegistrator } from "@happy-dom/global-registrator"; - -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); - -const { afterAll, describe, expect, it } = await import("bun:test"); +const { describe, expect, it } = await import("bun:test"); const { Editor } = await import("@tiptap/core"); const { default: Document } = await import("@tiptap/extension-document"); const { default: Paragraph } = await import("@tiptap/extension-paragraph"); @@ -11,10 +6,6 @@ const { default: Text } = await import("@tiptap/extension-text"); const { Markdown } = await import("tiptap-markdown"); const { PluginMentionNode } = await import("./PluginMentionNode"); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - const PLUGINS = [ { name: "linear", displayName: "Linear", description: "Issues" }, { name: "notion", displayName: "Notion", description: "Pages" }, diff --git a/apps/desktop/src/renderer/components/PluginMention/utils/restorePluginMentions/restorePluginMentions.test.ts b/apps/desktop/src/renderer/components/PluginMention/utils/restorePluginMentions/restorePluginMentions.test.ts index b208036a808..e77732228ec 100644 --- a/apps/desktop/src/renderer/components/PluginMention/utils/restorePluginMentions/restorePluginMentions.test.ts +++ b/apps/desktop/src/renderer/components/PluginMention/utils/restorePluginMentions/restorePluginMentions.test.ts @@ -1,9 +1,4 @@ -import { GlobalRegistrator } from "@happy-dom/global-registrator"; - -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); - -const { afterAll, describe, expect, it } = await import("bun:test"); +const { describe, expect, it } = await import("bun:test"); const { Editor } = await import("@tiptap/core"); const { default: Document } = await import("@tiptap/extension-document"); const { default: Paragraph } = await import("@tiptap/extension-paragraph"); @@ -16,10 +11,6 @@ const { FileMentionNode } = await import( const { PluginMentionNode } = await import("../../PluginMentionNode"); const { restorePluginMentions } = await import("./restorePluginMentions"); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - const linear = { name: "linear", displayName: "Linear", description: "" }; const resolve = (name: string) => (name === "linear" ? linear : null); diff --git a/apps/desktop/src/renderer/components/Redirect/Redirect.test.tsx b/apps/desktop/src/renderer/components/Redirect/Redirect.test.tsx index 89073cc943b..6cc15781eb7 100644 --- a/apps/desktop/src/renderer/components/Redirect/Redirect.test.tsx +++ b/apps/desktop/src/renderer/components/Redirect/Redirect.test.tsx @@ -1,14 +1,5 @@ -import { afterAll, afterEach, describe, expect, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; - -// happy-dom over the preloaded plain-object document — Redirect renders -// through a real TanStack router, which needs a DOM. Bun runs test files -// sequentially in one process and happy-dom's globals are process-wide, so -// we MUST unregister in afterAll (below) to restore the shared mock document -// for the other renderer suites — otherwise readonly DOM globals leak and -// break unrelated tests that assign e.g. globalThis.localStorage. -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); +import { afterEach, describe, expect, test } from "bun:test"; + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -35,10 +26,6 @@ const Redirect = TypedRedirect as (props: { }) => React.ReactElement | null; afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - /** * Build a router that renders `children` in the ALWAYS-MOUNTED root component * (so a redirect element survives the navigation it triggers), plus inert diff --git a/apps/desktop/src/renderer/components/TiptapPromptEditor/serializeEditorToText.test.ts b/apps/desktop/src/renderer/components/TiptapPromptEditor/serializeEditorToText.test.ts index 22fb571113e..5531dd7080b 100644 --- a/apps/desktop/src/renderer/components/TiptapPromptEditor/serializeEditorToText.test.ts +++ b/apps/desktop/src/renderer/components/TiptapPromptEditor/serializeEditorToText.test.ts @@ -1,10 +1,6 @@ -import { GlobalRegistrator } from "@happy-dom/global-registrator"; import type { JSONContent } from "@tiptap/core"; -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); - -const { afterAll, describe, expect, it } = await import("bun:test"); +const { describe, expect, it } = await import("bun:test"); const { Editor } = await import("@tiptap/core"); const { default: Document } = await import("@tiptap/extension-document"); const { default: Paragraph } = await import("@tiptap/extension-paragraph"); @@ -14,10 +10,6 @@ const { FileMentionNode } = await import("./FileMentionNode"); const { parseTextToEditorContent } = await import("./parseTextToEditorContent"); const { serializeEditorToText } = await import("./serializeEditorToText"); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - const linear = { name: "linear", displayName: "Linear", description: "" }; function serialize(content: JSONContent, plugins = [linear]) { diff --git a/apps/desktop/src/renderer/components/WorkspaceNameMarquee/WorkspaceNameMarquee.test.tsx b/apps/desktop/src/renderer/components/WorkspaceNameMarquee/WorkspaceNameMarquee.test.tsx index 47b1b34419a..8b7b7a304c6 100644 --- a/apps/desktop/src/renderer/components/WorkspaceNameMarquee/WorkspaceNameMarquee.test.tsx +++ b/apps/desktop/src/renderer/components/WorkspaceNameMarquee/WorkspaceNameMarquee.test.tsx @@ -1,13 +1,5 @@ -import { afterAll, afterEach, describe, expect, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, describe, expect, test } from "bun:test"; -// happy-dom over the preloaded plain-object document — the marquee measures -// scrollWidth/clientWidth through refs and a ResizeObserver, so it needs a -// real DOM. Bun runs test files sequentially in one process and happy-dom's -// globals are process-wide, so we MUST unregister in afterAll to restore the -// shared mock document for the other renderer suites. -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -16,10 +8,6 @@ const { cleanup, render } = await import("@testing-library/react"); const { WorkspaceNameMarquee } = await import("./WorkspaceNameMarquee"); afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - describe("WorkspaceNameMarquee", () => { test("renders the bare name when no prefix is given", () => { const { container } = render(); diff --git a/apps/desktop/src/renderer/hooks/host-service/useGitStatus/invalidateGitQueries.test.ts b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/invalidateGitQueries.test.ts new file mode 100644 index 00000000000..58a316cc4bf --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/invalidateGitQueries.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { invalidateGitQueries } from "./invalidateGitQueries"; + +const calls = { + getDiffPatch: [] as unknown[][], + getDiff: [] as unknown[][], + getBaseBranch: [] as unknown[][], + listCommits: [] as unknown[][], +}; + +const record = (key: keyof typeof calls) => ({ + invalidate: (...args: unknown[]) => { + calls[key].push(args); + return Promise.resolve(); + }, +}); + +const git = { + getDiffPatch: record("getDiffPatch"), + getDiff: record("getDiff"), + getBaseBranch: record("getBaseBranch"), + listCommits: record("listCommits"), +} as unknown as Parameters[0]; + +/** A `git.getDiffPatch` query as the Changes pane registers it: keyed on + * what is diffed, with the paths its cached patch covers in its data. */ +function patchQuery( + category: "against-base" | "staged" | "unstaged" | "commit", + requestedPaths: string[], +) { + const input = { workspaceId: "workspace-1", category }; + return { + queryKey: [["git", "getDiffPatch"], { input, type: "query" }], + state: { data: { kind: "patch", patch: "", requestedPaths } }, + }; +} + +function lastPatchPredicate() { + const [, filters] = calls.getDiffPatch.at(-1) ?? []; + const predicate = ( + filters as { predicate?: (query: unknown) => boolean } | undefined + )?.predicate; + if (!predicate) + throw new Error("getDiffPatch was invalidated without a predicate"); + return predicate; +} + +beforeEach(() => { + for (const entries of Object.values(calls)) entries.length = 0; +}); + +describe("invalidateGitQueries", () => { + test("invalidates commit lists and every patch after a broad git metadata change", () => { + invalidateGitQueries(git, "workspace-1", {}); + expect(calls.listCommits).toEqual([[{ workspaceId: "workspace-1" }]]); + expect(calls.getDiffPatch).toEqual([[{ workspaceId: "workspace-1" }]]); + }); + + test("does not invalidate commit lists for path-scoped worktree edits", () => { + invalidateGitQueries(git, "workspace-1", { paths: ["src/file.ts"] }); + expect(calls.listCommits).toEqual([]); + }); + + test("a worktree edit refetches the patch holding the file and not a sibling", () => { + invalidateGitQueries(git, "workspace-1", { paths: ["src/a.ts"] }); + expect(calls.getDiffPatch).toHaveLength(1); + expect(calls.getDiffPatch[0]?.[0]).toEqual({ workspaceId: "workspace-1" }); + const affected = lastPatchPredicate(); + expect(affected(patchQuery("against-base", ["src/a.ts", "src/b.ts"]))).toBe( + true, + ); + expect(affected(patchQuery("against-base", ["src/b.ts"]))).toBe(false); + expect(affected(patchQuery("staged", ["src/b.ts"]))).toBe(false); + }); + + test("a worktree edit always refetches the unstaged patch, which the file may be joining", () => { + invalidateGitQueries(git, "workspace-1", { paths: ["src/new.ts"] }); + expect(lastPatchPredicate()(patchQuery("unstaged", ["src/a.ts"]))).toBe( + true, + ); + }); +}); diff --git a/apps/desktop/src/renderer/hooks/host-service/useGitStatus/invalidateGitQueries.ts b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/invalidateGitQueries.ts new file mode 100644 index 00000000000..dfc72dc7dda --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/invalidateGitQueries.ts @@ -0,0 +1,41 @@ +import type { + GitChangedPayload, + workspaceTrpc, +} from "@superset/workspace-client"; +import { isDiffPatchQueryAffected } from "renderer/lib/diffPatchQuery"; + +type GitUtils = Pick< + ReturnType["git"], + "getDiffPatch" | "getDiff" | "getBaseBranch" | "listCommits" +>; + +export function invalidateGitQueries( + git: GitUtils, + workspaceId: string, + payload?: GitChangedPayload, +): void { + const paths = payload?.paths; + if (paths && paths.length > 0) { + // Patch keys name what is diffed, not which files, so an edit + // to an already-changed file leaves cached hunks behind while + // `loadDiffFiles` reads the file as it is now. Refetch the + // patches the write can have moved and leave the rest cached. + void git.getDiffPatch.invalidate( + { workspaceId }, + { predicate: (query) => isDiffPatchQueryAffected(query, paths) }, + ); + for (const path of paths) { + void git.getDiff.invalidate({ workspaceId, path }); + } + return; + } + void git.getDiffPatch.invalidate({ workspaceId }); + void git.getDiff.invalidate({ workspaceId }); + // Current branch may have changed (external checkout), and + // branch..base is per-branch — drop the cache so the next read + // picks up the new branch's base. + void git.getBaseBranch.invalidate({ workspaceId }); + // A metadata-only change can move HEAD (for example, an agent + // committing outside the app), so refresh cached commit lists too. + void git.listCommits.invalidate({ workspaceId }); +} diff --git a/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.test.tsx b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.test.tsx index abb3e9bef00..355fe9680fe 100644 --- a/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.test.tsx +++ b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.test.tsx @@ -1,129 +1,138 @@ -import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterAll, afterEach, expect, test } from "bun:test"; +import type { AppRouter } from "@superset/host-service"; +import { workspaceTrpc } from "@superset/workspace-client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import type { TRPCLink } from "@trpc/client"; +import { observable } from "@trpc/server/observable"; +import type { ContextType, ReactNode } from "react"; +import { HostWorkspacesContext } from "renderer/routes/_authenticated/providers/HostWorkspacesProvider"; +import { LocalHostServiceContext } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { SandboxAccessContext } from "renderer/routes/_authenticated/providers/SandboxAccessProvider"; +import { nativeWebGlobals } from "~/test-setup"; +import { useGitStatus } from "./useGitStatus"; -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; -const calls = { - getDiffPatch: [] as unknown[][], - getDiff: [] as unknown[][], - getBaseBranch: [] as unknown[][], - listCommits: [] as unknown[][], +const domEvents = { + Event: globalThis.Event, + MessageEvent: globalThis.MessageEvent, }; -let onGitChanged: ((payload?: { paths?: string[] }) => void) | undefined; - -const invalidate = - (key: keyof typeof calls) => - (...args: unknown[]) => { - calls[key].push(args); - return Promise.resolve(); - }; - -mock.module("@superset/workspace-client", () => ({ - workspaceTrpc: { - useUtils: () => ({ - git: { - getDiffPatch: { invalidate: invalidate("getDiffPatch") }, - getDiff: { invalidate: invalidate("getDiff") }, - getBaseBranch: { invalidate: invalidate("getBaseBranch") }, - listCommits: { invalidate: invalidate("listCommits") }, - }, - }), - git: { - getBaseBranch: { useQuery: () => ({ data: { baseBranch: "main" } }) }, - getStatus: { - useQuery: () => ({ data: undefined, refetch: () => Promise.resolve() }), - }, - }, - }, -})); - -mock.module("../useWorkspaceEvent", () => ({ - useWorkspaceEvent: ( - _event: string, - _workspaceId: string, - callback: (payload?: { paths?: string[] }) => void, - ) => { - onGitChanged = callback; - }, -})); - -const { act, cleanup, render } = await import("@testing-library/react"); -const { useGitStatus } = await import("./useGitStatus"); - -function Probe() { - useGitStatus("workspace-1"); - return null; -} - -/** A `git.getDiffPatch` query as the Changes pane registers it: keyed on - * what is diffed, with the paths its cached patch covers in its data. */ -function patchQuery( - category: "against-base" | "staged" | "unstaged" | "commit", - requestedPaths: string[], -) { - const input = { workspaceId: "workspace-1", category }; - return { - queryKey: [["git", "getDiffPatch"], { input, type: "query" }], - state: { data: { kind: "patch", patch: "", requestedPaths } }, - }; -} +Object.assign(globalThis, { + Event: nativeWebGlobals.Event, + MessageEvent: nativeWebGlobals.MessageEvent, +}); +afterAll(() => { + Object.assign(globalThis, domEvents); +}); -function lastPatchPredicate() { - const [, filters] = calls.getDiffPatch.at(-1) ?? []; - const predicate = ( - filters as { predicate?: (query: unknown) => boolean } | undefined - )?.predicate; - if (!predicate) - throw new Error("getDiffPatch was invalidated without a predicate"); - return predicate; -} +const WORKSPACE_ID = "workspace-1"; +const MACHINE_ID = "this-machine"; -beforeEach(() => { - for (const entries of Object.values(calls)) entries.length = 0; - onGitChanged = undefined; -}); +let stopHost: (() => void) | undefined; -afterAll(async () => { +afterEach(() => { cleanup(); - if (!alreadyRegistered) await GlobalRegistrator.unregister(); + stopHost?.(); + stopHost = undefined; }); -describe("useGitStatus git:changed invalidation", () => { - test("invalidates commit lists and every patch after a broad git metadata change", async () => { - render(); - await act(async () => onGitChanged?.({})); - expect(calls.listCommits).toEqual([[{ workspaceId: "workspace-1" }]]); - expect(calls.getDiffPatch).toEqual([[{ workspaceId: "workspace-1" }]]); +function startHost() { + const commands: string[] = []; + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(request, bunServer) { + if (bunServer.upgrade(request)) return; + return new Response(null, { status: 404 }); + }, + websocket: { + message(socket, raw) { + const command = JSON.parse(String(raw)) as { type: string }; + commands.push(command.type); + if (command.type !== "git:watch") return; + socket.send( + JSON.stringify({ type: "git:changed", workspaceId: WORKSPACE_ID }), + ); + }, + }, }); + stopHost = () => void server.stop(true); + return { commands, url: `http://127.0.0.1:${server.port}` }; +} - test("does not invalidate commit lists for path-scoped worktree edits", async () => { - render(); - await act(async () => onGitChanged?.({ paths: ["src/file.ts"] })); - expect(calls.listCommits).toEqual([]); - }); +function renderGitStatus(hostUrl: string) { + const asked: string[] = []; + const link: TRPCLink = () => (call) => + observable((observer) => { + asked.push(call.op.path); + observer.next({ + result: { + data: + call.op.path === "git.getBaseBranch" ? { baseBranch: null } : {}, + }, + }); + observer.complete(); + }); + const queryClient = new QueryClient(); + queryClient.setQueryData(["relay-endpoint"], { url: hostUrl }); + const wrapper = ({ children }: { children: ReactNode }) => ( + + + > + } + > + > + } + > + + {children} + + + + + + ); + const view = renderHook(() => useGitStatus(WORKSPACE_ID), { wrapper }); + const statusReads = () => + asked.filter((path) => path === "git.getStatus").length; + return { statusReads, view }; +} - test("a worktree edit refetches the patch holding the file and not a sibling", async () => { - render(); - await act(async () => onGitChanged?.({ paths: ["src/a.ts"] })); - expect(calls.getDiffPatch).toHaveLength(1); - expect(calls.getDiffPatch[0]?.[0]).toEqual({ workspaceId: "workspace-1" }); - const affected = lastPatchPredicate(); - expect(affected(patchQuery("against-base", ["src/a.ts", "src/b.ts"]))).toBe( - true, - ); - expect(affected(patchQuery("against-base", ["src/b.ts"]))).toBe(false); - expect(affected(patchQuery("staged", ["src/b.ts"]))).toBe(false); - }); +test("watches the workspace's host for git changes and refetches status on one", async () => { + const host = startHost(); + const { statusReads, view } = renderGitStatus(host.url); - test("a worktree edit always refetches the unstaged patch, which the file may be joining", async () => { - render(); - await act(async () => onGitChanged?.({ paths: ["src/new.ts"] })); - expect(lastPatchPredicate()(patchQuery("unstaged", ["src/a.ts"]))).toBe( - true, - ); - }); + await waitFor(() => expect(host.commands).toContain("git:watch")); + await waitFor(() => expect(statusReads()).toBeGreaterThanOrEqual(2)); + + view.unmount(); + await waitFor(() => expect(host.commands).toContain("git:unwatch")); }); diff --git a/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts index 9f0ba92222f..2e2f0d074b9 100644 --- a/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts @@ -3,9 +3,9 @@ import { workspaceTrpc, } from "@superset/workspace-client"; import { useCallback, useEffect, useMemo } from "react"; -import { isDiffPatchQueryAffected } from "renderer/lib/diffPatchQuery"; import { useWorkspaceEvent } from "../useWorkspaceEvent"; import { createTrailingRefreshScheduler } from "./createTrailingRefreshScheduler"; +import { invalidateGitQueries } from "./invalidateGitQueries"; const GIT_STATUS_STALE_TIME_MS = 5_000; // Status snapshots scale with changed-file count, so keep revisits warm without @@ -66,30 +66,7 @@ export function useGitStatus(workspaceId: string, enabled = true) { const invalidate = useCallback( (payload?: GitChangedPayload) => { void refreshScheduler.request(); - const paths = payload?.paths; - if (paths && paths.length > 0) { - // Patch keys name what is diffed, not which files, so an edit - // to an already-changed file leaves cached hunks behind while - // `loadDiffFiles` reads the file as it is now. Refetch the - // patches the write can have moved and leave the rest cached. - void utils.git.getDiffPatch.invalidate( - { workspaceId }, - { predicate: (query) => isDiffPatchQueryAffected(query, paths) }, - ); - for (const path of paths) { - void utils.git.getDiff.invalidate({ workspaceId, path }); - } - } else { - void utils.git.getDiffPatch.invalidate({ workspaceId }); - void utils.git.getDiff.invalidate({ workspaceId }); - // Current branch may have changed (external checkout), and - // branch..base is per-branch — drop the cache so the next read - // picks up the new branch's base. - void utils.git.getBaseBranch.invalidate({ workspaceId }); - // A metadata-only change can move HEAD (for example, an agent - // committing outside the app), so refresh cached commit lists too. - void utils.git.listCommits.invalidate({ workspaceId }); - } + invalidateGitQueries(utils.git, workspaceId, payload); }, [refreshScheduler, utils, workspaceId], ); diff --git a/apps/desktop/src/renderer/hooks/ports/usePortKillActions/confirmClosePorts.test.ts b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/confirmClosePorts.test.ts index 57a0f2314e7..1e82d608037 100644 --- a/apps/desktop/src/renderer/hooks/ports/usePortKillActions/confirmClosePorts.test.ts +++ b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/confirmClosePorts.test.ts @@ -1,29 +1,17 @@ -import { beforeEach, describe, expect, it, mock } from "bun:test"; -import { initI18n } from "@superset/i18n"; - -// Alert copy now renders through i18n._; activate the default locale so the -// descriptors fall back to their English messages. -initI18n(); - +import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; import type { AlertOptions } from "@superset/ui/atoms/Alert"; +import { useTerminalCloseConfirmStore } from "renderer/stores/terminal-close-confirm/store"; +import { confirmClosePorts } from "./confirmClosePorts"; -let suppressed = false; -const suppress = mock(() => { - suppressed = true; -}); +const suppressedBefore = useTerminalCloseConfirmStore.getState().suppressed; -mock.module("renderer/stores/terminal-close-confirm/store", () => ({ - useTerminalCloseConfirmStore: { - getState: () => ({ suppressed, suppress }), - }, -})); - -const { confirmClosePorts } = await import("./confirmClosePorts"); +afterAll(() => { + useTerminalCloseConfirmStore.setState({ suppressed: suppressedBefore }); +}); describe("confirmClosePorts", () => { beforeEach(() => { - suppressed = false; - suppress.mockClear(); + useTerminalCloseConfirmStore.getState().reset(); }); it("confirms a single port with running-process copy", async () => { @@ -88,8 +76,7 @@ describe("confirmClosePorts", () => { checkboxChecked: true, }); expect(await confirmation).toBe(true); - expect(suppressed).toBe(true); - expect(suppress).toHaveBeenCalledTimes(1); + expect(useTerminalCloseConfirmStore.getState().suppressed).toBe(true); expect(await confirmClosePorts(1, showAlert)).toBe(true); expect(showAlert).toHaveBeenCalledTimes(1); diff --git a/apps/desktop/src/renderer/hooks/ports/usePortKillActions/killPortTarget.test.ts b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/killPortTarget.test.ts index bcd5a7320b2..20b1fe3e5dc 100644 --- a/apps/desktop/src/renderer/hooks/ports/usePortKillActions/killPortTarget.test.ts +++ b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/killPortTarget.test.ts @@ -1,39 +1,34 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; -import { initI18n } from "@superset/i18n"; - -// Alert copy now renders through i18n._; activate the default locale so the -// descriptors fall back to their English messages. -initI18n(); +import { killPortTarget } from "./killPortTarget"; const remoteKillMock = mock(async () => ({ success: true })); - -mock.module("renderer/lib/host-service-client", () => ({ - getHostServiceClientByUrl: () => ({ - ports: { - kill: { - mutate: remoteKillMock, - }, - }, - }), -})); - -const { killPortTarget } = await import("./killPortTarget"); +const remoteClientUrls: string[] = []; +const getRemoteClient = (hostUrl: string) => { + remoteClientUrls.push(hostUrl); + return { ports: { kill: { mutate: remoteKillMock } } }; +}; describe("killPortTarget", () => { beforeEach(() => { remoteKillMock.mockClear(); + remoteClientUrls.length = 0; remoteKillMock.mockResolvedValue({ success: true }); }); it("routes host-owned ports through the host-service client", async () => { - const result = await killPortTarget({ - workspaceId: "workspace-1", - terminalId: "terminal-1", - port: 5173, - hostUrl: "http://host-service", - }); + const result = await killPortTarget( + { + workspaceId: "workspace-1", + terminalId: "terminal-1", + port: 5173, + hostUrl: "http://host-service", + }, + undefined, + getRemoteClient, + ); expect(result).toEqual({ success: true }); + expect(remoteClientUrls).toEqual(["http://host-service"]); expect(remoteKillMock).toHaveBeenCalledWith({ workspaceId: "workspace-1", terminalId: "terminal-1", diff --git a/apps/desktop/src/renderer/hooks/ports/usePortKillActions/killPortTarget.ts b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/killPortTarget.ts index 5d33ca048a1..24261616212 100644 --- a/apps/desktop/src/renderer/hooks/ports/usePortKillActions/killPortTarget.ts +++ b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/killPortTarget.ts @@ -17,6 +17,10 @@ export type LocalPortKill = (input: { port: number; }) => Promise; +type RemotePortKillClient = (hostUrl: string) => { + ports: { kill: { mutate: LocalPortKill } }; +}; + function toErrorMessage(error: unknown): string { if (error instanceof Error) return error.message; return String(error); @@ -25,6 +29,7 @@ function toErrorMessage(error: unknown): string { export async function killPortTarget( target: PortKillTarget, localKill?: LocalPortKill, + getRemoteClient: RemotePortKillClient = getHostServiceClientByUrl, ): Promise { const payload = { workspaceId: target.workspaceId, @@ -34,9 +39,7 @@ export async function killPortTarget( try { if (target.hostUrl) { - return await getHostServiceClientByUrl(target.hostUrl).ports.kill.mutate( - payload, - ); + return await getRemoteClient(target.hostUrl).ports.kill.mutate(payload); } if (!localKill) { diff --git a/apps/desktop/src/renderer/hooks/useAgentLaunchPreferences/useAgentLaunchPreferences.test.ts b/apps/desktop/src/renderer/hooks/useAgentLaunchPreferences/useAgentLaunchPreferences.test.ts index 9e8afc06e4f..500bb647e42 100644 --- a/apps/desktop/src/renderer/hooks/useAgentLaunchPreferences/useAgentLaunchPreferences.test.ts +++ b/apps/desktop/src/renderer/hooks/useAgentLaunchPreferences/useAgentLaunchPreferences.test.ts @@ -1,5 +1,4 @@ -import { afterAll, afterEach, beforeEach, expect, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, beforeEach, expect, test } from "bun:test"; import { getAgentEffortSupport, getAgentModelSupport, @@ -8,8 +7,6 @@ import { useAgentEffortPreference } from "../useAgentEffortPreference/useAgentEf import { useAgentModelPreference } from "../useAgentModelPreference/useAgentModelPreference"; import { useAgentLaunchPreferences } from "./useAgentLaunchPreferences"; -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); const { act, cleanup, renderHook } = await import("@testing-library/react"); const options = { agentStorageKey: "test-agent", @@ -20,10 +17,6 @@ const options = { }; beforeEach(() => window.localStorage.clear()); afterEach(cleanup); -afterAll(() => { - if (!alreadyRegistered) GlobalRegistrator.unregister(); -}); - test("remembers the explicitly selected agent across creations", () => { const first = renderHook(() => useAgentLaunchPreferences(options)); act(() => first.result.current.setSelectedAgent("cursor")); diff --git a/apps/desktop/src/renderer/hooks/useAgentSessionPlacement/useAgentSessionPlacement.test.tsx b/apps/desktop/src/renderer/hooks/useAgentSessionPlacement/useAgentSessionPlacement.test.tsx index b9a6be66835..1c6c9881b40 100644 --- a/apps/desktop/src/renderer/hooks/useAgentSessionPlacement/useAgentSessionPlacement.test.tsx +++ b/apps/desktop/src/renderer/hooks/useAgentSessionPlacement/useAgentSessionPlacement.test.tsx @@ -1,8 +1,5 @@ -import { afterAll, afterEach, beforeEach, expect, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, beforeEach, expect, test } from "bun:test"; -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); const { act, cleanup, renderHook } = await import("@testing-library/react"); const { setAgentSessionPlacement, useAgentSessionPlacement } = await import( "./useAgentSessionPlacement" @@ -10,10 +7,6 @@ const { setAgentSessionPlacement, useAgentSessionPlacement } = await import( beforeEach(() => window.localStorage.clear()); afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - test("preserves the existing placement preference", () => { window.localStorage.setItem("lastSelectedDiffCommentPlacement", "new-tab"); const { result } = renderHook(useAgentSessionPlacement); diff --git a/apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.test.tsx b/apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.test.tsx index 1dbab41a627..cb78708d2ad 100644 --- a/apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.test.tsx +++ b/apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.test.tsx @@ -1,25 +1,5 @@ -import { afterAll, describe, expect, mock, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; -import { renderToStaticMarkup } from "react-dom/server"; - -// completion.ts and the zustand persist store read the global localStorage; -// give them a working in-memory one before anything imports them. -const backing = new Map(); -globalThis.localStorage = { - getItem: (key: string) => backing.get(key) ?? null, - setItem: (key: string, value: string) => void backing.set(key, value), - removeItem: (key: string) => void backing.delete(key), - clear: () => backing.clear(), - key: (index: number) => [...backing.keys()][index] ?? null, - get length() { - return backing.size; - }, -} as Storage; - -// happy-dom on top, for the one mounted test that needs a live window event; -// it replaces the shim above with an equivalent working localStorage. -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); +import { afterEach, describe, expect, test } from "bun:test"; + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -30,81 +10,66 @@ const V1_ERA_CREATED_AT = new Date("2026-01-01T00:00:00Z"); // puts this account on v1. const V2_ERA_CREATED_AT = new Date("2026-08-01T00:00:00Z"); -// Mutable session the auth-client mock serves; tests swap the org per case -// because isV1MigrationCompleteAtBoot caches the first read per org. -let activeOrganizationId = "org-none"; -let createdAt: Date = V1_ERA_CREATED_AT; -// renderToStaticMarkup reads zustand's initial state, not live setState, so -// the override store is mocked with a mutable value instead. -let optInV2: boolean | null = null; - -// The forced-flip backstop keys off the V1_FORCED_FLIP_VERSION const (null in -// source), so it can't be armed through real state. Stub only that function; -// everything else (marker reads/writes) stays the real module. The version -// comparison itself is covered by isForcedFlipVersion tests in v1-migration. -const realCompletion = await import("renderer/lib/v1-migration/completion"); -let forcedFlipActive = false; -mock.module("renderer/lib/v1-migration/completion", () => ({ - ...realCompletion, - isV1ForcedFlipActive: () => forcedFlipActive, -})); - -mock.module("renderer/stores/v2-local-override", () => ({ - useV2LocalOverrideStore: ( - selector: (state: { - optInV2: boolean | null; - setOptInV2: (value: boolean) => void; - }) => T, - ): T => selector({ optInV2, setOptInV2: () => {} }), -})); -// Spread the real module so files loaded later that import its other exports -// (getAuthToken, ensureFreshJwt, ...) keep working — mock.module replaces the -// module for the whole test process, and file load order varies by platform. -const realAuthClient = await import("renderer/lib/auth-client"); -mock.module("renderer/lib/auth-client", () => ({ - ...realAuthClient, - authClient: { - ...realAuthClient.authClient, - useSession: () => ({ - data: { - session: { activeOrganizationId }, - user: { createdAt, onboardedAt: new Date() }, - }, - }), - }, -})); -const realEnv = await import("renderer/env.renderer"); -mock.module("renderer/env.renderer", () => ({ - ...realEnv, - env: { ...realEnv.env, NODE_ENV: "production" }, -})); - -const { useIsV1FlipLocked, useIsV2CloudEnabled } = await import( - "./useIsV2CloudEnabled" -); +const { + useIsV1FlipLocked, + useIsV1FlipLockedFor, + useIsV2CloudEnabled, + useIsV2CloudEnabledFor, + useIsV2OnlyUser, +} = await import("./useIsV2CloudEnabled"); +const { renderToStaticMarkup } = await import("react-dom/server"); +const { authClient } = await import("renderer/lib/auth-client"); const { markV1MigrationComplete } = await import( "renderer/lib/v1-migration/completion" ); +const { useV2LocalOverrideStore } = await import( + "renderer/stores/v2-local-override" +); const { act, cleanup, render } = await import("@testing-library/react"); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); +const optInV2Before = useV2LocalOverrideStore.getState().optInV2; + +afterEach(() => { + cleanup(); + useV2LocalOverrideStore.setState({ optInV2: optInV2Before }); + localStorage.clear(); }); +interface ProbeProps { + organizationId: string; + userCreatedAt?: Date; + forcedFlipActive?: boolean; +} -function Probe() { - const locked = useIsV1FlipLocked(); - const v2 = useIsV2CloudEnabled(); +function Probe({ + organizationId, + userCreatedAt = V1_ERA_CREATED_AT, + forcedFlipActive, +}: ProbeProps) { + const locked = useIsV1FlipLockedFor({ organizationId, forcedFlipActive }); + const v2 = useIsV2CloudEnabledFor({ + organizationId, + userCreatedAt, + forcedFlipActive, + }); return
; } -function readProbe(orgId: string, optIn: boolean | null) { - activeOrganizationId = orgId; - optInV2 = optIn; - const markup = renderToStaticMarkup(); - return { - locked: markup.includes('data-locked="true"'), - v2: markup.includes('data-v2="true"'), +function readProbe( + organizationId: string, + optInV2: boolean | null, + props: Omit = {}, +) { + useV2LocalOverrideStore.setState({ optInV2 }); + const { container, unmount } = render( + , + ); + const probe = container.firstElementChild; + const state = { + locked: probe?.getAttribute("data-locked") === "true", + v2: probe?.getAttribute("data-v2") === "true", }; + unmount(); + return state; } describe("useIsV1FlipLocked", () => { @@ -127,15 +92,10 @@ describe("useIsV1FlipLocked", () => { }); test("forced-flip backstop locks the flip without a marker", () => { - forcedFlipActive = true; - try { - expect(readProbe("org-forced", false)).toEqual({ - locked: true, - v2: true, - }); - } finally { - forcedFlipActive = false; - } + expect(readProbe("org-forced", false, { forcedFlipActive: true })).toEqual({ + locked: true, + v2: true, + }); }); test("completion mid-session locks the flip at once; the surface waits for the next launch", () => { @@ -163,44 +123,87 @@ describe("useIsV1FlipLocked", () => { }); test("completion mid-session freezes the surface: a later opt-out does not snap back", () => { - createdAt = V2_ERA_CREATED_AT; - try { - expect(readProbe("org-freeze-out", null)).toEqual({ - locked: false, - v2: true, - }); - markV1MigrationComplete("org-freeze-out"); - expect(readProbe("org-freeze-out", null)).toEqual({ - locked: true, - v2: true, - }); - expect(readProbe("org-freeze-out", false)).toEqual({ - locked: true, - v2: true, - }); - } finally { - createdAt = V1_ERA_CREATED_AT; - } + const v2Era = { userCreatedAt: V2_ERA_CREATED_AT }; + expect(readProbe("org-freeze-out", null, v2Era)).toEqual({ + locked: false, + v2: true, + }); + markV1MigrationComplete("org-freeze-out"); + expect(readProbe("org-freeze-out", null, v2Era)).toEqual({ + locked: true, + v2: true, + }); + expect(readProbe("org-freeze-out", false, v2Era)).toEqual({ + locked: true, + v2: true, + }); }); test("a mounted hook locks the moment completion fires, without a remount", async () => { - activeOrganizationId = "org-live"; - optInV2 = null; - const { container } = render(); + const { container } = render(); expect(container.querySelector('[data-locked="true"]')).toBeNull(); await act(async () => { markV1MigrationComplete("org-live"); }); expect(container.querySelector('[data-locked="true"]')).not.toBeNull(); expect(container.querySelector('[data-v2="false"]')).not.toBeNull(); - cleanup(); }); test("no active org: not locked", () => { - // Guards the `!organizationId → false` path in the marker read. - activeOrganizationId = ""; - optInV2 = null; - const markup = renderToStaticMarkup(); - expect(markup.includes('data-locked="true"')).toBe(false); + expect(readProbe("", null).locked).toBe(false); + }); +}); + +describe("session-reading hooks", () => { + // Rendered statically: a mounted useSession starts a real get-session + // request, and better-auth keeps the fetch it saw when the client was made. + const sessionAtom = authClient.$store.atoms.session as unknown as { + get: () => object; + set: (value: object) => void; + }; + const sessionBefore = sessionAtom.get(); + + afterEach(() => { + sessionAtom.set(sessionBefore); + }); + + function SessionProbe() { + const v2Only = useIsV2OnlyUser(); + const locked = useIsV1FlipLocked(); + const v2 = useIsV2CloudEnabled(); + return <>{JSON.stringify({ v2Only, locked, v2 })}; + } + + function readSessionProbe(activeOrganizationId: string, createdAt: Date) { + sessionAtom.set({ + ...sessionBefore, + data: { session: { activeOrganizationId }, user: { createdAt } }, + isPending: false, + }); + return JSON.parse( + renderToStaticMarkup().replaceAll(""", '"'), + ); + } + + test("take the signup cohort from the session user", () => { + expect(readSessionProbe("org-session-v1", V1_ERA_CREATED_AT)).toEqual({ + v2Only: false, + locked: false, + v2: false, + }); + expect(readSessionProbe("org-session-v2", V2_ERA_CREATED_AT)).toEqual({ + v2Only: true, + locked: false, + v2: true, + }); + }); + + test("take the migration marker from the session's active organization", () => { + markV1MigrationComplete("org-session-marked"); + expect(readSessionProbe("org-session-marked", V1_ERA_CREATED_AT)).toEqual({ + v2Only: false, + locked: true, + v2: true, + }); }); }); diff --git a/apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.ts b/apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.ts index ecabed46af7..93ca6923fb1 100644 --- a/apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.ts +++ b/apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.ts @@ -38,6 +38,12 @@ export function useIsV2OnlyUser(): boolean { return isV2OnlyUser(session?.user?.createdAt); } +interface V2SurfaceInput { + organizationId: string | null | undefined; + userCreatedAt: Date | string | null | undefined; + forcedFlipActive?: boolean; +} + /** * True when v2 is locked on for this machine (org migration completed, or * the forced-flip backstop is active). The optInV2 override has no effect in @@ -46,21 +52,39 @@ export function useIsV2OnlyUser(): boolean { */ export function useIsV1FlipLocked(): boolean { const { data: session } = authClient.useSession(); - const organizationId = session?.session?.activeOrganizationId; + return useIsV1FlipLockedFor({ + organizationId: session?.session?.activeOrganizationId, + }); +} + +export function useIsV1FlipLockedFor({ + organizationId, + forcedFlipActive = isV1ForcedFlipActive(), +}: Pick): boolean { const completedThisSession = useIsV1MigrationCompleteNow(organizationId); return ( isV1MigrationCompleteAtBoot(organizationId) || completedThisSession || - isV1ForcedFlipActive() + forcedFlipActive ); } /** Returns whether v2 is currently active for this user. */ export function useIsV2CloudEnabled(): boolean { - const v2Only = useIsV2OnlyUser(); - const optInV2 = useV2LocalOverrideStore((s) => s.optInV2); const { data: session } = authClient.useSession(); - const organizationId = session?.session?.activeOrganizationId; + return useIsV2CloudEnabledFor({ + organizationId: session?.session?.activeOrganizationId, + userCreatedAt: session?.user?.createdAt, + }); +} + +export function useIsV2CloudEnabledFor({ + organizationId, + userCreatedAt, + forcedFlipActive = isV1ForcedFlipActive(), +}: V2SurfaceInput): boolean { + const v2Only = isV2OnlyUser(userCreatedAt); + const optInV2 = useV2LocalOverrideStore((s) => s.optInV2); const completedThisSession = useIsV1MigrationCompleteNow(organizationId); // Migrate-then-flip: once this machine's v1 data has fully migrated for // the org, v2 wins — including over an explicit opt-out (D5, sunset). @@ -71,7 +95,7 @@ export function useIsV2CloudEnabled(): boolean { // Backstop: past the forced-flip version, machines whose migration never // completed (persistently failing entities) flip anyway; the headless // migrator keeps retrying post-flip and manual import remains available. - if (isV1ForcedFlipActive()) { + if (forcedFlipActive) { return true; } let effectiveOptIn = optInV2; diff --git a/apps/desktop/src/renderer/hooks/useOpenNewWorkspace/useOpenNewWorkspace.test.ts b/apps/desktop/src/renderer/hooks/useOpenNewWorkspace/useOpenNewWorkspace.test.ts index af0de8f689a..a251d33f79e 100644 --- a/apps/desktop/src/renderer/hooks/useOpenNewWorkspace/useOpenNewWorkspace.test.ts +++ b/apps/desktop/src/renderer/hooks/useOpenNewWorkspace/useOpenNewWorkspace.test.ts @@ -1,69 +1,34 @@ -import { afterAll, afterEach, beforeEach, expect, mock, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, beforeEach, expect, mock, test } from "bun:test"; +import type { useNavigate } from "@tanstack/react-router"; +import { useNewWorkspaceDraftStore } from "renderer/stores/new-workspace-draft"; +import { useNewWorkspaceModalStore } from "renderer/stores/new-workspace-modal"; +import { useV2WorkspaceCreateDefaultsStore } from "renderer/stores/v2-workspace-create-defaults"; +import { + openNewSession, + openNewWorkspace, + openNewWorkspaceForLocalProject, +} from "./useOpenNewWorkspace"; const navigate = mock(() => Promise.resolve()); -let v2Enabled = true; -mock.module("renderer/hooks/useIsV2CloudEnabled", () => ({ - useIsV2CloudEnabled: () => v2Enabled, -})); -mock.module( - "renderer/routes/_authenticated/providers/LocalHostServiceProvider", - () => ({ - useLocalHostService: () => ({ machineId: "this-machine" }), - }), -); +const MACHINE_ID = "this-machine"; +const v2 = { + isV2CloudEnabled: true, + machineId: MACHINE_ID, + navigate: navigate as unknown as ReturnType, +}; -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); -const { - act, - cleanup, - renderHook: renderHookWithOptions, -} = await import("@testing-library/react"); -const { - createRootRoute, - createRouter, - createMemoryHistory, - RouterContextProvider, -} = await import("@tanstack/react-router"); -const { createElement } = await import("react"); -function renderHook(hook: () => Result) { - const router = createRouter({ - routeTree: createRootRoute(), - history: createMemoryHistory({ initialEntries: ["/"] }), - }); - router.navigate = navigate; - return renderHookWithOptions(hook, { - wrapper: ({ children }) => - createElement(RouterContextProvider, { router, children }), - }); +function resetStores() { + useNewWorkspaceDraftStore.getState().updateDraft({ hostId: null }); + useNewWorkspaceDraftStore.getState().resetDraft(); + useV2WorkspaceCreateDefaultsStore.getState().setLastHostId(null); + useNewWorkspaceModalStore.getState().closeModal(); } -const { useNewWorkspaceDraftStore } = await import( - "renderer/stores/new-workspace-draft" -); -const { useNewWorkspaceModalStore } = await import( - "renderer/stores/new-workspace-modal" -); -const { useV2WorkspaceCreateDefaultsStore } = await import( - "renderer/stores/v2-workspace-create-defaults" -); -const { - useOpenNewSession, - useOpenNewWorkspace, - useOpenNewWorkspaceForLocalProject, -} = await import("./useOpenNewWorkspace"); beforeEach(() => { navigate.mockClear(); - v2Enabled = true; - useNewWorkspaceDraftStore.getState().updateDraft({ hostId: null }); - useNewWorkspaceDraftStore.getState().resetDraft(); - useV2WorkspaceCreateDefaultsStore.getState().setLastHostId(null); -}); -afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); + resetStores(); }); +afterEach(resetStores); test.each([ "cloud", @@ -75,8 +40,7 @@ test.each([ prompt: "Keep my prompt", checkout: "local", }); - const { result } = renderHook(useOpenNewWorkspaceForLocalProject); - act(() => result.current("new-project")); + openNewWorkspaceForLocalProject(v2, "new-project"); expect(navigate).toHaveBeenCalledWith( expect.objectContaining({ to: "/new-workspace", @@ -91,21 +55,19 @@ test.each([ checkout: "local", }); useNewWorkspaceDraftStore.getState().updateDraft({ hostId: "cloud" }); - act(() => result.current("second-project")); + openNewWorkspaceForLocalProject(v2, "second-project"); expect(useNewWorkspaceDraftStore.getState().hostId).toBe("this-machine"); }); test("ordinary new workspace navigation preserves the selected remote host", () => { useNewWorkspaceDraftStore.getState().updateDraft({ hostId: "other-machine" }); - const { result } = renderHook(useOpenNewWorkspace); - act(() => result.current("existing-project")); + openNewWorkspace(v2, "existing-project"); expect(useNewWorkspaceDraftStore.getState().hostId).toBe("other-machine"); }); test("project handoff leaves the cloud host for the local machine", () => { useNewWorkspaceDraftStore.getState().updateDraft({ hostId: "cloud" }); - const { result } = renderHook(useOpenNewWorkspace); - act(() => result.current("project-a")); + openNewWorkspace(v2, "project-a"); expect(navigate).toHaveBeenCalledWith( expect.objectContaining({ search: { projectId: "project-a", host: "this-machine" }, @@ -119,8 +81,7 @@ test("project handoff leaves the cloud host for the local machine", () => { test("project handoff leaves a remembered cloud host before the page restores it", () => { useV2WorkspaceCreateDefaultsStore.getState().setLastHostId("cloud"); - const { result } = renderHook(useOpenNewWorkspace); - act(() => result.current("project-a")); + openNewWorkspace(v2, "project-a"); expect(navigate).toHaveBeenCalledWith( expect.objectContaining({ search: { projectId: "project-a", host: "this-machine" }, @@ -131,8 +92,7 @@ test("project handoff leaves a remembered cloud host before the page restores it test("project handoff keeps a draft remote host over a remembered cloud host", () => { useV2WorkspaceCreateDefaultsStore.getState().setLastHostId("cloud"); useNewWorkspaceDraftStore.getState().updateDraft({ hostId: "other-machine" }); - const { result } = renderHook(useOpenNewWorkspace); - act(() => result.current("project-a")); + openNewWorkspace(v2, "project-a"); expect(navigate).toHaveBeenCalledWith( expect.objectContaining({ search: { projectId: "project-a", host: "other-machine" }, @@ -144,8 +104,7 @@ test("project handoff keeps a draft remote host over a remembered cloud host", ( test("session handoff keeps a draft remote host over a remembered cloud host", () => { useV2WorkspaceCreateDefaultsStore.getState().setLastHostId("cloud"); useNewWorkspaceDraftStore.getState().updateDraft({ hostId: "other-machine" }); - const { result } = renderHook(useOpenNewSession); - act(() => result.current()); + openNewSession(v2); expect(navigate).toHaveBeenCalledWith( expect.objectContaining({ search: { session: true, host: "other-machine" }, @@ -156,15 +115,13 @@ test("session handoff keeps a draft remote host over a remembered cloud host", ( test("new workspace without a project keeps the cloud host", () => { useNewWorkspaceDraftStore.getState().updateDraft({ hostId: "cloud" }); - const { result } = renderHook(useOpenNewWorkspace); - act(() => result.current()); + openNewWorkspace(v2); expect(useNewWorkspaceDraftStore.getState().hostId).toBe("cloud"); }); test("session handoff leaves the cloud host and selects the session", () => { useNewWorkspaceDraftStore.getState().updateDraft({ hostId: "cloud" }); - const { result } = renderHook(useOpenNewSession); - act(() => result.current()); + openNewSession(v2); expect(navigate).toHaveBeenCalledWith( expect.objectContaining({ search: { session: true, host: "this-machine" }, @@ -178,8 +135,7 @@ test("session handoff leaves the cloud host and selects the session", () => { test("session handoff re-selects the session when the URL already asks for it", () => { useNewWorkspaceDraftStore.getState().selectProject("project-a"); - const { result } = renderHook(useOpenNewSession); - act(() => result.current()); + openNewSession(v2); expect(useNewWorkspaceDraftStore.getState()).toMatchObject({ isSession: true, selectedProjectId: null, @@ -187,9 +143,10 @@ test("session handoff re-selects the session when the URL already asks for it", }); test("v1 local project handoff still opens the project modal", () => { - v2Enabled = false; - const { result } = renderHook(useOpenNewWorkspaceForLocalProject); - act(() => result.current("v1-project")); + openNewWorkspaceForLocalProject( + { ...v2, isV2CloudEnabled: false }, + "v1-project", + ); expect(navigate).not.toHaveBeenCalled(); expect(useNewWorkspaceModalStore.getState()).toMatchObject({ isOpen: true, diff --git a/apps/desktop/src/renderer/hooks/useOpenNewWorkspace/useOpenNewWorkspace.ts b/apps/desktop/src/renderer/hooks/useOpenNewWorkspace/useOpenNewWorkspace.ts index 2a37dcf10b3..a38317849e7 100644 --- a/apps/desktop/src/renderer/hooks/useOpenNewWorkspace/useOpenNewWorkspace.ts +++ b/apps/desktop/src/renderer/hooks/useOpenNewWorkspace/useOpenNewWorkspace.ts @@ -7,6 +7,58 @@ import { useNewWorkspaceDraftStore } from "renderer/stores/new-workspace-draft"; import { useNewWorkspaceModalStore } from "renderer/stores/new-workspace-modal"; import { useV2WorkspaceCreateDefaultsStore } from "renderer/stores/v2-workspace-create-defaults"; +interface NewWorkspaceHandoff { + isV2CloudEnabled: boolean; + machineId: string | null | undefined; + navigate: ReturnType; +} + +export function openNewWorkspace( + { isV2CloudEnabled, machineId, navigate }: NewWorkspaceHandoff, + projectId?: string | null, + requestedHostId?: string, +) { + if (!isV2CloudEnabled) { + useNewWorkspaceModalStore.getState().openModal(projectId ?? undefined); + return; + } + const hostId = + requestedHostId ?? + (projectId ? resolveHandoffHostId(machineId) : undefined); + if (hostId) { + useNewWorkspaceDraftStore.getState().updateDraft({ hostId }); + } + if (projectId) { + useNewWorkspaceDraftStore.getState().selectProject(projectId); + } + void navigate({ + to: "/new-workspace", + search: + projectId || hostId + ? { projectId: projectId ?? undefined, host: hostId } + : undefined, + }); +} + +export function openNewSession({ + isV2CloudEnabled, + machineId, + navigate, +}: NewWorkspaceHandoff) { + if (!isV2CloudEnabled) { + useNewWorkspaceModalStore.getState().openSessionModal(); + return; + } + const hostId = resolveHandoffHostId(machineId); + const draftStore = useNewWorkspaceDraftStore.getState(); + if (hostId) draftStore.updateDraft({ hostId }); + draftStore.selectSession(); + void navigate({ + to: "/new-workspace", + search: { session: true, host: hostId }, + }); +} + /** * Opens the new-workspace surface. v2 has no modal — the create surface is * the `/new-workspace` route — so this navigates there. v1 installs still @@ -18,28 +70,12 @@ export function useOpenNewWorkspace() { const { machineId } = useLocalHostService(); return useCallback( - (projectId?: string | null, requestedHostId?: string) => { - if (!isV2CloudEnabled) { - useNewWorkspaceModalStore.getState().openModal(projectId ?? undefined); - return; - } - const hostId = - requestedHostId ?? - (projectId ? resolveHandoffHostId(machineId) : undefined); - if (hostId) { - useNewWorkspaceDraftStore.getState().updateDraft({ hostId }); - } - if (projectId) { - useNewWorkspaceDraftStore.getState().selectProject(projectId); - } - void navigate({ - to: "/new-workspace", - search: - projectId || hostId - ? { projectId: projectId ?? undefined, host: hostId } - : undefined, - }); - }, + (projectId?: string | null, requestedHostId?: string) => + openNewWorkspace( + { isV2CloudEnabled, machineId, navigate }, + projectId, + requestedHostId, + ), [isV2CloudEnabled, machineId, navigate], ); } @@ -54,12 +90,24 @@ function resolveHandoffHostId(machineId: string | null | undefined) { return draftHostId ?? undefined; } +export function openNewWorkspaceForLocalProject( + handoff: NewWorkspaceHandoff, + projectId: string, +) { + openNewWorkspace(handoff, projectId, handoff.machineId ?? undefined); +} + export function useOpenNewWorkspaceForLocalProject() { + const navigate = useNavigate(); + const isV2CloudEnabled = useIsV2CloudEnabled(); const { machineId } = useLocalHostService(); - const openNewWorkspace = useOpenNewWorkspace(); return useCallback( - (projectId: string) => openNewWorkspace(projectId, machineId), - [machineId, openNewWorkspace], + (projectId: string) => + openNewWorkspaceForLocalProject( + { isV2CloudEnabled, machineId, navigate }, + projectId, + ), + [isV2CloudEnabled, machineId, navigate], ); } @@ -69,20 +117,10 @@ export function useOpenNewSession() { const isV2CloudEnabled = useIsV2CloudEnabled(); const { machineId } = useLocalHostService(); - return useCallback(() => { - if (!isV2CloudEnabled) { - useNewWorkspaceModalStore.getState().openSessionModal(); - return; - } - const hostId = resolveHandoffHostId(machineId); - const draftStore = useNewWorkspaceDraftStore.getState(); - if (hostId) draftStore.updateDraft({ hostId }); - draftStore.selectSession(); - void navigate({ - to: "/new-workspace", - search: { session: true, host: hostId }, - }); - }, [isV2CloudEnabled, machineId, navigate]); + return useCallback( + () => openNewSession({ isV2CloudEnabled, machineId, navigate }), + [isV2CloudEnabled, machineId, navigate], + ); } /** diff --git a/apps/desktop/src/renderer/lib/agent-session-orchestrator/agent-session-orchestrator.test.ts b/apps/desktop/src/renderer/lib/agent-session-orchestrator/agent-session-orchestrator.test.ts index 66120a8fbf9..30ce2a460c7 100644 --- a/apps/desktop/src/renderer/lib/agent-session-orchestrator/agent-session-orchestrator.test.ts +++ b/apps/desktop/src/renderer/lib/agent-session-orchestrator/agent-session-orchestrator.test.ts @@ -1,18 +1,11 @@ import { describe, expect, it, mock } from "bun:test"; import type { AgentLaunchRequest } from "@superset/shared/agent-launch"; +import { + launchAgentSession, + selectAgentLaunchAdapter, +} from "./agent-session-orchestrator"; import type { AgentLaunchTabsAdapter } from "./types"; -mock.module("renderer/lib/posthog", () => ({ - posthog: { - capture: mock(() => {}), - }, - initPostHog: mock(() => {}), -})); - -const { launchAgentSession, selectAgentLaunchAdapter } = await import( - "./agent-session-orchestrator" -); - function createDeferred() { let resolve!: () => void; const promise = new Promise((res) => { diff --git a/apps/desktop/src/renderer/lib/api-trpc-client.test.ts b/apps/desktop/src/renderer/lib/api-trpc-client.test.ts index 1e5b56d220b..4bab5ba0499 100644 --- a/apps/desktop/src/renderer/lib/api-trpc-client.test.ts +++ b/apps/desktop/src/renderer/lib/api-trpc-client.test.ts @@ -1,24 +1,20 @@ -import { afterEach, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, expect, test } from "bun:test"; import { ORGANIZATION_HEADER } from "@superset/shared/constants"; +import { apiTrpcClient } from "./api-trpc-client"; +import { getAuthToken, setAuthToken } from "./auth-client"; +import { cloudTrpcClient } from "./cloud-trpc"; import { setCloudOrganizationId } from "./cloudRequestContext"; -let authToken: string | null = "test-token"; - -mock.module("renderer/env.renderer", () => ({ - env: { NEXT_PUBLIC_API_URL: "https://api.example.test" }, -})); -mock.module("./auth-client", () => ({ - getAuthToken: () => authToken, -})); - -const { apiTrpcClient } = await import("./api-trpc-client"); -const { cloudTrpcClient } = await import("./cloud-trpc"); const originalFetch = globalThis.fetch; +const authTokenBefore = getAuthToken(); +beforeEach(() => { + setAuthToken("test-token"); +}); afterEach(() => { globalThis.fetch = originalFetch; setCloudOrganizationId(null); - authToken = "test-token"; + setAuthToken(authTokenBefore); }); test("automation and billing mutations follow the window organization on every request", async () => { @@ -76,7 +72,8 @@ test("both cloud clients share current authentication and organization headers", ); for (const organizationId of ["paid-org", "other-org", null]) { setCloudOrganizationId(organizationId); - authToken = organizationId ? `token-${organizationId}` : null; + const authToken = organizationId ? `token-${organizationId}` : null; + setAuthToken(authToken); await expect(apiTrpcClient.billing.activePlan.query()).rejects.toThrow( "request captured", ); diff --git a/apps/desktop/src/renderer/lib/persistent-hash-history/persistent-hash-history.test.ts b/apps/desktop/src/renderer/lib/persistent-hash-history/persistent-hash-history.test.ts index 9196f9b3de9..4a04c0fe331 100644 --- a/apps/desktop/src/renderer/lib/persistent-hash-history/persistent-hash-history.test.ts +++ b/apps/desktop/src/renderer/lib/persistent-hash-history/persistent-hash-history.test.ts @@ -55,6 +55,19 @@ Object.defineProperty(globalThis, "window", { configurable: true, }); +afterAll(() => { + Object.defineProperty(globalThis, "window", { + value: originalWindow, + writable: true, + configurable: true, + }); + Object.defineProperty(globalThis, "localStorage", { + value: originalLocalStorage, + writable: true, + configurable: true, + }); +}); + // Now safe to import — the module-level singleton will find window/localStorage const { createPersistentHashHistory } = await import( "./persistent-hash-history" @@ -69,19 +82,6 @@ afterEach(() => { storage.clear(); }); -afterAll(() => { - Object.defineProperty(globalThis, "window", { - value: originalWindow, - writable: true, - configurable: true, - }); - Object.defineProperty(globalThis, "localStorage", { - value: originalLocalStorage, - writable: true, - configurable: true, - }); -}); - describe("createPersistentHashHistory", () => { describe("push", () => { it("advances index and adds entries", () => { diff --git a/apps/desktop/src/renderer/lib/radix-layers/bodyPointerEventsLock.test.tsx b/apps/desktop/src/renderer/lib/radix-layers/bodyPointerEventsLock.test.tsx index 5edbbdc53df..89fb9e0b6bf 100644 --- a/apps/desktop/src/renderer/lib/radix-layers/bodyPointerEventsLock.test.tsx +++ b/apps/desktop/src/renderer/lib/radix-layers/bodyPointerEventsLock.test.tsx @@ -1,17 +1,9 @@ -import { afterAll, afterEach, describe, expect, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, describe, expect, test } from "bun:test"; -// happy-dom over the preloaded plain-object document: Radix needs a real DOM. -// Globals are process-wide, so unregister in afterAll (see Redirect.test.tsx). -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; -// Queries go through `within(document.body)` rather than `screen`: in a full -// suite run an earlier file may have loaded testing-library against a previous -// happy-dom window, and `screen` stays bound to that stale body. const { act, cleanup, fireEvent, render, waitFor, within } = await import( "@testing-library/react" ); @@ -29,10 +21,6 @@ afterEach(() => { cleanup(); document.body.style.pointerEvents = ""; }); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - /** * The sidebar's delete flow: a modal ContextMenu item opens an AlertDialog. * Both are Radix DismissableLayers that lock body pointer-events while open. diff --git a/apps/desktop/src/renderer/lib/radix-layers/inputGroupPopover.test.tsx b/apps/desktop/src/renderer/lib/radix-layers/inputGroupPopover.test.tsx index e78e4141d34..94cab06ed77 100644 --- a/apps/desktop/src/renderer/lib/radix-layers/inputGroupPopover.test.tsx +++ b/apps/desktop/src/renderer/lib/radix-layers/inputGroupPopover.test.tsx @@ -1,8 +1,5 @@ -import { afterAll, afterEach, describe, expect, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, describe, expect, test } from "bun:test"; -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -18,10 +15,6 @@ const { Popover, PopoverContent, PopoverTrigger } = await import( ); afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - // Mirrors the workspace composer: Radix content is portaled out of the DOM // footer but its React click events still bubble through InputGroupAddon. function ComposerWithIssuePicker({ diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts index 36ab6bd3dce..c8b02c52926 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts @@ -8,18 +8,11 @@ import { test, } from "bun:test"; -mock.module("renderer/lib/trpc-client", () => ({ - electronTrpcClient: { - keyboardLayout: { - changes: { subscribe: () => {} }, - }, - }, -})); - -const { terminalRuntimeRegistry } = await import("./terminal-runtime-registry"); -const { terminalMeasurementsChanged, tryPersistRuntimeState } = await import( - "./terminal-runtime" -); +import { + terminalMeasurementsChanged, + tryPersistRuntimeState, +} from "./terminal-runtime"; +import { terminalRuntimeRegistry } from "./terminal-runtime-registry"; test("new theme assignments reset query overrides while identical assignments preserve them", async () => { const { getDefaultTerminalAppearance } = await import("./appearance"); @@ -94,7 +87,10 @@ function createFakeStorage(): FakeStorageState { return { values, storage }; } -const originalLocalStorage = globalThis.localStorage; +const originalLocalStorage = Object.getOwnPropertyDescriptor( + globalThis, + "localStorage", +); let fakeStorage: FakeStorageState; beforeEach(() => { @@ -106,10 +102,11 @@ beforeEach(() => { }); afterEach(() => { - Object.defineProperty(globalThis, "localStorage", { - configurable: true, - value: originalLocalStorage, - }); + if (originalLocalStorage) { + Object.defineProperty(globalThis, "localStorage", originalLocalStorage); + } else { + Reflect.deleteProperty(globalThis, "localStorage"); + } }); describe("terminalRuntimeRegistry eviction cleanup", () => { diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts index 0c225ad4d2a..c8ca0b2c451 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts @@ -1,5 +1,4 @@ import { - afterAll, afterEach, beforeEach, describe, @@ -9,8 +8,16 @@ import { setSystemTime, test, } from "bun:test"; -import * as relaySocketModule from "@superset/workspace-client/relay-socket"; +import type { createRelaySocket } from "@superset/workspace-client/relay-socket"; import type { Terminal as XTerm } from "@xterm/xterm"; +import { + connect, + createTransport as createRealTransport, + disconnect, + park, + reconnect, + sendColors, +} from "./terminal-ws-transport"; // The transport builds on createRelaySocket (partysocket) — reconnection, // backoff, and the relay preflight live inside the shared socket. We inject a @@ -106,26 +113,12 @@ class FakeRelaySocket { } } -// mock.module affects later suites too. Snapshot the real exports before the -// mock rewrites their live bindings, then restore them when this suite ends. -// (auth-client / posthog are deliberately NOT mocked: with a -// faked socket getToken/ensureFreshJwt never runs, and posthog.capture before -// init is a harmless no-op — the real modules load fine, as the prior test did.) -const realRelaySocketModule = { ...relaySocketModule }; -mock.module("@superset/workspace-client/relay-socket", () => ({ - ...realRelaySocketModule, - createRelaySocket: (options: Record) => - new FakeRelaySocket(options), -})); -afterAll(() => { - mock.module( - "@superset/workspace-client/relay-socket", - () => realRelaySocketModule, - ); -}); +const createSocket = ((options: Record) => + new FakeRelaySocket(options)) as unknown as typeof createRelaySocket; -const { connect, createTransport, disconnect, park, reconnect, sendColors } = - await import("./terminal-ws-transport"); +function createTransport(options: { onSessionEnded?: () => void } = {}) { + return createRealTransport({ ...options, createSocket }); +} // `window` is aliased to `globalThis` by the xterm-env-polyfill preload, and // `globalThis.addEventListener` is absent on Linux CI runtimes, so the transport's diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts index 4cc430a36fb..0cb870020f8 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts @@ -110,6 +110,7 @@ export interface TerminalTransport { * once on first connect; it re-signs the URL and runs the relay preflight * before every (re)dial and retries indefinitely. */ _socket: RelaySocket | null; + _createSocket: typeof createRelaySocket; /** The xterm instance the socket feeds. */ _terminal: XTerm | null; /** Internal: disposes the terminal.onData → socket.send wiring. */ @@ -379,7 +380,10 @@ export function clearLogs(transport: TerminalTransport) { } export function createTransport( - options: { onSessionEnded?: () => void } = {}, + options: { + onSessionEnded?: () => void; + createSocket?: typeof createRelaySocket; + } = {}, ): TerminalTransport { return { connectionState: "disconnected", @@ -395,6 +399,7 @@ export function createTransport( narrowedListeners: new Set(), _onSessionEnded: options.onSessionEnded ?? null, _socket: null, + _createSocket: options.createSocket ?? createRelaySocket, _terminal: null, _onDataDisposable: null, _pendingColorReset: false, @@ -634,7 +639,7 @@ export function connect( return; } - const socket = createRelaySocket({ + const socket = transport._createSocket({ // buildUrl/getToken read transport state live, so a URL swap or token // rotation is picked up on the next dial without recreating the socket. buildUrl: () => { diff --git a/apps/desktop/src/renderer/lib/tiptap/markdown-attributes.test.ts b/apps/desktop/src/renderer/lib/tiptap/markdown-attributes.test.ts index f2d1527a6e8..5f75060c0c9 100644 --- a/apps/desktop/src/renderer/lib/tiptap/markdown-attributes.test.ts +++ b/apps/desktop/src/renderer/lib/tiptap/markdown-attributes.test.ts @@ -1,9 +1,4 @@ -import { GlobalRegistrator } from "@happy-dom/global-registrator"; - -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); - -const { afterAll, describe, expect, it } = await import("bun:test"); +const { describe, expect, it } = await import("bun:test"); const { Editor } = await import("@tiptap/core"); const { default: Document } = await import("@tiptap/extension-document"); const { default: Paragraph } = await import("@tiptap/extension-paragraph"); @@ -11,10 +6,6 @@ const { default: Text } = await import("@tiptap/extension-text"); const { Markdown } = await import("tiptap-markdown"); const { SafeLink } = await import("./markdown-attributes"); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - function createEditor() { return new Editor({ element: document.createElement("div"), diff --git a/apps/desktop/src/renderer/lib/v1-migration/v1-migration.test.ts b/apps/desktop/src/renderer/lib/v1-migration/v1-migration.test.ts index 10f5b34bd95..2c63631e574 100644 --- a/apps/desktop/src/renderer/lib/v1-migration/v1-migration.test.ts +++ b/apps/desktop/src/renderer/lib/v1-migration/v1-migration.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import type { HostServiceClient } from "renderer/lib/host-service-client"; import { resolvePresetImport } from "./presets"; import { @@ -491,18 +491,9 @@ describe("isForcedFlipVersion", () => { }); describe("completion markers", () => { - // completion.ts reads the global localStorage inside try/catch; give the - // bun test runtime a Map-backed shim. - const store = new Map(); - // @ts-expect-error minimal Storage shim for tests - globalThis.localStorage = { - getItem: (k: string) => store.get(k) ?? null, - setItem: (k: string, v: string) => void store.set(k, String(v)), - removeItem: (k: string) => void store.delete(k), - get length() { - return store.size; - }, - }; + afterEach(() => { + localStorage.clear(); + }); test("continuity peek is non-destructive; consume is one-shot", () => { markV1MigrationComplete("org-peek"); diff --git a/apps/desktop/src/renderer/routes/-layout.tsx b/apps/desktop/src/renderer/routes/-layout.tsx index df07cefaf96..25fe3cd09a5 100644 --- a/apps/desktop/src/renderer/routes/-layout.tsx +++ b/apps/desktop/src/renderer/routes/-layout.tsx @@ -2,6 +2,7 @@ import { Alerter } from "@superset/ui/atoms/Alert"; import type { ReactNode } from "react"; import { DesktopNoticesGate } from "renderer/components/DesktopNotices"; import { LanguageAwareI18nProvider } from "renderer/components/LanguageAwareI18nProvider"; +import { PostHogLocaleTagger } from "renderer/components/PostHogLocaleTagger"; import { PostHogSurfaceTagger } from "renderer/components/PostHogSurfaceTagger"; import { PostHogUserIdentifier } from "renderer/components/PostHogUserIdentifier"; import { TelemetrySync } from "renderer/components/TelemetrySync"; @@ -18,6 +19,7 @@ export function RootLayout({ children }: { children: ReactNode }) { + {children} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationBody/hooks/useAutomationDraft/useAutomationDraft.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationBody/hooks/useAutomationDraft/useAutomationDraft.test.ts index abc467530e9..3f70b333578 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationBody/hooks/useAutomationDraft/useAutomationDraft.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationBody/hooks/useAutomationDraft/useAutomationDraft.test.ts @@ -1,10 +1,5 @@ -import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, describe, expect, mock, test } from "bun:test"; -// happy-dom over the preloaded plain-object document. Process-wide, so this -// unregisters in afterAll to leave the other renderer suites their document. -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -13,10 +8,6 @@ const { act, cleanup, renderHook } = await import("@testing-library/react"); const { useAutomationDraft } = await import("./useAutomationDraft"); afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - /** * When the editor may write, and what it says while it may not. * diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/ScopeChip/ScopeChip.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/ScopeChip/ScopeChip.test.tsx index 44c5f603046..e6b78acdf0e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/ScopeChip/ScopeChip.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/ScopeChip/ScopeChip.test.tsx @@ -1,13 +1,5 @@ -import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; - -// happy-dom over the preloaded plain-object document — ScopeChip renders a -// Radix popover and a cmdk list, both of which need a real DOM. Bun runs test -// files sequentially in one process and happy-dom's globals are process-wide, -// so we MUST unregister in afterAll to restore the shared mock document for the -// other renderer suites. -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); +import { afterEach, describe, expect, mock, test } from "bun:test"; + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -18,10 +10,6 @@ const { ScopeChip } = await import("./ScopeChip"); type Scope = Parameters[0]["scope"]; afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - const OPTIONS = [ { id: "1", label: "alpha" }, { id: "2", label: "beta" }, @@ -29,12 +17,6 @@ const OPTIONS = [ /** * Renders the chip and opens its popover, which is where everything lives. - * - * Queries go through the render's own baseElement rather than the global - * `screen`: `screen` binds document.body when @testing-library/react is first - * imported, and another suite unregistering happy-dom leaves that binding - * pointing at a torn-down document — the module is cached, so re-importing does - * not rebind it. Everything here would then query an empty body. */ async function open(props: Partial[0]> = {}) { const onChange = mock((_next: unknown) => {}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggerMenuItems/TriggerMenuItems.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggerMenuItems/TriggerMenuItems.test.tsx index 910588bb4e1..b4fd7d5557b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggerMenuItems/TriggerMenuItems.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggerMenuItems/TriggerMenuItems.test.tsx @@ -1,10 +1,5 @@ -import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, describe, expect, mock, test } from "bun:test"; -// happy-dom over the preloaded plain-object document. Process-wide, so this -// unregisters in afterAll to leave the other renderer suites their document. -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -17,10 +12,6 @@ const { TriggerMenuItems } = await import("./TriggerMenuItems"); const { TRIGGER_PROVIDERS } = await import("../../providers"); afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - /** * The Add Trigger menu, for a plan that cannot add everything: * diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/components/RuntimeWarnings/RuntimeWarnings.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/components/RuntimeWarnings/RuntimeWarnings.test.tsx index a9f8b5c6049..bf62fd5c84d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/components/RuntimeWarnings/RuntimeWarnings.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/components/RuntimeWarnings/RuntimeWarnings.test.tsx @@ -1,10 +1,5 @@ -import { afterAll, afterEach, describe, expect, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, describe, expect, test } from "bun:test"; -// happy-dom over the preloaded plain-object document. Process-wide, so this -// unregisters in afterAll to leave the other renderer suites their document. -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -13,10 +8,6 @@ const { act, cleanup, render, within } = await import("@testing-library/react"); const { RuntimeWarnings } = await import("./RuntimeWarnings"); afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - /** * How a standing warning presents, below the rows and the scope line: * diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/TokenField/TokenField.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/TokenField/TokenField.test.tsx index a607e412ec3..32b0a1ea579 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/TokenField/TokenField.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/TokenField/TokenField.test.tsx @@ -1,10 +1,5 @@ -import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, describe, expect, mock, test } from "bun:test"; -// happy-dom over the preloaded plain-object document. Process-wide, so this -// unregisters in afterAll to leave the other renderer suites their document. -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -15,18 +10,10 @@ const { act, cleanup, fireEvent, render, within } = await import( const { TokenField, SEPARATORS_WITH_SPACE } = await import("./TokenField"); afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - /** * Renders the field over a live `values` array, the way its parents do — the * chip owns the scope and feeds it back, so a test that never re-renders would * pass while a second commit silently dropped the first. - * - * Queries go through the render's baseElement, not the global `screen`, which - * binds document.body at import time and goes stale when another suite - * unregisters happy-dom. */ async function setup( initial: string[] = [], diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.test.tsx index ffe6527c66b..f514f3159f2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.test.tsx @@ -1,10 +1,5 @@ -import { afterAll, afterEach, describe, expect, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, describe, expect, test } from "bun:test"; -// happy-dom over the preloaded plain-object document. Process-wide, so this -// unregisters in afterAll to leave the other renderer suites their document. -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -16,10 +11,6 @@ const { githubProvider } = await import("./github"); const { createGithubConfig } = await import("./grammar"); afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - /** * What a GitHub row looks like, and must keep looking like. * diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/schedule/schedule.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/schedule/schedule.test.tsx index 9b0ea5ad895..c26161d845b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/schedule/schedule.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/schedule/schedule.test.tsx @@ -1,10 +1,5 @@ -import { afterAll, afterEach, describe, expect, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, describe, expect, test } from "bun:test"; -// happy-dom over the preloaded plain-object document. Process-wide, so this -// unregisters in afterAll to leave the other renderer suites their document. -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -15,10 +10,6 @@ const { act, cleanup, fireEvent, render, within } = await import( const { TriggerSentence } = await import("../../TriggerSentence"); afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - /** * What a scheduled row looks like, and must keep looking like. * diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/components/EmojiNameChip/EmojiNameChip.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/components/EmojiNameChip/EmojiNameChip.test.tsx index fe75d2ba375..f3942a23dd1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/components/EmojiNameChip/EmojiNameChip.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/components/EmojiNameChip/EmojiNameChip.test.tsx @@ -1,10 +1,5 @@ -import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, describe, expect, mock, test } from "bun:test"; -// happy-dom over the preloaded plain-object document. Process-wide, so this -// unregisters in afterAll to leave the other renderer suites their document. -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -15,10 +10,6 @@ const { act, cleanup, fireEvent, render, within } = await import( const { EmojiNameChip } = await import("./EmojiNameChip"); afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - /** * The reaction picker, open: * diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.test.tsx index 364a9c94063..a6dead5436f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.test.tsx @@ -1,10 +1,5 @@ -import { afterAll, afterEach, describe, expect, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, describe, expect, test } from "bun:test"; -// happy-dom over the preloaded plain-object document. Process-wide, so this -// unregisters in afterAll to leave the other renderer suites their document. -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -14,10 +9,6 @@ const { TriggerSentence } = await import("../../TriggerSentence"); const { slackProvider } = await import("./slack"); afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - /** * What a Slack row looks like, and must keep looking like. * diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/useFolderFirstImport.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/importFolderFirst.test.ts similarity index 52% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/useFolderFirstImport.test.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/importFolderFirst.test.ts index ba4222868be..fa863bf05b3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/useFolderFirstImport.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/importFolderFirst.test.ts @@ -1,26 +1,17 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; -import * as reactActual from "react"; -// Static imports so the real modules are captured before the mocks below -// replace them. -import * as hostServiceClientActual from "renderer/lib/host-service-client"; -import * as projectsActual from "renderer/react-query/projects"; -import * as localHostServiceActual from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; -import * as gitInitConfirmActual from "renderer/stores/git-init-confirm"; +import { + type FolderImportClient, + type ImportFolderFirstDeps, + importFolderFirst, +} from "./importFolderFirst"; const hostUrl = "http://host-service"; const repoPath = "/repos/octocat"; -const setupResult = { - repoPath, -}; const cloudError = { url: "https://github.com/octocat/hello.git", message: "cloud-down", }; -const selectDirectoryMock = mock(async () => ({ - canceled: false, - path: repoPath, -})); const findByPathMock = mock( async (): Promise<{ candidates: { id: string; name: string }[]; @@ -31,74 +22,44 @@ const findByPathMock = mock( cloudErrors: [], }), ); -const setupMock = mock(async () => setupResult); +const setupMock = mock(async () => ({ repoPath })); const createMock = mock(async () => ({ projectId: "created-project", repoPath, })); const finalizeSetupMock = mock(() => undefined); const requestGitInitMock = mock(async () => false); - -// Spread the real module for the same reason the store mock below does: bun's -// mock.module is process-global and permanent, so dropping an export here -// deletes it for every test file that runs after this one. `useCallback` is -// identity so the hook can be exercised outside a render. -mock.module("react", () => ({ - ...reactActual, - useCallback: unknown>(callback: T) => - callback, -})); - -mock.module("renderer/lib/electron-trpc", () => ({ - electronTrpc: { - window: { - selectDirectory: { - useMutation: () => ({ mutateAsync: selectDirectoryMock }), - }, +const clientUrls: string[] = []; + +function startImport(overrides: Partial = {}) { + return importFolderFirst({ + pickDirectory: async () => ({ canceled: false, path: repoPath }), + waitForHostReady: async () => hostUrl, + getClient: (url) => { + clientUrls.push(url); + return { + project: { + findByPath: { query: findByPathMock }, + setup: { mutate: setupMock }, + create: { mutate: createMock }, + }, + } as unknown as FolderImportClient; }, - }, -})); - -mock.module("renderer/lib/host-service-client", () => ({ - ...hostServiceClientActual, - getHostServiceClientByUrl: () => ({ - project: { - findByPath: { query: findByPathMock }, - setup: { mutate: setupMock }, - create: { mutate: createMock }, + requestGitInit: requestGitInitMock, + finalizeSetup: finalizeSetupMock, + messages: { + hostUnavailable: () => "host unavailable", + cloudUnreachable: (first) => + `Couldn't reach cloud for ${first.url}: ${first.message}`, + multipleProjects: (candidates) => `multiple: ${candidates.length}`, }, - }), -})); - -mock.module("renderer/react-query/projects", () => ({ - ...projectsActual, - useFinalizeProjectSetup: () => finalizeSetupMock, -})); - -mock.module( - "renderer/routes/_authenticated/providers/LocalHostServiceProvider", - () => ({ - ...localHostServiceActual, - useLocalHostService: () => ({ - activeHostUrl: hostUrl, - waitForHostReady: async () => hostUrl, - }), - }), -); - -// Spread the real module — bun's mock.module is process-global, and a partial -// mock would break other test files importing the real store. -mock.module("renderer/stores/git-init-confirm", () => ({ - ...gitInitConfirmActual, - useRequestGitInitConfirm: () => requestGitInitMock, -})); - -const { useFolderFirstImport } = await import("./useFolderFirstImport"); + ...overrides, + }); +} -describe("useFolderFirstImport", () => { +describe("importFolderFirst", () => { beforeEach(() => { for (const fn of [ - selectDirectoryMock, findByPathMock, setupMock, createMock, @@ -107,6 +68,7 @@ describe("useFolderFirstImport", () => { ]) { fn.mockClear(); } + clientUrls.length = 0; findByPathMock.mockResolvedValue({ candidates: [], cloudErrors: [] }); requestGitInitMock.mockResolvedValue(false); }); @@ -118,9 +80,10 @@ describe("useFolderFirstImport", () => { }); const onError = mock(() => undefined); - const result = await useFolderFirstImport({ onError }).start(); + const result = await startImport({ onError }); expect(result).toBeNull(); + expect(clientUrls).toEqual([hostUrl]); expect(findByPathMock).toHaveBeenCalledWith({ repoPath }); expect(onError).toHaveBeenCalledWith( "Couldn't reach cloud for https://github.com/octocat/hello.git: cloud-down", @@ -139,7 +102,7 @@ describe("useFolderFirstImport", () => { requestGitInitMock.mockResolvedValue(true); const onError = mock(() => undefined); - const result = await useFolderFirstImport({ onError }).start(); + const result = await startImport({ onError }); expect(requestGitInitMock).toHaveBeenCalledWith(repoPath); expect(createMock).toHaveBeenCalledWith({ @@ -166,7 +129,7 @@ describe("useFolderFirstImport", () => { requestGitInitMock.mockResolvedValue(false); const onError = mock(() => undefined); - const result = await useFolderFirstImport({ onError }).start(); + const result = await startImport({ onError }); expect(result).toBeNull(); expect(requestGitInitMock).toHaveBeenCalledWith(repoPath); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/importFolderFirst.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/importFolderFirst.ts new file mode 100644 index 00000000000..88135b0e0f5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/importFolderFirst.ts @@ -0,0 +1,121 @@ +import type { HostServiceClient } from "renderer/lib/host-service-client"; +import { getBaseName } from "renderer/lib/pathBasename"; +import type { ProjectSetupResult } from "renderer/react-query/projects"; + +export interface MatchingProject { + id: string; + name: string; +} + +export type FolderImportClient = { + project: Pick< + HostServiceClient["project"], + "findByPath" | "setup" | "create" + >; +}; + +export interface ImportFolderFirstDeps { + pickDirectory: () => Promise<{ canceled: boolean; path?: string | null }>; + waitForHostReady: () => Promise; + getClient: (hostUrl: string) => FolderImportClient; + requestGitInit: (repoPath: string) => Promise; + finalizeSetup: (hostUrl: string, result: ProjectSetupResult) => void; + onError?: (message: string) => void; + onMultipleProjects?: (input: { candidates: MatchingProject[] }) => void; + messages: { + hostUnavailable: () => string; + cloudUnreachable: (first: { url: string; message: string }) => string; + multipleProjects: (candidates: MatchingProject[]) => string; + }; +} + +export async function importFolderFirst({ + pickDirectory, + waitForHostReady, + getClient, + requestGitInit, + finalizeSetup, + onError, + onMultipleProjects, + messages, +}: ImportFolderFirstDeps): Promise { + // Pick the folder first — the native dialog is a local Electron call and + // must not wait on the host service. Only the registration below needs it. + let repoPath: string; + try { + const picked = await pickDirectory(); + if (picked.canceled || !picked.path) return null; + repoPath = picked.path; + } catch (err) { + onError?.(err instanceof Error ? err.message : String(err)); + return null; + } + + const activeHostUrl = await waitForHostReady(); + if (!activeHostUrl) { + onError?.(messages.hostUnavailable()); + return null; + } + + const client = getClient(activeHostUrl); + let candidates: MatchingProject[]; + try { + const response = await client.project.findByPath.query({ repoPath }); + + // Folder isn't a git repo yet: offer to `git init` it, then import + // via the create path with init enabled. + if ("needsGitInit" in response && response.needsGitInit) { + const confirmed = await requestGitInit(repoPath); + if (!confirmed) return null; + const result = await client.project.create.mutate({ + name: getBaseName(repoPath), + mode: { kind: "importLocal", repoPath, initIfNeeded: true }, + }); + finalizeSetup(activeHostUrl, result); + return result; + } + + candidates = response.candidates; + if (candidates.length === 0 && response.cloudErrors.length > 0) { + onError?.(messages.cloudUnreachable(response.cloudErrors[0])); + return null; + } + } catch (err) { + onError?.(err instanceof Error ? err.message : String(err)); + return null; + } + + const [only, ...rest] = candidates; + if (rest.length > 0) { + if (onMultipleProjects) { + onMultipleProjects({ candidates }); + } else { + onError?.(messages.multipleProjects(candidates)); + } + return null; + } + + try { + let result: ProjectSetupResult; + if (only) { + const setupResult = await client.project.setup.mutate({ + projectId: only.id, + mode: { kind: "import", repoPath }, + }); + result = { + projectId: only.id, + repoPath: setupResult.repoPath, + }; + } else { + result = await client.project.create.mutate({ + name: getBaseName(repoPath), + mode: { kind: "importLocal", repoPath }, + }); + } + finalizeSetup(activeHostUrl, result); + return result; + } catch (err) { + onError?.(err instanceof Error ? err.message : String(err)); + return null; + } +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/useFolderFirstImport.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/useFolderFirstImport.ts index 79adf170042..33e8c78c8d8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/useFolderFirstImport.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/useFolderFirstImport.ts @@ -3,23 +3,18 @@ import { useCallback } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { getHostServiceUnavailableMessage } from "renderer/lib/host-service-unavailable"; -import { getBaseName } from "renderer/lib/pathBasename"; import { type ProjectSetupResult, useFinalizeProjectSetup, } from "renderer/react-query/projects"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { useRequestGitInitConfirm } from "renderer/stores/git-init-confirm"; +import { importFolderFirst, type MatchingProject } from "./importFolderFirst"; export interface UseFolderFirstImportResult { start: () => Promise; } -interface MatchingProject { - id: string; - name: string; -} - export function useFolderFirstImport(options?: { onError?: (message: string) => void; onMultipleProjects?: (input: { candidates: MatchingProject[] }) => void; @@ -32,113 +27,47 @@ export function useFolderFirstImport(options?: { const requestGitInit = useRequestGitInitConfirm(); const { onError, onMultipleProjects } = options ?? {}; - const start = useCallback(async (): Promise => { - // Pick the folder first — the native dialog is a local Electron call and - // must not wait on the host service. Only the registration below needs it. - let repoPath: string; - try { - const picked = await selectDirectory.mutateAsync({ - title: t({ - message: "Import existing folder", - }), - }); - if (picked.canceled || !picked.path) return null; - repoPath = picked.path; - } catch (err) { - onError?.(err instanceof Error ? err.message : String(err)); - return null; - } - - const activeHostUrl = await waitForHostReady(); - if (!activeHostUrl) { - onError?.( - getHostServiceUnavailableMessage(hostService, { - action: "importFolder", - }), - ); - return null; - } - - const client = getHostServiceClientByUrl(activeHostUrl); - let candidates: MatchingProject[]; - try { - const response = await client.project.findByPath.query({ repoPath }); - - // Folder isn't a git repo yet: offer to `git init` it, then import - // via the create path with init enabled. - if ("needsGitInit" in response && response.needsGitInit) { - const confirmed = await requestGitInit(repoPath); - if (!confirmed) return null; - const result = await client.project.create.mutate({ - name: getBaseName(repoPath), - mode: { kind: "importLocal", repoPath, initIfNeeded: true }, - }); - finalizeSetup(activeHostUrl, result); - return result; - } - - candidates = response.candidates; - if (candidates.length === 0 && response.cloudErrors.length > 0) { - const first = response.cloudErrors[0]; - onError?.( - t({ - message: `Couldn't reach cloud for ${first.url}: ${first.message}`, - }), - ); - return null; - } - } catch (err) { - onError?.(err instanceof Error ? err.message : String(err)); - return null; - } - - const [only, ...rest] = candidates; - if (rest.length > 0) { - if (onMultipleProjects) { - onMultipleProjects({ candidates }); - } else { - onError?.( - t({ - message: `Multiple projects use this repository (${candidates.length}). Open the project you want from settings to set it up on this device.`, + const start = useCallback( + () => + importFolderFirst({ + pickDirectory: () => + selectDirectory.mutateAsync({ + title: t({ + message: "Import existing folder", + }), }), - ); - } - return null; - } - - try { - let result: ProjectSetupResult; - if (only) { - const setupResult = await client.project.setup.mutate({ - projectId: only.id, - mode: { kind: "import", repoPath }, - }); - result = { - projectId: only.id, - repoPath: setupResult.repoPath, - }; - } else { - result = await client.project.create.mutate({ - name: getBaseName(repoPath), - mode: { kind: "importLocal", repoPath }, - }); - } - finalizeSetup(activeHostUrl, result); - return result; - } catch (err) { - onError?.(err instanceof Error ? err.message : String(err)); - return null; - } - }, [ - waitForHostReady, - finalizeSetup, - hostService, - onError, - onMultipleProjects, - requestGitInit, - selectDirectory, - t, - ]); + waitForHostReady: () => waitForHostReady(), + getClient: getHostServiceClientByUrl, + requestGitInit, + finalizeSetup, + onError, + onMultipleProjects, + messages: { + hostUnavailable: () => + getHostServiceUnavailableMessage(hostService, { + action: "importFolder", + }), + cloudUnreachable: (first) => + t({ + message: `Couldn't reach cloud for ${first.url}: ${first.message}`, + }), + multipleProjects: (candidates) => + t({ + message: `Multiple projects use this repository (${candidates.length}). Open the project you want from settings to set it up on this device.`, + }), + }, + }), + [ + waitForHostReady, + finalizeSetup, + hostService, + onError, + onMultipleProjects, + requestGitInit, + selectDirectory, + t, + ], + ); return { start }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AgentSessionPicker/AgentSessionPicker.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AgentSessionPicker/AgentSessionPicker.test.tsx new file mode 100644 index 00000000000..839878d5db8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AgentSessionPicker/AgentSessionPicker.test.tsx @@ -0,0 +1,117 @@ +import { afterEach, expect, test } from "bun:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, render } from "@testing-library/react"; +import { initTRPC } from "@trpc/server"; +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; +import type { ContextType } from "react"; +import type { TerminalAgentBinding } from "renderer/hooks/host-service/useTerminalAgentBindings"; +import { HostWorkspacesContext } from "renderer/routes/_authenticated/providers/HostWorkspacesProvider"; +import { LocalHostServiceContext } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { SandboxAccessContext } from "renderer/routes/_authenticated/providers/SandboxAccessProvider"; +import superjson from "superjson"; +import { AgentSessionPicker } from "./AgentSessionPicker"; +import { EXISTING_PREFIX } from "./hooks/useAgentSessionTarget"; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const WORKSPACE_ID = "workspace-1"; +const MACHINE_ID = "this-machine"; +const TERMINAL_ID = "terminal-1"; + +let stopHost: (() => void) | undefined; + +afterEach(() => { + cleanup(); + stopHost?.(); + stopHost = undefined; +}); + +function startHost() { + const listed: unknown[] = []; + const t = initTRPC.create({ transformer: superjson }); + const router = t.router({ + terminal: t.router({ + list: t.procedure + .input((input) => input) + .query(({ input }) => { + listed.push(input); + return { + sessions: [{ terminalId: TERMINAL_ID, title: "Fix the login" }], + }; + }), + }), + }); + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: (request) => + fetchRequestHandler({ + endpoint: "/trpc", + req: request, + router, + allowMethodOverride: true, + }), + }); + stopHost = () => void server.stop(true); + return { listed, url: `http://127.0.0.1:${server.port}` }; +} + +test("names an existing session by the title its host reports", async () => { + const host = startHost(); + const queryClient = new QueryClient(); + queryClient.setQueryData(["relay-endpoint"], { url: host.url }); + const session = { + terminalId: TERMINAL_ID, + agentId: "claude", + } as TerminalAgentBinding; + + const view = render( + + > + } + > + > + } + > + + {}} + sessions={[session]} + configs={[]} + /> + + + + , + ); + + expect(view.getByLabelText("Choose agent").textContent).toContain("claude"); + expect(await view.findByText("Fix the login")).toBeDefined(); + expect(host.listed).toEqual([{ workspaceId: WORKSPACE_ID }]); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AgentSessionPicker/AgentSessionPicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AgentSessionPicker/AgentSessionPicker.tsx index 95d25c97cae..69a3bbd03ee 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AgentSessionPicker/AgentSessionPicker.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AgentSessionPicker/AgentSessionPicker.tsx @@ -1,20 +1,7 @@ -import { Trans, useLingui } from "@lingui/react/macro"; import type { HostAgentConfig } from "@superset/host-service/settings"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@superset/ui/command"; -import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; -import { Check, ChevronDown } from "lucide-react"; import { useState } from "react"; import type { TerminalAgentBinding } from "renderer/hooks/host-service/useTerminalAgentBindings"; -import { ExistingSessionOption } from "./components/ExistingSessionOption"; -import { NewSessionOption } from "./components/NewSessionOption"; -import { EXISTING_PREFIX, NEW_PREFIX } from "./hooks/useAgentSessionTarget"; +import { AgentSessionSelect } from "./components/AgentSessionSelect"; import { useAgentSessionTitles } from "./hooks/useAgentSessionTitles"; interface AgentSessionPickerProps { @@ -32,147 +19,24 @@ export function AgentSessionPicker({ sessions, configs, }: AgentSessionPickerProps) { - const { t } = useLingui(); const [open, setOpen] = useState(false); const { titles, refreshTitles } = useAgentSessionTitles({ workspaceId, enabled: sessions.length > 0, open, }); - const session = sessions.find( - (item) => `${EXISTING_PREFIX}${item.terminalId}` === value, - ); - const config = configs.find((item) => `${NEW_PREFIX}${item.id}` === value); - const select = (next: string) => { - onValueChange(next); - setOpen(false); - }; return ( - { setOpen(next); if (next && sessions.length > 0) void refreshTitles(); }} - > - - - - event.stopPropagation()} - > - - - - - No results found. - - {sessions.length > 0 && ( - - {sessions.map((item) => { - const key = `${EXISTING_PREFIX}${item.terminalId}`; - return ( - select(key)} - className="gap-3 rounded-md py-2 text-xs" - > - - {value === key && ( - - )} - - ); - })} - - )} - {configs.length > 0 && ( - - {configs.map((item) => { - const key = `${NEW_PREFIX}${item.id}`; - return ( - select(key)} - className="gap-3 rounded-md py-2 text-xs" - > - - {value === key && ( - - )} - - ); - })} - - )} - - - - + /> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AgentSessionPicker/components/AgentSessionSelect/AgentSessionSelect.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AgentSessionPicker/components/AgentSessionSelect/AgentSessionSelect.tsx new file mode 100644 index 00000000000..a02ade2197c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AgentSessionPicker/components/AgentSessionSelect/AgentSessionSelect.tsx @@ -0,0 +1,168 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import type { HostAgentConfig } from "@superset/host-service/settings"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { Check, ChevronDown } from "lucide-react"; +import type { TerminalAgentBinding } from "renderer/hooks/host-service/useTerminalAgentBindings"; +import { EXISTING_PREFIX, NEW_PREFIX } from "../../hooks/useAgentSessionTarget"; +import { ExistingSessionOption } from "../ExistingSessionOption"; +import { NewSessionOption } from "../NewSessionOption"; + +interface AgentSessionSelectProps { + value: string | null; + onValueChange: (next: string) => void; + sessions: TerminalAgentBinding[]; + configs: HostAgentConfig[]; + titles: ReadonlyMap; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function AgentSessionSelect({ + value, + onValueChange, + sessions, + configs, + titles, + open, + onOpenChange, +}: AgentSessionSelectProps) { + const { t } = useLingui(); + const session = sessions.find( + (item) => `${EXISTING_PREFIX}${item.terminalId}` === value, + ); + const config = configs.find((item) => `${NEW_PREFIX}${item.id}` === value); + const select = (next: string) => { + onValueChange(next); + onOpenChange(false); + }; + return ( + + + + + event.stopPropagation()} + > + + + + + No results found. + + {sessions.length > 0 && ( + + {sessions.map((item) => { + const key = `${EXISTING_PREFIX}${item.terminalId}`; + return ( + select(key)} + className="gap-3 rounded-md py-2 text-xs" + > + + {value === key && ( + + )} + + ); + })} + + )} + {configs.length > 0 && ( + + {configs.map((item) => { + const key = `${NEW_PREFIX}${item.id}`; + return ( + select(key)} + className="gap-3 rounded-md py-2 text-xs" + > + + {value === key && ( + + )} + + ); + })} + + )} + + + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AgentSessionPicker/components/AgentSessionSelect/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AgentSessionPicker/components/AgentSessionSelect/index.ts new file mode 100644 index 00000000000..48acf0ab727 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AgentSessionPicker/components/AgentSessionSelect/index.ts @@ -0,0 +1 @@ +export { AgentSessionSelect } from "./AgentSessionSelect"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AgentSessionPicker/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AgentSessionPicker/index.ts index 58133e9e39c..d2545fca797 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AgentSessionPicker/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AgentSessionPicker/index.ts @@ -1,4 +1,5 @@ export { AgentSessionPicker } from "./AgentSessionPicker"; +export { AgentSessionSelect } from "./components/AgentSessionSelect"; export type { AgentSessionPlacement, AgentTarget, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/components/DestroyConfirmPane/DestroyConfirmPane.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/components/DestroyConfirmPane/DestroyConfirmPane.test.tsx index f9fc30d032a..3653a8aed51 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/components/DestroyConfirmPane/DestroyConfirmPane.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/components/DestroyConfirmPane/DestroyConfirmPane.test.tsx @@ -1,17 +1,9 @@ -import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, describe, expect, mock, test } from "bun:test"; -// happy-dom over the preloaded plain-object document: Radix needs a real DOM. -// Globals are process-wide, so unregister in afterAll (see Redirect.test.tsx). -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; -// Queries go through `within(document.body)` rather than `screen`: in a full -// suite run an earlier file may have loaded testing-library against a previous -// happy-dom window, and `screen` stays bound to that stale body. const { act, cleanup, fireEvent, render, within } = await import( "@testing-library/react" ); @@ -24,10 +16,6 @@ afterEach(() => { cleanup(); document.body.style.pointerEvents = ""; }); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - /** * The sidebar's delete flow: a ContextMenu "Delete" item opens the confirm * pane. Selecting the item with Enter must not also confirm the pane — the diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/fixtures/checks.test.fixture.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/fixtures/checks.test.fixture.tsx index 7f92e412fcb..1752fa27a8d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/fixtures/checks.test.fixture.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/fixtures/checks.test.fixture.tsx @@ -1,7 +1,5 @@ import { afterEach, expect, mock, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; -GlobalRegistrator.register(); const { createPortal } = await import("react-dom"); const { cleanup, fireEvent, render } = await import("@testing-library/react"); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.test.tsx index cb157fd55fc..cf31041d6f8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.test.tsx @@ -1,18 +1,10 @@ -import { afterAll, afterEach, describe, expect, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, describe, expect, test } from "bun:test"; import type { DashboardSidebarWorkspace } from "../../../../types"; -// happy-dom over the preloaded plain-object document: Radix needs a real DOM. -// Globals are process-wide, so unregister in afterAll (see Redirect.test.tsx). -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; -// Queries go through `within(document.body)` rather than `screen`: in a full -// suite run an earlier file may have loaded testing-library against a previous -// happy-dom window, and `screen` stays bound to that stale body. const { cleanup, render, within } = await import("@testing-library/react"); const { DashboardSidebarWorkspaceHoverCardContent } = await import( "./DashboardSidebarWorkspaceHoverCardContent" @@ -22,10 +14,6 @@ afterEach(() => { cleanup(); document.body.style.pointerEvents = ""; }); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - function workspaceWithName(name: string): DashboardSidebarWorkspace { return { id: "ws-1", diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspacesHeader/hooks/useProjectFilterExpanded/useProjectFilterExpanded.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspacesHeader/hooks/useProjectFilterExpanded/useProjectFilterExpanded.test.ts index a1309f4dc14..410dc07e35e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspacesHeader/hooks/useProjectFilterExpanded/useProjectFilterExpanded.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspacesHeader/hooks/useProjectFilterExpanded/useProjectFilterExpanded.test.ts @@ -1,10 +1,5 @@ -import { afterAll, afterEach, describe, expect, it } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, describe, expect, it } from "bun:test"; -// happy-dom over the preloaded plain-object document. Process-wide, so this -// unregisters in afterAll to leave the other renderer suites their document. -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -13,10 +8,6 @@ const { act, cleanup, renderHook } = await import("@testing-library/react"); const { useProjectFilterExpanded } = await import("./useProjectFilterExpanded"); afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - function render(query: string) { return renderHook(({ query }) => useProjectFilterExpanded(query), { initialProps: { query }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/fixtures/navigation.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/fixtures/navigation.ts index 497e42b73e5..f340931b0f4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/fixtures/navigation.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/fixtures/navigation.ts @@ -5,7 +5,7 @@ import type { DashboardSidebarWorkspace, } from "../../../types"; -GlobalRegistrator.register(); +if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx deleted file mode 100644 index c888044e8bb..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx +++ /dev/null @@ -1,401 +0,0 @@ -import { - afterAll, - afterEach, - beforeEach, - describe, - expect, - mock, - test, -} from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; - -// happy-dom is process-wide; unregister in afterAll so the shared mock -// document is restored for the other renderer suites. -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); -( - globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } -).IS_REACT_ACT_ENVIRONMENT = true; - -interface ListPage { - items: Array<{ - id: string; - title: string; - slug: string; - visibility: string; - }>; - nextCursor: string | null; -} - -interface InfiniteResult { - data?: { pages: ListPage[] }; - error?: { message: string } | null; - isPending: boolean; - hasNextPage: boolean; - isFetchingNextPage: boolean; - isFetchNextPageError: boolean; - fetchNextPage: () => void; -} - -const fetchNextPage = mock(() => {}); - -let listResult: InfiniteResult; -let listInput: Record | undefined; -let countsInput: Record | undefined; -let countsData: { - all: number; - team: number; - mine: number; - pinned: number; - workspaces: Array<{ workspaceId: string; count: number }>; - authors: Array<{ - userId: string | null; - name: string | null; - image: string | null; - count: number; - }>; -}; - -/** Lets a test fire the sentinel without a real viewport. */ -const observed: Array<() => void> = []; -class TestIntersectionObserver { - constructor( - private callback: (entries: Array<{ isIntersecting: boolean }>) => void, - ) {} - observe() { - observed.push(() => this.callback([{ isIntersecting: true }])); - } - disconnect() {} -} -(globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = - TestIntersectionObserver; - -function page(id: string, visibility = "org") { - return { id, title: `Page ${id}`, slug: `page-${id}`, visibility }; -} - -mock.module("renderer/lib/cloud-trpc", () => ({ - cloudTrpc: { - page: { - listPaginated: { - useInfiniteQuery: (input: Record) => { - listInput = input; - return listResult; - }, - }, - counts: { - useQuery: (input: Record) => { - countsInput = input; - return { data: countsData }; - }, - }, - delete: { useMutation: () => ({ mutateAsync: mock() }) }, - }, - useUtils: () => ({ - page: { - listPaginated: { - cancel: mock(async () => {}), - getInfiniteData: mock(() => undefined), - setInfiniteData: mock(), - invalidate: mock(), - }, - counts: { invalidate: mock() }, - }, - }), - }, -})); - -mock.module("renderer/lib/auth-client", () => ({ - authClient: { useSession: () => ({ data: null }) }, -})); - -// The grid pulls in page cards, thumbnails and the whole UI kit. This suite is -// about what PagesView hands it, so record the props instead of rendering them. -let gridProps: { pages: unknown[]; error?: string; isPending: boolean }; -mock.module("../PagesGrid", () => ({ - PagesGrid: (props: typeof gridProps) => { - gridProps = props; - return null; - }, -})); - -mock.module( - "renderer/routes/_authenticated/_dashboard/components/PageHeader", - () => ({ PageHeader: () => null }), -); - -const onScopeChange = mock((_scope: string) => {}); -const onSearchChange = mock((_search: string) => {}); - -mock.module("./hooks/useCreatePageWithAgent", () => ({ - useCreatePageWithAgent: () => ({ - creatingWithAgent: false, - handleCreateWithAgent: mock(), - }), -})); - -const PINS = ["pin-1", "pin-2"]; -mock.module( - "renderer/routes/_authenticated/_dashboard/hooks/usePageFavorites", - () => ({ - usePageFavorites: () => ({ - favoritePageIds: PINS, - favoritePageIdSet: new Set(PINS), - toggleFavorite: mock(), - }), - }), -); - -mock.module("../../hooks/usePageWorkspaceNames", () => ({ - usePageWorkspaceNames: () => new Map([["ws-1", "Workspace One"]]), -})); - -const { act, cleanup, fireEvent, render } = await import( - "@testing-library/react" -); -const { PagesView } = await import("./PagesView"); - -function renderView({ - scope = "all" as "all" | "pinned" | "team" | "mine", - search = "", - authorId = null as string | null, - workspaceId = null as string | null, -} = {}) { - return render( - , - ); -} - -beforeEach(() => { - fetchNextPage.mockClear(); - onScopeChange.mockClear(); - onSearchChange.mockClear(); - observed.length = 0; - listInput = undefined; - countsInput = undefined; - countsData = { - all: 1, - team: 1, - mine: 0, - pinned: 2, - workspaces: [{ workspaceId: "ws-1", count: 3 }], - authors: [ - { userId: "user-1", name: "Ada", image: null, count: 2 }, - { userId: "user-2", name: "Grace", image: null, count: 1 }, - ], - }; - listResult = { - data: { pages: [{ items: [page("a")], nextCursor: null }] }, - error: null, - isPending: false, - hasNextPage: false, - isFetchingNextPage: false, - isFetchNextPageError: false, - fetchNextPage, - }; -}); - -afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - -describe("PagesView server-side filtering", () => { - test("sends the search to the server instead of filtering what it loaded", async () => { - await act(async () => { - renderView({ search: "report" }); - }); - expect(listInput).toMatchObject({ search: "report" }); - }); - - test("sends the scope, the author and the workspace too", async () => { - await act(async () => { - renderView({ scope: "team", authorId: "user-1", workspaceId: "ws-1" }); - }); - expect(listInput).toMatchObject({ - scope: "team", - authorId: "user-1", - workspaceId: "ws-1", - }); - }); - - test("asks for the pinned tab by id, because pins are not a server column", async () => { - await act(async () => { - renderView({ scope: "pinned" }); - }); - expect(listInput).toMatchObject({ scope: "all", ids: PINS }); - }); - - test("asks for one batch, not the whole organization", async () => { - await act(async () => { - renderView(); - }); - expect(listInput?.limit).toBe(48); - }); -}); - -describe("PagesView loads on scroll", () => { - test("does not sweep the organization on mount", async () => { - listResult.hasNextPage = true; - await act(async () => { - renderView(); - }); - expect(fetchNextPage).not.toHaveBeenCalled(); - }); - - test("fetches the next batch when the sentinel comes into view", async () => { - listResult.hasNextPage = true; - await act(async () => { - renderView(); - }); - expect(observed.length).toBeGreaterThan(0); - await act(async () => { - for (const fire of observed) fire(); - }); - expect(fetchNextPage).toHaveBeenCalled(); - }); - - test("does not observe while a batch is already in flight", async () => { - listResult.hasNextPage = true; - listResult.isFetchingNextPage = true; - await act(async () => { - renderView(); - }); - expect(observed).toHaveLength(0); - }); - - test("does not re-observe after a failed batch, which would spin", async () => { - listResult.hasNextPage = true; - listResult.isFetchNextPageError = true; - await act(async () => { - renderView(); - }); - expect(observed).toHaveLength(0); - }); -}); - -describe("PagesView counts", () => { - test("takes the tab counts from the server, not from what it loaded", async () => { - countsData.all = 42; - const view = await act(async () => renderView()); - const allTab = view.getByRole("tab", { name: /All/ }); - // One loaded page, 42 in the organization: the count is the server's. - expect(allTab.textContent).toContain("42"); - expect(gridProps.pages).toHaveLength(1); - expect(countsInput).toMatchObject({ pinnedIds: PINS }); - }); - - test("offers the workspace filter off the server breakdown", async () => { - const view = await act(async () => renderView()); - expect( - view.queryByRole("button", { name: /Filter by workspace/ }), - ).toBeTruthy(); - }); - - test("hides the workspace filter when no workspace has pages", async () => { - countsData.workspaces = []; - const view = await act(async () => renderView()); - expect( - view.queryByRole("button", { name: /Filter by workspace/ }), - ).toBeNull(); - }); - - test("does not narrow the counts by the scope being counted", async () => { - await act(async () => { - renderView({ scope: "mine" }); - }); - expect(countsInput).not.toHaveProperty("scope"); - }); -}); - -describe("PagesView search", () => { - test("does not strand typed text in the box when another filter changes", async () => { - const view = await act(async () => renderView()); - const box = view.getByPlaceholderText("Search pages") as HTMLInputElement; - - await act(async () => { - fireEvent.change(box, { target: { value: "quarterly" } }); - }); - expect(box.value).toBe("quarterly"); - - // Switching tabs inside the debounce window must not drop the pending - // search: the box would keep showing text the grid was not filtered by. - await act(async () => { - const tab = view.getByRole("tab", { name: /Team/ }); - fireEvent.pointerDown(tab, { button: 0, ctrlKey: false }); - fireEvent.mouseDown(tab, { button: 0, ctrlKey: false }); - fireEvent.click(tab); - }); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 400)); - }); - - expect(onScopeChange).toHaveBeenCalledWith("team"); - expect(onSearchChange).toHaveBeenCalledWith("quarterly"); - }); -}); - -describe("PagesView empty state", () => { - test("does not blank a loaded grid while the counts are still in flight", async () => { - countsData = undefined as never; - const view = await act(async () => renderView()); - expect(gridProps.pages).toHaveLength(1); - expect(view.queryByRole("tab", { name: /All/ })).toBeTruthy(); - }); - - test("keeps the tabs but drops their counts when the organization has no pages", async () => { - listResult.data = { pages: [{ items: [], nextCursor: null }] }; - countsData.all = 0; - const view = await act(async () => renderView()); - expect(view.getByRole("tab", { name: /All/ }).textContent).toBe("All"); - }); -}); - -describe("PagesView error surfacing", () => { - test("keeps the loaded pages on screen when a later batch fails", async () => { - listResult.hasNextPage = true; - listResult.isFetchNextPageError = true; - listResult.error = { message: "network down" }; - await act(async () => { - renderView(); - }); - expect(gridProps.error).toBeUndefined(); - expect(gridProps.pages).toHaveLength(1); - }); - - test("shows the error when the first batch failed and nothing loaded", async () => { - listResult.data = { pages: [] }; - listResult.error = { message: "network down" }; - await act(async () => { - renderView(); - }); - expect(gridProps.error).toBe("network down"); - }); -}); - -describe("PagesView pinned tab", () => { - test("stays on the pinned tab — the server already answered how many", async () => { - await act(async () => { - renderView({ scope: "pinned" }); - }); - expect(onScopeChange).not.toHaveBeenCalled(); - }); - - test("keeps the tab visible when the viewer is sitting on it with nothing pinned", async () => { - countsData.pinned = 0; - const view = await act(async () => renderView({ scope: "pinned" })); - expect(view.queryByText("Pinned")).toBeTruthy(); - expect(onScopeChange).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx index 0a5f7d120d3..1e7dc83113d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx @@ -1,301 +1,44 @@ -import { Trans, useLingui } from "@lingui/react/macro"; -import { COMPANY } from "@superset/shared/constants"; -import { Input } from "@superset/ui/input"; -import { toast } from "@superset/ui/sonner"; -import { Tabs, TabsList, TabsTrigger } from "@superset/ui/tabs"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { LuSearch } from "react-icons/lu"; import { authClient } from "renderer/lib/auth-client"; -import { cloudTrpc } from "renderer/lib/cloud-trpc"; -import { FeatureHeader } from "renderer/routes/_authenticated/_dashboard/components/FeatureHeader"; -import { LoadMoreSentinel } from "renderer/routes/_authenticated/_dashboard/components/LoadMoreSentinel"; import { PageHeader } from "renderer/routes/_authenticated/_dashboard/components/PageHeader"; -import { useDebouncedSearchNavigation } from "renderer/routes/_authenticated/_dashboard/hooks/useDebouncedSearchNavigation"; import { usePageFavorites } from "renderer/routes/_authenticated/_dashboard/hooks/usePageFavorites"; -import { usePagesList } from "renderer/routes/_authenticated/_dashboard/hooks/usePagesList"; -import { pagesListInput } from "renderer/routes/_authenticated/_dashboard/utils/pagesListInput"; import { usePageWorkspaceNames } from "../../hooks/usePageWorkspaceNames"; -import { type PageScope, serverScope } from "../../utils/pageScope"; -import { PagesGrid } from "../PagesGrid"; -import { AuthorFilter, type PageAuthorOption } from "./components/AuthorFilter"; -import { - type PageWorkspaceOption, - WorkspaceFilter, -} from "./components/WorkspaceFilter"; +import { PagesList, type PagesListProps } from "./components/PagesList"; import { useCreatePageWithAgent } from "./hooks/useCreatePageWithAgent"; -const TABS: Array<{ value: PageScope }> = [ - { value: "all" }, - { value: "pinned" }, - { value: "team" }, - { value: "mine" }, -]; - -interface PagesViewProps { - search: string; - scope: PageScope; - authorId: string | null; - workspaceId: string | null; - onSearchChange: (search: string) => void; - onScopeChange: (scope: PageScope) => void; - onAuthorChange: (authorId: string | null) => void; - onWorkspaceChange: (workspaceId: string | null) => void; - onOpenPage: (page: { slug: string }) => void; -} - -export function PagesView({ - search, - scope, - authorId, - workspaceId, - onSearchChange, - onScopeChange, - onAuthorChange, - onWorkspaceChange, - onOpenPage, -}: PagesViewProps) { - const { t } = useLingui(); +type PagesViewProps = Pick< + PagesListProps, + | "search" + | "scope" + | "authorId" + | "workspaceId" + | "onSearchChange" + | "onScopeChange" + | "onAuthorChange" + | "onWorkspaceChange" + | "onOpenPage" +>; + +export function PagesView(props: PagesViewProps) { const { creatingWithAgent, handleCreateWithAgent } = useCreatePageWithAgent(); const { data: session } = authClient.useSession(); - const utils = cloudTrpc.useUtils(); const { favoritePageIds, favoritePageIdSet, toggleFavorite } = usePageFavorites(); - - // The input answers the keystroke; the URL — and so the query — settles. - // - // A pending search is deliberately never cancelled when another filter - // changes: every handler here updates the URL through a functional updater, - // so a search landing after a scope change composes with it instead of - // clobbering it. Cancelling would strand the typed text in the box while the - // grid stayed unfiltered. - const [searchInput, setSearchInput] = useState(search); - useEffect(() => setSearchInput(search), [search]); - const { scheduleSearchNavigation } = - useDebouncedSearchNavigation(onSearchChange); - const handleSearchChange = useCallback( - (value: string) => { - setSearchInput(value); - scheduleSearchNavigation(value); - }, - [scheduleSearchNavigation], - ); - - const filter = useMemo( - () => ({ - ...(search ? { search } : {}), - scope: serverScope(scope), - ...(authorId ? { authorId } : {}), - ...(workspaceId ? { workspaceId } : {}), - ...(scope === "pinned" ? { ids: favoritePageIds } : {}), - }), - [search, scope, authorId, workspaceId, favoritePageIds], - ); - - const pages = usePagesList(filter); - const { items, hasNextPage, isFetchingNextPage, scrollRef, sentinelRef } = - pages; - - const countsQuery = cloudTrpc.page.counts.useQuery({ - ...(search ? { search } : {}), - ...(authorId ? { authorId } : {}), - ...(workspaceId ? { workspaceId } : {}), - pinnedIds: favoritePageIds, - }); - const counts = useMemo( - () => ({ - all: countsQuery.data?.all ?? 0, - pinned: countsQuery.data?.pinned ?? 0, - team: countsQuery.data?.team ?? 0, - mine: countsQuery.data?.mine ?? 0, - }), - [countsQuery.data], - ); - - const listInput = useMemo(() => pagesListInput(filter), [filter]); - const deletePage = cloudTrpc.page.delete.useMutation({ - onMutate: async ({ id }) => { - await utils.page.listPaginated.cancel(listInput); - const previous = utils.page.listPaginated.getInfiniteData(listInput); - utils.page.listPaginated.setInfiniteData(listInput, (old) => - old - ? { - ...old, - pages: old.pages.map((page) => ({ - ...page, - items: page.items.filter((entry) => entry.id !== id), - })), - } - : old, - ); - return { previous }; - }, - onError: (_error, _variables, context) => { - if (context?.previous) { - utils.page.listPaginated.setInfiniteData(listInput, context.previous); - } - }, - onSettled: () => { - void utils.page.listPaginated.invalidate(listInput); - void utils.page.counts.invalidate(); - }, - }); - - const tabLabels: Record = { - all: t({ message: "All" }), - pinned: t({ message: "Pinned" }), - team: t({ message: "Team" }), - mine: t({ message: "Just me" }), - }; - - const currentUserId = session?.user.id; - const authorOptions = useMemo(() => { - const rows = countsQuery.data?.authors ?? []; - return rows - .flatMap((row) => - row.userId - ? [ - { - userId: row.userId, - name: row.name || t({ message: "Unknown" }), - image: row.image, - isCurrentUser: row.userId === currentUserId, - }, - ] - : [], - ) - .sort((a, b) => { - if (a.isCurrentUser !== b.isCurrentUser) - return a.isCurrentUser ? -1 : 1; - return a.name.localeCompare(b.name); - }); - }, [countsQuery.data, currentUserId, t]); - const workspaceNames = usePageWorkspaceNames(); - const workspaceOptions = useMemo( - () => - (countsQuery.data?.workspaces ?? []) - .filter((row) => workspaceNames.has(row.workspaceId)) - .map((row) => ({ - workspaceId: row.workspaceId, - name: workspaceNames.get(row.workspaceId) ?? row.workspaceId, - count: row.count, - })) - .sort((a, b) => a.name.localeCompare(b.name)), - [countsQuery.data, workspaceNames], - ); - - const tabs = useMemo( - () => - TABS.filter( - (tab) => - tab.value !== "pinned" || counts.pinned > 0 || scope === "pinned", - ), - [counts.pinned, scope], - ); - - const hasFilters = - Boolean(search.trim()) || - scope !== "all" || - authorId !== null || - workspaceId !== null; - // From the list, not the counts: those arrive on their own query, and a - // zero default while they are in flight would blank a loaded grid. - const orgEmpty = - !hasFilters && !pages.isPending && !pages.error && items.length === 0; return (
-
-
- Pages} - docsUrl={`${COMPANY.DOCS_URL}/pages`} - onCreate={handleCreateWithAgent} - isCreating={creatingWithAgent} - /> - -
- onScopeChange(value as PageScope)} - > - - {tabs.map((tab) => ( - - {tabLabels[tab.value]} - {!orgEmpty && ( - - {counts[tab.value]} - - )} - - ))} - - - -
- {(workspaceOptions.length > 0 || workspaceId !== null) && ( - - )} - {(authorOptions.length > 1 || authorId !== null) && ( - - )} -
- - handleSearchChange(event.target.value)} - placeholder={t({ - message: "Search pages", - })} - className="h-8 pl-7 text-sm" - /> -
-
-
- - { - await deletePage.mutateAsync({ id: pageId }); - toast.success( - t({ - message: "Page deleted", - }), - ); - }} - /> - - -
-
+
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/components/PagesList/PagesList.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/components/PagesList/PagesList.test.tsx new file mode 100644 index 00000000000..3fdf23db28b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/components/PagesList/PagesList.test.tsx @@ -0,0 +1,388 @@ +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import type { AppRouter } from "@superset/trpc"; +import type { TRPCLink } from "@trpc/client"; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const { QueryClient } = await import("@tanstack/react-query"); +const { TRPCClientError } = await import("@trpc/client"); +const { observable } = await import("@trpc/server/observable"); +const { act, cleanup, fireEvent, render, waitFor } = await import( + "@testing-library/react" +); +const { cloudTrpc } = await import("renderer/lib/cloud-trpc"); +const { PagesList } = await import("./PagesList"); + +type Input = Record; + +interface ListPage { + items: ReturnType[]; + nextCursor: string | null; +} + +interface Counts { + all: number; + team: number; + mine: number; + pinned: number; + workspaces: Array<{ workspaceId: string; count: number }>; + authors: Array<{ + userId: string | null; + name: string | null; + image: string | null; + count: number; + }>; +} + +function page(id: string) { + return { + id, + title: `Page ${id}`, + slug: `page-${id}`, + url: `https://app.superset.sh/page/page-${id}`, + thumbnailUrl: null, + visibility: "org", + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + latestVersion: 1, + sharedVersion: null, + createdByUserId: null, + ownerName: null, + }; +} + +const neverAnswers = () => new Promise(() => {}); + +let listInputs: Input[] = []; +let countsInputs: Input[] = []; +let answerList: (input: Input) => ListPage | Promise; +let answerCounts: () => Counts | Promise; +let counts: Counts; + +const link: TRPCLink = () => (call) => + observable((observer) => { + const input = call.op.input as Input; + Promise.resolve() + .then(() => { + if (call.op.path === "page.listPaginated") { + listInputs.push(input); + return answerList(input); + } + if (call.op.path === "page.counts") { + countsInputs.push(input); + return answerCounts(); + } + throw new Error(`Unexpected call to ${call.op.path}`); + }) + .then( + (data) => { + observer.next({ result: { data } }); + observer.complete(); + }, + (error) => observer.error(TRPCClientError.from(error as Error)), + ); + }); + +/** Lets a test fire the sentinel without a real viewport. */ +let observed: Array<() => void> = []; +class TestIntersectionObserver { + constructor( + private callback: (entries: Array<{ isIntersecting: boolean }>) => void, + ) {} + observe() { + observed.push(() => this.callback([{ isIntersecting: true }])); + } + disconnect() {} +} +const intersectionObserverHost = globalThis as { + IntersectionObserver?: unknown; +}; +const realIntersectionObserver = intersectionObserverHost.IntersectionObserver; +intersectionObserverHost.IntersectionObserver = TestIntersectionObserver; + +const onScopeChange = mock((_scope: string) => {}); +const onSearchChange = mock((_search: string) => {}); +const PINS = ["pin-1", "pin-2"]; + +function renderList({ + scope = "all" as "all" | "pinned" | "team" | "mine", + search = "", + authorId = null as string | null, + workspaceId = null as string | null, +} = {}) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const view = render( + + + , + ); + const settled = () => + waitFor(() => { + expect(listInputs.length).toBeGreaterThan(0); + expect(queryClient.isFetching()).toBe(0); + }); + return { view, settled }; +} + +async function scrollToSentinel() { + await waitFor(() => expect(observed.length).toBeGreaterThan(0)); + const fire = observed; + observed = []; + await act(async () => { + for (const intersect of fire) intersect(); + }); + await waitFor(() => expect(listInputs).toHaveLength(2)); +} + +const loadedPages = (view: ReturnType) => + view.queryAllByText(/^Page /); + +beforeEach(() => { + onScopeChange.mockClear(); + onSearchChange.mockClear(); + observed = []; + listInputs = []; + countsInputs = []; + counts = { + all: 1, + team: 1, + mine: 0, + pinned: 2, + workspaces: [{ workspaceId: "ws-1", count: 3 }], + authors: [ + { userId: "user-1", name: "Ada", image: null, count: 2 }, + { userId: "user-2", name: "Grace", image: null, count: 1 }, + ], + }; + answerList = () => ({ items: [page("a")], nextCursor: null }); + answerCounts = () => counts; +}); + +afterEach(cleanup); +afterAll(async () => { + intersectionObserverHost.IntersectionObserver = realIntersectionObserver; +}); + +describe("PagesList server-side filtering", () => { + test("sends the search to the server instead of filtering what it loaded", async () => { + await renderList({ search: "report" }).settled(); + expect(listInputs[0]).toMatchObject({ search: "report" }); + }); + + test("sends the scope, the author and the workspace too", async () => { + await renderList({ + scope: "team", + authorId: "user-1", + workspaceId: "ws-1", + }).settled(); + expect(listInputs[0]).toMatchObject({ + scope: "team", + authorId: "user-1", + workspaceId: "ws-1", + }); + }); + + test("asks for the pinned tab by id, because pins are not a server column", async () => { + await renderList({ scope: "pinned" }).settled(); + expect(listInputs[0]).toMatchObject({ scope: "all", ids: PINS }); + }); + + test("asks for one batch, not the whole organization", async () => { + await renderList().settled(); + expect(listInputs[0]?.limit).toBe(48); + }); +}); + +describe("PagesList loads on scroll", () => { + beforeEach(() => { + answerList = (input) => + input.cursor + ? { items: [page("b")], nextCursor: null } + : { items: [page("a")], nextCursor: "after-a" }; + }); + + test("does not sweep the organization on mount", async () => { + await renderList().settled(); + expect(listInputs).toHaveLength(1); + }); + + test("fetches the next batch when the sentinel comes into view", async () => { + const { view, settled } = renderList(); + await scrollToSentinel(); + await settled(); + expect(listInputs[1]).toMatchObject({ cursor: "after-a" }); + expect(loadedPages(view)).toHaveLength(2); + }); + + test("does not observe while a batch is already in flight", async () => { + answerList = (input) => + input.cursor + ? neverAnswers() + : { items: [page("a")], nextCursor: "after-a" }; + renderList(); + await scrollToSentinel(); + await act(async () => {}); + expect(observed).toHaveLength(0); + }); + + test("does not re-observe after a failed batch, which would spin", async () => { + answerList = (input) => { + if (input.cursor) throw new Error("network down"); + return { items: [page("a")], nextCursor: "after-a" }; + }; + const { settled } = renderList(); + await scrollToSentinel(); + await settled(); + expect(observed).toHaveLength(0); + expect(listInputs).toHaveLength(2); + }); +}); + +describe("PagesList counts", () => { + test("takes the tab counts from the server, not from what it loaded", async () => { + counts.all = 42; + const { view, settled } = renderList(); + await settled(); + // One loaded page, 42 in the organization: the count is the server's. + expect(view.getByRole("tab", { name: /All/ }).textContent).toContain("42"); + expect(loadedPages(view)).toHaveLength(1); + expect(countsInputs[0]).toMatchObject({ pinnedIds: PINS }); + }); + + test("offers the workspace filter off the server breakdown", async () => { + const { view, settled } = renderList(); + await settled(); + expect( + view.queryByRole("button", { name: /Filter by workspace/ }), + ).toBeTruthy(); + }); + + test("hides the workspace filter when no workspace has pages", async () => { + counts.workspaces = []; + const { view, settled } = renderList(); + await settled(); + expect( + view.queryByRole("button", { name: /Filter by workspace/ }), + ).toBeNull(); + }); + + test("does not narrow the counts by the scope being counted", async () => { + await renderList({ scope: "mine" }).settled(); + expect(countsInputs[0]).not.toHaveProperty("scope"); + }); +}); + +describe("PagesList search", () => { + test("does not strand typed text in the box when another filter changes", async () => { + const { view, settled } = renderList(); + await settled(); + const box = view.getByPlaceholderText("Search pages") as HTMLInputElement; + + await act(async () => { + fireEvent.change(box, { target: { value: "quarterly" } }); + }); + expect(box.value).toBe("quarterly"); + + // Switching tabs inside the debounce window must not drop the pending + // search: the box would keep showing text the grid was not filtered by. + await act(async () => { + const tab = view.getByRole("tab", { name: /Team/ }); + fireEvent.pointerDown(tab, { button: 0, ctrlKey: false }); + fireEvent.mouseDown(tab, { button: 0, ctrlKey: false }); + fireEvent.click(tab); + }); + + await waitFor(() => + expect(onSearchChange).toHaveBeenCalledWith("quarterly"), + ); + expect(onScopeChange).toHaveBeenCalledWith("team"); + }); +}); + +describe("PagesList empty state", () => { + test("does not blank a loaded grid while the counts are still in flight", async () => { + answerCounts = neverAnswers; + const { view } = renderList(); + await waitFor(() => expect(loadedPages(view)).toHaveLength(1)); + expect(view.queryByRole("tab", { name: /All/ })).toBeTruthy(); + }); + + test("keeps the tabs but drops their counts when the organization has no pages", async () => { + answerList = () => ({ items: [], nextCursor: null }); + counts.all = 0; + const { view, settled } = renderList(); + await settled(); + expect(view.getByRole("tab", { name: /All/ }).textContent).toBe("All"); + }); +}); + +describe("PagesList error surfacing", () => { + test("keeps the loaded pages on screen when a later batch fails", async () => { + answerList = (input) => { + if (input.cursor) throw new Error("network down"); + return { items: [page("a")], nextCursor: "after-a" }; + }; + const { view, settled } = renderList(); + await scrollToSentinel(); + await settled(); + expect(view.queryByText("network down")).toBeNull(); + expect(loadedPages(view)).toHaveLength(1); + }); + + test("shows the error when the first batch failed and nothing loaded", async () => { + answerList = () => { + throw new Error("network down"); + }; + const { view, settled } = renderList(); + await settled(); + expect(view.queryByText("network down")).toBeTruthy(); + }); +}); + +describe("PagesList pinned tab", () => { + test("stays on the pinned tab — the server already answered how many", async () => { + await renderList({ scope: "pinned" }).settled(); + expect(onScopeChange).not.toHaveBeenCalled(); + }); + + test("keeps the tab visible when the viewer is sitting on it with nothing pinned", async () => { + counts.pinned = 0; + const { view, settled } = renderList({ scope: "pinned" }); + await settled(); + expect(view.queryByText("Pinned")).toBeTruthy(); + expect(onScopeChange).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/components/PagesList/PagesList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/components/PagesList/PagesList.tsx new file mode 100644 index 00000000000..5fdde44c4ec --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/components/PagesList/PagesList.tsx @@ -0,0 +1,301 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import { COMPANY } from "@superset/shared/constants"; +import { Input } from "@superset/ui/input"; +import { toast } from "@superset/ui/sonner"; +import { Tabs, TabsList, TabsTrigger } from "@superset/ui/tabs"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { LuSearch } from "react-icons/lu"; +import { cloudTrpc } from "renderer/lib/cloud-trpc"; +import { FeatureHeader } from "renderer/routes/_authenticated/_dashboard/components/FeatureHeader"; +import { LoadMoreSentinel } from "renderer/routes/_authenticated/_dashboard/components/LoadMoreSentinel"; +import { useDebouncedSearchNavigation } from "renderer/routes/_authenticated/_dashboard/hooks/useDebouncedSearchNavigation"; +import { usePagesList } from "renderer/routes/_authenticated/_dashboard/hooks/usePagesList"; +import { pagesListInput } from "renderer/routes/_authenticated/_dashboard/utils/pagesListInput"; +import { type PageScope, serverScope } from "../../../../utils/pageScope"; +import { PagesGrid } from "../../../PagesGrid"; +import { AuthorFilter, type PageAuthorOption } from "../AuthorFilter"; +import { type PageWorkspaceOption, WorkspaceFilter } from "../WorkspaceFilter"; + +const TABS: Array<{ value: PageScope }> = [ + { value: "all" }, + { value: "pinned" }, + { value: "team" }, + { value: "mine" }, +]; + +export interface PagesListProps { + search: string; + scope: PageScope; + authorId: string | null; + workspaceId: string | null; + onSearchChange: (search: string) => void; + onScopeChange: (scope: PageScope) => void; + onAuthorChange: (authorId: string | null) => void; + onWorkspaceChange: (workspaceId: string | null) => void; + onOpenPage: (page: { slug: string }) => void; + currentUserId: string | undefined; + favoritePageIds: string[]; + favoritePageIdSet: ReadonlySet; + onTogglePin: (pageId: string) => void; + workspaceNames: Map; + creatingWithAgent: boolean; + onCreateWithAgent: () => void; + searchDebounceMs?: number; +} + +export function PagesList({ + search, + scope, + authorId, + workspaceId, + onSearchChange, + onScopeChange, + onAuthorChange, + onWorkspaceChange, + onOpenPage, + currentUserId, + favoritePageIds, + favoritePageIdSet, + onTogglePin, + workspaceNames, + creatingWithAgent, + onCreateWithAgent, + searchDebounceMs, +}: PagesListProps) { + const { t } = useLingui(); + const utils = cloudTrpc.useUtils(); + + // The input answers the keystroke; the URL — and so the query — settles. + // + // A pending search is deliberately never cancelled when another filter + // changes: every handler here updates the URL through a functional updater, + // so a search landing after a scope change composes with it instead of + // clobbering it. Cancelling would strand the typed text in the box while the + // grid stayed unfiltered. + const [searchInput, setSearchInput] = useState(search); + useEffect(() => setSearchInput(search), [search]); + const { scheduleSearchNavigation } = useDebouncedSearchNavigation( + onSearchChange, + searchDebounceMs, + ); + const handleSearchChange = useCallback( + (value: string) => { + setSearchInput(value); + scheduleSearchNavigation(value); + }, + [scheduleSearchNavigation], + ); + + const filter = useMemo( + () => ({ + ...(search ? { search } : {}), + scope: serverScope(scope), + ...(authorId ? { authorId } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(scope === "pinned" ? { ids: favoritePageIds } : {}), + }), + [search, scope, authorId, workspaceId, favoritePageIds], + ); + + const pages = usePagesList(filter); + const { items, hasNextPage, isFetchingNextPage, scrollRef, sentinelRef } = + pages; + + const countsQuery = cloudTrpc.page.counts.useQuery({ + ...(search ? { search } : {}), + ...(authorId ? { authorId } : {}), + ...(workspaceId ? { workspaceId } : {}), + pinnedIds: favoritePageIds, + }); + const counts = useMemo( + () => ({ + all: countsQuery.data?.all ?? 0, + pinned: countsQuery.data?.pinned ?? 0, + team: countsQuery.data?.team ?? 0, + mine: countsQuery.data?.mine ?? 0, + }), + [countsQuery.data], + ); + + const listInput = useMemo(() => pagesListInput(filter), [filter]); + const deletePage = cloudTrpc.page.delete.useMutation({ + onMutate: async ({ id }) => { + await utils.page.listPaginated.cancel(listInput); + const previous = utils.page.listPaginated.getInfiniteData(listInput); + utils.page.listPaginated.setInfiniteData(listInput, (old) => + old + ? { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.filter((entry) => entry.id !== id), + })), + } + : old, + ); + return { previous }; + }, + onError: (_error, _variables, context) => { + if (context?.previous) { + utils.page.listPaginated.setInfiniteData(listInput, context.previous); + } + }, + onSettled: () => { + void utils.page.listPaginated.invalidate(listInput); + void utils.page.counts.invalidate(); + }, + }); + + const tabLabels: Record = { + all: t({ message: "All" }), + pinned: t({ message: "Pinned" }), + team: t({ message: "Team" }), + mine: t({ message: "Just me" }), + }; + + const authorOptions = useMemo(() => { + const rows = countsQuery.data?.authors ?? []; + return rows + .flatMap((row) => + row.userId + ? [ + { + userId: row.userId, + name: row.name || t({ message: "Unknown" }), + image: row.image, + isCurrentUser: row.userId === currentUserId, + }, + ] + : [], + ) + .sort((a, b) => { + if (a.isCurrentUser !== b.isCurrentUser) + return a.isCurrentUser ? -1 : 1; + return a.name.localeCompare(b.name); + }); + }, [countsQuery.data, currentUserId, t]); + + const workspaceOptions = useMemo( + () => + (countsQuery.data?.workspaces ?? []) + .filter((row) => workspaceNames.has(row.workspaceId)) + .map((row) => ({ + workspaceId: row.workspaceId, + name: workspaceNames.get(row.workspaceId) ?? row.workspaceId, + count: row.count, + })) + .sort((a, b) => a.name.localeCompare(b.name)), + [countsQuery.data, workspaceNames], + ); + + const tabs = useMemo( + () => + TABS.filter( + (tab) => + tab.value !== "pinned" || counts.pinned > 0 || scope === "pinned", + ), + [counts.pinned, scope], + ); + + const hasFilters = + Boolean(search.trim()) || + scope !== "all" || + authorId !== null || + workspaceId !== null; + // From the list, not the counts: those arrive on their own query, and a + // zero default while they are in flight would blank a loaded grid. + const orgEmpty = + !hasFilters && !pages.isPending && !pages.error && items.length === 0; + + return ( +
+
+ Pages} + docsUrl={`${COMPANY.DOCS_URL}/pages`} + onCreate={onCreateWithAgent} + isCreating={creatingWithAgent} + /> + +
+ onScopeChange(value as PageScope)} + > + + {tabs.map((tab) => ( + + {tabLabels[tab.value]} + {!orgEmpty && ( + + {counts[tab.value]} + + )} + + ))} + + + +
+ {(workspaceOptions.length > 0 || workspaceId !== null) && ( + + )} + {(authorOptions.length > 1 || authorId !== null) && ( + + )} +
+ + handleSearchChange(event.target.value)} + placeholder={t({ + message: "Search pages", + })} + className="h-8 pl-7 text-sm" + /> +
+
+
+ + { + await deletePage.mutateAsync({ id: pageId }); + toast.success( + t({ + message: "Page deleted", + }), + ); + }} + /> + + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/components/PagesList/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/components/PagesList/index.ts new file mode 100644 index 00000000000..d6f3da74bdb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/components/PagesList/index.ts @@ -0,0 +1 @@ +export { PagesList, type PagesListProps } from "./PagesList"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/components/PullRequestsTopBar/components/AuthorFilter/AuthorFilter.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/components/PullRequestsTopBar/components/AuthorFilter/AuthorFilter.test.tsx index c42578a31b3..238f73e53c6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/components/PullRequestsTopBar/components/AuthorFilter/AuthorFilter.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/components/PullRequestsTopBar/components/AuthorFilter/AuthorFilter.test.tsx @@ -1,8 +1,5 @@ -import { afterAll, afterEach, expect, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, expect, test } from "bun:test"; -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -17,10 +14,6 @@ const { useState } = await import("react"); const { AuthorFilter } = await import("./AuthorFilter"); afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - function FilterHarness() { const [value, setValue] = useState(null); return ; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/CommentsSection/components/CommentRow/CommentRow.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/CommentsSection/components/CommentRow/CommentRow.test.tsx index a72fcda93e9..70ed3195b92 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/CommentsSection/components/CommentRow/CommentRow.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/CommentsSection/components/CommentRow/CommentRow.test.tsx @@ -1,9 +1,6 @@ -import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, describe, expect, mock, test } from "bun:test"; import type { NormalizedComment } from "../../types"; -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -12,10 +9,6 @@ const { cleanup, fireEvent, render } = await import("@testing-library/react"); const { CommentRow } = await import("./CommentRow"); afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - const comment: NormalizedComment = { id: "comment-1", authorLogin: "reviewer", diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/hooks/useFilesTabActions/useFilesTabActions.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/hooks/useFilesTabActions/useFilesTabActions.test.tsx index 6906d0ce4bd..45b7dd3a861 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/hooks/useFilesTabActions/useFilesTabActions.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/hooks/useFilesTabActions/useFilesTabActions.test.tsx @@ -1,28 +1,36 @@ -import { afterAll, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test"; import type { FileTree, FileTreeDropResult } from "@pierre/trees"; +import type { AppRouter } from "@superset/host-service/trpc"; +import { toast } from "@superset/ui/sonner"; +import { workspaceTrpc } from "@superset/workspace-client"; +import { QueryClient } from "@tanstack/react-query"; +import { TRPCClientError, type TRPCLink } from "@trpc/client"; +import { observable } from "@trpc/server/observable"; import { renderToStaticMarkup } from "react-dom/server"; import { FileMoveContext } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileMoveContext"; import type { FilesTabBridge } from "../useFilesTabBridge"; +import { useFilesTabActions } from "./useFilesTabActions"; let persist: (input: unknown) => Promise = async () => undefined; -const showError = mock(() => {}); -mock.module("@superset/workspace-client", () => ({ - workspaceTrpc: { - filesystem: new Proxy( - {}, - { - get: () => ({ - useMutation: () => ({ - mutateAsync: (input: unknown) => persist(input), - }), - }), +let showError: ReturnType>; + +const hostLink: TRPCLink = () => (call) => + observable((observer) => { + persist(call.op.input).then( + (data) => { + observer.next({ result: { data } }); + observer.complete(); }, - ), - }, -})); -mock.module("@superset/ui/sonner", () => ({ toast: { error: showError } })); -const { useFilesTabActions } = await import("./useFilesTabActions"); -afterAll(() => mock.restore()); + (error: Error) => observer.error(TRPCClientError.from(error)), + ); + }); + +beforeEach(() => { + showError = spyOn(toast, "error").mockImplementation(() => ""); +}); +afterEach(() => { + showError.mockRestore(); +}); function setup(paths: string[]) { let current = true; @@ -45,9 +53,14 @@ function setup(paths: string[]) { return null; } renderToStaticMarkup( - - - , + + + + + , ); return { actions, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDiffPaneTarget/useDiffPaneTarget.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDiffPaneTarget/useDiffPaneTarget.test.ts index 9a36728e067..ed1ee114c37 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDiffPaneTarget/useDiffPaneTarget.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDiffPaneTarget/useDiffPaneTarget.test.ts @@ -1,5 +1,4 @@ -import { afterAll, afterEach, describe, expect, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, describe, expect, test } from "bun:test"; import { createWorkspaceStore, type LayoutNode, @@ -7,10 +6,6 @@ import { } from "@superset/panes"; import type { DiffPaneData, PaneViewerData } from "../../types"; -// happy-dom over the preloaded plain-object document. Process-wide, so this -// unregisters in afterAll to leave the other renderer suites their document. -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -19,10 +14,6 @@ const { act, cleanup, renderHook } = await import("@testing-library/react"); const { useDiffPaneTarget } = await import("./useDiffPaneTarget"); afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - function paneLayout(paneId: string): LayoutNode { return { type: "pane", paneId }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.test.ts index 763c0df90fd..c5b6c1c52a3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.test.ts @@ -1,27 +1,20 @@ import { describe, expect, mock, spyOn, test } from "bun:test"; +import { pointerPassthrough } from "renderer/lib/pointer-passthrough"; +import { BrowserRuntimeRegistryImpl } from "./browserRuntimeRegistry"; const register = mock(async (_input: unknown) => ({ success: true })); const unregister = mock(async () => ({ success: true })); -mock.module("renderer/lib/trpc-client", () => ({ - electronTrpcClient: { - browser: { - register: { mutate: register }, - unregister: { mutate: unregister }, - onAgentActivePanes: { subscribe: () => ({ unsubscribe: () => {} }) }, - }, - browserHistory: { - upsert: { mutate: async () => ({ success: true }) }, - }, +const browserRuntimeRegistry = new BrowserRuntimeRegistryImpl({ + browser: { + register: { mutate: register }, + unregister: { mutate: unregister }, + onAgentActivePanes: { subscribe: () => ({ unsubscribe: () => {} }) }, }, -})); - -( - document.documentElement as unknown as Record -).toggleAttribute = mock(() => {}); - -const { pointerPassthrough } = await import("renderer/lib/pointer-passthrough"); -const { browserRuntimeRegistry } = await import("./browserRuntimeRegistry"); + browserHistory: { + upsert: { mutate: async () => ({ success: true }) }, + }, +} as unknown as ConstructorParameters[0]); describe("browserRuntimeRegistry detached persistence", () => { test("retains its persistence callback for navigation completion after detach", async () => { @@ -147,6 +140,15 @@ describe("browserRuntimeRegistry pointer passthrough", () => { registryInternals.entries.set("passthrough-shown", shown); registryInternals.entries.set("passthrough-parked", parked); + const root = document.documentElement as unknown as { + toggleAttribute?: unknown; + }; + const ownToggleAttribute = Object.getOwnPropertyDescriptor( + root, + "toggleAttribute", + ); + root.toggleAttribute = () => {}; + try { pointerPassthrough.set("test-gesture", true); expect(shown.webview.style.pointerEvents).toBe("none"); @@ -156,6 +158,11 @@ describe("browserRuntimeRegistry pointer passthrough", () => { expect(shown.webview.style.pointerEvents).toBe("auto"); } finally { pointerPassthrough.set("test-gesture", false); + if (ownToggleAttribute) { + Object.defineProperty(root, "toggleAttribute", ownToggleAttribute); + } else { + delete root.toggleAttribute; + } registryInternals.entries.delete("passthrough-shown"); registryInternals.entries.delete("passthrough-parked"); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts index 7afca5ff316..bf5750c6a14 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts @@ -78,7 +78,12 @@ export const BROWSER_ZOOM = Object.freeze({ min: 0.25, max: 5, step: 0.1 }); export type BrowserZoomDirection = "in" | "out" | "reset"; -class BrowserRuntimeRegistryImpl { +type BrowserIpcClient = Pick< + typeof electronTrpcClient, + "browser" | "browserHistory" +>; + +export class BrowserRuntimeRegistryImpl { private entries = new Map(); private listenersByPaneId = new Map void>>(); private foundInPageListenersByPaneId = new Map< @@ -133,7 +138,7 @@ class BrowserRuntimeRegistryImpl { return root; } - constructor() { + constructor(private readonly ipc: BrowserIpcClient = electronTrpcClient) { // Webviews are hoisted to , out of reach of the stylesheet rule // that handles iframes, so the passthrough state is mirrored onto them. pointerPassthrough.subscribe((active) => @@ -151,7 +156,7 @@ class BrowserRuntimeRegistryImpl { } }); - electronTrpcClient.browser.onAgentActivePanes.subscribe(undefined, { + this.ipc.browser.onAgentActivePanes.subscribe(undefined, { onData: ({ paneIds }: { paneIds: string[] }) => { this.agentActivePaneIds = new Set(paneIds); for (const [paneId, entry] of this.entries) { @@ -325,7 +330,7 @@ class BrowserRuntimeRegistryImpl { const webContentsId = webview.getWebContentsId(); if (entry.webContentsId !== webContentsId) { entry.webContentsId = webContentsId; - electronTrpcClient.browser.register + this.ipc.browser.register .mutate({ paneId, webContentsId, workspaceId: entry.workspaceId }) .catch((err) => { console.error("[browserRuntimeRegistry] register failed:", err); @@ -356,7 +361,7 @@ class BrowserRuntimeRegistryImpl { this.refreshZoomState(paneId); const { currentUrl, pageTitle, faviconUrl } = entry.state; if (currentUrl && currentUrl !== "about:blank") { - electronTrpcClient.browserHistory.upsert + this.ipc.browserHistory.upsert .mutate({ url: currentUrl, title: pageTitle, faviconUrl }) .catch((err) => { console.error("[browserRuntimeRegistry] upsert history:", err); @@ -390,7 +395,7 @@ class BrowserRuntimeRegistryImpl { this.setState(paneId, { faviconUrl: favicon }); const { currentUrl, pageTitle } = entry.state; if (currentUrl && currentUrl !== "about:blank") { - electronTrpcClient.browserHistory.upsert + this.ipc.browserHistory.upsert .mutate({ url: currentUrl, title: pageTitle, faviconUrl: favicon }) .catch((err) => { console.error("[browserRuntimeRegistry] upsert favicon:", err); @@ -540,7 +545,7 @@ class BrowserRuntimeRegistryImpl { if (entry.workspaceId !== workspaceId) { entry.workspaceId = workspaceId; if (entry.webContentsId != null) { - electronTrpcClient.browser.register + this.ipc.browser.register .mutate({ paneId, webContentsId: entry.webContentsId, @@ -626,7 +631,7 @@ class BrowserRuntimeRegistryImpl { this.entries.delete(paneId); this.listenersByPaneId.delete(paneId); this.foundInPageListenersByPaneId.delete(paneId); - electronTrpcClient.browser.unregister.mutate({ paneId }).catch((err) => { + this.ipc.browser.unregister.mutate({ paneId }).catch((err) => { console.error( `[browserRuntimeRegistry] unregister failed for ${paneId}:`, err, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.test.tsx index 4c24edbffa4..a525bdb424f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.test.tsx @@ -1,18 +1,11 @@ -import { afterAll, afterEach, beforeEach, expect, spyOn, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); const { act, cleanup, renderHook } = await import("@testing-library/react"); const { prependToDraft, takeRecoveredDraftText, useComposerDraft } = await import("./useComposerDraft"); beforeEach(() => window.localStorage.clear()); afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - test("a composer mounted before the save delay reads text typed into the one it replaces", () => { const connecting = renderHook(() => useComposerDraft("chat-v3-draft:handoff"), diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/PageLinkCard/PageLinkCard.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/PageLinkCard/PageLinkCard.test.tsx index a8ef24e4d8d..d272e8850c8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/PageLinkCard/PageLinkCard.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/PageLinkCard/PageLinkCard.test.tsx @@ -1,10 +1,7 @@ -import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, describe, expect, mock, test } from "bun:test"; import type { AppRouter } from "@superset/trpc"; import type { TRPCLink } from "@trpc/client"; -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -23,10 +20,6 @@ type OpenPage = import("../../providers/ChatPaneActionsProvider").OpenPage; const { PageLinkCard } = await import("./PageLinkCard"); afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - const PAGE_URL = "https://app.superset.sh/page/quarterly-report-a3f9k"; const readable = { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/PageLinkCard/components/ChatPageCard/ChatPageCard.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/PageLinkCard/components/ChatPageCard/ChatPageCard.test.tsx index ab36901f73f..dc163f18e6b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/PageLinkCard/components/ChatPageCard/ChatPageCard.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/PageLinkCard/components/ChatPageCard/ChatPageCard.test.tsx @@ -1,9 +1,6 @@ -import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, describe, expect, mock, test } from "bun:test"; import type { ChatPageCardPage } from "./ChatPageCard"; -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -12,10 +9,6 @@ const { cleanup, fireEvent, render } = await import("@testing-library/react"); const { ChatPageCard } = await import("./ChatPageCard"); afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - const THUMBNAIL = "https://frame.usercontent.test/thumbnail/page-1/3?t=ticket"; const page: ChatPageCardPage = { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/SessionView/hooks/useStableList/useStableList.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/SessionView/hooks/useStableList/useStableList.test.ts index 2afd465bcf5..74bc33f42d9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/SessionView/hooks/useStableList/useStableList.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/SessionView/hooks/useStableList/useStableList.test.ts @@ -1,8 +1,5 @@ -import { afterAll, afterEach, expect, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, expect, test } from "bun:test"; -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -10,10 +7,6 @@ const { cleanup, renderHook } = await import("@testing-library/react"); const { useStableList } = await import("./useStableList"); afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - test("keeps the previous array while its entries are the same", () => { const a = { id: "a" }; const b = { id: "b" }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/hooks/useScrollAnchorKey/useScrollAnchorKey.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/hooks/useScrollAnchorKey/useScrollAnchorKey.test.ts index dcd97eb4223..3f0dd70b612 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/hooks/useScrollAnchorKey/useScrollAnchorKey.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Transcript/hooks/useScrollAnchorKey/useScrollAnchorKey.test.ts @@ -1,10 +1,7 @@ -import { afterAll, afterEach, expect, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, expect, test } from "bun:test"; import type { OutboxEntry } from "@superset/chat/core"; import type { TranscriptRow } from "../../utils/transcriptRows"; -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -12,10 +9,6 @@ const { cleanup, renderHook } = await import("@testing-library/react"); const { useScrollAnchorKey } = await import("./useScrollAnchorKey"); afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - function userRow(key: string): TranscriptRow { return { kind: "item", diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/CommentThread/CommentThread.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/CommentThread/CommentThread.test.tsx index c9e2ebff980..3010dd4b577 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/CommentThread/CommentThread.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/CommentThread/CommentThread.test.tsx @@ -4,77 +4,72 @@ import { beforeEach, describe, expect, - mock, + spyOn, test, } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; - -// happy-dom is process-wide; unregister in afterAll so the shared mock -// document is restored for the other renderer suites. -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); -( - globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } -).IS_REACT_ACT_ENVIRONMENT = true; - -interface ReplyVariables { - workspaceId: string; - commentId: number; - body: string; -} -interface ReplyMutationOptions { - onSuccess?: () => void; - onError?: (error: Error, variables: ReplyVariables) => void; +import type { AppRouter } from "@superset/host-service/trpc"; +import type { TRPCLink } from "@trpc/client"; + +const reactActGlobal = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }; +const previousActEnvironment = reactActGlobal.IS_REACT_ACT_ENVIRONMENT; +reactActGlobal.IS_REACT_ACT_ENVIRONMENT = true; + +const { QueryClient } = await import("@tanstack/react-query"); +const { TRPCClientError } = await import("@trpc/client"); +const { getQueryKey } = await import("@trpc/react-query"); +const { observable } = await import("@trpc/server/observable"); +const { act, cleanup, fireEvent, render, waitFor, within } = await import( + "@testing-library/react" +); +const { toast } = await import("@superset/ui/sonner"); +const { workspaceTrpc } = await import("@superset/workspace-client"); +const { CommentThread } = await import("./CommentThread"); + +interface Reply { + input: unknown; + succeed: () => void; + fail: (error: Error) => void; } -const replyMutate = mock((_variables: ReplyVariables) => {}); -const invalidateThreads = mock((_input: { workspaceId: string }) => {}); -// The component wires its reply mutation once per render; capturing the -// options lets a test settle the request the way tRPC would. -let replyOptions: ReplyMutationOptions = {}; -let replyPending = false; - -mock.module("@superset/workspace-client", () => ({ - workspaceTrpc: { - useUtils: () => ({ - git: { getPullRequestThreads: { invalidate: invalidateThreads } }, - }), - git: { - setReviewThreadResolution: { - useMutation: () => ({ mutate: () => {}, isPending: false }), - }, - replyToReviewThread: { - useMutation: (options: ReplyMutationOptions) => { - replyOptions = options; - return { mutate: replyMutate, isPending: replyPending }; - }, +let replies: Reply[] = []; +let queryClient: InstanceType; +let toastError: ReturnType>; + +const hostLink: TRPCLink = () => (call) => + observable((observer) => { + if (call.op.path !== "git.replyToReviewThread") { + observer.error(TRPCClientError.from(new Error(call.op.path))); + return; + } + replies.push({ + input: call.op.input, + succeed: () => { + observer.next({ result: { data: null } }); + observer.complete(); }, - }, - }, -})); - -// Comment bodies are irrelevant here, and the real renderer pulls in -// react-syntax-highlighter, whose CommonJS build extends React.PureComponent -// at load time. Two earlier suites replace `react` with a hooks-only stub -// for the rest of the process, so loading it from here crashes on CI. -mock.module("renderer/components/CommentMarkdown", () => ({ - CommentMarkdown: ({ body }: { body: string }) => body, -})); - -const { act, cleanup, fireEvent, render, within } = await import( - "@testing-library/react" + fail: (error) => observer.error(TRPCClientError.from(error)), + }); + }); + +const THREADS_KEY = getQueryKey( + workspaceTrpc.git.getPullRequestThreads, + { workspaceId: "ws-1" }, + "query", ); -const { CommentThread } = await import("./CommentThread"); beforeEach(() => { - replyMutate.mockClear(); - invalidateThreads.mockClear(); - replyOptions = {}; - replyPending = false; + replies = []; + queryClient = new QueryClient(); + queryClient.setQueryData(THREADS_KEY, []); + toastError = spyOn(toast, "error").mockImplementation(() => ""); +}); +afterEach(() => { + cleanup(); + queryClient.clear(); + toastError.mockRestore(); }); -afterEach(cleanup); afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); + reactActGlobal.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; }); const COMMENTS = [ @@ -87,13 +82,18 @@ async function setup({ orphaned = false } = {}) { let view!: ReturnType; await act(async () => { view = render( - , + + + , ); }); const ui = within(view.baseElement as HTMLElement); @@ -115,15 +115,16 @@ describe("CommentThread reply", () => { fireEvent.click(replyButton); }); - expect(replyMutate.mock.calls).toEqual([ - [{ workspaceId: "ws-1", commentId: 555, body: "Looks good" }], + expect(replies.map((reply) => reply.input)).toEqual([ + { workspaceId: "ws-1", commentId: 555, body: "Looks good" }, ]); expect((textarea as HTMLTextAreaElement).value).toBe(""); + expect(queryClient.getQueryState(THREADS_KEY)?.isInvalidated).toBe(false); - await act(async () => { - replyOptions.onSuccess?.(); - }); - expect(invalidateThreads.mock.calls).toEqual([[{ workspaceId: "ws-1" }]]); + await act(async () => replies[0]?.succeed()); + await waitFor(() => + expect(queryClient.getQueryState(THREADS_KEY)?.isInvalidated).toBe(true), + ); }); test("Cmd+Enter submits from the textarea", async () => { @@ -133,7 +134,7 @@ describe("CommentThread reply", () => { fireEvent.keyDown(textarea, { key: "Enter", metaKey: true }); }); - expect(replyMutate).toHaveBeenCalledTimes(1); + expect(replies).toHaveLength(1); }); test("keeps Reply disabled until there is a non-blank draft", async () => { @@ -146,15 +147,18 @@ describe("CommentThread reply", () => { }); test("does not post while a reply is already in flight", async () => { - replyPending = true; const { replyButton, textarea, type } = await setup(); + await type("first"); + await act(async () => { + fireEvent.click(replyButton); + }); await type("again"); await act(async () => { fireEvent.keyDown(textarea, { key: "Enter", metaKey: true }); }); expect((replyButton as HTMLButtonElement).disabled).toBe(true); - expect(replyMutate).not.toHaveBeenCalled(); + expect(replies).toHaveLength(1); }); test("keeps the draft when the thread has no comment to reply onto", async () => { @@ -164,7 +168,7 @@ describe("CommentThread reply", () => { fireEvent.click(replyButton); }); - expect(replyMutate).not.toHaveBeenCalled(); + expect(replies).toHaveLength(0); expect((textarea as HTMLTextAreaElement).value).toBe("Orphaned"); }); @@ -176,13 +180,9 @@ describe("CommentThread reply", () => { }); expect((textarea as HTMLTextAreaElement).value).toBe(""); - await act(async () => { - replyOptions.onError?.(new Error("boom"), { - workspaceId: "ws-1", - commentId: 555, - body: "Looks good", - }); - }); - expect((textarea as HTMLTextAreaElement).value).toBe("Looks good"); + await act(async () => replies[0]?.fail(new Error("boom"))); + await waitFor(() => + expect((textarea as HTMLTextAreaElement).value).toBe("Looks good"), + ); }); }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.test.tsx index f0a3ce28562..7f418152490 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.test.tsx @@ -7,17 +7,16 @@ import { mock, test, } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; import type { CodeViewItem } from "@pierre/diffs"; +import type { AppRouter } from "@superset/host-service/trpc"; +import type { TRPCLink } from "@trpc/client"; import type { ReactNode } from "react"; import type { ChangesetFile } from "../../../../../useChangeset"; import type { DiffAnnotationMetadata } from "../useDiffAnnotations"; -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); -( - globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } -).IS_REACT_ACT_ENVIRONMENT = true; +const reactActGlobal = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }; +const previousActEnvironment = reactActGlobal.IS_REACT_ACT_ENVIRONMENT; +reactActGlobal.IS_REACT_ACT_ENVIRONMENT = true; /** What the fake host answers `git.getDiffPatch` with, per category. */ const patchByCategory = new Map(); @@ -38,27 +37,33 @@ const getDiffPatch = mock(async (input: { category: string }) => { }); let releaseHeldRequest: () => void = () => {}; -const actualWorkspaceClient = await import("@superset/workspace-client"); -mock.module("@superset/workspace-client", () => ({ - ...actualWorkspaceClient, - useWorkspaceClient: () => ({ - trpcClient: { git: { getDiffPatch: { query: getDiffPatch } } }, - }), - workspaceTrpc: { - git: { - getDiffPatch: { _def: () => ({ path: ["git", "getDiffPatch"] }) }, - }, - }, -})); - const { act, cleanup, renderHook, waitFor } = await import( "@testing-library/react" ); const { QueryClient, QueryClientProvider } = await import( "@tanstack/react-query" ); +const { observable } = await import("@trpc/server/observable"); +const { workspaceTrpc } = await import("@superset/workspace-client"); const { useDiffCodeViewItems } = await import("./useDiffCodeViewItems"); +const hostLink: TRPCLink = () => (call) => + observable((observer) => { + void getDiffPatch(call.op.input as { category: string }).then((data) => { + observer.next({ result: { data } }); + observer.complete(); + }); + }); + +function hostWrapper(client: InstanceType) { + const trpcClient = workspaceTrpc.createClient({ links: [hostLink] }); + return ({ children }: { children: ReactNode }) => ( + + {children} + + ); +} + const FILE_A = [ "diff --git a/a.ts b/a.ts", "index 0000001..0000002 100644", @@ -150,9 +155,7 @@ function renderItems(files: ChangesetFile[]) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); - const wrapper = ({ children }: { children: ReactNode }) => ( - {children} - ); + const wrapper = hostWrapper(client); const rendered = renderHook( (props: Parameters[0]) => useDiffCodeViewItems(props), @@ -170,7 +173,7 @@ beforeEach(() => { afterEach(cleanup); afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); + reactActGlobal.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; }); describe("useDiffCodeViewItems", () => { @@ -233,9 +236,7 @@ describe("useDiffCodeViewItems", () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); - const wrapper = ({ children }: { children: ReactNode }) => ( - {children} - ); + const wrapper = hostWrapper(client); const paneA = renderHook(() => useDiffCodeViewItems(options(files)), { wrapper, }); @@ -269,9 +270,7 @@ describe("useDiffCodeViewItems", () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); - const wrapper = ({ children }: { children: ReactNode }) => ( - {children} - ); + const wrapper = hostWrapper(client); holdNextRequest = () => {}; const paneA = renderHook( () => useDiffCodeViewItems(options([unstagedFile("a.ts")])), diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts index eab04712497..acc422de8bc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts @@ -4,7 +4,7 @@ import type { FileDiffMetadata, LineAnnotation, } from "@pierre/diffs"; -import { useWorkspaceClient, workspaceTrpc } from "@superset/workspace-client"; +import { workspaceTrpc } from "@superset/workspace-client"; import { useQueries } from "@tanstack/react-query"; import { getQueryKey } from "@trpc/react-query"; import { @@ -168,7 +168,7 @@ export function useDiffCodeViewItems({ annotationsByPath, extraAnnotationsByItemId, }: UseDiffCodeViewItemsOptions): UseDiffCodeViewItemsResult { - const { trpcClient } = useWorkspaceClient(); + const { client: trpcClient } = workspaceTrpc.useUtils(); // Generated artifacts (lockfiles, compiled catalogs) stay collapsed behind // a button: their patches are tens of thousands of hunk lines of noise, // and the compiled ones are single multi-megabyte lines, which diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/hooks/usePendingFilePosition/usePendingFilePosition.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/hooks/usePendingFilePosition/usePendingFilePosition.test.tsx index 21683e78fd4..e948acdd51c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/hooks/usePendingFilePosition/usePendingFilePosition.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/hooks/usePendingFilePosition/usePendingFilePosition.test.tsx @@ -1,18 +1,11 @@ -import { afterAll, afterEach, describe, expect, it, mock } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, describe, expect, it, mock } from "bun:test"; import { usePendingFilePosition } from "./usePendingFilePosition"; -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; const { cleanup, renderHook } = await import("@testing-library/react"); afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - function options() { return { editorRef: { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneHeaderExtras/components/PageWatcherMenu/PageWatcherMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneHeaderExtras/components/PageWatcherMenu/PageWatcherMenu.tsx index a86e41abce5..be653a67922 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneHeaderExtras/components/PageWatcherMenu/PageWatcherMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneHeaderExtras/components/PageWatcherMenu/PageWatcherMenu.tsx @@ -1,38 +1,16 @@ -import { Plural, Trans, useLingui } from "@lingui/react/macro"; -import { errorMessage } from "@superset/i18n/errors"; -import { Button } from "@superset/ui/button"; -import { useFramePointerDown } from "@superset/ui/page-comments"; -import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; -import { toast } from "@superset/ui/sonner"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; -import { Bot, LoaderCircle } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; -import { HiMiniXMark } from "react-icons/hi2"; import { type PageWatcherRow, usePageWatchersForPage, } from "renderer/hooks/host-service/usePageWatchersForPage"; -import { - type TerminalAgentBinding, - useTerminalAgentBindings, -} from "renderer/hooks/host-service/useTerminalAgentBindings"; +import { useTerminalAgentBindings } from "renderer/hooks/host-service/useTerminalAgentBindings"; import { useWorkspaceHostUrl } from "renderer/hooks/host-service/useWorkspaceHostUrl"; import { useV2AgentConfigs } from "renderer/hooks/useV2AgentConfigs"; -import { cloudTrpc } from "renderer/lib/cloud-trpc"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; -import { - AgentSessionPicker, - type AgentTarget, - useAgentSessionTarget, -} from "renderer/routes/_authenticated/_dashboard/components/AgentSessionPicker"; +import { AgentSessionPicker } from "renderer/routes/_authenticated/_dashboard/components/AgentSessionPicker"; import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; -import { AgentIcon } from "renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentIcon"; import type { CreateNewAgentSession } from "../../../../../useAgentSessionLauncher"; -import { waitForPageAgent } from "./utils/waitForPageAgent"; - -const WATCHING_REFRESH_MS = 30_000; -const IDLE_REFRESH_MS = 5 * 60_000; +import { PageWatcherPopover } from "./components/PageWatcherPopover"; interface PageWatcherMenuProps { workspaceId: string; @@ -47,276 +25,36 @@ export function PageWatcherMenu({ canManage, onCreateNewAgentSession, }: PageWatcherMenuProps) { - const { t } = useLingui(); const navigate = useNavigate(); const watchers = usePageWatchersForPage({ pageId, workspaceId }); const hostUrl = useWorkspaceHostUrl(workspaceId); const { data: configs = [] } = useV2AgentConfigs(hostUrl); const bindings = useTerminalAgentBindings(workspaceId); - const availableAgents = useMemo( - () => - Array.from(bindings.values()) - .filter( - (binding) => - !watchers.some( - (watcher) => - watcher.hostUrl === hostUrl && - watcher.terminalId === binding.terminalId, - ), - ) - .sort((a, b) => b.lastEventAt - a.lastEventAt), - [bindings, watchers, hostUrl], - ); - const { value, resolved, onValueChange } = useAgentSessionTarget({ - sessions: availableAgents, - configs, - }); - const queryClient = useQueryClient(); - const cloudUtils = cloudTrpc.useUtils(); - const [menuOpen, setMenuOpen] = useState(false); - - const refreshWatchers = () => { - void queryClient.invalidateQueries({ queryKey: ["page-watchers-by-host"] }); - void queryClient.invalidateQueries({ queryKey: ["page-watchers"] }); - if (pageId) void cloudUtils.page.get.invalidate({ id: pageId }); - }; - - const unwatch = useMutation({ - mutationFn: async (watcher: PageWatcherRow) => - await getHostServiceClientByUrl(watcher.hostUrl).pageWatch.unwatch.mutate( - { pageId: pageId ?? "" }, - ), - onSettled: refreshWatchers, - onError: (error) => - toast.error(t({ message: "Could not stop watching" }), { - description: errorMessage(error), - }), - }); - - useFramePointerDown(useCallback(() => setMenuOpen(false), [])); - - // The page row's own flag is the org-wide answer, and the only thing that - // knows about a watcher on a host this machine cannot reach. - const cloudWatch = cloudTrpc.page.get.useQuery( - { id: pageId ?? "" }, - { - enabled: Boolean(pageId), - refetchInterval: (query) => - query.state.data?.watch.watching - ? WATCHING_REFRESH_MS - : IDLE_REFRESH_MS, - }, - ); - - const unconfirmedWatch = - watchers.length === 0 && cloudWatch.data?.watch.watching === true; - - const assign = useMutation({ - mutationFn: async (binding: TerminalAgentBinding) => { - const page = cloudWatch.data; - if (!hostUrl || !page || !canManage) return; - await getHostServiceClientByUrl(hostUrl).pageWatch.assign.mutate({ - pageId: page.id, - slug: page.slug, - title: page.title, - workspaceId, - terminalId: binding.terminalId, - agentId: binding.agentId, - }); - }, - onSuccess: () => setMenuOpen(false), - onSettled: refreshWatchers, - onError: (error) => - toast.error(t({ message: "Could not add agent" }), { - description: errorMessage(error), - }), - }); - - const launch = useMutation({ - mutationFn: async ({ - configId, - placement, - }: Extract) => { - const page = cloudWatch.data; - if (!hostUrl || !page || !canManage) return; - const client = getHostServiceClientByUrl(hostUrl); - const result = await onCreateNewAgentSession({ - configId, - placement, - prompt: `Watch the Superset Page with ID ${page.id} for reader comments. Read it with superset pages get ${page.id}. Load the Superset Pages skill (superset-page or superset:page) and follow it when handling comments. The app will register this terminal as the page watcher. Do not republish or change the page until a reader requests a change. After reading, wait for comments to be delivered to this session.`, - }); - if (!result) return; - const binding = await waitForPageAgent({ - client, - workspaceId, - terminalId: result.terminalId, - }); - if (!binding) throw new Error(t({ message: "Could not start agent" })); - await client.pageWatch.assign.mutate({ - pageId: page.id, - slug: page.slug, - title: page.title, - workspaceId, - terminalId: binding.terminalId, - agentId: binding.agentId, - }); - }, - onSettled: refreshWatchers, - onError: (error) => - toast.error(t({ message: "Could not add agent" }), { - description: errorMessage(error), - }), - }); - - const pending = assign.isPending || launch.isPending || unwatch.isPending; - const submit = () => { - if (!resolved || pending || !hostUrl || !cloudWatch.data || !canManage) - return; - if (resolved.kind === "new") { - launch.mutate(resolved); - } else { - const binding = bindings.get(resolved.terminalId); - if (binding) assign.mutate(binding); - } - }; - const open = (watcher: PageWatcherRow) => { + const openWatcher = (watcher: PageWatcherRow) => { void navigateToV2Workspace(watcher.workspaceId, navigate, { search: { terminalId: watcher.terminalId, focusRequestId: crypto.randomUUID(), }, }); - setMenuOpen(false); }; return ( - - - - - -
- Listening agents -
-
- {watchers.length > 0 ? ( - - ) : unconfirmedWatch ? ( - Listening status unavailable - ) : ( - No listening agents - )} -
-
- {watchers.map((watcher) => { - const navigable = watcher.workspaceName !== null; - return ( -
- - -
- ); - })} - {canManage ? ( -
{ - event.preventDefault(); - submit(); - }} - > -
- - {availableAgents.length === 0 && configs.length === 0 ? ( - - No agents yet - - ) : null} - -
-
- ) : null} - - + ( + + )} + /> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneHeaderExtras/components/PageWatcherMenu/PageWatcherMenu.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneHeaderExtras/components/PageWatcherMenu/components/PageWatcherPopover/PageWatcherPopover.test.tsx similarity index 75% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneHeaderExtras/components/PageWatcherMenu/PageWatcherMenu.test.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneHeaderExtras/components/PageWatcherMenu/components/PageWatcherPopover/PageWatcherPopover.test.tsx index 70274ebd54e..f0a45a0101e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneHeaderExtras/components/PageWatcherMenu/PageWatcherMenu.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneHeaderExtras/components/PageWatcherMenu/components/PageWatcherPopover/PageWatcherPopover.test.tsx @@ -4,93 +4,88 @@ import { beforeEach, describe, expect, - mock, test, } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import type { HostAgentConfig } from "@superset/host-service/settings"; +import type { AppRouter } from "@superset/trpc"; +import type { TRPCLink } from "@trpc/client"; +import type { PageWatcherRow } from "renderer/hooks/host-service/usePageWatchersForPage"; +import type { TerminalAgentBinding } from "renderer/hooks/host-service/useTerminalAgentBindings"; +import type { HostServiceClient } from "renderer/lib/host-service-client"; -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; +const { useState } = await import("react"); +const { toast } = await import("@superset/ui/sonner"); +const { QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" +); +const { getQueryKey } = await import("@trpc/react-query"); +const { observable } = await import("@trpc/server/observable"); +const { act, cleanup, fireEvent, render, waitFor, within } = await import( + "@testing-library/react" +); +const { setAgentSessionPlacement } = await import( + "renderer/hooks/useAgentSessionPlacement" +); +const { cloudTrpc } = await import("renderer/lib/cloud-trpc"); +const { AgentSessionSelect } = await import( + "renderer/routes/_authenticated/_dashboard/components/AgentSessionPicker" +); +const { PageWatcherPopover } = await import("./PageWatcherPopover"); +type AgentPickerSlotProps = import("./PageWatcherPopover").AgentPickerSlotProps; + const PAGE_ID = "5d3f2a1e-0c7b-4a2d-9f11-6b8c0d4e7a52"; const WORKSPACE_ID = "ws-local"; +const HOST_URL = "http://host-local"; -interface Row { - workspaceId: string; - workspaceName: string | null; - terminalId: string; - agentId: string | null; - sessionTitle: string | null; - hostId: string; - hostUrl: string; -} +type Row = PageWatcherRow; type CloudWatch = { watching: boolean; agentId: string | null }; let rows: Row[] = []; -let terminalSessions: Array<{ terminalId: string; title: string | null }> = []; -let bindings = new Map(); +let sessionTitles = new Map(); +let bindings = new Map(); let assigned: unknown[] = []; let assignError: Error | undefined; let errors: string[] = []; -let configs: Array<{ id: string; label: string; presetId: string }> = []; +let configs: HostAgentConfig[] = []; let launched: unknown[] = []; let launchResult: { terminalId: string } | null = { terminalId: "new-terminal", }; let bindingPolls = 0; let bindingDelay = 0; -mock.module("renderer/assets/app-icons/preset-icons", () => ({ - usePresetIcon: () => null, -})); -mock.module("renderer/hooks/useV2AgentConfigs", () => ({ - useV2AgentConfigs: () => ({ data: configs }), -})); +let cloudWatch: CloudWatch = { watching: false, agentId: null }; +let navigated: Array<{ workspaceId: string; terminalId: string }> = []; +let unwatched: Array<{ hostUrl: string; pageId: string }> = []; + const createNewAgentSession = async (input: unknown) => { launched.push(input); return launchResult; }; -mock.module("renderer/hooks/host-service/useTerminalAgentBindings", () => ({ - useTerminalAgentBindings: () => bindings, -})); -mock.module("renderer/hooks/host-service/useWorkspaceHostUrl", () => ({ - useWorkspaceHostUrl: () => "http://host-local", -})); -mock.module("@superset/ui/sonner", () => ({ - toast: { error: (message: string) => errors.push(message) }, -})); -let cloudWatch: CloudWatch = { watching: false, agentId: null }; -let navigated: Array<{ workspaceId: string; terminalId: string | undefined }> = - []; -let unwatched: Array<{ hostUrl: string; pageId: string }> = []; -mock.module("renderer/hooks/host-service/usePageWatchersForPage", () => ({ - usePageWatchersForPage: () => rows, -})); -mock.module("renderer/lib/cloud-trpc", () => ({ - cloudTrpc: { - page: { - get: { - useQuery: () => ({ - data: { - id: PAGE_ID, - slug: "my-page", - title: "My page", - watch: cloudWatch, - }, - }), - }, - }, - useUtils: () => ({ - page: { get: { invalidate: () => Promise.resolve() } }, - }), - }, -})); -mock.module("renderer/lib/host-service-client", () => ({ - getHostServiceClientByUrl: (hostUrl: string) => ({ - terminal: { list: { query: async () => ({ sessions: terminalSessions }) } }, +const realToastError = toast.error; +toast.error = ((message: string) => { + errors.push(message); +}) as typeof toast.error; + +const cloudPage = () => ({ + id: PAGE_ID, + slug: "my-page", + title: "My page", + watch: cloudWatch, +}); + +const pageLink: TRPCLink = () => () => + observable((observer) => { + observer.next({ result: { data: cloudPage() } }); + observer.complete(); + }); + +const getHostClient = (hostUrl: string) => + ({ terminalAgents: { listByWorkspace: { query: async () => { @@ -117,54 +112,48 @@ mock.module("renderer/lib/host-service-client", () => ({ }, }, }, - }), -})); -mock.module("@tanstack/react-router", () => ({ - useNavigate: () => () => Promise.resolve(), -})); -mock.module( - "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation", - () => ({ - navigateToV2Workspace: ( - workspaceId: string, - _navigate: unknown, - options?: { search?: { terminalId?: string } }, - ) => { - navigated.push({ - workspaceId, - terminalId: options?.search?.terminalId, - }); - return Promise.resolve(); - }, - }), -); -mock.module( - "renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentIcon", - () => ({ AgentIcon: () => null }), -); + }) as unknown as Pick; + +function AgentPicker(props: AgentPickerSlotProps) { + const [open, setOpen] = useState(false); + return ( + + ); +} -const { act, cleanup, fireEvent, render, waitFor, within } = await import( - "@testing-library/react" -); -const { QueryClient, QueryClientProvider } = await import( - "@tanstack/react-query" -); -const { PageWatcherMenu } = await import("./PageWatcherMenu"); +function binding(terminalId: string, lastEventAt: number) { + return { + terminalId, + workspaceId: WORKSPACE_ID, + agentId: "codex", + lastEventAt, + } as TerminalAgentBinding; +} + +function agentConfig(id: string, label: string, presetId: string) { + return { id, label, presetId } as HostAgentConfig; +} afterEach(async () => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); cleanup(); }); + localStorage.clear(); }); afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); + toast.error = realToastError; }); beforeEach(() => { localStorage.clear(); rows = []; - terminalSessions = []; + sessionTitles = new Map(); configs = []; launched = []; launchResult = { terminalId: "new-terminal" }; @@ -193,16 +182,37 @@ function watcher(overrides: Partial = {}): Row { } async function renderMenu(canManage = true) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { staleTime: Number.POSITIVE_INFINITY } }, + }); + queryClient.setQueryData( + getQueryKey(cloudTrpc.page.get, { id: PAGE_ID }, "query"), + cloudPage(), + ); let view!: ReturnType; await act(async () => { view = render( - - + + + + navigated.push({ workspaceId, terminalId }) + } + renderAgentPicker={(picker) => } + /> + , ); }); @@ -345,11 +355,7 @@ describe("cloud listening status without a confirmed live session", () => { describe("assigning an existing workspace agent", () => { beforeEach(() => { - bindings.set("local-term", { - terminalId: "local-term", - agentId: "codex", - lastEventAt: 1, - }); + bindings.set("local-term", binding("local-term", 1)); }); test("lists an agent even when nothing watches the page and assigns it on the workspace host", async () => { @@ -359,7 +365,7 @@ describe("assigning an existing workspace agent", () => { }); expect(assigned).toEqual([ { - hostUrl: "http://host-local", + hostUrl: HOST_URL, input: { pageId: PAGE_ID, slug: "my-page", @@ -382,9 +388,7 @@ describe("assigning an existing workspace agent", () => { }); test("does not offer an agent that already watches this page", async () => { - rows = [ - watcher({ hostUrl: "http://host-local", terminalId: "local-term" }), - ]; + rows = [watcher({ hostUrl: HOST_URL, terminalId: "local-term" })]; const ui = await openMenu(); expect(ui.queryByText("codex")).toBeNull(); }); @@ -404,9 +408,7 @@ describe("assigning an existing workspace agent", () => { describe("starting a page watcher", () => { beforeEach(() => { - configs = [ - { id: "claude-config", label: "Claude Code", presetId: "claude" }, - ]; + configs = [agentConfig("claude-config", "Claude Code", "claude")]; }); test("launches a configured agent when no agent exists, then assigns its actual binding", async () => { @@ -423,7 +425,7 @@ describe("starting a page watcher", () => { ]); expect(assigned).toEqual([ { - hostUrl: "http://host-local", + hostUrl: HOST_URL, input: { pageId: PAGE_ID, slug: "my-page", @@ -443,11 +445,8 @@ describe("starting a page watcher", () => { fireEvent.click(ui.getByRole("button", { name: "Add listening agent" })); }); expect(assigned).toEqual([]); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 300)); - }); + await waitFor(() => expect(assigned).toHaveLength(1)); expect(bindingPolls).toBe(2); - expect(assigned).toHaveLength(1); }); test("does not assign a watcher when launch fails", async () => { @@ -483,12 +482,9 @@ describe("starting a page watcher", () => { }); test("uses the Settings placement for a new session", async () => { - configs = [{ id: "claude-config", label: "Claude Code", presetId: "claude" }]; + configs = [agentConfig("claude-config", "Claude Code", "claude")]; const ui = await openMenu(); await act(async () => { - const { setAgentSessionPlacement } = await import( - "renderer/hooks/useAgentSessionPlacement" - ); setAgentSessionPlacement("new-tab"); }); await act(async () => { @@ -500,8 +496,8 @@ test("uses the Settings placement for a new session", async () => { test("filters agent choices and selects without launching", async () => { configs = [ - { id: "claude-config", label: "Claude Code", presetId: "claude" }, - { id: "codex-config", label: "Codex", presetId: "codex" }, + agentConfig("claude-config", "Claude Code", "claude"), + agentConfig("codex-config", "Codex", "codex"), ]; const ui = await openMenu(); await act(async () => { @@ -529,16 +525,8 @@ test("filters agent choices and selects without launching", async () => { }); test("distinguishes multiple sessions of the same agent after selection", async () => { - bindings.set("first-terminal", { - terminalId: "first-terminal", - agentId: "codex", - lastEventAt: 2, - }); - bindings.set("second-terminal", { - terminalId: "second-terminal", - agentId: "codex", - lastEventAt: 1, - }); + bindings.set("first-terminal", binding("first-terminal", 2)); + bindings.set("second-terminal", binding("second-terminal", 1)); const ui = await openMenu(); await act(async () => { fireEvent.click(ui.getByRole("button", { name: "Choose agent" })); @@ -560,26 +548,17 @@ test("distinguishes multiple sessions of the same agent after selection", async test("searches session names and keeps the selected name in the preview", async () => { for (const terminalId of ["first-terminal", "second-terminal"]) { - bindings.set(terminalId, { - terminalId, - workspaceId: WORKSPACE_ID, - agentId: "codex", - lastEventAt: 1, - }); + bindings.set(terminalId, binding(terminalId, 1)); } - terminalSessions = [ - { terminalId: "first-terminal", title: "Review page layout" }, - { terminalId: "second-terminal", title: "Fix authentication" }, - ]; + sessionTitles = new Map([ + ["first-terminal", "Review page layout"], + ["second-terminal", "Fix authentication"], + ]); const ui = await openMenu(); await act(async () => { fireEvent.click(ui.getByRole("button", { name: "Choose agent" })); }); - await waitFor(() => - expect( - ui.getByRole("option", { name: "Fix authentication" }), - ).toBeDefined(), - ); + expect(ui.getByRole("option", { name: "Fix authentication" })).toBeDefined(); await act(async () => { fireEvent.change(ui.getByRole("combobox"), { target: { value: "authentication" }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneHeaderExtras/components/PageWatcherMenu/components/PageWatcherPopover/PageWatcherPopover.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneHeaderExtras/components/PageWatcherMenu/components/PageWatcherPopover/PageWatcherPopover.tsx new file mode 100644 index 00000000000..5f343eb1113 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneHeaderExtras/components/PageWatcherMenu/components/PageWatcherPopover/PageWatcherPopover.tsx @@ -0,0 +1,324 @@ +import { Plural, Trans, useLingui } from "@lingui/react/macro"; +import type { HostAgentConfig } from "@superset/host-service/settings"; +import { errorMessage } from "@superset/i18n/errors"; +import { Button } from "@superset/ui/button"; +import { useFramePointerDown } from "@superset/ui/page-comments"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { toast } from "@superset/ui/sonner"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Bot, LoaderCircle } from "lucide-react"; +import { type ReactNode, useCallback, useMemo, useState } from "react"; +import { HiMiniXMark } from "react-icons/hi2"; +import type { PageWatcherRow } from "renderer/hooks/host-service/usePageWatchersForPage"; +import type { TerminalAgentBinding } from "renderer/hooks/host-service/useTerminalAgentBindings"; +import { cloudTrpc } from "renderer/lib/cloud-trpc"; +import type { HostServiceClient } from "renderer/lib/host-service-client"; +import { + type AgentTarget, + useAgentSessionTarget, +} from "renderer/routes/_authenticated/_dashboard/components/AgentSessionPicker"; +import { AgentIcon } from "renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentIcon"; +import type { CreateNewAgentSession } from "../../../../../../../useAgentSessionLauncher"; +import { waitForPageAgent } from "../../utils/waitForPageAgent"; + +const WATCHING_REFRESH_MS = 30_000; +const IDLE_REFRESH_MS = 5 * 60_000; + +export interface AgentPickerSlotProps { + value: string | null; + onValueChange: (next: string) => void; + sessions: TerminalAgentBinding[]; + configs: HostAgentConfig[]; +} + +interface PageWatcherPopoverProps { + workspaceId: string; + pageId: string | undefined; + canManage: boolean; + onCreateNewAgentSession: CreateNewAgentSession; + watchers: PageWatcherRow[]; + hostUrl: string | null; + configs: HostAgentConfig[]; + bindings: Map; + getHostClient: ( + hostUrl: string, + ) => Pick; + onOpenWatcher: (watcher: PageWatcherRow) => void; + renderAgentPicker: (props: AgentPickerSlotProps) => ReactNode; +} + +export function PageWatcherPopover({ + workspaceId, + pageId, + canManage, + onCreateNewAgentSession, + watchers, + hostUrl, + configs, + bindings, + getHostClient, + onOpenWatcher, + renderAgentPicker, +}: PageWatcherPopoverProps) { + const { t } = useLingui(); + const availableAgents = useMemo( + () => + Array.from(bindings.values()) + .filter( + (binding) => + !watchers.some( + (watcher) => + watcher.hostUrl === hostUrl && + watcher.terminalId === binding.terminalId, + ), + ) + .sort((a, b) => b.lastEventAt - a.lastEventAt), + [bindings, watchers, hostUrl], + ); + const { value, resolved, onValueChange } = useAgentSessionTarget({ + sessions: availableAgents, + configs, + }); + const queryClient = useQueryClient(); + const cloudUtils = cloudTrpc.useUtils(); + const [menuOpen, setMenuOpen] = useState(false); + + const refreshWatchers = () => { + void queryClient.invalidateQueries({ queryKey: ["page-watchers-by-host"] }); + void queryClient.invalidateQueries({ queryKey: ["page-watchers"] }); + if (pageId) void cloudUtils.page.get.invalidate({ id: pageId }); + }; + + const unwatch = useMutation({ + mutationFn: async (watcher: PageWatcherRow) => + await getHostClient(watcher.hostUrl).pageWatch.unwatch.mutate({ + pageId: pageId ?? "", + }), + onSettled: refreshWatchers, + onError: (error) => + toast.error(t({ message: "Could not stop watching" }), { + description: errorMessage(error), + }), + }); + + useFramePointerDown(useCallback(() => setMenuOpen(false), [])); + + // The page row's own flag is the org-wide answer, and the only thing that + // knows about a watcher on a host this machine cannot reach. + const cloudWatch = cloudTrpc.page.get.useQuery( + { id: pageId ?? "" }, + { + enabled: Boolean(pageId), + refetchInterval: (query) => + query.state.data?.watch.watching + ? WATCHING_REFRESH_MS + : IDLE_REFRESH_MS, + }, + ); + + const unconfirmedWatch = + watchers.length === 0 && cloudWatch.data?.watch.watching === true; + + const assign = useMutation({ + mutationFn: async (binding: TerminalAgentBinding) => { + const page = cloudWatch.data; + if (!hostUrl || !page || !canManage) return; + await getHostClient(hostUrl).pageWatch.assign.mutate({ + pageId: page.id, + slug: page.slug, + title: page.title, + workspaceId, + terminalId: binding.terminalId, + agentId: binding.agentId, + }); + }, + onSuccess: () => setMenuOpen(false), + onSettled: refreshWatchers, + onError: (error) => + toast.error(t({ message: "Could not add agent" }), { + description: errorMessage(error), + }), + }); + + const launch = useMutation({ + mutationFn: async ({ + configId, + placement, + }: Extract) => { + const page = cloudWatch.data; + if (!hostUrl || !page || !canManage) return; + const client = getHostClient(hostUrl); + const result = await onCreateNewAgentSession({ + configId, + placement, + prompt: `Watch the Superset Page with ID ${page.id} for reader comments. Read it with superset pages get ${page.id}. Load the Superset Pages skill (superset-page or superset:page) and follow it when handling comments. The app will register this terminal as the page watcher. Do not republish or change the page until a reader requests a change. After reading, wait for comments to be delivered to this session.`, + }); + if (!result) return; + const binding = await waitForPageAgent({ + client, + workspaceId, + terminalId: result.terminalId, + }); + if (!binding) throw new Error(t({ message: "Could not start agent" })); + await client.pageWatch.assign.mutate({ + pageId: page.id, + slug: page.slug, + title: page.title, + workspaceId, + terminalId: binding.terminalId, + agentId: binding.agentId, + }); + }, + onSettled: refreshWatchers, + onError: (error) => + toast.error(t({ message: "Could not add agent" }), { + description: errorMessage(error), + }), + }); + + const pending = assign.isPending || launch.isPending || unwatch.isPending; + const submit = () => { + if (!resolved || pending || !hostUrl || !cloudWatch.data || !canManage) + return; + if (resolved.kind === "new") { + launch.mutate(resolved); + } else { + const binding = bindings.get(resolved.terminalId); + if (binding) assign.mutate(binding); + } + }; + + const open = (watcher: PageWatcherRow) => { + onOpenWatcher(watcher); + setMenuOpen(false); + }; + + return ( + + + + + +
+ Listening agents +
+
+ {watchers.length > 0 ? ( + + ) : unconfirmedWatch ? ( + Listening status unavailable + ) : ( + No listening agents + )} +
+
+ {watchers.map((watcher) => { + const navigable = watcher.workspaceName !== null; + return ( +
+ + +
+ ); + })} + {canManage ? ( +
{ + event.preventDefault(); + submit(); + }} + > +
+ {renderAgentPicker({ + value, + onValueChange, + sessions: availableAgents, + configs, + })} + {availableAgents.length === 0 && configs.length === 0 ? ( + + No agents yet + + ) : null} + +
+
+ ) : null} + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneHeaderExtras/components/PageWatcherMenu/components/PageWatcherPopover/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneHeaderExtras/components/PageWatcherMenu/components/PageWatcherPopover/index.ts new file mode 100644 index 00000000000..f874c22c5ce --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneHeaderExtras/components/PageWatcherMenu/components/PageWatcherPopover/index.ts @@ -0,0 +1,4 @@ +export { + type AgentPickerSlotProps, + PageWatcherPopover, +} from "./PageWatcherPopover"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PullRequestPane/components/PullRequestCommentCard/PullRequestCommentCard.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PullRequestPane/components/PullRequestCommentCard/PullRequestCommentCard.test.tsx index 16bfda3b403..45de456346a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PullRequestPane/components/PullRequestCommentCard/PullRequestCommentCard.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PullRequestPane/components/PullRequestCommentCard/PullRequestCommentCard.test.tsx @@ -1,38 +1,33 @@ import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import type { PullRequestCommentCardProps } from "./PullRequestCommentCard"; -const registered = GlobalRegistrator.isRegistered; -if (!registered) GlobalRegistrator.register(); -( - globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } -).IS_REACT_ACT_ENVIRONMENT = true; -mock.module("@superset/workspace-client", () => ({ - workspaceTrpc: { - useUtils: () => ({ - git: { getPullRequestThreads: { invalidate: () => {} } }, - }), - git: { - setReviewThreadResolution: { - useMutation: () => ({ mutate: () => {}, isPending: false }), - }, - }, - }, -})); -mock.module("renderer/components/MarkdownRenderer/components", () => ({ - SafeImage: () => null, -})); -mock.module("renderer/lib/trpc-client", () => ({ - electronTrpcClient: { external: { copyText: { mutate: async () => {} } } }, -})); -mock.module("renderer/stores", () => ({ useTheme: () => ({ type: "dark" }) })); -mock.module("renderer/components/CommentMarkdown", () => ({ - CommentMarkdown: ({ body }: { body: string }) =>

{body}

, -})); +const reactActGlobal = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }; +const previousActEnvironment = reactActGlobal.IS_REACT_ACT_ENVIRONMENT; +reactActGlobal.IS_REACT_ACT_ENVIRONMENT = true; const { cleanup, fireEvent, render } = await import("@testing-library/react"); +const { QueryClient } = await import("@tanstack/react-query"); +const { workspaceTrpc } = await import("@superset/workspace-client"); const { PullRequestCommentCard } = await import("./PullRequestCommentCard"); + +function renderCard( + props: Pick, +) { + return render( + + {}} + {...props} + /> + , + ); +} afterEach(cleanup); afterAll(async () => { - if (!registered) await GlobalRegistrator.unregister(); + reactActGlobal.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; }); const comment = { id: "c1", @@ -47,16 +42,14 @@ const comment = { describe("review cards", () => { test("renders the full comment body without making it a navigation button", () => { const onOpenInDiff = mock(() => {}); - const view = render( - {}} - onOpenInDiff={onOpenInDiff} - />, - ); + const view = renderCard({ comment, onOpenInDiff }); const body = view.getByText(/Keep the/); - expect(body.textContent).toBe(comment.body); + expect(body.textContent).toBe("Keep the selected line visible."); + expect(view.getByText("selected line").tagName).toBe("STRONG"); + expect(view.getByRole("link", { name: "the context" })).toHaveProperty( + "href", + "https://github.com/example/repo/pull/1", + ); expect(body.closest("button")).toBeNull(); fireEvent.click(body); expect(onOpenInDiff).not.toHaveBeenCalled(); @@ -66,14 +59,10 @@ describe("review cards", () => { ["RIGHT", "additions"], ] as const)("opens the %s diff from the file link", (diffSide, side) => { const onOpenInDiff = mock(() => {}); - const view = render( - {}} - onOpenInDiff={onOpenInDiff} - />, - ); + const view = renderCard({ + comment: { ...comment, diffSide }, + onOpenInDiff, + }); fireEvent.click(view.getByRole("button", { name: /src\/app.ts/ })); expect(onOpenInDiff).toHaveBeenCalledWith( "src/app.ts", diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useHostReachability/useHostReachability.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useHostReachability/useHostReachability.test.ts index f04b24c5544..a775f8c847d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useHostReachability/useHostReachability.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useHostReachability/useHostReachability.test.ts @@ -1,5 +1,4 @@ import { afterAll, afterEach, expect, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; import { nativeWebGlobals } from "~/test-setup"; @@ -14,8 +13,6 @@ const previousWebGlobals = { AbortSignal: globalThis.AbortSignal, }; const NativeResponse = nativeWebGlobals.Response; -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); Object.assign(globalThis, nativeWebGlobals); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } @@ -29,7 +26,6 @@ const { useHostReachability } = await import("./useHostReachability"); afterEach(cleanup); afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); Object.assign(globalThis, previousWebGlobals); }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore/v2WorkspacesFilterStore.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore/v2WorkspacesFilterStore.test.ts index 5a2421cea4c..b9e43073c11 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore/v2WorkspacesFilterStore.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore/v2WorkspacesFilterStore.test.ts @@ -1,6 +1,16 @@ -import { expect, test } from "bun:test"; +import { afterAll, beforeEach, expect, test } from "bun:test"; import { useV2WorkspacesFilterStore } from "./v2WorkspacesFilterStore"; +function resetStore() { + useV2WorkspacesFilterStore.setState( + useV2WorkspacesFilterStore.getInitialState(), + true, + ); +} + +beforeEach(resetStore); +afterAll(resetStore); + test("defaults workspaces to the board with archived workspaces hidden", () => { const state = useV2WorkspacesFilterStore.getState(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/NewWorkspaceScreen/hooks/useProjectPreselection/useProjectPreselection.test.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/NewWorkspaceScreen/hooks/useProjectPreselection/useProjectPreselection.test.ts index ef9b10893c4..27559bd8cb1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/NewWorkspaceScreen/hooks/useProjectPreselection/useProjectPreselection.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/NewWorkspaceScreen/hooks/useProjectPreselection/useProjectPreselection.test.ts @@ -1,18 +1,11 @@ -import { afterAll, afterEach, expect, mock, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, expect, mock, test } from "bun:test"; -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); const { act, cleanup, renderHook } = await import("@testing-library/react"); const { useProjectPreselection } = await import("./useProjectPreselection"); const { useNewWorkspaceDraftStore } = await import( "renderer/stores/new-workspace-draft" ); afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - test("holds the intended project while its list refreshes, then permits manual selection", () => { useNewWorkspaceDraftStore.getState().resetDraft(); useNewWorkspaceDraftStore.getState().selectProject("new-project"); diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx index 76f27643456..f3464bac551 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx @@ -49,12 +49,14 @@ function dropCloudQueriesForOrgSwitch(): void { }); } -type CollectionsContextType = ReturnType & { +export type CollectionsContextType = ReturnType & { activeOrganizationId: string; switchOrganization: (organizationId: string) => Promise; }; -const CollectionsContext = createContext(null); +export const CollectionsContext = createContext( + null, +); export function preloadActiveOrganizationCollections( activeOrganizationId: string | null | undefined, diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/HostWorkspacesProvider/HostWorkspacesProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/HostWorkspacesProvider/HostWorkspacesProvider.tsx index 3b4c2b27863..3f56c6b997f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/HostWorkspacesProvider/HostWorkspacesProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/HostWorkspacesProvider/HostWorkspacesProvider.tsx @@ -4,9 +4,8 @@ import { useHostWorkspacesSource, } from "renderer/hooks/host-workspaces/useHostWorkspaces"; -const HostWorkspacesContext = createContext( - null, -); +export const HostWorkspacesContext = + createContext(null); /** * Runs the per-host workspace fan-out once (queries, event subscriptions, diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/HostWorkspacesProvider/index.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/HostWorkspacesProvider/index.ts index e0c8e59a726..10b3a107068 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/HostWorkspacesProvider/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/HostWorkspacesProvider/index.ts @@ -1,5 +1,6 @@ export type { HostWorkspaceItem } from "renderer/hooks/host-workspaces/useHostWorkspaces"; export { + HostWorkspacesContext, HostWorkspacesProvider, useHostWorkspaces, } from "./HostWorkspacesProvider"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/LocalHostServiceProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/LocalHostServiceProvider.tsx index bd5bafe7a10..05b4909fd7a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/LocalHostServiceProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/LocalHostServiceProvider.tsx @@ -34,7 +34,7 @@ interface LocalHostServiceContextValue { waitForHostReady: (timeoutMs?: number) => Promise; } -const LocalHostServiceContext = +export const LocalHostServiceContext = createContext(null); const MOCK_ORGANIZATION_IDS = [MOCK_ORG_ID]; diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/index.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/index.ts index cf441ab82ae..c84732f86f5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/index.ts @@ -1,4 +1,5 @@ export { + LocalHostServiceContext, LocalHostServiceProvider, useLocalHostService, } from "./LocalHostServiceProvider"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/SandboxAccessProvider/SandboxAccessProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/SandboxAccessProvider/SandboxAccessProvider.tsx index 6dac2e93252..f8eda5daa7b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/SandboxAccessProvider/SandboxAccessProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/SandboxAccessProvider/SandboxAccessProvider.tsx @@ -32,7 +32,9 @@ export interface SandboxAccessValue { agentCredentialsChangedWorkspaceId: string | null; } -const SandboxAccessContext = createContext(null); +export const SandboxAccessContext = createContext( + null, +); interface SandboxAccess { url: string; diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/SandboxAccessProvider/index.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/SandboxAccessProvider/index.ts index bed84066e5b..090086794aa 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/SandboxAccessProvider/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/SandboxAccessProvider/index.ts @@ -3,6 +3,7 @@ export type { SandboxTarget, } from "./SandboxAccessProvider"; export { + SandboxAccessContext, SandboxAccessProvider, useSandboxAccess, } from "./SandboxAccessProvider"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/ExperimentalSettings.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/ExperimentalSettings.test.tsx deleted file mode 100644 index 4b6cd6625c4..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/ExperimentalSettings.test.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, mock, test } from "bun:test"; -import { renderToStaticMarkup } from "react-dom/server"; - -// completion.ts and the zustand persist stores read the global localStorage; -// give them a working in-memory one before anything imports them. -const backing = new Map(); -globalThis.localStorage = { - getItem: (key: string) => backing.get(key) ?? null, - setItem: (key: string, value: string) => void backing.set(key, value), - removeItem: (key: string) => void backing.delete(key), - clear: () => backing.clear(), - key: (index: number) => [...backing.keys()][index] ?? null, - get length() { - return backing.size; - }, -} as Storage; - -// Pre-cutoff account: defaults to v1, sees the "Import from v1" row. -const V1_ERA_CREATED_AT = new Date("2026-01-01T00:00:00Z"); -// Post-cutoff account: v2-only signup cohort. -const V2_ONLY_CREATED_AT = new Date("2026-08-01T00:00:00Z"); - -// Mutable session the auth-client mock serves; tests swap the org per case -// because isV1MigrationCompleteAtBoot caches the first read per org. -let activeOrganizationId = "org-none"; -let createdAt: Date = V1_ERA_CREATED_AT; - -// Spread the real module so files loaded later that import its other exports -// (getAuthToken, ensureFreshJwt, ...) keep working — mock.module replaces the -// module for the whole test process, and file load order varies by platform. -const realAuthClient = await import("renderer/lib/auth-client"); -mock.module("renderer/lib/auth-client", () => ({ - ...realAuthClient, - authClient: { - ...realAuthClient.authClient, - useSession: () => ({ - data: { - session: { activeOrganizationId }, - user: { createdAt, onboardedAt: new Date() }, - }, - }), - }, -})); -const realEnv = await import("renderer/env.renderer"); -mock.module("renderer/env.renderer", () => ({ - ...realEnv, - env: { ...realEnv.env, NODE_ENV: "production" }, -})); -mock.module("renderer/lib/analytics", () => ({ - track: () => {}, -})); -// Owns electronTrpc queries; irrelevant to what this file asserts. -mock.module("./components/WaitForSetupBeforeAgentSetting", () => ({ - WaitForSetupBeforeAgentSetting: () => null, -})); - -const { ExperimentalSettings } = await import("./ExperimentalSettings"); -const { markV1MigrationComplete } = await import( - "renderer/lib/v1-migration/completion" -); - -function renderSettings(orgId: string, userCreatedAt: Date) { - activeOrganizationId = orgId; - createdAt = userCreatedAt; - return renderToStaticMarkup(); -} - -describe("ExperimentalSettings v1/v2 switch", () => { - test("v1 user without a migration marker sees the toggle and the v1 import", () => { - const markup = renderSettings("org-v1", V1_ERA_CREATED_AT); - expect(markup).toContain("Try Superset v2"); - expect(markup).toContain("Import from v1"); - }); - - test("migrated (flip-locked) machine hides the dead toggle", () => { - markV1MigrationComplete("org-flipped"); - const markup = renderSettings("org-flipped", V1_ERA_CREATED_AT); - expect(markup).not.toContain("Try Superset v2"); - }); - - test("migrated machine keeps Import from v1 as the recovery path", () => { - markV1MigrationComplete("org-flipped-import"); - const markup = renderSettings("org-flipped-import", V1_ERA_CREATED_AT); - expect(markup).toContain("Import from v1"); - }); - - test("v2-only signup without a marker still sees the toggle (opt-out remains real)", () => { - const markup = renderSettings("org-v2only", V2_ONLY_CREATED_AT); - expect(markup).toContain("Try Superset v2"); - expect(markup).not.toContain("Import from v1"); - }); -}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/ExperimentalSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/ExperimentalSettings.tsx index 7b3c1cdc66c..37416a9a519 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/ExperimentalSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/ExperimentalSettings.tsx @@ -1,31 +1,10 @@ -import { Trans, useLingui } from "@lingui/react/macro"; -import { Button } from "@superset/ui/button"; -import { Label } from "@superset/ui/label"; -import { Switch } from "@superset/ui/switch"; import { useIsV1FlipLocked, useIsV2CloudEnabled, useIsV2OnlyUser, } from "renderer/hooks/useIsV2CloudEnabled"; -import { track } from "renderer/lib/analytics"; -import { HighlightText } from "renderer/routes/_authenticated/settings/components/HighlightText"; -import { - useInlineWorkspacePortsStore, - usePortsDisplayMode, -} from "renderer/stores/inline-workspace-ports"; -import { useSettingsSearchQuery } from "renderer/stores/settings-state"; -import { useOpenV1ImportModal } from "renderer/stores/v1-import-modal"; -import { useV2LocalOverrideStore } from "renderer/stores/v2-local-override"; -import { - useWorkspaceAgentsRowEnabled, - useWorkspaceAgentsRowStore, -} from "renderer/stores/workspace-agents-row"; -import { - isItemVisible, - SETTING_ITEM_ID, - type SettingItemId, -} from "../../../utils/settings-search"; -import { WaitForSetupBeforeAgentSetting } from "./components/WaitForSetupBeforeAgentSetting"; +import type { SettingItemId } from "../../../utils/settings-search"; +import { ExperimentalSettingsList } from "./components/ExperimentalSettingsList"; interface ExperimentalSettingsProps { visibleItems?: SettingItemId[] | null; @@ -34,193 +13,15 @@ interface ExperimentalSettingsProps { export function ExperimentalSettings({ visibleItems, }: ExperimentalSettingsProps) { - const { t } = useLingui(); - const searchQuery = useSettingsSearchQuery(); - const showSupersetV2 = isItemVisible( - SETTING_ITEM_ID.EXPERIMENTAL_SUPERSET_V2, - visibleItems, - ); - const showV1Migration = isItemVisible( - SETTING_ITEM_ID.EXPERIMENTAL_V1_MIGRATION, - visibleItems, - ); - const showInlineWorkspacePorts = isItemVisible( - SETTING_ITEM_ID.EXPERIMENTAL_INLINE_WORKSPACE_PORTS, - visibleItems, - ); - const showWorkspaceAgents = isItemVisible( - SETTING_ITEM_ID.EXPERIMENTAL_WORKSPACE_AGENTS, - visibleItems, - ); - const showWaitForSetupBeforeAgent = isItemVisible( - SETTING_ITEM_ID.EXPERIMENTAL_WAIT_FOR_SETUP_BEFORE_AGENT, - visibleItems, - ); const isV2CloudEnabled = useIsV2CloudEnabled(); const isV2OnlyUser = useIsV2OnlyUser(); const isV1FlipLocked = useIsV1FlipLocked(); - const setOptInV2 = useV2LocalOverrideStore((state) => state.setOptInV2); - const openV1ImportModal = useOpenV1ImportModal(); - const portsDisplayMode = usePortsDisplayMode(); - const setPortsDisplayMode = useInlineWorkspacePortsStore( - (state) => state.setMode, - ); - const workspaceAgentsEnabled = useWorkspaceAgentsRowEnabled(); - const setWorkspaceAgentsEnabled = useWorkspaceAgentsRowStore( - (state) => state.setEnabled, - ); - return ( -
-
-

- Experimental -

-

- Try early access features and previews. -

-
- -
- {showSupersetV2 && !isV1FlipLocked && ( -
-
- -

- -

-
- { - track("surface_toggled", { - from: isV2CloudEnabled ? "v2" : "v1", - to: enabled ? "v2" : "v1", - }); - setOptInV2(enabled); - }} - /> -
- )} - {showV1Migration && !isV2OnlyUser && ( -
-
- -

- -

- {!isV2CloudEnabled && ( -

- -

- )} -
- -
- )} - {showInlineWorkspacePorts && ( -
-
- -

- -

-
- - setPortsDisplayMode(checked ? "topbar" : "inline") - } - /> -
- )} - {showWorkspaceAgents && ( -
-
- -

- -

-
- -
- )} - {showWaitForSetupBeforeAgent && } -
-
+ ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/components/ExperimentalSettingsList/ExperimentalSettingsList.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/components/ExperimentalSettingsList/ExperimentalSettingsList.test.tsx new file mode 100644 index 00000000000..2cf0a6080bd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/components/ExperimentalSettingsList/ExperimentalSettingsList.test.tsx @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { SETTING_ITEM_ID } from "../../../../../utils/settings-search"; +import { ExperimentalSettingsList } from "./ExperimentalSettingsList"; + +function renderSettings({ + isV1FlipLocked = false, + isV2OnlyUser = false, +}: { + isV1FlipLocked?: boolean; + isV2OnlyUser?: boolean; +}) { + return renderToStaticMarkup( + , + ); +} + +describe("ExperimentalSettings v1/v2 switch", () => { + test("v1 user without a migration marker sees the toggle and the v1 import", () => { + const markup = renderSettings({}); + expect(markup).toContain("Try Superset v2"); + expect(markup).toContain("Import from v1"); + }); + + test("migrated (flip-locked) machine hides the dead toggle but keeps Import from v1 as the recovery path", () => { + const markup = renderSettings({ isV1FlipLocked: true }); + expect(markup).not.toContain("Try Superset v2"); + expect(markup).toContain("Import from v1"); + }); + + test("v2-only signup without a marker still sees the toggle (opt-out remains real)", () => { + const markup = renderSettings({ isV2OnlyUser: true }); + expect(markup).toContain("Try Superset v2"); + expect(markup).not.toContain("Import from v1"); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/components/ExperimentalSettingsList/ExperimentalSettingsList.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/components/ExperimentalSettingsList/ExperimentalSettingsList.tsx new file mode 100644 index 00000000000..e74c09aaad4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/components/ExperimentalSettingsList/ExperimentalSettingsList.tsx @@ -0,0 +1,224 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import { Button } from "@superset/ui/button"; +import { Label } from "@superset/ui/label"; +import { Switch } from "@superset/ui/switch"; +import { track } from "renderer/lib/analytics"; +import { HighlightText } from "renderer/routes/_authenticated/settings/components/HighlightText"; +import { + useInlineWorkspacePortsStore, + usePortsDisplayMode, +} from "renderer/stores/inline-workspace-ports"; +import { useSettingsSearchQuery } from "renderer/stores/settings-state"; +import { useOpenV1ImportModal } from "renderer/stores/v1-import-modal"; +import { useV2LocalOverrideStore } from "renderer/stores/v2-local-override"; +import { + useWorkspaceAgentsRowEnabled, + useWorkspaceAgentsRowStore, +} from "renderer/stores/workspace-agents-row"; +import { + isItemVisible, + SETTING_ITEM_ID, + type SettingItemId, +} from "../../../../../utils/settings-search"; +import { WaitForSetupBeforeAgentSetting } from "../WaitForSetupBeforeAgentSetting"; + +interface ExperimentalSettingsListProps { + visibleItems?: SettingItemId[] | null; + isV2CloudEnabled: boolean; + isV2OnlyUser: boolean; + isV1FlipLocked: boolean; +} + +export function ExperimentalSettingsList({ + visibleItems, + isV2CloudEnabled, + isV2OnlyUser, + isV1FlipLocked, +}: ExperimentalSettingsListProps) { + const { t } = useLingui(); + const searchQuery = useSettingsSearchQuery(); + const showSupersetV2 = isItemVisible( + SETTING_ITEM_ID.EXPERIMENTAL_SUPERSET_V2, + visibleItems, + ); + const showV1Migration = isItemVisible( + SETTING_ITEM_ID.EXPERIMENTAL_V1_MIGRATION, + visibleItems, + ); + const showInlineWorkspacePorts = isItemVisible( + SETTING_ITEM_ID.EXPERIMENTAL_INLINE_WORKSPACE_PORTS, + visibleItems, + ); + const showWorkspaceAgents = isItemVisible( + SETTING_ITEM_ID.EXPERIMENTAL_WORKSPACE_AGENTS, + visibleItems, + ); + const showWaitForSetupBeforeAgent = isItemVisible( + SETTING_ITEM_ID.EXPERIMENTAL_WAIT_FOR_SETUP_BEFORE_AGENT, + visibleItems, + ); + const setOptInV2 = useV2LocalOverrideStore((state) => state.setOptInV2); + const openV1ImportModal = useOpenV1ImportModal(); + const portsDisplayMode = usePortsDisplayMode(); + const setPortsDisplayMode = useInlineWorkspacePortsStore( + (state) => state.setMode, + ); + const workspaceAgentsEnabled = useWorkspaceAgentsRowEnabled(); + const setWorkspaceAgentsEnabled = useWorkspaceAgentsRowStore( + (state) => state.setEnabled, + ); + + return ( +
+
+

+ Experimental +

+

+ Try early access features and previews. +

+
+ +
+ {showSupersetV2 && !isV1FlipLocked && ( +
+
+ +

+ +

+
+ { + track("surface_toggled", { + from: isV2CloudEnabled ? "v2" : "v1", + to: enabled ? "v2" : "v1", + }); + setOptInV2(enabled); + }} + /> +
+ )} + {showV1Migration && !isV2OnlyUser && ( +
+
+ +

+ +

+ {!isV2CloudEnabled && ( +

+ +

+ )} +
+ +
+ )} + {showInlineWorkspacePorts && ( +
+
+ +

+ +

+
+ + setPortsDisplayMode(checked ? "topbar" : "inline") + } + /> +
+ )} + {showWorkspaceAgents && ( +
+
+ +

+ +

+
+ +
+ )} + {showWaitForSetupBeforeAgent && } +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/components/ExperimentalSettingsList/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/components/ExperimentalSettingsList/index.ts new file mode 100644 index 00000000000..e68d3d8584b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/components/ExperimentalSettingsList/index.ts @@ -0,0 +1 @@ +export { ExperimentalSettingsList } from "./ExperimentalSettingsList"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/LeaderboardCard/components/LeaderboardRank/LeaderboardRank.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/LeaderboardCard/components/LeaderboardRank/LeaderboardRank.test.tsx index 1dc5d7a1f85..c1513ad2127 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/LeaderboardCard/components/LeaderboardRank/LeaderboardRank.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/LeaderboardCard/components/LeaderboardRank/LeaderboardRank.test.tsx @@ -1,13 +1,8 @@ -import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, describe, expect, mock, test } from "bun:test"; // The marketing origin is env-derived: a developer's local .env repoints it at // a dev server, so assert the links the component builds, not a literal host. import { COMPANY } from "@superset/shared/constants"; -// happy-dom is process-wide; unregister in afterAll so the shared mock -// document is restored for the other renderer suites. -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -17,10 +12,6 @@ const React = await import("react"); const { LeaderboardRank } = await import("./LeaderboardRank"); afterEach(cleanup); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - function membership(tokens: number | string) { return { handle: "kiet", diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.test.tsx deleted file mode 100644 index 9600393ea05..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.test.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { describe, expect, mock, test } from "bun:test"; -import { renderToStaticMarkup } from "react-dom/server"; - -/** - * Reproduces #5843: "Project icons are missing in 1.16". - * - * Nearly all projects now render through the v2 settings page - * (`V2ProjectSettings`). A fully-built icon picker (`IconUploadField`) exists - * in the v2 tree with backing tRPC mutations, but it was never wired into - * `V2ProjectSettings` — so there is no longer any UI to set a project icon. - * - * This test renders `V2ProjectSettings` with its data/host dependencies - * stubbed out and asserts the icon picker is present. Before the fix it fails - * (the picker is never rendered); after wiring `IconUploadField` in, it passes. - */ - -const project = { - projectKey: "project-1", - id: "project-1", - name: "Acme", - repoOwner: "acme-co", - repoName: "acme", - repoUrl: "https://github.com/acme-co/acme", - hostIds: ["host-1"], - hostReachable: true, - createdAt: 0, - updatedAt: 0, -}; - -// The targeted host's own row, as returned by `project.get`. Sections that -// need host-sourced values are gated on this being present. -const hostProject = { - id: "project-1", - name: "Acme", - repoPath: "/repos/acme", - repoOwner: "acme-co", - repoName: "acme", - repoUrl: "https://github.com/acme-co/acme", - worktreeBaseDir: null, - branchPrefixMode: null, - branchPrefixCustom: null, - icon: null, - sparseCheckoutPaths: ["apps/desktop"], - namingInstructions: "Prefix branches with fix/ or feat/", -}; - -// External data/host dependencies — stubbed so the component renders in a -// plain SSR pass without providers. -// Bun module mocks persist across test files. Only replace this settings -// query so unrelated components still receive their real query results. -const { useQuery: realUseQuery } = await import("@tanstack/react-query"); -mock.module("@tanstack/react-query", () => ({ - useQuery: (...args: Parameters) => - args[0].queryKey[0] === "host-project" && args[0].queryKey[1] === "get" - ? { data: hostProject, refetch: () => {} } - : realUseQuery(...args), -})); -mock.module("@tanstack/react-router", () => ({ - useNavigate: () => () => {}, -})); -mock.module("renderer/hooks/host-projects/useHostProjects", () => ({ - useHostProjects: () => ({ projects: [project], isReady: true }), -})); -mock.module("renderer/hooks/host-service/useHostTargetUrl", () => ({ - useHostUrl: () => "http://127.0.0.1:7777", -})); -mock.module("renderer/lib/host-service-client", () => ({ - getHostServiceClientByUrl: () => ({}), -})); -mock.module( - "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions", - () => ({ - useWorkspaceHostOptions: () => ({ - currentDeviceName: "This device", - localHostId: "host-1", - otherHosts: [], - }), - }), -); -mock.module( - "renderer/routes/_authenticated/components/ProjectThumbnail", - () => ({ - ProjectThumbnail: () => null, - }), -); -mock.module( - "renderer/routes/_authenticated/providers/LocalHostServiceProvider", - () => ({ - useLocalHostService: () => ({ machineId: "host-1" }), - }), -); -mock.module("../../../../components/HostSelect", () => ({ - HostSelect: () => null, -})); - -// Sibling sections — irrelevant to this test, stubbed to keep the render light -// and free of their own provider requirements. -mock.module("./components/BranchPrefixSection", () => ({ - BranchPrefixSection: () => null, -})); -mock.module("./components/DeleteProjectSection", () => ({ - DeleteProjectSection: () => null, -})); -mock.module("./components/NameSection", () => ({ NameSection: () => null })); -mock.module("./components/ProjectLocationSection", () => ({ - ProjectLocationSection: () => null, -})); -mock.module("./components/RepositorySection", () => ({ - RepositorySection: () => null, -})); -mock.module("./components/V2ScriptsEditor", () => ({ - V2ScriptsEditor: () => null, -})); -mock.module("./components/WorktreeLocationSection", () => ({ - WorktreeLocationSection: () => null, -})); - -// Stubbed with a marker for the same reason as the icon picker below: the -// assertion checks that V2ProjectSettings wires the section in, not how the -// section renders its textarea. -mock.module("./components/SparseCheckoutSection", () => ({ - SparseCheckoutSection: ({ paths }: { paths: string[] }) => ( -
- sparse-checkout -
- ), -})); - -// Stubbed with a marker for the same reason: the assertion checks that -// V2ProjectSettings wires the section in and seeds it from the host row. -mock.module("./components/NamingInstructionsSection", () => ({ - NamingInstructionsSection: ({ - instructions, - }: { - instructions: string | null; - }) => ( -
- naming-instructions -
- ), -})); - -// The icon picker itself — stubbed with a recognizable marker so the assertion -// checks the wiring (that V2ProjectSettings renders it) rather than the -// picker's internals. -mock.module("./components/IconUploadField", () => ({ - IconUploadField: ({ projectId }: { projectId: string }) => ( -
- icon-picker -
- ), -})); - -const { V2ProjectSettings } = await import("./V2ProjectSettings"); - -describe("V2ProjectSettings", () => { - test("renders the project icon picker so users can set an icon (#5843)", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('data-testid="project-icon-picker"'); - expect(markup).toContain('data-project-id="project-1"'); - }); - - test("renders the naming-instructions editor seeded from the targeted host row", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('data-testid="project-naming-instructions"'); - expect(markup).toContain( - 'data-instructions="Prefix branches with fix/ or feat/"', - ); - }); - - test("renders the sparse-checkout editor seeded from the targeted host row", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('data-testid="project-sparse-checkout"'); - expect(markup).toContain('data-paths="apps/desktop"'); - }); -}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx index dd23de5c18b..0eb7e47c87c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx @@ -1,34 +1,17 @@ import { Trans, useLingui } from "@lingui/react/macro"; -import { Label } from "@superset/ui/label"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { useEffect, useMemo, useRef } from "react"; -import { - PROJECT_ICON_NONE, - resolveProjectIconUrl, -} from "renderer/hooks/host-projects/resolveProjectIconUrl"; import { useHostProjects } from "renderer/hooks/host-projects/useHostProjects"; import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { useWorkspaceHostOptions } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions"; -import { ProjectThumbnail } from "renderer/routes/_authenticated/components/ProjectThumbnail"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; -import { - HostSelect, - type HostSelectOption, -} from "../../../../components/HostSelect"; -import { SettingsRow } from "../../../../components/SettingsRow"; -import { SettingsSection } from "../../../../components/SettingsSection"; -import { BranchPrefixSection } from "./components/BranchPrefixSection"; +import type { HostSelectOption } from "../../../../components/HostSelect"; import { DeleteProjectSection } from "./components/DeleteProjectSection"; -import { IconUploadField } from "./components/IconUploadField"; -import { NameSection } from "./components/NameSection"; -import { NamingInstructionsSection } from "./components/NamingInstructionsSection"; import { ProjectLocationSection } from "./components/ProjectLocationSection"; -import { RepositorySection } from "./components/RepositorySection"; -import { SparseCheckoutSection } from "./components/SparseCheckoutSection"; +import { V2ProjectSettingsBody } from "./components/V2ProjectSettingsBody"; import { V2ScriptsEditor } from "./components/V2ScriptsEditor"; -import { WorktreeLocationSection } from "./components/WorktreeLocationSection"; interface V2ProjectSettingsProps { projectId: string; @@ -103,7 +86,6 @@ export function V2ProjectSettings({ }); return targetHostId; }, [machineId, selectedHost, t, targetHostId]); - const hasMultipleHosts = hostOptions.length > 1; const isRemoteTarget = Boolean( targetHostId && machineId && targetHostId !== machineId, ); @@ -149,242 +131,50 @@ export function V2ProjectSettings({ ); } - // Icons are per-host. Prefer the targeted host's row — the one the picker - // writes to — falling back to the merged fan-out value only while it loads - // (same rule as Name). Custom icon wins; else the GitHub owner avatar. - const projectIcon = hostProject ? hostProject.icon : project.icon; - const iconUrl = resolveProjectIconUrl({ - icon: projectIcon, - repoOwner: project.repoOwner, - }); - // Accent color follows the same per-host precedence as the icon. - const projectColor = hostProject ? hostProject.color : project.color; - const canRename = Boolean( - targetHostUrl && targetHostId && project.hostIds.includes(targetHostId), - ); - return ( -
-
-
- -

{project.name}

-
- {hasMultipleHosts && targetHostId ? ( - { - void navigate({ - to: "/settings/projects/$projectId", - params: { projectId }, - search: { hostId: nextHostId }, - replace: true, - }); - }} - /> - ) : null} -
- -
- - - refetchHostProject()} - /> - - - - - - - - - - - {targetHostUrl && hostProject && ( - - refetchHostProject()} - /> - - )} - {targetHostUrl && hostProject && ( - refetchHostProject()} - /> - )} - - - - - refetchHostProject()} - /> - - - refetchHostProject()} - /> - - {targetHostUrl && hostProject && ( -
-
- -

- - Folders to check out into new worktrees, one per line, - relative to the repo root. Files at the root are always - included. Empty checks out everything. - -

-
- refetchHostProject()} - /> -
- )} -
- - {targetHostUrl && ( - - - - )} - - - - -
-
+ { + void navigate({ + to: "/settings/projects/$projectId", + params: { projectId }, + search: { hostId: nextHostId }, + replace: true, + }); + }} + onHostProjectChanged={refetchHostProject} + locationSection={ + refetchHostProject()} + /> + } + scriptsEditor={ + targetHostUrl ? ( + + ) : null + } + dangerZone={ + + } + /> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/IconUploadField/IconUploadField.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/IconUploadField/IconUploadField.test.tsx new file mode 100644 index 00000000000..3f1144b8a23 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/IconUploadField/IconUploadField.test.tsx @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { IconUploadField } from "./IconUploadField"; + +function renderPicker(hostUrl: string | null) { + return renderToStaticMarkup( + , + ); +} + +describe("IconUploadField", () => { + test("offers the icon and color picker for a project on a reachable host", () => { + const markup = renderPicker("http://127.0.0.1:7777"); + expect(markup).toContain('aria-label="Change project icon and color"'); + expect(markup).not.toContain('disabled=""'); + }); + + test("disables the picker while the project's host cannot be reached", () => { + expect(renderPicker(null)).toContain('disabled=""'); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/NamingInstructionsSection/NamingInstructionsSection.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/NamingInstructionsSection/NamingInstructionsSection.test.tsx new file mode 100644 index 00000000000..609be56fd6f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/NamingInstructionsSection/NamingInstructionsSection.test.tsx @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderToStaticMarkup } from "react-dom/server"; +import { NamingInstructionsSection } from "./NamingInstructionsSection"; + +function renderEditor(instructions: string | null) { + return renderToStaticMarkup( + + {}} + /> + , + ); +} + +describe("NamingInstructionsSection", () => { + test("opens on the host row's instructions", () => { + expect(renderEditor("Use the ticket id as the branch name")).toContain( + ">Use the ticket id as the branch name", + ); + }); + + test("opens empty on a host that has none", () => { + expect(renderEditor(null)).toContain(">"); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SparseCheckoutSection/SparseCheckoutSection.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SparseCheckoutSection/SparseCheckoutSection.test.tsx new file mode 100644 index 00000000000..c26e4607e81 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SparseCheckoutSection/SparseCheckoutSection.test.tsx @@ -0,0 +1,19 @@ +import { expect, test } from "bun:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderToStaticMarkup } from "react-dom/server"; +import { SparseCheckoutSection } from "./SparseCheckoutSection"; + +test("SparseCheckoutSection opens on the host row's folders, one per line", () => { + const markup = renderToStaticMarkup( + + {}} + /> + , + ); + + expect(markup).toContain(">apps/desktop\npackages/ui"); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ProjectSettingsBody/V2ProjectSettingsBody.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ProjectSettingsBody/V2ProjectSettingsBody.test.tsx new file mode 100644 index 00000000000..1a22b263a22 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ProjectSettingsBody/V2ProjectSettingsBody.test.tsx @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, render } from "@testing-library/react"; +import type { ComponentProps } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { V2ProjectSettingsBody } from "./V2ProjectSettingsBody"; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(cleanup); + +type BodyProps = ComponentProps; + +const HOST_URL = "http://127.0.0.1:7777"; + +const project: BodyProps["project"] = { + projectKey: "project-1", + id: "project-1", + name: "Acme", + repoOwner: "acme", + repoName: "app", + repoUrl: "https://github.com/acme/app", + icon: null, + color: null, + hostIds: ["host-1"], + hostReachable: true, + createdAt: 1, + updatedAt: 1, +}; + +const hostProject = { + id: "project-1", + name: "Acme", + icon: null, + color: null, + repoPath: "/repos/app", + worktreeBaseDir: null, + branchPrefixMode: null, + branchPrefixCustom: null, + namingInstructions: "Prefix branches with fix/ or feat/", + sparseCheckoutPaths: ["apps/desktop"], +} as unknown as NonNullable; + +function renderBody(props: Partial = {}) { + const queryClient = new QueryClient(); + return render( + + + {}} + onHostProjectChanged={() => {}} + locationSection={
} + scriptsEditor={
} + dangerZone={
} + {...props} + /> + + , + ); +} + +describe("V2ProjectSettingsBody", () => { + test("offers the project icon picker so users can set an icon (#5843)", () => { + const view = renderBody(); + expect(view.getByLabelText("Change project icon and color")).toHaveProperty( + "disabled", + false, + ); + }); + + test("seeds the naming-instructions editor from the targeted host row", () => { + const view = renderBody(); + expect( + view.container.querySelector( + "#project-naming-instructions", + )?.value, + ).toBe("Prefix branches with fix/ or feat/"); + }); + + test("seeds the sparse-checkout editor from the targeted host row", () => { + const view = renderBody(); + expect( + view.container.querySelector( + "#project-sparse-checkout", + )?.value, + ).toBe("apps/desktop"); + }); + + test("hides the host-backed editors until the host row has loaded", () => { + const view = renderBody({ hostProject: undefined }); + expect(view.container.querySelector("#project-naming-instructions")).toBe( + null, + ); + expect(view.container.querySelector("#project-sparse-checkout")).toBe(null); + expect(view.getByLabelText("Change project icon and color")).toBeDefined(); + }); + + test("places the location, scripts and danger-zone sections it is given", () => { + const view = renderBody(); + for (const slot of ["location", "scripts", "danger"]) { + expect(view.getByTestId(slot)).toBeDefined(); + } + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ProjectSettingsBody/V2ProjectSettingsBody.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ProjectSettingsBody/V2ProjectSettingsBody.tsx new file mode 100644 index 00000000000..c10a77e690f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ProjectSettingsBody/V2ProjectSettingsBody.tsx @@ -0,0 +1,281 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import { Label } from "@superset/ui/label"; +import type { ReactNode } from "react"; +import { + PROJECT_ICON_NONE, + resolveProjectIconUrl, +} from "renderer/hooks/host-projects/resolveProjectIconUrl"; +import type { HostProjectItem } from "renderer/hooks/host-projects/useHostProjects"; +import type { HostServiceClient } from "renderer/lib/host-service-client"; +import { ProjectThumbnail } from "renderer/routes/_authenticated/components/ProjectThumbnail"; +import { + HostSelect, + type HostSelectOption, +} from "../../../../../../components/HostSelect"; +import { SettingsRow } from "../../../../../../components/SettingsRow"; +import { SettingsSection } from "../../../../../../components/SettingsSection"; +import { BranchPrefixSection } from "../BranchPrefixSection"; +import { IconUploadField } from "../IconUploadField"; +import { NameSection } from "../NameSection"; +import { NamingInstructionsSection } from "../NamingInstructionsSection"; +import { RepositorySection } from "../RepositorySection"; +import { SparseCheckoutSection } from "../SparseCheckoutSection"; +import { WorktreeLocationSection } from "../WorktreeLocationSection"; + +type HostProjectRow = Awaited< + ReturnType +>; + +interface V2ProjectSettingsBodyProps { + projectId: string; + project: HostProjectItem; + hostProject: HostProjectRow | null | undefined; + targetHostUrl: string | null; + targetHostId: string | null; + targetHostName: string; + isRemoteTarget: boolean; + isHostOnline: boolean; + hostOptions: HostSelectOption[]; + onHostChange: (hostId: string) => void; + onHostProjectChanged: () => unknown; + locationSection: ReactNode; + scriptsEditor: ReactNode; + dangerZone: ReactNode; +} + +export function V2ProjectSettingsBody({ + projectId, + project, + hostProject, + targetHostUrl, + targetHostId, + targetHostName, + isRemoteTarget, + isHostOnline, + hostOptions, + onHostChange, + onHostProjectChanged, + locationSection, + scriptsEditor, + dangerZone, +}: V2ProjectSettingsBodyProps) { + const { t } = useLingui(); + // Icons are per-host. Prefer the targeted host's row — the one the picker + // writes to — falling back to the merged fan-out value only while it loads + // (same rule as Name). Custom icon wins; else the GitHub owner avatar. + const projectIcon = hostProject ? hostProject.icon : project.icon; + const iconUrl = resolveProjectIconUrl({ + icon: projectIcon, + repoOwner: project.repoOwner, + }); + // Accent color follows the same per-host precedence as the icon. + const projectColor = hostProject ? hostProject.color : project.color; + const canRename = Boolean( + targetHostUrl && targetHostId && project.hostIds.includes(targetHostId), + ); + + return ( +
+
+
+ +

{project.name}

+
+ {hostOptions.length > 1 && targetHostId ? ( + + ) : null} +
+ +
+ + + onHostProjectChanged()} + /> + + + + + + + + + + + {targetHostUrl && hostProject && ( + + onHostProjectChanged()} + /> + + )} + {targetHostUrl && hostProject && ( + onHostProjectChanged()} + /> + )} + + + + + {locationSection} + + + onHostProjectChanged()} + /> + + {targetHostUrl && hostProject && ( +
+
+ +

+ + Folders to check out into new worktrees, one per line, + relative to the repo root. Files at the root are always + included. Empty checks out everything. + +

+
+ onHostProjectChanged()} + /> +
+ )} +
+ + {targetHostUrl && ( + + {scriptsEditor} + + )} + + + {dangerZone} + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ProjectSettingsBody/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ProjectSettingsBody/index.ts new file mode 100644 index 00000000000..680cac265ab --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ProjectSettingsBody/index.ts @@ -0,0 +1 @@ +export { V2ProjectSettingsBody } from "./V2ProjectSettingsBody"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.test.ts index d6fd4203bd9..fb2aa13711c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.test.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.test.ts @@ -1,107 +1,99 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; -// Static import so the real module is captured before the mock below replaces it. -import * as reactActual from "react"; +import { afterEach, describe, expect, test } from "bun:test"; +import type { TRPCLink } from "@trpc/client"; +import type { AppRouter } from "lib/trpc/routers"; +import type { ReactNode } from "react"; -let rawQueryResult: { - data?: { - content: string; - byteLength: number; - exceededLimit: boolean; - revision: string; - }; - error?: Error; - isLoading: boolean; -}; +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; -const readFileUseQuery = mock( - (_input: unknown, options: { enabled: boolean }) => - options.enabled - ? rawQueryResult - : { data: undefined, error: undefined, isLoading: false }, +const { QueryClient } = await import("@tanstack/react-query"); +const { observable } = await import("@trpc/server/observable"); +const { act, cleanup, renderHook, waitFor } = await import( + "@testing-library/react" ); -const emptyUseQuery = mock(() => ({ data: undefined, isLoading: false })); - -// Spread the real module: bun's mock.module is process-global and permanent, -// so dropping an export here deletes it for every test file that runs after -// this one. `useMemo` runs eagerly so the hook can be exercised outside a -// render. -mock.module("react", () => ({ - ...reactActual, - useMemo: (factory: () => T) => factory(), -})); - -mock.module("renderer/lib/electron-trpc", () => ({ - electronTrpc: { - changes: { - getBranches: { useQuery: emptyUseQuery }, - getGitFileContents: { useQuery: emptyUseQuery }, - getGitOriginalContent: { useQuery: emptyUseQuery }, - }, - filesystem: { - readFile: { useQuery: readFileUseQuery }, - }, - }, -})); - +const { createElement } = await import("react"); +const { electronTrpc } = await import("renderer/lib/electron-trpc"); const { FILE_CONTENT_GC_TIME_MS, FILE_CONTENT_STALE_TIME_MS, useFileContent } = await import("./useFileContent"); -describe("useFileContent", () => { - beforeEach(() => { - rawQueryResult = { isLoading: true }; - readFileUseQuery.mockClear(); - }); +afterEach(cleanup); +function renderFileContent(answerRead: (attempt: number) => unknown) { + const reads: unknown[] = []; + const link: TRPCLink = () => (call) => + observable((observer) => { + if (call.op.path !== "filesystem.readFile") return; + reads.push(call.op.input); + const answer = answerRead(reads.length); + if (answer === undefined) return; + observer.next({ result: { data: answer } }); + observer.complete(); + }); + const queryClient = new QueryClient(); + const client = electronTrpc.createClient({ links: [link] }); + const view = renderHook( + () => + useFileContent({ + workspaceId: "workspace-1", + worktreePath: "/worktrees/one", + filePath: "/worktrees/one/README.md", + viewMode: "raw", + }), + { + wrapper: ({ children }: { children: ReactNode }) => + createElement(electronTrpc.Provider, { client, queryClient, children }), + }, + ); + return { queryClient, reads, view }; +} - test("keeps exact-workspace cached file content visible during a refresh", () => { - rawQueryResult = { - data: { - content: "cached content", - byteLength: 14, - exceededLimit: false, - revision: "revision-1", - }, - isLoading: true, - }; +describe("useFileContent", () => { + test("keeps exact-workspace cached file content visible during a refresh", async () => { + const { queryClient, reads, view } = renderFileContent((attempt) => + attempt === 1 + ? { + content: "cached content", + byteLength: 14, + exceededLimit: false, + revision: "revision-1", + } + : undefined, + ); + await waitFor(() => expect(view.result.current.rawFileData).toBeDefined()); - const result = useFileContent({ - workspaceId: "workspace-1", - worktreePath: "/worktrees/one", - filePath: "/worktrees/one/README.md", - viewMode: "raw", + act(() => { + void queryClient.invalidateQueries(); }); + await waitFor(() => expect(reads).toHaveLength(2)); - expect(result.rawFileData).toEqual({ + expect(view.result.current.rawFileData).toEqual({ ok: true, content: "cached content", truncated: false, byteLength: 14, }); - expect(result.isLoadingRaw).toBe(false); - expect(readFileUseQuery).toHaveBeenNthCalledWith( - 1, - { - workspaceId: "workspace-1", - absolutePath: "/worktrees/one/README.md", - encoding: "utf-8", - maxBytes: 2 * 1024 * 1024, - }, - expect.objectContaining({ - enabled: true, - gcTime: FILE_CONTENT_GC_TIME_MS, - staleTime: FILE_CONTENT_STALE_TIME_MS, - }), + expect(view.result.current.isLoadingRaw).toBe(false); + expect(reads[0]).toEqual({ + workspaceId: "workspace-1", + absolutePath: "/worktrees/one/README.md", + encoding: "utf-8", + maxBytes: 2 * 1024 * 1024, + }); + const query = queryClient + .getQueryCache() + .getAll() + .find((cached) => cached.state.data !== undefined); + expect(query?.options.gcTime).toBe(FILE_CONTENT_GC_TIME_MS); + expect(query?.observers[0]?.options.staleTime).toBe( + FILE_CONTENT_STALE_TIME_MS, ); }); - test("shows initial loading only when no cached file data exists", () => { - const result = useFileContent({ - workspaceId: "workspace-2", - worktreePath: "/worktrees/two", - filePath: "/worktrees/two/README.md", - viewMode: "raw", - }); + test("shows initial loading only when no cached file data exists", async () => { + const { reads, view } = renderFileContent(() => undefined); + await waitFor(() => expect(reads).toHaveLength(1)); - expect(result.rawFileData).toBeUndefined(); - expect(result.isLoadingRaw).toBe(true); + expect(view.result.current.rawFileData).toBeUndefined(); + expect(view.result.current.isLoadingRaw).toBe(true); }); }); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.attach.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.attach.test.ts index 7050b39118c..d421417491c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.attach.test.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.attach.test.ts @@ -8,8 +8,7 @@ * Skip that and the restarted shell has a session nobody is subscribed to: it * runs, and its output never reaches the pane. */ -import { afterAll, afterEach, describe, expect, it, spyOn } from "bun:test"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { afterEach, describe, expect, it, spyOn } from "bun:test"; import type { FitAddon } from "@xterm/addon-fit"; import type { SearchAddon } from "@xterm/addon-search"; import type { Terminal as XTerm } from "@xterm/xterm"; @@ -22,10 +21,6 @@ import type { } from "../types"; import type { UseTerminalLifecycleOptions } from "./useTerminalLifecycle"; -// happy-dom over the preloaded plain-object document. Process-wide, so this -// unregisters in afterAll to leave the other renderer suites their document. -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -48,10 +43,6 @@ afterEach(() => { for (const spy of spies) spy.mockRestore(); spies.length = 0; }); -afterAll(async () => { - if (!alreadyRegistered) await GlobalRegistrator.unregister(); -}); - /** * xterm needs a canvas to open, which happy-dom has none of. The lifecycle * effect only calls back into it for the members below. diff --git a/apps/desktop/src/renderer/screens/main/hooks/useGitChangesStatus/useGitChangesStatus.test.ts b/apps/desktop/src/renderer/screens/main/hooks/useGitChangesStatus/useGitChangesStatus.test.ts index c8b567f3c3b..ff04f70a479 100644 --- a/apps/desktop/src/renderer/screens/main/hooks/useGitChangesStatus/useGitChangesStatus.test.ts +++ b/apps/desktop/src/renderer/screens/main/hooks/useGitChangesStatus/useGitChangesStatus.test.ts @@ -1,6 +1,28 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; +import type { TRPCLink } from "@trpc/client"; +import type { AppRouter } from "lib/trpc/routers"; +import type { ReactNode } from "react"; import type { GitChangesStatus } from "shared/changes-types"; +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const { QueryClient } = await import("@tanstack/react-query"); +const { observable } = await import("@trpc/server/observable"); +const { act, cleanup, renderHook, waitFor } = await import( + "@testing-library/react" +); +const { createElement } = await import("react"); +const { electronTrpc } = await import("renderer/lib/electron-trpc"); +const { + ERROR_BACKOFF_REFETCH_INTERVAL_MS, + GIT_CHANGES_QUERY_GC_TIME_MS, + resolveStatusRefetchInterval, + useGitChangesStatus, +} = await import("./useGitChangesStatus"); + +afterEach(cleanup); const status: GitChangesStatus = { branch: "feature", defaultBranch: "release", @@ -17,111 +39,87 @@ const status: GitChangesStatus = { hasUpstream: true, }; -let branchQueryResult: { data?: unknown } = {}; -let statusQueryResult: { - data?: GitChangesStatus; - isLoading: boolean; - refetch: () => Promise; -}; - -const getBranchesUseQuery = mock( - (_input: unknown, _options: unknown) => branchQueryResult, -); -const getStatusUseQuery = mock( - (_input: unknown, _options: unknown) => statusQueryResult, -); +interface Call { + path: string; + input: unknown; +} -mock.module("renderer/lib/electron-trpc", () => ({ - electronTrpc: { - changes: { - getBranches: { useQuery: getBranchesUseQuery }, - getStatus: { useQuery: getStatusUseQuery }, +function renderStatus(answerStatus: (attempt: number) => unknown) { + const calls: Call[] = []; + const link: TRPCLink = () => (call) => + observable((observer) => { + calls.push({ path: call.op.path, input: call.op.input }); + if (call.op.path !== "changes.getStatus") return; + const attempt = calls.filter( + (made) => made.path === "changes.getStatus", + ).length; + const answer = answerStatus(attempt); + if (answer === undefined) return; + observer.next({ result: { data: answer } }); + observer.complete(); + }); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const client = electronTrpc.createClient({ links: [link] }); + const view = renderHook( + () => useGitChangesStatus({ worktreePath: "/worktrees/one" }), + { + wrapper: ({ children }: { children: ReactNode }) => + createElement(electronTrpc.Provider, { client, queryClient, children }), }, - }, -})); - -const { - ERROR_BACKOFF_REFETCH_INTERVAL_MS, - GIT_CHANGES_QUERY_GC_TIME_MS, - useGitChangesStatus, -} = await import("./useGitChangesStatus"); - -type RefetchIntervalFn = (query: { - state: { - status: "success" | "error"; - data?: GitChangesStatus; - error?: { data?: { code?: string } } | null; - }; -}) => number | false; - -function getStatusRefetchInterval(): RefetchIntervalFn { - const options = getStatusUseQuery.mock.calls[0]?.[1] as { - refetchInterval: RefetchIntervalFn; - }; - return options.refetchInterval; + ); + return { calls, queryClient, view }; } describe("useGitChangesStatus", () => { - beforeEach(() => { - branchQueryResult = {}; - statusQueryResult = { - isLoading: true, - refetch: async () => {}, - }; - getBranchesUseQuery.mockClear(); - getStatusUseQuery.mockClear(); - }); - - test("starts branch and status queries together on a cold workspace", () => { - useGitChangesStatus({ worktreePath: "/worktrees/one" }); + test("starts branch and status queries together on a cold workspace", async () => { + const { calls, queryClient, view } = renderStatus(() => undefined); - expect(getBranchesUseQuery).toHaveBeenCalledWith( - { worktreePath: "/worktrees/one" }, - expect.objectContaining({ - enabled: true, - gcTime: GIT_CHANGES_QUERY_GC_TIME_MS, - }), - ); - expect(getStatusUseQuery).toHaveBeenCalledWith( - { worktreePath: "/worktrees/one" }, - expect.objectContaining({ - enabled: true, - gcTime: GIT_CHANGES_QUERY_GC_TIME_MS, - }), - ); + await waitFor(() => expect(calls).toHaveLength(2)); + expect(calls).toContainEqual({ + path: "changes.getBranches", + input: { worktreePath: "/worktrees/one" }, + }); + expect(calls).toContainEqual({ + path: "changes.getStatus", + input: { worktreePath: "/worktrees/one" }, + }); + expect(view.result.current.isLoading).toBe(true); + for (const query of queryClient.getQueryCache().getAll()) { + expect(query.options.gcTime).toBe(GIT_CHANGES_QUERY_GC_TIME_MS); + } }); - test("keeps exact-worktree cached status visible during a refresh", () => { - statusQueryResult = { - data: status, - isLoading: true, - refetch: async () => {}, - }; + test("keeps exact-worktree cached status visible during a refresh", async () => { + const { calls, view } = renderStatus((attempt) => + attempt === 1 ? status : undefined, + ); + await waitFor(() => expect(view.result.current.status).toEqual(status)); - const result = useGitChangesStatus({ worktreePath: "/worktrees/one" }); + act(() => { + void view.result.current.refetch(); + }); + await waitFor(() => + expect( + calls.filter((made) => made.path === "changes.getStatus"), + ).toHaveLength(2), + ); - expect(result.status).toBe(status); - expect(result.isLoading).toBe(false); - expect(result.effectiveBaseBranch).toBe("release"); + expect(view.result.current.status).toEqual(status); + expect(view.result.current.isLoading).toBe(false); + expect(view.result.current.effectiveBaseBranch).toBe("release"); }); +}); +describe("resolveStatusRefetchInterval", () => { test("polls at the configured interval on success", () => { - useGitChangesStatus({ - worktreePath: "/worktrees/one", - refetchInterval: 2500, - }); - - const interval = getStatusRefetchInterval(); - expect(interval({ state: { status: "success", data: status } })).toBe(2500); + expect( + resolveStatusRefetchInterval(2500, { status: "success", data: status }), + ).toBe(2500); }); test("backs off to the slow interval on deterministic failures", () => { - useGitChangesStatus({ - worktreePath: "/worktrees/one", - refetchInterval: 2500, - }); - - const interval = getStatusRefetchInterval(); for (const code of [ "BAD_REQUEST", "NOT_FOUND", @@ -129,39 +127,31 @@ describe("useGitChangesStatus", () => { "FORBIDDEN", ]) { expect( - interval({ state: { status: "error", error: { data: { code } } } }), + resolveStatusRefetchInterval(2500, { + status: "error", + error: { data: { code } }, + }), ).toBe(ERROR_BACKOFF_REFETCH_INTERVAL_MS); } }); test("slows but keeps retrying on transient failures", () => { - useGitChangesStatus({ - worktreePath: "/worktrees/one", - refetchInterval: 2500, - }); - - const interval = getStatusRefetchInterval(); expect( - interval({ - state: { - status: "error", - error: { data: { code: "INTERNAL_SERVER_ERROR" } }, - }, + resolveStatusRefetchInterval(2500, { + status: "error", + error: { data: { code: "INTERNAL_SERVER_ERROR" } }, }), ).toBe(10_000); - expect(interval({ state: { status: "error", error: null } })).toBe(10_000); + expect( + resolveStatusRefetchInterval(2500, { status: "error", error: null }), + ).toBe(10_000); }); test("never starts polling on error when no interval is configured", () => { - useGitChangesStatus({ worktreePath: "/worktrees/one" }); - - const interval = getStatusRefetchInterval(); expect( - interval({ - state: { - status: "error", - error: { data: { code: "NOT_FOUND" } }, - }, + resolveStatusRefetchInterval(undefined, { + status: "error", + error: { data: { code: "NOT_FOUND" } }, }), ).toBe(false); }); diff --git a/apps/desktop/src/renderer/screens/main/hooks/useGitChangesStatus/useGitChangesStatus.ts b/apps/desktop/src/renderer/screens/main/hooks/useGitChangesStatus/useGitChangesStatus.ts index 86168fc29d9..f4e5fafcfbb 100644 --- a/apps/desktop/src/renderer/screens/main/hooks/useGitChangesStatus/useGitChangesStatus.ts +++ b/apps/desktop/src/renderer/screens/main/hooks/useGitChangesStatus/useGitChangesStatus.ts @@ -44,6 +44,30 @@ function errorBackoffInterval(baseInterval: number, error: unknown): number { return Math.max(baseInterval, LARGE_CHANGESET_REFETCH_INTERVAL_MS); } +export function resolveStatusRefetchInterval( + refetchInterval: number | undefined, + state: { status: string; data?: unknown; error?: unknown }, +): number | false { + if (!refetchInterval) return false; + if (state.status === "error") { + return errorBackoffInterval(refetchInterval, state.error); + } + const data = state.data as GitChangesStatus | undefined; + if (!data) return refetchInterval; + + const totalChangedFiles = + data.againstBase.length + + data.staged.length + + data.unstaged.length + + data.untracked.length; + + if (totalChangedFiles >= LARGE_CHANGESET_THRESHOLD) { + return Math.max(refetchInterval, LARGE_CHANGESET_REFETCH_INTERVAL_MS); + } + + return refetchInterval; +} + export function getGitChangesErrorCause( error: unknown, ): GitChangesErrorCause | undefined { @@ -104,26 +128,8 @@ export function useGitChangesStatus({ { enabled: enabled && !!worktreePath, gcTime: GIT_CHANGES_QUERY_GC_TIME_MS, - refetchInterval: (query) => { - if (!refetchInterval) return false; - if (query.state.status === "error") { - return errorBackoffInterval(refetchInterval, query.state.error); - } - const data = query.state.data as GitChangesStatus | undefined; - if (!data) return refetchInterval; - - const totalChangedFiles = - data.againstBase.length + - data.staged.length + - data.unstaged.length + - data.untracked.length; - - if (totalChangedFiles >= LARGE_CHANGESET_THRESHOLD) { - return Math.max(refetchInterval, LARGE_CHANGESET_REFETCH_INTERVAL_MS); - } - - return refetchInterval; - }, + refetchInterval: (query) => + resolveStatusRefetchInterval(refetchInterval, query.state), refetchOnWindowFocus, staleTime: staleTime ?? STATUS_QUERY_STALE_TIME_MS, }, diff --git a/apps/desktop/src/renderer/stores/theme/utils/terminal-theme-type.test.ts b/apps/desktop/src/renderer/stores/theme/utils/terminal-theme-type.test.ts index 7ea67ae5a5f..e89f3d00893 100644 --- a/apps/desktop/src/renderer/stores/theme/utils/terminal-theme-type.test.ts +++ b/apps/desktop/src/renderer/stores/theme/utils/terminal-theme-type.test.ts @@ -1,21 +1,16 @@ -import { beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, beforeEach, describe, expect, it } from "bun:test"; import { resolveTerminalThemeType } from "./terminal-theme-type"; -// Mock localStorage for Node.js test environment -const mockStorage = new Map(); -const mockLocalStorage = { - getItem: (key: string) => mockStorage.get(key) ?? null, - setItem: (key: string, value: string) => mockStorage.set(key, value), - removeItem: (key: string) => mockStorage.delete(key), - clear: () => mockStorage.clear(), -}; +const themeTypeBefore = localStorage.getItem("theme-type"); -// @ts-expect-error - mocking global localStorage -globalThis.localStorage = mockLocalStorage; +afterAll(() => { + if (themeTypeBefore === null) localStorage.removeItem("theme-type"); + else localStorage.setItem("theme-type", themeTypeBefore); +}); describe("resolveTerminalThemeType", () => { beforeEach(() => { - mockStorage.clear(); + localStorage.removeItem("theme-type"); }); it("prefers active theme type when provided", () => { diff --git a/apps/desktop/src/xterm-ime-patch.test.ts b/apps/desktop/src/xterm-ime-patch.test.ts index b1c32ce96b6..ee9b1ee42de 100644 --- a/apps/desktop/src/xterm-ime-patch.test.ts +++ b/apps/desktop/src/xterm-ime-patch.test.ts @@ -1,17 +1,7 @@ import { afterAll, describe, expect, test } from "bun:test"; import { dirname, join } from "node:path"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; import type { Terminal as XtermTerminal } from "@xterm/xterm"; -// Removal checklist: https://github.com/superset-sh/superset/issues/7490 -// Upstream fix: https://github.com/xtermjs/xterm.js/pull/6162 -// Once our pinned xterm release includes it, remove only the CompositionHelper -// patch hunks. Keep these behavioral tests; patches/README.md has the checklist. -// Exercise the installed bundles, so dropping the version-pinned dependency -// patch fails behaviorally. DOM measurement is stubbed; actual glyph clipping -// is covered by the D2Coding CDP reproduction linked in patches/README.md. -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); const canvases = [globalThis.HTMLCanvasElement, globalThis.OffscreenCanvas] .filter(Boolean) .map((canvas) => ({ @@ -29,7 +19,6 @@ afterAll(async () => { if (descriptor) Object.defineProperty(prototype, "getContext", descriptor); else delete (prototype as { getContext?: unknown }).getContext; } - if (!alreadyRegistered) await GlobalRegistrator.unregister(); }); type Core = { diff --git a/apps/desktop/src/xterm-render-debouncer-patch.test.ts b/apps/desktop/src/xterm-render-debouncer-patch.test.ts index 747d71e36d4..eb3eea8727f 100644 --- a/apps/desktop/src/xterm-render-debouncer-patch.test.ts +++ b/apps/desktop/src/xterm-render-debouncer-patch.test.ts @@ -1,7 +1,6 @@ import { afterAll, afterEach, describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; // Guards the RenderDebouncer hunk of the bun patch on @xterm/xterm // (DESKTOP-27 / DESKTOP-CS, see patches/README.md). Unpatched, dispose() @@ -16,11 +15,6 @@ import { GlobalRegistrator } from "@happy-dom/global-registrator"; // the patch while everything still builds. If this fails after a bump, // regenerate the patch per patches/README.md; do NOT delete it. -// happy-dom over the preloaded plain-object document: Terminal.open() needs -// a real DOM. Globals are process-wide, so unregister in afterAll. -const alreadyRegistered = GlobalRegistrator.isRegistered; -if (!alreadyRegistered) GlobalRegistrator.register(); - // The DOM renderer measures glyphs through a 2d context happy-dom does not // implement (it prefers OffscreenCanvas when present); widths only need to be // finite for this test. The stub lives on happy-dom's prototypes, which outlive @@ -52,14 +46,12 @@ afterAll(async () => { if (descriptor) Object.defineProperty(prototype, "getContext", descriptor); else delete (prototype as { getContext?: unknown }).getContext; } - if (!alreadyRegistered) await GlobalRegistrator.unregister(); }); const { Terminal } = await import("@xterm/xterm"); // The frame stubs below live on the process-wide window, which outlives this -// file when another suite registered happy-dom first (`alreadyRegistered`, so -// afterAll does not unregister). Leaving a queue that only this file drains +// file. Leaving a queue that only this file drains // would silently strand every later rAF, so restore after each test. let restoreFrames: (() => void) | undefined; afterEach(() => { diff --git a/apps/desktop/test-setup.ts b/apps/desktop/test-setup.ts index b5c0d470118..44ec601d711 100644 --- a/apps/desktop/test-setup.ts +++ b/apps/desktop/test-setup.ts @@ -3,21 +3,22 @@ * * This file mocks EXTERNAL dependencies only: * - Electron APIs (app, dialog, BrowserWindow, ipcMain) - * - Browser globals (document, window) + * - Browser globals (one happy-dom window for the whole run) * - trpc-electron renderer requirements * * DO NOT mock internal code here - tests should use real implementations * or mock at the individual test level when necessary. */ import "../../scripts/test-preload.ts"; -import { beforeEach, mock } from "bun:test"; +import { mock } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; import "@superset/workspace-client/relay-socket"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { z } from "zod"; -// Partysocket subclasses EventTarget at import time; socket tests must use the -// same native event realm even after another suite registers happy-dom. +// Partysocket subclasses EventTarget at import time, before happy-dom is +// registered below; socket tests must use that same native event realm. export const nativeWebGlobals = { WebSocket: globalThis.WebSocket, fetch: globalThis.fetch, @@ -35,88 +36,37 @@ process.env.SKIP_ENV_VALIDATION = "1"; const testTmpDir = join(tmpdir(), "superset-test"); // ============================================================================= -// Browser Global Mocks (required for renderer code that touches DOM) +// DOM (one happy-dom window for the whole run) // ============================================================================= -const mockStyleMap = new Map(); -const mockClassList = new Set(); - -const mockHead = { - appendChild: mock(() => {}), - removeChild: mock(() => {}), -}; - -// biome-ignore lint/suspicious/noExplicitAny: Test setup requires extending globalThis -(globalThis as any).document = { - addEventListener: mock(() => {}), - removeEventListener: mock(() => {}), - documentElement: { - style: { - setProperty: (key: string, value: string) => mockStyleMap.set(key, value), - getPropertyValue: (key: string) => mockStyleMap.get(key) || "", - }, - classList: { - add: (className: string) => mockClassList.add(className), - remove: (className: string) => mockClassList.delete(className), - toggle: (className: string) => { - mockClassList.has(className) - ? mockClassList.delete(className) - : mockClassList.add(className); - }, - contains: (className: string) => mockClassList.has(className), - }, - }, - head: mockHead, - getElementsByTagName: mock((tag: string) => { - if (tag === "head") return [mockHead]; - return []; - }), - createElement: mock((_tag: string) => ({ - setAttribute: mock(() => {}), - appendChild: mock(() => {}), - textContent: "", - type: "", - })), - createTextNode: mock((text: string) => ({ - textContent: text, - })), -}; - -// zustand's persist middleware defaults to `window.localStorage`. The -// xterm-env-polyfill preload aliases `window` to globalThis, so that lookup -// resolves to `undefined` without throwing and persist crashes on the first -// setState. Provide an in-memory Storage so persisted stores work in tests. -const localStorageData = new Map(); -(globalThis as { localStorage?: Storage }).localStorage = { - get length() { - return localStorageData.size; - }, - clear: () => localStorageData.clear(), - getItem: (key: string) => localStorageData.get(key) ?? null, - key: (index: number) => [...localStorageData.keys()][index] ?? null, - removeItem: (key: string) => { - localStorageData.delete(key); - }, - setItem: (key: string, value: string) => { - localStorageData.set(key, value); - }, +// Registered once and never unregistered: react-dom keeps the scheduler +// functions it saw at import, and unregistering closes the window they belong +// to, so later files would never see an update made outside act(). +const nativeRuntimeGlobals = { + ...nativeWebGlobals, + Request: globalThis.Request, + Headers: globalThis.Headers, + URL: globalThis.URL, + TransformStream: globalThis.TransformStream, + WritableStream: globalThis.WritableStream, + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + setInterval: globalThis.setInterval, + clearInterval: globalThis.clearInterval, + queueMicrotask: globalThis.queueMicrotask, + atob: globalThis.atob, + btoa: globalThis.btoa, }; - -beforeEach(() => { - localStorageData.clear(); -}); - -// Ensure window has addEventListener/removeEventListener for react-hotkeys-hook's IIFE -if (typeof globalThis.window !== "undefined") { - const win = globalThis.window; - if (!win.addEventListener) win.addEventListener = mock(() => {}); - if (!win.removeEventListener) win.removeEventListener = mock(() => {}); -} else { - // biome-ignore lint/suspicious/noExplicitAny: Test setup requires extending globalThis - (globalThis as any).window = { - addEventListener: mock(() => {}), - removeEventListener: mock(() => {}), - }; +const domEventGlobals = ["Event", "MessageEvent", "EventTarget"]; + +if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register(); +for (const [name, value] of Object.entries(nativeRuntimeGlobals)) { + if (domEventGlobals.includes(name)) continue; + Object.defineProperty(globalThis, name, { + value, + writable: true, + configurable: true, + }); } // localStorage: renderer stores persisted with zustand's `persist` middleware