|
| 1 | +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
| 2 | +import { render, screen, waitFor, within } from "@testing-library/react"; |
| 3 | +import userEvent from "@testing-library/user-event"; |
| 4 | +import type { APIClient } from "../api/client"; |
| 5 | +import type { WorkspaceExplorerView } from "../api/types"; |
| 6 | +import { V2RecoveryProvider } from "../v2/recovery-storage"; |
| 7 | +import { FileEditDraftProvider } from "./file-edit-drafts"; |
| 8 | +import { WorkspaceExplorer } from "./workspace-explorer"; |
| 9 | + |
| 10 | +vi.mock("@monaco-editor/react", () => ({ |
| 11 | + default: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => |
| 12 | + <textarea aria-label="Monaco proposal editor" onChange={(event) => onChange(event.target.value)} value={value} />, |
| 13 | +})); |
| 14 | +vi.mock("../lib/monaco-local", () => ({ configureLocalMonaco: vi.fn() })); |
| 15 | + |
| 16 | +describe("WorkspaceExplorer Markdown preview", () => { |
| 17 | + it("switches the returned content between source and safe formatted Markdown", async () => { |
| 18 | + const snapshot = markdownSnapshot([ |
| 19 | + "# Project notes", "", "**Ready**", "", "- Review changes", "", |
| 20 | + "[web](https://example.com/docs) [relative](./notes.md) [local](file:///secret) [unsafe](javascript:alert(1))", |
| 21 | + "", "", "<script>alert(1)</script>", |
| 22 | + "", "SESSION_SECRET=[REDACTED:secret]", |
| 23 | + ].join("\n")); |
| 24 | + const client = previewClient(snapshot); |
| 25 | + const user = userEvent.setup(); |
| 26 | + const view = renderExplorer(client); |
| 27 | + |
| 28 | + expect(await screen.findByText("# Project notes")).toBeInTheDocument(); |
| 29 | + expect(screen.getByRole("button", { name: "Source", pressed: true })).toBeInTheDocument(); |
| 30 | + await user.click(screen.getByRole("button", { name: "Preview" })); |
| 31 | + const preview = screen.getByRole("region", { name: "Markdown preview" }); |
| 32 | + expect(within(preview).getByRole("heading", { name: "Project notes" })).toBeInTheDocument(); |
| 33 | + expect(within(preview).getByText("Ready").tagName).toBe("STRONG"); |
| 34 | + expect(within(preview).getByRole("listitem")).toHaveTextContent("Review changes"); |
| 35 | + expect(within(preview).getByRole("link", { name: "web" })).toHaveAttribute("target", "_blank"); |
| 36 | + expect(within(preview).getByRole("link", { name: "web" })).toHaveAttribute("rel", "noreferrer noopener"); |
| 37 | + for (const label of ["relative", "local", "unsafe"]) { |
| 38 | + expect(within(preview).getByText(label).tagName).toBe("SPAN"); |
| 39 | + } |
| 40 | + expect(preview.querySelector("script, img")).not.toBeInTheDocument(); |
| 41 | + expect(preview).toHaveTextContent("SESSION_SECRET=[REDACTED:secret]"); |
| 42 | + expect(screen.getByText("1 redacted")).toBeInTheDocument(); |
| 43 | + expect(view.container.querySelector(".explorer-file-lines")).not.toBeInTheDocument(); |
| 44 | + |
| 45 | + await user.click(screen.getByRole("button", { name: "Source" })); |
| 46 | + expect(screen.getByText("# Project notes")).toBeInTheDocument(); |
| 47 | + expect(screen.queryByRole("region", { name: "Markdown preview" })).not.toBeInTheDocument(); |
| 48 | + expect(client.workspaceExplore).toHaveBeenCalledTimes(1); |
| 49 | + }); |
| 50 | + |
| 51 | + it.each(["README.MD", "notes.markdown", "guide.mdown", "notes.txt"])("offers preview only for Markdown paths: %s", async (path) => { |
| 52 | + const client = previewClient({ ...markdownSnapshot(), path }); |
| 53 | + renderExplorer(client, { initialPath: path }); |
| 54 | + await screen.findByText("# Project notes"); |
| 55 | + expect(Boolean(screen.queryByRole("button", { name: "Preview" }))).toBe(path !== "notes.txt"); |
| 56 | + }); |
| 57 | + |
| 58 | + it("returns to source for search results and later citation lines", async () => { |
| 59 | + const client = previewClient(markdownSnapshot()); |
| 60 | + const user = userEvent.setup(); |
| 61 | + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); |
| 62 | + const explorer = (initialLine: number) => <QueryClientProvider client={queryClient}> |
| 63 | + <WorkspaceExplorer client={client} workspaceID="workspace-1" initialPath="README.md" initialLine={initialLine} /> |
| 64 | + </QueryClientProvider>; |
| 65 | + const view = render(explorer(1)); |
| 66 | + await user.click(await screen.findByRole("button", { name: "Preview" })); |
| 67 | + expect(screen.queryByText("Positioned at line 1")).not.toBeInTheDocument(); |
| 68 | + await user.type(screen.getByLabelText("Search Workspace evidence"), "matched"); |
| 69 | + await user.click(screen.getByRole("button", { name: "Search Workspace" })); |
| 70 | + await user.click(await screen.findByRole("button", { name: /README.md.*line 2/ })); |
| 71 | + expect(await screen.findByText("Positioned at line 2")).toBeInTheDocument(); |
| 72 | + expect(screen.getByRole("button", { name: "Source", pressed: true })).toBeInTheDocument(); |
| 73 | + expect(view.container.querySelector('[data-line="2"]')).toHaveClass("is-highlighted"); |
| 74 | + |
| 75 | + await user.click(screen.getByRole("button", { name: "Preview" })); |
| 76 | + view.rerender(explorer(3)); |
| 77 | + expect(await screen.findByText("Positioned at line 3")).toBeInTheDocument(); |
| 78 | + expect(screen.getByRole("button", { name: "Source", pressed: true })).toBeInTheDocument(); |
| 79 | + }); |
| 80 | + |
| 81 | + it("references the same snapshot and retains truncation and provenance in preview", async () => { |
| 82 | + const snapshot = { ...markdownSnapshot(), truncated: true, total_bytes: 1024 }; |
| 83 | + const onSelectReference = vi.fn(); |
| 84 | + const client = previewClient(snapshot); |
| 85 | + const user = userEvent.setup(); |
| 86 | + renderExplorer(client, { onSelectReference }); |
| 87 | + await user.click(await screen.findByRole("button", { name: "Reference this file" })); |
| 88 | + await user.click(screen.getByRole("button", { name: "Preview" })); |
| 89 | + await user.click(screen.getByRole("button", { name: "Reference this file" })); |
| 90 | + expect(onSelectReference.mock.calls).toEqual([[snapshot], [snapshot]]); |
| 91 | + expect(screen.getByText("truncated")).toBeInTheDocument(); |
| 92 | + expect(screen.getByText("workspace_file / File source record")).toBeInTheDocument(); |
| 93 | + expect(screen.getByText("b".repeat(12))).toBeInTheDocument(); |
| 94 | + expect(client.workspaceExplore).toHaveBeenCalledTimes(1); |
| 95 | + }); |
| 96 | + |
| 97 | + it("keeps an editing draft across returning to preview and switching views", async () => { |
| 98 | + const snapshot = { ...markdownSnapshot(), redaction_count: 0 }; |
| 99 | + const client = Object.assign(previewClient(snapshot), { |
| 100 | + hasFileEditProposals: true, |
| 101 | + issueFileEditProposalSource: vi.fn().mockResolvedValue({ |
| 102 | + protocol_version: "file_edit_proposal.v1", run_id: "run-1", workspace_id: "workspace-1", |
| 103 | + path: snapshot.path, content: snapshot.content, content_sha256: snapshot.provenance.content_sha256, |
| 104 | + source_handle: "B".repeat(43), expires_at: "2099-07-18T00:05:00Z", editable: true, file_write: false, |
| 105 | + }), |
| 106 | + createFileEditProposal: vi.fn(), |
| 107 | + }); |
| 108 | + const user = userEvent.setup(); |
| 109 | + renderExplorer(client, { runID: "run-1" }); |
| 110 | + await user.click(await screen.findByRole("button", { name: "Preview" })); |
| 111 | + await user.click(screen.getByRole("button", { name: "Edit proposal" })); |
| 112 | + const editor = await screen.findByLabelText("Monaco proposal editor"); |
| 113 | + expect(screen.queryByRole("group", { name: "File view" })).not.toBeInTheDocument(); |
| 114 | + await user.clear(editor); |
| 115 | + await user.type(editor, "# Retained draft"); |
| 116 | + await user.click(screen.getByRole("button", { name: "Close file proposal editor" })); |
| 117 | + await user.click(screen.getByRole("button", { name: "Keep draft" })); |
| 118 | + expect(screen.getByRole("region", { name: "Markdown preview" })).toBeInTheDocument(); |
| 119 | + await user.click(screen.getByRole("button", { name: "Source" })); |
| 120 | + await user.click(screen.getByRole("button", { name: "Preview" })); |
| 121 | + await user.click(screen.getByRole("button", { name: "Edit proposal" })); |
| 122 | + await waitFor(() => expect(screen.getByLabelText("Monaco proposal editor")).toHaveValue("# Retained draft")); |
| 123 | + expect(client.createFileEditProposal).not.toHaveBeenCalled(); |
| 124 | + }); |
| 125 | +}); |
| 126 | + |
| 127 | +function markdownSnapshot(content = "# Project notes\nmatched text\nthird line\n"): WorkspaceExplorerView { |
| 128 | + return { |
| 129 | + protocol_version: "workspace_explorer.v1", workspace_id: "workspace-1", path: "README.md", |
| 130 | + kind: "file", entries: [], content, total_bytes: content.length, returned_bytes: content.length, |
| 131 | + truncated: false, redaction_count: 1, root_path_exposed: false, |
| 132 | + provenance: { version: "context_provenance.v1", source_kind: "workspace_file", source_ref: "README.md", |
| 133 | + content_sha256: "b".repeat(64), instruction_authorized: false }, |
| 134 | + }; |
| 135 | +} |
| 136 | + |
| 137 | +function previewClient(snapshot: WorkspaceExplorerView) { |
| 138 | + return Object.assign({} as APIClient, { |
| 139 | + baseURL: "http://localhost:8765/api/v1", |
| 140 | + workspaceExplore: vi.fn().mockResolvedValue(snapshot), |
| 141 | + workspaceSearch: vi.fn().mockResolvedValue({ results: [{ path: "README.md", line: 2, |
| 142 | + match_kind: "content", snippet: "matched text", content_truncated: false, |
| 143 | + provenance: snapshot.provenance }], truncated: false }), |
| 144 | + }); |
| 145 | +} |
| 146 | + |
| 147 | +function renderExplorer(client: APIClient, props: Partial<Parameters<typeof WorkspaceExplorer>[0]> = {}) { |
| 148 | + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); |
| 149 | + return render(<QueryClientProvider client={queryClient}><V2RecoveryProvider client={client} scopeID="preview-tests"><FileEditDraftProvider> |
| 150 | + <WorkspaceExplorer client={client} workspaceID="workspace-1" initialPath="README.md" {...props} /> |
| 151 | + </FileEditDraftProvider></V2RecoveryProvider></QueryClientProvider>); |
| 152 | +} |
0 commit comments