Files
beaver-plugin-obsidian/tests/smoke.mjs
T

321 lines
8.2 KiB
JavaScript

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,
chatsFolder: () => "💬 чаты",
client,
component: new Component(),
onOpenSettings: () => {},
state,
},
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("Agent") && 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")
);
// Nothing picked - the panel just opened on a phone - lands on the master
// rather than on a prompt to pick something.
state.selected = null;
flushSync();
await settle();
flushSync();
check(
"with nothing picked it falls back to the master",
state.selected === MASTER,
String(state.selected)
);
state.selected = BRANCH_BLASTER;
flushSync();
await settle();
flushSync();
check("an explicit pick still wins", state.selected === BRANCH_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 by day",
rows.length >= 4 && text().includes("Today"),
`${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);