import React, { act } from "react"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { afterEach, test } from "node:test"; import { parseHTML } from "linkedom"; import { FilePathDropZone } from "../components/file-path-drop-zone.tsx"; import { useDictationTarget } from "../components/dictation.tsx"; import { PathInput } from "../components/path-input.tsx"; import { readPathInput, restorePathSelection } from "../components/path-input-dom.ts"; import { quoteFilePath } from "../src/core/file-paths.ts"; let cleanup: (() => Promise) | undefined; afterEach(async () => { await cleanup?.(); cleanup = undefined; }); /** No native DataTransfer/Selection in linkedom. These shims exercise React's * real bubbling handlers and the editor's logical caret contract, not OS drag. */ async function fixture({ desktop = true, claimed = true } = {}) { const { window: win, document: doc } = parseHTML("
"); let active: Element = doc.body; Object.defineProperty(doc, "activeElement", { get: () => active }); const selection = { anchorNode: null as Node | null, anchorOffset: 0, focusNode: null as Node | null, focusOffset: 0, setBaseAndExtent(anchorNode: Node, anchorOffset: number, focusNode: Node, focusOffset: number) { Object.assign(this, { anchorNode, anchorOffset, focusNode, focusOffset }); }, }; Object.assign(doc, { getSelection: () => selection, oninput: null }); win.HTMLElement.prototype.focus = function () { active = this; }; const resolved: File[] = []; const backingPaths = new Map(); const appends: string[] = []; let byteReads = 0; let uploads = 0; let submits = 0; let keys = 0; let value = "readthis"; const bridge = desktop ? { getPathForFile(file: File) { resolved.push(file); const result = backingPaths.get(file); if (result instanceof Error) throw result; return result ?? ""; } } : undefined; const previous = new Map(); for (const [name, next] of Object.entries({ React, window: win, document: doc, Node: win.Node, HTMLElement: win.HTMLElement, Element: win.Element, Event: win.Event, CustomEvent: win.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true, fetch: () => { uploads++; throw new Error("No network expected for file drops"); }, })) { previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); Object.defineProperty(globalThis, name, { configurable: true, writable: true, value: next }); } const oldBridge = Object.getOwnPropertyDescriptor(win, "lh"); Object.defineProperty(win, "lh", { configurable: true, value: bridge }); const { createRoot } = await import("react-dom/client"); const root = createRoot(doc.getElementById("root")!); function Harness() { useDictationTarget((text) => appends.push(text), claimed); return <>
{ event.preventDefault(); submits++; }}> { value = next; draw(); }} onKeyDown={() => { keys++; }} />
Content away from the input
; } function draw() { root.render(); } await act(async () => { draw(); }); cleanup = async () => { await act(async () => { root.unmount(); }); if (oldBridge) Object.defineProperty(win, "lh", oldBridge); else delete (win as unknown as Record).lh; for (const [name, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, name, descriptor); else delete (globalThis as Record)[name]; } }; const editor = doc.querySelector('[data-testid="editor"]')!; function file(name: string, path?: string | Error) { const result = new File(["synthetic bytes must never be read"], name, { type: "application/pdf" }); for (const method of ["text", "arrayBuffer", "stream"] as const) { Object.defineProperty(result, method, { value: () => { byteReads++; throw new Error("No file content reads"); } }); } if (path !== undefined) backingPaths.set(result, path); return result; } function transfer(files: File[], directories: number[] = [], types = ["Files"]) { return { types, files: Object.assign([...files], { item: (index: number) => files[index] ?? null }), items: files.map((file, index) => ({ kind: "file", type: file.type, getAsFile: () => file, webkitGetAsEntry: () => ({ isDirectory: directories.includes(index), isFile: !directories.includes(index) }) })), dropEffect: "none", }; } async function event(type: string, dataTransfer = transfer([]), target: Element = doc.querySelector('[data-testid="content"]')!) { const event = Object.assign(new win.Event(type, { bubbles: true, cancelable: true }), { dataTransfer }); await act(async () => { target.dispatchEvent(event); }); return event; } return { doc, editor, appends, resolved, file, transfer, event, get value() { return value; }, get status() { return doc.querySelector('[role="status"]')?.textContent ?? ""; }, focusEditor(offset: number) { editor.focus(); restorePathSelection(editor, { anchor: offset, focus: offset }); doc.dispatchEvent(new win.Event("selectionchange")); }, assertPathOnly() { assert.equal(byteReads, 0); assert.equal(uploads, 0); assert.equal(submits, 0); assert.equal(keys, 0); }, }; } test("desktop drop inserts quoted paths at the focused editor caret instead of the dictation claimant", async () => { const f = await fixture(); f.focusEditor(4); const paths = ["/actual/course notes.pdf", "C:\\course\\chapter`one.pdf", "\\\\host\\share\\図.png"]; const files = paths.map((path, index) => f.file(`display-name-${index}.pdf`, path)); const dropped = await f.event("drop", f.transfer(files)); assert.equal(dropped.defaultPrevented, true); assert.equal(f.value, `read ${paths.map(quoteFilePath).join(" ")} this`); assert.equal(readPathInput(f.editor), f.value); assert.equal(f.doc.activeElement, f.editor); assert.deepEqual(f.appends, [], "focused editor wins over the registered question field"); assert.deepEqual(f.resolved, files, "only Electron's full path, never File.name, enters the draft"); assert.equal(f.status, "", "successful file attachment is silent"); f.assertPathOnly(); }); test("a successful drop clears an earlier failure without replacing it with a confirmation bubble", async () => { const f = await fixture(); await f.event("drop", f.transfer([f.file("virtual.pdf")])); assert.match(f.status, /No local file paths were available/); await f.event("drop", f.transfer([f.file("valid.pdf", "/existing/valid.pdf")])); assert.deepEqual(f.appends, ["`/existing/valid.pdf`"]); assert.equal(f.doc.querySelector('[role="status"]'), null); f.assertPathOnly(); }); test("unfocused drop uses the dictation target registry, not the last focused editor", async () => { const f = await fixture(); f.focusEditor(4); f.doc.querySelector('[data-testid="elsewhere"]')!.focus(); const paths = ["~/notes one.pdf", "/tmp/notes`final`"]; await f.event("drop", f.transfer(paths.map((path) => f.file("not-a-physical-path.pdf", path)))); assert.deepEqual(f.appends, [paths.map(quoteFilePath).join(" ")]); assert.equal(f.value, "readthis", "saved editor caret must not override current default target"); f.assertPathOnly(); }); test("browser drop prevents navigation, explains desktop-only local paths and never infers File.name", async () => { const f = await fixture({ desktop: false }); const transfer = f.transfer([f.file("/not/a/backing/path.pdf")]); const enter = await f.event("dragenter", transfer); assert.equal(enter.defaultPrevented, true); assert.match(f.doc.querySelector('[data-testid="file-path-drop-indicator"]')!.textContent!, /desktop app/); const over = await f.event("dragover", transfer); assert.equal(over.defaultPrevented, true); assert.equal(transfer.dropEffect, "copy"); const drop = await f.event("drop", transfer); assert.equal(drop.defaultPrevented, true); assert.match(f.status, /Local file paths can only be attached in the desktop app/); assert.match(f.status, /paste a full path instead/); assert.equal(f.doc.querySelector('[data-testid="file-path-drop-indicator"]'), null); assert.equal(f.value, "readthis"); assert.deepEqual(f.appends, []); assert.deepEqual(f.resolved, []); f.assertPathOnly(); }); test("invalid, virtual and directory items are skipped while valid backing paths stay ordered", async () => { const f = await fixture(); const files = [ f.file("virtual.pdf"), f.file("bad.pdf", "relative.pdf"), f.file("broken.pdf", new Error("not backed")), f.file("folder", "/directory"), f.file("valid.pdf", "/existing/valid.pdf"), f.file("multiline.pdf", "/a\nb.pdf"), f.file("NUL.pdf", "/a\0b.pdf"), f.file("url.pdf", "file:///a.pdf"), ]; await f.event("drop", f.transfer(files, [3])); assert.deepEqual(f.appends, ["`/existing/valid.pdf`"]); assert.equal(f.resolved.includes(files[3]!), false, "directory metadata prevents resolving a folder"); assert.match(f.status, /Some dropped items had no available file path/); f.assertPathOnly(); }); test("a drop with no backing paths or no destination has a truthful notice and stages nothing", async () => { const f = await fixture({ claimed: false }); const missing = await f.event("drop", f.transfer([f.file("virtual.pdf")])); assert.equal(missing.defaultPrevented, true); assert.match(f.status, /No local file paths were available/); await f.event("drop", f.transfer([f.file("valid.pdf", "/existing/valid.pdf")])); assert.match(f.status, /Open a chat to attach file paths/); assert.deepEqual(f.appends, []); assert.equal(f.value, "readthis"); f.assertPathOnly(); }); test("non-file drags stay native; sidebar file drops prevent navigation without attaching", async () => { const f = await fixture(); const plain = f.transfer([], [], ["text/plain"]); for (const type of ["dragenter", "dragover", "drop"]) { assert.equal((await f.event(type, plain)).defaultPrevented, false); } const sidebar = f.doc.querySelector('[data-testid="sidebar"]')!; assert.equal((await f.event("drop", f.transfer([f.file("notes.pdf", "/existing/notes.pdf")]), sidebar)).defaultPrevented, true); assert.deepEqual(f.resolved, []); assert.deepEqual(f.appends, []); assert.equal(f.status, ""); f.assertPathOnly(); }); test("nested enter/leave depth keeps the drop overlay until the zone is left; notice is dismissible", async () => { const f = await fixture({ desktop: false }); const transfer = f.transfer([f.file("notes.pdf")]); await f.event("dragenter", transfer); await f.event("dragenter", transfer); await f.event("dragleave", transfer); assert.ok(f.doc.querySelector('[data-testid="file-path-drop-indicator"]')); await f.event("dragleave", transfer); assert.equal(f.doc.querySelector('[data-testid="file-path-drop-indicator"]'), null); await f.event("drop", transfer); assert.notEqual(f.status, ""); await act(async () => { f.doc.querySelector('[aria-label="Dismiss attachment notice"]')!.click(); }); assert.equal(f.status, ""); f.assertPathOnly(); }); test("production AppShell places the sidebar outside its content-only drop zone", () => { const source = readFileSync(new URL("../components/sidebar.tsx", import.meta.url), "utf8"); const shell = source.slice(source.indexOf("export function AppShell(")); assert.match(shell, /\s*]*>\{children\}<\/FilePathDropZone>/); assert.doesNotMatch(shell, /]*>[\s\S]*