| 1 | import assert from "node:assert/strict"; |
| 2 | import { JSDOM } from "jsdom"; |
| 3 | import React, { Fragment, act } from "react"; |
| 4 | import { createRoot } from "react-dom/client"; |
| 5 | import type { AppBindings } from "../lib/bridge"; |
| 6 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 7 | import { parseMarkdownToBlocks } from "../lib/markdownPipeline"; |
| 8 | import { hastBlockToJsx } from "../lib/hastJsx"; |
| 9 | import { createComponents } from "../components/markdownComponents"; |
| 10 | import { LocaleProvider } from "../lib/i18n"; |
| 11 | import { useBrowserPanelStore } from "../lib/browserPanelStore"; |
| 12 | |
| 13 | const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { url: "http://localhost/" }); |
| 14 | Object.assign(globalThis, { |
| 15 | window: dom.window, document: dom.window.document, Node: dom.window.Node, |
| 16 | HTMLElement: dom.window.HTMLElement, MouseEvent: dom.window.MouseEvent, |
| 17 | Event: dom.window.Event, IS_REACT_ACT_ENVIRONMENT: true, |
| 18 | }); |
| 19 | |
| 20 | let resolveReferences!: (value: { |
| 21 | turnKey: string; |
| 22 | references: Array<{ key: string; path: string; status: "resolved"; displayPath: string; kind: string; actions: string[] }>; |
| 23 | }) => void; |
| 24 | let pendingCandidates: Array<{ key: string; path: string }> = []; |
| 25 | let nativeOpens = 0; |
| 26 | const previewCalls: string[] = []; |
| 27 | const browserTab = { |
| 28 | id: "html-tab", taskId: "tab-a", url: "http://127.0.0.1/preview", title: "index.html", |
| 29 | loading: false, canGoBack: false, canGoForward: false, temporary: false, |
| 30 | mode: "agent" as const, epoch: 0, zoom: 1, error: null, |
| 31 | }; |
| 32 | |
| 33 | const stub = installDesktopHostStub(({ |
| 34 | main: { App: { |
| 35 | ResolveChatFileReferencesForTab: async (_tabId: string, turnKey: string, candidates: Array<{ key: string; path: string }>) => { |
| 36 | pendingCandidates = candidates; |
| 37 | return new Promise((resolve) => { resolveReferences = resolve; }); |
| 38 | }, |
| 39 | OpenFileBrowserPreviewForTab: async (_tabId: string, request: { path: string }) => { |
| 40 | previewCalls.push(request.path); |
| 41 | return { tabId: browserTab.id, url: browserTab.url, status: "opened", sessionGeneration: 1 }; |
| 42 | }, |
| 43 | OpenLocalPath: async () => { nativeOpens += 1; }, |
| 44 | } as Partial<AppBindings> as AppBindings }, |
| 45 | }).main.App); |
| 46 | |
| 47 | useBrowserPanelStore.setState({ |
| 48 | host: { |
| 49 | list: async () => [browserTab], activate: async () => {}, close: async () => {}, |
| 50 | } as unknown as NonNullable<ReturnType<typeof useBrowserPanelStore.getState>["host"]>, |
| 51 | taskId: "tab-a", tabs: [], shown: [], visible: true, |
| 52 | }); |
| 53 | |
| 54 | const blocks = parseMarkdownToBlocks("[Open the page](file:///repo/out/index.html)"); |
| 55 | const components = createComponents(false); |
| 56 | const { ChatFileScopeProvider, ChatFileTurnProvider, useChatFileCandidateReport } = await import("../components/ChatFileLinkContext"); |
| 57 | function Reporter() { |
| 58 | useChatFileCandidateReport(blocks, 1); |
| 59 | return null; |
| 60 | } |
| 61 | |
| 62 | const rootElement = document.getElementById("root")!; |
| 63 | const root = createRoot(rootElement); |
| 64 | await act(async () => { |
| 65 | root.render(<LocaleProvider> |
| 66 | <ChatFileScopeProvider scopeKey="session-a" tabId="tab-a"> |
| 67 | <ChatFileTurnProvider turnKey="turn-a" factsVersion={1} presentedFiles={[]} modifiedFiles={[]} tabId="tab-a"> |
| 68 | <Reporter /> |
| 69 | {blocks.map((block) => <Fragment key={block.key}>{hastBlockToJsx(block, components)}</Fragment>)} |
| 70 | </ChatFileTurnProvider> |
| 71 | </ChatFileScopeProvider> |
| 72 | </LocaleProvider>); |
| 73 | }); |
| 74 | await act(async () => { await Promise.resolve(); await Promise.resolve(); }); |
| 75 | |
| 76 | const pendingLink = rootElement.querySelector<HTMLAnchorElement>("a"); |
| 77 | assert.ok(pendingLink, "the explicit local file remains a pending in-app link"); |
| 78 | await act(async () => { |
| 79 | pendingLink.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true, cancelable: true })); |
| 80 | }); |
| 81 | assert.equal(nativeOpens, 0, "the first click never falls through to the OS opener"); |
| 82 | assert.equal(previewCalls.length, 0, "opening waits for host verification"); |
| 83 | |
| 84 | await act(async () => { |
| 85 | resolveReferences({ |
| 86 | turnKey: "turn-a", |
| 87 | references: pendingCandidates.map((candidate) => ({ |
| 88 | key: candidate.key, path: candidate.path, status: "resolved", displayPath: "out/index.html", |
| 89 | kind: "html", actions: ["preview", "source", "browser", "reveal-tree", "copy-path", "save-copy"], |
| 90 | })), |
| 91 | }); |
| 92 | await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); |
| 93 | }); |
| 94 | |
| 95 | assert.deepEqual(previewCalls, ["out/index.html"], "the queued first click opens the host-verified HTML in the built-in browser"); |
| 96 | assert.equal(nativeOpens, 0); |
| 97 | assert.equal(useBrowserPanelStore.getState().activeTabId, browserTab.id); |
| 98 | |
| 99 | await act(async () => { root.unmount(); }); |
| 100 | stub.uninstall(); |
| 101 | dom.window.close(); |
| 102 | console.log("PASS unresolved local HTML clicks wait for verification and stay in-app"); |
| 103 |