import { Signal } from "@char/aftercare"; import { assertEquals } from "@std/assert"; import { until } from "@berry/util"; import { fill, press, withDom } from "@berry/util/dom.ts"; import { composer, go } from "./composer.tsx"; import type { CommandInfo } from "./conversation.tsx"; import { Scope } from "./ui.ts"; Deno.test("composer drafts survive leaving a view and successful sends retire only the sent text", async (t) => { await withDom(async (window) => { const scopes: Scope[] = []; const errors: string[] = []; const commands = new Signal([]); let send: (text: string, followUp: boolean) => Promise = () => Promise.resolve(); let completeReferences: (prefix: string) => Promise = () => Promise.resolve([]); const open = (route: string, busy = new Signal(false), shellEnabled = new Signal(false)) => { location.hash = `#/${route}`; const scope = new Scope(); scopes.push(scope); const view = composer( scope, route, busy, commands, (text, followUp) => send(text, followUp), (message) => errors.push(message), undefined, (prefix) => completeReferences(prefix), shellEnabled, ); document.body.replaceChildren(view.element); const input = view.element.querySelector("textarea")!; return { ...view, input, edit: (text: string) => fill(input, text), key: (key: string) => void press(input, key), leave() { scope.dispose(); view.element.remove(); }, }; }; try { await t.step( "typed drafts are independent for conversations, branch points and forks", async () => { const routes = ["first", "second", "first@4", "first@5", "first/fork@4"]; for (const route of routes) { const view = open(route); assertEquals(view.input.value, ""); view.edit(` ${route}\nunfinished `); view.leave(); } for (const route of routes) { const view = open(route); assertEquals(view.input.value, ` ${route}\nunfinished `); view.leave(); } await window.happyDOM.waitUntilComplete(); }, ); await t.step("a fresh frontend restores the draft from browser storage", async () => { const view = open("reload"); view.setText("survives reload"); view.leave(); const fresh = await import( new URL("./composer.tsx?reload", import.meta.url).href ) as typeof import("./composer.tsx"); const scope = new Scope(); scopes.push(scope); const restored = fresh.composer( scope, "reload", new Signal(false), commands, () => Promise.resolve(), () => {}, ); document.body.replaceChildren(restored.element); assertEquals(restored.element.querySelector("textarea")!.value, "survives reload"); await window.happyDOM.waitUntilComplete(); }); await t.step( "tree prefills and later edits survive returning to the destination", async () => { const source = open("source"); go({ tid: "prefilled", at: 8, fork: true, text: "original message" }, source.setText); source.leave(); const destination = open("prefilled/fork@8"); assertEquals(destination.input.value, "original message"); destination.leave(); const returned = open("prefilled/fork@8"); assertEquals(returned.input.value, "original message"); returned.edit("edited message"); returned.leave(); assertEquals(open("prefilled/fork@8").input.value, "edited message"); await window.happyDOM.waitUntilComplete(); }, ); await t.step( "references complete at the caret, browse directories and save the draft", async () => { const requested: string[] = []; completeReferences = (prefix) => { requested.push(prefix); return Promise.resolve(prefix === "src/" ? ["src/main.ts", "src/mod.ts"] : ["src/"]); }; const view = open("references"); const suggestions = view.element.querySelector(".suggestions")!; view.edit("read @sr then explain"); view.input.setSelectionRange(8, 8); view.input.click(); await until(() => !suggestions.hidden && suggestions.children.length === 1); view.key("Tab"); assertEquals(view.input.value, "read @src/ then explain"); await until(() => suggestions.children.length === 2); view.key("ArrowDown"); view.key("Enter"); assertEquals(view.input.value, "read @src/mod.ts then explain"); assertEquals(requested, ["sr", "src/"]); view.leave(); assertEquals(open("references").input.value, "read @src/mod.ts then explain"); await window.happyDOM.waitUntilComplete(); }, ); await t.step("leaving a reference clears its suggestions immediately", async () => { completeReferences = () => Promise.resolve(["src/main.ts"]); commands.set([{ name: "example", extension: "test", description: "example" }]); const view = open("leaving-reference"); const suggestions = view.element.querySelector(".suggestions")!; view.edit("@src"); await until(() => !suggestions.hidden); view.edit("@src "); assertEquals(suggestions.hidden, true); view.edit("/ex"); assertEquals(suggestions.querySelector(".command")?.textContent, "/example"); view.edit("@src"); assertEquals(suggestions.hidden, true); await until(() => suggestions.textContent === "@src/main.ts"); view.key("Escape"); assertEquals(suggestions.hidden, true); view.leave(); await window.happyDOM.waitUntilComplete(); }); await t.step("shell entries do not complete references and use shell text", async () => { const requested: string[] = []; completeReferences = (prefix) => { requested.push(prefix); return Promise.resolve(["src/main.ts"]); }; const view = open("shell-references", new Signal(false), new Signal(true)); const suggestions = view.element.querySelector(".suggestions")!; view.edit("!cat @sr"); assertEquals(view.input.classList.contains("shell"), true); view.edit("!!cat @sr"); assertEquals(view.input.classList.contains("shell"), true); // reference lookups are debounced; give a suppressed one time to fire await new Promise((resolve) => setTimeout(resolve, 150)); assertEquals(requested, []); assertEquals(suggestions.hidden, true); view.edit("cat @sr"); assertEquals(view.input.classList.contains("shell"), false); await until(() => !suggestions.hidden); assertEquals(requested, ["sr"]); view.edit("!cat @sr"); assertEquals(suggestions.hidden, true); view.leave(); const returned = open("shell-references", new Signal(false), new Signal(true)); assertEquals(returned.input.value, "!cat @sr"); assertEquals(returned.input.classList.contains("shell"), true); assertEquals(returned.element.querySelector(".suggestions")!.hidden, true); returned.leave(); await window.happyDOM.waitUntilComplete(); }); await t.step("a leading bang without shell still completes references", async () => { completeReferences = () => Promise.resolve(["src/main.ts"]); const view = open("bang-message"); view.edit("!cat @sr"); assertEquals(view.input.classList.contains("shell"), false); await until(() => !view.element.querySelector(".suggestions")!.hidden); view.leave(); await window.happyDOM.waitUntilComplete(); }); await t.step("outdated reference results cannot revive dismissed suggestions", async () => { const pending: ((paths: string[]) => void)[] = []; const asked: string[] = []; completeReferences = (prefix) => { asked.push(prefix); return new Promise((resolve) => pending.push(resolve)); }; const view = open("stale-references"); view.edit("@old"); await until(() => asked.length === 1); view.edit("@new"); await until(() => asked.length === 2); pending[0](["old.ts"]); await Promise.resolve(); assertEquals(view.element.querySelector(".suggestions")!.children.length, 0); view.key("Escape"); pending[1](["new.ts"]); await Promise.resolve(); view.key("Tab"); assertEquals(view.input.value, "@new"); view.edit("@gone"); await until(() => asked.length === 3); view.leave(); pending[2](["gone.ts"]); await Promise.resolve(); assertEquals(view.element.querySelector(".suggestions")!.children.length, 0); await window.happyDOM.waitUntilComplete(); }); await t.step("failed sends retain text and successful retries clear it", async () => { send = () => Promise.reject(new Error("offline")); const view = open("retry"); view.edit(" send me\nplease "); view.key("Enter"); await window.happyDOM.waitUntilComplete(); assertEquals(errors, ["offline"]); view.leave(); const returned = open("retry"); assertEquals(returned.input.value, " send me\nplease "); const sent: string[] = []; send = (text) => { sent.push(text); return Promise.resolve(); }; returned.key("Enter"); await window.happyDOM.waitUntilComplete(); assertEquals(sent, ["send me\nplease"]); assertEquals(returned.input.value, ""); returned.leave(); assertEquals(open("retry").input.value, ""); await window.happyDOM.waitUntilComplete(); }); await t.step( "a pending send cannot erase text typed after sending or duplicate the send", async () => { let finish!: () => void; let sends = 0; send = () => { sends++; return new Promise((resolve) => finish = resolve); }; const view = open("pending"); view.edit("sent text"); view.key("Enter"); view.key("Enter"); view.edit("next message"); finish(); await window.happyDOM.waitUntilComplete(); assertEquals(sends, 1); assertEquals(view.input.value, "next message"); view.leave(); assertEquals(open("pending").input.value, "next message"); await window.happyDOM.waitUntilComplete(); }, ); await t.step( "on a touch screen, enter starts a new line and buttons send, steer and follow up", async () => { Object.defineProperty(window.navigator, "maxTouchPoints", { configurable: true, value: 5, }); const sent: [string, boolean][] = []; send = (text, followUp) => { sent.push([text, followUp]); return Promise.resolve(); }; const busy = new Signal(false); const view = open("touch", busy); const press = (label: string) => ([...view.element.querySelectorAll("button")] as HTMLButtonElement[]) .find((button) => !button.closest("[hidden]") && button.textContent?.trim() === label )! .click(); view.edit("first line"); view.key("Enter"); await window.happyDOM.waitUntilComplete(); assertEquals(sent, []); assertEquals(view.input.value, "first line"); press("send"); await window.happyDOM.waitUntilComplete(); busy.set(true); view.edit("later"); const more = view.element.querySelector( '[aria-label="Other send options"]', )!; const menu = document.getElementById(more.getAttribute("aria-controls")!)!; assertEquals(more.getAttribute("aria-expanded"), "false"); view.input.focus(); more.click(); assertEquals(more.getAttribute("aria-expanded"), "true"); assertEquals(menu.hidden, false); press("follow up"); await window.happyDOM.waitUntilComplete(); assertEquals(more.getAttribute("aria-expanded"), "false"); assertEquals(menu.hidden, true); assertEquals(document.activeElement, view.input); view.edit("now"); press("steer"); await window.happyDOM.waitUntilComplete(); assertEquals(sent, [["first line", false], ["later", true], ["now", false]]); view.leave(); Reflect.deleteProperty(window.navigator, "maxTouchPoints"); }, ); } finally { for (const scope of scopes) scope.dispose(); } }); });