mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-03 14:58:18 +00:00
Merge shared runtime changes onto current main
This commit is contained in:
commit
6211597d2d
128 files changed
+9204
-1285
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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { minify } from "terser";
|
||||
import { terserOption } from "../../terser.config.mjs";
|
||||
|
||||
const fixture = `
|
||||
const host = require("obsidian");
|
||||
class ModuleLog {
|
||||
constructor(configuration) { this.savedSettings = configuration; }
|
||||
read(longParameterName) {
|
||||
const longIntermediateValue = host.prefix + longParameterName;
|
||||
return { savedSettings: this.savedSettings, message: longIntermediateValue };
|
||||
}
|
||||
}
|
||||
function renderMessage(inputValue) { return new ModuleLog({ persistedField: "unchanged" }).read(inputValue); }
|
||||
function readWithEval(longEvalParameter) { return eval("longEvalParameter"); }
|
||||
function failWithName() { throw new Error("fixture failure"); }
|
||||
module.exports = { ModuleLog, renderMessage, readWithEval, failWithName };
|
||||
`;
|
||||
|
||||
describe("production minifier", () => {
|
||||
it("shortens local identifiers while preserving host properties, module names, and diagnostics", async () => {
|
||||
const result = await minify(fixture, { ...terserOption, sourceMap: false });
|
||||
expect(result.code).toBeDefined();
|
||||
const sandbox = {
|
||||
module: {
|
||||
exports: {} as {
|
||||
ModuleLog: { name: string };
|
||||
renderMessage(input: string): unknown;
|
||||
readWithEval(input: string): string;
|
||||
failWithName(): never;
|
||||
},
|
||||
},
|
||||
require: (name: string) => {
|
||||
expect(name).toBe("obsidian");
|
||||
return { prefix: "host:" };
|
||||
},
|
||||
};
|
||||
runInNewContext(result.code!, sandbox);
|
||||
const api = sandbox.module.exports;
|
||||
expect(api.ModuleLog.name).toBe("ModuleLog");
|
||||
expect(api.renderMessage.name).toBe("renderMessage");
|
||||
expect(api.renderMessage("sample")).toEqual({
|
||||
savedSettings: { persistedField: "unchanged" },
|
||||
message: "host:sample",
|
||||
});
|
||||
expect(api.readWithEval("evaluated")).toBe("evaluated");
|
||||
let failure: Error | undefined;
|
||||
try {
|
||||
api.failWithName();
|
||||
} catch (error) {
|
||||
failure = error as Error;
|
||||
}
|
||||
expect(failure?.stack).toContain("failWithName");
|
||||
expect(result.code).not.toContain("longParameterName");
|
||||
expect(result.code).not.toContain("longIntermediateValue");
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,21 @@ test("CLI file operations: push / cat / ls / info / rm / resolve / cat-rev / pul
|
||||
console.log("[PASS] push/cat roundtrip matched");
|
||||
});
|
||||
|
||||
await t.step("push/pull binary roundtrip", async () => {
|
||||
const source = workDir.join("binary-source.bin");
|
||||
const restored = workDir.join("binary-restored.bin");
|
||||
const remotePath = "test/blob-roundtrip.bin";
|
||||
const content = Uint8Array.from({ length: 1025 }, (_, index) => index % 256);
|
||||
await Deno.writeFile(source, content);
|
||||
|
||||
await run("push", source, remotePath);
|
||||
await run("pull", remotePath, restored);
|
||||
|
||||
assertEquals(await Deno.readFile(restored), content, "Binary push/pull must preserve every byte");
|
||||
const info = JSON.parse(await run("info", remotePath));
|
||||
assertEquals(info.size, content.byteLength, "Binary Metadata must retain the original byte length");
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// ls: single file
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -270,6 +270,10 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms
|
||||
|
||||
`test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. That target-pattern case keeps a normal-file allow-list for Markdown and PDF files enabled on both devices, then requires exact hidden JSON content after the receiving device includes it. Initial enablement must open one user-visible progress Notice before the enabled setting is saved, then retain that Notice while its nested rebuild and scan phases continue in the ordinary log. The configured fixture starts with a current CouchDB remote profile, so migration from legacy remote settings remains the responsibility of the upgrade scenarios and cannot add unrelated Notices to this check. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one separate action Notice whose controls remain usable in mobile layouts; a manually dismissed group must not repeat its acknowledged rows when a later change arrives.
|
||||
|
||||
The JSON conflict cases enter through `resolveConflictOnInternalFiles()`, exercising the analysis queue and the serial manual-resolution queue. They check the stored content and remaining conflict revisions as well as the Vault files; the manual case must remain unfinished until a choice is made.
|
||||
|
||||
Separate Vault pairs begin with Hidden File Sync disabled and enable it through the visible `Merge`, `Fetch`, and `Overwrite` buttons. Each pair contains a newer local file, a newer database file, and files present on only one side. The workflow verifies the selected contents in the files and database, the saved enablement setting, persistence after restart, and convergence on the other device. `--case=conflicts` runs only the conflict cases; `--case=enablement` runs only these enablement cases. Add `--mode=Merge`, `--mode=Fetch`, or `--mode=Overwrite` to select one enablement method.
|
||||
|
||||
`test:e2e:obsidian:hidden-file-selectors` stores and restores selected JS, CSS, and JSON files with a normal-file allow-list set, and compares both directions with an empty allow-list. It uses one temporary Vault and a local database, and also runs in the complete local suite. Run it through the focused wrapper to build the current bundle first; no remote service is required. Set `E2E_HIDDEN_FILE_SELECTORS_OUTPUT` to choose the JSON report path.
|
||||
|
||||
Before enablement, the scenario directly requests initialisation while Hidden File Sync is disabled. It requires a refused result, unchanged local files, Metadata revisions, and processed markers, and no initialisation progress Notice.
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { decodeBinary } from "octagonal-wheels/binary";
|
||||
import { VERSIONING_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ENCRYPTED_INTERNAL_METADATA_FEATURE, REMOTE_FEATURE_GENERATION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
captureJsonResolveDialogue,
|
||||
clickJsonResolveOption,
|
||||
obsidianRemoteDebuggingPort,
|
||||
openLiveSyncSettings,
|
||||
withObsidianPage,
|
||||
} from "../runner/ui.ts";
|
||||
import { iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
|
||||
@@ -60,6 +62,7 @@ const snippetContent = [
|
||||
|
||||
const mergeJsonPath = ".obsidian/livesync-e2e-merge.json";
|
||||
const manualMergeJsonPath = ".obsidian/livesync-e2e-manual-merge.json";
|
||||
const hiddenConflictStateKey = "__livesyncE2EHiddenConflictResolution";
|
||||
const targetPath = ".obsidian/livesync-targeted/only-a.json";
|
||||
const hiddenFileCliTimeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_CLI_TIMEOUT_MS ?? 90000);
|
||||
const hiddenFileInitialisationStateKey = "__livesyncE2EHiddenFileInitialisation";
|
||||
@@ -85,6 +88,8 @@ type RunnerContext = {
|
||||
couchDb: CouchDbConfig;
|
||||
dbName: string;
|
||||
activeSessions: Set<ObsidianLiveSyncSession>;
|
||||
temporaryVaults: Set<TemporaryVault>;
|
||||
diagnosticPaths: string[];
|
||||
};
|
||||
|
||||
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
|
||||
@@ -313,72 +318,82 @@ async function resolveHiddenConflicts(cliBinary: string, env: NodeJS.ProcessEnv)
|
||||
);
|
||||
}
|
||||
|
||||
async function autoMergeHiddenJsonConflict(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
async function startHiddenConflictResolution(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
await evalObsidianJson(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
"const prefixedPath=`i:${path}`;",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const addOn=core.getAddOn('HiddenFileSync');",
|
||||
"let doc=false;",
|
||||
"for await (const entry of core.localDatabase.findEntries('i:','i;',{conflicts:true})){",
|
||||
" if(entry.path===prefixedPath){ doc=entry; break; }",
|
||||
"}",
|
||||
"if(!doc) throw new Error(`Could not find hidden conflict candidate: ${path}`);",
|
||||
"if(!doc._conflicts?.length) throw new Error(`Hidden file has no conflicts: ${path}`);",
|
||||
"const conflicts=doc._conflicts.sort((a,b)=>Number(a.split('-')[0])-Number(b.split('-')[0]));",
|
||||
"const conflictedRev=conflicts[0];",
|
||||
"const conflictedRevNo=Number(conflictedRev.split('-')[0]);",
|
||||
"const revFrom=await core.localDatabase.getRaw(doc._id,{revs_info:true});",
|
||||
"const commonBase=(revFrom._revs_info||[])",
|
||||
" .filter((rev)=>rev.status==='available'&&Number(rev.rev.split('-')[0])<conflictedRevNo)",
|
||||
" .map((rev)=>rev.rev)[0]||'';",
|
||||
"const result=await core.localDatabase.managers.conflictManager.mergeObject(",
|
||||
" doc.path, commonBase, doc._rev, conflictedRev",
|
||||
"(()=>{",
|
||||
`const key=${JSON.stringify(hiddenConflictStateKey)};`,
|
||||
"const state={done:false,error:null};",
|
||||
"globalThis[key]=state;",
|
||||
"const addOn=app.plugins.plugins['obsidian-livesync'].core.getAddOn('HiddenFileSync');",
|
||||
"void addOn.resolveConflictOnInternalFiles().then(",
|
||||
"()=>{state.done=true;},",
|
||||
"error=>{state.error=String(error);state.done=true;}",
|
||||
");",
|
||||
"if(!result){",
|
||||
" throw new Error(`Hidden JSON conflict was not auto-mergeable: ${path}; base=${commonBase}; current=${doc._rev}; conflict=${conflictedRev}`);",
|
||||
"}",
|
||||
"await addOn.ensureDir(path);",
|
||||
"const stat=await addOn.writeFile(path,result);",
|
||||
"if(!stat) throw new Error(`Could not write merged hidden file: ${path}`);",
|
||||
"await addOn.storeInternalFileToDatabase({path,mtime:stat.mtime,ctime:stat.ctime,size:stat.size},true);",
|
||||
"await core.localDatabase.removeRevision(doc._id,conflictedRev);",
|
||||
"await addOn.extractInternalFileFromDatabase(path);",
|
||||
"await addOn.scanAllDatabaseChanges(true);",
|
||||
"return JSON.stringify({ok:true,merged:JSON.parse(result)});",
|
||||
"return JSON.stringify({started:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function openHiddenJsonResolveModal(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
async function waitForHiddenConflictResolution(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
await evalObsidianJson(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const key=${JSON.stringify(hiddenConflictStateKey)};`,
|
||||
"const state=globalThis[key];",
|
||||
"if(!state)throw new Error('Hidden conflict resolution was not started');",
|
||||
"const deadline=Date.now()+60000;",
|
||||
"while(!state.done&&Date.now()<deadline)await new Promise(resolve=>setTimeout(resolve,50));",
|
||||
"if(!state.done)throw new Error('Hidden conflict pipeline did not complete');",
|
||||
"if(state.error)throw new Error(state.error);",
|
||||
"delete globalThis[key];",
|
||||
"return JSON.stringify({done:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env,
|
||||
hiddenFileCliTimeoutMs
|
||||
);
|
||||
}
|
||||
|
||||
async function assertResolvedHiddenContent(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
path: string,
|
||||
expectedContent: string
|
||||
): Promise<void> {
|
||||
const stored = await evalObsidianJson<{
|
||||
rev: string;
|
||||
currentRev: string;
|
||||
conflicts: string[];
|
||||
datatype: string;
|
||||
data: string | string[];
|
||||
}>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
"const prefixedPath=`i:${path}`;",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const addOn=core.getAddOn('HiddenFileSync');",
|
||||
"let doc=false;",
|
||||
"for await (const entry of core.localDatabase.findEntries('i:','i;',{conflicts:true})){",
|
||||
" if(entry.path===prefixedPath){ doc=entry; break; }",
|
||||
"}",
|
||||
"if(!doc?._conflicts?.length) throw new Error(`Could not find hidden JSON conflict: ${path}`);",
|
||||
"const conflicts=doc._conflicts.sort((a,b)=>Number(a.split('-')[0])-Number(b.split('-')[0]));",
|
||||
"const docA=await core.localDatabase.getDBEntry(prefixedPath,{rev:doc._rev});",
|
||||
"const docB=await core.localDatabase.getDBEntry(prefixedPath,{rev:conflicts[0]});",
|
||||
"if(docA===false||docB===false) throw new Error(`Could not load conflicted hidden JSON entries: ${path}`);",
|
||||
"void addOn.showJSONMergeDialogAndMerge(docA,docB);",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"const db=app.plugins.plugins['obsidian-livesync'].core.localDatabase;",
|
||||
"const entry=await db.getDBEntry(`i:${path}`);",
|
||||
"if(!entry)throw new Error(`Resolved hidden file is missing: ${path}`);",
|
||||
"const current=await db.getRaw(entry._id,{conflicts:true});",
|
||||
"return JSON.stringify({rev:entry._rev,currentRev:current._rev,conflicts:current._conflicts||[],datatype:entry.datatype,data:entry.data});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
assertEqual(stored.rev, stored.currentRev, `Hidden result changed while reading ${path}.`);
|
||||
assertEqual(stored.conflicts.length, 0, `Hidden conflict remains for ${path}.`);
|
||||
const content =
|
||||
stored.datatype === "plain"
|
||||
? typeof stored.data === "string"
|
||||
? stored.data
|
||||
: stored.data.join("")
|
||||
: new TextDecoder().decode(decodeBinary(stored.data));
|
||||
assertEqual(content, expectedContent, `Stored hidden content differs from the Vault file: ${path}.`);
|
||||
}
|
||||
|
||||
async function storeHiddenFileAsConflict(
|
||||
@@ -437,6 +452,15 @@ async function createHiddenJsonConflict(
|
||||
|
||||
await writeVaultFile(vault.path, path, right);
|
||||
await storeHiddenFileAsConflict(context.cliBinary, session.cliEnv, path, baseEntry.rev);
|
||||
const conflicts = await evalObsidianJson<number>(
|
||||
context.cliBinary,
|
||||
`(async()=>{
|
||||
const entry=await app.plugins.plugins['obsidian-livesync'].core.localDatabase.getDBEntryMeta(${JSON.stringify(`i:${path}`)},{conflicts:true});
|
||||
return JSON.stringify(entry?entry._conflicts?.length||0:0);
|
||||
})()`,
|
||||
session.cliEnv
|
||||
);
|
||||
assertEqual(conflicts, 1, "The hidden conflict fixture did not retain two live revisions.");
|
||||
}
|
||||
|
||||
async function startConfiguredSession(
|
||||
@@ -601,11 +625,12 @@ async function runJsonConflictRoundTrip(
|
||||
|
||||
let session = await startConfiguredSession(context, vaultB);
|
||||
await createHiddenJsonConflict(context, session, vaultB, mergeJsonPath, base, left, right);
|
||||
await autoMergeHiddenJsonConflict(context.cliBinary, session.cliEnv, mergeJsonPath);
|
||||
await resolveHiddenConflicts(context.cliBinary, session.cliEnv);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
const mergedOnB = await waitForPathContent(vaultB.path, mergeJsonPath, (content) =>
|
||||
hasJsonValues(content, { fromA: true, fromB: true })
|
||||
);
|
||||
await assertResolvedHiddenContent(context.cliBinary, session.cliEnv, mergeJsonPath, mergedOnB);
|
||||
await stopSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultA);
|
||||
@@ -613,6 +638,7 @@ async function runJsonConflictRoundTrip(
|
||||
const mergedOnA = await waitForPathContent(vaultA.path, mergeJsonPath, (content) =>
|
||||
hasJsonValues(content, { fromA: true, fromB: true })
|
||||
);
|
||||
await assertResolvedHiddenContent(context.cliBinary, session.cliEnv, mergeJsonPath, mergedOnA);
|
||||
await stopSession(context, session);
|
||||
|
||||
assertEqual(mergedOnA, mergedOnB, "Merged hidden JSON content was not consistent across both vaults.");
|
||||
@@ -626,13 +652,21 @@ async function runJsonManualConflictResolution(context: RunnerContext, vault: Te
|
||||
|
||||
const session = await startConfiguredSession(context, vault);
|
||||
await createHiddenJsonConflict(context, session, vault, manualMergeJsonPath, base, left, right);
|
||||
await openHiddenJsonResolveModal(context.cliBinary, session.cliEnv, manualMergeJsonPath);
|
||||
await startHiddenConflictResolution(context.cliBinary, session.cliEnv);
|
||||
const screenshotPath = await captureJsonResolveDialogue(obsidianRemoteDebuggingPort());
|
||||
const completedBeforeSelection = await evalObsidianJson<boolean>(
|
||||
context.cliBinary,
|
||||
`JSON.stringify(globalThis[${JSON.stringify(hiddenConflictStateKey)}].done)`,
|
||||
session.cliEnv
|
||||
);
|
||||
assertEqual(completedBeforeSelection, false, "The hidden conflict pipeline completed before selection.");
|
||||
await clickJsonResolveOption(obsidianRemoteDebuggingPort(), "AB");
|
||||
await waitForHiddenConflictResolution(context.cliBinary, session.cliEnv);
|
||||
|
||||
const merged = await waitForPathContent(vault.path, manualMergeJsonPath, (content) =>
|
||||
hasJsonValues(content, { shared: "right", fromA: true, fromB: true })
|
||||
);
|
||||
await assertResolvedHiddenContent(context.cliBinary, session.cliEnv, manualMergeJsonPath, merged);
|
||||
await stopSession(context, session);
|
||||
|
||||
const parsed = JSON.parse(merged);
|
||||
@@ -1033,7 +1067,212 @@ async function runConfigurationNoticeGrouping(context: RunnerContext, vault: Tem
|
||||
}
|
||||
}
|
||||
|
||||
type HiddenEnablementMode = "Merge" | "Fetch" | "Overwrite";
|
||||
|
||||
async function enableHiddenSyncThroughSettings(
|
||||
context: RunnerContext,
|
||||
session: ObsidianLiveSyncSession,
|
||||
vault: TemporaryVault,
|
||||
mode: HiddenEnablementMode
|
||||
): Promise<void> {
|
||||
const stateKey = "__livesyncE2EHiddenEnablement";
|
||||
await evalObsidianJson(
|
||||
context.cliBinary,
|
||||
`(()=>{
|
||||
const core=app.plugins.plugins['obsidian-livesync'].core;
|
||||
if(core.settings.syncInternalFiles)throw new Error('Hidden File Sync must start disabled');
|
||||
const setting=core.services.setting;
|
||||
const original=setting.enableOptionalFeature;
|
||||
const state={done:false,error:null,mode:null,restore:()=>{setting.enableOptionalFeature=original;}};
|
||||
globalThis[${JSON.stringify(stateKey)}]=state;
|
||||
setting.enableOptionalFeature=async function(mode){
|
||||
state.mode=mode;
|
||||
try{return await original.call(this,mode);}
|
||||
catch(error){state.error=String(error);throw error;}
|
||||
finally{state.done=true;state.restore();}
|
||||
};
|
||||
return JSON.stringify(true);
|
||||
})()`,
|
||||
session.cliEnv
|
||||
);
|
||||
try {
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const navigator = await openLiveSyncSettings(page);
|
||||
const settingsPage = await navigator.openPage("Sync Settings");
|
||||
const row = settingsPage.locator(".setting-item").filter({
|
||||
has: navigator.page.getByRole("button", { name: "Merge", exact: true }),
|
||||
});
|
||||
await row.getByRole("button", { name: mode, exact: true }).click();
|
||||
});
|
||||
const result = await evalObsidianJson<{ mode: string; enabled: boolean }>(
|
||||
context.cliBinary,
|
||||
`
|
||||
(async()=>{
|
||||
const state=globalThis[${JSON.stringify(stateKey)}];
|
||||
const deadline=Date.now()+60000;
|
||||
while(!state.done&&Date.now()<deadline)await new Promise(resolve=>setTimeout(resolve,50));
|
||||
if(!state.done)throw new Error('Hidden File Sync enablement did not complete');
|
||||
if(state.error)throw new Error(state.error);
|
||||
const core=app.plugins.plugins['obsidian-livesync'].core;
|
||||
return JSON.stringify({mode:state.mode,enabled:core.settings.syncInternalFiles});
|
||||
})()
|
||||
`,
|
||||
session.cliEnv,
|
||||
hiddenFileCliTimeoutMs
|
||||
);
|
||||
assertEqual(result.mode, mode.toUpperCase(), "The settings action invoked a different enablement mode.");
|
||||
assertEqual(result.enabled, true, "Hidden File Sync was not enabled by its settings action.");
|
||||
const saved = JSON.parse(await readVaultFile(vault.path, ".obsidian/plugins/obsidian-livesync/data.json"));
|
||||
assertEqual(saved.syncInternalFiles, true, "Hidden File Sync enablement was not persisted.");
|
||||
} finally {
|
||||
await evalObsidianJson(
|
||||
context.cliBinary,
|
||||
`(()=>{
|
||||
globalThis[${JSON.stringify(stateKey)}]?.restore();
|
||||
delete globalThis[${JSON.stringify(stateKey)}];
|
||||
return JSON.stringify(true);
|
||||
})()`,
|
||||
session.cliEnv
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function startPersistedSession(context: RunnerContext, vault: TemporaryVault): Promise<ObsidianLiveSyncSession> {
|
||||
const session = await startObsidianLiveSyncSession({ binary: context.binary, cliBinary: context.cliBinary, vault });
|
||||
context.activeSessions.add(session);
|
||||
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
|
||||
const enabled = await evalObsidianJson<boolean>(
|
||||
context.cliBinary,
|
||||
"JSON.stringify(app.plugins.plugins['obsidian-livesync'].core.settings.syncInternalFiles)",
|
||||
session.cliEnv
|
||||
);
|
||||
assertEqual(enabled, true, "Hidden File Sync enablement did not survive restart.");
|
||||
return session;
|
||||
}
|
||||
|
||||
async function verifyHiddenFiles(
|
||||
context: RunnerContext,
|
||||
session: ObsidianLiveSyncSession,
|
||||
vault: TemporaryVault,
|
||||
expected: Record<string, string>
|
||||
): Promise<void> {
|
||||
for (const [path, content] of Object.entries(expected)) {
|
||||
await waitForPathContent(vault.path, path, (actual) => actual === content);
|
||||
await assertResolvedHiddenContent(context.cliBinary, session.cliEnv, path, content);
|
||||
}
|
||||
}
|
||||
|
||||
async function runHiddenEnablement(context: RunnerContext, mode: HiddenEnablementMode): Promise<void> {
|
||||
const source = await createTemporaryVault();
|
||||
context.temporaryVaults.add(source);
|
||||
const target = await createTemporaryVault();
|
||||
context.temporaryVaults.add(target);
|
||||
const root = `.obsidian/livesync-enable-${mode.toLowerCase()}`;
|
||||
const localNewer = `${root}/local-newer.json`;
|
||||
const remoteNewer = `${root}/remote-newer.json`;
|
||||
const remoteOnly = `${root}/remote-only.json`;
|
||||
const localOnly = `${root}/local-only.json`;
|
||||
context.diagnosticPaths = [localNewer, remoteNewer, remoteOnly, localOnly];
|
||||
const remoteContent = JSON.stringify({ source: "remote", mode }) + "\n";
|
||||
const localContent = JSON.stringify({ source: "local", mode }) + "\n";
|
||||
const timestamp = Math.floor(Date.now() / 1000) - 120;
|
||||
for (const path of [localNewer, remoteNewer, remoteOnly]) {
|
||||
await writeVaultFile(source.path, path, remoteContent);
|
||||
await utimes(join(source.path, path), timestamp + 10, timestamp + 10);
|
||||
}
|
||||
const settings = {
|
||||
syncInternalFiles: false,
|
||||
useAdvancedMode: true,
|
||||
syncInternalFilesTargetPatterns: `^\\.obsidian(?:$|/livesync-enable-${mode.toLowerCase()}(?:/|$))`,
|
||||
};
|
||||
let session = await startConfiguredSession(context, source, settings);
|
||||
await enableHiddenSyncThroughSettings(context, session, source, "Overwrite");
|
||||
await verifyHiddenFiles(context, session, source, {
|
||||
[localNewer]: remoteContent,
|
||||
[remoteNewer]: remoteContent,
|
||||
[remoteOnly]: remoteContent,
|
||||
});
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
const uploaded: LocalDatabaseEntry[] = [];
|
||||
for (const path of [localNewer, remoteNewer, remoteOnly]) {
|
||||
uploaded.push(await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path, { hidden: true }));
|
||||
}
|
||||
await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) =>
|
||||
uploaded.every((entry) => docs.some((doc) => doc._id === entry.id && doc._rev === entry.rev))
|
||||
);
|
||||
await stopSession(context, session);
|
||||
|
||||
for (const [path, time] of [
|
||||
[localNewer, timestamp + 30],
|
||||
[remoteNewer, timestamp],
|
||||
[localOnly, timestamp + 30],
|
||||
] as const) {
|
||||
await writeVaultFile(target.path, path, localContent);
|
||||
await utimes(join(target.path, path), time, time);
|
||||
}
|
||||
session = await startConfiguredSession(context, target, settings);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
for (const path of [localNewer, remoteNewer]) {
|
||||
assertEqual(await readVaultFile(target.path, path), localContent, "Disabled sync replaced a local fixture.");
|
||||
}
|
||||
const receivedRevisions = await evalObsidianJson<string[]>(
|
||||
context.cliBinary,
|
||||
`(async()=>{
|
||||
const db=app.plugins.plugins['obsidian-livesync'].core.localDatabase;
|
||||
const revisions=[];
|
||||
for(const id of ${JSON.stringify(uploaded.map((entry) => entry.id))}) {
|
||||
const doc=await db.getRaw(id);
|
||||
revisions.push(doc._rev);
|
||||
}
|
||||
return JSON.stringify(revisions);
|
||||
})()`,
|
||||
session.cliEnv
|
||||
);
|
||||
assertEqual(
|
||||
JSON.stringify(receivedRevisions),
|
||||
JSON.stringify(uploaded.map((entry) => entry.rev)),
|
||||
"Disabled Hidden File Sync did not retain the uploaded revisions in its database."
|
||||
);
|
||||
assertEqual(await pathExists(target.path, remoteOnly), false, "Disabled sync reflected a remote-only file.");
|
||||
|
||||
await enableHiddenSyncThroughSettings(context, session, target, mode);
|
||||
const expected = {
|
||||
[localNewer]: mode === "Fetch" ? remoteContent : localContent,
|
||||
[remoteNewer]: mode === "Overwrite" ? localContent : remoteContent,
|
||||
[remoteOnly]: remoteContent,
|
||||
[localOnly]: localContent,
|
||||
};
|
||||
await verifyHiddenFiles(context, session, target, expected);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await stopSession(context, session);
|
||||
|
||||
session = await startPersistedSession(context, target);
|
||||
await verifyHiddenFiles(context, session, target, expected);
|
||||
await stopSession(context, session);
|
||||
|
||||
session = await startPersistedSession(context, source);
|
||||
await pullAndApplyHiddenFiles(context, session);
|
||||
await verifyHiddenFiles(context, session, source, expected);
|
||||
await stopSession(context, session);
|
||||
console.log(
|
||||
`Hidden File Sync ${mode} preserved the selected contents in files and the database, after restart, and on the other device.`
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const selectedCase = process.argv.find((argument) => argument.startsWith("--case="))?.slice(7);
|
||||
if (selectedCase && selectedCase !== "conflicts" && selectedCase !== "enablement") {
|
||||
throw new Error("Use --case=conflicts or --case=enablement.");
|
||||
}
|
||||
const conflictsOnly = selectedCase === "conflicts";
|
||||
const enablementOnly = selectedCase === "enablement";
|
||||
const selectedMode = process.argv.find((argument) => argument.startsWith("--mode="))?.slice(7);
|
||||
const enablementModes = (["Merge", "Fetch", "Overwrite"] as const).filter(
|
||||
(mode) => !selectedMode || selectedMode === mode
|
||||
);
|
||||
if (selectedMode && (!enablementOnly || enablementModes.length !== 1)) {
|
||||
throw new Error("Use --case=enablement with --mode=Merge, --mode=Fetch, or --mode=Overwrite.");
|
||||
}
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) {
|
||||
@@ -1044,7 +1283,15 @@ async function main(): Promise<void> {
|
||||
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "hidden-roundtrip");
|
||||
const vaultA = await createTemporaryVault();
|
||||
const vaultB = await createTemporaryVault();
|
||||
const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName, activeSessions: new Set() };
|
||||
const context: RunnerContext = {
|
||||
binary,
|
||||
cliBinary: cli.binary,
|
||||
couchDb,
|
||||
dbName,
|
||||
activeSessions: new Set(),
|
||||
temporaryVaults: new Set([vaultA, vaultB]),
|
||||
diagnosticPaths: [mergeJsonPath, manualMergeJsonPath, snippetPath],
|
||||
};
|
||||
|
||||
try {
|
||||
await assertCouchDbReachable(couchDb);
|
||||
@@ -1055,17 +1302,56 @@ async function main(): Promise<void> {
|
||||
console.log(`Temporary vault B: ${vaultB.path}`);
|
||||
console.log(`Temporary CouchDB database: ${dbName}`);
|
||||
|
||||
await runCreateRoundTrip(context, vaultA, vaultB);
|
||||
await runDeleteRoundTrip(context, vaultA, vaultB);
|
||||
await runJsonConflictRoundTrip(context, vaultA, vaultB);
|
||||
await runJsonManualConflictResolution(context, vaultB);
|
||||
await runTargetMismatch(context, vaultA, vaultB);
|
||||
await runInitialisationNoticeGrouping(context, vaultB);
|
||||
await runConfigurationNoticeGrouping(context, vaultB);
|
||||
if (!conflictsOnly && !enablementOnly) {
|
||||
await runCreateRoundTrip(context, vaultA, vaultB);
|
||||
await runDeleteRoundTrip(context, vaultA, vaultB);
|
||||
}
|
||||
if (!enablementOnly) {
|
||||
await runJsonConflictRoundTrip(context, vaultA, vaultB);
|
||||
await runJsonManualConflictResolution(context, vaultB);
|
||||
}
|
||||
if (!conflictsOnly && !enablementOnly) {
|
||||
await runTargetMismatch(context, vaultA, vaultB);
|
||||
await runInitialisationNoticeGrouping(context, vaultB);
|
||||
await runConfigurationNoticeGrouping(context, vaultB);
|
||||
}
|
||||
if (!conflictsOnly) {
|
||||
for (const mode of enablementModes) await runHiddenEnablement(context, mode);
|
||||
}
|
||||
} catch (error) {
|
||||
for (const session of context.activeSessions) {
|
||||
await captureObsidianPage(
|
||||
session.remoteDebuggingPort,
|
||||
"hidden-file-workflow-failure.png",
|
||||
async () => {}
|
||||
).catch((captureError: unknown) => console.error(String(captureError)));
|
||||
await evalObsidianJson(
|
||||
cli.binary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const addOn=core.getAddOn('HiddenFileSync');",
|
||||
`const paths=${JSON.stringify(context.diagnosticPaths)};`,
|
||||
"const files=await addOn.scanInternalFileNames();",
|
||||
"const observations=[];",
|
||||
"for(const path of paths){",
|
||||
"const stat=await core.storageAccess.statHidden(path);",
|
||||
"const entry=await core.localDatabase.getDBEntryMeta(`i:${path}`,{conflicts:true});",
|
||||
"observations.push({path,selected:files.includes(path),target:await addOn.isTargetFile(path),stat,processed:addOn.getLastProcessedFileKey(path),entry:entry?{rev:entry._rev,conflicts:entry._conflicts||[]}:false});",
|
||||
"}",
|
||||
`const operation=globalThis[${JSON.stringify(hiddenConflictStateKey)}];`,
|
||||
"return JSON.stringify({enabled:core.settings.syncInternalFiles,ready:core.localDatabase.isReady,remaining:addOn.conflictResolutionProcessor.totalRemaining,operation,observations});",
|
||||
"})()",
|
||||
].join(""),
|
||||
session.cliEnv
|
||||
)
|
||||
.then((snapshot) => console.error("Hidden-file failure state:", JSON.stringify(snapshot)))
|
||||
.catch((snapshotError: unknown) => console.error(String(snapshotError)));
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
for (const session of context.activeSessions) await stopSession(context, session);
|
||||
await vaultA.dispose();
|
||||
await vaultB.dispose();
|
||||
for (const vault of context.temporaryVaults) await vault.dispose();
|
||||
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
|
||||
await deleteCouchDbDatabase(couchDb, dbName).catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : error);
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { LOG_LEVEL_URGENT } from "octagonal-wheels/common/logger";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { requireObsidianBinary, requireObsidianCli } from "../runner/environment.ts";
|
||||
import {
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObsidianDeviceLocalState,
|
||||
waitForLiveSyncCoreReady,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000";
|
||||
|
||||
type FixtureLog = {
|
||||
message: string;
|
||||
level?: number;
|
||||
key?: string;
|
||||
};
|
||||
|
||||
type ShutdownObservation = {
|
||||
closeCalls: number;
|
||||
disposeCalls: number;
|
||||
disposeCompleted: boolean;
|
||||
fixtureLogs: FixtureLog[];
|
||||
events: string[];
|
||||
};
|
||||
|
||||
async function evaluate<T>(cliBinary: string, session: ObsidianLiveSyncSession, body: string): Promise<T> {
|
||||
return await evalObsidianJson<T>(
|
||||
cliBinary,
|
||||
`(async()=>{
|
||||
const require=(condition,message)=>{if(!condition)throw new Error(message);};
|
||||
const waitFor=async(predicate,message)=>{
|
||||
const until=Date.now()+15000;
|
||||
while(!predicate()) {
|
||||
if(Date.now()>until)throw new Error(message);
|
||||
await new Promise(resolve=>setTimeout(resolve,20));
|
||||
}
|
||||
};
|
||||
${body}
|
||||
})()`,
|
||||
session.cliEnv
|
||||
);
|
||||
}
|
||||
|
||||
function createPluginData(dbName: string): Record<string, unknown> {
|
||||
return createE2eCouchDbPluginData(
|
||||
{ uri: "http://127.0.0.1:1", username: "unused", password: "unused", dbName },
|
||||
{
|
||||
syncInternalFiles: true,
|
||||
usePluginSync: true,
|
||||
usePluginSyncV2: true,
|
||||
deviceAndVaultName: "log-lifecycle-device",
|
||||
autoSweepPlugins: false,
|
||||
autoSweepPluginsPeriodic: false,
|
||||
syncInternalFilesInterval: 0,
|
||||
liveSync: false,
|
||||
periodicReplication: false,
|
||||
syncOnStart: false,
|
||||
syncOnSave: false,
|
||||
syncOnFileOpen: false,
|
||||
syncOnEditorSave: false,
|
||||
syncAfterMerge: false,
|
||||
showVerboseLog: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function startSession(
|
||||
binary: string,
|
||||
cliBinary: string,
|
||||
vault: TemporaryVault
|
||||
): Promise<ObsidianLiveSyncSession> {
|
||||
const session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary,
|
||||
vault,
|
||||
pluginData: createPluginData(`log-lifecycle-${vault.name}`),
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
lifecycle: {
|
||||
beforeLaunch: async () => {
|
||||
const registryPath = join(vault.userDataPath, "obsidian.json");
|
||||
const registry = JSON.parse(await readFile(registryPath, "utf8"));
|
||||
registry.updateDisabled = true;
|
||||
await writeFile(registryPath, JSON.stringify(registry));
|
||||
},
|
||||
},
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
function createShutdownScript(options: {
|
||||
duringMarker: string;
|
||||
failureMarker: string;
|
||||
reloadMarker: string;
|
||||
injectFailure: boolean;
|
||||
urgentLevel: number;
|
||||
}): string {
|
||||
const fixtures = JSON.stringify(options);
|
||||
return `
|
||||
const fixtures=${fixtures};
|
||||
const plugin=app.plugins.plugins["obsidian-livesync"];
|
||||
const core=plugin?.core;
|
||||
require(core,"The LiveSync plug-in did not expose its core");
|
||||
const module=core.modules.find(candidate=>candidate.constructor.name==="ModuleLog");
|
||||
require(module,"The loaded core did not contain ModuleLog");
|
||||
const api=core.services.API;
|
||||
const database=core.services.database.localDatabaseDirect;
|
||||
require(database,"The loaded core did not expose its local database");
|
||||
|
||||
let releaseCloseGate;
|
||||
const closeGate=new Promise(resolve=>{releaseCloseGate=resolve;});
|
||||
const state={
|
||||
core,
|
||||
api,
|
||||
module,
|
||||
fixtureLogs:[],
|
||||
events:[],
|
||||
closeStarted:false,
|
||||
closeCalls:0,
|
||||
disposeCalls:0,
|
||||
disposeCompleted:false,
|
||||
closeGateReleased:false,
|
||||
disableError:"",
|
||||
};
|
||||
app.__livesyncLogLifecycleState=state;
|
||||
|
||||
const originalAddLog=module.__addLog;
|
||||
module.__addLog=function(message,level,key) {
|
||||
if(typeof message==="string" && [
|
||||
fixtures.duringMarker,
|
||||
fixtures.failureMarker,
|
||||
fixtures.reloadMarker
|
||||
].some(marker=>marker && message.includes(marker))) {
|
||||
state.fixtureLogs.push({message,level,key});
|
||||
const marker=[fixtures.duringMarker,fixtures.failureMarker,fixtures.reloadMarker]
|
||||
.find(candidate=>candidate && message.includes(candidate));
|
||||
state.events.push("log:"+marker);
|
||||
}
|
||||
return originalAddLog.apply(this,arguments);
|
||||
};
|
||||
|
||||
const resource=module.logResource;
|
||||
const originalDispose=resource.dispose;
|
||||
resource.dispose=async function() {
|
||||
state.disposeCalls++;
|
||||
state.events.push("dispose-start");
|
||||
try {
|
||||
return await originalDispose.apply(this,arguments);
|
||||
} finally {
|
||||
state.disposeCompleted=true;
|
||||
state.events.push("dispose-complete");
|
||||
}
|
||||
};
|
||||
|
||||
const originalClose=database.close;
|
||||
database.close=async function() {
|
||||
state.closeCalls++;
|
||||
if(state.closeCalls===1) {
|
||||
state.closeStarted=true;
|
||||
state.events.push("close-start");
|
||||
await closeGate;
|
||||
}
|
||||
const result=await originalClose.apply(this,arguments);
|
||||
if(state.closeCalls===1) {
|
||||
state.events.push("close-finished");
|
||||
if(fixtures.injectFailure) {
|
||||
state.events.push("close-failure-injected");
|
||||
throw new Error(fixtures.failureMarker);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
state.restore=()=>{
|
||||
module.__addLog=originalAddLog;
|
||||
resource.dispose=originalDispose;
|
||||
database.close=originalClose;
|
||||
};
|
||||
|
||||
const run=async()=>{
|
||||
state.disablePromise=app.plugins.disablePlugin("obsidian-livesync");
|
||||
state.disablePromise.catch(error=>{state.disableError=String(error);});
|
||||
await waitFor(()=>state.closeStarted,"The real local database close did not start");
|
||||
require(state.disposeCalls===0 && !state.disposeCompleted,
|
||||
"The log resource was released before database shutdown completed");
|
||||
api.addLog(fixtures.duringMarker,undefined,"e2e-log-lifecycle-during-close");
|
||||
await waitFor(
|
||||
()=>state.fixtureLogs.some(entry=>entry.message.includes(fixtures.duringMarker)),
|
||||
"ModuleLog did not receive the during-close log"
|
||||
);
|
||||
require(state.disposeCalls===0,"The log resource was released while database close was gated");
|
||||
releaseCloseGate();
|
||||
state.closeGateReleased=true;
|
||||
await waitFor(()=>state.disposeCompleted,"The log resource was not released after database close");
|
||||
|
||||
require(state.closeCalls===1,"The local database close was not observed exactly once");
|
||||
require(state.disposeCalls===1,"The log resource was not disposed exactly once");
|
||||
require(state.events.indexOf("close-finished")>=0 &&
|
||||
state.events.indexOf("close-finished")<state.events.indexOf("dispose-start"),
|
||||
"The log resource was disposed before the local database close completed");
|
||||
|
||||
const failedLog=fixtures.failureMarker
|
||||
? state.fixtureLogs.find(entry=>entry.message.includes(fixtures.failureMarker))
|
||||
: undefined;
|
||||
if(fixtures.injectFailure) {
|
||||
require(failedLog,"The injected post-close failure was not logged");
|
||||
require(failedLog.key==="shutdown","The shutdown failure log used the wrong key");
|
||||
require(failedLog.level===fixtures.urgentLevel,"The shutdown failure log was not urgent");
|
||||
require(
|
||||
state.events.indexOf("close-finished")<state.events.indexOf("log:"+fixtures.failureMarker) &&
|
||||
state.events.indexOf("log:"+fixtures.failureMarker)<state.events.indexOf("dispose-start"),
|
||||
"The shutdown failure was not logged between database close and log resource disposal"
|
||||
);
|
||||
} else {
|
||||
require(!failedLog,"The normal shutdown unexpectedly reported the injected failure");
|
||||
}
|
||||
|
||||
let detached=false;
|
||||
try {
|
||||
api.addLog("e2e-log-lifecycle-after-disposal");
|
||||
} catch(error) {
|
||||
detached=String(error).includes("not assigned");
|
||||
}
|
||||
require(detached,"The old API log handler remained assigned after disposal");
|
||||
await state.disablePromise;
|
||||
require(!state.disableError,"Disabling the plug-in failed: "+state.disableError);
|
||||
return JSON.stringify({
|
||||
closeCalls:state.closeCalls,
|
||||
disposeCalls:state.disposeCalls,
|
||||
disposeCompleted:state.disposeCompleted,
|
||||
fixtureLogs:state.fixtureLogs,
|
||||
events:state.events
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
releaseCloseGate();
|
||||
if(state.closeStarted && !state.disposeCompleted) {
|
||||
try {
|
||||
await waitFor(()=>state.disposeCompleted,"Cleanup timed out waiting for log resource disposal");
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
async function runCase(options: {
|
||||
binary: string;
|
||||
cliBinary: string;
|
||||
caseName: string;
|
||||
injectFailure: boolean;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const vault = await createTemporaryVault(`obsidian-log-lifecycle-${options.caseName}-`);
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
const markers = {
|
||||
duringMarker: `log-lifecycle-${options.caseName}-during-${randomUUID()}`,
|
||||
failureMarker: options.injectFailure
|
||||
? `log-lifecycle-${options.caseName}-injected-close-failure-${randomUUID()}`
|
||||
: "",
|
||||
reloadMarker: options.injectFailure ? "" : `log-lifecycle-${options.caseName}-reload-${randomUUID()}`,
|
||||
injectFailure: options.injectFailure,
|
||||
urgentLevel: LOG_LEVEL_URGENT,
|
||||
};
|
||||
|
||||
try {
|
||||
session = await startSession(options.binary, options.cliBinary, vault);
|
||||
await waitForLiveSyncCoreReady(options.cliBinary, session.cliEnv);
|
||||
const shutdown = await evaluate<ShutdownObservation>(options.cliBinary, session, createShutdownScript(markers));
|
||||
if (options.injectFailure) {
|
||||
return { caseName: options.caseName, shutdown };
|
||||
}
|
||||
|
||||
await evaluate(
|
||||
options.cliBinary,
|
||||
session,
|
||||
`await app.plugins.enablePlugin("obsidian-livesync"); return JSON.stringify({enabled:true});`
|
||||
);
|
||||
await waitForLiveSyncCoreReady(options.cliBinary, session.cliEnv);
|
||||
const reload = await evaluate<Record<string, unknown>>(
|
||||
options.cliBinary,
|
||||
session,
|
||||
`
|
||||
const state=app.__livesyncLogLifecycleState;
|
||||
require(state,"The old core observation was not retained across plug-in reload");
|
||||
const plugin=app.plugins.plugins["obsidian-livesync"];
|
||||
const core=plugin?.core;
|
||||
require(core && core!==state.core,"Enabling the plug-in did not create a new core");
|
||||
const module=core.modules.find(candidate=>candidate.constructor.name==="ModuleLog");
|
||||
require(module,"The reloaded core did not contain ModuleLog");
|
||||
const originalAddLog=module.__addLog;
|
||||
const observed=[];
|
||||
module.__addLog=function(message,level,key) {
|
||||
if(typeof message==="string" && message.includes(${JSON.stringify(markers.reloadMarker)})) {
|
||||
observed.push({message,level,key});
|
||||
}
|
||||
return originalAddLog.apply(this,arguments);
|
||||
};
|
||||
try {
|
||||
core.services.API.addLog(${JSON.stringify(markers.reloadMarker)},undefined,"e2e-log-lifecycle-reload");
|
||||
await waitFor(()=>observed.length>0,"The reloaded ModuleLog did not receive its API log");
|
||||
require(observed.length===1,"The reloaded ModuleLog received the fixture log more than once");
|
||||
require(
|
||||
!state.fixtureLogs.some(entry=>entry.message.includes(${JSON.stringify(markers.reloadMarker)})),
|
||||
"The old ModuleLog observer received the reloaded core's fixture log"
|
||||
);
|
||||
let oldHandlerDetached=false;
|
||||
try {
|
||||
state.api.addLog("e2e-log-lifecycle-reloaded-old-api");
|
||||
} catch(error) {
|
||||
oldHandlerDetached=String(error).includes("not assigned");
|
||||
}
|
||||
require(oldHandlerDetached,"The old API log handler was reassigned after reload");
|
||||
return JSON.stringify({
|
||||
coreChanged:core!==state.core,
|
||||
observedByNewModule:observed.length,
|
||||
observedByOldModule:state.fixtureLogs.filter(
|
||||
entry=>entry.message.includes(${JSON.stringify(markers.reloadMarker)})
|
||||
).length
|
||||
});
|
||||
} finally {
|
||||
module.__addLog=originalAddLog;
|
||||
state.restore();
|
||||
delete app.__livesyncLogLifecycleState;
|
||||
}
|
||||
`
|
||||
);
|
||||
return { caseName: options.caseName, shutdown, reload };
|
||||
} finally {
|
||||
if (session) {
|
||||
try {
|
||||
await evaluate(
|
||||
options.cliBinary,
|
||||
session,
|
||||
`
|
||||
const state=app.__livesyncLogLifecycleState;
|
||||
state?.restore?.();
|
||||
delete app.__livesyncLogLifecycleState;
|
||||
return JSON.stringify({cleaned:true});
|
||||
`
|
||||
);
|
||||
} catch {}
|
||||
try {
|
||||
await session.app.stop();
|
||||
} finally {
|
||||
await vault.dispose();
|
||||
}
|
||||
} else {
|
||||
await vault.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cliBinary = requireObsidianCli();
|
||||
const bundleHash = createHash("sha256")
|
||||
.update(new Uint8Array(await readFile("main.js")))
|
||||
.digest("hex");
|
||||
|
||||
const observations = [
|
||||
await runCase({ binary, cliBinary, caseName: "normal", injectFailure: false }),
|
||||
await runCase({ binary, cliBinary, caseName: "controlled-failure", injectFailure: true }),
|
||||
];
|
||||
console.log(JSON.stringify({ ok: true, bundleHash, observations }, null, 2));
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -49,6 +49,7 @@ const focusedScenarios = new Set([
|
||||
"customisation-sync",
|
||||
"customisation-catalogue-reload",
|
||||
"optional-file-save-completion",
|
||||
"log-lifecycle",
|
||||
"livesync-batch",
|
||||
"received-change-readiness",
|
||||
"sync-guard",
|
||||
|
||||
Reference in new issue
Block a user