Merge model-visible image processing and desktop file-path attachments
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
/** Offline integration contracts at the provider boundary: real shared image
|
||||
* processing, faux model streams, synthetic rasters, and fake headless I/O.
|
||||
* No real browser, provider, learner data, or ambient credentials are used. */
|
||||
import assert from "node:assert/strict";
|
||||
import * as fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { after, test } from "node:test";
|
||||
import { createCanvas } from "@napi-rs/canvas";
|
||||
import * as photon from "@silvia-odwyer/photon-node";
|
||||
import {
|
||||
createFauxCore,
|
||||
fauxAssistantMessage,
|
||||
fauxText,
|
||||
fauxToolCall,
|
||||
type FauxResponseStep,
|
||||
type ImageContent,
|
||||
type TranscriptContext,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import type { PiEngine } from "../src/agent/models.ts";
|
||||
import type { ModelImageResizeOptions } from "../src/agent/image-processing/index.ts";
|
||||
import type { HeadlessApi } from "../src/agent/subagents/headless.ts";
|
||||
|
||||
// Install storage/auth seams BEFORE importing the agent composition root.
|
||||
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-agent-image-paths-"));
|
||||
const originalEnv = process.env;
|
||||
const originalFetch = globalThis.fetch;
|
||||
fs.mkdirSync(path.join(scratch, "home"));
|
||||
fs.writeFileSync(path.join(scratch, "auth.json"), "{}");
|
||||
process.env = {
|
||||
...(originalEnv.NODE_TEST_CONTEXT ? { NODE_TEST_CONTEXT: originalEnv.NODE_TEST_CONTEXT } : {}),
|
||||
...(originalEnv.NODE_OPTIONS ? { NODE_OPTIONS: originalEnv.NODE_OPTIONS } : {}),
|
||||
NODE_ENV: "test",
|
||||
HOME: path.join(scratch, "home"),
|
||||
LH_DATA_DIR: path.join(scratch, "data"),
|
||||
LH_AUTH_FILE: path.join(scratch, "auth.json"),
|
||||
};
|
||||
globalThis.fetch = async () => { throw new Error("CLOSED: unexpected network request"); };
|
||||
after(() => {
|
||||
process.env = originalEnv;
|
||||
globalThis.fetch = originalFetch;
|
||||
fs.rmSync(scratch, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const { readTool } = await import("../src/agent/tools/read.ts");
|
||||
const { createTeacherAgent, createProberAgent, createPracticeAgent } = await import("../src/agent/teacher-agent.ts");
|
||||
const { runPdfReaderAgent } = await import("../src/agent/subagents/pdf-reader.ts");
|
||||
const { runVisualMaker } = await import("../src/agent/subagents/visual-maker.ts");
|
||||
|
||||
const PDF = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf");
|
||||
const SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="4800" height="120" viewBox="0 0 4800 120"><rect width="4800" height="120" fill="white"/></svg>';
|
||||
const CUSTOM_RESIZE = { maxWidth: 320, maxHeight: 200 };
|
||||
|
||||
function png(width: number, height: number): Buffer {
|
||||
const canvas = createCanvas(width, height);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#336699";
|
||||
context.fillRect(0, 0, width, height);
|
||||
return canvas.toBuffer("image/png");
|
||||
}
|
||||
|
||||
function dimensions(image: ImageContent): { width: number; height: number } {
|
||||
const decoded = photon.PhotonImage.new_from_byteslice(Buffer.from(image.data, "base64"));
|
||||
try {
|
||||
return { width: decoded.get_width(), height: decoded.get_height() };
|
||||
} finally { decoded.free(); }
|
||||
}
|
||||
|
||||
function images(context: TranscriptContext): ImageContent[] {
|
||||
return context.messages.flatMap((message) => Array.isArray(message.content)
|
||||
? message.content.filter((part): part is ImageContent => part.type === "image")
|
||||
: []);
|
||||
}
|
||||
|
||||
function text(context: TranscriptContext): string {
|
||||
return context.messages.map((message) => typeof message.content === "string"
|
||||
? message.content
|
||||
: message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n")).join("\n");
|
||||
}
|
||||
|
||||
function scripted(steps: FauxResponseStep[], resize?: ModelImageResizeOptions, vision = true) {
|
||||
const faux = createFauxCore({ tokenSize: { min: 64, max: 64 } });
|
||||
faux.setResponses(steps);
|
||||
const base = faux.getModel();
|
||||
const model: PiEngine["model"] = {
|
||||
...base,
|
||||
input: vision ? ["text", "image"] : ["text"],
|
||||
...(resize ? { inputLimits: { ...base.inputLimits, images: { ...base.inputLimits?.images, resize } } } : {}),
|
||||
};
|
||||
const requests: TranscriptContext[] = [];
|
||||
const engine: PiEngine = {
|
||||
model,
|
||||
streamFn: (model, context, options) => {
|
||||
// Snapshot the actual provider-facing context, not mutable agent state.
|
||||
requests.push(structuredClone(context));
|
||||
return faux.streamSimple(model, context, options);
|
||||
},
|
||||
};
|
||||
return { engine, requests };
|
||||
}
|
||||
|
||||
function readSteps(file: string): FauxResponseStep[] {
|
||||
return [
|
||||
fauxAssistantMessage([fauxToolCall("read", { path: file }, { id: "read-1" })], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage([fauxText("Inspected the supplied source.")]),
|
||||
];
|
||||
}
|
||||
|
||||
function fakeHeadless(rendered: Buffer | ((page: number) => Buffer)) {
|
||||
const svgCalls: string[] = [];
|
||||
const pdfCalls: number[][] = [];
|
||||
const headless: HeadlessApi = {
|
||||
available: () => true,
|
||||
unavailableReason: () => null,
|
||||
runDemo: async () => { throw new Error("unexpected browser execution"); },
|
||||
renderSvg: async (svg) => {
|
||||
svgCalls.push(svg);
|
||||
return typeof rendered === "function" ? rendered(1) : rendered;
|
||||
},
|
||||
renderPdfPages: async (_file, pages) => {
|
||||
pdfCalls.push([...pages]);
|
||||
return pages.map((page) => ({ page, png: typeof rendered === "function" ? rendered(page) : rendered }));
|
||||
},
|
||||
};
|
||||
return { headless, svgCalls, pdfCalls };
|
||||
}
|
||||
|
||||
function assertProcessedImage(context: TranscriptContext, expected: { width: number; height: number }, raw: Buffer): void {
|
||||
const parts = images(context);
|
||||
assert.equal(parts.length, 1, "exactly one successful image reaches the faux provider");
|
||||
assert.deepEqual(dimensions(parts[0]!), expected);
|
||||
assert.ok(Buffer.byteLength(parts[0]!.data, "utf8") < 4.5 * 1024 * 1024);
|
||||
assert.notEqual(parts[0]!.data, raw.toString("base64"), "the oversized raw raster is never sent");
|
||||
assert.match(text(context), /\[Image: original \d+x\d+, displayed at \d+x\d+\. Multiply coordinates by/);
|
||||
}
|
||||
|
||||
test("shared teacher read detects valid image content under a non-image extension", async () => {
|
||||
const bytes = png(24, 16);
|
||||
const file = path.join(scratch, "lecture-notes.txt");
|
||||
fs.writeFileSync(file, bytes);
|
||||
const { engine, requests } = scripted(readSteps(file));
|
||||
// Pass the actual shared read tool, exercising the teacher's native clone
|
||||
// of read.execute rather than a pre-bound test-only implementation.
|
||||
const agent = createTeacherAgent(undefined, { engine, tools: { read: readTool } });
|
||||
await agent.prompt("Inspect the supplied local file.");
|
||||
assert.equal(requests.length, 2);
|
||||
const visible = requests[1]!;
|
||||
assert.equal(images(visible).length, 1);
|
||||
assert.equal(images(visible)[0]!.mimeType, "image/png");
|
||||
assert.equal(images(visible)[0]!.data, bytes.toString("base64"));
|
||||
assert.deepEqual(dimensions(images(visible)[0]!), { width: 24, height: 16 });
|
||||
assert.match(text(visible), /Read image file \[image\/png\]/);
|
||||
});
|
||||
|
||||
test("shared read omits corrupt recognized images without exposing a raw image fallback", async () => {
|
||||
// Valid signature/IHDR, missing pixel data: sniff succeeds, decode fails.
|
||||
const bytes = png(24, 16).subarray(0, 33);
|
||||
const file = path.join(scratch, "corrupt.png");
|
||||
fs.writeFileSync(file, bytes);
|
||||
const { engine, requests } = scripted(readSteps(file));
|
||||
await createTeacherAgent(undefined, { engine, tools: { read: readTool } }).prompt("Inspect the file.");
|
||||
assert.equal(requests.length, 2);
|
||||
assert.deepEqual(images(requests[1]!), []);
|
||||
assert.match(text(requests[1]!), /Image omitted: could not be resized below the inline image size limit/);
|
||||
assert.ok(!JSON.stringify(requests[1]).includes(bytes.toString("base64")));
|
||||
});
|
||||
|
||||
test("shared read supplies the native nonvision-model note", async () => {
|
||||
const file = path.join(scratch, "nonvision.png");
|
||||
fs.writeFileSync(file, png(24, 16));
|
||||
const { engine, requests } = scripted(readSteps(file), undefined, false);
|
||||
await createTeacherAgent(undefined, { engine, tools: { read: readTool } }).prompt("Inspect the file.");
|
||||
assert.equal(requests.length, 2);
|
||||
assert.match(text(requests[1]!), /\[Current model does not support images\. The image will be omitted from this request\.\]/);
|
||||
});
|
||||
|
||||
test("teacher read binds independent active-model resize limits without mutating the shared tool", async () => {
|
||||
const bytes = png(640, 320);
|
||||
const file = path.join(scratch, "model-bound.png");
|
||||
fs.writeFileSync(file, bytes);
|
||||
const originalExecute = readTool.execute;
|
||||
const a = scripted(readSteps(file), { maxWidth: 96, maxHeight: 64 });
|
||||
const b = scripted(readSteps(file), { maxWidth: 80, maxHeight: 30 });
|
||||
const agentA = createTeacherAgent(undefined, { engine: a.engine, tools: { read: readTool } });
|
||||
const agentB = createTeacherAgent(undefined, { engine: b.engine, tools: { read: readTool } });
|
||||
await agentB.prompt("Inspect with model B.");
|
||||
await agentA.prompt("Inspect with model A.");
|
||||
assertProcessedImage(a.requests[1]!, { width: 96, height: 48 }, bytes);
|
||||
assertProcessedImage(b.requests[1]!, { width: 60, height: 30 }, bytes);
|
||||
assert.equal(readTool.execute, originalExecute);
|
||||
});
|
||||
|
||||
for (const [role, resize, expected] of [
|
||||
["prober", { maxWidth: 96, maxHeight: 64 }, { width: 96, height: 48 }],
|
||||
["practice", { maxWidth: 80, maxHeight: 30 }, { width: 60, height: 30 }],
|
||||
] as const) {
|
||||
test(`${role}'s actual registry retains argument healing and text reads while binding image limits`, async () => {
|
||||
const bytes = png(640, 320);
|
||||
const imageFile = path.join(scratch, `${role}-actual-registry.png`);
|
||||
const textFile = path.join(scratch, `${role}-actual-registry.txt`);
|
||||
fs.writeFileSync(imageFile, bytes);
|
||||
fs.writeFileSync(textFile, "first\nsecond\nthird\nfourth\nfifth");
|
||||
const { engine, requests } = scripted([
|
||||
fauxAssistantMessage([fauxToolCall("read", { path: imageFile }, { id: "registry-image" })], { stopReason: "toolUse" }),
|
||||
// Exact existing heal contract: schema-integer fields encoded as
|
||||
// JSON number strings heal before validation. The path stays intact.
|
||||
fauxAssistantMessage([fauxToolCall("read", { path: textFile, offset: "2", limit: "2" }, { id: "registry-text" })], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage([fauxText("Inspected the image and selected text lines.")]),
|
||||
], resize);
|
||||
const originalExecute = readTool.execute;
|
||||
const forbidden = () => { throw new Error("unexpected job dispatch or handoff"); };
|
||||
const chatStore = { getChat: () => null };
|
||||
// Only context ports are mocked; no app/store imports or explicit
|
||||
// tool overrides. Job/handoff operations must never execute here.
|
||||
const agent = role === "prober"
|
||||
? createProberAgent({
|
||||
chatId: "offline-read-prober", chatStore,
|
||||
jobStore: new Proxy({}, { get: forbidden }),
|
||||
spawn: forbidden, deliver: forbidden,
|
||||
} as never, { engine })
|
||||
: createPracticeAgent({ chatId: "offline-read-practice", chatStore }, { engine });
|
||||
const names = agent.state.tools.map((tool) => tool.name);
|
||||
assert.ok(names.includes(role === "prober" ? "probe_done" : "problem"), "real role registry was assembled");
|
||||
const boundRead = agent.state.tools.find((tool) => tool.name === "read");
|
||||
assert.ok(boundRead?.prepareArguments, "model binding preserves the registry's prepareArguments wrapper");
|
||||
assert.notEqual(boundRead.execute, originalExecute, "shared read execute is rebound to the active model");
|
||||
assert.deepEqual(boundRead.prepareArguments({ path: textFile, offset: "2", limit: "2" }), { path: textFile, offset: 2, limit: 2 });
|
||||
await agent.prompt("Inspect the local image, then read the selected text lines.");
|
||||
assert.equal(requests.length, 3);
|
||||
assertProcessedImage(requests[1]!, expected, bytes);
|
||||
const selected = requests[2]!.messages.find((message) => message.role === "toolResult" && message.toolCallId === "registry-text");
|
||||
assert.ok(selected && selected.role === "toolResult");
|
||||
assert.equal(selected.isError, false);
|
||||
assert.deepEqual(selected.content, [{ type: "text", text: "second\nthird\n\n[2 more lines in file. Use offset=4 to continue.]" }]);
|
||||
assert.equal(readTool.execute, originalExecute, "role construction did not mutate the shared tool");
|
||||
});
|
||||
}
|
||||
|
||||
test("PDF reader preserves both processed image outputs from multiple render calls in one batch", async () => {
|
||||
const wide = png(4800, 120);
|
||||
const tall = png(120, 4800);
|
||||
const { headless, pdfCalls } = fakeHeadless((page) => page === 1 ? wide : tall);
|
||||
const { engine, requests } = scripted([
|
||||
fauxAssistantMessage([
|
||||
fauxToolCall("pdf_render", { pages: "1", dpi: 150 }, { id: "batch-render-1" }),
|
||||
fauxToolCall("pdf_render", { pages: "2", dpi: 150 }, { id: "batch-render-2" }),
|
||||
], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage([fauxText("Both rendered pages are visible (p. 1, p. 2). Pages consulted: 1, 2")]),
|
||||
]);
|
||||
const result = await runPdfReaderAgent({ path: PDF, question: "Compare the two figures." }, { engine, headless });
|
||||
assert.equal(requests.length, 2);
|
||||
assert.deepEqual(pdfCalls, [[1], [2]]);
|
||||
const visible = requests[1]!;
|
||||
const parts = images(visible);
|
||||
assert.equal(parts.length, 2, "the second render call must not overwrite the first call's images");
|
||||
assert.deepEqual(parts.map(dimensions), [{ width: 2000, height: 50 }, { width: 50, height: 2000 }]);
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
assert.ok(parts[i]!.data.length < 4.5 * 1024 * 1024);
|
||||
assert.notEqual(parts[i]!.data, [wide, tall][i]!.toString("base64"));
|
||||
}
|
||||
assert.deepEqual(visible.messages.slice(-3).map((message) => message.role), ["toolResult", "toolResult", "user"]);
|
||||
const pageMessage = visible.messages.at(-1)!;
|
||||
assert.ok(Array.isArray(pageMessage.content));
|
||||
assert.deepEqual(pageMessage.content.filter((part) => part.type === "text").map((part) => part.text.split("\n")[0]), [
|
||||
"The rendered page(s), in order (p1, p2):", "Page 1", "Page 2",
|
||||
]);
|
||||
assert.deepEqual(result.pagesRead, [1, 2]);
|
||||
assert.deepEqual(result.pagesConsulted, [1, 2]);
|
||||
});
|
||||
|
||||
for (const [label, resize, expected] of [
|
||||
["native defaults", undefined, { width: 2000, height: 50 }],
|
||||
["active-model overrides", CUSTOM_RESIZE, { width: 320, height: 8 }],
|
||||
] as const) {
|
||||
test(`PDF reader sends processed renders using ${label}; failed page is not counted`, async () => {
|
||||
const bytes = png(4800, 120);
|
||||
const { headless, pdfCalls } = fakeHeadless((page) => page === 1 ? bytes : Buffer.from("invalid rendered page"));
|
||||
const { engine, requests } = scripted([
|
||||
fauxAssistantMessage([fauxToolCall("pdf_render", { pages: "1-2", dpi: 150 }, { id: "render-1" })], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage([fauxText("The visible figure is on page 1 (p. 1). Pages consulted: 1")]),
|
||||
], resize);
|
||||
const result = await runPdfReaderAgent({ path: PDF, question: "Inspect the page figure." }, { engine, headless });
|
||||
assert.equal(requests.length, 2);
|
||||
assert.deepEqual(pdfCalls, [[1, 2]]);
|
||||
assertProcessedImage(requests[1]!, expected, bytes);
|
||||
assert.match(text(requests[1]!), /Page 2: \[Image omitted:/);
|
||||
assert.deepEqual(result.pagesRead, [1]);
|
||||
assert.deepEqual(result.pagesConsulted, [1]);
|
||||
assert.match(result.brief, /visible figure/);
|
||||
const visible = requests[1]!.messages;
|
||||
assert.equal(visible.at(-2)!.role, "toolResult");
|
||||
assert.equal(visible.at(-1)!.role, "user", "processed page images ride the user message after tool results");
|
||||
});
|
||||
|
||||
for (const forced of [false, true]) {
|
||||
test(`visual maker ${forced ? "forced-final" : "explicit render_svg"} path processes images using ${label}`, async () => {
|
||||
const bytes = png(4800, 120);
|
||||
const { headless, svgCalls } = fakeHeadless(bytes);
|
||||
const steps: FauxResponseStep[] = forced ? [
|
||||
fauxAssistantMessage([fauxToolCall("write_svg", { svg: SVG }, { id: "write-1" })], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage([fauxText("Premature notes without inspection.")]),
|
||||
fauxAssistantMessage([fauxText("Confirmed after inspecting the final render.")]),
|
||||
] : [
|
||||
fauxAssistantMessage([
|
||||
fauxToolCall("write_svg", { svg: SVG }, { id: "write-1" }),
|
||||
fauxToolCall("render_svg", {}, { id: "render-1" }),
|
||||
], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage([fauxText("Confirmed after inspecting the render.")]),
|
||||
];
|
||||
const { engine, requests } = scripted(steps, resize);
|
||||
const result = await runVisualMaker({ brief: "One precise synthetic figure." }, { engine, headless });
|
||||
assert.equal(requests.length, forced ? 3 : 2);
|
||||
assert.deepEqual(svgCalls, [SVG]);
|
||||
assertProcessedImage(requests.at(-1)!, expected, bytes);
|
||||
assert.equal(result.svg, SVG);
|
||||
assert.match(result.notes!, /^Confirmed after inspecting/);
|
||||
assert.equal(requests.at(-1)!.messages.at(-1)!.role, "user");
|
||||
if (forced) {
|
||||
assert.deepEqual(images(requests[1]!), [], "premature finish did not see an image yet");
|
||||
assert.match(text(requests[2]!), /You finished without looking at a render of your final SVG/);
|
||||
} else {
|
||||
assert.match(text(requests[1]!), /Here is the current render of your SVG/);
|
||||
assert.equal(requests[1]!.messages.at(-2)!.role, "toolResult");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const forced of [false, true]) {
|
||||
test(`visual maker ${forced ? "forced-final" : "explicit render_svg"} processing failure never verifies or delivers the SVG`, async () => {
|
||||
const { headless, svgCalls } = fakeHeadless(Buffer.from("corrupt render"));
|
||||
const steps: FauxResponseStep[] = [
|
||||
fauxAssistantMessage([
|
||||
fauxToolCall("write_svg", { svg: SVG }, { id: "write-1" }),
|
||||
...(!forced ? [fauxToolCall("render_svg", {}, { id: "render-1" })] : []),
|
||||
], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage([fauxText("Unverified claim that the picture is correct.")]),
|
||||
];
|
||||
const { engine, requests } = scripted(steps);
|
||||
const activity: string[] = [];
|
||||
const result = await runVisualMaker({ brief: "One synthetic figure." }, { engine, headless, onActivity: (line) => activity.push(line) });
|
||||
assert.equal(requests.length, 2);
|
||||
assert.ok(requests.every((request) => images(request).length === 0));
|
||||
assert.equal(result.svg, "");
|
||||
assert.match(result.notes!, /^FAILED: inspecting the final SVG failed/);
|
||||
assert.match(result.notes!, /Image omitted:/);
|
||||
assert.ok(!activity.some((line) => line.includes("inspecting")), "a failed image is never marked inspected");
|
||||
assert.equal(svgCalls.length, forced ? 1 : 2, "an explicit processing failure leaves the final-SVG gate unsatisfied");
|
||||
if (!forced) assert.match(text(requests[1]!), /RENDER ERROR.*Image omitted:/);
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import assert from "node:assert/strict";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { after, describe, it } from "node:test";
|
||||
import { createCanvas } from "@napi-rs/canvas";
|
||||
import { Value } from "typebox/value";
|
||||
import { createFauxCore, fauxAssistantMessage, fauxToolCall, type FauxResponseStep, type ToolCall } from "@earendil-works/pi-ai";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -171,7 +172,11 @@ function fakeHeadless(opts: {
|
||||
if (opts.renderFailures && api.svgRenders.length <= opts.renderFailures) {
|
||||
throw new Error("XML parse error");
|
||||
}
|
||||
return Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // PNG magic
|
||||
// The shared native image processor decodes renders, so a signature
|
||||
// alone is no longer a successful fake raster.
|
||||
const canvas = createCanvas(1, 1);
|
||||
canvas.getContext("2d").fillRect(0, 0, 1, 1);
|
||||
return canvas.toBuffer("image/png");
|
||||
},
|
||||
};
|
||||
return api as HeadlessApi & { demoRuns: string[]; svgRenders: string[] };
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
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.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<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/);
|
||||
});
|
||||
@@ -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"\)/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,357 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { createCanvas } from "@napi-rs/canvas";
|
||||
import * as photon from "@silvia-odwyer/photon-node";
|
||||
import {
|
||||
detectSupportedImageMimeType,
|
||||
detectSupportedImageMimeTypeFromFile,
|
||||
processImage,
|
||||
} from "../src/agent/image-processing/index.ts";
|
||||
import { applyExifOrientation } from "../src/agent/image-processing/exif-orientation.mjs";
|
||||
import { resizeImageInProcess } from "../src/agent/image-processing/resize-core.mjs";
|
||||
import { resizeImage } from "../src/agent/image-processing/resize.ts";
|
||||
|
||||
// Synthetic, offline fixtures only. Canvas generates inputs; Photon is the
|
||||
// actual processing implementation and supplies independent encoding oracles.
|
||||
function png(width = 96, height = 96, noise = false): Buffer {
|
||||
const canvas = createCanvas(width, height);
|
||||
const context = canvas.getContext("2d");
|
||||
if (noise) {
|
||||
const pixels = context.createImageData(width, height);
|
||||
let state = 0x12345678;
|
||||
for (let i = 0; i < pixels.data.length; i += 4) {
|
||||
for (let channel = 0; channel < 3; channel++) {
|
||||
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
|
||||
pixels.data[i + channel] = state >>> 24;
|
||||
}
|
||||
pixels.data[i + 3] = 255;
|
||||
}
|
||||
context.putImageData(pixels, 0, 0);
|
||||
} else {
|
||||
context.fillStyle = "#33aadd";
|
||||
context.fillRect(0, 0, width, height);
|
||||
}
|
||||
return canvas.toBuffer("image/png");
|
||||
}
|
||||
|
||||
function encodings(bytes: Uint8Array, width: number, height: number, qualities = [80, 85, 70, 55, 40]) {
|
||||
const image = photon.PhotonImage.new_from_byteslice(bytes);
|
||||
const resized = photon.resize(image, width, height, photon.SamplingFilter.Lanczos3);
|
||||
try {
|
||||
return [
|
||||
{ mimeType: "image/png", data: Buffer.from(resized.get_bytes()).toString("base64") },
|
||||
...qualities.map((quality) => ({ mimeType: "image/jpeg", data: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64") })),
|
||||
];
|
||||
} finally {
|
||||
resized.free();
|
||||
image.free();
|
||||
}
|
||||
}
|
||||
|
||||
function bmp(): Buffer {
|
||||
// A 2x1, uncompressed, 24-bit Windows BMP (padded scanline).
|
||||
const bytes = Buffer.alloc(62);
|
||||
bytes.write("BM");
|
||||
bytes.writeUInt32LE(bytes.length, 2);
|
||||
bytes.writeUInt32LE(54, 10);
|
||||
bytes.writeUInt32LE(40, 14);
|
||||
bytes.writeInt32LE(2, 18);
|
||||
bytes.writeInt32LE(1, 22);
|
||||
bytes.writeUInt16LE(1, 26);
|
||||
bytes.writeUInt16LE(24, 28);
|
||||
bytes.writeUInt32LE(8, 34);
|
||||
bytes.set([0, 0, 255, 0, 255, 0], 54);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function exifJpeg(orientation: number): Buffer {
|
||||
const image = photon.PhotonImage.new_from_byteslice(png(12, 8));
|
||||
let jpeg: Buffer;
|
||||
try {
|
||||
jpeg = Buffer.from(image.get_bytes_jpeg(80));
|
||||
} finally {
|
||||
image.free();
|
||||
}
|
||||
const exif = Buffer.alloc(36);
|
||||
exif.set([0xff, 0xe1, 0, 34]);
|
||||
exif.write("Exif\0\0", 4, "binary");
|
||||
exif.write("II", 10);
|
||||
exif.writeUInt16LE(42, 12);
|
||||
exif.writeUInt32LE(8, 14);
|
||||
exif.writeUInt16LE(1, 18);
|
||||
exif.writeUInt16LE(0x0112, 20);
|
||||
exif.writeUInt16LE(3, 22);
|
||||
exif.writeUInt32LE(1, 24);
|
||||
exif.writeUInt16LE(orientation, 28);
|
||||
return Buffer.concat([jpeg.subarray(0, 2), exif, jpeg.subarray(2)]);
|
||||
}
|
||||
|
||||
test("already-fit image bytes are unchanged, MIME aliases normalize, and transfer leaves input intact", async () => {
|
||||
const bytes = png();
|
||||
const original = Buffer.from(bytes);
|
||||
const result = await processImage(bytes, " Image/PNG; charset=binary ");
|
||||
assert.deepEqual(result, { ok: true, data: bytes.toString("base64"), mimeType: "image/png", hints: [] });
|
||||
assert.deepEqual(bytes, original);
|
||||
const jpeg = exifJpeg(1);
|
||||
assert.deepEqual(await processImage(jpeg, "image/jpg"), { ok: true, data: jpeg.toString("base64"), mimeType: "image/jpeg", hints: [] });
|
||||
});
|
||||
|
||||
test("native default dimensions are 2000x2000 and dimension hints use the native mapping", async () => {
|
||||
const bytes = png(12000, 24);
|
||||
const result = await processImage(bytes, "image/png");
|
||||
assert.ok(result.ok);
|
||||
assert.equal(result.mimeType, "image/png");
|
||||
assert.deepEqual(result.hints, ["[Image: original 12000x24, displayed at 2000x4. Multiply coordinates by 6.00 to map to original image.]"]);
|
||||
const decoded = photon.PhotonImage.new_from_byteslice(Buffer.from(result.data, "base64"));
|
||||
try {
|
||||
assert.equal(decoded.get_width(), 2000);
|
||||
assert.equal(decoded.get_height(), 4);
|
||||
} finally { decoded.free(); }
|
||||
const tall = await resizeImage(png(24, 12000), "image/png");
|
||||
assert.ok(tall);
|
||||
assert.equal(tall.width, 4);
|
||||
assert.equal(tall.height, 2000);
|
||||
});
|
||||
|
||||
test("model dimensions use sequential rounding, not a one-step scale", async () => {
|
||||
const result = await resizeImage(png(301, 503), "image/png", { maxWidth: 101, maxHeight: 100 });
|
||||
assert.ok(result);
|
||||
// 301x503 -> 101x169 -> 60x100.
|
||||
assert.equal(result.width, 60);
|
||||
assert.equal(result.height, 100);
|
||||
assert.equal(result.data, encodings(png(301, 503), 60, 100)[0]!.data);
|
||||
const rounding = await resizeImage(png(50, 77), "image/png", { maxWidth: 10, maxHeight: 10 });
|
||||
assert.ok(rounding);
|
||||
assert.equal(rounding.width, 7); // round(100 / round(77 * 10 / 50)); one-step gives 6.
|
||||
assert.equal(rounding.height, 10);
|
||||
});
|
||||
|
||||
test("native cap is strict and measures base64 bytes, not binary bytes", async () => {
|
||||
const source = png(24, 24);
|
||||
// A valid PNG with ignored trailing bytes: under 4.5 MiB binary, exactly
|
||||
// 4.5 MiB base64. At equality the unchanged fast path must NOT run.
|
||||
const padded = Buffer.alloc((4.5 * 1024 * 1024 / 4) * 3);
|
||||
source.copy(padded);
|
||||
assert.ok(padded.length < 4.5 * 1024 * 1024);
|
||||
assert.equal(padded.toString("base64").length, 4.5 * 1024 * 1024);
|
||||
const exact = await processImage(padded, "image/png");
|
||||
assert.ok(exact.ok);
|
||||
assert.notEqual(exact.data, padded.toString("base64"));
|
||||
assert.ok(exact.data.length < 4.5 * 1024 * 1024);
|
||||
assert.equal(exact.hints.length, 1, "native re-encoding is marked resized even at identical dimensions");
|
||||
const justBelow = padded.subarray(0, padded.length - 3);
|
||||
const below = await processImage(justBelow, "image/png");
|
||||
assert.ok(below.ok);
|
||||
assert.equal(below.data, justBelow.toString("base64"));
|
||||
assert.deepEqual(below.hints, []);
|
||||
});
|
||||
|
||||
test("PNG wins first under cap even when JPEG is smaller", async () => {
|
||||
const bytes = png(96, 96, true);
|
||||
const candidates = encodings(bytes, 95, 95);
|
||||
assert.ok(candidates[0]!.data.length > candidates[1]!.data.length);
|
||||
const result = await resizeImage(bytes, "image/png", { maxWidth: 95, maxBytes: candidates[0]!.data.length + 1 });
|
||||
assert.ok(result);
|
||||
assert.equal(result.mimeType, "image/png");
|
||||
assert.equal(result.data, candidates[0]!.data);
|
||||
});
|
||||
|
||||
test("JPEG quality selection is native first-fit [80,85,70,55,40], not smallest output", async () => {
|
||||
const bytes = png(96, 96, true);
|
||||
const candidates = encodings(bytes, 96, 96);
|
||||
assert.ok(candidates[0]!.data.length > candidates[2]!.data.length);
|
||||
assert.ok(candidates[2]!.data.length > candidates[1]!.data.length);
|
||||
assert.ok(candidates[1]!.data.length > candidates[3]!.data.length);
|
||||
const at80 = await resizeImage(bytes, "image/png", { maxBytes: candidates[1]!.data.length + 1 });
|
||||
assert.ok(at80);
|
||||
assert.equal(at80.data, candidates[1]!.data);
|
||||
// Strict equality excludes 80; 85 is larger, so 70 is the next fit.
|
||||
const at70 = await resizeImage(bytes, "image/png", { maxBytes: candidates[1]!.data.length });
|
||||
assert.ok(at70);
|
||||
assert.equal(at70.data, candidates[3]!.data);
|
||||
assert.equal(at70.width, 96);
|
||||
assert.equal(at70.height, 96);
|
||||
for (const [index, quality] of [[4, 55], [5, 40]] as const) {
|
||||
const candidate = candidates[index]!;
|
||||
const cap = candidate.data.length + 1;
|
||||
assert.ok(candidates.slice(0, index).every((earlier) => earlier.data.length >= cap));
|
||||
const lowerQuality = await resizeImage(bytes, "image/png", { maxBytes: cap });
|
||||
assert.ok(lowerQuality);
|
||||
assert.equal(lowerQuality.data, candidate.data, `native quality ${quality} wins when preceding candidates are over cap`);
|
||||
assert.equal(lowerQuality.width, 96);
|
||||
assert.equal(lowerQuality.height, 96);
|
||||
}
|
||||
const override = await resizeImage(bytes, "image/png", { jpegQuality: 85, maxBytes: candidates[2]!.data.length + 1 });
|
||||
assert.ok(override);
|
||||
assert.equal(override.data, candidates[2]!.data, "caller quality precedes the native remaining quality steps");
|
||||
});
|
||||
|
||||
test("all encodings over cap reduce each dimension by floor(0.75), eventually omitting at 1x1", async () => {
|
||||
const bytes = png(97, 95, true);
|
||||
const full = encodings(bytes, 97, 95);
|
||||
const next = encodings(bytes, 72, 71);
|
||||
const maxBytes = Math.min(...full.map((candidate) => candidate.data.length));
|
||||
const expected = next.find((candidate) => candidate.data.length < maxBytes);
|
||||
assert.ok(expected, "synthetic fixture fits on the first reduced dimensions");
|
||||
const result = await resizeImage(bytes, "image/png", { maxBytes });
|
||||
assert.ok(result);
|
||||
assert.equal(result.width, 72);
|
||||
assert.equal(result.height, 71);
|
||||
assert.equal(result.mimeType, expected.mimeType);
|
||||
assert.equal(result.data, expected.data);
|
||||
assert.equal(await resizeImage(bytes, "image/png", { maxBytes: 1 }), null);
|
||||
const omitted = await processImage(bytes, "image/png", { resizeOptions: { maxBytes: 1 } });
|
||||
assert.deepEqual(omitted, { ok: false, message: "[Image omitted: could not be resized below the inline image size limit.]" });
|
||||
});
|
||||
|
||||
test("BMP converts to PNG and reports the native conversion hint", async () => {
|
||||
const bytes = bmp();
|
||||
assert.equal(detectSupportedImageMimeType(bytes), "image/bmp");
|
||||
const result = await processImage(bytes, "IMAGE/BMP");
|
||||
assert.ok(result.ok);
|
||||
assert.equal(result.mimeType, "image/png");
|
||||
assert.equal(detectSupportedImageMimeType(Buffer.from(result.data, "base64")), "image/png");
|
||||
assert.deepEqual(result.hints, ["[Image converted from image/bmp to image/png.]"]);
|
||||
const decoded = photon.PhotonImage.new_from_byteslice(Buffer.from(result.data, "base64"));
|
||||
try {
|
||||
assert.equal(decoded.get_width(), 2);
|
||||
assert.equal(decoded.get_height(), 1);
|
||||
} finally { decoded.free(); }
|
||||
});
|
||||
|
||||
test("EXIF-oriented dimensions govern resizing, with native unchanged-byte behavior when already fit", async () => {
|
||||
for (const orientation of [2, 3, 4, 5, 6, 7, 8]) {
|
||||
const bytes = exifJpeg(orientation);
|
||||
const untouched = await resizeImage(bytes, "image/jpeg");
|
||||
assert.ok(untouched);
|
||||
assert.equal(untouched.data, bytes.toString("base64"));
|
||||
assert.equal(untouched.originalWidth, orientation >= 5 ? 8 : 12);
|
||||
assert.equal(untouched.originalHeight, orientation >= 5 ? 12 : 8);
|
||||
const resized = await resizeImage(bytes, "image/jpeg", { maxWidth: 6, maxHeight: 6 });
|
||||
assert.ok(resized);
|
||||
assert.equal(resized.width, orientation >= 5 ? 4 : 6);
|
||||
assert.equal(resized.height, orientation >= 5 ? 6 : 4);
|
||||
}
|
||||
});
|
||||
|
||||
test("EXIF mirrors and rotations preserve exact pixel placement for JPEG and both WebP EXIF layouts", () => {
|
||||
const expected = [
|
||||
[1, 2, 3, 4, 5, 6], // unchanged
|
||||
[3, 2, 1, 6, 5, 4], // horizontal mirror
|
||||
[6, 5, 4, 3, 2, 1], // 180 degrees
|
||||
[4, 5, 6, 1, 2, 3], // vertical mirror
|
||||
[1, 4, 2, 5, 3, 6], // transpose
|
||||
[4, 1, 5, 2, 6, 3], // 90 degrees clockwise
|
||||
[6, 3, 5, 2, 4, 1], // transverse
|
||||
[3, 6, 2, 5, 1, 4], // 90 degrees counterclockwise
|
||||
];
|
||||
for (let orientation = 1; orientation <= 8; orientation++) {
|
||||
const jpeg = exifJpeg(orientation);
|
||||
// Only metadata is needed here: decoding is covered by the resize test.
|
||||
const exif = jpeg.subarray(6, 38);
|
||||
for (const metadata of [jpeg, ...[exif, exif.subarray(6)].map((data) => {
|
||||
// Odd-size RIFF chunk before EXIF verifies native even-byte padding.
|
||||
const webp = Buffer.alloc(12 + 10 + 8 + data.length);
|
||||
webp.write("RIFF", 0);
|
||||
webp.writeUInt32LE(webp.length - 8, 4);
|
||||
webp.write("WEBP", 8);
|
||||
webp.write("JUNK", 12);
|
||||
webp.writeUInt32LE(1, 16);
|
||||
webp.write("EXIF", 22);
|
||||
webp.writeUInt32LE(data.length, 26);
|
||||
data.copy(webp, 30);
|
||||
return webp;
|
||||
})]) {
|
||||
const pixels = Uint8Array.from([1, 2, 3, 4, 5, 6].flatMap((value) => [value, 0, 0, 255]));
|
||||
const raw = new photon.PhotonImage(pixels, 3, 2);
|
||||
const oriented = applyExifOrientation(photon, raw, metadata);
|
||||
try {
|
||||
assert.equal(oriented.get_width(), orientation >= 5 ? 2 : 3);
|
||||
assert.equal(oriented.get_height(), orientation >= 5 ? 3 : 2);
|
||||
assert.deepEqual(Array.from(oriented.get_raw_pixels()).filter((_, index) => index % 4 === 0), expected[orientation - 1]);
|
||||
} finally {
|
||||
if (oriented !== raw) oriented.free();
|
||||
raw.free();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("corrupt supported input is omitted, unsupported conversion fails, and explicit autoResizeImages=false retains native bypass", async () => {
|
||||
const bytes = Buffer.from("not an image");
|
||||
assert.deepEqual(await processImage(bytes, "image/png"), { ok: false, message: "[Image omitted: could not be resized below the inline image size limit.]" });
|
||||
assert.deepEqual(await processImage(bytes, "image/bmp"), { ok: false, message: "[Image omitted: could not be converted to a supported inline image format.]" });
|
||||
assert.deepEqual(await processImage(bytes, "image/png", { autoResizeImages: false }), { ok: true, data: bytes.toString("base64"), mimeType: "image/png", hints: [] });
|
||||
const wide = png(12000, 24);
|
||||
const bypass = await processImage(wide, "image/png", { autoResizeImages: false });
|
||||
assert.ok(bypass.ok);
|
||||
assert.equal(bypass.data, wide.toString("base64"));
|
||||
const converted = await processImage(bmp(), "image/bmp", { autoResizeImages: false });
|
||||
assert.ok(converted.ok);
|
||||
assert.equal(converted.mimeType, "image/png");
|
||||
});
|
||||
|
||||
test("MIME sniffing matches native supported signatures and rejects APNG/JPEG-LS/malformed BMP", async () => {
|
||||
assert.equal(detectSupportedImageMimeType(png()), "image/png");
|
||||
assert.equal(detectSupportedImageMimeType(Buffer.from([0xff, 0xd8, 0xff, 0xe0])), "image/jpeg");
|
||||
assert.equal(detectSupportedImageMimeType(Buffer.from([0xff, 0xd8, 0xff, 0xf7])), null);
|
||||
assert.equal(detectSupportedImageMimeType(Buffer.from("GIF89a")), "image/gif");
|
||||
assert.equal(detectSupportedImageMimeType(Buffer.from("GIF87a")), "image/gif");
|
||||
assert.equal(detectSupportedImageMimeType(Buffer.from("RIFFxxxxWEBP")), "image/webp");
|
||||
assert.equal(detectSupportedImageMimeType(Buffer.from("BM")), null);
|
||||
assert.equal(detectSupportedImageMimeType(Buffer.from("not an image")), null);
|
||||
const truncatedPng = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
assert.equal(detectSupportedImageMimeType(truncatedPng), null);
|
||||
// MIME sniffing, like Pi, walks chunk headers without checking CRCs.
|
||||
const source = png(2, 2);
|
||||
const acTL = Buffer.alloc(20);
|
||||
acTL.writeUInt32BE(8, 0);
|
||||
acTL.write("acTL", 4);
|
||||
const apng = Buffer.concat([source.subarray(0, 33), acTL, source.subarray(33)]);
|
||||
assert.equal(detectSupportedImageMimeType(apng), null);
|
||||
const badBmp = bmp();
|
||||
badBmp.writeUInt16LE(2, 26);
|
||||
assert.equal(detectSupportedImageMimeType(badBmp), null);
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "lh-image-processing-"));
|
||||
try {
|
||||
await writeFile(path.join(dir, "renamed.txt"), source);
|
||||
await writeFile(path.join(dir, "animated.png"), apng);
|
||||
assert.equal(await detectSupportedImageMimeTypeFromFile(path.join(dir, "renamed.txt")), "image/png");
|
||||
assert.equal(await detectSupportedImageMimeTypeFromFile(path.join(dir, "animated.png")), null);
|
||||
await assert.rejects(detectSupportedImageMimeTypeFromFile(path.join(dir, "missing")), { code: "ENOENT" });
|
||||
} finally { await rm(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("unbundled Node worker responds with tagged output and matches the in-process fallback", async () => {
|
||||
const bytes = png(96, 96, true);
|
||||
const options = { maxWidth: 64, maxHeight: 64, maxBytes: 4000 };
|
||||
const worker = new Worker(path.join(process.cwd(), "src/agent/image-processing/resize-worker.mjs"));
|
||||
try {
|
||||
const reply = new Promise<{ type: string; result: unknown }>((resolve, reject) => {
|
||||
worker.once("message", resolve);
|
||||
worker.once("error", reject);
|
||||
});
|
||||
worker.postMessage({ inputBytes: new Uint8Array(bytes), mimeType: "image/png", options });
|
||||
assert.deepEqual(await reply, { type: "pi:image-resize-response", result: await resizeImageInProcess(bytes, "image/png", options) });
|
||||
assert.deepEqual(await resizeImage(bytes, "image/png", options), await resizeImageInProcess(bytes, "image/png", options));
|
||||
} finally { await worker.terminate(); }
|
||||
});
|
||||
|
||||
test("missing cwd-relative worker falls back in-process without altering the caller bytes", async () => {
|
||||
const cwd = process.cwd();
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "lh-image-worker-fallback-"));
|
||||
const bytes = png();
|
||||
const options = { maxWidth: 64, maxHeight: 64 };
|
||||
const expected = await resizeImageInProcess(bytes, "image/png", options);
|
||||
try {
|
||||
process.chdir(dir);
|
||||
assert.deepEqual(await resizeImage(bytes, "image/png", options), expected);
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,629 @@
|
||||
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, pathInputStaticRange, 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, /<reasoning>/);
|
||||
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("Cmd+Backspace delegates line extent to beforeinput instead of deleting just the final chip", async () => {
|
||||
const source = "Explain `/tmp/notes.pdf`";
|
||||
const f = await fixture(source);
|
||||
f.select(source.length);
|
||||
const key = await f.event("keydown", { key: "Backspace", metaKey: true });
|
||||
assert.equal(key.defaultPrevented, false);
|
||||
assert.equal(f.value, source);
|
||||
const deletion = await f.event("beforeinput", {
|
||||
inputType: "deleteSoftLineBackward",
|
||||
getTargetRanges: () => [{ startContainer: f.root.firstChild!, startOffset: 0, endContainer: f.root, endOffset: f.root.childNodes.length }],
|
||||
});
|
||||
assert.equal(deletion.defaultPrevented, true);
|
||||
assert.equal(f.value, "");
|
||||
await f.event("keydown", { key: "z", metaKey: true });
|
||||
assert.equal(f.value, source);
|
||||
assert.deepEqual(readPathSelection(f.root), { anchor: source.length, focus: source.length });
|
||||
});
|
||||
|
||||
test("native word and soft/hard line deletion ranges retain mixed text/chip extents in both directions", async () => {
|
||||
const source = "keep old `/tmp/notes.pdf` tail\nnext";
|
||||
const f = await fixture(source);
|
||||
const lineEnd = source.indexOf("\n");
|
||||
const chipEnd = source.indexOf(" tail");
|
||||
const cases = [
|
||||
["deleteWordBackward", 5, lineEnd], ["deleteWordForward", 5, chipEnd],
|
||||
["deleteSoftLineBackward", 0, lineEnd], ["deleteSoftLineForward", 5, lineEnd],
|
||||
["deleteHardLineBackward", 0, chipEnd], ["deleteHardLineForward", 5, lineEnd],
|
||||
] as const;
|
||||
for (const [inputType, start, end] of cases) {
|
||||
await f.external(source);
|
||||
const backward = inputType.endsWith("Backward");
|
||||
f.select(backward ? end : start);
|
||||
const key = await f.event("keydown", { key: backward ? "Backspace" : "Delete", ctrlKey: true });
|
||||
assert.equal(key.defaultPrevented, false, inputType);
|
||||
assert.equal(f.value, source);
|
||||
const a = pathInputPoint(f.root, start);
|
||||
const b = pathInputPoint(f.root, end);
|
||||
const deletion = await f.event("beforeinput", {
|
||||
inputType,
|
||||
getTargetRanges: () => [{ startContainer: a.node, startOffset: a.offset, endContainer: b.node, endOffset: b.offset }],
|
||||
});
|
||||
assert.equal(deletion.defaultPrevented, true, inputType);
|
||||
assert.equal(f.value, source.slice(0, start) + source.slice(end), inputType);
|
||||
assert.deepEqual(readPathSelection(f.root), { anchor: start, focus: start });
|
||||
}
|
||||
});
|
||||
|
||||
test("deleteEntireSoftLine honors the browser extent and never substitutes a chip when ranges are absent", async () => {
|
||||
const source = "before\nExplain `/tmp/notes.pdf` tail\nafter";
|
||||
const f = await fixture(source);
|
||||
const start = source.indexOf("Explain");
|
||||
const end = source.indexOf("\nafter");
|
||||
f.select(source.indexOf("`")); // Adjacent to a chip, but not a chip-only command.
|
||||
for (const getTargetRanges of [undefined, () => []]) {
|
||||
const missing = await f.event("beforeinput", { inputType: "deleteEntireSoftLine", getTargetRanges });
|
||||
assert.equal(missing.defaultPrevented, false);
|
||||
assert.equal(f.value, source);
|
||||
}
|
||||
const a = pathInputPoint(f.root, start);
|
||||
const b = pathInputPoint(f.root, end);
|
||||
const deletion = await f.event("beforeinput", {
|
||||
inputType: "deleteEntireSoftLine",
|
||||
getTargetRanges: () => [{ startContainer: a.node, startOffset: a.offset, endContainer: b.node, endOffset: b.offset }],
|
||||
});
|
||||
assert.equal(deletion.defaultPrevented, true);
|
||||
assert.equal(f.value, "before\n\nafter");
|
||||
assert.deepEqual(readPathSelection(f.root), { anchor: start, focus: start });
|
||||
});
|
||||
|
||||
test("browser target ranges that intersect a visual chip interior expand the complete source atomically", async () => {
|
||||
const source = "keep old `/tmp/notes.pdf` tail";
|
||||
const f = await fixture(source);
|
||||
f.select(5);
|
||||
const label = f.root.querySelector(`[${PATH_SOURCE_ATTRIBUTE}] [role=img] span`)!.firstChild!;
|
||||
const start = pathInputPoint(f.root, 5);
|
||||
const target = { startContainer: start.node, startOffset: start.offset, endContainer: label, endOffset: 2 };
|
||||
const mapped = pathInputStaticRange(f.root, target)!;
|
||||
assert.ok(mapped.focus > source.indexOf("`"));
|
||||
assert.ok(mapped.focus < source.lastIndexOf("`") + 1);
|
||||
await f.event("beforeinput", { inputType: "deleteWordForward", getTargetRanges: () => [target] });
|
||||
assert.equal(f.value, "keep tail");
|
||||
assert.equal(f.root.querySelector(`[${PATH_SOURCE_ATTRIBUTE}]`), null);
|
||||
});
|
||||
|
||||
test("missing or invalid word/line target ranges defer to native editing and preserve owned undo", async () => {
|
||||
const source = "Explain `/tmp/notes.pdf`";
|
||||
const f = await fixture(source);
|
||||
f.select(source.length);
|
||||
for (const modifier of ["metaKey", "ctrlKey", "altKey"]) {
|
||||
for (const key of ["Backspace", "Delete"]) {
|
||||
const event = await f.event("keydown", { key, [modifier]: true });
|
||||
assert.equal(event.defaultPrevented, false);
|
||||
assert.equal(f.value, source);
|
||||
}
|
||||
}
|
||||
for (const inputType of ["deleteWordBackward", "deleteWordForward", "deleteSoftLineBackward", "deleteSoftLineForward", "deleteHardLineBackward", "deleteHardLineForward"]) {
|
||||
for (const getTargetRanges of [undefined, () => []]) {
|
||||
const beforeInput = await f.event("beforeinput", { inputType, getTargetRanges });
|
||||
assert.equal(beforeInput.defaultPrevented, false, `${inputType} must not fall through to adjacent-chip deletion`);
|
||||
assert.equal(f.value, source);
|
||||
}
|
||||
}
|
||||
const valid = { startContainer: f.root, startOffset: 0, endContainer: f.root, endOffset: f.root.childNodes.length };
|
||||
for (const getTargetRanges of [
|
||||
undefined, () => [], () => [{ ...valid, startContainer: f.doc.body }],
|
||||
() => [{ ...valid, startOffset: -1 }], () => [{ ...valid, endOffset: 999 }],
|
||||
() => [{ ...valid, endOffset: 0 }], () => [valid, valid], () => { throw new Error("not available"); },
|
||||
]) {
|
||||
const beforeInput = await f.event("beforeinput", { inputType: "deleteWordBackward", getTargetRanges });
|
||||
assert.equal(beforeInput.defaultPrevented, false);
|
||||
assert.equal(f.value, source);
|
||||
}
|
||||
// With no usable target ranges the browser edits the DOM, then input projects it.
|
||||
await f.nativeText("", "deleteHardLineBackward");
|
||||
assert.equal(f.value, "");
|
||||
await f.event("keydown", { key: "z", metaKey: true });
|
||||
assert.equal(f.value, source);
|
||||
});
|
||||
|
||||
test("ordinary backward/forward chip deletion and selected ranges retain their existing behavior", async () => {
|
||||
const source = "a `/tmp/notes.pdf` b";
|
||||
const f = await fixture(source);
|
||||
f.select(source.length - 2);
|
||||
const backspace = await f.event("keydown", { key: "Backspace" });
|
||||
assert.equal(backspace.defaultPrevented, true);
|
||||
assert.equal(f.value, "a b");
|
||||
await f.external(source);
|
||||
f.select(2);
|
||||
const forward = await f.event("keydown", { key: "Delete" });
|
||||
assert.equal(forward.defaultPrevented, true);
|
||||
assert.equal(f.value, "a b");
|
||||
await f.external(source);
|
||||
f.select(source.length, 0);
|
||||
const selected = await f.event("keydown", { key: "Backspace" });
|
||||
assert.equal(selected.defaultPrevented, true);
|
||||
assert.equal(f.value, "");
|
||||
});
|
||||
|
||||
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中文"]);
|
||||
});
|
||||
@@ -8,9 +8,10 @@
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import { realHeadless } from "../src/agent/subagents/headless.ts";
|
||||
import { MAX_PDF_CANVAS_PIXELS, MAX_PDF_CANVAS_SIDE, pdfCanvasGeometry, realHeadless } from "../src/agent/subagents/headless.ts";
|
||||
import { standardFontsDir } from "../src/agent/subagents/pdf-ops.ts";
|
||||
|
||||
const FIXTURE = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf");
|
||||
@@ -31,6 +32,83 @@ function pngFacts(buf: Buffer): { w: number; h: number; idats: number } {
|
||||
return { w, h, idats };
|
||||
}
|
||||
|
||||
/** Synthetic page only — no personal PDF or external fixture generator. */
|
||||
function oversizedPdf(): Buffer {
|
||||
const content = "0 0 1 rg 0 0 20000 10000 re f\n";
|
||||
const objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 20000 10000] /Rotate 90 /Resources << >> /Contents 4 0 R >>",
|
||||
`<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}endstream`,
|
||||
];
|
||||
let pdf = "%PDF-1.7\n";
|
||||
const offsets = [0];
|
||||
for (const [index, object] of objects.entries()) {
|
||||
offsets.push(Buffer.byteLength(pdf));
|
||||
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
||||
}
|
||||
const xref = Buffer.byteLength(pdf);
|
||||
pdf += `xref\n0 ${offsets.length}\n0000000000 65535 f \n`;
|
||||
for (const offset of offsets.slice(1)) pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
|
||||
pdf += `trailer\n<< /Size ${offsets.length} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
|
||||
return Buffer.from(pdf);
|
||||
}
|
||||
|
||||
describe("PDF pre-allocation canvas geometry", () => {
|
||||
it("uses decimal 16 megapixels and an 8192-pixel side limit", () => {
|
||||
assert.equal(MAX_PDF_CANVAS_PIXELS, 16_000_000);
|
||||
assert.equal(MAX_PDF_CANVAS_SIDE, 8192);
|
||||
});
|
||||
|
||||
it("preserves ordinary requested DPI, including fractional ceil dimensions and low DPI", () => {
|
||||
for (const dpi of [11, 72, 144, 150, 300]) {
|
||||
const geometry = pdfCanvasGeometry(595.28, 841.89, dpi);
|
||||
assert.deepEqual(geometry, {
|
||||
scale: dpi / 72,
|
||||
width: Math.ceil(595.28 * dpi / 72),
|
||||
height: Math.ceil(841.89 * dpi / 72),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds extreme aspect ratios, huge geometry, sides, and rounded pixel area without upscaling", () => {
|
||||
const cases: readonly (readonly [number, number, number])[] = [
|
||||
[1e12, 0.000001, 300],
|
||||
[0.000001, 1e12, 300],
|
||||
[1e308, 1e308, Number.MAX_VALUE],
|
||||
[8192.000001, 10, 72],
|
||||
[6100, 3900, 72], // ceil at the area-limited scale exceeds 16MP
|
||||
[4000.000001, 4000.000001, 72],
|
||||
[4000, 4000, 72], // exactly 16MP stays unchanged
|
||||
];
|
||||
for (const [width, height, dpi] of cases) {
|
||||
const result = pdfCanvasGeometry(width, height, dpi);
|
||||
assert.ok(result.scale > 0 && result.scale <= dpi / 72);
|
||||
assert.equal(result.width, Math.max(1, Math.ceil(width * result.scale)));
|
||||
assert.equal(result.height, Math.max(1, Math.ceil(height * result.scale)));
|
||||
assert.ok(result.width <= MAX_PDF_CANVAS_SIDE && result.height <= MAX_PDF_CANVAS_SIDE);
|
||||
assert.ok(result.width * result.height <= MAX_PDF_CANVAS_PIXELS);
|
||||
}
|
||||
assert.deepEqual(pdfCanvasGeometry(4000, 4000, 72), { scale: 1, width: 4000, height: 4000 });
|
||||
});
|
||||
|
||||
it("uses rotated viewport dimensions symmetrically", () => {
|
||||
const normal = pdfCanvasGeometry(20000, 10000, 144);
|
||||
const rotated = pdfCanvasGeometry(10000, 20000, 144);
|
||||
assert.equal(rotated.scale, normal.scale);
|
||||
assert.equal(rotated.width, normal.height);
|
||||
assert.equal(rotated.height, normal.width);
|
||||
});
|
||||
|
||||
it("rejects nonfinite or nonpositive dimensions and DPI cleanly", () => {
|
||||
for (const invalid of [0, -1, NaN, Infinity, -Infinity]) {
|
||||
assert.throws(() => pdfCanvasGeometry(invalid, 792, 72), /dimensions must be finite and positive/);
|
||||
assert.throws(() => pdfCanvasGeometry(612, invalid, 72), /dimensions must be finite and positive/);
|
||||
assert.throws(() => pdfCanvasGeometry(612, 792, invalid), /DPI must be finite and positive/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("native pdf_render (pdfjs paints, @napi-rs/canvas hosts)", () => {
|
||||
it("the shared font directory ends in pdfjs's required slash on every OS", () => {
|
||||
const fonts = standardFontsDir();
|
||||
@@ -63,6 +141,32 @@ describe("native pdf_render (pdfjs paints, @napi-rs/canvas hosts)", () => {
|
||||
assert.ok(Math.abs(fb.h - 2 * fa.h) <= 2, `144dpi height ≈ 2× 72dpi (${fa.h} → ${fb.h})`);
|
||||
});
|
||||
|
||||
it("bounds a synthetic oversized rotated PDF before native canvas allocation", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lh-pdf-canvas-test-"));
|
||||
try {
|
||||
const fixture = path.join(dir, "oversized.pdf");
|
||||
fs.writeFileSync(fixture, oversizedPdf());
|
||||
const [page] = await realHeadless.renderPdfPages(fixture, [1], { dpi: 144 });
|
||||
assert.ok(page);
|
||||
const facts = pngFacts(page.png);
|
||||
const expected = pdfCanvasGeometry(10000, 20000, 144);
|
||||
assert.equal(facts.w, expected.width);
|
||||
assert.equal(facts.h, expected.height);
|
||||
assert.ok(facts.h > facts.w, "PDF rotation is included before scaling");
|
||||
assert.ok(facts.w <= 8192 && facts.h <= 8192);
|
||||
assert.ok(facts.w * facts.h <= 16_000_000);
|
||||
assert.equal(facts.idats, 1, "raw renderer still returns canonical PNG");
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid DPI before opening the PDF", async () => {
|
||||
for (const dpi of [0, -1, NaN, Infinity, -Infinity, Number.MIN_VALUE]) {
|
||||
await assert.rejects(realHeadless.renderPdfPages("unused.pdf", [1], { dpi }), /DPI must be finite and positive/);
|
||||
}
|
||||
});
|
||||
|
||||
it("skips out-of-range page numbers silently", async () => {
|
||||
const pages = await realHeadless.renderPdfPages(FIXTURE, [0, 2, 99], { dpi: 72 });
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -45,6 +45,9 @@ describe("standalone JavaScript tracing exclusions and native workaround policy"
|
||||
"node_modules/pdfjs-dist/legacy/**/*",
|
||||
"node_modules/pdfjs-dist/standard_fonts/**/*",
|
||||
"node_modules/@napi-rs/canvas-*/**/*",
|
||||
"src/agent/image-processing/*.mjs",
|
||||
"src/agent/image-processing/LICENSE",
|
||||
"node_modules/@silvia-odwyer/photon-node/**/*",
|
||||
"node_modules/@resvg/resvg-js-*/**/*",
|
||||
"node_modules/@earendil-works/pi-ai/dist/providers/data/**/*",
|
||||
"node_modules/@earendil-works/pi-ai/dist/auth/oauth/{load,github-copilot,xai,device-code}.js",
|
||||
|
||||
@@ -10,10 +10,12 @@ import assert from "node:assert/strict";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import { after, describe, it } from "node:test";
|
||||
import { createCanvas } from "@napi-rs/canvas";
|
||||
import { readTool } from "../src/agent/tools/read.ts";
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "lh-read-test-"));
|
||||
after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
function write(name: string, content: string): string {
|
||||
const file = path.join(tmp, name);
|
||||
@@ -100,8 +102,10 @@ describe("read tool — text semantics", () => {
|
||||
describe("read tool — image semantics (native content parts)", () => {
|
||||
it("returns native text + image parts for image files, details null (output-less part)", async () => {
|
||||
const file = path.join(tmp, "dot.png");
|
||||
// minimal valid PNG header bytes are enough — detection is by extension
|
||||
fs.writeFileSync(file, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
||||
// Detection now sniffs bytes and the native processor decodes the image.
|
||||
const canvas = createCanvas(1, 1);
|
||||
canvas.getContext("2d").fillRect(0, 0, 1, 1);
|
||||
fs.writeFileSync(file, canvas.toBuffer("image/png"));
|
||||
const result = await readTool.execute!("test", { path: file });
|
||||
assert.equal(result.content.length, 2);
|
||||
const parts = result.content as Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user