diff --git a/packages/review-tutor/src/page-script.ts b/packages/review-tutor/src/page-script.ts index 296defc..fcb5413 100644 --- a/packages/review-tutor/src/page-script.ts +++ b/packages/review-tutor/src/page-script.ts @@ -67,7 +67,13 @@ export const pageScript = String.raw` composerSelectionKey = null, historyEntries = [], historyIndex = 0, - activeHistoryBadge = null; + activeHistoryBadge = null, + structureSnapshot = null, + structureError = null, + structureInputId = null, + structureStatus = "idle", + structureRequestSequence = 0, + selectedConnection = null; const learningBadgeGroups = new Map(); const foreignActiveIds = new Set(); let railCollapsed = sessionStorage.getItem("reviewTutorRailCollapsed") === "1"; @@ -370,28 +376,263 @@ export const pageScript = String.raw` updateAnswerVisibility(); } const diffControls = ["files", "previous-file", "next-file", "select-lines", "open-composer", "totals"]; + function structureStatusLabel(status) { + return status === "modified" ? "edited" : status; + } + function structureKindLabel(kind) { + return kind === "reexport" ? "re-export" : kind === "dynamic-import" ? "dynamic" : kind; + } + function resetStructure() { + structureRequestSequence++; + structureSnapshot = null; + structureError = null; + structureInputId = null; + structureStatus = "idle"; + selectedConnection = null; + element("structure-content").replaceChildren(); + } + function clearStructureLanding() { + document.querySelector(".diff-row.structure-landing")?.classList.remove("structure-landing"); + } + function normalizedLine(text) { + return String(text || "").trim().replace(/\s+/g, " "); + } + function jumpToEvidence(evidence, edgeStatus, evidenceIndex, allEvidence) { + clearStructureLanding(); + const fileIndex = files.findIndex((file) => file.path === evidence.path); + if (fileIndex < 0) { + announce(evidence.path + " is not in the diff view."); + return; + } + let preferredKind = edgeStatus === "added" + ? "addition" + : edgeStatus === "removed" + ? "deletion" + : null; + if (edgeStatus === "modified") { + const identical = allEvidence.length > 1 && allEvidence.every((item) => normalizedLine(item.text) === normalizedLine(allEvidence[0].text)); + preferredKind = identical || evidenceIndex > 0 ? "addition" : "deletion"; + } + const matches = []; + const evidenceText = normalizedLine(evidence.text); + files[fileIndex].lines.forEach((line, rowIndex) => { + const lineText = normalizedLine(line.text); + if (rowSelectable(line) && line.selectLine === evidence.line && evidenceText && (lineText === evidenceText || lineText.startsWith(evidenceText))) + matches.push({ line, rowIndex }); + }); + const target = preferredKind + ? matches.find((match) => match.line.kind === preferredKind) || matches[0] + : matches[0]; + setView("diff"); + scrollFile(fileIndex); + if (!target) { + const head = element("file-" + fileIndex)?.querySelector(".file-head"); + head?.focus({ preventScroll: true }); + announce("Line " + evidence.line + " is not in the diff view."); + return; + } + const control = rowControl({ fileIndex, rowIndex: target.rowIndex }); + const row = rowNode({ fileIndex, rowIndex: target.rowIndex }); + row?.classList.add("structure-landing"); + row?.scrollIntoView({ block: "center" }); + control?.focus({ preventScroll: true }); + } + function renderStructure() { + selectedConnection = null; + const container = element("structure-content"); + container.replaceChildren(); + if (!currentSource || structureStatus === "loading") { + const empty = document.createElement("p"); + empty.className = "empty"; + empty.textContent = currentSource ? "Analyzing structure…" : "Load a source to begin reviewing."; + container.append(empty); + return; + } + if (structureStatus === "error") { + const card = document.createElement("div"); + card.className = "structure-error-card"; + const message = document.createElement("p"); + message.id = "structure-error"; + message.textContent = structureError; + const retry = document.createElement("button"); + retry.id = "structure-retry"; + retry.className = "ghost"; + retry.type = "button"; + retry.textContent = "Retry"; + retry.addEventListener("click", () => { + structureStatus = "idle"; + ensureStructure(); + element("structure-title")?.focus(); + }); + card.append(message, retry); + container.append(card); + return; + } + if (!structureSnapshot) return; + const snapshot = structureSnapshot; + const comparison = document.createElement("p"); + comparison.id = "structure-comparison"; + comparison.className = "structure-comparison"; + comparison.textContent = snapshot.comparison.from + " → " + snapshot.comparison.to; + container.append(comparison); + const disclosureItems = [ + ...snapshot.comparison.reasons.map((reason) => ({ reason })), + ...snapshot.limits.omitted, + ]; + if (snapshot.comparison.partial || snapshot.limits.truncated) { + const notice = document.createElement("aside"); + notice.id = "structure-partial"; + notice.className = "structure-partial"; + const sentence = document.createElement("p"); + sentence.textContent = "Structure analysis is partial; some connections may be missing."; + const details = document.createElement("details"); + const summary = document.createElement("summary"); + summary.textContent = disclosureItems.length + " " + (disclosureItems.length === 1 ? "reason" : "reasons"); + const reasons = document.createElement("ul"); + for (const item of disclosureItems) { + const reason = document.createElement("li"); + reason.textContent = (item.path ? item.path + ": " : "") + item.reason; + reasons.append(reason); + } + details.append(summary, reasons); + notice.append(sentence, details); + container.append(notice); + } + if (!snapshot.edges.length) + container.append(makeSpan("structure-zero", "No connections among changed files. Unchanged neighbours are outside this view.")); + snapshot.files.forEach((file, fileIndex) => { + const connections = snapshot.edges.filter((edge) => edge.from === file.path); + if (!connections.length && !(!file.analyzed && file.reason)) return; + const group = document.createElement("section"); + group.className = "structure-file"; + const headingId = "structure-file-" + fileIndex; + group.setAttribute("aria-labelledby", headingId); + const head = document.createElement("div"); + head.className = "structure-file-head file-head"; + const heading = document.createElement("h3"); + heading.id = headingId; + heading.className = "structure-file-path"; + heading.textContent = file.path; + heading.title = file.path; + const status = makeSpan("structure-file-status status-chip status-" + file.status, file.status); + head.append(heading, status); + group.append(head); + if (file.renamedFrom) + group.append(makeSpan("structure-file-note", "renamed from " + file.renamedFrom)); + if (!file.analyzed && file.reason) + group.append(makeSpan("structure-file-note", file.reason)); + const list = document.createElement("div"); + list.className = "connection-list"; + connections.forEach((edge, edgeIndex) => { + const key = fileIndex + "-" + edgeIndex; + const evidenceId = "connection-evidence-" + key; + const row = document.createElement("button"); + row.type = "button"; + row.className = "connection-row"; + row.dataset.status = edge.status; + row.setAttribute("aria-expanded", "false"); + row.setAttribute("aria-controls", evidenceId); + row.append( + makeSpan("connection-kind", structureKindLabel(edge.kind)), + makeSpan("connection-target", edge.to), + ); + if (edge.typeOnly) row.append(makeSpan("connection-type", "type")); + row.append(makeSpan("connection-status status-chip status-" + edge.status, structureStatusLabel(edge.status))); + const evidenceList = document.createElement("ul"); + evidenceList.id = evidenceId; + evidenceList.className = "connection-evidence"; + evidenceList.hidden = true; + edge.evidence.forEach((evidence, evidenceIndex) => { + const item = document.createElement("li"); + const code = document.createElement("span"); + code.className = "evidence-code"; + code.textContent = evidence.line + " " + evidence.text; + const open = document.createElement("button"); + open.type = "button"; + open.className = "ghost open-in-diff"; + open.textContent = "Open in Diff"; + open.addEventListener("click", () => jumpToEvidence(evidence, edge.status, evidenceIndex, edge.evidence)); + item.append(code, open); + evidenceList.append(item); + }); + row.addEventListener("click", () => { + const expanded = row.getAttribute("aria-expanded") === "true"; + if (selectedConnection && selectedConnection !== row) { + selectedConnection.classList.remove("selected"); + selectedConnection.setAttribute("aria-expanded", "false"); + element(selectedConnection.getAttribute("aria-controls")).hidden = true; + } + selectedConnection = row; + row.classList.add("selected"); + row.setAttribute("aria-expanded", String(!expanded)); + evidenceList.hidden = expanded; + }); + list.append(row, evidenceList); + }); + if (connections.length) group.append(list); + container.append(group); + }); + } + function ensureStructure() { + if (!currentSource) { + renderStructure(); + return; + } + if (structureInputId === currentSource.id && structureStatus !== "idle") return; + const inputId = currentSource.id; + const sequence = ++structureRequestSequence; + structureInputId = inputId; + structureStatus = "loading"; + renderStructure(); + announce("Analyzing structure…"); + api("/api/structure").then((snapshot) => { + if (sequence !== structureRequestSequence || currentSource?.id !== inputId) return; + if (snapshot?.inputId !== inputId) { + structureError = "Structure analysis did not match the current source."; + structureSnapshot = null; + structureStatus = "error"; + renderStructure(); + announce(structureError); + return; + } + structureSnapshot = snapshot; + structureError = null; + structureStatus = "loaded"; + renderStructure(); + announce("Structure analysis complete."); + }).catch((error) => { + if (sequence !== structureRequestSequence || currentSource?.id !== inputId) return; + structureError = error instanceof Error ? error.message : String(error); + structureSnapshot = null; + structureStatus = "error"; + renderStructure(); + announce("Structure analysis failed: " + structureError); + }); + } function setView(view, focusPanel = false) { - activeView = view === "log" ? "log" : "diff"; - if (activeView === "log") hideInlineComposer(); + activeView = ["diff", "structure", "log"].includes(view) ? view : "diff"; + if (activeView !== "diff") hideInlineComposer(); element("diff").hidden = activeView !== "diff"; + element("structure-section").hidden = activeView !== "structure"; element("log-section").hidden = activeView !== "log"; for (const id of diffControls) element(id).hidden = activeView !== "diff"; element("mobile-ask").hidden = activeView !== "diff"; - for (const name of ["diff", "log"]) { + for (const name of ["diff", "structure", "log"]) { const tab = element("view-" + name); const selected = name === activeView; tab.setAttribute("aria-selected", String(selected)); tab.tabIndex = selected ? 0 : -1; } - announce(activeView === "log" ? "Learning log view." : "Diff view."); - if (focusPanel) (activeView === "log" ? element("log-title") : element("view-diff")).focus(); + announce(activeView === "log" ? "Learning log view." : activeView === "structure" ? "Structure view." : "Diff view."); + if (activeView === "structure") ensureStructure(); + if (focusPanel) (activeView === "log" ? element("log-title") : activeView === "structure" ? element("structure-title") : element("view-diff")).focus(); } function handleViewKey(event) { if (!["ArrowLeft", "ArrowRight", "Home", "End", "Enter", " "].includes(event.key)) return; - const tabs = [element("view-diff"), element("view-log")]; + const tabs = [element("view-diff"), element("view-structure"), element("view-log")]; const current = tabs.indexOf(event.currentTarget); if (["Enter", " "].includes(event.key)) { - setView(event.currentTarget.id === "view-log" ? "log" : "diff"); + setView(event.currentTarget.id.replace("view-", "")); } else { const target = event.key === "Home" ? tabs[0] @@ -400,6 +641,7 @@ export const pageScript = String.raw` : event.key === "ArrowLeft" ? tabs[Math.max(0, current - 1)] : tabs[Math.min(tabs.length - 1, current + 1)]; + tabs.forEach((tab) => { tab.tabIndex = tab === target ? 0 : -1; }); target.focus(); } event.preventDefault(); @@ -511,6 +753,7 @@ export const pageScript = String.raw` function chooseRow(fileIndex, rowIndex, extend, confirm) { const file = files[fileIndex], row = file.lines[rowIndex]; if (!rowSelectable(row)) return; + clearStructureLanding(); clearError(); const previousAnchor = selectionAnchor; if (!extend || !selectionAnchor || selectionAnchor.fileIndex !== fileIndex || file.lines[selectionAnchor.rowIndex].block !== row.block) @@ -828,6 +1071,7 @@ export const pageScript = String.raw` article.id = "file-" + fileIndex; const head = document.createElement("div"); head.className = "file-head"; + head.tabIndex = -1; const disclosure = document.createElement("button"); disclosure.textContent = "▾"; disclosure.setAttribute("aria-label", "Collapse " + file.path); @@ -1513,6 +1757,7 @@ export const pageScript = String.raw` } function acceptSource(source) { currentSource = source; + resetStructure(); const parsed = parseUnifiedDiff(source.content, source.label); files = parsed.files; preambleText = parsed.preamble; @@ -1530,8 +1775,8 @@ export const pageScript = String.raw` } element("kind").addEventListener("change", updateSourceFields); element("model").addEventListener("change", updateThinking); - for (const id of ["view-diff", "view-log"]) { - element(id).addEventListener("click", () => setView(id === "view-log" ? "log" : "diff")); + for (const id of ["view-diff", "view-structure", "view-log"]) { + element(id).addEventListener("click", () => setView(id.replace("view-", ""))); element(id).addEventListener("keydown", handleViewKey); } element("history-previous").addEventListener("click", () => renderHistoryEntry(Math.max(0, historyIndex - 1))); diff --git a/packages/review-tutor/src/page-styles.ts b/packages/review-tutor/src/page-styles.ts index 2f134ff..58d3440 100644 --- a/packages/review-tutor/src/page-styles.ts +++ b/packages/review-tutor/src/page-styles.ts @@ -18,7 +18,7 @@ button:hover:not(:disabled) { border-color:rgba(255,255,255,.28)} button:disabled { cursor:not-allowed;opacity:.48} -button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,[tabindex]:focus-visible { +button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible,[tabindex]:focus-visible { outline:2px solid rgba(255,122,26,.6);outline-offset:2px} input,select { width:100%;height:var(--control-h);padding:0 10px} @@ -105,7 +105,7 @@ display:none} .diff-scroll { overflow:auto;min-height:0;scroll-behavior:smooth} .empty { -padding:56px 24px;color:var(--muted)} +display:block;margin:0;padding:56px 24px;color:var(--muted)} .diff-preamble { margin:14px 14px 4px;padding:10px 12px;border:1px solid var(--hairline);border-radius:var(--radius-card);background:var(--surface-1);color:var(--muted);font:11px/1.6 var(--font-mono);white-space:pre-wrap;overflow-wrap:anywhere} .file { @@ -134,6 +134,8 @@ background:rgba(60,190,125,.07);box-shadow:inset 2px 0 var(--green)} background:rgba(220,80,95,.07);box-shadow:inset 2px 0 var(--rose)} .diff-row.selected { background:rgba(255,122,26,.11);box-shadow:inset 2px 0 var(--ember)} +.diff-row.structure-landing { +box-shadow:inset 2px 0 var(--ember)} .file.plain .diff-row { grid-template-columns:52px 24px minmax(0,1fr)} .file.plain .line-no:nth-child(2) { @@ -286,6 +288,64 @@ content:"";display:inline-block;width:7px;height:1em;background:var(--ember);ver position:fixed;top:calc(var(--topbar-h) + 10px);left:50%;translate:-50% 0;z-index:60;max-width:min(560px,calc(100vw - 24px));padding:10px 14px;border:1px solid rgba(231,123,134,.4);border-radius:var(--radius-control);background:var(--surface-2);color:#ff9ca5;white-space:pre-wrap;box-shadow:0 6px 24px rgba(0,0,0,.5)} .error:empty { display:none} +.structure-section { +contain:layout;min-width:0;padding:14px 14px 100px;overflow-x:hidden} +.structure-comparison { +margin:0 0 10px;color:var(--subtle);font:12px var(--font-mono)} +.structure-partial { +margin:0 0 14px;padding:10px 12px;border:1px solid var(--amber);border-radius:var(--radius-card);background:var(--surface-1);color:var(--subtle)} +.structure-partial p { +margin:0} +.structure-partial details { +margin-top:6px} +.structure-partial summary { +width:max-content;max-width:100%;color:var(--amber);cursor:pointer;font:600 11px var(--font-mono)} +.structure-partial ul { +margin:7px 0 0;padding-left:20px;overflow-wrap:anywhere} +.structure-error-card { +display:flex;align-items:center;gap:12px;padding:12px;border:1px solid var(--rose);border-radius:var(--radius-card);background:var(--surface-1)} +.structure-error-card p { +flex:1;min-width:0;margin:0;color:var(--rose);overflow-wrap:anywhere} +.structure-zero { +display:block;padding:24px 0;color:var(--muted)} +.structure-file { +min-width:0;border:1px solid var(--hairline);border-radius:var(--radius-card);overflow:clip;background:var(--surface-1)} +.structure-file + .structure-file { +margin-top:10px} +.structure-file-head { +position:static;min-width:0} +.structure-file-path { +min-width:0;margin:0;font:12px var(--font-mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.status-chip,.connection-kind,.connection-type { +flex:none;color:var(--muted);font:600 10px var(--font-mono);letter-spacing:.1em;text-transform:uppercase} +.status-added { +color:var(--green)} +.status-removed { +color:var(--rose)} +.status-modified { +color:var(--amber)} +.structure-file-note { +display:block;padding:6px 14px;border-bottom:1px solid var(--hairline);color:var(--muted);font:11px var(--font-mono);overflow-wrap:anywhere} +.connection-list { +min-width:0} +.connection-row { +width:100%;min-width:0;min-height:32px;display:grid;grid-template-columns:auto minmax(0,1fr) auto auto;align-items:center;gap:9px;padding:3px 12px;border:0;border-radius:0;background:transparent;text-align:left;white-space:normal} +.connection-row:hover:not(:disabled) { +background:var(--surface-2)} +.connection-row.selected { +position:relative;z-index:1;outline:1px solid var(--ember);outline-offset:-1px;background:var(--surface-2)} +.connection-target { +min-width:0;color:var(--text);font:12px var(--font-mono);overflow-wrap:anywhere} +.connection-evidence { +margin:0;padding:0;list-style:none;border-top:1px solid var(--hairline);background:var(--surface)} +.connection-evidence li { +min-width:0;display:flex;align-items:center;gap:12px;padding:7px 12px 7px 22px;border-left:2px solid var(--hairline-strong)} +.connection-evidence li + li { +border-top:1px solid var(--hairline)} +.evidence-code { +flex:1;min-width:0;color:var(--subtle);font:12px/1.5 var(--font-mono);white-space:pre-wrap;overflow-wrap:anywhere} +.open-in-diff { +flex:none} .log-section { contain:layout;padding:20px 14px 100px;border-top:1px solid var(--hairline);scroll-margin-top:8px} .log-head { @@ -377,9 +437,29 @@ column-gap:2px} flex:1 1 100%} .toolbar .totals { margin-left:0} +.structure-file-path { +overflow:visible;text-overflow:clip;white-space:normal;overflow-wrap:anywhere} +.connection-row { +grid-template-columns:auto auto auto minmax(0,1fr);grid-template-areas:"kind type status ." "target target target target";gap:2px 8px;padding-top:5px;padding-bottom:5px} +.connection-kind { +grid-area:kind} +.connection-target { +grid-area:target} +.connection-type { +grid-area:type} +.connection-status { +grid-area:status} +.connection-evidence li { +align-items:stretch;flex-direction:column;padding-left:12px} +.open-in-diff { +width:100%;min-height:var(--control-h-touch)} } .diff-scroll { overflow:visible} +.structure-section { +padding-left:8px;padding-right:8px} +.connection-row { +min-height:var(--control-h-touch)} .file-head { top:var(--filehead-top);min-height:var(--control-h-touch);height:var(--control-h-touch)} .diff-row { @@ -412,6 +492,10 @@ top:6px} display:block;position:fixed;z-index:30;left:12px;right:12px;bottom:12px;min-height:48px;box-shadow:0 4px 18px #000;background:var(--ember);color:#160a02;border-color:var(--ember);font-weight:700} .mobile-ask:disabled { opacity:1} +.structure-error-card button,.structure-partial summary,.open-in-diff { +min-height:var(--control-h-touch)} +.structure-partial summary { +padding:8px 4px} .source-actions .primary { min-height:var(--control-h-touch)} .field select,.field input,.field textarea { diff --git a/packages/review-tutor/src/page.ts b/packages/review-tutor/src/page.ts index 393fc23..0af82c4 100644 --- a/packages/review-tutor/src/page.ts +++ b/packages/review-tutor/src/page.ts @@ -36,12 +36,12 @@ export const pageHtml = `
-
+0 −0
+
+0 −0
Tutor

Ask the tutor

Select code. Ask anything.

No code selected

Load a source and enter a question.

-

Load a source to begin reviewing.

+

Load a source to begin reviewing.

`; diff --git a/packages/review-tutor/test/page.test.ts b/packages/review-tutor/test/page.test.ts index 1a66f73..86dec8c 100644 --- a/packages/review-tutor/test/page.test.ts +++ b/packages/review-tutor/test/page.test.ts @@ -19,7 +19,8 @@ const source = { "diff --git a/src/b.ts b/src/b.ts", "--- a/src/b.ts", "+++ b/src/b.ts", - "@@ -1 +1 @@", + "@@ -0,0 +1,2 @@", + "+import type { a } from './a.js';", "+export const b = true;", ].join("\n"), }; @@ -31,6 +32,39 @@ const state = { questions: [], }; +const structure = { + protocol: "rt/1", + inputId: source.id, + comparison: { + kind: "worktree", + label: "Working tree", + from: "index", + to: "working tree", + partial: true, + reasons: ["One file exceeded the analysis limit."], + }, + files: [ + { path: "src/b.ts", status: "added", additions: 2, deletions: 0, analyzed: true }, + { path: "src/a.ts", status: "modified", additions: 1, deletions: 1, analyzed: true }, + { path: "src/old.ts", status: "removed", additions: 0, deletions: 2, analyzed: true }, + { path: "src/new.ts", status: "renamed", renamedFrom: "src/legacy.ts", additions: 0, deletions: 0, analyzed: true }, + { path: "vendor/generated.ts", status: "modified", additions: 3, deletions: 3, analyzed: false, reason: "File exceeded the statement limit." }, + ], + edges: [ + { from: "src/b.ts", to: "src/a.ts", kind: "import", typeOnly: true, status: "added", specifier: "./a.js", evidence: [{ path: "src/b.ts", line: 1, text: "import type { a } from './a.js';" }] }, + { from: "src/a.ts", to: "src/new.ts", kind: "reexport", typeOnly: false, status: "modified", specifier: "./new.js", evidence: [{ path: "src/a.ts", line: 2, text: "export { value } from './new.js';" }] }, + { from: "src/old.ts", to: "src/a.ts", kind: "require", typeOnly: false, status: "removed", specifier: "./a", evidence: [{ path: "src/old.ts", line: 1, text: "require('./a');" }] }, + { from: "src/new.ts", to: "src/a.ts", kind: "dynamic-import", typeOnly: false, status: "unchanged", specifier: "./a.js", evidence: [] }, + ], + limits: { + maxFiles: 200, + maxEdges: 2000, + maxEvidencePerEdge: 4, + truncated: true, + omitted: [{ path: "src/skipped.ts", reason: "Edge limit reached." }], + }, +}; + class FakeEventSource { static instances: FakeEventSource[] = []; onopen: (() => void) | null = null; @@ -66,6 +100,16 @@ function logResponse(value: Promise | Response | unknown[] | undefined return json(value ?? fallback); } +function stateResponse(value: unknown) { + if (value instanceof Error) throw value; + return json(value); +} + +function structureResponse(value: Promise | Response | unknown | Error) { + if (value instanceof Error) throw value; + return value instanceof Promise || value instanceof Response ? value : json(value); +} + async function boot(options: { width?: number; askResponse?: Promise | Response; @@ -76,6 +120,7 @@ async function boot(options: { storedPageId?: string; storedQuizIds?: string; stateResponses?: unknown[]; + structureResponses?: Array | Response | unknown | Error>; failHeartbeat?: boolean; railCollapsed?: boolean; } = {}) { @@ -95,15 +140,13 @@ async function boot(options: { const requests: Array<{ path: string; init?: RequestInit }> = []; const stateResponses = [...(options.stateResponses ?? [])]; const logResponses = [...(options.logResponses ?? [])]; + const structureResponses = [...(options.structureResponses ?? [structure])]; const bootState = { ...state, input: options.source ?? source }; const fetch = vi.fn(async (path: string, init?: RequestInit) => { requests.push({ path, init }); - if (path === "/api/state") { - const response = stateResponses.shift() ?? bootState; - if (response instanceof Error) throw response; - return json(response); - } + if (path === "/api/state") return stateResponse(stateResponses.shift() ?? bootState); if (path === "/api/log?limit=100") return logResponse(logResponses.shift(), options.entries ?? []); + if (path === "/api/structure") return structureResponse(structureResponses.shift() ?? structure); if (path === "/api/heartbeat" && options.failHeartbeat) return Promise.reject(new Error("heartbeat down")); if (path === "/api/ask") return options.askResponse ?? json({ id: "q-1", state: "queued", answer: "", createdAt: new Date().toISOString() }); if (path.startsWith("/api/log/")) return json({}); @@ -157,6 +200,11 @@ function lineControl(row: any) { return control; } +function pressEnter(window: InstanceType, control: any) { + if (control.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }))) + control.click(); +} + function input(document: TestDocument, id: string, value: string) { const node = byId(document, id); node.value = value; @@ -274,8 +322,10 @@ describe("Review Tutor composed page", () => { expect(byId(document, "error").parentElement).toBe(document.body); expect(byId(document, "lifecycle").parentElement).toBe(document.body); expect(byId(document, "log-section").parentElement).toBe(byId(document, "diff-scroll")); - expect(byId(document, "log-section").previousElementSibling).toBe(byId(document, "diff")); + expect(byId(document, "structure-section").previousElementSibling).toBe(byId(document, "diff")); + expect(byId(document, "log-section").previousElementSibling).toBe(byId(document, "structure-section")); expect(byId(document, "diff").getAttribute("role")).toBe("tabpanel"); + expect(byId(document, "structure-section").getAttribute("role")).toBe("tabpanel"); expect(byId(document, "log-section").getAttribute("role")).toBe("tabpanel"); expect(byId(document, "log-section").hidden).toBe(true); expect(byId(document, "view-diff").getAttribute("aria-selected")).toBe("true"); @@ -283,6 +333,391 @@ describe("Review Tutor composed page", () => { expect(Array.from(byId(document, "harness").options).map((item: any) => [item.value, item.textContent])).toEqual([["pi", "Pi"]]); }); + it("adds Structure to the peer tablist with roving keyboard focus", async () => { + const { window, document } = await boot(); + const tabs = Array.from(byId(document, "view-switch").querySelectorAll('[role="tab"]')) as any[]; + expect(tabs.map((tab) => tab.textContent)).toEqual(["Diff", "Structure", "Learning log"]); + expect(tabs.map((tab) => tab.tabIndex)).toEqual([0, -1, -1]); + + byId(document, "view-diff").dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + expect(document.activeElement).toBe(byId(document, "view-structure")); + expect(tabs.map((tab) => tab.tabIndex)).toEqual([-1, 0, -1]); + expect(Array.from(document.querySelectorAll('.view-tab[tabindex="0"]'))).toEqual([byId(document, "view-structure")]); + byId(document, "view-structure").dispatchEvent(new window.KeyboardEvent("keydown", { key: "End", bubbles: true })); + expect(document.activeElement).toBe(byId(document, "view-log")); + expect(tabs.map((tab) => tab.tabIndex)).toEqual([-1, -1, 0]); + }); + + it("fetches structure once per input and refetches the new payload after a source switch", async () => { + const nextSource = { ...source, id: "input-2", digest: "digest-2", label: "Next tree" }; + const nextStructure = { + ...structure, + inputId: nextSource.id, + comparison: { ...structure.comparison, label: "Next tree", from: "main", to: "feature" }, + }; + const { document, requests, events } = await boot({ structureResponses: [structure, nextStructure] }); + + byId(document, "view-structure").click(); + await flush(); + byId(document, "view-diff").click(); + byId(document, "view-structure").click(); + await flush(); + expect(requests.filter((request) => request.path === "/api/structure")).toHaveLength(1); + expect(requests.find((request) => request.path === "/api/structure")?.init?.headers).toMatchObject({ Authorization: "Bearer secret-token" }); + + events?.emit("source", nextSource); + byId(document, "view-structure").click(); + await flush(); + expect(requests.filter((request) => request.path === "/api/structure")).toHaveLength(2); + expect(byId(document, "structure-comparison").textContent).toBe("main → feature"); + }); + + it("shows a retryable error when a structure snapshot does not match the current input", async () => { + const { document } = await boot({ + structureResponses: [{ ...structure, inputId: "other-input", comparison: { ...structure.comparison, from: "wrong", to: "snapshot" } }], + }); + byId(document, "view-structure").click(); + await flush(); + expect(document.querySelector("#structure-comparison")).toBeNull(); + expect(byId(document, "structure-error").textContent).toBe("Structure analysis did not match the current source."); + expect(byId(document, "structure-retry").textContent).toBe("Retry"); + expect(byId(document, "lifecycle").textContent).toBe("Structure analysis did not match the current source."); + }); + + it("ignores a fetched structure snapshot when an SSE source switch wins the race", async () => { + let resolveOld!: (response: Response) => void; + const oldPending = new Promise((resolve) => { resolveOld = resolve; }); + const nextSource = { ...source, id: "input-2", digest: "digest-2", label: "Next tree" }; + const nextStructure = { + ...structure, + inputId: nextSource.id, + comparison: { ...structure.comparison, from: "base", to: "next" }, + }; + const { document, events } = await boot({ structureResponses: [oldPending, nextStructure] }); + + byId(document, "view-structure").click(); + resolveOld(json({ ...structure, inputId: "other-input", comparison: { ...structure.comparison, from: "wrong", to: "snapshot" } })); + events?.emit("source", nextSource); + await flush(); + expect(document.querySelector("#structure-comparison")).toBeNull(); + + byId(document, "view-structure").click(); + await flush(); + expect(byId(document, "structure-comparison").textContent).toBe("base → next"); + }); + + it("renders block empty states, announces view then loading, and focuses the title after Retry", async () => { + let resolveStructure!: (response: Response) => void; + const pending = new Promise((resolve) => { resolveStructure = resolve; }); + const typedError = new Response(JSON.stringify({ error: "structure unavailable" }), { status: 503, statusText: "Unavailable", headers: { "content-type": "application/json" } }); + const { document } = await boot({ structureResponses: [pending, structure] }); + const announcements: string[] = []; + const observer = new document.defaultView!.MutationObserver((records) => { + for (const record of records) + announcements.push(Array.from(record.addedNodes).map((node: any) => node.textContent).join("")); + }); + observer.observe(byId(document, "lifecycle"), { childList: true }); + + byId(document, "view-structure").click(); + await Promise.resolve(); + expect(byId(document, "lifecycle").textContent).toBe("Analyzing structure…"); + expect(announcements.filter(Boolean)).toEqual(["Structure view.", "Analyzing structure…"]); + expect(byId(document, "structure-content").firstElementChild?.tagName).toBe("P"); + resolveStructure(typedError); + await flush(); + expect(byId(document, "structure-error").textContent).toBe("structure unavailable"); + expect(byId(document, "lifecycle").textContent).toBe("Structure analysis failed: structure unavailable"); + byId(document, "structure-retry").click(); + expect(document.activeElement).toBe(byId(document, "structure-title")); + await flush(); + expect(byId(document, "structure-comparison").textContent).toBe("index → working tree"); + expect(document.activeElement).toBe(byId(document, "structure-title")); + observer.disconnect(); + }); + + it("clears stale connection state across an error, Retry, and loaded re-render", async () => { + const nextSource = { ...source, id: "input-2", digest: "digest-2", label: "Next tree" }; + const nextStructure = { ...structure, inputId: nextSource.id }; + const typedError = new Response(JSON.stringify({ error: "structure unavailable" }), { status: 503, headers: { "content-type": "application/json" } }); + const { document, events } = await boot({ structureResponses: [structure, typedError, nextStructure] }); + + byId(document, "view-structure").click(); + await flush(); + (document.querySelector(".connection-row") as any).click(); + events?.emit("source", nextSource); + byId(document, "view-structure").click(); + await flush(); + byId(document, "structure-retry").click(); + await flush(); + const rows = Array.from(document.querySelectorAll(".connection-row")) as any[]; + rows[1].click(); + expect(rows[1].getAttribute("aria-expanded")).toBe("true"); + expect(pageHtml).toContain("function renderStructure() {\n selectedConnection = null;"); + expect(pageHtml).not.toContain("function structureText("); + expect(pageHtml).not.toContain("message.textContent = structureSnapshot"); + }); + + it("renders the locked structure hierarchy, statuses, partial disclosure, and empty copy", async () => { + const { document } = await boot(); + byId(document, "view-structure").click(); + await flush(); + + expect(byId(document, "structure-partial").textContent).toContain("Structure analysis is partial; some connections may be missing."); + expect(byId(document, "structure-partial").querySelector("summary")?.textContent).toBe("2 reasons"); + expect(byId(document, "structure-partial").textContent).toContain("src/skipped.ts: Edge limit reached."); + const groups = Array.from(document.querySelectorAll(".structure-file")) as any[]; + expect(groups).toHaveLength(5); + expect(groups.every((group) => group.tagName === "SECTION" && group.getAttribute("aria-labelledby"))).toBe(true); + expect(groups.map((group) => group.querySelector(".structure-file-status")?.textContent)).toEqual(["added", "modified", "removed", "renamed", "modified"]); + expect(groups[3].textContent).toContain("renamed from src/legacy.ts"); + expect(groups[4].textContent).toContain("File exceeded the statement limit."); + expect(groups[2].querySelector('.connection-row[data-status="removed"]')).not.toBeNull(); + expect(document.querySelector(".connection-type")?.textContent).toBe("type"); + expect(document.querySelector('.connection-row[data-status="modified"] .connection-status')?.textContent).toBe("edited"); + expect(groups.every((group) => !group.querySelector(".file-counts"))).toBe(true); + expect(groups.every((group) => group.querySelector(".structure-file-path")?.getAttribute("title") === group.querySelector(".structure-file-path")?.textContent)).toBe(true); + expect(pageHtml).toMatch(/\.structure-file-head \{\nposition:static/); + + const emptySnapshot = { + ...structure, + comparison: { ...structure.comparison, partial: false, reasons: [] }, + files: [ + { ...structure.files[3] }, + { ...structure.files[4] }, + ], + edges: [], + limits: { ...structure.limits, truncated: false, omitted: [] }, + }; + const emptyPage = await boot({ structureResponses: [emptySnapshot] }); + byId(emptyPage.document, "view-structure").click(); + await flush(); + expect(byId(emptyPage.document, "structure-content").textContent).toContain("No connections among changed files. Unchanged neighbours are outside this view."); + const emptyGroups = emptyPage.document.querySelectorAll(".structure-file"); + expect(emptyGroups).toHaveLength(1); + expect(emptyGroups.item(0)?.textContent).not.toContain("renamed from"); + expect(emptyGroups.item(0)?.textContent).toContain("File exceeded the statement limit."); + }); + + it("renders only a header and reason for an unanalyzed file without connections", async () => { + const binarySnapshot = { + ...structure, + files: [{ path: "assets/logo.bin", status: "added", additions: 0, deletions: 0, analyzed: false, reason: "binary content: no import data" }], + edges: [], + }; + const { document } = await boot({ width: 390, structureResponses: [binarySnapshot] }); + byId(document, "view-structure").click(); + await flush(); + + const group = document.querySelector(".structure-file") as unknown as HTMLElement; + expect(Array.from(group.children).map((child) => child.className)).toEqual([ + "structure-file-head file-head", + "structure-file-note", + ]); + expect(group.children.item(1)?.textContent).toBe("binary content: no import data"); + }); + + it("supports Enter activation, same-row collapse, single selection, and evidence jumps", async () => { + const { window, document } = await boot(); + byId(document, "view-structure").click(); + await flush(); + const rows = Array.from(document.querySelectorAll(".connection-row")) as any[]; + expect(rows[0].tagName).toBe("BUTTON"); + pressEnter(window, rows[0]); + expect(rows[0].getAttribute("aria-expanded")).toBe("true"); + expect(rows[0].classList.contains("selected")).toBe(true); + expect(byId(document, rows[0].getAttribute("aria-controls")).tagName).toBe("UL"); + rows[0].click(); + expect(rows[0].getAttribute("aria-expanded")).toBe("false"); + rows[0].click(); + rows[1].click(); + expect(rows[0].getAttribute("aria-expanded")).toBe("false"); + expect(rows[0].classList.contains("selected")).toBe(false); + expect(rows[1].getAttribute("aria-expanded")).toBe("true"); + rows[0].click(); + const open = byId(document, rows[0].getAttribute("aria-controls")).querySelector("button") as any; + open.click(); + expect(byId(document, "diff").hidden).toBe(false); + expect(byId(document, "structure-section").hidden).toBe(true); + expect(document.activeElement).toBe(lineControl(selectableRows(document).find((row) => row.dataset.file === "1" && row.dataset.row === "1"))); + expect(document.querySelectorAll(".diff-row.structure-landing")).toHaveLength(1); + lineControl(selectableRows(document)[0]).dispatchEvent(new document.defaultView!.Event("pointerdown", { bubbles: true })); + expect(document.querySelectorAll(".diff-row.structure-landing")).toHaveLength(0); + }); + + it("anchors modified evidence to its old and new sides, preferring additions for identical text", async () => { + const modifiedSource = { + ...source, + content: "diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-old value\n+new value", + }; + const modifiedStructure = { + ...structure, + files: [{ path: "a.ts", status: "modified", additions: 1, deletions: 1, analyzed: true }], + edges: [{ + from: "a.ts", to: "b.ts", kind: "import", typeOnly: false, status: "modified", specifier: "./b.js", + evidence: [{ path: "a.ts", line: 1, text: "old value" }, { path: "a.ts", line: 1, text: "new value" }], + }], + }; + const oldPage = await boot({ source: modifiedSource, structureResponses: [modifiedStructure] }); + byId(oldPage.document, "view-structure").click(); + await flush(); + (oldPage.document.querySelector(".connection-row") as any).click(); + const opens = Array.from(oldPage.document.querySelectorAll(".open-in-diff")) as any[]; + opens[0].click(); + expect(oldPage.document.querySelector(".diff-row.structure-landing")?.classList.contains("deletion")).toBe(true); + + byId(oldPage.document, "view-structure").click(); + (oldPage.document.querySelector(".connection-row") as any).click(); + (Array.from(oldPage.document.querySelectorAll(".open-in-diff")) as any[])[1].click(); + expect(oldPage.document.querySelector(".diff-row.structure-landing")?.classList.contains("addition")).toBe(true); + expect(oldPage.document.querySelectorAll(".diff-row.structure-landing")).toHaveLength(1); + + const identicalSource = { ...modifiedSource, content: modifiedSource.content.replace("old value", "same").replace("new value", "same") }; + const identicalStructure = { + ...modifiedStructure, + edges: [{ ...modifiedStructure.edges[0], evidence: [{ path: "a.ts", line: 1, text: "same" }, { path: "a.ts", line: 1, text: "same" }] }], + }; + const identicalPage = await boot({ source: identicalSource, structureResponses: [identicalStructure] }); + byId(identicalPage.document, "view-structure").click(); + await flush(); + (identicalPage.document.querySelector(".connection-row") as any).click(); + (identicalPage.document.querySelector(".open-in-diff") as any).click(); + expect(identicalPage.document.querySelector(".diff-row.structure-landing")?.classList.contains("addition")).toBe(true); + }); + + it("matches analyzer-truncated evidence as a prefix of the full diff line", async () => { + const prefix = "x".repeat(200); + const prefixSource = { + ...source, + content: `diff --git a/long.ts b/long.ts\n--- /dev/null\n+++ b/long.ts\n@@ -0,0 +1 @@\n+${prefix} full suffix`, + }; + const prefixStructure = { + ...structure, + files: [{ path: "long.ts", status: "added", additions: 1, deletions: 0, analyzed: true }], + edges: [{ + from: "long.ts", to: "target.ts", kind: "import", typeOnly: false, status: "added", specifier: "./target", + evidence: [{ path: "long.ts", line: 1, text: prefix }], + }], + }; + const loaded = await boot({ source: prefixSource, structureResponses: [prefixStructure] }); + byId(loaded.document, "view-structure").click(); + await flush(); + (loaded.document.querySelector(".connection-row") as any).click(); + (loaded.document.querySelector(".open-in-diff") as any).click(); + expect(loaded.document.querySelector(".diff-row.structure-landing")?.textContent).toContain("full suffix"); + }); + + it("falls back to a matching line when the preferred diff side is absent", async () => { + const fallbackSource = { + ...source, + content: "diff --git a/added.ts b/added.ts\n--- /dev/null\n+++ b/added.ts\n@@ -0,0 +1 @@\n+only present here", + }; + const fallbackStructure = { + ...structure, + files: [{ path: "added.ts", status: "added", additions: 1, deletions: 0, analyzed: true }], + edges: [{ + from: "added.ts", to: "target.ts", kind: "import", typeOnly: false, status: "removed", specifier: "./target", + evidence: [{ path: "added.ts", line: 1, text: "only present here" }], + }], + }; + const loaded = await boot({ source: fallbackSource, structureResponses: [fallbackStructure] }); + byId(loaded.document, "view-structure").click(); + await flush(); + (loaded.document.querySelector(".connection-row") as any).click(); + (loaded.document.querySelector(".open-in-diff") as any).click(); + expect(loaded.document.querySelector(".diff-row.structure-landing")?.classList.contains("addition")).toBe(true); + }); + + it("matches indented and removed-file evidence after whitespace normalization", async () => { + const evidenceSource = { + ...source, + content: [ + "diff --git a/indented.ts b/indented.ts", "--- a/indented.ts", "+++ b/indented.ts", "@@ -0,0 +1 @@", "+ call( value );", + "diff --git a/removed.ts b/removed.ts", "--- a/removed.ts", "+++ /dev/null", "@@ -1 +0,0 @@", "-gone();", + ].join("\n"), + }; + const evidenceStructure = { + ...structure, + files: [ + { path: "indented.ts", status: "added", additions: 1, deletions: 0, analyzed: true }, + { path: "removed.ts", status: "removed", additions: 0, deletions: 1, analyzed: true }, + ], + edges: [ + { from: "indented.ts", to: "value.ts", kind: "import", typeOnly: false, status: "added", specifier: "./value", evidence: [{ path: "indented.ts", line: 1, text: "call( value );" }] }, + { from: "removed.ts", to: "gone.ts", kind: "import", typeOnly: false, status: "removed", specifier: "./gone", evidence: [{ path: "removed.ts", line: 1, text: "gone();" }] }, + ], + }; + const loaded = await boot({ source: evidenceSource, structureResponses: [evidenceStructure] }); + byId(loaded.document, "view-structure").click(); + await flush(); + const rows = Array.from(loaded.document.querySelectorAll(".connection-row")) as any[]; + rows[0].click(); + (loaded.document.querySelector(".open-in-diff") as any).click(); + expect(loaded.document.querySelector(".diff-row.structure-landing")?.classList.contains("addition")).toBe(true); + + byId(loaded.document, "view-structure").click(); + rows[1].click(); + (rows[1].nextElementSibling.querySelector(".open-in-diff") as any).click(); + expect(loaded.document.querySelector(".diff-row.structure-landing")?.classList.contains("deletion")).toBe(true); + expect(loaded.document.querySelectorAll(".diff-row.structure-landing")).toHaveLength(1); + }); + + it("announces unavailable evidence and only leaves Structure when the file is rendered", async () => { + const unavailable = { + ...structure, + files: [{ path: "src/a.ts", status: "modified", additions: 1, deletions: 1, analyzed: true }], + edges: [ + { from: "src/a.ts", to: "line.ts", kind: "import", typeOnly: false, status: "added", specifier: "./line", evidence: [{ path: "src/a.ts", line: 99, text: "missing();" }] }, + { from: "src/a.ts", to: "file.ts", kind: "import", typeOnly: false, status: "added", specifier: "./file", evidence: [{ path: "not-rendered.ts", line: 1, text: "missing();" }] }, + ], + }; + const { document } = await boot({ structureResponses: [unavailable] }); + byId(document, "view-structure").click(); + await flush(); + const rows = Array.from(document.querySelectorAll(".connection-row")) as any[]; + rows[0].click(); + (rows[0].nextElementSibling.querySelector(".open-in-diff") as any).click(); + expect(byId(document, "diff").hidden).toBe(false); + expect(byId(document, "lifecycle").textContent).toBe("Line 99 is not in the diff view."); + expect(document.activeElement).toBe(document.querySelector("#file-0 .file-head")); + expect(document.querySelectorAll(".diff-row.structure-landing")).toHaveLength(0); + + byId(document, "view-structure").click(); + rows[1].click(); + (rows[1].nextElementSibling.querySelector(".open-in-diff") as any).click(); + expect(byId(document, "structure-section").hidden).toBe(false); + expect(byId(document, "lifecycle").textContent).toBe("not-rendered.ts is not in the diff view."); + }); + + it("uses touch targets and compact chips without making structure heads sticky", async () => { + expect(pageHtml).toContain("button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible,[tabindex]:focus-visible"); + expect(pageHtml).toMatch(/\.status-chip,\.connection-kind,\.connection-type \{\n[^}]*font:600 10px/); + expect(pageHtml).toMatch(/@media\(max-width:860px\)[\s\S]*\.structure-error-card button,\.structure-partial summary,\.open-in-diff \{\nmin-height:var\(--control-h-touch\)/); + expect(pageHtml).toMatch(/@media\(max-width:520px\)[\s\S]*\.structure-file-path \{\n[^}]*white-space:normal[^}]*overflow-wrap:anywhere/); + expect(pageHtml).toContain('grid-template-areas:"kind type status ." "target target target target"'); + + const mobileStyles = pageHtml.slice(pageHtml.indexOf("@media(max-width:860px)")); + expect(mobileStyles).toMatch(/\.structure-partial summary \{\npadding:8px 4px\}/); + expect(mobileStyles).not.toMatch(/\.structure-partial summary \{\n[^}]*display:flex/); + expect(pageHtml).toMatch(/\.empty \{\ndisplay:block[^}]*\}/); + expect(pageHtml).not.toMatch(/\.empty \{\n[^}]*text-align:center/); + expect(pageHtml).toMatch(/\.diff-row\.structure-landing \{\nbox-shadow:inset 2px 0 var\(--ember\)\}/); + expect(pageHtml).not.toMatch(/\.diff-row\.structure-landing \{\n[^}]*background/); + }); + + it("mirrors Diff's no-input block and renders the 360px structure DOM without a layout engine", async () => { + const noInput = await boot({ width: 360, stateResponses: [{ ...state, input: undefined }] }); + byId(noInput.document, "view-structure").click(); + expect(byId(noInput.document, "structure-content").textContent).toBe("Load a source to begin reviewing."); + expect(byId(noInput.document, "structure-content").firstElementChild?.tagName).toBe("P"); + expect(noInput.requests.filter((request) => request.path === "/api/structure")).toHaveLength(0); + + const loaded = await boot({ width: 360 }); + byId(loaded.document, "view-structure").click(); + await flush(); + const panel = byId(loaded.document, "structure-section"); + expect(panel.scrollWidth).toBeLessThanOrEqual(panel.clientWidth); + }); + it("switches Diff and Learning log as stable peer views", async () => { const entry = { id: "entry-view", inputId: source.id, source, selection: { text: "" }, question: "Saved", answer: "Answer", modelId: "model-1", preferences: {}, note: "", reviewLater: false, createdAt: new Date().toISOString() }; const { window, document } = await boot({ entries: [entry] }); @@ -299,6 +734,8 @@ describe("Review Tutor composed page", () => { expect(document.querySelector(".log-entry textarea")).toBe(note); byId(document, "view-log").dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })); + expect(document.activeElement).toBe(byId(document, "view-structure")); + byId(document, "view-structure").dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })); expect(document.activeElement).toBe(byId(document, "view-diff")); expect(byId(document, "diff").hidden).toBe(true); byId(document, "view-diff").dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true })); @@ -816,7 +1253,7 @@ describe("Review Tutor composed page", () => { expect(document.querySelectorAll(".diff-preamble")).toHaveLength(1); expect(document.querySelectorAll(".file")).toHaveLength(2); expect(document.querySelectorAll("#files option")).toHaveLength(2); - expect(byId(document, "totals").textContent).toBe("+2 −1"); + expect(byId(document, "totals").textContent).toBe("+3 −1"); }); it("omits fabricated coordinates for mixed old/new selection and reports count", async () => {