The inset only knew the desktop status bar, so on a phone the composer sat under the navbar - which paints over a leaf instead of shortening it - and the software keyboard hid it outright. Every bottom bar is measured now, and the keyboard is read off `visualViewport`: it shrinks the visible area without resizing the layout, so no workspace event fires and the panel had no way to know. Focus re-measures once iOS has finished animating the keys in. The panel asks the shell to fall back to the latest master, so opening it on a phone lands in the day's thread rather than on a prompt. The preview fixtures anchor at midday instead of the wall clock: spread over hours, a run just after midnight pushed half of them into yesterday and emptied the "Today" group the switcher smoke checks.
321 lines
8.2 KiB
JavaScript
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,
|
|
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("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);
|