diff --git a/apps/array/src/api/fetcher.test.ts b/apps/array/src/api/fetcher.test.ts new file mode 100644 index 00000000..0a7b4e74 --- /dev/null +++ b/apps/array/src/api/fetcher.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { buildApiFetcher } from "./fetcher"; + +describe("buildApiFetcher", () => { + const mockFetch = vi.fn(); + const mockInput = { + method: "get" as const, + url: new URL("https://api.example.com/test"), + path: "/test", + }; + const ok = (data = {}) => ({ + ok: true, + status: 200, + json: () => Promise.resolve(data), + }); + const err = (status: number) => ({ + ok: false, + status, + json: () => Promise.resolve({ error: status }), + }); + + beforeEach(() => { + vi.resetAllMocks(); + vi.stubGlobal("fetch", mockFetch); + }); + + it("makes request with bearer token", async () => { + mockFetch.mockResolvedValueOnce(ok()); + const fetcher = buildApiFetcher({ apiToken: "my-token" }); + + await fetcher.fetch(mockInput); + + expect(mockFetch.mock.calls[0][1].headers.get("Authorization")).toBe( + "Bearer my-token", + ); + }); + + it("retries with new token on 401", async () => { + const onTokenRefresh = vi.fn().mockResolvedValue("new-token"); + mockFetch.mockResolvedValueOnce(err(401)).mockResolvedValueOnce(ok()); + + const fetcher = buildApiFetcher({ apiToken: "old-token", onTokenRefresh }); + const response = await fetcher.fetch(mockInput); + + expect(response.ok).toBe(true); + expect(onTokenRefresh).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[1][1].headers.get("Authorization")).toBe( + "Bearer new-token", + ); + }); + + it("uses refreshed token for subsequent requests", async () => { + const onTokenRefresh = vi.fn().mockResolvedValue("refreshed-token"); + mockFetch + .mockResolvedValueOnce(err(401)) + .mockResolvedValueOnce(ok()) + .mockResolvedValueOnce(ok()); + + const fetcher = buildApiFetcher({ + apiToken: "initial-token", + onTokenRefresh, + }); + await fetcher.fetch(mockInput); + await fetcher.fetch(mockInput); + + expect(mockFetch.mock.calls[2][1].headers.get("Authorization")).toBe( + "Bearer refreshed-token", + ); + }); + + it("does not refresh on non-401 errors", async () => { + const onTokenRefresh = vi.fn(); + mockFetch.mockResolvedValueOnce(err(403)); + + const fetcher = buildApiFetcher({ apiToken: "token", onTokenRefresh }); + + await expect(fetcher.fetch(mockInput)).rejects.toThrow("[403]"); + expect(onTokenRefresh).not.toHaveBeenCalled(); + }); + + it("throws on 401 without refresh callback", async () => { + mockFetch.mockResolvedValueOnce(err(401)); + const fetcher = buildApiFetcher({ apiToken: "token" }); + + await expect(fetcher.fetch(mockInput)).rejects.toThrow("[401]"); + }); + + it("throws when refresh fails", async () => { + const onTokenRefresh = vi.fn().mockRejectedValue(new Error("failed")); + mockFetch.mockResolvedValueOnce(err(401)); + + const fetcher = buildApiFetcher({ apiToken: "token", onTokenRefresh }); + + await expect(fetcher.fetch(mockInput)).rejects.toThrow("[401]"); + }); + + it("handles network errors", async () => { + mockFetch.mockRejectedValueOnce(new Error("Network failure")); + const fetcher = buildApiFetcher({ apiToken: "token" }); + + await expect(fetcher.fetch(mockInput)).rejects.toThrow( + "Network request failed", + ); + }); +}); diff --git a/apps/array/src/api/fetcher.ts b/apps/array/src/api/fetcher.ts index b0d956b6..c67b3db0 100644 --- a/apps/array/src/api/fetcher.ts +++ b/apps/array/src/api/fetcher.ts @@ -4,6 +4,8 @@ export const buildApiFetcher: (config: { apiToken: string; onTokenRefresh?: () => Promise; }) => Parameters[0] = (config) => { + let currentToken = config.apiToken; + const makeRequest = async ( input: Parameters[0]["fetch"]>[0], token: string, @@ -51,13 +53,14 @@ export const buildApiFetcher: (config: { return { fetch: async (input) => { - let response = await makeRequest(input, config.apiToken); + let response = await makeRequest(input, currentToken); // Handle 401 with automatic token refresh if (!response.ok && response.status === 401 && config.onTokenRefresh) { try { const newToken = await config.onTokenRefresh(); - response = await makeRequest(input, newToken); + currentToken = newToken; + response = await makeRequest(input, currentToken); } catch { // Token refresh failed - throw the original 401 error const errorResponse = await response.json(); diff --git a/apps/array/src/renderer/features/panels/store/panelLayoutStore.test.ts b/apps/array/src/renderer/features/panels/store/panelLayoutStore.test.ts index 95ba2914..1d1d1680 100644 --- a/apps/array/src/renderer/features/panels/store/panelLayoutStore.test.ts +++ b/apps/array/src/renderer/features/panels/store/panelLayoutStore.test.ts @@ -1,19 +1,14 @@ import { assertActiveTab, - assertActiveTabInNestedPanel, assertGroupStructure, assertPanelLayout, assertTabCount, - assertTabInNestedPanel, - closeMultipleTabs, findPanelById, type GroupNode, getLayout, getNestedPanel, getPanelTree, openMultipleFiles, - splitAndAssert, - testSizePreservation, withRootGroup, } from "@test/panelTestHelpers"; import { beforeEach, describe, expect, it } from "vitest"; @@ -44,16 +39,17 @@ describe("panelLayoutStore", () => { withRootGroup("task-1", (root: GroupNode) => { assertGroupStructure(root, { - direction: "horizontal", + direction: "vertical", childCount: 2, sizes: [70, 30], }); assertPanelLayout(root, [ + { panelId: "main-panel", expectedTabs: ["logs"], activeTab: "logs" }, { - panelId: "main-panel", - expectedTabs: ["logs", "shell"], - activeTab: "logs", + panelId: "terminal-panel", + expectedTabs: ["shell"], + activeTab: "shell", }, ]); }); @@ -68,11 +64,11 @@ describe("panelLayoutStore", () => { it("adds file tab to main panel", () => { usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx"); - assertTabCount(getPanelTree("task-1"), "main-panel", 3); + assertTabCount(getPanelTree("task-1"), "main-panel", 2); assertPanelLayout(getPanelTree("task-1"), [ { panelId: "main-panel", - expectedTabs: ["logs", "shell", "file-src/App.tsx"], + expectedTabs: ["logs", "file-src/App.tsx"], }, ]); }); @@ -173,13 +169,15 @@ describe("panelLayoutStore", () => { assertActiveTab(getPanelTree("task-1"), "main-panel", "file-src/App.tsx"); }); - it("falls back to shell when last file tab closed", () => { - closeMultipleTabs("task-1", "main-panel", [ - "file-src/App.tsx", - "file-src/Other.tsx", - ]); + it("falls back to logs when last file tab closed", () => { + usePanelLayoutStore + .getState() + .closeTab("task-1", "main-panel", "file-src/App.tsx"); + usePanelLayoutStore + .getState() + .closeTab("task-1", "main-panel", "file-src/Other.tsx"); - assertActiveTab(getPanelTree("task-1"), "main-panel", "shell"); + assertActiveTab(getPanelTree("task-1"), "main-panel", "logs"); }); }); @@ -222,48 +220,45 @@ describe("panelLayoutStore", () => { usePanelLayoutStore.getState().initializeTask("task-1"); }); - it( - "preserves custom panel sizes when opening a file", - testSizePreservation("opening a file", () => { - openMultipleFiles("task-1", ["src/App.tsx"]); - }, [50, 50]), - ); + it("preserves custom panel sizes when opening a file", () => { + usePanelLayoutStore + .getState() + .updateSizes("task-1", "left-group", [60, 40]); - it( - "preserves custom panel sizes when switching tabs", - testSizePreservation("switching tabs", () => { - openMultipleFiles("task-1", ["src/App.tsx", "src/Other.tsx"]); - usePanelLayoutStore - .getState() - .setActiveTab("task-1", "main-panel", "file-src/App.tsx"); - }, [60, 40]), - ); + openMultipleFiles("task-1", ["src/App.tsx"]); - it( - "preserves custom panel sizes when closing tabs", - testSizePreservation("closing tabs", () => { - openMultipleFiles("task-1", ["src/App.tsx", "src/Other.tsx"]); - usePanelLayoutStore - .getState() - .closeTab("task-1", "main-panel", "file-src/Other.tsx"); - }), - ); + withRootGroup("task-1", (root) => { + expect(root.sizes).toEqual([60, 40]); + }); + }); - it( - "preserves custom panel sizes in nested groups when splitting panels", - testSizePreservation("splitting panels", () => { - openMultipleFiles("task-1", ["src/App.tsx", "src/Other.tsx"]); - usePanelLayoutStore - .getState() - .splitPanel( - "task-1", - "file-src/Other.tsx", - "main-panel", - "main-panel", - "right", - ); - }, [65, 35]), - ); + it("preserves custom panel sizes when switching tabs", () => { + usePanelLayoutStore + .getState() + .updateSizes("task-1", "left-group", [55, 45]); + openMultipleFiles("task-1", ["src/App.tsx", "src/Other.tsx"]); + usePanelLayoutStore + .getState() + .setActiveTab("task-1", "main-panel", "file-src/App.tsx"); + + withRootGroup("task-1", (root) => { + expect(root.sizes).toEqual([55, 45]); + }); + }); + + it("preserves custom panel sizes when closing tabs", () => { + usePanelLayoutStore + .getState() + .updateSizes("task-1", "left-group", [80, 20]); + openMultipleFiles("task-1", ["src/App.tsx", "src/Other.tsx"]); + usePanelLayoutStore + .getState() + .closeTab("task-1", "main-panel", "file-src/Other.tsx"); + + withRootGroup("task-1", (root) => { + expect(root.sizes).toEqual([80, 20]); + }); + }); }); describe("persistence", () => { @@ -352,11 +347,13 @@ describe("panelLayoutStore", () => { }); it("reorders tabs within a panel", () => { - usePanelLayoutStore.getState().reorderTabs("task-1", "main-panel", 2, 3); + // tabs: [logs, file-src/App.tsx, file-src/Other.tsx, file-src/Third.tsx] + // move index 1 to index 3 + usePanelLayoutStore.getState().reorderTabs("task-1", "main-panel", 1, 3); const panel = findPanelById(getPanelTree("task-1"), "main-panel"); const tabIds = panel?.content.tabs.map((t: { id: string }) => t.id); - expect(tabIds?.[2]).toBe("file-src/Other.tsx"); + expect(tabIds?.[1]).toBe("file-src/Other.tsx"); expect(tabIds?.[3]).toBe("file-src/App.tsx"); }); @@ -364,9 +361,9 @@ describe("panelLayoutStore", () => { usePanelLayoutStore .getState() .setActiveTab("task-1", "main-panel", "file-src/App.tsx"); - usePanelLayoutStore.getState().reorderTabs("task-1", "main-panel", 0, 2); + usePanelLayoutStore.getState().reorderTabs("task-1", "main-panel", 1, 3); - assertActiveTabInNestedPanel("task-1", "file-src/App.tsx", "left"); + assertActiveTab(getPanelTree("task-1"), "main-panel", "file-src/App.tsx"); }); }); @@ -376,31 +373,34 @@ describe("panelLayoutStore", () => { usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx"); }); - it("moves tab to different panel", () => { + it("moves tab between panels", () => { usePanelLayoutStore .getState() - .moveTab("task-1", "file-src/App.tsx", "main-panel", "top-right"); + .moveTab("task-1", "file-src/App.tsx", "main-panel", "terminal-panel"); - assertTabInNestedPanel("task-1", "file-src/App.tsx", false, "left"); - assertTabInNestedPanel( - "task-1", - "file-src/App.tsx", - true, - "right", - "left", + const mainPanel = findPanelById(getPanelTree("task-1"), "main-panel"); + const terminalPanel = findPanelById( + getPanelTree("task-1"), + "terminal-panel", ); + + expect( + mainPanel?.content.tabs.find((t) => t.id === "file-src/App.tsx"), + ).toBeUndefined(); + expect( + terminalPanel?.content.tabs.find((t) => t.id === "file-src/App.tsx"), + ).toBeDefined(); }); it("sets moved tab as active in target panel", () => { usePanelLayoutStore .getState() - .moveTab("task-1", "file-src/App.tsx", "main-panel", "top-right"); + .moveTab("task-1", "file-src/App.tsx", "main-panel", "terminal-panel"); - assertActiveTabInNestedPanel( - "task-1", + assertActiveTab( + getPanelTree("task-1"), + "terminal-panel", "file-src/App.tsx", - "right", - "left", ); }); }); @@ -408,7 +408,7 @@ describe("panelLayoutStore", () => { describe("splitPanel", () => { beforeEach(() => { usePanelLayoutStore.getState().initializeTask("task-1"); - usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx"); + openMultipleFiles("task-1", ["src/App.tsx", "src/Other.tsx"]); }); it.each([ @@ -419,12 +419,23 @@ describe("panelLayoutStore", () => { ] as const)( "splits panel %s creates %s layout", (direction, expectedDirection) => { - splitAndAssert( - "task-1", - "file-src/App.tsx", - direction, - expectedDirection, - ); + usePanelLayoutStore + .getState() + .splitPanel( + "task-1", + "file-src/App.tsx", + "main-panel", + "main-panel", + direction, + ); + + // After split, main-panel becomes a group + const mainPanelNode = getNestedPanel("task-1", 0); + expect(mainPanelNode.type).toBe("group"); + if (mainPanelNode.type === "group") { + expect(mainPanelNode.direction).toBe(expectedDirection); + expect(mainPanelNode.children).toHaveLength(2); + } }, ); @@ -439,19 +450,19 @@ describe("panelLayoutStore", () => { "right", ); - assertTabInNestedPanel( - "task-1", - "file-src/App.tsx", - true, - "left", - "right", - ); - assertActiveTabInNestedPanel( - "task-1", - "file-src/App.tsx", - "left", - "right", - ); + // After right split: main-panel becomes a group with [original, new] + const mainPanelNode = getNestedPanel("task-1", 0); + expect(mainPanelNode.type).toBe("group"); + if (mainPanelNode.type === "group") { + const newPanel = mainPanelNode.children[1]; + expect(newPanel.type).toBe("leaf"); + if (newPanel.type === "leaf") { + expect( + newPanel.content.tabs.some((t) => t.id === "file-src/App.tsx"), + ).toBe(true); + expect(newPanel.content.activeTabId).toBe("file-src/App.tsx"); + } + } }); }); @@ -461,23 +472,12 @@ describe("panelLayoutStore", () => { }); it("updates panel group sizes", () => { - usePanelLayoutStore.getState().updateSizes("task-1", "root", [60, 40]); - - withRootGroup("task-1", (root: GroupNode) => { - expect(root.sizes).toEqual([60, 40]); - }); - }); - - it("updates nested group sizes", () => { usePanelLayoutStore .getState() - .updateSizes("task-1", "right-group", [30, 70]); + .updateSizes("task-1", "left-group", [60, 40]); - const rightGroup = getNestedPanel("task-1", "right"); - assertGroupStructure(rightGroup, { - direction: "vertical", - childCount: 2, - sizes: [30, 70], + withRootGroup("task-1", (root: GroupNode) => { + expect(root.sizes).toEqual([60, 40]); }); }); }); @@ -485,7 +485,7 @@ describe("panelLayoutStore", () => { describe("tree cleanup", () => { beforeEach(() => { usePanelLayoutStore.getState().initializeTask("task-1"); - usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx"); + openMultipleFiles("task-1", ["src/App.tsx", "src/Other.tsx"]); }); it("removes empty panels after closing all tabs", () => { @@ -499,13 +499,18 @@ describe("panelLayoutStore", () => { "right", ); - const newPanel = getNestedPanel("task-1", "left", "right"); - usePanelLayoutStore - .getState() - .closeTab("task-1", newPanel.id, "file-src/App.tsx"); + // Find the new panel and close its tab + const mainPanelNode = getNestedPanel("task-1", 0); + if (mainPanelNode.type === "group") { + const newPanel = mainPanelNode.children[1]; + usePanelLayoutStore + .getState() + .closeTab("task-1", newPanel.id, "file-src/App.tsx"); + } - const updatedLeftPanel = getNestedPanel("task-1", "left"); - expect(updatedLeftPanel.type).toBe("leaf"); + // After closing, the group should simplify back to a leaf + const updatedMainPanel = getNestedPanel("task-1", 0); + expect(updatedMainPanel.type).toBe("leaf"); }); }); }); diff --git a/apps/array/src/renderer/features/task-detail/components/PanelSplitting.integration.test.tsx b/apps/array/src/renderer/features/task-detail/components/PanelSplitting.integration.test.tsx deleted file mode 100644 index 0dcc2182..00000000 --- a/apps/array/src/renderer/features/task-detail/components/PanelSplitting.integration.test.tsx +++ /dev/null @@ -1,454 +0,0 @@ -import { usePanelLayoutStore } from "@features/panels"; -import type { PanelNode } from "@features/panels/store/panelTypes"; -import { useTaskExecutionStore } from "@features/task-detail/stores/taskExecutionStore"; -import { MOCK_FILES } from "@test/fixtures"; -import { createMockTask, mockElectronAPI } from "@test/panelTestHelpers"; -import { renderWithProviders, screen, waitFor } from "@test/utils"; -import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { TaskDetail } from "./TaskDetail"; - -// Test constants -const TEST_FILES = { - APP: "file-App.tsx", - HELPER: "file-helper.ts", - README: "file-README.md", -} as const; - -const PANEL_IDS = { - MAIN: "main-panel", -} as const; - -const TAB_IDS = { - LOGS: "logs", - SHELL: "shell", -} as const; - -const mockTask = createMockTask(); - -mockElectronAPI({ - listRepoFiles: vi.fn().mockResolvedValue(MOCK_FILES), -}); - -// Helper to find panel by ID in tree -function findPanelById( - node: PanelNode | undefined, - id: string, -): PanelNode | null { - if (!node) return null; - if (node.id === id) return node; - - if (node.type === "group" && node.children) { - for (const child of node.children) { - const found = findPanelById(child, id); - if (found) return found; - } - } - - return null; -} - -// Test helpers -class PanelSplitTester { - constructor(private taskId: string) {} - - async setupWithFile(fileName: string = "App.tsx") { - const user = userEvent.setup(); - renderWithProviders(); - - await waitFor(() => expect(screen.getByText(fileName)).toBeInTheDocument()); - await user.click(screen.getAllByText(fileName)[0]); - await waitFor(() => - expect( - screen.getByRole("tab", { - name: new RegExp(fileName.replace(".", "\\."), "i"), - }), - ).toBeInTheDocument(), - ); - - return user; - } - - async openFile(user: ReturnType, fileName: string) { - await user.click(screen.getAllByText(fileName)[0]); - await waitFor(() => - expect( - screen.getByRole("tab", { - name: new RegExp(fileName.replace(".", "\\."), "i"), - }), - ).toBeInTheDocument(), - ); - } - - split( - tabId: string, - sourcePanelId: string, - targetPanelId: string, - direction: "left" | "right" | "top" | "bottom", - ) { - usePanelLayoutStore - .getState() - .splitPanel(this.taskId, tabId, sourcePanelId, targetPanelId, direction); - } - - getLayout() { - return usePanelLayoutStore.getState().getLayout(this.taskId); - } - - getRoot() { - return this.getLayout()?.panelTree; - } - - assertSplitStructure(expectedDirection: "horizontal" | "vertical") { - const root = this.getRoot(); - - if (root?.type !== "group") { - throw new Error(`Expected root to be group, got ${root?.type}`); - } - - const leftPanel = root.children[0]; - if (leftPanel.type !== "group") { - throw new Error( - `Expected left panel to be group after split, got ${leftPanel.type}. ` + - `This means the split did not occur correctly.`, - ); - } - - if (leftPanel.direction !== expectedDirection) { - throw new Error( - `Expected split direction to be ${expectedDirection}, got ${leftPanel.direction}`, - ); - } - - if (leftPanel.children.length !== 2) { - throw new Error( - `Expected split to create 2 child panels, got ${leftPanel.children.length}`, - ); - } - } - - assertNoSplit() { - const root = this.getRoot(); - - if (root?.type !== "group") { - throw new Error(`Expected root to be group, got ${root?.type}`); - } - - const leftPanel = root.children[0]; - if (leftPanel.type !== "leaf") { - throw new Error( - `Expected left panel to remain a leaf (no split), but got ${leftPanel.type}. ` + - `This means a split occurred when it shouldn't have.`, - ); - } - } - - findPanel(panelId: string) { - return findPanelById(this.getRoot(), panelId); - } - - getPanelIds(): { paneAId: string; paneBId: string } { - const root = this.getRoot(); - - if (root?.type !== "group") throw new Error("Root is not a group"); - - const mainGroup = root.children[0]; - if (mainGroup.type !== "group") - throw new Error("Main group is not a group"); - - return { - paneAId: mainGroup.children[0].id, - paneBId: mainGroup.children[1].id, - }; - } - - closeTab(panelId: string, tabId: string) { - usePanelLayoutStore.getState().closeTab(this.taskId, panelId, tabId); - } -} - -describe("Panel Splitting Integration Tests", () => { - let tester: PanelSplitTester; - - beforeEach(() => { - usePanelLayoutStore.getState().clearAllLayouts(); - localStorage.clear(); - vi.clearAllMocks(); - - useTaskExecutionStore.getState().setRepoPath(mockTask.id, "/test/repo"); - tester = new PanelSplitTester(mockTask.id); - }); - - describe.each([ - { direction: "right" as const, expectedDirection: "horizontal" as const }, - { direction: "left" as const, expectedDirection: "horizontal" as const }, - { direction: "top" as const, expectedDirection: "vertical" as const }, - { direction: "bottom" as const, expectedDirection: "vertical" as const }, - ])("$direction splits", ({ direction, expectedDirection }) => { - it(`creates ${expectedDirection} split when dragging tab to ${direction} edge`, async () => { - await tester.setupWithFile(); - tester.split(TEST_FILES.APP, PANEL_IDS.MAIN, PANEL_IDS.MAIN, direction); - - await waitFor(() => { - tester.assertSplitStructure(expectedDirection); - }); - }); - }); - - describe("tab moves to new split panel", () => { - it("moves dragged tab to new split panel and activates it", async () => { - const user = await tester.setupWithFile(); - await tester.openFile(user, "helper.ts"); - - tester.split(TEST_FILES.HELPER, PANEL_IDS.MAIN, PANEL_IDS.MAIN, "right"); - - await waitFor(() => { - const root = tester.getRoot(); - expect(root?.type).toBe("group"); - - if (root?.type === "group") { - const leftPanel = root.children[0]; - expect(leftPanel.type).toBe("group"); - - if (leftPanel.type === "group") { - const newPanel = leftPanel.children[1]; - expect(newPanel.type).toBe("leaf"); - - if (newPanel.type === "leaf") { - const hasHelperTab = newPanel.content.tabs.some( - (t) => t.id === TEST_FILES.HELPER, - ); - expect(hasHelperTab).toBe(true); - expect(newPanel.content.activeTabId).toBe(TEST_FILES.HELPER); - } - } - } - }); - }); - }); - - describe("persistence", () => { - it("persists split layout across remounts", async () => { - await tester.setupWithFile(); - const { unmount } = renderWithProviders(); - - tester.split(TEST_FILES.APP, PANEL_IDS.MAIN, PANEL_IDS.MAIN, "right"); - - await waitFor(() => tester.assertSplitStructure("horizontal")); - - unmount(); - renderWithProviders(); - - await waitFor(() => tester.assertSplitStructure("horizontal")); - }); - }); - - describe("cross-panel splitting", () => { - it("allows dragging single tab from one panel to split another panel", async () => { - // Setup: Open two files and create initial split - const user = await tester.setupWithFile(); - await tester.openFile(user, "helper.ts"); - - tester.split(TEST_FILES.HELPER, PANEL_IDS.MAIN, PANEL_IDS.MAIN, "right"); - await waitFor(() => tester.assertSplitStructure("horizontal")); - - // Now we have: Pane A (left) with logs + App.tsx, Pane B (right) with helper.ts - await tester.openFile(user, "README.md"); - - const { paneAId, paneBId } = tester.getPanelIds(); - - // Cross-panel split: drag helper.ts from Pane B to split Pane A vertically - tester.split(TEST_FILES.HELPER, paneBId, paneAId, "top"); - - await waitFor(() => { - const root = tester.getRoot(); - expect(root?.type).toBe("group"); - - if (root?.type === "group") { - const leftSide = root.children[0]; - expect(leftSide.type).toBe("group"); - - if (leftSide.type === "group") { - expect(leftSide.direction).toBe("vertical"); - expect(leftSide.children).toHaveLength(2); - - const topPanel = leftSide.children[0]; - expect(topPanel.type).toBe("leaf"); - if (topPanel.type === "leaf") { - expect(topPanel.content.tabs).toHaveLength(1); - expect(topPanel.content.tabs[0].id).toBe(TEST_FILES.HELPER); - } - - const bottomPanel = leftSide.children[1]; - expect(bottomPanel.type).toBe("leaf"); - if (bottomPanel.type === "leaf") { - expect(bottomPanel.content.tabs).toHaveLength(4); - const tabIds = bottomPanel.content.tabs.map((t) => t.id); - expect(tabIds).toContain(TAB_IDS.LOGS); - expect(tabIds).toContain(TAB_IDS.SHELL); - expect(tabIds).toContain(TEST_FILES.APP); - expect(tabIds).toContain(TEST_FILES.README); - } - } - - expect(root.children).toHaveLength(2); - } - }); - }); - }); - - describe("split constraints", () => { - it("allows cross-panel split when both panels have only one tab", async () => { - await tester.setupWithFile(); - - // Create initial split - tester.split(TEST_FILES.APP, PANEL_IDS.MAIN, PANEL_IDS.MAIN, "right"); - await waitFor(() => tester.assertSplitStructure("horizontal")); - - // Close shell tab so Panel A has only 1 tab - const { paneAId, paneBId } = tester.getPanelIds(); - tester.closeTab(paneAId, TAB_IDS.SHELL); - - // Verify both panels have only 1 tab - const panelA = tester.findPanel(paneAId); - const panelB = tester.findPanel(paneBId); - expect(panelA?.type).toBe("leaf"); - expect(panelB?.type).toBe("leaf"); - if (panelA?.type === "leaf") expect(panelA.content.tabs).toHaveLength(1); - if (panelB?.type === "leaf") expect(panelB.content.tabs).toHaveLength(1); - - // Cross-panel split should succeed even with single tabs - tester.split(TEST_FILES.APP, paneBId, paneAId, "top"); - - await waitFor(() => { - const root = tester.getRoot(); - expect(root?.type).toBe("group"); - - if (root?.type === "group") { - const leftSide = root.children[0]; - expect(leftSide.type).toBe("group"); - - if (leftSide.type === "group") { - expect(leftSide.direction).toBe("vertical"); - expect(leftSide.children).toHaveLength(2); - - const topPanel = leftSide.children[0]; - expect(topPanel.type).toBe("leaf"); - if (topPanel.type === "leaf") { - expect(topPanel.content.tabs).toHaveLength(1); - expect(topPanel.content.tabs[0].id).toBe(TEST_FILES.APP); - } - - const bottomPanel = leftSide.children[1]; - expect(bottomPanel.type).toBe("leaf"); - if (bottomPanel.type === "leaf") { - expect(bottomPanel.content.tabs).toHaveLength(1); - expect(bottomPanel.content.tabs[0].id).toBe(TAB_IDS.LOGS); - } - } - } - }); - }); - - it("does not split when panel has only one tab", async () => { - await tester.setupWithFile(); - - // Close logs and shell tabs to leave only 1 tab - tester.closeTab(PANEL_IDS.MAIN, TAB_IDS.LOGS); - tester.closeTab(PANEL_IDS.MAIN, TAB_IDS.SHELL); - - await waitFor(() => { - const root = tester.getRoot(); - if (root?.type === "group") { - const leftPanel = root.children[0]; - if (leftPanel.type === "leaf") { - expect(leftPanel.content.tabs).toHaveLength(1); - } - } - }); - - // Attempt split with only one tab should fail - tester.split(TEST_FILES.APP, PANEL_IDS.MAIN, PANEL_IDS.MAIN, "right"); - - tester.assertNoSplit(); - }); - - it("allows split when panel has multiple tabs", async () => { - const user = await tester.setupWithFile(); - await tester.openFile(user, "helper.ts"); - - // Split should work with multiple tabs - tester.split(TEST_FILES.HELPER, PANEL_IDS.MAIN, PANEL_IDS.MAIN, "right"); - - tester.assertSplitStructure("horizontal"); - }); - - it("shows drop zones for cross-panel split even when target has 1 tab", async () => { - await tester.setupWithFile(); - - // Re-render to get container access - const { container } = renderWithProviders(); - - // Create split so we have two panels - tester.split(TEST_FILES.APP, PANEL_IDS.MAIN, PANEL_IDS.MAIN, "right"); - await waitFor(() => tester.assertSplitStructure("horizontal")); - - // Get panel B ID for drag simulation - const { paneBId } = tester.getPanelIds(); - - // Simulate dragging from Panel B - usePanelLayoutStore - .getState() - .setDraggingTab(mockTask.id, TEST_FILES.APP, paneBId); - - // Drop zones should appear for cross-panel drag - await waitFor(() => { - const dropZones = container.querySelectorAll(".drop-zone"); - expect(dropZones.length).toBeGreaterThan(0); - }); - - // Verify edge drop zones exist (not just center) - const dropZones = container.querySelectorAll(".drop-zone"); - const zoneTypes = Array.from(dropZones).map((zone: Element) => { - const classList = Array.from(zone.classList); - return classList.find((cls: string) => cls.startsWith("drop-zone-")); - }); - - const hasTopZone = zoneTypes.some((type) => type === "drop-zone-top"); - expect(hasTopZone).toBe(true); - - usePanelLayoutStore.getState().clearDraggingTab(mockTask.id); - }); - }); - - describe("task isolation", () => { - it("keeps separate split layouts for different tasks", async () => { - const task1 = { ...mockTask, id: "task-1" }; - const task2 = { ...mockTask, id: "task-2" }; - - useTaskExecutionStore.getState().setRepoPath("task-1", "/test/repo"); - useTaskExecutionStore.getState().setRepoPath("task-2", "/test/repo"); - - // Task 1: Create split - const tester1 = new PanelSplitTester("task-1"); - const { unmount: unmount1 } = renderWithProviders( - , - ); - await tester1.setupWithFile(); - - tester1.split(TEST_FILES.APP, PANEL_IDS.MAIN, PANEL_IDS.MAIN, "right"); - await waitFor(() => tester1.assertSplitStructure("horizontal")); - - unmount1(); - - // Task 2: Should NOT have split - const tester2 = new PanelSplitTester("task-2"); - renderWithProviders(); - await waitFor(() => - expect(screen.getByText("App.tsx")).toBeInTheDocument(), - ); - - tester2.assertNoSplit(); - }); - }); -}); diff --git a/apps/array/src/renderer/features/task-detail/components/TaskDetail.integration.test.tsx b/apps/array/src/renderer/features/task-detail/components/TaskDetail.integration.test.tsx deleted file mode 100644 index 206081c7..00000000 --- a/apps/array/src/renderer/features/task-detail/components/TaskDetail.integration.test.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import { usePanelLayoutStore } from "@features/panels"; -import { useTaskExecutionStore } from "@features/task-detail/stores/taskExecutionStore"; -import { MOCK_FILES } from "@test/fixtures"; -import { createMockTask, mockElectronAPI } from "@test/panelTestHelpers"; -import { renderWithProviders, screen, waitFor, within } from "@test/utils"; -import type { UserEvent } from "@testing-library/user-event"; -import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { TaskDetail } from "./TaskDetail"; - -// Test constants -const TEST_FILES = { - APP: "App.tsx", - HELPER: "helper.ts", - README: "README.md", -} as const; - -const TEST_REPO_PATH = "/test/repo"; - -const mockTask = createMockTask(); - -mockElectronAPI({ - listRepoFiles: vi.fn().mockResolvedValue(MOCK_FILES), -}); - -// Test helpers -async function waitForFileTreeLoad(fileName: string = TEST_FILES.APP) { - await waitFor(() => { - expect(screen.getByText(fileName)).toBeInTheDocument(); - }); -} - -async function openFileFromTree(user: UserEvent, fileName: string) { - await waitForFileTreeLoad(fileName); - await user.click(screen.getAllByText(fileName)[0]); - await expectFileTabExists(fileName); -} - -async function openMultipleFiles(user: UserEvent, fileNames: string[]) { - await waitForFileTreeLoad(); - for (const fileName of fileNames) { - await user.click(screen.getAllByText(fileName)[0]); - } -} - -async function expectFileTabExists(fileName: string) { - await waitFor(() => { - const regex = new RegExp(fileName.replace(/\./g, "\\."), "i"); - expect(screen.getByRole("tab", { name: regex })).toBeInTheDocument(); - }); -} - -function expectFileTabNotExists(fileName: string) { - const regex = new RegExp(fileName.replace(/\./g, "\\."), "i"); - expect(screen.queryByRole("tab", { name: regex })).not.toBeInTheDocument(); -} - -async function expectTabIsActive(fileName: string) { - await waitFor(() => { - const regex = new RegExp(fileName.replace(/\./g, "\\."), "i"); - const tab = screen.getByRole("tab", { name: regex }); - expect(tab).toHaveAttribute("data-active", "true"); - }); -} - -function expectTabCount(fileName: string, count: number) { - const regex = new RegExp(fileName.replace(/\./g, "\\."), "i"); - const tabs = screen.queryAllByRole("tab", { name: regex }); - expect(tabs).toHaveLength(count); -} - -async function closeTab(user: UserEvent, fileName: string) { - const regex = new RegExp(fileName.replace(/\./g, "\\."), "i"); - const tab = screen.getByRole("tab", { name: regex }); - const closeButton = within(tab).getByRole("button", { name: /close/i }); - await user.click(closeButton); -} - -async function clickTab(user: UserEvent, fileName: string) { - const regex = new RegExp(fileName.replace(/\./g, "\\."), "i"); - const tab = screen.getByRole("tab", { name: regex }); - await user.click(tab); -} - -describe("TaskDetail Integration Tests", () => { - beforeEach(() => { - usePanelLayoutStore.getState().clearAllLayouts(); - localStorage.clear(); - vi.clearAllMocks(); - useTaskExecutionStore.getState().setRepoPath(mockTask.id, TEST_REPO_PATH); - }); - - describe("file opening workflow", () => { - it("opens file tab when clicking file in tree", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await openFileFromTree(user, TEST_FILES.APP); - await expectTabIsActive(TEST_FILES.APP); - }); - - it("does not duplicate tab when clicking same file twice", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await waitForFileTreeLoad(TEST_FILES.APP); - const fileItem = screen.getByText(TEST_FILES.APP); - await user.click(fileItem); - await user.click(fileItem); - - await waitFor(() => expectTabCount(TEST_FILES.APP, 1)); - }); - - it("switches to existing tab when clicking already-open file", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await openFileFromTree(user, TEST_FILES.APP); - await openFileFromTree(user, TEST_FILES.HELPER); - await expectTabIsActive(TEST_FILES.HELPER); - - await user.click(screen.getAllByText(TEST_FILES.APP)[0]); - await expectTabIsActive(TEST_FILES.APP); - }); - }); - - describe("tab management", () => { - it("closes tab when clicking close button", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await openFileFromTree(user, TEST_FILES.APP); - await closeTab(user, TEST_FILES.APP); - - await waitFor(() => expectFileTabNotExists(TEST_FILES.APP)); - }); - - it("switches active tab when clicking on inactive tab", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await openMultipleFiles(user, [TEST_FILES.APP, TEST_FILES.HELPER]); - await expectTabIsActive(TEST_FILES.HELPER); - - await clickTab(user, TEST_FILES.APP); - await expectTabIsActive(TEST_FILES.APP); - }); - - it("auto-selects next tab when closing active tab", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await openMultipleFiles(user, [ - TEST_FILES.APP, - TEST_FILES.HELPER, - TEST_FILES.README, - ]); - await expectTabIsActive(TEST_FILES.README); - - await closeTab(user, TEST_FILES.README); - await expectTabIsActive(TEST_FILES.HELPER); - }); - }); - - describe("persistence", () => { - it("persists open tabs across remounts", async () => { - const user = userEvent.setup(); - const { unmount } = renderWithProviders(); - - await openFileFromTree(user, TEST_FILES.APP); - unmount(); - - renderWithProviders(); - await expectFileTabExists(TEST_FILES.APP); - }); - }); - - describe("task isolation", () => { - it("keeps separate tabs for different tasks", async () => { - const user = userEvent.setup(); - const task1 = { ...mockTask, id: "task-1" }; - const task2 = { ...mockTask, id: "task-2" }; - - useTaskExecutionStore.getState().setRepoPath("task-1", TEST_REPO_PATH); - useTaskExecutionStore.getState().setRepoPath("task-2", TEST_REPO_PATH); - - const { unmount: unmount1 } = renderWithProviders( - , - ); - await openFileFromTree(user, TEST_FILES.APP); - unmount1(); - - renderWithProviders(); - await waitForFileTreeLoad(TEST_FILES.APP); - expectFileTabNotExists(TEST_FILES.APP); - }); - }); -}); diff --git a/apps/array/src/test/fixtures.ts b/apps/array/src/test/fixtures.ts index b1a7a665..29e22560 100644 --- a/apps/array/src/test/fixtures.ts +++ b/apps/array/src/test/fixtures.ts @@ -38,11 +38,11 @@ export function createMockGroupNode(overrides?: Partial): PanelNode { } as PanelNode; } -// File fixtures +// File fixtures - format matches what listDirectory returns export const MOCK_FILES = [ - { path: "App.tsx", name: "App.tsx" }, - { path: "helper.ts", name: "helper.ts" }, - { path: "README.md", name: "README.md" }, + { path: "/test/repo/App.tsx", name: "App.tsx", type: "file" as const }, + { path: "/test/repo/helper.ts", name: "helper.ts", type: "file" as const }, + { path: "/test/repo/README.md", name: "README.md", type: "file" as const }, ]; export const MOCK_FILE_CONTENT = "// file content"; diff --git a/apps/array/src/test/panelTestHelpers.ts b/apps/array/src/test/panelTestHelpers.ts index fe97bc0e..61d4bbee 100644 --- a/apps/array/src/test/panelTestHelpers.ts +++ b/apps/array/src/test/panelTestHelpers.ts @@ -17,25 +17,59 @@ export function createMockTask(overrides: Partial = {}): Task { }; } +function createAutoMock(): unknown { + const cache = new Map(); + return new Proxy( + {}, + { + get(_, prop: string) { + if (!cache.has(prop)) { + if (prop.startsWith("on")) { + cache.set( + prop, + vi.fn().mockReturnValue(() => {}), + ); + } else { + cache.set(prop, vi.fn().mockResolvedValue(undefined)); + } + } + return cache.get(prop); + }, + }, + ); +} + export function mockElectronAPI( overrides: Partial = {}, ) { - window.electronAPI = { - listRepoFiles: vi.fn().mockResolvedValue([ - { path: "App.tsx", name: "App.tsx" }, - { path: "helper.ts", name: "helper.ts" }, - { path: "README.md", name: "README.md" }, - ]), + const mockFiles = [ + { path: "/test/repo/App.tsx", name: "App.tsx", type: "file" as const }, + { path: "/test/repo/helper.ts", name: "helper.ts", type: "file" as const }, + { path: "/test/repo/README.md", name: "README.md", type: "file" as const }, + ]; + + const defaults = { + listRepoFiles: vi.fn().mockResolvedValue(mockFiles), + listDirectory: vi.fn().mockResolvedValue(mockFiles), readRepoFile: vi.fn().mockResolvedValue("// file content"), - shellCreate: vi.fn().mockResolvedValue(undefined), - shellWrite: vi.fn().mockResolvedValue(undefined), - shellResize: vi.fn().mockResolvedValue(undefined), - shellDispose: vi.fn().mockResolvedValue(undefined), - shellDestroy: vi.fn().mockResolvedValue(undefined), - onShellData: vi.fn().mockReturnValue(() => {}), - onShellExit: vi.fn().mockReturnValue(() => {}), + getChangedFilesHead: vi.fn().mockResolvedValue([]), + getDiffStats: vi.fn().mockResolvedValue({ additions: 0, deletions: 0 }), + showFileContextMenu: vi.fn().mockResolvedValue({ action: null }), + workspace: createAutoMock(), ...overrides, - } as unknown as typeof window.electronAPI; + }; + + window.electronAPI = new Proxy(defaults, { + get(target, prop: string) { + if (prop in target) { + return target[prop as keyof typeof target]; + } + if (prop.startsWith("on")) { + return vi.fn().mockReturnValue(() => {}); + } + return vi.fn().mockResolvedValue(undefined); + }, + }) as typeof window.electronAPI; } export interface PanelStructure { diff --git a/apps/array/src/test/setup.ts b/apps/array/src/test/setup.ts index 89e40df8..94fb3196 100644 --- a/apps/array/src/test/setup.ts +++ b/apps/array/src/test/setup.ts @@ -2,6 +2,19 @@ import "@testing-library/jest-dom"; import { cleanup } from "@testing-library/react"; import { afterAll, afterEach, beforeAll, vi } from "vitest"; +// Mock electron-log before any imports that use it +vi.mock("electron-log/renderer", () => { + const mockLog = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + scope: () => mockLog, + transports: { console: { level: "debug" } }, + }; + return { default: mockLog }; +}); + // Suppress act() warnings from Radix UI async updates in tests, // we don't care about them. const originalError = console.error; @@ -29,6 +42,7 @@ globalThis.ResizeObserver = class ResizeObserver { }; HTMLCanvasElement.prototype.getContext = vi.fn(); +Element.prototype.scrollIntoView = vi.fn(); Object.defineProperty(window, "matchMedia", { writable: true,