feat(*): svelte 5 panel view sharing the gateway ui, obsidian theme, preview and tests
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
export class TFile {
|
||||
path: string;
|
||||
extension: string;
|
||||
constructor(path: string, extension = "md") {
|
||||
this.path = path;
|
||||
this.extension = extension;
|
||||
}
|
||||
}
|
||||
|
||||
export class TFolder {
|
||||
path: string;
|
||||
constructor(path: string) {
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
export const notices: string[] = [];
|
||||
|
||||
export class Notice {
|
||||
constructor(message: string) {
|
||||
notices.push(message);
|
||||
}
|
||||
hide(): void {}
|
||||
}
|
||||
|
||||
export class Plugin {}
|
||||
export class ItemView {}
|
||||
export class PluginSettingTab {}
|
||||
export class Setting {}
|
||||
export class Component {}
|
||||
export class FuzzySuggestModal {}
|
||||
export const Platform = { isMobile: false };
|
||||
export const moment = () => undefined;
|
||||
export const requestUrl = () =>
|
||||
Promise.reject(new Error("no network in the stub"));
|
||||
export const setIcon = () => undefined;
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// Enough of Obsidian's renderer for the panel: paragraphs, bold, code,
|
||||
// wikilinks as ``a.internal-link`` with ``data-href``, bare URLs.
|
||||
export const MarkdownRenderer = {
|
||||
render(
|
||||
_app: unknown,
|
||||
markdown: string,
|
||||
el: HTMLElement,
|
||||
_sourcePath: string,
|
||||
_component: unknown
|
||||
): Promise<void> {
|
||||
const blocks = markdown.split(/\n{2,}/).map((block) => {
|
||||
const html = escapeHtml(block)
|
||||
.replace(
|
||||
/!?\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
|
||||
(_full, target: string, alias?: string) =>
|
||||
`<a class="internal-link" data-href="${target}" href="${target}">${alias ?? target}</a>`
|
||||
)
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||
.replace(
|
||||
/(^|\s)(https?:\/\/[^\s]+)/g,
|
||||
(_full, lead: string, url: string) =>
|
||||
`${lead}<a class="external-link" href="${url}">${url}</a>`
|
||||
)
|
||||
.replace(/\n/g, "<br>");
|
||||
if (block.startsWith("- ")) {
|
||||
const items = html
|
||||
.split("<br>")
|
||||
.map((line) => `<li>${line.replace(/^- /, "")}</li>`);
|
||||
return `<ul>${items.join("")}</ul>`;
|
||||
}
|
||||
return `<p>${html}</p>`;
|
||||
});
|
||||
el.innerHTML = blocks.join("");
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
+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);
|
||||
@@ -0,0 +1,92 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(
|
||||
join(import.meta.dirname, "../src/tailwind.css"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
function block(selector: string): string {
|
||||
const at = source.indexOf(`\n${selector} {`);
|
||||
if (at === -1) {
|
||||
return "";
|
||||
}
|
||||
const open = source.indexOf("{", at);
|
||||
const close = source.indexOf("\n}", open);
|
||||
return source.slice(open, close);
|
||||
}
|
||||
|
||||
describe("theme aliases", () => {
|
||||
const OBSIDIAN_VARS = [
|
||||
"--background-primary",
|
||||
"--background-secondary",
|
||||
"--background-modifier-border",
|
||||
"--text-normal",
|
||||
"--text-muted",
|
||||
"--color-accent",
|
||||
"--text-on-accent",
|
||||
"--text-error",
|
||||
];
|
||||
|
||||
it("are declared on .beaver-root, not :root", () => {
|
||||
const root = block(".beaver-root");
|
||||
expect(root).not.toBe("");
|
||||
for (const name of OBSIDIAN_VARS) {
|
||||
expect(root, `${name} must be aliased on .beaver-root`).toContain(name);
|
||||
}
|
||||
});
|
||||
|
||||
it("never reference Obsidian variables from :root", () => {
|
||||
const rootBlocks = [...source.matchAll(/(^|\n):root\s*\{([^}]*)\}/g)];
|
||||
for (const [, , body] of rootBlocks) {
|
||||
for (const name of OBSIDIAN_VARS) {
|
||||
expect(body, `:root must not reference ${name}`).not.toContain(name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("scopes the dark override under the view root", () => {
|
||||
expect(source).toContain(".theme-dark .beaver-root");
|
||||
expect(source).not.toMatch(/\n\.theme-dark\s*\{/);
|
||||
});
|
||||
|
||||
it("keeps the msos palette out: the live accent is Obsidian's", () => {
|
||||
const root = block(".beaver-root");
|
||||
expect(root).toContain("--signal: var(--color-accent)");
|
||||
expect(root).toContain("--primary: var(--color-accent)");
|
||||
expect(root).not.toMatch(/oklch\([^)]*\s34[0-2]\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset specificity", () => {
|
||||
it("uses a doubled class to outrank app and theme styles", () => {
|
||||
expect(source).toContain(".beaver-root.beaver-root");
|
||||
});
|
||||
|
||||
it("gives every control Obsidian's corner language", () => {
|
||||
expect(source).toContain('[data-slot="button"]');
|
||||
expect(source).toContain("var(--button-radius");
|
||||
expect(source).toContain('[data-slot="input"]');
|
||||
});
|
||||
});
|
||||
|
||||
describe("cascade against Obsidian", () => {
|
||||
it("marks utilities important so they outrank app.css", () => {
|
||||
expect(source).toMatch(
|
||||
/@import "tailwindcss\/utilities\.css"[^;]*\bimportant\b/
|
||||
);
|
||||
});
|
||||
|
||||
it("scans the shared panel sources", () => {
|
||||
expect(source).toContain('@source "../../beaver-gateway/ui/src/lib"');
|
||||
expect(source).toContain("panel/panel.css");
|
||||
});
|
||||
|
||||
it("imports only the theme and utility layers", () => {
|
||||
expect(source).toContain('@import "tailwindcss/theme.css"');
|
||||
expect(source).toContain('@import "tailwindcss/utilities.css"');
|
||||
expect(source).not.toMatch(/@import "tailwindcss"\s*[;l]/);
|
||||
expect(source).not.toMatch(/@import\s+"tailwindcss\/preflight/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user