Files
OpenResearch/ui/tests/inlineHtmlFigure.test.mjs
T
devin-ai-integration[bot] 2c45ff3183 Fix inline chat images in nested artifact folders (OR-554) (#709)
* Fix nested artifact image embeds in chat (OR-554)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* Resolve HTML figure assets within the selected file root (OR-554)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* Bind HTML figure assets to the candidate that served them (OR-554)

Each image candidate now carries its own resolveAsset closure, so an inline
HTML figure resolves its dependencies in the root that actually served it
instead of rebuilding the root from an attempt index. FileViewer resolves an
embedded HTML document against the Markdown file's folder before binding its
assets, so parent-relative assets of nested HTML work there too.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* Let HtmlPreview pass external assets through; drop InlineHtmlFigure adapter (OR-554)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-20 13:44:39 -07:00

79 lines
3.6 KiB
JavaScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import ts from "typescript";
const source = readFileSync(new URL("../src/components/InlineHtmlFigure.tsx", import.meta.url), "utf8");
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX },
}).outputText;
// Minimal hook runtime: enough to run InlineHtmlFigure's effect and re-render on state changes.
function mount(props) {
const slots = [];
let index = 0;
let tree;
const pending = [];
const react = {
useState: (initial) => {
const slot = index++;
if (!(slot in slots)) slots[slot] = initial;
return [slots[slot], (value) => { slots[slot] = value; render(); }];
},
useEffect: (effect, deps) => {
const slot = index++;
const previous = slots[slot];
if (previous && deps.every((dep, i) => dep === previous.deps[i])) return;
slots[slot] = { deps };
pending.push(() => { previous?.cleanup?.(); slots[slot].cleanup = effect(); });
},
};
const HtmlPreview = () => null;
const element = (type, elementProps) => ({ type, props: elementProps });
const exports = {};
new Function("require", "exports", compiled)((name) => ({
react,
"react/jsx-runtime": { jsx: element, jsxs: element },
"../paraglide/messages.js": { m: new Proxy({}, { get: (_, key) => () => String(key) }) },
"./HtmlPreview": { HtmlPreview },
})[name], exports);
function render() {
index = 0;
tree = exports.InlineHtmlFigure(props);
while (pending.length) pending.shift()();
}
render();
const find = (node) => !node?.props ? undefined : node.type === HtmlPreview ? node : [node.props.children].flat().map(find).find(Boolean);
return { preview: () => find(tree)?.props, rerender: (next) => { props = next; render(); }, text: () => JSON.stringify(tree) };
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
const response = (status, type, body = "") => ({ ok: status === 200, headers: { get: () => type }, text: async () => body });
test("the fallback candidate that served the HTML resolves its assets", async (t) => {
const fetched = [];
const replies = { "/a": response(404, "text/html"), "/b": response(200, "image/png"), "/c": response(200, "text/html; charset=utf-8", "<p>c</p>"), "/d": response(200, "text/html") };
t.mock.method(globalThis, "fetch", async (url) => { fetched.push(url); return replies[url]; });
const candidates = ["/a", "/b", "/c", "/d"].map((url) => ({ url, resolveAsset: (src) => `${url}:${src}` }));
const view = mount({ candidates, name: "chart" });
await settle();
const preview = view.preview();
assert.deepEqual(fetched, ["/a", "/b", "/c"]);
assert.equal(preview.url, "/c");
assert.equal(preview.html, "<p>c</p>");
assert.equal(preview.resolveSrc, candidates[2].resolveAsset);
});
test("changing candidates aborts the previous load, and all-failed shows a bounded error", async (t) => {
const signals = [];
t.mock.method(globalThis, "fetch", async (_url, { signal }) => { signals.push(signal); return response(404, "text/html"); });
const view = mount({ candidates: [{ url: "/old", resolveAsset: () => null }], name: "chart" });
view.rerender({ candidates: [{ url: "/x", resolveAsset: () => null }, { url: "/y", resolveAsset: () => null }], name: "chart" });
assert.equal(signals[0].aborted, true);
await settle();
assert.equal(signals.length, 3);
assert.equal(view.preview(), undefined);
assert.match(view.text(), /file_viewer_failed_to_load_file/);
assert.match(view.text(), /"href":"\/x"/);
});