feat: attach desktop file paths as editable learner chips

This commit is contained in:
amosblomqvist
2026-10-28 18:54:57 +02:00
parent 84f93999c5
commit c4e934c9dc
30 changed files with 2331 additions and 59 deletions
+1 -1
View File
@@ -96,7 +96,7 @@ describe("desktop window chrome", () => {
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), context);
assert.equal(bridge.platform, "win32");
assert.deepEqual(Object.keys(bridge).sort(), [
"chatgpt", "checkForUpdates", "expandWindow", "getUpdateState", "getZoom", "installUpdate", "onOpenSettings", "onUpdateStatus", "onZoom", "platform", "subscriptions",
"chatgpt", "checkForUpdates", "expandWindow", "getPathForFile", "getUpdateState", "getZoom", "installUpdate", "onOpenSettings", "onUpdateStatus", "onZoom", "platform", "subscriptions",
]);
assert.deepEqual(Object.keys(bridge.subscriptions as object).sort(), ["cancel", "consent", "disconnect", "login", "onChange", "open", "respond", "retry", "status"]);
});
+89
View File
@@ -0,0 +1,89 @@
import React from "react";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { parseHTML } from "linkedom";
import { FilePathChip, PathText } from "../components/file-path-chip.tsx";
import { quoteFilePath } from "../src/core/file-paths.ts";
function documentFor(node: React.ReactNode) {
return parseHTML(`<html><body>${renderToStaticMarkup(node)}</body></html>`).document;
}
describe("FilePathChip", () => {
it("shows a filename and decorative type icon with an accessible full path", () => {
const path = "/tmp/course notes/α.pdf";
const document = documentFor(<FilePathChip path={path} />);
const chip = document.body.firstElementChild!;
assert.equal(chip.tagName, "SPAN");
assert.equal(chip.textContent, "α.pdf");
assert.equal(chip.getAttribute("title"), path);
assert.equal(chip.getAttribute("role"), "img");
assert.equal(chip.getAttribute("aria-label"), path);
assert.equal(chip.querySelector("svg")!.getAttribute("aria-hidden"), "true");
assert.ok(chip.querySelector(".lucide-file-text"));
assert.equal(document.querySelector("a, button, input, [tabindex]"), null);
assert.equal(chip.getAttributeNames().some((name) => name.toLowerCase() === "contenteditable"), false);
assert.ok(chip.classList.contains("inline-flex"));
assert.ok(chip.querySelector(".truncate"));
});
it("supports atomic editor rendering without requiring an editor in transcripts", () => {
const document = documentFor(<FilePathChip path="C:\\notes\\chapter.txt" contentEditable={false} className="editor-chip" />);
const chip = document.body.firstElementChild!;
// React SSR emits contentEditable; linkedom preserves attribute case.
const editableAttribute = chip.getAttributeNames().find((name) => name.toLowerCase() === "contenteditable")!;
assert.equal(chip.getAttribute(editableAttribute), "false");
assert.ok(chip.classList.contains("editor-chip"));
assert.equal(chip.textContent, "chapter.txt");
});
it("chooses type icons without accessing files or changing their names", () => {
for (const [filename, icon] of [
["figure.PNG", "file-image"], ["lecture.mp3", "file-headphone"], ["clip.mp4", "file-play"],
["course.zip", "file-archive"], ["data.csv", "file-spreadsheet"], ["example.ts", "file-code"],
["notes.md", "file-text"], ["unknown.xyz", "file"],
]) {
const document = documentFor(<FilePathChip path={`/not/real/${filename}`} />);
assert.equal(document.body.textContent, filename);
assert.ok(document.querySelector(`.lucide-${icon}`));
}
});
it("keeps dangerous filename HTML inert in both label and title", () => {
const path = '/tmp/<img src=x onerror="alert(1)">.pdf';
const document = documentFor(<FilePathChip path={path} />);
assert.equal(document.body.textContent, '<img src=x onerror="alert(1)">.pdf');
assert.equal(document.body.firstElementChild!.getAttribute("title"), path);
assert.equal(document.querySelector("img, script, [onerror], a"), null);
});
});
describe("PathText", () => {
it("keeps ordinary text, markup-looking text and inline code completely unchanged", () => {
const text = 'Ordinary <b>prose</b>\n`code` ./relative `file:///tmp/a` & words';
const document = documentFor(<PathText text={text} />);
assert.equal(document.body.textContent, text);
assert.equal(document.body.children.length, 0);
assert.equal(renderToStaticMarkup(<PathText text="" />), "");
});
it("renders all quoted platforms, adjacent words and paths containing backticks", () => {
const paths = ["/tmp/a.pdf", "C:\\notes\\b.md", "\\\\server\\share\\c.png", "~/d`e.txt"];
const text = `before${paths.map(quoteFilePath).join(" then ")}after`;
const document = documentFor(<PathText text={text} />);
const chips = document.querySelectorAll('[role="img"]');
assert.equal(chips.length, paths.length);
assert.deepEqual(Array.from(chips, (chip) => chip.getAttribute("title")), paths);
assert.equal(document.body.textContent, "beforea.pdf then b.md then c.png then d`e.txtafter");
assert.equal(text, `before${paths.map(quoteFilePath).join(" then ")}after`, "presentation never rewrites saved text");
});
it("does not interpret HTML in ordinary text or path labels", () => {
const path = '/tmp/<img src=x onerror="alert(1)">.png';
const document = documentFor(<PathText text={`<script>alert(1)</script> ${quoteFilePath(path)}`} />);
assert.equal(document.querySelector("script, img, [onerror], a"), null);
assert.equal(document.querySelector('[role="img"]')!.getAttribute("title"), path);
assert.equal(document.body.textContent, '<script>alert(1)</script> <img src=x onerror="alert(1)">.png');
});
});
+223
View File
@@ -0,0 +1,223 @@
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<void>) | 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("<html><body><div id='root'></div></body></html>");
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<File, string | Error>();
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<string, PropertyDescriptor | undefined>();
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 <>
<aside data-testid="sidebar">Sidebar</aside>
<FilePathDropZone>
<form onSubmit={(event) => { event.preventDefault(); submits++; }}>
<PathInput data-testid="editor" value={value} onValueChange={(next) => { value = next; draw(); }} onKeyDown={() => { keys++; }} />
<button type="button" data-testid="elsewhere">Elsewhere</button>
<div data-testid="content">Content away from the input</div>
</form>
</FilePathDropZone>
</>;
}
function draw() { root.render(<Harness />); }
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<string, unknown>).lh;
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else delete (globalThis as Record<string, unknown>)[name];
}
};
const editor = doc.querySelector<HTMLElement>('[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.match(f.status, /File paths attached/);
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<HTMLElement>('[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<HTMLElement>('[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, /<SessionsSidebar\s*\/>\s*<FilePathDropZone\b[^>]*>\{children\}<\/FilePathDropZone>/);
assert.doesNotMatch(shell, /<FilePathDropZone\b[^>]*>[\s\S]*<SessionsSidebar/);
});
+93
View File
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { describe, it } from "node:test";
import { runInNewContext } from "node:vm";
interface PathBridge {
platform: string;
getPathForFile(file: unknown): string;
[key: string]: unknown;
}
/** Execute the actual preload in a VM, without Electron, IPC or app startup. */
function fixture(resolve: (file: unknown) => string) {
let bridge!: PathBridge;
const supplied: unknown[] = [];
const sent: unknown[][] = [];
const invoked: unknown[][] = [];
const imports: string[] = [];
const exposed: string[] = [];
const electron = {
contextBridge: { exposeInMainWorld(name: string, surface: PathBridge) { exposed.push(name); bridge = surface; } },
webUtils: { getPathForFile(file: unknown) { supplied.push(file); return resolve(file); } },
ipcRenderer: {
on() {}, removeListener() {},
send: (...args: unknown[]) => { sent.push(args); },
invoke: (...args: unknown[]) => { invoked.push(args); return Promise.resolve(); },
},
};
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), {
process: { platform: "darwin", argv: [] },
require: (name: string) => { imports.push(name); assert.equal(name, "electron"); return electron; },
});
return { bridge, supplied, sent, invoked, imports, exposed, electron };
}
describe("path-only preload bridge", () => {
it("forwards the exact supplied File identity to webUtils, returning only the backing path without IPC", () => {
const file = new File(["synthetic fixture"], "display-name.pdf", { type: "application/pdf" });
let reads = 0;
for (const method of ["text", "arrayBuffer", "stream"] as const) {
Object.defineProperty(file, method, { value: () => { reads++; throw new Error("Must not read file content"); } });
}
const fullPath = "/actual/course/notes with `backticks`.pdf";
const f = fixture((supplied) => { assert.equal(supplied, file); return fullPath; });
assert.deepEqual(f.exposed, ["lh"]);
assert.deepEqual(f.imports, ["electron"]);
assert.equal(f.bridge.getPathForFile(file), fullPath);
assert.equal(f.supplied.length, 1);
assert.equal(f.supplied[0], file);
assert.notEqual(fullPath, file.name);
assert.equal(reads, 0);
assert.deepEqual(f.sent, []);
assert.deepEqual(f.invoked, []);
});
it("keeps Electron's empty result for a File without an OS backing path, never substituting its name", () => {
const virtual = new File(["virtual"], "/misleading/full/path.pdf");
const f = fixture(() => "");
assert.equal(f.bridge.getPathForFile(virtual), "");
assert.equal(f.supplied[0], virtual);
assert.deepEqual(f.sent, []);
assert.deepEqual(f.invoked, []);
});
it("preserves webUtils errors for invalid File arguments instead of inventing a path", () => {
const invalid = { name: "/misleading/filename.pdf", path: "/not/a/capability.pdf" };
const error = new TypeError("Expected a File");
const f = fixture((supplied) => { assert.equal(supplied, invalid); throw error; });
assert.throws(() => f.bridge.getPathForFile(invalid), (caught) => caught === error);
assert.equal(f.supplied[0], invalid);
assert.deepEqual(f.sent, []);
assert.deepEqual(f.invoked, []);
});
it("adds just the narrow path resolver to the explicit surface, without exposing Electron or filesystem APIs", () => {
const f = fixture(() => "");
assert.deepEqual(Object.keys(f.bridge).sort(), [
"chatgpt", "checkForUpdates", "expandWindow", "getPathForFile", "getUpdateState", "getZoom",
"installUpdate", "onOpenSettings", "onUpdateStatus", "onZoom", "platform", "subscriptions",
]);
assert.equal(f.bridge.platform, "darwin");
for (const name of ["webUtils", "ipcRenderer", "electron", "fs", "require", "readFile", "writeFile", "uploadFile", "openPath"]) {
assert.equal(Object.hasOwn(f.bridge, name), false, name);
}
assert.notEqual(f.bridge.getPathForFile, f.electron.webUtils.getPathForFile, "webUtils itself is not exposed");
});
it("retains context isolation, sandbox and disabled Node integration in the production window", () => {
const main = readFileSync(new URL("../electron/main.cjs", import.meta.url), "utf8");
assert.match(main, /webPreferences:\s*\{\s*contextIsolation:\s*true,\s*sandbox:\s*true,\s*nodeIntegration:\s*false,/);
assert.match(main, /preload:\s*path\.join\(__dirname,\s*"preload\.cjs"\)/);
});
});
+127
View File
@@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { filePathLabel, parseFilePaths, quoteFilePath } from "../src/core/file-paths.ts";
function paths(text: string) {
return parseFilePaths(text).flatMap((part) => part.kind === "path" ? [part.path] : []);
}
function assertLossless(text: string) {
const parts = parseFilePaths(text);
assert.equal(parts.map((part) => part.text).join(""), text);
let offset = 0;
for (const part of parts) {
assert.equal(part.start, offset);
assert.equal(text.slice(part.start, part.end), part.text);
offset = part.end;
}
assert.equal(offset, text.length);
}
describe("plain-text file path tokens", () => {
it("returns typed tokens with exact original text and UTF-16 offsets", () => {
const text = "📚 Read `/tmp/my notes.pdf` now.";
assert.deepEqual(parseFilePaths(text), [
{ kind: "text", text: "📚 Read ", start: 0, end: 8 },
{ kind: "path", text: "`/tmp/my notes.pdf`", path: "/tmp/my notes.pdf", start: 8, end: 27 },
{ kind: "text", text: " now.", start: 27, end: 32 },
]);
assert.deepEqual(parseFilePaths(""), []);
});
it("accepts rooted POSIX, drive, UNC and home paths without filesystem access", () => {
for (const path of [
"/tmp/notes.pdf", "/", "/does not exist/α ∂ 🧠.pdf", "//volume/file",
"C:\\Users\\Learner\\My notes.pdf", "d:/notes/測度.md", "C:\\",
"\\\\server\\share\\nested folder\\notes.pdf", "\\\\server\\share", "\\\\server/share/file.txt",
"~/Documents/notes.pdf", "~/", "/tmp/a\\b.txt", "/tmp/<>&\"'[]()#?!;:$*=|.txt",
]) {
const quoted = quoteFilePath(path);
assert.deepEqual(paths(quoted), [path]);
assertLossless(quoted);
}
});
it("recognizes manual and pasted quoted paths anywhere, including adjacent prose", () => {
const text = "word`/a`word (`~/b`) [`C:/c`](x) <`/d`> /not/quoted";
assert.deepEqual(paths(text), ["/a", "~/b", "C:/c", "/d"]);
assertLossless(text);
});
it("does not reinterpret ordinary prose, relative paths, URLs or code", () => {
for (const text of [
"Read /tmp/file.pdf and ~/notes", "`hello world`", "`a + b`", "`notes.pdf`", "`./notes.pdf`",
"`../notes.pdf`", "`C:notes.pdf`", "`~someone/notes.pdf`", "`file:///tmp/notes.pdf`",
"`https://example.test/a.pdf`", "`\\notes.pdf`", "`\\\\server`", "`const p = '/a';`",
"` /leading space`", "``code `/a` here``", "```\n/a\n```", "```\n`/a`\n```",
]) {
assert.deepEqual(paths(text), [], text);
assertLossless(text);
}
});
it("matches complete equal delimiter runs, never their partial boundaries", () => {
for (const text of ["`/a", "``/a`", "`/a``", "```/a``"]) {
assert.deepEqual(paths(text), []);
assertLossless(text);
}
// Other-width runs are legal filename characters, not partial closers.
assert.deepEqual(paths("``/a```/b``"), ["/a```/b"]);
assert.deepEqual(paths("`/a``/b`"), ["/a``/b"]);
assert.deepEqual(paths("``not code closed? `/a`"), ["/a"]);
assert.deepEqual(paths("```/a```"), ["/a"]);
});
it("preserves backticks with variable delimiters and one-space padding", () => {
assert.equal(quoteFilePath("/tmp/a`b.txt"), "``/tmp/a`b.txt``");
assert.equal(quoteFilePath("/tmp/a`"), "`` /tmp/a` ``");
assert.equal(quoteFilePath("/tmp/a```"), "```` /tmp/a``` ````");
for (const path of ["/tmp/a`", "/tmp/a``b`", "/tmp/`/```/``", "/tmp/a `", "/tmp/a ", "/tmp/ "]) {
const text = `before ${quoteFilePath(path)} after`;
assert.deepEqual(paths(text), [path]);
assertLossless(text);
}
assert.deepEqual(paths("`` /tmp/a` ``"), ["/tmp/a`"]);
assert.deepEqual(paths("` /tmp/a `"), ["/tmp/a "]);
assert.deepEqual(paths("` /tmp/a `"), [], "strip one padding space only");
});
it("rejects all C0/C1 controls, multiline paths and Unicode line separators", () => {
const controls = [
...Array.from({ length: 32 }, (_, i) => String.fromCharCode(i)),
...Array.from({ length: 33 }, (_, i) => String.fromCharCode(127 + i)),
"\u2028", "\u2029",
];
for (const control of controls) {
const path = `/tmp/a${control}b.pdf`;
assert.deepEqual(paths(`\`${path}\``), []);
assert.throws(() => quoteFilePath(path), TypeError);
}
for (const path of ["", "relative", "file:///tmp/a", "C:a", "\\\\server"]) {
assert.throws(() => quoteFilePath(path), TypeError);
}
});
it("round-trips many combinations of legal filename punctuation and whitespace", () => {
for (const piece of ["`", "``", " ``` ", " ", " ", "<script>", "[link](x)", "a\\b", "Ω📚"]) {
for (const suffix of ["", "`", "``", " ", ".pdf"]) {
const path = `/tmp/${piece}${suffix}`;
const quoted = quoteFilePath(path);
assert.deepEqual(paths(quoted), [path]);
assertLossless(quoted);
}
}
});
});
describe("filePathLabel", () => {
it("extracts basenames for each platform without normalizing the path", () => {
for (const [path, label] of [
["/tmp/a.pdf", "a.pdf"], ["~/測度 theory.md", "測度 theory.md"],
["C:\\Users\\a.pdf", "a.pdf"], ["C:/Users/a.pdf", "a.pdf"],
["\\\\server\\share\\a.pdf", "a.pdf"], ["/tmp/a\\b.txt", "a\\b.txt"],
["/tmp/folder/", "folder"], ["/", "/"], ["~/", "~/"], ["C:\\", "C:\\"],
["/tmp/a`b", "a`b"],
]) assert.equal(filePathLabel(path!), label);
});
});
+491
View File
@@ -0,0 +1,491 @@
import React, { act } from "react";
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import { parseHTML } from "linkedom";
import { renderToStaticMarkup } from "react-dom/server";
import { PathInput, INSERT_FILE_PATHS_EVENT } from "../components/path-input.tsx";
import {
PATH_CARET_ATTRIBUTE, PATH_SOURCE_ATTRIBUTE, PathInputHistory, adjacentPathRange, atomicPathRange,
insertFilePaths, normalizePathEdit, normalizePathInputValue, pathInputOffset, pathInputPoint, readPathInput,
readPathSelection, renderPathInput, replacePathSelection, restorePathSelection,
} from "../components/path-input-dom.ts";
import { quoteFilePath } from "../src/core/file-paths.ts";
function editor() {
const { document } = parseHTML("<html><body><div id='editor'></div></body></html>");
return document.getElementById("editor")!;
}
test("DOM projection is lossless across whitespace, Unicode and all rooted path forms", () => {
const root = editor();
const paths = ["/a/notes one.pdf", "C:\\a\\notes.pdf", "\\\\host\\share\\sheet.csv", "~/notes`final`.pdf"];
const value = ` 👩🏽‍💻\t中\n${paths.map(quoteFilePath).join("\n\t")}\n \n`;
const hosts = renderPathInput(root, value);
assert.equal(hosts.length, paths.length);
for (const { host } of hosts) {
host.textContent = "abbreviated.pdf";
assert.equal(host.getAttribute("contenteditable"), "false");
}
assert.equal(readPathInput(root), value);
assert.equal(root.dataset.empty, "false");
renderPathInput(root, "");
assert.equal(readPathInput(root), "");
assert.equal(root.dataset.empty, "true");
});
test("logical offsets round-trip across text, adjacent chips, line breaks and reversed selections", () => {
const root = editor();
const value = `🙂A ${quoteFilePath("/one")} ${quoteFilePath("/two")}\nend`;
renderPathInput(root, value);
for (let offset = 0; offset <= value.length; offset++) {
const point = pathInputPoint(root, offset);
const mapped = pathInputOffset(root, point.node, point.offset)!;
const token = Array.from(root.querySelectorAll(`[${PATH_SOURCE_ATTRIBUTE}]`)).find((host) => {
const start = value.indexOf(host.getAttribute(PATH_SOURCE_ATTRIBUTE)!);
return offset > start && offset < start + host.getAttribute(PATH_SOURCE_ATTRIBUTE)!.length;
});
if (!token) assert.equal(mapped, offset, `offset ${offset}`);
else {
const raw = token.getAttribute(PATH_SOURCE_ATTRIBUTE)!;
const start = value.indexOf(raw);
assert.ok(mapped === start || mapped === start + raw.length);
}
}
assert.equal(pathInputOffset(root, root.ownerDocument.body, 0), null);
});
test("BR/block fallback never reads visual labels or normalizes ordinary spaces", () => {
const root = editor();
root.innerHTML = "<div> a </div><div><br></div><div>b</div>";
assert.equal(readPathInput(root), " a \n\nb");
root.innerHTML = "<br>";
assert.equal(readPathInput(root), "");
root.innerHTML = "a<br>b";
assert.equal(readPathInput(root), "a\nb");
});
test("trailing newline caret placeholder is presentation-only and logical end maps before it", () => {
const root = editor();
for (const value of ["\n", "\n\n", "one\n", "one\n\n", "one\n\n\n"]) {
renderPathInput(root, value);
assert.equal(root.querySelectorAll(`[${PATH_CARET_ATTRIBUTE}]`).length, 1);
assert.equal(root.lastElementChild?.tagName, "BR");
assert.equal(root.lastElementChild?.getAttribute("aria-hidden"), "true");
assert.equal(root.textContent, value);
assert.equal(readPathInput(root), value);
const point = pathInputPoint(root, value.length);
assert.equal(point.node, root);
assert.equal(point.offset, root.childNodes.length - 1);
assert.equal(pathInputOffset(root, point.node, point.offset), value.length);
assert.equal(pathInputOffset(root, root.lastChild!, 0), value.length);
}
for (const value of ["", "one", "one\ntwo"]) {
renderPathInput(root, value);
assert.equal(root.querySelector(`[${PATH_CARET_ATTRIBUTE}]`), null);
assert.equal(readPathInput(root), value);
}
});
test("range replacement/deletion is atomic and leaves neighboring whitespace untouched", () => {
const token = quoteFilePath("/course/notes.pdf");
const value = `a ${token} b`;
assert.deepEqual(atomicPathRange(value, { anchor: 5, focus: 1 }), { start: 1, end: 2 + token.length });
assert.equal(replacePathSelection(value, { anchor: 3, focus: 8 }, "").value, "a b");
assert.deepEqual(adjacentPathRange(value, { anchor: 2, focus: 2 }, "forward"), { anchor: 2, focus: 2 + token.length });
assert.deepEqual(adjacentPathRange(value, { anchor: 2 + token.length, focus: 2 + token.length }, "backward"), { anchor: 2, focus: 2 + token.length });
assert.equal(adjacentPathRange(value, { anchor: 1, focus: 1 }, "forward"), null);
});
test("file insertion uses full quoted sources at saved ranges with separating spaces", () => {
const next = insertFilePaths("readthis", { anchor: 4, focus: 4 }, ["/a b.pdf", "C:\\a\\x.txt"]);
assert.equal(next.value, "read `/a b.pdf` `C:\\a\\x.txt` this");
assert.equal(next.selection.anchor, next.value.length - 4);
assert.equal(insertFilePaths("a\n", { anchor: 2, focus: 2 }, ["~/x"]).value, "a\n`~/x`");
assert.throws(() => insertFilePaths("", { anchor: 0, focus: 0 }, ["relative.txt"]), TypeError);
});
test("owned history restores values and directional selections; a new edit discards redo", () => {
const history = new PathInputHistory();
const a = { value: "a", selection: { anchor: 1, focus: 0 } };
const b = { value: "ab", selection: { anchor: 2, focus: 2 } };
history.push(a);
assert.deepEqual(history.undo(b), a);
assert.deepEqual(history.redo(a), b);
history.undo(b);
history.push(a);
assert.equal(history.redo(b), null);
history.reset();
assert.equal(history.undo(b), null);
});
test("textbox exposes accessibility, placeholder, global-drop markers and requested sizing", () => {
const html = renderToStaticMarkup(<PathInput value="" onValueChange={() => {}} placeholder="Ask" aria-label="Message" defaultDropTarget className="min-h-24 max-h-64" />);
for (const pattern of [/role="textbox"/, /aria-multiline="true"/, /aria-label="Message"/, /data-file-path-input=""/, /data-file-path-default=""/, /data-placeholder="Ask"/, /min-h-24 max-h-64/]) assert.match(html, pattern);
});
test("SSR preserves the full escaped initial draft without waiting for client effects", () => {
const draft = " Restored <reasoning> & notes\n`/course/full notes.pdf`\n ";
const html = renderToStaticMarkup(<PathInput value={draft} onValueChange={() => assert.fail("SSR cannot emit changes")} placeholder="Reasoning" />);
const { document } = parseHTML(`<html><body>${html}</body></html>`);
const field = document.querySelector('[data-file-path-input]')!;
assert.equal(field.textContent, draft);
assert.equal(field.getAttribute("contenteditable") ?? field.getAttribute("contentEditable"), "true");
assert.equal(field.getAttribute("data-empty"), "false");
assert.equal(field.querySelector("reasoning"), null);
assert.match(html, /&lt;reasoning&gt;/);
const single = renderToStaticMarkup(<PathInput singleLine value={"one\r\ntwo"} onValueChange={() => {}} />);
assert.equal(parseHTML(`<html><body>${single}</body></html>`).document.body.textContent, "onetwo");
});
test("textbox accessible name falls back to placeholder and preserves explicit aria-label", () => {
const fallback = renderToStaticMarkup(<PathInput value="" onValueChange={() => {}} placeholder="Your reasoning" />);
assert.match(fallback, /aria-label="Your reasoning"/);
const explicit = renderToStaticMarkup(<PathInput value="" onValueChange={() => {}} placeholder="Your reasoning" aria-label="Custom answer" />);
assert.match(explicit, /aria-label="Custom answer"/);
assert.doesNotMatch(explicit, /aria-label="Your reasoning"/);
});
let cleanup: (() => Promise<void>) | undefined;
afterEach(async () => { await cleanup?.(); cleanup = undefined; });
/** linkedom has no Selection/default editing actions. The shim tests our offset
* contract, NOT browser caret navigation or the native IME implementation. */
async function fixture(initial: string, singleLine = false) {
const { window: win, document: doc } = parseHTML("<html><body><div id='root'></div><button id='elsewhere'></button></body></html>");
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 previous = new Map<string, PropertyDescriptor | undefined>();
for (const [name, value] of Object.entries({ React, window: win, document: doc, HTMLElement: win.HTMLElement, Element: win.Element, Event: win.Event, IS_REACT_ACT_ENVIRONMENT: true })) {
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
}
const { createRoot } = await import("react-dom/client");
const reactRoot = createRoot(doc.getElementById("root")!);
const ref = React.createRef<HTMLDivElement>();
let value = initial;
const writes: string[] = [];
let keys = 0;
function draw() {
reactRoot.render(<PathInput ref={ref} singleLine={singleLine} value={value} onValueChange={(next) => { writes.push(next); value = next; draw(); }} onKeyDown={() => { keys++; }} />);
}
await act(async () => { draw(); });
cleanup = async () => {
await act(async () => { reactRoot.unmount(); });
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else delete (globalThis as Record<string, unknown>)[name];
}
};
const root = ref.current!;
root.focus();
function select(anchor: number, focus = anchor) {
restorePathSelection(root, { anchor, focus });
doc.dispatchEvent(new win.Event("selectionchange"));
}
async function event(type: string, properties: Record<string, unknown> = {}) {
const event = new win.Event(type, { bubbles: true, cancelable: true });
Object.assign(event, properties);
await act(async () => { root.dispatchEvent(event); });
return event;
}
return {
root, doc, select, event, writes, selection,
get value() { return value; }, get keys() { return keys; },
async external(next: string) { await act(async () => { value = next; draw(); }); },
async nativeText(next: string, inputType = "insertText") {
await event("beforeinput", { inputType, isComposing: false });
root.replaceChildren(doc.createTextNode(next));
select(next.length);
await event("input", { inputType });
},
};
}
test("native typing, token insertion/deletion and undo/redo share one history", async () => {
const f = await fixture("a");
f.select(1);
await f.nativeText("ab");
await f.event(INSERT_FILE_PATHS_EVENT, { detail: ["/notes.pdf"] });
assert.equal(f.value, "ab `/notes.pdf`");
assert.match(f.root.textContent!, /notes.pdf/);
await f.event("keydown", { key: "Backspace" });
assert.equal(f.value, "ab ");
await f.event("keydown", { key: "z", metaKey: true });
assert.equal(f.value, "ab `/notes.pdf`");
await f.event("keydown", { key: "z", metaKey: true });
assert.equal(f.value, "ab");
await f.event("keydown", { key: "z", metaKey: true });
assert.equal(f.value, "a");
await f.event("beforeinput", { inputType: "historyRedo" });
assert.equal(f.value, "ab");
});
test("immutable SSR fallback never stomps native edits, controlled updates, chips or reset", async () => {
const initial = "Restored draft `/course/original.pdf`";
const f = await fixture(initial);
assert.equal(readPathInput(f.root), initial);
assert.equal(f.root.querySelectorAll(`[${PATH_SOURCE_ATTRIBUTE}]`).length, 1);
f.select(initial.length);
await f.nativeText("New typed draft");
assert.equal(readPathInput(f.root), "New typed draft");
assert.equal(f.root.textContent, "New typed draft");
await f.external("Controlled `/course/replaced.pdf`");
assert.equal(readPathInput(f.root), "Controlled `/course/replaced.pdf`");
assert.equal(f.root.querySelectorAll(`[${PATH_SOURCE_ATTRIBUTE}]`).length, 1);
assert.doesNotMatch(f.root.textContent!, /Restored draft/);
await f.external("");
await f.external(""); // Re-render again with the same initial fallback child prop.
assert.equal(readPathInput(f.root), "");
assert.equal(f.root.textContent, "");
assert.equal(f.root.dataset.empty, "true");
});
test("external dictation append/reset and drop at an unfocused saved selection", async () => {
const f = await fixture("one two");
f.select(3);
f.doc.getElementById("elsewhere")!.focus();
await f.event(INSERT_FILE_PATHS_EVENT, { detail: ["/notes one.pdf"] });
assert.equal(f.value, "one `/notes one.pdf` two");
assert.equal(f.doc.activeElement, f.root);
assert.equal(f.keys, 0, "staging must not submit");
f.select(f.value.length);
await f.external(`${f.value}\nspoken words`);
assert.equal(readPathInput(f.root), f.value);
await f.event("keydown", { key: "z", ctrlKey: true });
assert.equal(f.value, "one `/notes one.pdf` two");
await f.external("");
assert.equal(readPathInput(f.root), "");
await f.event("keydown", { key: "z", ctrlKey: true });
assert.equal(f.value, "");
});
test("plaintext paste ignores HTML and copy/cut use full token source in either direction", async () => {
const source = "read `/deep/course/notes.pdf` now";
const f = await fixture(source);
f.select(source.length - 4, 5);
const clipboard = new Map<string, string>();
const clipboardData = { setData: (type: string, text: string) => clipboard.set(type, text), getData: (type: string) => type === "text/plain" ? "<img src=x onerror=alert(1)>\n中" : "<b>unsafe HTML</b>" };
await f.event("copy", { clipboardData });
assert.equal(clipboard.get("text/plain"), "`/deep/course/notes.pdf`");
await f.event("cut", { clipboardData });
assert.equal(f.value, "read now");
f.select(5);
await f.event("paste", { clipboardData });
assert.equal(f.value, "read <img src=x onerror=alert(1)>\n中 now");
assert.equal(f.root.querySelector("img"), null);
assert.equal(readPathInput(f.root), f.value);
});
test("image/file-only paste prevents native insertion without deleting selected text or chips", async () => {
const source = "plain `/course/notes.pdf` end";
const f = await fixture(source);
const clipboardData = { types: ["Files", "image/png"], getData: () => "" };
for (const [anchor, focus] of [[0, 5], [6, source.length - 4]]) {
f.select(anchor!, focus!);
const paste = await f.event("paste", { clipboardData });
assert.equal(paste.defaultPrevented, true);
assert.equal(f.value, source);
assert.equal(readPathInput(f.root), source);
}
assert.deepEqual(f.writes, []);
assert.equal(f.root.querySelectorAll(`[${PATH_SOURCE_ATTRIBUTE}]`).length, 1);
});
test("empty clipboard text stays a no-op before, during and after IME with a chip selected", async () => {
const source = "a `/course/notes.pdf` z";
const f = await fixture(source);
f.select(2, source.length - 2);
const clipboardData = { getData: () => "", types: ["text/html"] };
await f.event("paste", { clipboardData });
await f.event("compositionstart");
const during = await f.event("paste", { clipboardData });
assert.equal(during.defaultPrevented, true);
assert.equal(readPathInput(f.root), source);
await f.event("compositionend");
assert.equal(readPathInput(f.root), source, "no empty pending paste can remove the selected chip");
await f.event("paste", { clipboardData });
assert.equal(f.value, source);
assert.deepEqual(f.writes, []);
});
test("Shift+Delete delegates to cut, preserving full-source clipboard and deleting only once", async () => {
const f = await fixture("plain`/next.pdf`");
for (const [source, selected] of [["plain`/next.pdf`", "plain"], ["`/course/notes.pdf` `/next.pdf`", "`/course/notes.pdf` "]] as const) {
await f.external(source);
f.select(0, selected.length);
const key = await f.event("keydown", { key: "Delete", shiftKey: true });
assert.equal(key.defaultPrevented, false, "native Cut shortcut must reach the browser");
assert.equal(f.value, source, "keydown alone must not delete the selection");
let copied = "";
const cut = await f.event("cut", { clipboardData: { setData: (type: string, value: string) => { assert.equal(type, "text/plain"); copied = value; } } });
assert.equal(cut.defaultPrevented, true);
assert.equal(copied, selected);
assert.equal(f.value, "`/next.pdf`");
const beforeInput = await f.event("beforeinput", { inputType: "deleteByCut" });
assert.equal(beforeInput.defaultPrevented, true);
assert.equal(f.value, "`/next.pdf`", "a trailing deleteByCut must not delete the neighboring chip");
}
});
test("copying even a partial visual chip label copies its complete source", async () => {
const f = await fixture("read `/deep/course/notes.pdf` now");
const label = f.root.querySelector(`[${PATH_SOURCE_ATTRIBUTE}] [role=img] span`)!.firstChild!;
f.selection.setBaseAndExtent(label, 1, label, 3);
f.doc.dispatchEvent(new window.Event("selectionchange"));
let copied = "";
await f.event("copy", { clipboardData: { setData: (_type: string, value: string) => { copied = value; } } });
assert.equal(copied, "`/deep/course/notes.pdf`");
});
test("composition keeps native DOM untouched, emits once, and suppresses consumer Enter", async () => {
const f = await fixture("hello ");
f.select(6);
await f.event("compositionstart");
f.root.replaceChildren(f.doc.createTextNode("hello 中"));
f.select(7);
await f.event("input", { isComposing: true });
await f.event("keydown", { key: "Enter", keyCode: 229, isComposing: true });
assert.equal(f.writes.length, 0);
assert.equal(f.keys, 0);
assert.equal(readPathInput(f.root), "hello 中");
await f.event("compositionend");
assert.deepEqual(f.writes, ["hello 中"]);
await f.event("keydown", { key: "z", metaKey: true });
assert.equal(f.value, "hello ");
});
test("Enter inserts a literal newline and range deletion spans text and complete chips", async () => {
const f = await fixture("a `/one.pdf` b");
f.select(f.value.length);
await f.event("beforeinput", { inputType: "insertParagraph" });
assert.equal(f.value, "a `/one.pdf` b\n");
f.select(1, f.value.length - 1);
await f.event("keydown", { key: "Delete" });
assert.equal(f.value, "a\n");
assert.equal(readPathInput(f.root), "a\n");
});
test("multiple trailing newlines preserve selection and survive native Backspace plus undo/redo", async () => {
const f = await fixture("one\n\n");
f.select(f.value.length, 1);
assert.deepEqual(readPathSelection(f.root), { anchor: 5, focus: 1 });
f.select(f.value.length);
assert.equal(f.selection.focusNode, f.root);
assert.equal(f.root.lastElementChild?.hasAttribute(PATH_CARET_ATTRIBUTE), true);
// linkedom has no default Backspace action; model the native text deletion.
await f.nativeText("one\n", "deleteContentBackward");
assert.equal(f.value, "one\n");
assert.equal(readPathInput(f.root), f.value);
assert.equal(f.root.querySelectorAll(`[${PATH_CARET_ATTRIBUTE}]`).length, 1);
await f.event("keydown", { key: "z", metaKey: true });
assert.equal(f.value, "one\n\n");
assert.deepEqual(readPathSelection(f.root), { anchor: 5, focus: 5 });
assert.equal(f.root.querySelectorAll(`[${PATH_CARET_ATTRIBUTE}]`).length, 1);
await f.event("keydown", { key: "z", metaKey: true, shiftKey: true });
assert.equal(f.value, "one\n");
assert.deepEqual(readPathSelection(f.root), { anchor: 4, focus: 4 });
await f.external("");
assert.equal(f.root.dataset.empty, "true");
assert.equal(f.root.querySelector(`[${PATH_CARET_ATTRIBUTE}]`), null);
});
test("single-line mode never adds a trailing newline caret placeholder", async () => {
const f = await fixture("one\n\n", true);
assert.equal(readPathInput(f.root), "one");
assert.equal(f.root.querySelector(`[${PATH_CARET_ATTRIBUTE}]`), null);
});
test("paste during IME stays plaintext and is deferred until composition finishes", async () => {
const f = await fixture("");
f.select(0);
await f.event("compositionstart");
f.root.replaceChildren(f.doc.createTextNode("中"));
f.select(1);
await f.event("input", { isComposing: true });
assert.equal(f.root.dataset.empty, "false", "placeholder hides without normalizing the composing DOM");
const paste = await f.event("paste", { clipboardData: { getData: () => "<b>plain</b>" } });
assert.equal(paste.defaultPrevented, true);
assert.equal(readPathInput(f.root), "中");
assert.equal(f.writes.length, 0);
await f.event("compositionend");
assert.equal(f.value, "中<b>plain</b>");
assert.equal(f.root.querySelector("b"), null);
});
test("single-line normalization strips only CR/LF and remaps directional UTF-16 selections", () => {
const source = "🙂\r\n中\t x\ny\r";
assert.equal(normalizePathInputValue(source, false), source);
assert.equal(normalizePathInputValue(source, true), "🙂中\t xy");
assert.deepEqual(normalizePathEdit({ value: source, selection: { anchor: source.length, focus: 4 } }, true), {
value: "🙂中\t xy", selection: { anchor: 7, focus: 2 },
});
});
test("single-line mode exposes native input accessibility and horizontal, nonwrapping layout", () => {
const html = renderToStaticMarkup(<PathInput value="" onValueChange={() => {}} singleLine className="h-8" />);
assert.match(html, /aria-multiline="false"/);
assert.match(html, /whitespace-pre /);
assert.match(html, /overflow-x-auto/);
assert.match(html, /overflow-y-hidden/);
assert.doesNotMatch(html, /whitespace-pre-wrap/);
assert.match(html, /h-8/);
});
test("single-line initial/external assignment sanitizes silently; native edits and paste emit sanitized values", async () => {
const f = await fixture("one\r\ntwo\n", true);
assert.equal(readPathInput(f.root), "onetwo");
assert.deepEqual(f.writes, []);
await f.external("hello\r\nworld");
assert.equal(readPathInput(f.root), "helloworld");
assert.deepEqual(f.writes, []);
f.select(10);
await f.nativeText("ab\r\nc");
assert.equal(f.value, "abc");
assert.equal(readPathInput(f.root), "abc");
assert.equal(pathInputOffset(f.root, f.selection.focusNode!, f.selection.focusOffset), 3);
await f.event("paste", { clipboardData: { getData: () => "x\r\n y\n" } });
assert.equal(f.value, "abcx y");
assert.deepEqual(f.writes, ["abc", "abcx y"]);
});
test("single-line Enter reaches its consumer but cannot insert a newline", async () => {
const f = await fixture("answer", true);
f.select(6);
await f.event("keydown", { key: "Enter", shiftKey: true });
assert.equal(f.keys, 1);
const paragraph = await f.event("beforeinput", { inputType: "insertParagraph" });
const lineBreak = await f.event("beforeinput", { inputType: "insertLineBreak" });
assert.equal(paragraph.defaultPrevented, true);
assert.equal(lineBreak.defaultPrevented, true);
assert.equal(f.value, "answer");
assert.deepEqual(f.writes, []);
await f.event(INSERT_FILE_PATHS_EVENT, { detail: ["/course/notes.pdf"] });
assert.equal(f.value, "answer `/course/notes.pdf`");
});
test("single-line IME sanitizes after completion, and queued external assignments do not synthesize changes", async () => {
const f = await fixture("a", true);
f.select(1);
await f.event("compositionstart");
f.root.replaceChildren(f.doc.createTextNode("a中\r\n文"));
f.select(5);
await f.event("input", { isComposing: true });
assert.deepEqual(f.writes, []);
await f.event("compositionend");
assert.equal(f.value, "a中文");
assert.equal(readPathInput(f.root), "a中文");
await f.event("compositionstart");
await f.external("external\r\nvalue");
assert.equal(readPathInput(f.root), "a中文", "assignment waits for native composition to finish");
await f.event("compositionend");
assert.equal(readPathInput(f.root), "externalvalue");
assert.deepEqual(f.writes, ["a中文"]);
});
+1 -1
View File
@@ -96,7 +96,7 @@ describe("question submission draft restoration", () => {
it("restores an ask's custom answer and a free-text answer", () => {
const custom = renderToStaticMarkup(<QuestionDock toolName="ask_user_question" toolCallId="ask1" input={{ question: "Fixture?", options: [{ label: "Listed answer" }, { label: "Another answer" }], multiSelect: true }} expanded onExpand={() => {}} onSubmit={() => {}} initialDraft={draft} />);
assert.ok(custom.includes('value="preserved custom answer"'));
assert.match(custom, /role="textbox"[^>]*aria-multiline="false"[^>]*>preserved custom answer<\/div>/);
assert.match(custom, /aria-checked="true" data-testid="other-option"/);
const text = renderToStaticMarkup(<QuestionDock toolName="ask_user_question" toolCallId="ask2" input={{ question: "Fixture?" }} expanded onExpand={() => {}} onSubmit={() => {}} initialDraft={draft} />);
assert.ok(text.includes("preserved free-text answer"));
+1 -1
View File
@@ -100,7 +100,7 @@ describe("DraftShell — the sessionless surface", () => {
const html = renderToStaticMarkup(<DraftShell busy={false} text="keep all my text" onChange={() => {}} onSend={() => {}} startError />);
assert.ok(html.includes('role="alert"'));
assert.ok(html.includes(OPENING_PROMPT_FAILURE));
assert.match(html, /<textarea[^>]*>keep all my text<\/textarea>/);
assert.match(html, /<div[^>]*role="textbox"[^>]*contentEditable="true"[^>]*>keep all my text<\/div>/);
});
it("renders the composer blob, the stats bar, and the fresh-chat empty state", () => {
const html = renderToStaticMarkup(
+2 -1
View File
@@ -81,13 +81,14 @@ describe("sessionFindRanges — the displayed-text DOM seam", () => {
assert.ok((ranges[0]!.startContainer as Text).data.includes("session"));
});
it("hidden subtrees never match: hidden attr, menus, katex MathML duplicate, textareas (display:none is browser-verified)", () => {
it("hidden subtrees never match: hidden attr, menus, katex MathML duplicate, draft editors (display:none is browser-verified)", () => {
mount(`<div data-session-find-root="">
<p>needle visible</p>
<p hidden>needle hidden-attr</p>
<div data-lh-menu><p>needle menu</p></div>
<span class="katex-mathml">needle mathml</span>
<textarea>needle draft</textarea>
<div contenteditable="true" data-file-path-input>needle path draft <span contenteditable="false">notes.pdf</span></div>
</div>`);
const ranges = sessionFindRanges(document.body as unknown as HTMLElement, "needle");
assert.equal(ranges.length, 1);