Own browser UI sessions and shared navigation

This commit is contained in:
vorotamoroz committed 2026-10-29 14:58:38 +00:00
1 parent cfdc698775
commit 273079e827
28 files changed
+1948 -121

No files matched your search

@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
const kit = vi.hoisted(() => ({
uiOptions: [] as { document: Document; signal: AbortSignal }[],
notices: [] as { document: Document; dispose: ReturnType<typeof vi.fn>; show: ReturnType<typeof vi.fn> }[],
}));
vi.mock("@vrtmrz/browser-ui-kit", () => ({
createBrowserUi(options: { document: Document; signal: AbortSignal }) {
kit.uiOptions.push(options);
return { promptText: vi.fn(async () => "selected") };
},
BrowserUiNotifications: class {
dispose = vi.fn();
show = vi.fn();
constructor(options: { document: Document }) {
kit.notices.push({ document: options.document, dispose: this.dispose, show: this.show });
}
},
}));
import { BrowserConfirm } from "@/apps/browser/BrowserConfirm";
import { createBrowserUiScope } from "@/apps/browser/BrowserUiScope";
beforeEach(() => {
kit.uiOptions.length = 0;
kit.notices.length = 0;
});
describe("BrowserConfirm UI owner", () => {
it("passes the same document and lifetime to existing Kit capabilities", async () => {
const context = createServiceContext();
const anchor = { textContent: "", click: vi.fn() };
const document = { createElement: vi.fn(() => anchor) } as unknown as Document;
const ui = createBrowserUiScope(context, { document });
const confirm = new BrowserConfirm(context, ui);
expect(kit.uiOptions[0]).toMatchObject({ document, signal: ui.signal });
expect(kit.notices[0].document).toBe(document);
await expect(confirm.askString("Title", "Value", "Placeholder")).resolves.toBe("selected");
confirm.askInPopup("key", "Go {HERE}", (link) => {
link.textContent = "Open";
});
expect(document.createElement).toHaveBeenCalledWith("a");
expect(kit.notices[0].show).toHaveBeenCalledWith(
"key",
expect.objectContaining({ action: expect.objectContaining({ label: "Open" }) })
);
context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
expect(ui.signal.aborted).toBe(true);
expect(kit.notices[0].dispose).toHaveBeenCalledOnce();
});
it("disposes only the notifications belonging to the unloaded context", () => {
const first = createServiceContext();
const second = createServiceContext();
new BrowserConfirm(first, createBrowserUiScope(first, { document: {} as Document }));
new BrowserConfirm(second, createBrowserUiScope(second, { document: {} as Document }));
first.events.emitEvent(EVENT_PLUGIN_UNLOADED);
first.events.emitEvent(EVENT_PLUGIN_UNLOADED);
expect(kit.notices[0].dispose).toHaveBeenCalledOnce();
expect(kit.notices[1].dispose).not.toHaveBeenCalled();
second.events.emitEvent(EVENT_PLUGIN_UNLOADED);
});
it("disposes notifications immediately for a pre-aborted owner", () => {
const context = createServiceContext();
const parent = new AbortController();
parent.abort();
new BrowserConfirm(context, createBrowserUiScope(context, { signal: parent.signal }));
expect(kit.uiOptions[0].signal.aborted).toBe(true);
expect(kit.notices[0].dispose).toHaveBeenCalledOnce();
context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
expect(kit.notices[0].dispose).toHaveBeenCalledOnce();
});
});
+286
View File
@@ -0,0 +1,286 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const browserMenuHarness = vi.hoisted(() => {
const createDocument = () => {
const created: { tag: string; removed: boolean; remove: ReturnType<typeof vi.fn> }[] = [];
const appended: unknown[] = [];
const document = {
createElement: vi.fn((tag: string) => {
const element: { tag: string; removed: boolean; remove: ReturnType<typeof vi.fn> } = {
tag,
removed: false,
remove: vi.fn(() => {
element.removed = true;
}),
};
created.push(element);
return element;
}),
body: {
appendChild: vi.fn((element: unknown) => {
appended.push(element);
return element;
}),
},
};
return { document, created, appended };
};
return {
documents: {
active: createDocument(),
explicit: createDocument(),
},
mounts: [] as {
component: unknown;
target: unknown;
props: Record<string, unknown>;
handle: { id: number };
}[],
unmounts: [] as unknown[],
};
});
vi.mock("@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", () => ({
_activeDocument: browserMenuHarness.documents.active.document,
}));
vi.mock("svelte", () => ({
mount: vi.fn((component: unknown, options: { target: unknown; props: Record<string, unknown> }) => {
const handle = { id: browserMenuHarness.mounts.length };
browserMenuHarness.mounts.push({ component, target: options.target, props: options.props, handle });
return handle;
}),
unmount: vi.fn((handle: unknown) => {
browserMenuHarness.unmounts.push(handle);
return Promise.resolve();
}),
}));
import { Menu, MenuItem, MenuSeparator } from "@/apps/browser/BrowserMenu";
type BrowserMenuOptionsFixture = { document?: Document; signal?: AbortSignal };
function menuWithOptions(options: BrowserMenuOptionsFixture): Menu {
const MenuWithOptions = Menu as unknown as new (options: BrowserMenuOptionsFixture) => Menu;
return new MenuWithOptions(options);
}
function explicitDocument(): Document {
return browserMenuHarness.documents.explicit.document as unknown as Document;
}
function closeMenu(index: number): () => void {
const close = browserMenuHarness.mounts[index]!.props.closeMenu;
if (typeof close !== "function") throw new Error("Menu mount did not receive a close callback");
return close as () => void;
}
async function settlesAfterMicrotask(promise: Promise<void>): Promise<boolean> {
let settled = false;
void promise.then(() => {
settled = true;
});
await Promise.resolve();
return settled;
}
beforeEach(() => {
browserMenuHarness.documents.active.created.length = 0;
browserMenuHarness.documents.active.appended.length = 0;
browserMenuHarness.documents.explicit.created.length = 0;
browserMenuHarness.documents.explicit.appended.length = 0;
browserMenuHarness.mounts.length = 0;
browserMenuHarness.unmounts.length = 0;
vi.clearAllMocks();
});
afterEach(() => {
for (const mount of browserMenuHarness.mounts) {
const close = mount.props.closeMenu;
if (typeof close === "function") (close as () => void)();
}
});
describe("browser Menu lifecycle", () => {
it("preserves item, separator, chained construction, and positioned display APIs", async () => {
const menu = new Menu();
const handler = vi.fn(async () => {});
expect(
menu.addItem((item) => {
item.setTitle("Open").setIcon("folder").onClick(handler);
})
).toBe(menu);
expect(menu.addSeparator()).toBe(menu);
expect(menu.items[0]).toBeInstanceOf(MenuItem);
expect(menu.items[0]).toMatchObject({ type: "item", title: "Open", icon: "folder", handler });
expect(menu.items[1]).toBeInstanceOf(MenuSeparator);
const displayed = menu.showAtPosition({ x: 12, y: 34 });
expect(browserMenuHarness.documents.active.document.createElement).toHaveBeenCalledWith("div");
expect(browserMenuHarness.documents.active.appended).toEqual([browserMenuHarness.mounts[0]!.target]);
expect(browserMenuHarness.mounts[0]!.props).toMatchObject({ items: menu.items, x: 12, y: 34 });
expect(browserMenuHarness.mounts[0]!.target).toBe(browserMenuHarness.documents.active.created[0]);
closeMenu(0)();
await expect(displayed).resolves.toBeUndefined();
});
it("keeps the returned close promise pending until a menu close action", async () => {
const menu = new Menu();
const displayed = menu.showAtPosition({ x: 1, y: 2 });
expect(await settlesAfterMicrotask(displayed)).toBe(false);
closeMenu(0)();
await expect(displayed).resolves.toBeUndefined();
});
it("resolves the active display promise when hidden without a menu action", async () => {
const menu = new Menu();
const displayed = menu.showAtPosition({ x: 1, y: 2 });
menu.hide();
await expect(displayed).resolves.toBeUndefined();
});
it("closes a replaced display while keeping the replacement active", async () => {
const menu = new Menu();
const first = menu.showAtPosition({ x: 1, y: 2 });
const staleClose = closeMenu(0);
const firstElement = browserMenuHarness.mounts[0]!.target as { removed: boolean };
const second = menu.showAtPosition({ x: 3, y: 4 });
const secondElement = browserMenuHarness.mounts[1]!.target as { removed: boolean };
await expect(first).resolves.toBeUndefined();
expect(firstElement.removed).toBe(true);
expect(secondElement.removed).toBe(false);
expect(menu.waitingForClose?.promise).toBe(second);
expect(await settlesAfterMicrotask(second)).toBe(false);
try {
staleClose();
expect(menu.waitingForClose?.promise).toBe(second);
expect(secondElement.removed).toBe(false);
expect(await settlesAfterMicrotask(second)).toBe(false);
} finally {
closeMenu(1)();
}
await expect(second).resolves.toBeUndefined();
});
it("mounts a menu in its explicitly supplied document", async () => {
const menu = menuWithOptions({ document: explicitDocument() });
const displayed = menu.showAtPosition({ x: 5, y: 6 });
expect(browserMenuHarness.documents.explicit.document.createElement).toHaveBeenCalledWith("div");
expect(browserMenuHarness.documents.explicit.appended).toEqual([browserMenuHarness.mounts[0]!.target]);
expect(browserMenuHarness.mounts[0]!.target).toBe(browserMenuHarness.documents.explicit.created[0]);
expect(browserMenuHarness.documents.active.document.createElement).not.toHaveBeenCalled();
closeMenu(0)();
await expect(displayed).resolves.toBeUndefined();
});
it("aborting one menu closes only that menu", async () => {
const firstOwner = new AbortController();
const secondOwner = new AbortController();
const firstMenu = menuWithOptions({ document: explicitDocument(), signal: firstOwner.signal });
const secondMenu = menuWithOptions({ document: explicitDocument(), signal: secondOwner.signal });
const first = firstMenu.showAtPosition({ x: 1, y: 2 });
const second = secondMenu.showAtPosition({ x: 3, y: 4 });
let firstSettled = false;
void first.then(() => {
firstSettled = true;
});
firstOwner.abort();
await Promise.resolve();
expect(firstSettled).toBe(true);
expect(secondMenu.waitingForClose?.promise).toBe(second);
expect(await settlesAfterMicrotask(second)).toBe(false);
closeMenu(1)();
await expect(second).resolves.toBeUndefined();
});
it("does not mount for an already aborted owner", async () => {
const owner = new AbortController();
owner.abort();
const menu = menuWithOptions({ document: explicitDocument(), signal: owner.signal });
const displayed = menu.showAtPosition({ x: 1, y: 2 });
expect(browserMenuHarness.mounts).toHaveLength(0);
expect(await settlesAfterMicrotask(displayed)).toBe(true);
});
it("does not mount again after its owner aborts", async () => {
const owner = new AbortController();
const menu = menuWithOptions({ document: explicitDocument(), signal: owner.signal });
const first = menu.showAtPosition({ x: 1, y: 2 });
owner.abort();
expect(await settlesAfterMicrotask(first)).toBe(true);
const reopened = menu.showAtPosition({ x: 3, y: 4 });
expect(await settlesAfterMicrotask(reopened)).toBe(true);
expect(browserMenuHarness.mounts).toHaveLength(1);
});
it.each(["hide", "dismissal", "replacement", "abort"] as const)(
"unmounts each Svelte component exactly once for %s",
async (reason) => {
const owner = new AbortController();
const menu = menuWithOptions({ document: explicitDocument(), signal: owner.signal });
const first = menu.showAtPosition({ x: 1, y: 2 });
let second: Promise<void> | undefined;
if (reason === "hide") {
menu.hide();
} else if (reason === "dismissal") {
closeMenu(0)();
} else if (reason === "replacement") {
second = menu.showAtPosition({ x: 3, y: 4 });
closeMenu(1)();
} else {
owner.abort();
}
expect(await settlesAfterMicrotask(first)).toBe(true);
if (second) expect(await settlesAfterMicrotask(second)).toBe(true);
for (const mount of browserMenuHarness.mounts) {
expect(browserMenuHarness.unmounts.filter((handle) => handle === mount.handle)).toHaveLength(1);
}
}
);
it("removes the menu element before resolving the close promise", async () => {
const menu = new Menu();
const displayed = menu.showAtPosition({ x: 1, y: 2 });
const element = browserMenuHarness.mounts[0]!.target as { removed: boolean };
let removedWhenResolved = false;
void displayed.then(() => {
removedWhenResolved = element.removed;
});
menu.hide();
await displayed;
expect(removedWhenResolved).toBe(true);
});
it("removes its abort listener when closed so a later abort does not unmount it again", async () => {
const owner = new AbortController();
const addListener = vi.spyOn(owner.signal, "addEventListener");
const removeListener = vi.spyOn(owner.signal, "removeEventListener");
const menu = menuWithOptions({ document: explicitDocument(), signal: owner.signal });
const displayed = menu.showAtPosition({ x: 1, y: 2 });
const handle = browserMenuHarness.mounts[0]!.handle;
menu.hide();
await expect(displayed).resolves.toBeUndefined();
owner.abort();
expect(addListener.mock.calls.map(([type]) => type)).toContain("abort");
expect(removeListener.mock.calls.map(([type]) => type)).toContain("abort");
expect(browserMenuHarness.unmounts.filter((unmounted) => unmounted === handle)).toHaveLength(1);
});
});
@@ -0,0 +1,199 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import type {
ComponentHasResult,
DialogHostProps,
SvelteDialogManagerDependencies,
} from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog";
const rendering = vi.hoisted(() => ({
mount: vi.fn(),
unmount: vi.fn(async (_component: unknown) => {}),
legacy: { document: undefined as Document | undefined },
}));
vi.mock("svelte", async (importOriginal) => ({
...(await importOriginal<typeof import("svelte")>()),
mount: rendering.mount,
unmount: rendering.unmount,
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", () => ({
get _activeDocument() {
return rendering.legacy.document;
},
}));
vi.mock("@/modules/services/LiveSyncUI/DialogHost.svelte", () => ({ default: () => {} }));
import {
BrowserModal,
BrowserSvelteDialogManager,
LiveSyncBrowserDialog,
} from "@/apps/browser/BrowserSvelteDialogManager";
import { createBrowserUiScope } from "@/apps/browser/BrowserUiScope";
class TestElement {
children: TestElement[] = [];
parentElement: TestElement | null = null;
hidden = false;
className = "";
textContent = "";
constructor(
readonly ownerDocument: TestDocument,
readonly tagName: string
) {}
appendChild(child: TestElement) {
child.remove();
child.parentElement = this;
this.children.push(child);
return child;
}
remove() {
if (this.parentElement)
this.parentElement.children = this.parentElement.children.filter((child) => child !== this);
this.parentElement = null;
}
replaceChildren() {
for (const child of [...this.children]) child.remove();
}
}
class TestDocument {
body = new TestElement(this, "body");
createElement(tag: string) {
return new TestElement(this, tag);
}
}
const asDocument = (document: TestDocument) => document as unknown as Document;
const component = (() => {}) as unknown as ComponentHasResult<string, string>;
type Context = ReturnType<typeof createServiceContext>;
type UiOptions = { document?: Document; signal?: AbortSignal };
function createManager(options?: UiOptions) {
const context = createServiceContext();
return {
context,
manager: new BrowserSvelteDialogManager(
context,
{} as SvelteDialogManagerDependencies<Context>,
options ? createBrowserUiScope(context, options) : undefined
),
};
}
function lastMount() {
const call = rendering.mount.mock.calls[rendering.mount.mock.calls.length - 1];
return { target: call[1].target as TestElement, props: call[1].props as DialogHostProps<string, string> };
}
beforeEach(() => {
vi.clearAllMocks();
rendering.legacy.document = asDocument(new TestDocument());
rendering.mount.mockImplementation(() => ({}));
});
afterEach(() => {
rendering.legacy.document = undefined;
});
describe("Browser Svelte dialogue ownership", () => {
it("releases every per-dialogue context subscription on legacy direct close", async () => {
const subscriptions: ReturnType<typeof vi.fn>[] = [];
const context = {
events: {
onceEvent: vi.fn(() => {
const unregister = vi.fn();
subscriptions.push(unregister);
return unregister;
}),
},
} as unknown as Context;
const dialog = new LiveSyncBrowserDialog(context, {} as SvelteDialogManagerDependencies<Context>, component);
dialog.open();
const result = dialog.waitForClose();
dialog.close();
await expect(result).resolves.toBeUndefined();
expect(subscriptions.length).toBeGreaterThan(0);
for (const unsubscribe of subscriptions) expect(unsubscribe).toHaveBeenCalledOnce();
});
it("preserves the legacy document, normal result, and component cleanup", async () => {
const { manager } = createManager();
const result = manager.openSvelteDialog(component, "initial");
const { target, props } = lastMount();
expect(target.ownerDocument).toBe(rendering.legacy.document);
expect(props.getInitialData?.()).toBe("initial");
props.setResult("selected");
props.closeDialog();
await expect(result).resolves.toBe("selected");
expect((rendering.legacy.document as unknown as TestDocument).body.children).toHaveLength(0);
expect(rendering.unmount).toHaveBeenCalledOnce();
});
it("preserves rejection when the owning context unloads", async () => {
const { context, manager } = createManager();
const result = manager.openSvelteDialog(component);
const rejected = expect(result).rejects.toThrow("Plug-in unloaded");
context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
await rejected;
expect(rendering.unmount).toHaveBeenCalledOnce();
expect((rendering.legacy.document as unknown as TestDocument).body.children).toHaveLength(0);
});
it("uses its supplied document even when the legacy document changes before opening", async () => {
const targetDocument = new TestDocument();
const { manager } = createManager({ document: asDocument(targetDocument) });
rendering.legacy.document = asDocument(new TestDocument());
const result = manager.openSvelteDialog(component);
const { target, props } = lastMount();
expect(target.ownerDocument).toBe(targetDocument);
expect(targetDocument.body.children).toHaveLength(1);
expect((rendering.legacy.document as unknown as TestDocument).body.children).toHaveLength(0);
props.closeDialog();
await expect(result).resolves.toBeUndefined();
});
it("aborts one owner without closing another owner's dialogue", async () => {
const firstOwner = new AbortController();
const firstDocument = new TestDocument();
const secondDocument = new TestDocument();
const first = createManager({ document: asDocument(firstDocument), signal: firstOwner.signal });
const second = createManager({ document: asDocument(secondDocument) });
const firstResult = first.manager.openSvelteDialog(component);
let observedFirst: unknown = "still pending";
void firstResult.then(
() => {
observedFirst = "resolved";
},
(error) => {
observedFirst = error;
}
);
const secondResult = second.manager.openSvelteDialog(component);
const secondProps = lastMount().props;
const reason = new Error("fixture owner ended");
firstOwner.abort(reason);
await vi.waitFor(() => expect(observedFirst).toBe(reason), { timeout: 1000, interval: 20 });
expect(firstDocument.body.children).toHaveLength(0);
expect(secondDocument.body.children).toHaveLength(1);
secondProps.setResult("second");
secondProps.closeDialog();
await expect(secondResult).resolves.toBe("second");
});
it("does not render for an already aborted owner or reopen after unload", async () => {
const owner = new AbortController();
const reason = new Error("fixture owner ended");
owner.abort(reason);
const aborted = createManager({ signal: owner.signal });
const rejected = aborted.manager.openSvelteDialog(component).catch((error) => error);
expect(rendering.mount).not.toHaveBeenCalled();
await expect(rejected).resolves.toBe(reason);
const unloaded = createManager();
unloaded.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
const afterUnload = unloaded.manager.openSvelteDialog(component).catch((error) => error);
expect(rendering.mount).not.toHaveBeenCalled();
await expect(afterUnload).resolves.toBeInstanceOf(Error);
});
it("keeps a native modal in the document which created its elements", () => {
const document = new TestDocument();
const modal = new BrowserModal(asDocument(document));
rendering.legacy.document = asDocument(new TestDocument());
modal.open();
expect(document.body.children).toEqual([modal.baseEl]);
expect((modal.contentEl as unknown as TestElement).ownerDocument).toBe(document);
modal.close();
expect(document.body.children).toHaveLength(0);
});
});
@@ -0,0 +1,56 @@
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { describe, expect, it, vi } from "vitest";
import { createBrowserUiScope } from "@/apps/browser/BrowserUiScope";
describe("browser UI scope", () => {
it("owns its document and cancellation independently from another application", () => {
const firstContext = createServiceContext();
const secondContext = createServiceContext();
const firstDocument = {} as Document;
const secondDocument = {} as Document;
const first = createBrowserUiScope(firstContext, { document: firstDocument });
const second = createBrowserUiScope(secondContext, { document: secondDocument });
const abort = vi.fn();
first.signal.addEventListener("abort", abort);
firstContext.events.emitEvent(EVENT_PLUGIN_UNLOADED);
firstContext.events.emitEvent(EVENT_PLUGIN_UNLOADED);
expect(first.document).toBe(firstDocument);
expect(second.document).toBe(secondDocument);
expect(first.signal.aborted).toBe(true);
expect(first.signal.reason).toEqual(new Error("Plug-in unloaded"));
expect(second.signal.aborted).toBe(false);
expect(abort).toHaveBeenCalledOnce();
secondContext.events.emitEvent(EVENT_PLUGIN_UNLOADED);
});
it("forwards parent cancellation and unregisters the context subscription", () => {
const context = createServiceContext();
const parent = new AbortController();
const unregister = vi.fn();
const once = vi.spyOn(context.events, "onceEvent").mockReturnValue(unregister);
const scope = createBrowserUiScope(context, { signal: parent.signal });
const reason = new Error("fixture host ended");
parent.abort(reason);
expect(scope.signal.aborted).toBe(true);
expect(scope.signal.reason).toBe(reason);
expect(unregister).toHaveBeenCalledOnce();
once.mockRestore();
});
it("removes the parent listener on unload and does not subscribe an already aborted parent", () => {
const context = createServiceContext();
const parent = new AbortController();
const remove = vi.spyOn(parent.signal, "removeEventListener");
const scope = createBrowserUiScope(context, { signal: parent.signal });
context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
expect(remove).toHaveBeenCalledWith("abort", expect.any(Function));
const reason = scope.signal.reason;
parent.abort(new Error("later"));
expect(scope.signal.reason).toBe(reason);
const closedContext = createServiceContext();
const subscribe = vi.spyOn(closedContext.events, "onceEvent");
const closed = createBrowserUiScope(closedContext, { signal: parent.signal });
expect(closed.signal.aborted).toBe(true);
expect(closed.signal.reason).toBe(parent.signal.reason);
expect(subscribe).not.toHaveBeenCalled();
});
});
@@ -86,3 +86,64 @@ describe("SvelteDialogSession", () => {
expect(close).toHaveBeenCalledOnce();
});
});
describe("SvelteDialogSession owner signal", () => {
it("rejects and releases the mounted dialogue when its owner aborts", async () => {
const context = createServiceContext();
const controller = new AbortController();
const lifecycle = { signal: controller.signal };
const unmount = vi.fn().mockResolvedValue(undefined);
let session: SvelteDialogSession<string, string, typeof context>;
const close = vi.fn(() => session.onClose());
session = new SvelteDialogSession({
...lifecycle,
surface: {
contentEl: { replaceChildren: vi.fn() } as unknown as HTMLElement,
close,
setTitle: vi.fn(),
},
context,
dependencies: {} as SvelteDialogManagerDependencies<typeof context>,
dialogHost: (() => {}) as unknown as Component<DialogHostProps>,
component: (() => {}) as unknown as ComponentHasResult<string, string>,
renderer: { mount: () => ({}) as never, unmount },
});
session.onOpen();
let observed: unknown = "pending";
void session.waitForClose().catch((error) => {
observed = error;
});
const reason = new Error("fixture owner ended");
controller.abort(reason);
await vi.waitFor(() => expect(observed).toBe(reason), { timeout: 1000, interval: 20 });
expect(close).toHaveBeenCalledOnce();
expect(unmount).toHaveBeenCalledOnce();
context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
expect(close).toHaveBeenCalledOnce();
});
it("detaches an owner signal when normal close has already settled the result", async () => {
const context = createServiceContext();
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, "removeEventListener");
const lifecycle = { signal: controller.signal };
const close = vi.fn();
const session = new SvelteDialogSession({
...lifecycle,
surface: { contentEl: { replaceChildren: vi.fn() } as unknown as HTMLElement, close, setTitle: vi.fn() },
context,
dependencies: {} as SvelteDialogManagerDependencies<typeof context>,
dialogHost: (() => {}) as unknown as Component<DialogHostProps>,
component: (() => {}) as unknown as ComponentHasResult<string, string>,
renderer: { mount: () => ({}) as never, unmount: vi.fn().mockResolvedValue(undefined) },
});
session.onOpen();
const result = session.waitForClose();
session.onClose();
await expect(result).resolves.toBeUndefined();
expect(remove).toHaveBeenCalledWith("abort", expect.any(Function));
controller.abort();
context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
expect(close).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,202 @@
import { assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import { chromium } from "playwright";
import { observePageFailures, startStaticServer, waitFor } from "../helpers/browser.ts";
type OwnerName = "owner-a" | "owner-b" | "owner-c" | "owner-d";
type DialogOutcome =
| { readonly kind: "resolved"; readonly value: unknown }
| { readonly kind: "rejected"; readonly message: string };
interface BrowserUiLifecycleFixture {
readonly ready: boolean;
startDialog(name: OwnerName, initialData?: string): void;
startMenu(name: OwnerName, title: string): void;
clickSavedBackdrop(name: OwnerName): void;
hideMenu(name: OwnerName): void;
unload(name: OwnerName): void;
abortParent(name: OwnerName): void;
state(name: OwnerName): {
readonly dialogs: readonly DialogOutcome[];
readonly menus: readonly string[];
readonly hasSavedBackdrop: boolean;
};
}
declare global {
interface Window {
browserUiLifecycle?: BrowserUiLifecycleFixture;
}
}
const fixtureDist = resolve(import.meta.dirname!, "../../../_testdata/browser-ui-lifecycle");
Deno.test({
name: "Browser UI: dialogue and menu lifecycles remain owned by their document and plug-in scope",
sanitizeOps: false,
sanitizeResources: false,
async fn() {
const server = await startStaticServer(fixtureDist);
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
const assertNoPageFailures = observePageFailures(page);
await page.goto(server.baseUrl);
await page.waitForFunction(() => window.browserUiLifecycle?.ready === true);
const startDialog = async (name: OwnerName, initialData?: string) =>
await page.evaluate(
({ name, initialData }) => window.browserUiLifecycle!.startDialog(name, initialData),
{ name, initialData }
);
const startMenu = async (name: OwnerName, title: string) =>
await page.evaluate(({ name, title }) => window.browserUiLifecycle!.startMenu(name, title), {
name,
title,
});
const state = async (name: OwnerName) =>
await page.evaluate((ownerName) => window.browserUiLifecycle!.state(ownerName), name);
const ownerA = page.frameLocator("#owner-a");
const ownerB = page.frameLocator("#owner-b");
const ownerC = page.frameLocator("#owner-c");
const ownerD = page.frameLocator("#owner-d");
await startDialog("owner-a", "A-seed");
await startDialog("owner-b", "B-seed");
const initial = await page.evaluate(() => ({
first: window.browserUiLifecycle!.state("owner-a").dialogs,
second: window.browserUiLifecycle!.state("owner-b").dialogs,
}));
assertEquals(initial, { first: [], second: [] });
assertNoPageFailures();
await ownerA.getByTestId("lifecycle-dialog").waitFor();
await ownerB.getByTestId("lifecycle-dialog").waitFor();
assertEquals(await page.locator(".dialog-host").count(), 0);
assertEquals(await ownerA.locator(".dialog-host").count(), 1);
assertEquals(await ownerB.locator(".dialog-host").count(), 1);
await ownerA.getByTestId("submit-dialog").click();
await waitFor(async () => (await state("owner-a")).dialogs.length === 1, "Owner A dialogue did not finish");
assertEquals((await state("owner-a")).dialogs, [{ kind: "resolved", value: "result:A-seed" }]);
assertEquals(await ownerA.locator(".dialog-host").count(), 0);
assertEquals(await ownerB.locator(".dialog-host").count(), 1);
await ownerB.getByTestId("close-dialog").click();
await waitFor(async () => (await state("owner-b")).dialogs.length === 1, "Owner B dialogue did not finish");
assertEquals((await state("owner-b")).dialogs, [{ kind: "resolved", value: "undefined" }]);
await startMenu("owner-a", "first menu");
await ownerA.getByText("first menu", { exact: true }).waitFor();
assertEquals(await page.locator(".background").count(), 0);
assertEquals(await ownerA.locator(".background").count(), 1);
assertEquals(await ownerB.locator(".background").count(), 0);
await startMenu("owner-a", "replacement menu");
await ownerA.getByText("replacement menu", { exact: true }).waitFor();
await waitFor(
async () => (await state("owner-a")).menus.length === 1,
"Replacing the menu did not close its first promise"
);
assertEquals((await state("owner-a")).menus, ["resolved"]);
assertEquals((await state("owner-a")).hasSavedBackdrop, true);
await page.evaluate(() => window.browserUiLifecycle!.clickSavedBackdrop("owner-a"));
assertEquals(await ownerA.locator(".background").count(), 1);
assertEquals((await state("owner-a")).menus, ["resolved"]);
await page.evaluate(() => window.browserUiLifecycle!.hideMenu("owner-a"));
await waitFor(
async () => (await state("owner-a")).menus.length === 2,
"Hiding the replacement menu did not finish"
);
assertEquals((await state("owner-a")).menus, ["resolved", "resolved"]);
assertEquals(await ownerA.locator(".background").count(), 0);
await startDialog("owner-a", "unload-A");
await startMenu("owner-a", "A unload menu");
await startDialog("owner-b", "still-B");
await startMenu("owner-b", "B remains open");
await ownerA.getByTestId("lifecycle-dialog").waitFor();
await ownerA.getByText("A unload menu", { exact: true }).waitFor();
await ownerB.getByTestId("lifecycle-dialog").waitFor();
await ownerB.getByText("B remains open", { exact: true }).waitFor();
await page.evaluate(() => window.browserUiLifecycle!.unload("owner-a"));
await waitFor(
async () =>
(await state("owner-a")).dialogs.length === 2 && (await state("owner-a")).menus.length === 3,
"Owner A unload did not settle its UI operations"
);
assertEquals((await state("owner-a")).dialogs[1], { kind: "rejected", message: "Plug-in unloaded" });
assertEquals(await ownerA.locator(".dialog-host").count(), 0);
assertEquals(await ownerA.locator(".background").count(), 0);
assertEquals(await ownerB.locator(".dialog-host").count(), 1);
assertEquals(await ownerB.locator(".background").count(), 1);
await startDialog("owner-a", "after-unload");
await startMenu("owner-a", "after unload menu");
await waitFor(
async () =>
(await state("owner-a")).dialogs.length === 3 && (await state("owner-a")).menus.length === 4,
"Post-unload UI operations did not settle"
);
assertEquals((await state("owner-a")).dialogs[2], { kind: "rejected", message: "Plug-in unloaded" });
assertEquals((await state("owner-a")).menus[3], "resolved");
assertEquals(await ownerA.locator(".dialog-host").count(), 0);
assertEquals(await ownerA.locator(".background").count(), 0);
await startDialog("owner-c", "parent-abort");
await startMenu("owner-c", "parent abort menu");
await ownerC.getByTestId("lifecycle-dialog").waitFor();
await ownerC.getByText("parent abort menu", { exact: true }).waitFor();
await page.evaluate(() => window.browserUiLifecycle!.abortParent("owner-c"));
await waitFor(
async () =>
(await state("owner-c")).dialogs.length === 1 && (await state("owner-c")).menus.length === 1,
"Parent abort did not settle its UI operations"
);
assertEquals((await state("owner-c")).dialogs[0], { kind: "rejected", message: "Fixture UI owner ended" });
assertEquals((await state("owner-c")).menus[0], "resolved");
assertEquals(await ownerC.locator(".dialog-host").count(), 0);
assertEquals(await ownerC.locator(".background").count(), 0);
assertEquals(await ownerB.locator(".dialog-host").count(), 1);
assertEquals(await ownerB.locator(".background").count(), 1);
await startDialog("owner-c", "after-parent-abort");
await startMenu("owner-c", "after parent abort menu");
await waitFor(
async () =>
(await state("owner-c")).dialogs.length === 2 && (await state("owner-c")).menus.length === 2,
"Post-abort UI operations did not settle"
);
assertEquals((await state("owner-c")).dialogs[1], { kind: "rejected", message: "Fixture UI owner ended" });
assertEquals((await state("owner-c")).menus[1], "resolved");
assertEquals(await ownerC.locator(".dialog-host").count(), 0);
assertEquals(await ownerC.locator(".background").count(), 0);
await startDialog("owner-d", "pre-aborted");
await startMenu("owner-d", "pre-aborted menu");
await waitFor(
async () =>
(await state("owner-d")).dialogs.length === 1 && (await state("owner-d")).menus.length === 1,
"Pre-aborted UI operations did not settle"
);
assertEquals((await state("owner-d")).dialogs[0], { kind: "rejected", message: "Fixture UI owner ended" });
assertEquals((await state("owner-d")).menus[0], "resolved");
assertEquals(await ownerD.locator(".dialog-host").count(), 0);
assertEquals(await ownerD.locator(".background").count(), 0);
await page.evaluate(() => window.browserUiLifecycle!.hideMenu("owner-b"));
await waitFor(async () => (await state("owner-b")).menus.length === 1, "Owner B menu did not close");
await ownerB.getByTestId("close-dialog").click();
await waitFor(async () => (await state("owner-b")).dialogs.length === 2, "Owner B dialogue did not close");
assertEquals(await ownerB.locator(".background").count(), 0);
assertEquals(await ownerB.locator(".dialog-host").count(), 0);
assertNoPageFailures();
} finally {
await browser.close();
await server.close();
}
},
});
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Browser UI lifecycle fixture</title>
</head>
<body>
<iframe id="owner-a" title="Owner A"></iframe>
<iframe id="owner-b" title="Owner B"></iframe>
<iframe id="owner-c" title="Owner C"></iframe>
<iframe id="owner-d" title="Owner D"></iframe>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -0,0 +1,16 @@
<script lang="ts">
import { getDialogContext, type GuestDialogProps } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog";
const { setResult, getInitialData }: GuestDialogProps<string, string> = $props();
const dialog = getDialogContext<string, string>();
dialog.setTitle("Lifecycle dialogue");
function submitResult() {
setResult(`result:${getInitialData?.() ?? "none"}`);
}
</script>
<section data-testid="lifecycle-dialog">
<button data-testid="submit-dialog" onclick={submitResult}>Submit result</button>
<button data-testid="close-dialog" onclick={() => dialog.closeDialog()}>Close without result</button>
</section>
+154
View File
@@ -0,0 +1,154 @@
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { MemoryValueStore } from "@vrtmrz/value-store";
import { Menu } from "@/apps/browser/BrowserMenu";
import { createLiveSyncBrowserServiceHub } from "@/apps/browser/createLiveSyncBrowserServiceHub";
import TestLifecycleDialog from "./TestLifecycleDialog.svelte";
type OwnerName = "owner-a" | "owner-b" | "owner-c" | "owner-d";
type DialogOutcome =
| { readonly kind: "resolved"; readonly value: unknown }
| { readonly kind: "rejected"; readonly message: string };
interface Owner {
readonly context: ReturnType<typeof createServiceContext>;
readonly document: Document;
readonly hub: ReturnType<typeof createLiveSyncBrowserServiceHub>;
readonly menu: Menu;
readonly parentAbort?: AbortController;
readonly dialogOutcomes: DialogOutcome[];
readonly menuOutcomes: string[];
savedBackdrop?: HTMLElement;
}
interface BrowserUiLifecycleFixture {
readonly ready: boolean;
startDialog(name: OwnerName, initialData?: string): void;
startMenu(name: OwnerName, title: string): void;
clickSavedBackdrop(name: OwnerName): void;
hideMenu(name: OwnerName): void;
unload(name: OwnerName): void;
abortParent(name: OwnerName): void;
state(name: OwnerName): {
readonly dialogs: readonly DialogOutcome[];
readonly menus: readonly string[];
readonly hasSavedBackdrop: boolean;
};
}
declare global {
interface Window {
browserUiLifecycle?: BrowserUiLifecycleFixture;
}
}
const owners = new Map<OwnerName, Owner>();
function ownerDocument(name: OwnerName): Document {
const frame = document.getElementById(name);
if (!(frame instanceof HTMLIFrameElement) || !frame.contentDocument) {
throw new Error(`The ${name} frame is not ready`);
}
return frame.contentDocument;
}
function createOwner(name: OwnerName, options: { parentAbort?: AbortController; preAborted?: boolean } = {}): Owner {
const frameDocument = ownerDocument(name);
const parentAbort = options.parentAbort;
if (options.preAborted) {
parentAbort?.abort(new Error("Fixture UI owner ended"));
}
const context = createServiceContext();
const hub = createLiveSyncBrowserServiceHub({
context,
deviceValues: new MemoryValueStore(),
ui: {
document: frameDocument,
...(parentAbort ? { signal: parentAbort.signal } : {}),
},
});
return {
context,
document: frameDocument,
hub,
menu: new Menu({ document: frameDocument, signal: hub.browserUi.signal }),
...(parentAbort ? { parentAbort } : {}),
dialogOutcomes: [],
menuOutcomes: [],
};
}
const ownerCAbort = new AbortController();
const ownerDAbort = new AbortController();
ownerDAbort.abort(new Error("Fixture UI owner ended"));
owners.set("owner-a", createOwner("owner-a"));
owners.set("owner-b", createOwner("owner-b"));
owners.set("owner-c", createOwner("owner-c", { parentAbort: ownerCAbort }));
owners.set("owner-d", createOwner("owner-d", { parentAbort: ownerDAbort, preAborted: true }));
const fixture: BrowserUiLifecycleFixture = {
ready: true,
startDialog(name, initialData) {
const owner = owners.get(name)!;
try {
void owner.hub.UI.dialogManager.open(TestLifecycleDialog, initialData).then(
(value) =>
owner.dialogOutcomes.push({ kind: "resolved", value: value === undefined ? "undefined" : value }),
(error: unknown) =>
owner.dialogOutcomes.push({
kind: "rejected",
message: error instanceof Error ? error.message : String(error),
})
);
} catch (error) {
owner.dialogOutcomes.push({
kind: "rejected",
message: error instanceof Error ? error.message : String(error),
});
}
},
startMenu(name, title) {
const owner = owners.get(name)!;
owner.savedBackdrop = owner.document.querySelector<HTMLElement>(".background") ?? undefined;
try {
void owner.menu
.addItem((item) => item.setTitle(title))
.showAtPosition({ x: 12, y: 12 })
.then(
() => owner.menuOutcomes.push("resolved"),
(error: unknown) =>
owner.menuOutcomes.push(`rejected:${error instanceof Error ? error.message : String(error)}`)
);
} catch (error) {
owner.menuOutcomes.push(`rejected:${error instanceof Error ? error.message : String(error)}`);
}
},
clickSavedBackdrop(name) {
const owner = owners.get(name)!;
const window = owner.document.defaultView;
if (owner.savedBackdrop && window) {
owner.savedBackdrop.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
}
},
hideMenu(name) {
owners.get(name)!.menu.hide();
},
unload(name) {
owners.get(name)!.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
},
abortParent(name) {
owners.get(name)!.parentAbort?.abort(new Error("Fixture UI owner ended"));
},
state(name) {
const owner = owners.get(name)!;
return {
dialogs: [...owner.dialogOutcomes],
menus: [...owner.menuOutcomes],
hasSavedBackdrop: owner.savedBackdrop !== undefined,
};
},
};
window.browserUiLifecycle = fixture;
@@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"]
},
"include": ["src/**/*.ts", "src/**/*.svelte"],
"exclude": []
}
@@ -0,0 +1,25 @@
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { defineConfig } from "vite";
const fixtureDirectory = dirname(fileURLToPath(import.meta.url));
const repositoryRoot = resolve(fixtureDirectory, "../../..");
export default defineConfig({
root: fixtureDirectory,
plugins: [svelte()],
resolve: {
alias: {
"@": resolve(repositoryRoot, "src"),
},
},
base: "./",
build: {
outDir: resolve(repositoryRoot, "_testdata/browser-ui-lifecycle"),
emptyOutDir: true,
rollupOptions: {
input: resolve(fixtureDirectory, "index.html"),
},
},
});
@@ -38,7 +38,10 @@ type ObsidianTestApp = {
| {
core?: {
services?: {
conflict?: { ensureAllProcessed(): Promise<boolean> };
conflict?: {
ensureAllProcessed(): Promise<boolean>;
conflictProcessQueueCount: { value: number };
};
};
};
}
@@ -607,11 +610,40 @@ async function main(): Promise<void> {
await requestRepeatedConflictChecks(cliBinary, repeatedSession.cliEnv, repeatedPath, 3);
await page.waitForFunction((element) => !element.isConnected, original, { timeout: uiTimeoutMs });
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await page.waitForTimeout(250);
// All enqueue calls have settled. Only the final resolver may remain
// while its dialogue waits for a response; earlier checks also count.
await page.waitForFunction(
(targetPath) => {
const conflict = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins?.["obsidian-livesync"]
?.core?.services?.conflict;
const visible = Array.from(document.querySelectorAll(".modal-container")).filter(
(element) =>
element.getClientRects().length > 0 &&
element.querySelector(".modal-title")?.textContent?.includes("Conflicting changes") &&
element.textContent?.includes(targetPath)
);
return conflict?.conflictProcessQueueCount.value === 1 && visible.length === 1;
},
repeatedPath,
{ timeout: uiTimeoutMs }
);
const visibleCount = await conflictDialogue(page, repeatedPath).evaluateAll(
(elements) => elements.filter((element) => element.getClientRects().length > 0).length
);
if (visibleCount !== 1) {
const dialogues = await page.locator(".modal-container").evaluateAll((elements) =>
elements.map((element) => ({
visible: element.getClientRects().length > 0,
text: element.textContent?.slice(0, 1200),
}))
);
const fixture = await readConflictFixture(cliBinary, repeatedSession.cliEnv, repeatedPath);
console.error(JSON.stringify({ sameFileReplacementFailure: { visibleCount, dialogues, fixture } }));
await captureObsidianPage(
repeatedSession.remoteDebuggingPort,
"conflict-dialog-same-file-replacement.failure.png",
async () => {}
);
throw new Error(`Expected one newest same-file dialogue, but found ${visibleCount}`);
}
});
@@ -265,7 +265,51 @@ async function main(): Promise<void> {
);
screenshotPaths.push(await captureStep(page, "06-after-search-next-match"));
const highlightDiff = modal.locator('.history-highlight-diff-label input[type="checkbox"]');
await highlightDiff.check();
const diffBlocks = modal.locator(".history-added, .history-deleted");
await diffBlocks.first().waitFor({ state: "visible", timeout: 10000 });
const diffIndicator = modal.locator(".diff-nav-indicator");
const diffCount = await diffBlocks.count();
const assertDiffSelection = async (index: number, count: number) => {
assertEqual(
(await diffIndicator.innerText()).trim(),
`${index + 1}/${count}`,
"Unexpected diff indicator."
);
const focused = await diffBlocks.evaluateAll((elements) =>
elements.flatMap((element, index) => (element.classList.contains("diff-focused") ? [index] : []))
);
assertEqual(
JSON.stringify(focused),
JSON.stringify([index]),
"Diff focus did not move to exactly one block."
);
};
await assertDiffSelection(0, diffCount);
const diffPrev = modal.locator(".diff-nav-btn").first();
const diffNext = modal.locator(".diff-nav-btn").last();
await diffPrev.click();
await assertDiffSelection(diffCount - 1, diffCount);
await diffNext.click();
await assertDiffSelection(0, diffCount);
for (let index = 0; index < diffCount; index++) await diffNext.click();
await assertDiffSelection(0, diffCount);
const oldBlock = await diffBlocks.first().elementHandle();
if (oldBlock === null) throw new Error("Could not retain the previous revision's diff block");
const changeRevision = (await nextBtn.isDisabled()) ? prevBtn : nextBtn;
await changeRevision.click();
await page.waitForFunction((element) => !element.isConnected, oldBlock, { timeout: 10000 });
await diffBlocks.first().waitFor({ state: "visible", timeout: 10000 });
const replacementDiffCount = await diffBlocks.count();
await assertDiffSelection(0, replacementDiffCount);
await diffPrev.click();
await assertDiffSelection(replacementDiffCount - 1, replacementDiffCount);
screenshotPaths.push(await captureStep(page, "07-diff-navigation-after-revision-change"));
return [
`diffNavigation: ${diffCount} blocks; replacement revision: ${replacementDiffCount} blocks`,
`initialIndicator: ${initialIndicator}`,
`afterPrevIndicator: ${afterPrevIndicator}`,
`afterNextIndicator: ${afterNextIndicator}`,