feat(*): svelte 5 panel view sharing the gateway ui, obsidian theme, preview and tests
This commit is contained in:
+305
@@ -0,0 +1,305 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import esbuild from "esbuild";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { root, sharedLib, svelte } from "../esbuild.config.mjs";
|
||||
|
||||
const out = join(mkdtempSync(join(tmpdir(), "beaver-")), "bundle.mjs");
|
||||
writeFileSync(
|
||||
join(root, "tests/.entry.ts"),
|
||||
`export { default as App } from "../src/ui/app.svelte";
|
||||
export { PanelState } from "../src/state.svelte";
|
||||
export { FakeClient, MASTER, BRANCH_UFW, BRANCH_BLASTER, DEEP_PANEL } from "../preview/fake-client";
|
||||
export { mount, unmount, flushSync } from "svelte";
|
||||
export { Component } from "obsidian";
|
||||
export { conversationActions } from "$lib/panel/actions.svelte";
|
||||
`
|
||||
);
|
||||
|
||||
await esbuild.build({
|
||||
bundle: true,
|
||||
conditions: ["svelte", "browser"],
|
||||
entryPoints: [join(root, "tests/.entry.ts")],
|
||||
format: "esm",
|
||||
logLevel: "error",
|
||||
mainFields: ["svelte", "browser", "module", "main"],
|
||||
outfile: out,
|
||||
plugins: [
|
||||
sharedLib({ obsidian: join(root, "tests/obsidian-stub.ts") }),
|
||||
svelte(),
|
||||
],
|
||||
target: "es2022",
|
||||
});
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
for (const key of Object.getOwnPropertyNames(dom.window)) {
|
||||
if (key.startsWith("_") || globalThis[key] !== undefined) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
globalThis[key] = dom.window[key];
|
||||
} catch {
|
||||
/* getter-only window properties are fine to skip */
|
||||
}
|
||||
}
|
||||
globalThis.window = dom.window;
|
||||
globalThis.document = dom.window.document;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
dom.window.Element.prototype.getBoundingClientRect = () => ({
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
toJSON: () => ({}),
|
||||
top: 0,
|
||||
width: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
dom.window.Element.prototype.setPointerCapture = () => {};
|
||||
dom.window.Element.prototype.releasePointerCapture = () => {};
|
||||
dom.window.Element.prototype.hasPointerCapture = () => false;
|
||||
dom.window.Element.prototype.scrollIntoView = () => {};
|
||||
dom.window.Element.prototype.animate = () => {
|
||||
const animation = {
|
||||
addEventListener() {},
|
||||
cancel() {},
|
||||
commitStyles() {},
|
||||
currentTime: 0,
|
||||
effect: { getComputedTiming: () => ({ delay: 0, duration: 0 }) },
|
||||
finish() {},
|
||||
finished: Promise.resolve(),
|
||||
onfinish: null,
|
||||
pause() {},
|
||||
play() {},
|
||||
playState: "finished",
|
||||
removeEventListener() {},
|
||||
reverse() {},
|
||||
startTime: 0,
|
||||
};
|
||||
queueMicrotask(() => animation.onfinish?.());
|
||||
return animation;
|
||||
};
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
globalThis.matchMedia = () => ({
|
||||
addEventListener() {},
|
||||
matches: false,
|
||||
removeEventListener() {},
|
||||
});
|
||||
dom.window.matchMedia = globalThis.matchMedia;
|
||||
|
||||
const {
|
||||
App,
|
||||
PanelState,
|
||||
FakeClient,
|
||||
MASTER,
|
||||
BRANCH_UFW,
|
||||
BRANCH_BLASTER,
|
||||
DEEP_PANEL,
|
||||
mount,
|
||||
unmount,
|
||||
flushSync,
|
||||
Component,
|
||||
conversationActions,
|
||||
} = await import(pathToFileURL(out).href);
|
||||
|
||||
const failures = [];
|
||||
function check(name, condition, detail = "") {
|
||||
if (condition) {
|
||||
console.log(` ok ${name}`);
|
||||
} else {
|
||||
failures.push(name);
|
||||
console.log(` FAIL ${name}${detail ? ` -- ${detail}` : ""}`);
|
||||
}
|
||||
}
|
||||
const settle = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const client = new FakeClient();
|
||||
const state = new PanelState();
|
||||
state.selected = MASTER;
|
||||
const opened = [];
|
||||
const app = {
|
||||
workspace: {
|
||||
openLinkText: (link, from) => opened.push({ from, target: link }),
|
||||
},
|
||||
};
|
||||
const target = dom.window.document.body;
|
||||
const instance = mount(App, {
|
||||
props: {
|
||||
app,
|
||||
client,
|
||||
component: new Component(),
|
||||
onOpenSettings: () => {},
|
||||
state,
|
||||
vaultSubpath: () => "💬 чаты",
|
||||
},
|
||||
target,
|
||||
});
|
||||
flushSync();
|
||||
await settle();
|
||||
flushSync();
|
||||
|
||||
const text = () => target.textContent ?? "";
|
||||
check(
|
||||
"root carries the theme class",
|
||||
target.querySelector(".beaver-root") !== null
|
||||
);
|
||||
check(
|
||||
"portal layer is on body",
|
||||
target.querySelector(".beaver-portal") !== null
|
||||
);
|
||||
check("thread header shows the master", text().includes("Master · 29 Aug"));
|
||||
check(
|
||||
"the running turn's tools are on screen",
|
||||
text().includes("Task") && text().includes("Bash")
|
||||
);
|
||||
check(
|
||||
"history rendered through the host renderer",
|
||||
target.querySelector(".beaver-md") !== null
|
||||
);
|
||||
|
||||
const wikilink = target.querySelector("a.internal-link");
|
||||
check("wikilink rendered as an internal link", wikilink !== null);
|
||||
if (wikilink) {
|
||||
wikilink.dispatchEvent(
|
||||
new dom.window.MouseEvent("click", { bubbles: true, cancelable: true })
|
||||
);
|
||||
check(
|
||||
"clicking it opens the note in Obsidian",
|
||||
opened.some((o) => o.target === "2026-08-28"),
|
||||
JSON.stringify(opened)
|
||||
);
|
||||
}
|
||||
|
||||
state.selected = BRANCH_BLASTER;
|
||||
flushSync();
|
||||
await settle();
|
||||
flushSync();
|
||||
check("switching follows the state", text().includes("бластер"));
|
||||
check(
|
||||
"a pending question shows its buttons",
|
||||
text().includes("Xiaomi Mi Blaster")
|
||||
);
|
||||
|
||||
const composer = target.querySelector("textarea");
|
||||
check("composer is there", composer !== null);
|
||||
if (composer) {
|
||||
composer.value = "закажи Broadlink";
|
||||
composer.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
||||
flushSync();
|
||||
composer
|
||||
.closest("form")
|
||||
?.dispatchEvent(
|
||||
new dom.window.Event("submit", { bubbles: true, cancelable: true })
|
||||
);
|
||||
await settle();
|
||||
check(
|
||||
"sending goes out as source=panel",
|
||||
client.sent.some(
|
||||
(m) => m.origin === "panel" && m.text === "закажи Broadlink"
|
||||
),
|
||||
JSON.stringify(client.sent)
|
||||
);
|
||||
}
|
||||
|
||||
const switcher = target.querySelector('[aria-label="Switch conversation"]');
|
||||
check("narrow layout shows the switcher", switcher !== null);
|
||||
switcher?.dispatchEvent(
|
||||
new dom.window.PointerEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
pointerType: "mouse",
|
||||
})
|
||||
);
|
||||
switcher?.click();
|
||||
flushSync();
|
||||
await settle();
|
||||
const rows = [...dom.window.document.querySelectorAll("[data-row]")];
|
||||
check(
|
||||
"switcher lists open conversations grouped",
|
||||
rows.length >= 4 &&
|
||||
text().includes("Branches") &&
|
||||
text().includes("Deep chats"),
|
||||
`${rows.length}`
|
||||
);
|
||||
check(
|
||||
"closed ones stay out until asked",
|
||||
!rows.some((row) => row.dataset.row === "c5triage0000")
|
||||
);
|
||||
|
||||
dom.window.document.dispatchEvent(
|
||||
new dom.window.KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
code: "Escape",
|
||||
key: "Escape",
|
||||
})
|
||||
);
|
||||
flushSync();
|
||||
await settle();
|
||||
|
||||
state.selected = DEEP_PANEL;
|
||||
flushSync();
|
||||
await settle();
|
||||
flushSync();
|
||||
check(
|
||||
"header carries the actions menu",
|
||||
target.querySelector('[aria-label="Conversation actions"]') !== null
|
||||
);
|
||||
|
||||
const scope = (current) => ({
|
||||
client,
|
||||
current,
|
||||
host: { name: "obsidian", openNote: () => {}, touch: false },
|
||||
onBranch: () => {},
|
||||
onChanged: () => {},
|
||||
onOpen: () => {},
|
||||
onRename: () => {},
|
||||
});
|
||||
const labels = async (id, current = true) =>
|
||||
conversationActions(await client.conversation(id), scope(current)).map(
|
||||
(a) => a.label
|
||||
);
|
||||
const deep = await labels(DEEP_PANEL);
|
||||
check(
|
||||
"deep chat offers its note, memory and digest",
|
||||
deep.includes("Open note") &&
|
||||
deep.includes("Remember on close") &&
|
||||
deep.includes("Close with digest"),
|
||||
deep.join(", ")
|
||||
);
|
||||
check(
|
||||
"a deep chat has no Telegram toggle",
|
||||
!deep.some((l) => l.includes("Telegram"))
|
||||
);
|
||||
const hidden = await labels(BRANCH_BLASTER, false);
|
||||
check(
|
||||
"a hidden branch can return to Telegram and be opened",
|
||||
hidden.includes("Return to Telegram") && hidden.includes("Open"),
|
||||
hidden.join(", ")
|
||||
);
|
||||
const shown = await labels(BRANCH_UFW);
|
||||
check(
|
||||
"a visible branch can hide from Telegram and merge",
|
||||
shown.includes("Hide from Telegram") &&
|
||||
shown.includes("Merge into parent") &&
|
||||
!shown.includes("Open"),
|
||||
shown.join(", ")
|
||||
);
|
||||
|
||||
await unmount(instance);
|
||||
console.log(
|
||||
failures.length === 0
|
||||
? "\nsmoke: all good"
|
||||
: `\nsmoke: ${failures.length} FAILED`
|
||||
);
|
||||
process.exit(failures.length === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user