| 1 | import assert from "node:assert/strict"; |
| 2 | import { JSDOM } from "jsdom"; |
| 3 | import type { TopicbarView } from "../app-shell/TopicbarRegion"; |
| 4 | |
| 5 | const dom = new JSDOM("<div id='root'></div>", { pretendToBeVisual: true }); |
| 6 | Object.assign(globalThis, { window: dom.window, document: dom.window.document, HTMLElement: dom.window.HTMLElement, |
| 7 | KeyboardEvent: dom.window.KeyboardEvent, IS_REACT_ACT_ENVIRONMENT: true }); |
| 8 | const { default: React, act } = await import("react"); |
| 9 | const { createRoot } = await import("react-dom/client"); |
| 10 | const { TopicbarRegion } = await import("../app-shell/TopicbarRegion"); |
| 11 | const { LocaleProvider } = await import("../lib/i18n"); |
| 12 | const root = createRoot(document.getElementById("root")!); |
| 13 | const calls: string[] = []; |
| 14 | const commands = { |
| 15 | openAutomation: () => { calls.push("automation"); }, toggleSidebar: () => { calls.push("sidebar"); }, |
| 16 | setTitleDraft: (value: string) => { calls.push(`draft:${value}`); }, commitRename: () => { calls.push("commit"); }, |
| 17 | cancelRename: () => { calls.push("cancel"); }, startRename: () => { calls.push("rename"); }, |
| 18 | openWorktree: (id: string) => { calls.push(`worktree:${id}`); }, |
| 19 | }; |
| 20 | const view: TopicbarView = { |
| 21 | automationReturn: true, automationReturnLabel: "Back to automation", chromeHidden: true, brand: false, |
| 22 | sidebar: { title: "Sidebar", blocked: false, pressed: false, collapsed: true }, |
| 23 | title: { text: "A", hover: "Full A", renameLabel: "Rename", editing: false, draft: "Draft A", editSize: 12, canRename: true, workspaceLabel: "Project" }, |
| 24 | subtitle: { visible: true, title: "Workspace", worktreeTabId: "A", mergeLabel: "Merge", mergeTooltip: "Merge back", sourcePlatform: "feishu", sourceLabel: "Channel" }, |
| 25 | }; |
| 26 | const paint = (next = view) => act(async () => root.render(<LocaleProvider><TopicbarRegion view={next} commands={commands}> |
| 27 | <div className="topicbar__actions"><button>Fixture action</button></div> |
| 28 | </TopicbarRegion></LocaleProvider>)); |
| 29 | const click = (selector: string) => act(async () => document.querySelector<HTMLButtonElement>(selector)!.click()); |
| 30 | try { |
| 31 | await paint(); |
| 32 | assert.deepEqual([...document.querySelector("header")!.children].map(node => node.className), |
| 33 | ["btn btn--small", "tooltip-trigger", "topicbar__identity", "topicbar__spacer", "topicbar__actions"], "region extraction adds no DOM wrapper"); |
| 34 | await paint({ ...view, brand: true }); |
| 35 | assert.deepEqual([...document.querySelector("header")!.children].map(node => node.className), |
| 36 | ["topicbar__brand", "btn btn--small", "tooltip-trigger", "topicbar__identity", "topicbar__spacer", "topicbar__actions"], |
| 37 | "the Windows bar leads with the app mark"); |
| 38 | assert.equal(document.querySelector(".topicbar__brand-logo")!.tagName, "IMG", "the leading mark is the app logo asset"); |
| 39 | await paint(); |
| 40 | const action = document.querySelector(".topicbar__actions button"); |
| 41 | assert.equal(document.querySelectorAll(".topicbar__subtitle .worktree-badge").length, 1); |
| 42 | assert.ok(document.querySelector(".worktree-badge")!.getAttribute("aria-label"), "isolated worktree identity is accessible"); |
| 43 | await click(".topicbar__title-button"); |
| 44 | await click(".topicbar__worktree-btn"); |
| 45 | await click(".topicbar__chrome-btn"); |
| 46 | await click(".btn"); |
| 47 | assert.deepEqual(calls, ["rename", "worktree:A", "sidebar", "automation"]); |
| 48 | assert.equal(document.activeElement, document.querySelector(".btn"), "return control establishes focus synchronously before navigating"); |
| 49 | await paint({ ...view, sidebar: { ...view.sidebar, blocked: true }, subtitle: { ...view.subtitle, worktreeTabId: "B" } }); |
| 50 | await click(".topicbar__chrome-btn"); |
| 51 | await click(".topicbar__worktree-btn"); |
| 52 | assert.equal(calls.filter(value => value === "sidebar").length, 1); |
| 53 | assert.equal(calls.at(-1), "worktree:B", "synchronous command receives the rendered source identity"); |
| 54 | await paint({ ...view, title: { ...view.title, editing: true } }); |
| 55 | const input = document.querySelector<HTMLInputElement>("input")!; |
| 56 | assert.equal(input.value, "Draft A"); assert.equal(input.size, 12); |
| 57 | assert.equal(input.selectionStart, 0); assert.equal(input.selectionEnd, input.value.length); |
| 58 | await act(async () => { |
| 59 | Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!.call(input, "Renamed A"); |
| 60 | input.dispatchEvent(new dom.window.Event("input", { bubbles: true })); |
| 61 | }); |
| 62 | assert.equal(calls.at(-1), "draft:Renamed A", "DOM event is converted synchronously to a draft value"); |
| 63 | const enter = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }); |
| 64 | const escape = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }); |
| 65 | await act(async () => { input.dispatchEvent(enter); input.dispatchEvent(escape); }); |
| 66 | assert.equal(enter.defaultPrevented, true); assert.equal(escape.defaultPrevented, true); |
| 67 | assert.deepEqual(calls.slice(-2), ["commit", "cancel"]); |
| 68 | await act(async () => input.blur()); |
| 69 | assert.equal(calls.at(-1), "commit", "blur retains the existing rename commit contract"); |
| 70 | assert.equal(action, document.querySelector(".topicbar__actions button"), "title editing preserves action subtree identity"); |
| 71 | await paint({ ...view, subtitle: { ...view.subtitle, worktreeTabId: undefined } }); |
| 72 | assert.equal(document.querySelector(".worktree-badge"), null, "ordinary topics do not claim isolated worktree identity"); |
| 73 | assert.equal(document.querySelector(".topicbar__worktree-btn"), null, "ordinary topics expose no worktree merge action"); |
| 74 | await act(async () => root.unmount()); |
| 75 | console.log("topicbar region: DOM structure, source commands, focus, rename keys and action identity passed"); |
| 76 | } finally { dom.window.close(); } |
| 77 |