mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-03 06:48:18 +00:00
Own browser UI sessions and shared navigation
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user