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("", { 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);