| 1 | // Run: node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/file-navigation-dock.test.tsx |
| 2 | // |
| 3 | // The preview-tab experience behind the navigation record: reopening activates |
| 4 | // the existing tab, the cap and its ordering are kept, source mode reuses the |
| 5 | // tab, a reveal expands the tree, and closing then reopening the dock tab is a |
| 6 | // new lifecycle generation rather than a restored one. |
| 7 | |
| 8 | import assert from "node:assert/strict"; |
| 9 | import React, { act } from "react"; |
| 10 | import { renderFilesWorkspace, waitFor } from "./workspace-panel-test-harness"; |
| 11 | import { WorkspaceDockRegion, type WorkspaceDockRegionProps } from "../app-shell/WorkspaceDockRegion"; |
| 12 | import { LocaleProvider } from "../lib/i18n"; |
| 13 | import { performResourceAction } from "../lib/fileNavigationCommands"; |
| 14 | import { fileNavigationOwner } from "../lib/fileNavigationCommands"; |
| 15 | import { FILE_PREVIEW_LIMIT, fileNavigationKey } from "../lib/fileNavigationOwner"; |
| 16 | import { useActivityBarStore } from "../store/activityBar"; |
| 17 | |
| 18 | const reads: string[] = []; |
| 19 | const { dom, root, dockTabId } = await renderFilesWorkspace({ |
| 20 | ReadPresentedFileForTab: async (_tab, _tool, path) => { |
| 21 | reads.push(`preview:${path}`); |
| 22 | return { path, body: `preview content ${path}`, size: 24, truncated: false, binary: false }; |
| 23 | }, |
| 24 | ReadPresentedFileSourceForTab: async (_tab, _tool, path) => { |
| 25 | reads.push(`source:${path}`); |
| 26 | return { path, body: `source content ${path}`, size: 24, truncated: false, binary: false }; |
| 27 | }, |
| 28 | ListDirForTab: async (_tab, dir) => dir === "" ? [{ name: "app.ts", isDir: false }] : [], |
| 29 | }); |
| 30 | const props: WorkspaceDockRegionProps = { |
| 31 | visible: true, overlay: false, mode: "files", showContext: false, |
| 32 | t: key => key, onPickEntry: () => {}, remote: { onClose: () => {} }, context: {} as WorkspaceDockRegionProps["context"], |
| 33 | workspace: { open: true, tabId: "navigation-session", cwd: "/repo", maximized: false, onClose: () => {}, onToggleMaximized: () => {} }, |
| 34 | workspaceKey: "tabs-test", workspaceRoot: "/repo", |
| 35 | }; |
| 36 | let sessionTabId = "navigation-session"; |
| 37 | const paint = (visible: boolean) => act(async () => root.render( |
| 38 | <LocaleProvider><WorkspaceDockRegion {...props} workspace={{ ...props.workspace, tabId: sessionTabId }} visible={visible} /></LocaleProvider>, |
| 39 | )); |
| 40 | const record = () => fileNavigationOwner().getSnapshot(fileNavigationKey({ sessionTabId, dockTabId })); |
| 41 | const paths = () => record()?.entries.map((entry) => entry.resource.path) ?? []; |
| 42 | const open = async (path: string, action: "preview" | "source" | "reveal-tree" = "preview") => { |
| 43 | await act(async () => performResourceAction({ hostId: "local", tabId: sessionTabId, path, source: "presented", toolCallId: "call" }, action)); |
| 44 | await paint(true); |
| 45 | }; |
| 46 | await paint(true); |
| 47 | |
| 48 | // ── Consecutive A / B / A: one tab each, most recent last ── |
| 49 | await open("a.md"); |
| 50 | await open("b.md"); |
| 51 | await open("a.md"); |
| 52 | assert.deepEqual(paths(), ["b.md", "a.md"], "reopening activates the existing tab instead of adding one"); |
| 53 | await waitFor("reactivated preview", () => document.body.textContent?.includes("preview content a.md") === true); |
| 54 | |
| 55 | // ── Source mode reuses the tab and reads the other representation ── |
| 56 | const sourceToggle = [...document.querySelectorAll<HTMLButtonElement>(".workspace-preview__window-actions button")] |
| 57 | .find(button => /source/i.test(button.getAttribute("aria-label") ?? "")); |
| 58 | assert(sourceToggle, "a presented preview exposes its source toggle"); |
| 59 | await act(async () => { sourceToggle!.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); }); |
| 60 | await waitFor("source preview", () => document.body.textContent?.includes("source content a.md") === true); |
| 61 | assert.deepEqual(paths(), ["b.md", "a.md"], "switching the mode reuses the file tab"); |
| 62 | assert(reads.includes("source:a.md"), "the source read used the source entry point"); |
| 63 | |
| 64 | // ── reveal-tree expands the rail and locates the file ── |
| 65 | const hideTree = [...document.querySelectorAll<HTMLButtonElement>("button")] |
| 66 | .find(button => /hide file tree/i.test(button.getAttribute("aria-label") ?? "")); |
| 67 | if (hideTree) await act(async () => { hideTree.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); }); |
| 68 | await open("a.md", "reveal-tree"); |
| 69 | assert(document.querySelector(".workspace-tree") !== null, "reveal-tree shows the file tree"); |
| 70 | assert.equal(document.querySelector('[data-workspace-path="app.ts"]') !== null, true, "reveal-tree lists the workspace file"); |
| 71 | |
| 72 | // ── The preview tab list keeps its cap and its order ── |
| 73 | for (const path of ["c.txt", "d.txt", "e.txt", "f.txt", "g.txt"]) await open(path); |
| 74 | assert.equal(paths().length, FILE_PREVIEW_LIMIT, "the dock keeps the preview tab limit"); |
| 75 | assert.deepEqual(paths(), ["c.txt", "d.txt", "e.txt", "f.txt", "g.txt"], "the cap keeps the most recently used tabs in order"); |
| 76 | assert.equal(document.querySelectorAll(".workspace-document-tab").length, FILE_PREVIEW_LIMIT, "the tab strip shows the same list"); |
| 77 | const readsBeforeRepeat = reads.length; |
| 78 | await open("c.txt"); |
| 79 | assert.deepEqual(paths(), ["d.txt", "e.txt", "f.txt", "g.txt", "c.txt"], "reopening moves the tab to the most recent position"); |
| 80 | assert(reads.length > readsBeforeRepeat, "activating a tab reads it again for this dock"); |
| 81 | |
| 82 | // ── Closing the dock tab and reopening it is a new lifecycle generation ── |
| 83 | const closedGeneration = record()!.generation; |
| 84 | await act(async () => { |
| 85 | useActivityBarStore.getState().closeTab(dockTabId); |
| 86 | // The runtime keeps the open dock tabs of the active session retained; a |
| 87 | // standalone region mount has no runtime, so the test reconciles for it. |
| 88 | fileNavigationOwner().retain([]); |
| 89 | await Promise.resolve(); |
| 90 | }); |
| 91 | await paint(false); |
| 92 | assert.equal(record(), null, "a closed dock tab keeps no record"); |
| 93 | // Reopening from the recently-closed list reuses the same tab id. |
| 94 | await act(async () => useActivityBarStore.getState().reopenTab(dockTabId)); |
| 95 | assert.equal(useActivityBarStore.getState().activeTabId, dockTabId, "the reopened tab reuses its id"); |
| 96 | await paint(true); |
| 97 | await act(async () => performResourceAction({ hostId: "local", tabId: "navigation-session", path: "h.txt", source: "presented", toolCallId: "call" }, "preview")); |
| 98 | await paint(true); |
| 99 | const reopened = record()!; |
| 100 | assert(reopened.generation > closedGeneration, "a reopened dock tab starts a new lifecycle generation"); |
| 101 | // The remembered paths come back, but only as workspace files: the presented |
| 102 | // tool scope an earlier session opened them with is never restored. |
| 103 | const rememberedEntries = reopened.entries.filter(entry => entry.resource.path !== "h.txt"); |
| 104 | assert(rememberedEntries.length > 0, "the reopened dock restores the remembered paths"); |
| 105 | assert(rememberedEntries.every(entry => entry.resource.access.source === "workspace" && entry.resource.access.toolCallId === undefined), |
| 106 | "restored entries carry workspace access only"); |
| 107 | assert.equal(reopened.selected?.resource.path, "h.txt"); |
| 108 | assert.equal(reopened.selected?.resource.access.source, "presented", "the new command's own access context is used"); |
| 109 | |
| 110 | // ── Collapsing the dock keeps the record; expanding restores it ── |
| 111 | await paint(false); |
| 112 | await paint(true); |
| 113 | const expanded = record()!; |
| 114 | assert.equal(expanded.generation, reopened.generation, "a collapse is not a new lifecycle"); |
| 115 | assert.deepEqual(expanded.entries.map(entry => entry.resource.path), reopened.entries.map(entry => entry.resource.path), |
| 116 | "expanding restores the committed previews"); |
| 117 | assert.equal(expanded.selected?.resource.path, "h.txt", "expanding restores the committed selection"); |
| 118 | assert.equal(expanded.navigation?.revision, reopened.navigation?.revision, "restoring never replays the command that opened it"); |
| 119 | |
| 120 | await act(async () => root.unmount()); |
| 121 | dom.window.close(); |
| 122 | console.log("PASS dock preview tabs: reuse, cap, source mode, reveal, lifecycle generations, collapse restore and first navigation per session"); |
| 123 |