Merge main into NFC transport support

This commit is contained in:
vorotamoroz committed 2026-11-01 14:11:17 +00:00
commit 4957f8d28f
160 files changed
+12441 -1388

No files matched your search

+57
View File
@@ -77,6 +77,63 @@ npm run test:e2e:docker:mirror --workspace self-hosted-livesync-cli
The expected result is `PASS=7 FAIL=0`. These mirror suites use temporary local databases and need no CouchDB service. The complete `test:e2e:docker:all` command also runs the other Docker CLI suites and manages a disposable CouchDB fixture; use it to check the full Docker CI gate. Keep the ordinary-edit and unknown-provenance scenarios aligned between the Node.js and Bash suites.
## CLI Fetch database continuity
The standard CLI CI suite and direct task runner execute
`test:fetch-database-continuity` against a dedicated RustFS container. Each
scenario creates its own bucket. The test binds a uniquely named container to a
dynamic loopback port, then stops only that container. Redacted evidence is
written under the operating system's temporary directory and is not uploaded as
an artefact.
For a local run, build the CLI and run
`node test/e2e-cli/run-task.ts test:fetch-database-continuity` with Node.js 26
and Docker available. The managed container uses `accessKey` and `secretKey`
from the CLI `.test.env` file when present, and defaults to `minioadmin` for
both. No endpoint, bucket, or evidence-directory variables are needed. Set
`LIVESYNC_ISSUE1304_EVIDENCE_DIR` to choose another evidence directory outside
the worktree.
To use an external S3-compatible fixture without Docker, set all four
`LIVESYNC_ISSUE1304_MINIO_ENDPOINT`, `LIVESYNC_ISSUE1304_MINIO_ACCESS_KEY`,
`LIVESYNC_ISSUE1304_MINIO_SECRET_KEY`, and `LIVESYNC_ISSUE1304_MINIO_BUCKET`.
The bucket must be fresh and empty for every run. Partial external fixture
configuration is rejected. This test writes remote data; never point it at a
production bucket.
The test seeds an encrypted, path-obfuscated file with a second-generation
revision, imports one Setup URI on two clients, fetches on A without
`--write-settings`, and restarts A while B remains online. It checks the saved
database suffix, file bytes, original revision, and absence of a duplicate root.
Managed runs execute the explicit `--write-settings` control automatically in
a separate scenario bucket. For an external fixture, set
`LIVESYNC_ISSUE1304_POSITIVE_CONTROL=1` and
`LIVESYNC_ISSUE1304_POSITIVE_CONTROL_BUCKET` to a second fresh bucket to run
that control.
For historical reproduction against the unmodified implementation, set
`LIVESYNC_ISSUE1304_VERIFY_LEGACY_FAILURE=1`. This preserves the original
same-content conflict-resolution sequence and its file-preservation assertion;
the affected implementation fails because A's file disappears. Leave this
variable unset when validating the correction. The test saves redacted process
logs, file hashes, and database evidence. It does not cover Obsidian runtime or
CouchDB behaviour.
## Object Storage Chunk recovery regression tests
After building the CLI, run the focused recovery test with Node.js 26 or later:
```bash
NODE_OPTIONS=--max-old-space-size=3072 npm run build --workspace self-hosted-livesync-cli
NODE_OPTIONS=--max-old-space-size=3072 MINIO_ENDPOINT=http://127.0.0.1:9000 MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin MINIO_BUCKET_NAME=livesync-test npm run test:e2e:task --workspace self-hosted-livesync-cli -- test:journal-chunk-recovery
```
The test manages the disposable `minio-test` RustFS container on ports 9000 and 9001. Run it separately from other tests using that container or those ports. It creates a distinct bucket and temporary Vaults for each case and removes its test container afterwards.
Both the unencrypted and encrypted cases rebuild from a complete file containing raw unit separators (`U+001F`), Fetch into another Vault, and compare the exact bytes. A fresh CLI process reads the persisted content. The test then damages the receiving Vault file and verifies that Fetch retains that newer local file and preserves both contents as independent conflict branches. Each revision is read explicitly so the assertions do not depend on which branch PouchDB selects as the winner. After moving the damaged file to a separate backup, another Fetch into the empty Vault restores the remote contents. It retains `--write-settings` to exercise explicit settings writeback; database selection is now saved automatically, and the continuity test below covers omission of that option. This test is included in the standard CLI CI task list.
This establishes the CLI and shared Rebuild/Fetch behaviour. It does not exercise Obsidian's Hidden File Sync or Customisation Sync initialisation, nor inject a damaged Chunk under an existing content-addressed ID.
## Multiple-device conflict regression tests
### Preparation and execution
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
const kit = vi.hoisted(() => ({
uiOptions: [] as { document: Document; signal: AbortSignal }[],
notices: [] as { document: Document; dispose: ReturnType<typeof vi.fn>; show: ReturnType<typeof vi.fn> }[],
}));
vi.mock("@vrtmrz/browser-ui-kit", () => ({
createBrowserUi(options: { document: Document; signal: AbortSignal }) {
kit.uiOptions.push(options);
return { promptText: vi.fn(async () => "selected") };
},
BrowserUiNotifications: class {
dispose = vi.fn();
show = vi.fn();
constructor(options: { document: Document }) {
kit.notices.push({ document: options.document, dispose: this.dispose, show: this.show });
}
},
}));
import { BrowserConfirm } from "@/apps/browser/BrowserConfirm";
import { createBrowserUiScope } from "@/apps/browser/BrowserUiScope";
beforeEach(() => {
kit.uiOptions.length = 0;
kit.notices.length = 0;
});
describe("BrowserConfirm UI owner", () => {
it("passes the same document and lifetime to existing Kit capabilities", async () => {
const context = createServiceContext();
const anchor = { textContent: "", click: vi.fn() };
const document = { createElement: vi.fn(() => anchor) } as unknown as Document;
const ui = createBrowserUiScope(context, { document });
const confirm = new BrowserConfirm(context, ui);
expect(kit.uiOptions[0]).toMatchObject({ document, signal: ui.signal });
expect(kit.notices[0].document).toBe(document);
await expect(confirm.askString("Title", "Value", "Placeholder")).resolves.toBe("selected");
confirm.askInPopup("key", "Go {HERE}", (link) => {
link.textContent = "Open";
});
expect(document.createElement).toHaveBeenCalledWith("a");
expect(kit.notices[0].show).toHaveBeenCalledWith(
"key",
expect.objectContaining({ action: expect.objectContaining({ label: "Open" }) })
);
context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
expect(ui.signal.aborted).toBe(true);
expect(kit.notices[0].dispose).toHaveBeenCalledOnce();
});
it("disposes only the notifications belonging to the unloaded context", () => {
const first = createServiceContext();
const second = createServiceContext();
new BrowserConfirm(first, createBrowserUiScope(first, { document: {} as Document }));
new BrowserConfirm(second, createBrowserUiScope(second, { document: {} as Document }));
first.events.emitEvent(EVENT_PLUGIN_UNLOADED);
first.events.emitEvent(EVENT_PLUGIN_UNLOADED);
expect(kit.notices[0].dispose).toHaveBeenCalledOnce();
expect(kit.notices[1].dispose).not.toHaveBeenCalled();
second.events.emitEvent(EVENT_PLUGIN_UNLOADED);
});
it("disposes notifications immediately for a pre-aborted owner", () => {
const context = createServiceContext();
const parent = new AbortController();
parent.abort();
new BrowserConfirm(context, createBrowserUiScope(context, { signal: parent.signal }));
expect(kit.uiOptions[0].signal.aborted).toBe(true);
expect(kit.notices[0].dispose).toHaveBeenCalledOnce();
context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
expect(kit.notices[0].dispose).toHaveBeenCalledOnce();
});
});
+286
View File
@@ -0,0 +1,286 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const browserMenuHarness = vi.hoisted(() => {
const createDocument = () => {
const created: { tag: string; removed: boolean; remove: ReturnType<typeof vi.fn> }[] = [];
const appended: unknown[] = [];
const document = {
createElement: vi.fn((tag: string) => {
const element: { tag: string; removed: boolean; remove: ReturnType<typeof vi.fn> } = {
tag,
removed: false,
remove: vi.fn(() => {
element.removed = true;
}),
};
created.push(element);
return element;
}),
body: {
appendChild: vi.fn((element: unknown) => {
appended.push(element);
return element;
}),
},
};
return { document, created, appended };
};
return {
documents: {
active: createDocument(),
explicit: createDocument(),
},
mounts: [] as {
component: unknown;
target: unknown;
props: Record<string, unknown>;
handle: { id: number };
}[],
unmounts: [] as unknown[],
};
});
vi.mock("@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", () => ({
_activeDocument: browserMenuHarness.documents.active.document,
}));
vi.mock("svelte", () => ({
mount: vi.fn((component: unknown, options: { target: unknown; props: Record<string, unknown> }) => {
const handle = { id: browserMenuHarness.mounts.length };
browserMenuHarness.mounts.push({ component, target: options.target, props: options.props, handle });
return handle;
}),
unmount: vi.fn((handle: unknown) => {
browserMenuHarness.unmounts.push(handle);
return Promise.resolve();
}),
}));
import { Menu, MenuItem, MenuSeparator } from "@/apps/browser/BrowserMenu";
type BrowserMenuOptionsFixture = { document?: Document; signal?: AbortSignal };
function menuWithOptions(options: BrowserMenuOptionsFixture): Menu {
const MenuWithOptions = Menu as unknown as new (options: BrowserMenuOptionsFixture) => Menu;
return new MenuWithOptions(options);
}
function explicitDocument(): Document {
return browserMenuHarness.documents.explicit.document as unknown as Document;
}
function closeMenu(index: number): () => void {
const close = browserMenuHarness.mounts[index]!.props.closeMenu;
if (typeof close !== "function") throw new Error("Menu mount did not receive a close callback");
return close as () => void;
}
async function settlesAfterMicrotask(promise: Promise<void>): Promise<boolean> {
let settled = false;
void promise.then(() => {
settled = true;
});
await Promise.resolve();
return settled;
}
beforeEach(() => {
browserMenuHarness.documents.active.created.length = 0;
browserMenuHarness.documents.active.appended.length = 0;
browserMenuHarness.documents.explicit.created.length = 0;
browserMenuHarness.documents.explicit.appended.length = 0;
browserMenuHarness.mounts.length = 0;
browserMenuHarness.unmounts.length = 0;
vi.clearAllMocks();
});
afterEach(() => {
for (const mount of browserMenuHarness.mounts) {
const close = mount.props.closeMenu;
if (typeof close === "function") (close as () => void)();
}
});
describe("browser Menu lifecycle", () => {
it("preserves item, separator, chained construction, and positioned display APIs", async () => {
const menu = new Menu();
const handler = vi.fn(async () => {});
expect(
menu.addItem((item) => {
item.setTitle("Open").setIcon("folder").onClick(handler);
})
).toBe(menu);
expect(menu.addSeparator()).toBe(menu);
expect(menu.items[0]).toBeInstanceOf(MenuItem);
expect(menu.items[0]).toMatchObject({ type: "item", title: "Open", icon: "folder", handler });
expect(menu.items[1]).toBeInstanceOf(MenuSeparator);
const displayed = menu.showAtPosition({ x: 12, y: 34 });
expect(browserMenuHarness.documents.active.document.createElement).toHaveBeenCalledWith("div");
expect(browserMenuHarness.documents.active.appended).toEqual([browserMenuHarness.mounts[0]!.target]);
expect(browserMenuHarness.mounts[0]!.props).toMatchObject({ items: menu.items, x: 12, y: 34 });
expect(browserMenuHarness.mounts[0]!.target).toBe(browserMenuHarness.documents.active.created[0]);
closeMenu(0)();
await expect(displayed).resolves.toBeUndefined();
});
it("keeps the returned close promise pending until a menu close action", async () => {
const menu = new Menu();
const displayed = menu.showAtPosition({ x: 1, y: 2 });
expect(await settlesAfterMicrotask(displayed)).toBe(false);
closeMenu(0)();
await expect(displayed).resolves.toBeUndefined();
});
it("resolves the active display promise when hidden without a menu action", async () => {
const menu = new Menu();
const displayed = menu.showAtPosition({ x: 1, y: 2 });
menu.hide();
await expect(displayed).resolves.toBeUndefined();
});
it("closes a replaced display while keeping the replacement active", async () => {
const menu = new Menu();
const first = menu.showAtPosition({ x: 1, y: 2 });
const staleClose = closeMenu(0);
const firstElement = browserMenuHarness.mounts[0]!.target as { removed: boolean };
const second = menu.showAtPosition({ x: 3, y: 4 });
const secondElement = browserMenuHarness.mounts[1]!.target as { removed: boolean };
await expect(first).resolves.toBeUndefined();
expect(firstElement.removed).toBe(true);
expect(secondElement.removed).toBe(false);
expect(menu.waitingForClose?.promise).toBe(second);
expect(await settlesAfterMicrotask(second)).toBe(false);
try {
staleClose();
expect(menu.waitingForClose?.promise).toBe(second);
expect(secondElement.removed).toBe(false);
expect(await settlesAfterMicrotask(second)).toBe(false);
} finally {
closeMenu(1)();
}
await expect(second).resolves.toBeUndefined();
});
it("mounts a menu in its explicitly supplied document", async () => {
const menu = menuWithOptions({ document: explicitDocument() });
const displayed = menu.showAtPosition({ x: 5, y: 6 });
expect(browserMenuHarness.documents.explicit.document.createElement).toHaveBeenCalledWith("div");
expect(browserMenuHarness.documents.explicit.appended).toEqual([browserMenuHarness.mounts[0]!.target]);
expect(browserMenuHarness.mounts[0]!.target).toBe(browserMenuHarness.documents.explicit.created[0]);
expect(browserMenuHarness.documents.active.document.createElement).not.toHaveBeenCalled();
closeMenu(0)();
await expect(displayed).resolves.toBeUndefined();
});
it("aborting one menu closes only that menu", async () => {
const firstOwner = new AbortController();
const secondOwner = new AbortController();
const firstMenu = menuWithOptions({ document: explicitDocument(), signal: firstOwner.signal });
const secondMenu = menuWithOptions({ document: explicitDocument(), signal: secondOwner.signal });
const first = firstMenu.showAtPosition({ x: 1, y: 2 });
const second = secondMenu.showAtPosition({ x: 3, y: 4 });
let firstSettled = false;
void first.then(() => {
firstSettled = true;
});
firstOwner.abort();
await Promise.resolve();
expect(firstSettled).toBe(true);
expect(secondMenu.waitingForClose?.promise).toBe(second);
expect(await settlesAfterMicrotask(second)).toBe(false);
closeMenu(1)();
await expect(second).resolves.toBeUndefined();
});
it("does not mount for an already aborted owner", async () => {
const owner = new AbortController();
owner.abort();
const menu = menuWithOptions({ document: explicitDocument(), signal: owner.signal });
const displayed = menu.showAtPosition({ x: 1, y: 2 });
expect(browserMenuHarness.mounts).toHaveLength(0);
expect(await settlesAfterMicrotask(displayed)).toBe(true);
});
it("does not mount again after its owner aborts", async () => {
const owner = new AbortController();
const menu = menuWithOptions({ document: explicitDocument(), signal: owner.signal });
const first = menu.showAtPosition({ x: 1, y: 2 });
owner.abort();
expect(await settlesAfterMicrotask(first)).toBe(true);
const reopened = menu.showAtPosition({ x: 3, y: 4 });
expect(await settlesAfterMicrotask(reopened)).toBe(true);
expect(browserMenuHarness.mounts).toHaveLength(1);
});
it.each(["hide", "dismissal", "replacement", "abort"] as const)(
"unmounts each Svelte component exactly once for %s",
async (reason) => {
const owner = new AbortController();
const menu = menuWithOptions({ document: explicitDocument(), signal: owner.signal });
const first = menu.showAtPosition({ x: 1, y: 2 });
let second: Promise<void> | undefined;
if (reason === "hide") {
menu.hide();
} else if (reason === "dismissal") {
closeMenu(0)();
} else if (reason === "replacement") {
second = menu.showAtPosition({ x: 3, y: 4 });
closeMenu(1)();
} else {
owner.abort();
}
expect(await settlesAfterMicrotask(first)).toBe(true);
if (second) expect(await settlesAfterMicrotask(second)).toBe(true);
for (const mount of browserMenuHarness.mounts) {
expect(browserMenuHarness.unmounts.filter((handle) => handle === mount.handle)).toHaveLength(1);
}
}
);
it("removes the menu element before resolving the close promise", async () => {
const menu = new Menu();
const displayed = menu.showAtPosition({ x: 1, y: 2 });
const element = browserMenuHarness.mounts[0]!.target as { removed: boolean };
let removedWhenResolved = false;
void displayed.then(() => {
removedWhenResolved = element.removed;
});
menu.hide();
await displayed;
expect(removedWhenResolved).toBe(true);
});
it("removes its abort listener when closed so a later abort does not unmount it again", async () => {
const owner = new AbortController();
const addListener = vi.spyOn(owner.signal, "addEventListener");
const removeListener = vi.spyOn(owner.signal, "removeEventListener");
const menu = menuWithOptions({ document: explicitDocument(), signal: owner.signal });
const displayed = menu.showAtPosition({ x: 1, y: 2 });
const handle = browserMenuHarness.mounts[0]!.handle;
menu.hide();
await expect(displayed).resolves.toBeUndefined();
owner.abort();
expect(addListener.mock.calls.map(([type]) => type)).toContain("abort");
expect(removeListener.mock.calls.map(([type]) => type)).toContain("abort");
expect(browserMenuHarness.unmounts.filter((unmounted) => unmounted === handle)).toHaveLength(1);
});
});
@@ -0,0 +1,199 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import type {
ComponentHasResult,
DialogHostProps,
SvelteDialogManagerDependencies,
} from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog";
const rendering = vi.hoisted(() => ({
mount: vi.fn(),
unmount: vi.fn(async (_component: unknown) => {}),
legacy: { document: undefined as Document | undefined },
}));
vi.mock("svelte", async (importOriginal) => ({
...(await importOriginal<typeof import("svelte")>()),
mount: rendering.mount,
unmount: rendering.unmount,
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", () => ({
get _activeDocument() {
return rendering.legacy.document;
},
}));
vi.mock("@/modules/services/LiveSyncUI/DialogHost.svelte", () => ({ default: () => {} }));
import {
BrowserModal,
BrowserSvelteDialogManager,
LiveSyncBrowserDialog,
} from "@/apps/browser/BrowserSvelteDialogManager";
import { createBrowserUiScope } from "@/apps/browser/BrowserUiScope";
class TestElement {
children: TestElement[] = [];
parentElement: TestElement | null = null;
hidden = false;
className = "";
textContent = "";
constructor(
readonly ownerDocument: TestDocument,
readonly tagName: string
) {}
appendChild(child: TestElement) {
child.remove();
child.parentElement = this;
this.children.push(child);
return child;
}
remove() {
if (this.parentElement)
this.parentElement.children = this.parentElement.children.filter((child) => child !== this);
this.parentElement = null;
}
replaceChildren() {
for (const child of [...this.children]) child.remove();
}
}
class TestDocument {
body = new TestElement(this, "body");
createElement(tag: string) {
return new TestElement(this, tag);
}
}
const asDocument = (document: TestDocument) => document as unknown as Document;
const component = (() => {}) as unknown as ComponentHasResult<string, string>;
type Context = ReturnType<typeof createServiceContext>;
type UiOptions = { document?: Document; signal?: AbortSignal };
function createManager(options?: UiOptions) {
const context = createServiceContext();
return {
context,
manager: new BrowserSvelteDialogManager(
context,
{} as SvelteDialogManagerDependencies<Context>,
options ? createBrowserUiScope(context, options) : undefined
),
};
}
function lastMount() {
const call = rendering.mount.mock.calls[rendering.mount.mock.calls.length - 1];
return { target: call[1].target as TestElement, props: call[1].props as DialogHostProps<string, string> };
}
beforeEach(() => {
vi.clearAllMocks();
rendering.legacy.document = asDocument(new TestDocument());
rendering.mount.mockImplementation(() => ({}));
});
afterEach(() => {
rendering.legacy.document = undefined;
});
describe("Browser Svelte dialogue ownership", () => {
it("releases every per-dialogue context subscription on legacy direct close", async () => {
const subscriptions: ReturnType<typeof vi.fn>[] = [];
const context = {
events: {
onceEvent: vi.fn(() => {
const unregister = vi.fn();
subscriptions.push(unregister);
return unregister;
}),
},
} as unknown as Context;
const dialog = new LiveSyncBrowserDialog(context, {} as SvelteDialogManagerDependencies<Context>, component);
dialog.open();
const result = dialog.waitForClose();
dialog.close();
await expect(result).resolves.toBeUndefined();
expect(subscriptions.length).toBeGreaterThan(0);
for (const unsubscribe of subscriptions) expect(unsubscribe).toHaveBeenCalledOnce();
});
it("preserves the legacy document, normal result, and component cleanup", async () => {
const { manager } = createManager();
const result = manager.openSvelteDialog(component, "initial");
const { target, props } = lastMount();
expect(target.ownerDocument).toBe(rendering.legacy.document);
expect(props.getInitialData?.()).toBe("initial");
props.setResult("selected");
props.closeDialog();
await expect(result).resolves.toBe("selected");
expect((rendering.legacy.document as unknown as TestDocument).body.children).toHaveLength(0);
expect(rendering.unmount).toHaveBeenCalledOnce();
});
it("preserves rejection when the owning context unloads", async () => {
const { context, manager } = createManager();
const result = manager.openSvelteDialog(component);
const rejected = expect(result).rejects.toThrow("Plug-in unloaded");
context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
await rejected;
expect(rendering.unmount).toHaveBeenCalledOnce();
expect((rendering.legacy.document as unknown as TestDocument).body.children).toHaveLength(0);
});
it("uses its supplied document even when the legacy document changes before opening", async () => {
const targetDocument = new TestDocument();
const { manager } = createManager({ document: asDocument(targetDocument) });
rendering.legacy.document = asDocument(new TestDocument());
const result = manager.openSvelteDialog(component);
const { target, props } = lastMount();
expect(target.ownerDocument).toBe(targetDocument);
expect(targetDocument.body.children).toHaveLength(1);
expect((rendering.legacy.document as unknown as TestDocument).body.children).toHaveLength(0);
props.closeDialog();
await expect(result).resolves.toBeUndefined();
});
it("aborts one owner without closing another owner's dialogue", async () => {
const firstOwner = new AbortController();
const firstDocument = new TestDocument();
const secondDocument = new TestDocument();
const first = createManager({ document: asDocument(firstDocument), signal: firstOwner.signal });
const second = createManager({ document: asDocument(secondDocument) });
const firstResult = first.manager.openSvelteDialog(component);
let observedFirst: unknown = "still pending";
void firstResult.then(
() => {
observedFirst = "resolved";
},
(error) => {
observedFirst = error;
}
);
const secondResult = second.manager.openSvelteDialog(component);
const secondProps = lastMount().props;
const reason = new Error("fixture owner ended");
firstOwner.abort(reason);
await vi.waitFor(() => expect(observedFirst).toBe(reason), { timeout: 1000, interval: 20 });
expect(firstDocument.body.children).toHaveLength(0);
expect(secondDocument.body.children).toHaveLength(1);
secondProps.setResult("second");
secondProps.closeDialog();
await expect(secondResult).resolves.toBe("second");
});
it("does not render for an already aborted owner or reopen after unload", async () => {
const owner = new AbortController();
const reason = new Error("fixture owner ended");
owner.abort(reason);
const aborted = createManager({ signal: owner.signal });
const rejected = aborted.manager.openSvelteDialog(component).catch((error) => error);
expect(rendering.mount).not.toHaveBeenCalled();
await expect(rejected).resolves.toBe(reason);
const unloaded = createManager();
unloaded.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
const afterUnload = unloaded.manager.openSvelteDialog(component).catch((error) => error);
expect(rendering.mount).not.toHaveBeenCalled();
await expect(afterUnload).resolves.toBeInstanceOf(Error);
});
it("keeps a native modal in the document which created its elements", () => {
const document = new TestDocument();
const modal = new BrowserModal(asDocument(document));
rendering.legacy.document = asDocument(new TestDocument());
modal.open();
expect(document.body.children).toEqual([modal.baseEl]);
expect((modal.contentEl as unknown as TestElement).ownerDocument).toBe(document);
modal.close();
expect(document.body.children).toHaveLength(0);
});
});
@@ -0,0 +1,56 @@
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { describe, expect, it, vi } from "vitest";
import { createBrowserUiScope } from "@/apps/browser/BrowserUiScope";
describe("browser UI scope", () => {
it("owns its document and cancellation independently from another application", () => {
const firstContext = createServiceContext();
const secondContext = createServiceContext();
const firstDocument = {} as Document;
const secondDocument = {} as Document;
const first = createBrowserUiScope(firstContext, { document: firstDocument });
const second = createBrowserUiScope(secondContext, { document: secondDocument });
const abort = vi.fn();
first.signal.addEventListener("abort", abort);
firstContext.events.emitEvent(EVENT_PLUGIN_UNLOADED);
firstContext.events.emitEvent(EVENT_PLUGIN_UNLOADED);
expect(first.document).toBe(firstDocument);
expect(second.document).toBe(secondDocument);
expect(first.signal.aborted).toBe(true);
expect(first.signal.reason).toEqual(new Error("Plug-in unloaded"));
expect(second.signal.aborted).toBe(false);
expect(abort).toHaveBeenCalledOnce();
secondContext.events.emitEvent(EVENT_PLUGIN_UNLOADED);
});
it("forwards parent cancellation and unregisters the context subscription", () => {
const context = createServiceContext();
const parent = new AbortController();
const unregister = vi.fn();
const once = vi.spyOn(context.events, "onceEvent").mockReturnValue(unregister);
const scope = createBrowserUiScope(context, { signal: parent.signal });
const reason = new Error("fixture host ended");
parent.abort(reason);
expect(scope.signal.aborted).toBe(true);
expect(scope.signal.reason).toBe(reason);
expect(unregister).toHaveBeenCalledOnce();
once.mockRestore();
});
it("removes the parent listener on unload and does not subscribe an already aborted parent", () => {
const context = createServiceContext();
const parent = new AbortController();
const remove = vi.spyOn(parent.signal, "removeEventListener");
const scope = createBrowserUiScope(context, { signal: parent.signal });
context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
expect(remove).toHaveBeenCalledWith("abort", expect.any(Function));
const reason = scope.signal.reason;
parent.abort(new Error("later"));
expect(scope.signal.reason).toBe(reason);
const closedContext = createServiceContext();
const subscribe = vi.spyOn(closedContext.events, "onceEvent");
const closed = createBrowserUiScope(closedContext, { signal: parent.signal });
expect(closed.signal.aborted).toBe(true);
expect(closed.signal.reason).toBe(parent.signal.reason);
expect(subscribe).not.toHaveBeenCalled();
});
});
@@ -86,3 +86,64 @@ describe("SvelteDialogSession", () => {
expect(close).toHaveBeenCalledOnce();
});
});
describe("SvelteDialogSession owner signal", () => {
it("rejects and releases the mounted dialogue when its owner aborts", async () => {
const context = createServiceContext();
const controller = new AbortController();
const lifecycle = { signal: controller.signal };
const unmount = vi.fn().mockResolvedValue(undefined);
let session: SvelteDialogSession<string, string, typeof context>;
const close = vi.fn(() => session.onClose());
session = new SvelteDialogSession({
...lifecycle,
surface: {
contentEl: { replaceChildren: vi.fn() } as unknown as HTMLElement,
close,
setTitle: vi.fn(),
},
context,
dependencies: {} as SvelteDialogManagerDependencies<typeof context>,
dialogHost: (() => {}) as unknown as Component<DialogHostProps>,
component: (() => {}) as unknown as ComponentHasResult<string, string>,
renderer: { mount: () => ({}) as never, unmount },
});
session.onOpen();
let observed: unknown = "pending";
void session.waitForClose().catch((error) => {
observed = error;
});
const reason = new Error("fixture owner ended");
controller.abort(reason);
await vi.waitFor(() => expect(observed).toBe(reason), { timeout: 1000, interval: 20 });
expect(close).toHaveBeenCalledOnce();
expect(unmount).toHaveBeenCalledOnce();
context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
expect(close).toHaveBeenCalledOnce();
});
it("detaches an owner signal when normal close has already settled the result", async () => {
const context = createServiceContext();
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, "removeEventListener");
const lifecycle = { signal: controller.signal };
const close = vi.fn();
const session = new SvelteDialogSession({
...lifecycle,
surface: { contentEl: { replaceChildren: vi.fn() } as unknown as HTMLElement, close, setTitle: vi.fn() },
context,
dependencies: {} as SvelteDialogManagerDependencies<typeof context>,
dialogHost: (() => {}) as unknown as Component<DialogHostProps>,
component: (() => {}) as unknown as ComponentHasResult<string, string>,
renderer: { mount: () => ({}) as never, unmount: vi.fn().mockResolvedValue(undefined) },
});
session.onOpen();
const result = session.waitForClose();
session.onClose();
await expect(result).resolves.toBeUndefined();
expect(remove).toHaveBeenCalledWith("abort", expect.any(Function));
controller.abort();
context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
expect(close).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,202 @@
import { assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import { chromium } from "playwright";
import { observePageFailures, startStaticServer, waitFor } from "../helpers/browser.ts";
type OwnerName = "owner-a" | "owner-b" | "owner-c" | "owner-d";
type DialogOutcome =
| { readonly kind: "resolved"; readonly value: unknown }
| { readonly kind: "rejected"; readonly message: string };
interface BrowserUiLifecycleFixture {
readonly ready: boolean;
startDialog(name: OwnerName, initialData?: string): void;
startMenu(name: OwnerName, title: string): void;
clickSavedBackdrop(name: OwnerName): void;
hideMenu(name: OwnerName): void;
unload(name: OwnerName): void;
abortParent(name: OwnerName): void;
state(name: OwnerName): {
readonly dialogs: readonly DialogOutcome[];
readonly menus: readonly string[];
readonly hasSavedBackdrop: boolean;
};
}
declare global {
interface Window {
browserUiLifecycle?: BrowserUiLifecycleFixture;
}
}
const fixtureDist = resolve(import.meta.dirname!, "../../../_testdata/browser-ui-lifecycle");
Deno.test({
name: "Browser UI: dialogue and menu lifecycles remain owned by their document and plug-in scope",
sanitizeOps: false,
sanitizeResources: false,
async fn() {
const server = await startStaticServer(fixtureDist);
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
const assertNoPageFailures = observePageFailures(page);
await page.goto(server.baseUrl);
await page.waitForFunction(() => window.browserUiLifecycle?.ready === true);
const startDialog = async (name: OwnerName, initialData?: string) =>
await page.evaluate(
({ name, initialData }) => window.browserUiLifecycle!.startDialog(name, initialData),
{ name, initialData }
);
const startMenu = async (name: OwnerName, title: string) =>
await page.evaluate(({ name, title }) => window.browserUiLifecycle!.startMenu(name, title), {
name,
title,
});
const state = async (name: OwnerName) =>
await page.evaluate((ownerName) => window.browserUiLifecycle!.state(ownerName), name);
const ownerA = page.frameLocator("#owner-a");
const ownerB = page.frameLocator("#owner-b");
const ownerC = page.frameLocator("#owner-c");
const ownerD = page.frameLocator("#owner-d");
await startDialog("owner-a", "A-seed");
await startDialog("owner-b", "B-seed");
const initial = await page.evaluate(() => ({
first: window.browserUiLifecycle!.state("owner-a").dialogs,
second: window.browserUiLifecycle!.state("owner-b").dialogs,
}));
assertEquals(initial, { first: [], second: [] });
assertNoPageFailures();
await ownerA.getByTestId("lifecycle-dialog").waitFor();
await ownerB.getByTestId("lifecycle-dialog").waitFor();
assertEquals(await page.locator(".dialog-host").count(), 0);
assertEquals(await ownerA.locator(".dialog-host").count(), 1);
assertEquals(await ownerB.locator(".dialog-host").count(), 1);
await ownerA.getByTestId("submit-dialog").click();
await waitFor(async () => (await state("owner-a")).dialogs.length === 1, "Owner A dialogue did not finish");
assertEquals((await state("owner-a")).dialogs, [{ kind: "resolved", value: "result:A-seed" }]);
assertEquals(await ownerA.locator(".dialog-host").count(), 0);
assertEquals(await ownerB.locator(".dialog-host").count(), 1);
await ownerB.getByTestId("close-dialog").click();
await waitFor(async () => (await state("owner-b")).dialogs.length === 1, "Owner B dialogue did not finish");
assertEquals((await state("owner-b")).dialogs, [{ kind: "resolved", value: "undefined" }]);
await startMenu("owner-a", "first menu");
await ownerA.getByText("first menu", { exact: true }).waitFor();
assertEquals(await page.locator(".background").count(), 0);
assertEquals(await ownerA.locator(".background").count(), 1);
assertEquals(await ownerB.locator(".background").count(), 0);
await startMenu("owner-a", "replacement menu");
await ownerA.getByText("replacement menu", { exact: true }).waitFor();
await waitFor(
async () => (await state("owner-a")).menus.length === 1,
"Replacing the menu did not close its first promise"
);
assertEquals((await state("owner-a")).menus, ["resolved"]);
assertEquals((await state("owner-a")).hasSavedBackdrop, true);
await page.evaluate(() => window.browserUiLifecycle!.clickSavedBackdrop("owner-a"));
assertEquals(await ownerA.locator(".background").count(), 1);
assertEquals((await state("owner-a")).menus, ["resolved"]);
await page.evaluate(() => window.browserUiLifecycle!.hideMenu("owner-a"));
await waitFor(
async () => (await state("owner-a")).menus.length === 2,
"Hiding the replacement menu did not finish"
);
assertEquals((await state("owner-a")).menus, ["resolved", "resolved"]);
assertEquals(await ownerA.locator(".background").count(), 0);
await startDialog("owner-a", "unload-A");
await startMenu("owner-a", "A unload menu");
await startDialog("owner-b", "still-B");
await startMenu("owner-b", "B remains open");
await ownerA.getByTestId("lifecycle-dialog").waitFor();
await ownerA.getByText("A unload menu", { exact: true }).waitFor();
await ownerB.getByTestId("lifecycle-dialog").waitFor();
await ownerB.getByText("B remains open", { exact: true }).waitFor();
await page.evaluate(() => window.browserUiLifecycle!.unload("owner-a"));
await waitFor(
async () =>
(await state("owner-a")).dialogs.length === 2 && (await state("owner-a")).menus.length === 3,
"Owner A unload did not settle its UI operations"
);
assertEquals((await state("owner-a")).dialogs[1], { kind: "rejected", message: "Plug-in unloaded" });
assertEquals(await ownerA.locator(".dialog-host").count(), 0);
assertEquals(await ownerA.locator(".background").count(), 0);
assertEquals(await ownerB.locator(".dialog-host").count(), 1);
assertEquals(await ownerB.locator(".background").count(), 1);
await startDialog("owner-a", "after-unload");
await startMenu("owner-a", "after unload menu");
await waitFor(
async () =>
(await state("owner-a")).dialogs.length === 3 && (await state("owner-a")).menus.length === 4,
"Post-unload UI operations did not settle"
);
assertEquals((await state("owner-a")).dialogs[2], { kind: "rejected", message: "Plug-in unloaded" });
assertEquals((await state("owner-a")).menus[3], "resolved");
assertEquals(await ownerA.locator(".dialog-host").count(), 0);
assertEquals(await ownerA.locator(".background").count(), 0);
await startDialog("owner-c", "parent-abort");
await startMenu("owner-c", "parent abort menu");
await ownerC.getByTestId("lifecycle-dialog").waitFor();
await ownerC.getByText("parent abort menu", { exact: true }).waitFor();
await page.evaluate(() => window.browserUiLifecycle!.abortParent("owner-c"));
await waitFor(
async () =>
(await state("owner-c")).dialogs.length === 1 && (await state("owner-c")).menus.length === 1,
"Parent abort did not settle its UI operations"
);
assertEquals((await state("owner-c")).dialogs[0], { kind: "rejected", message: "Fixture UI owner ended" });
assertEquals((await state("owner-c")).menus[0], "resolved");
assertEquals(await ownerC.locator(".dialog-host").count(), 0);
assertEquals(await ownerC.locator(".background").count(), 0);
assertEquals(await ownerB.locator(".dialog-host").count(), 1);
assertEquals(await ownerB.locator(".background").count(), 1);
await startDialog("owner-c", "after-parent-abort");
await startMenu("owner-c", "after parent abort menu");
await waitFor(
async () =>
(await state("owner-c")).dialogs.length === 2 && (await state("owner-c")).menus.length === 2,
"Post-abort UI operations did not settle"
);
assertEquals((await state("owner-c")).dialogs[1], { kind: "rejected", message: "Fixture UI owner ended" });
assertEquals((await state("owner-c")).menus[1], "resolved");
assertEquals(await ownerC.locator(".dialog-host").count(), 0);
assertEquals(await ownerC.locator(".background").count(), 0);
await startDialog("owner-d", "pre-aborted");
await startMenu("owner-d", "pre-aborted menu");
await waitFor(
async () =>
(await state("owner-d")).dialogs.length === 1 && (await state("owner-d")).menus.length === 1,
"Pre-aborted UI operations did not settle"
);
assertEquals((await state("owner-d")).dialogs[0], { kind: "rejected", message: "Fixture UI owner ended" });
assertEquals((await state("owner-d")).menus[0], "resolved");
assertEquals(await ownerD.locator(".dialog-host").count(), 0);
assertEquals(await ownerD.locator(".background").count(), 0);
await page.evaluate(() => window.browserUiLifecycle!.hideMenu("owner-b"));
await waitFor(async () => (await state("owner-b")).menus.length === 1, "Owner B menu did not close");
await ownerB.getByTestId("close-dialog").click();
await waitFor(async () => (await state("owner-b")).dialogs.length === 2, "Owner B dialogue did not close");
assertEquals(await ownerB.locator(".background").count(), 0);
assertEquals(await ownerB.locator(".dialog-host").count(), 0);
assertNoPageFailures();
} finally {
await browser.close();
await server.close();
}
},
});
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Browser UI lifecycle fixture</title>
</head>
<body>
<iframe id="owner-a" title="Owner A"></iframe>
<iframe id="owner-b" title="Owner B"></iframe>
<iframe id="owner-c" title="Owner C"></iframe>
<iframe id="owner-d" title="Owner D"></iframe>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -0,0 +1,16 @@
<script lang="ts">
import { getDialogContext, type GuestDialogProps } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog";
const { setResult, getInitialData }: GuestDialogProps<string, string> = $props();
const dialog = getDialogContext<string, string>();
dialog.setTitle("Lifecycle dialogue");
function submitResult() {
setResult(`result:${getInitialData?.() ?? "none"}`);
}
</script>
<section data-testid="lifecycle-dialog">
<button data-testid="submit-dialog" onclick={submitResult}>Submit result</button>
<button data-testid="close-dialog" onclick={() => dialog.closeDialog()}>Close without result</button>
</section>
+154
View File
@@ -0,0 +1,154 @@
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { MemoryValueStore } from "@vrtmrz/value-store";
import { Menu } from "@/apps/browser/BrowserMenu";
import { createLiveSyncBrowserServiceHub } from "@/apps/browser/createLiveSyncBrowserServiceHub";
import TestLifecycleDialog from "./TestLifecycleDialog.svelte";
type OwnerName = "owner-a" | "owner-b" | "owner-c" | "owner-d";
type DialogOutcome =
| { readonly kind: "resolved"; readonly value: unknown }
| { readonly kind: "rejected"; readonly message: string };
interface Owner {
readonly context: ReturnType<typeof createServiceContext>;
readonly document: Document;
readonly hub: ReturnType<typeof createLiveSyncBrowserServiceHub>;
readonly menu: Menu;
readonly parentAbort?: AbortController;
readonly dialogOutcomes: DialogOutcome[];
readonly menuOutcomes: string[];
savedBackdrop?: HTMLElement;
}
interface BrowserUiLifecycleFixture {
readonly ready: boolean;
startDialog(name: OwnerName, initialData?: string): void;
startMenu(name: OwnerName, title: string): void;
clickSavedBackdrop(name: OwnerName): void;
hideMenu(name: OwnerName): void;
unload(name: OwnerName): void;
abortParent(name: OwnerName): void;
state(name: OwnerName): {
readonly dialogs: readonly DialogOutcome[];
readonly menus: readonly string[];
readonly hasSavedBackdrop: boolean;
};
}
declare global {
interface Window {
browserUiLifecycle?: BrowserUiLifecycleFixture;
}
}
const owners = new Map<OwnerName, Owner>();
function ownerDocument(name: OwnerName): Document {
const frame = document.getElementById(name);
if (!(frame instanceof HTMLIFrameElement) || !frame.contentDocument) {
throw new Error(`The ${name} frame is not ready`);
}
return frame.contentDocument;
}
function createOwner(name: OwnerName, options: { parentAbort?: AbortController; preAborted?: boolean } = {}): Owner {
const frameDocument = ownerDocument(name);
const parentAbort = options.parentAbort;
if (options.preAborted) {
parentAbort?.abort(new Error("Fixture UI owner ended"));
}
const context = createServiceContext();
const hub = createLiveSyncBrowserServiceHub({
context,
deviceValues: new MemoryValueStore(),
ui: {
document: frameDocument,
...(parentAbort ? { signal: parentAbort.signal } : {}),
},
});
return {
context,
document: frameDocument,
hub,
menu: new Menu({ document: frameDocument, signal: hub.browserUi.signal }),
...(parentAbort ? { parentAbort } : {}),
dialogOutcomes: [],
menuOutcomes: [],
};
}
const ownerCAbort = new AbortController();
const ownerDAbort = new AbortController();
ownerDAbort.abort(new Error("Fixture UI owner ended"));
owners.set("owner-a", createOwner("owner-a"));
owners.set("owner-b", createOwner("owner-b"));
owners.set("owner-c", createOwner("owner-c", { parentAbort: ownerCAbort }));
owners.set("owner-d", createOwner("owner-d", { parentAbort: ownerDAbort, preAborted: true }));
const fixture: BrowserUiLifecycleFixture = {
ready: true,
startDialog(name, initialData) {
const owner = owners.get(name)!;
try {
void owner.hub.UI.dialogManager.open(TestLifecycleDialog, initialData).then(
(value) =>
owner.dialogOutcomes.push({ kind: "resolved", value: value === undefined ? "undefined" : value }),
(error: unknown) =>
owner.dialogOutcomes.push({
kind: "rejected",
message: error instanceof Error ? error.message : String(error),
})
);
} catch (error) {
owner.dialogOutcomes.push({
kind: "rejected",
message: error instanceof Error ? error.message : String(error),
});
}
},
startMenu(name, title) {
const owner = owners.get(name)!;
owner.savedBackdrop = owner.document.querySelector<HTMLElement>(".background") ?? undefined;
try {
void owner.menu
.addItem((item) => item.setTitle(title))
.showAtPosition({ x: 12, y: 12 })
.then(
() => owner.menuOutcomes.push("resolved"),
(error: unknown) =>
owner.menuOutcomes.push(`rejected:${error instanceof Error ? error.message : String(error)}`)
);
} catch (error) {
owner.menuOutcomes.push(`rejected:${error instanceof Error ? error.message : String(error)}`);
}
},
clickSavedBackdrop(name) {
const owner = owners.get(name)!;
const window = owner.document.defaultView;
if (owner.savedBackdrop && window) {
owner.savedBackdrop.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
}
},
hideMenu(name) {
owners.get(name)!.menu.hide();
},
unload(name) {
owners.get(name)!.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
},
abortParent(name) {
owners.get(name)!.parentAbort?.abort(new Error("Fixture UI owner ended"));
},
state(name) {
const owner = owners.get(name)!;
return {
dialogs: [...owner.dialogOutcomes],
menus: [...owner.menuOutcomes],
hasSavedBackdrop: owner.savedBackdrop !== undefined,
};
},
};
window.browserUiLifecycle = fixture;
@@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"]
},
"include": ["src/**/*.ts", "src/**/*.svelte"],
"exclude": []
}
@@ -0,0 +1,25 @@
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { defineConfig } from "vite";
const fixtureDirectory = dirname(fileURLToPath(import.meta.url));
const repositoryRoot = resolve(fixtureDirectory, "../../..");
export default defineConfig({
root: fixtureDirectory,
plugins: [svelte()],
resolve: {
alias: {
"@": resolve(repositoryRoot, "src"),
},
},
base: "./",
build: {
outDir: resolve(repositoryRoot, "_testdata/browser-ui-lifecycle"),
emptyOutDir: true,
rollupOptions: {
input: resolve(fixtureDirectory, "index.html"),
},
},
});
+58
View File
@@ -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");
});
});
+3 -1
View File
@@ -14,5 +14,7 @@
"test:e2e-matrix:couchdb-enc0",
"test:e2e-matrix:couchdb-enc1",
"test:e2e-matrix:minio-enc0",
"test:e2e-matrix:minio-enc1"
"test:e2e-matrix:minio-enc1",
"test:fetch-database-continuity",
"test:journal-chunk-recovery"
]
+54
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import { writeSync } from "node:fs";
import { spawnProcess } from "./process.ts";
/**
@@ -577,6 +578,59 @@ export async function startMinio(
await waitForMinioBucket(minioEndpoint, accessKey, secretKey, bucket);
}
export type IsolatedMinioFixture = {
endpoint: string;
accessKey: string;
secretKey: string;
stop: () => Promise<void>;
};
/** Start an S3 fixture owned by this caller without touching the shared test container. */
export async function startIsolatedMinio(accessKey: string, secretKey: string): Promise<IsolatedMinioFixture> {
const container = `livesync-cli-continuity-${randomUUID()}`;
console.log(`[INFO] starting isolated S3 test container: ${container}`);
let tracked = false;
const stop = async () => {
await stopAndRemoveContainer(container);
if (tracked) untrackContainer(container);
};
try {
await dockerOrFail(
"run",
"-d",
"--name",
container,
"-p",
"127.0.0.1::9000",
"-e",
`RUSTFS_ACCESS_KEY=${accessKey}`,
"-e",
`RUSTFS_SECRET_KEY=${secretKey}`,
"-e",
"RUSTFS_CONSOLE_ENABLE=true",
"-e",
"RUSTFS_CORS_ALLOWED_ORIGINS=*",
S3_IMAGE,
"/data"
);
trackContainer(container);
tracked = true;
const mapping = await dockerOrFail("port", container, "9000/tcp");
const port = Number(mapping.trim().match(/:(\d+)$/)?.[1]);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`Could not determine the isolated S3 fixture port from: ${mapping.trim()}`);
}
const endpoint = `http://127.0.0.1:${port}`;
await fetchRetry(endpoint, { method: "GET" }, 30, 1000, [403, 404]);
return { endpoint, accessKey, secretKey, stop };
} catch (error) {
await stop();
throw error;
}
}
// ---------------------------------------------------------------------------
// P2P relay (strfry)
// ---------------------------------------------------------------------------
+12 -1
View File
@@ -1,7 +1,8 @@
{
"test:helpers": {
"test": [
"helpers/process.test.ts"
"helpers/process.test.ts",
"test-ci-task-contract.ts"
]
},
"test": {
@@ -47,6 +48,11 @@
"test-settings-writeback.ts"
]
},
"test:fetch-database-continuity": {
"test": [
"test-fetch-database-continuity.ts"
]
},
"test:remote-administration-exit-codes": {
"test": [
"test-remote-administration-exit-codes.ts"
@@ -166,6 +172,11 @@
"test-e2e-two-vaults-couchdb.ts"
]
},
"test:journal-chunk-recovery": {
"test": [
"test-journal-chunk-recovery.ts"
]
},
"test:e2e-matrix": {
"test": [
"test-e2e-two-vaults-matrix.ts"
+18
View File
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { test } from "node:test";
test("fetch database continuity is exercised by standard CLI CI", async () => {
const ciTasks = JSON.parse(await readFile(new URL("./ci-tasks.json", import.meta.url), "utf8")) as string[];
const tasks = JSON.parse(await readFile(new URL("./tasks.json", import.meta.url), "utf8")) as Record<
string,
{ test?: string[] }
>;
const workflow = await readFile(new URL("../../.github/workflows/cli-node-tests.yml", import.meta.url), "utf8");
assert(ciTasks.includes("test:helpers"), "the standard CI suite must run the task registration contract");
assert(tasks["test:helpers"]?.test?.includes("test-ci-task-contract.ts"));
assert(ciTasks.includes("test:fetch-database-continuity"), "the continuity task must be in the standard CI suite");
assert.deepEqual(tasks["test:fetch-database-continuity"]?.test, ["test-fetch-database-continuity.ts"]);
assert.match(workflow, /test\/e2e-cli\/ci-tasks\.json/, "the automatic CI matrix must read the authoritative task list");
});
@@ -0,0 +1,637 @@
import assert from "node:assert/strict";
import { createHash, randomUUID } from "node:crypto";
import { cp, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { after, before, test } from "node:test";
import { CreateBucketCommand, S3Client } from "@aws-sdk/client-s3";
import { CLI_DIR, CLI_DIST } from "./helpers/cli.ts";
import { startIsolatedMinio, type IsolatedMinioFixture } from "./helpers/docker.ts";
import { applyRemoteSyncSettings, generateSetupUriFromSettings, initSettingsFile } from "./helpers/settings.ts";
import { TempDir } from "./helpers/temp.ts";
import { spawnProcess, type RunningProcess } from "./helpers/process.ts";
type RemoteSettings = {
endpoint: string;
accessKey: string;
secretKey: string;
bucket: string;
evidenceDir: string;
};
type CliResult = { code: number; stdout: string; stderr: string; combined: string };
type ScenarioMode = "reproduction" | "write-settings-control";
const SETUP_PASSPHRASE = "issue-1304-setup-passphrase";
const RESOLUTION_MARKER = "Resolving [same]: Conflicted revision has been deleted";
const INITIAL_REPLICATION_COMPLETE = "[Daemon] Initial replication complete";
const INITIAL_REPLICATION_FAILED = "[Daemon] Initial replication failed";
const VERIFY_LEGACY_FAILURE = process.env["LIVESYNC_ISSUE1304_VERIFY_LEGACY_FAILURE"] === "1";
const EXPLICIT_FIXTURE_VARIABLES = [
"LIVESYNC_ISSUE1304_MINIO_ENDPOINT",
"LIVESYNC_ISSUE1304_MINIO_ACCESS_KEY",
"LIVESYNC_ISSUE1304_MINIO_SECRET_KEY",
"LIVESYNC_ISSUE1304_MINIO_BUCKET",
];
const EXPLICIT_FIXTURE = EXPLICIT_FIXTURE_VARIABLES.some((name) => process.env[name]?.trim());
if (EXPLICIT_FIXTURE && EXPLICIT_FIXTURE_VARIABLES.some((name) => !process.env[name]?.trim())) {
throw new Error(`External fixture mode requires all of: ${EXPLICIT_FIXTURE_VARIABLES.join(", ")}`);
}
let managedFixture: IsolatedMinioFixture | undefined;
if (!EXPLICIT_FIXTURE) {
before(async () => {
managedFixture = await startIsolatedMinio(
process.env["accessKey"]?.trim() ?? "minioadmin",
process.env["secretKey"]?.trim() ?? "minioadmin"
);
});
after(async () => {
await managedFixture?.stop();
});
}
function requiredEnv(...names: string[]): string {
for (const name of names) {
const value = process.env[name]?.trim();
if (value) return value;
}
throw new Error(`Required environment variable is missing: ${names.join(" or ")}`);
}
function evidenceDirectory(): string {
return resolve(
process.env["LIVESYNC_ISSUE1304_EVIDENCE_DIR"]?.trim() || join(tmpdir(), "livesync-cli-issue1304-evidence")
);
}
function remoteSettings(): RemoteSettings {
return {
endpoint: requiredEnv("LIVESYNC_ISSUE1304_MINIO_ENDPOINT").replace(/\/$/, ""),
accessKey: requiredEnv("LIVESYNC_ISSUE1304_MINIO_ACCESS_KEY"),
secretKey: requiredEnv("LIVESYNC_ISSUE1304_MINIO_SECRET_KEY"),
bucket: requiredEnv("LIVESYNC_ISSUE1304_MINIO_BUCKET"),
evidenceDir: evidenceDirectory(),
};
}
async function createManagedBucket(remote: Pick<RemoteSettings, "endpoint" | "accessKey" | "secretKey">, mode: ScenarioMode): Promise<string> {
const shortMode = mode === "reproduction" ? "repro" : "control";
const bucket = `livesync-ci-1304-${shortMode}-${randomUUID().replaceAll("-", "")}`;
const client = new S3Client({
endpoint: remote.endpoint,
region: "us-east-1",
forcePathStyle: true,
credentials: { accessKeyId: remote.accessKey, secretAccessKey: remote.secretKey },
});
try {
await client.send(new CreateBucketCommand({ Bucket: bucket }));
} finally {
client.destroy();
}
return bucket;
}
async function scenarioRemoteSettings(mode: ScenarioMode): Promise<RemoteSettings> {
if (EXPLICIT_FIXTURE) {
const remote = remoteSettings();
if (mode === "write-settings-control") remote.bucket = requiredEnv("LIVESYNC_ISSUE1304_POSITIVE_CONTROL_BUCKET");
return remote;
}
if (!managedFixture) throw new Error("The managed S3 fixture was not started");
const remote = {
endpoint: managedFixture.endpoint,
accessKey: managedFixture.accessKey,
secretKey: managedFixture.secretKey,
};
return {
...remote,
bucket: await createManagedBucket(remote, mode),
evidenceDir: evidenceDirectory(),
};
}
function redactText(value: string, secrets: readonly string[]): string {
let redacted = value;
for (const secret of secrets) {
if (!secret) continue;
redacted = redacted.split(secret).join("[redacted]");
const encoded = encodeURIComponent(secret);
if (encoded !== secret) redacted = redacted.split(encoded).join("[redacted]");
}
return redacted;
}
async function streamText(stream: ReadableStream<Uint8Array>, onText: (text: string) => void): Promise<void> {
const reader = stream.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) onText(decoder.decode(value, { stream: true }));
}
const tail = decoder.decode();
if (tail) onText(tail);
} finally {
reader.releaseLock();
}
}
class CapturedCliProcess {
readonly child: RunningProcess;
#stdout = "";
#stderr = "";
#secrets = [
process.env["LIVESYNC_ISSUE1304_MINIO_ENDPOINT"] ?? "",
process.env["LIVESYNC_ISSUE1304_MINIO_ACCESS_KEY"] ?? "",
process.env["LIVESYNC_ISSUE1304_MINIO_SECRET_KEY"] ?? "",
SETUP_PASSPHRASE,
"issue-1304-content-passphrase",
];
#streamsDone: Promise<void>;
constructor(args: string[]) {
this.child = spawnProcess(process.execPath, {
args: [CLI_DIST, ...args],
cwd: CLI_DIR,
stdin: "null",
stdout: "piped",
stderr: "piped",
});
this.#streamsDone = Promise.all([
streamText(this.child.stdout, (text) => (this.#stdout += text)),
streamText(this.child.stderr, (text) => (this.#stderr += text)),
]).then(() => undefined);
}
get combined(): string {
return this.#stdout + this.#stderr;
}
async waitUntil(predicate: (output: string) => boolean, description: string, timeoutMs = 60_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate(this.combined)) return;
const tick = Math.min(100, deadline - Date.now());
const event = await Promise.race([
this.child.status.then((status) => ({ kind: "exit" as const, status })),
new Promise<{ kind: "tick" }>((resolveTick) => setTimeout(() => resolveTick({ kind: "tick" }), tick)),
]);
if (event.kind === "exit") {
await this.#streamsDone;
if (predicate(this.combined)) return;
throw new Error(
`${description}; process exited with ${event.status.code}\n${redactText(this.combined, this.#secrets).slice(-5000)}`
);
}
}
throw new Error(`Timed out waiting for ${description}\n${redactText(this.combined, this.#secrets).slice(-5000)}`);
}
async stop(): Promise<number> {
try {
this.child.kill("SIGTERM");
} catch {
// A completed process needs no further signal.
}
const status = await this.child.status;
await this.#streamsDone;
return status.code;
}
}
async function runCliQuietly(args: string[], input?: string): Promise<CliResult> {
const child = spawnProcess(process.execPath, {
args: [CLI_DIST, ...args],
cwd: CLI_DIR,
stdin: input === undefined ? "null" : "piped",
stdout: "piped",
stderr: "piped",
});
let stdout = "";
let stderr = "";
const streamsDone = Promise.all([
streamText(child.stdout, (text) => (stdout += text)),
streamText(child.stderr, (text) => (stderr += text)),
]);
if (input !== undefined) {
const writer = child.stdin.getWriter();
await writer.write(new TextEncoder().encode(input));
await writer.close();
}
const [status] = await Promise.all([child.status, streamsDone]);
return { code: status.code, stdout, stderr, combined: stdout + stderr };
}
function countOccurrences(value: string, needle: string): number {
return value.split(needle).length - 1;
}
function safeCommand(
label: string,
result: CliResult,
commandLog: Array<{ label: string; code: number; output: string }>,
secrets: readonly string[]
): void {
const output = redactText(result.combined, secrets);
commandLog.push({ label, code: result.code, output });
assert.equal(result.code, 0, `${label} failed with exit code ${result.code}\n${output.slice(-5000)}`);
}
async function fileState(filePath: string): Promise<{ exists: boolean; bytes?: number; sha256?: string }> {
try {
const contents = await readFile(filePath);
return {
exists: true,
bytes: contents.byteLength,
sha256: createHash("sha256").update(contents).digest("hex"),
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { exists: false };
throw error;
}
}
async function collectDatabaseTree(root: string, relative = ""): Promise<Array<{ path: string; bytes: number; sha256: string }>> {
const result: Array<{ path: string; bytes: number; sha256: string }> = [];
let entries;
try {
entries = await readdir(join(root, relative), { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return result;
throw error;
}
entries.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
if (!relative && entry.name === ".livesync") continue;
const child = relative ? join(relative, entry.name) : entry.name;
const absolute = join(root, child);
if (entry.isDirectory()) {
result.push(...(await collectDatabaseTree(root, child)));
} else if (entry.isFile()) {
const contents = await readFile(absolute);
result.push({
path: child,
bytes: contents.byteLength,
sha256: createHash("sha256").update(contents).digest("hex"),
});
}
}
return result;
}
async function copyDatabaseTree(source: string, destination: string): Promise<void> {
await mkdir(destination, { recursive: true });
let entries;
try {
entries = await readdir(source, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
throw error;
}
for (const entry of entries) {
if (entry.name === ".livesync") continue;
await cp(join(source, entry.name), join(destination, entry.name), { recursive: true, errorOnExist: false });
}
}
async function sanitisedRuntimeState(root: string, secrets: readonly string[]): Promise<Record<string, string>> {
const runtimeRoot = join(root, ".livesync", "runtime");
const files: Record<string, string> = {};
let entries;
try {
entries = await readdir(runtimeRoot, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return files;
throw error;
}
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
const text = await readFile(join(runtimeRoot, entry.name), "utf8");
try {
files[entry.name] = JSON.stringify(JSON.parse(text), (_key, value: unknown) => {
return typeof value === "string" ? redactText(value, secrets) : value;
}, 2);
} catch {
files[entry.name] = redactText(text, secrets);
}
}
return files;
}
async function settingsSummary(settingsPath: string): Promise<Record<string, unknown> | null> {
try {
const settings = JSON.parse(await readFile(settingsPath, "utf8")) as Record<string, unknown>;
return {
additionalSuffixOfDatabaseName: settings.additionalSuffixOfDatabaseName ?? null,
remoteType: settings.remoteType ?? null,
liveSync: settings.liveSync ?? null,
syncOnStart: settings.syncOnStart ?? null,
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
}
async function runScenario(mode: ScenarioMode, remote: RemoteSettings): Promise<void> {
const evidenceRoot = remote.evidenceDir;
const evidenceName = `${mode}-${Date.now()}-${process.pid}`;
const evidenceDir = join(evidenceRoot, evidenceName);
const workDir = await TempDir.create(`livesync-cli-issue1304-${mode}`);
const scenarioId = `${Date.now()}-${process.pid}`;
const suffix = `issue1304-${scenarioId}`;
const targetPath = `notes/issue1304-${scenarioId}/continuity.md`;
const initialContent = `issue-1304 initial ${scenarioId}\n`;
const finalContent = `issue-1304 final ${scenarioId}\n`;
const finalBytes = new TextEncoder().encode(finalContent);
const secrets = [remote.endpoint, remote.accessKey, remote.secretKey, SETUP_PASSPHRASE, "issue-1304-content-passphrase"];
const commandLog: Array<{ label: string; code: number; output: string }> = [];
const processes: Record<string, CapturedCliProcess | undefined> = {};
const settingsTimeline: Record<string, unknown> = {};
const postResolutionInfo: Record<string, Record<string, unknown> | null> = {};
let observedFinalFileA: { exists: boolean; bytes?: number; sha256?: string } | undefined;
let observedFinalContentA: string | undefined;
let setupUri: string | undefined;
let failure: unknown;
const databaseSeed = workDir.join("database-seed");
const databaseA = workDir.join("database-a");
const databaseB = workDir.join("database-b");
const vaultSeed = workDir.join("vault-seed");
const vaultA = workDir.join("vault-a");
const vaultB = workDir.join("vault-b");
const settingsSeed = workDir.join("settings-seed.json");
const settingsA = workDir.join("settings-a.json");
const settingsB = workDir.join("settings-b.json");
const finalFileA = join(vaultA, targetPath);
const finalFileB = join(vaultB, targetPath);
try {
await Promise.all([
mkdir(databaseSeed, { recursive: true }),
mkdir(databaseA, { recursive: true }),
mkdir(databaseB, { recursive: true }),
mkdir(vaultSeed, { recursive: true }),
mkdir(vaultA, { recursive: true }),
mkdir(vaultB, { recursive: true }),
]);
await initSettingsFile(settingsSeed);
await applyRemoteSyncSettings(settingsSeed, {
remoteType: "MINIO",
minioBucket: remote.bucket,
minioEndpoint: remote.endpoint,
minioAccessKey: remote.accessKey,
minioSecretKey: remote.secretKey,
encrypt: true,
passphrase: "issue-1304-content-passphrase",
usePathObfuscation: true,
});
const seedSettings = JSON.parse(await readFile(settingsSeed, "utf8")) as Record<string, unknown>;
seedSettings.additionalSuffixOfDatabaseName = suffix;
await writeFile(settingsSeed, JSON.stringify(seedSettings, null, 2));
settingsTimeline.setupSuffix = suffix;
setupUri = await generateSetupUriFromSettings(settingsSeed, SETUP_PASSPHRASE, true);
secrets.push(setupUri);
const seedRun = async (label: string, args: string[], input?: string) => {
const result = await runCliQuietly(args, input);
safeCommand(label, result, commandLog, secrets);
return result;
};
await seedRun("seed-put-initial", [databaseSeed, "--vault", vaultSeed, "--settings", settingsSeed, "put", targetPath], initialContent);
await seedRun("seed-sync-initial", [databaseSeed, "--vault", vaultSeed, "--settings", settingsSeed, "sync"]);
await seedRun("seed-put-final", [databaseSeed, "--vault", vaultSeed, "--settings", settingsSeed, "put", targetPath], finalContent);
await seedRun("seed-sync-final", [databaseSeed, "--vault", vaultSeed, "--settings", settingsSeed, "sync"]);
const seedInfoResult = await seedRun("seed-info", [databaseSeed, "--vault", vaultSeed, "--settings", settingsSeed, "info", targetPath]);
const seedInfo = JSON.parse(seedInfoResult.stdout) as { revision?: string; revisions?: string[] };
const seedRevisionGeneration = Number(seedInfo.revision?.split("-")[0] ?? 0);
assert(
seedRevisionGeneration >= 2 && (seedInfo.revisions?.length ?? 0) >= 1,
`Expected a remote seed revision with generation >= 2; got ${JSON.stringify({ revision: seedInfo.revision, revisions: seedInfo.revisions })}`
);
settingsTimeline.seedRevision = seedInfo.revision;
await initSettingsFile(settingsA);
await initSettingsFile(settingsB);
const setupA = await runCliQuietly(
[databaseA, "--vault", vaultA, "--settings", settingsA, "setup", setupUri],
`${SETUP_PASSPHRASE}\n`
);
safeCommand("setup-client-a", setupA, commandLog, secrets);
const setupB = await runCliQuietly(
[databaseB, "--vault", vaultB, "--settings", settingsB, "setup", setupUri],
`${SETUP_PASSPHRASE}\n`
);
safeCommand("setup-client-b", setupB, commandLog, secrets);
const bFetchArgs = [databaseB, "--vault", vaultB, "--settings", settingsB, "--force", "fetch", "--write-settings"];
const bFetch = await runCliQuietly(bFetchArgs);
safeCommand("fetch-client-b", bFetch, commandLog, secrets);
assert.equal(await readFile(finalFileB, "utf8"), finalContent, "Client B did not fetch the final file bytes");
settingsTimeline.clientBSuffixAfterFetch = (await settingsSummary(settingsB))?.additionalSuffixOfDatabaseName;
assert.equal(settingsTimeline.clientBSuffixAfterFetch, "headless-app");
// Client B remains live so it can resolve the equal-content root after A
// scans its old database and uploads a new root.
processes.clientB = new CapturedCliProcess([
"-v",
databaseB,
"--vault",
vaultB,
"--settings",
settingsB,
"--interval",
"1",
"daemon",
]);
await processes.clientB.waitUntil((output) => output.includes(INITIAL_REPLICATION_COMPLETE), "Client B initial replication");
settingsTimeline.clientASuffixBeforeFetch = (await settingsSummary(settingsA))?.additionalSuffixOfDatabaseName;
assert.equal(settingsTimeline.clientASuffixBeforeFetch, suffix);
const aFetchArgs = [databaseA, "--vault", vaultA, "--settings", settingsA, "--force", "fetch"];
if (mode === "write-settings-control") aFetchArgs.push("--write-settings");
const aFetch = await runCliQuietly(aFetchArgs);
safeCommand("fetch-client-a", aFetch, commandLog, secrets);
assert.equal(await readFile(finalFileA, "utf8"), finalContent, "Client A did not fetch the final file bytes");
settingsTimeline.clientASuffixAfterFetch = (await settingsSummary(settingsA))?.additionalSuffixOfDatabaseName;
assert.equal(
settingsTimeline.clientASuffixAfterFetch,
mode === "reproduction" && VERIFY_LEGACY_FAILURE ? suffix : "headless-app",
"Client A settings suffix did not match the selected fetch mode"
);
// This startup selects A's persisted suffix. In the reproduction mode it
// scans the old, empty database while the fetched bytes are in the vault.
processes.clientAStartup = new CapturedCliProcess([
"-v",
databaseA,
"--vault",
vaultA,
"--settings",
settingsA,
"daemon",
]);
await processes.clientAStartup.waitUntil(
(output) => output.includes(INITIAL_REPLICATION_COMPLETE) || output.includes(INITIAL_REPLICATION_FAILED),
"Client A startup replication result",
45_000
);
await processes.clientAStartup.stop();
const markResolved = await runCliQuietly([databaseA, "--vault", vaultA, "--settings", settingsA, "mark-resolved"]);
safeCommand("mark-resolved-client-a", markResolved, commandLog, secrets);
if (mode === "reproduction" && VERIFY_LEGACY_FAILURE) {
processes.clientA = new CapturedCliProcess([
"-v",
databaseA,
"--vault",
vaultA,
"--settings",
settingsA,
"--interval",
"1",
"daemon",
]);
await processes.clientA.waitUntil((output) => output.includes(INITIAL_REPLICATION_COMPLETE), "Client A interval daemon initial replication");
const processingMarker = `Processing ${targetPath}`;
const aProcessingBeforeResolution = countOccurrences(processes.clientA.combined, processingMarker);
await processes.clientB.waitUntil((output) => output.includes(RESOLUTION_MARKER), "Client B automatic same-content resolution");
await processes.clientA.waitUntil(
(output) => countOccurrences(output, processingMarker) > aProcessingBeforeResolution,
"Client A to reflect the post-resolution revision",
45_000
);
} else {
processes.clientA = new CapturedCliProcess([
"-v",
databaseA,
"--vault",
vaultA,
"--settings",
settingsA,
"--interval",
"1",
"daemon",
]);
await processes.clientA.waitUntil((output) => output.includes(INITIAL_REPLICATION_COMPLETE), "Client A control daemon initial replication");
await new Promise((resolveDelay) => setTimeout(resolveDelay, 2_500));
assert.equal(await readFile(finalFileA, "utf8"), finalContent, "Client A control lost the fetched file");
}
} catch (error) {
failure = error;
}
for (const processCapture of Object.values(processes)) {
if (!processCapture) continue;
try {
await processCapture.stop();
} catch (error) {
if (failure === undefined) failure = error;
}
}
try {
await mkdir(evidenceDir, { recursive: true });
for (const [name, database, vault, settingsPath] of [
["clientA", databaseA, vaultA, settingsA],
["clientB", databaseB, vaultB, settingsB],
] as const) {
const result = await runCliQuietly([database, "--vault", vault, "--settings", settingsPath, "info", targetPath]);
const output = redactText(result.combined, secrets);
commandLog.push({ label: `post-resolution-info-${name}`, code: result.code, output });
if (result.code === 0) {
try {
postResolutionInfo[name] = JSON.parse(result.stdout) as Record<string, unknown>;
} catch {
postResolutionInfo[name] = null;
}
} else {
postResolutionInfo[name] = null;
}
}
observedFinalFileA = await fileState(finalFileA);
if (observedFinalFileA.exists) observedFinalContentA = await readFile(finalFileA, "utf8");
const databaseTrees: Record<string, Array<{ path: string; bytes: number; sha256: string }>> = {};
const runtimeState: Record<string, Record<string, string>> = {};
const databasePaths = { seed: databaseSeed, clientA: databaseA, clientB: databaseB };
for (const [name, path] of Object.entries(databasePaths)) {
databaseTrees[name] = await collectDatabaseTree(path);
await copyDatabaseTree(path, join(evidenceDir, `database-${name}`));
runtimeState[name] = await sanitisedRuntimeState(path, secrets);
if (Object.keys(runtimeState[name]).length > 0) {
const runtimeDir = join(evidenceDir, `runtime-${name}`);
await mkdir(runtimeDir, { recursive: true });
for (const [fileName, contents] of Object.entries(runtimeState[name])) {
await writeFile(join(runtimeDir, fileName), contents);
}
}
}
const logs = Object.fromEntries(
Object.entries(processes).map(([name, processCapture]) => [
name,
processCapture ? redactText(processCapture.combined, secrets) : "",
])
);
const fileStates = {
clientA: await fileState(finalFileA),
clientB: await fileState(finalFileB),
};
const evidence = {
scenario: mode,
targetPath,
expectedContentBytes: finalBytes.byteLength,
expectedContentSha256: createHash("sha256").update(finalBytes).digest("hex"),
settingsTimeline,
settings: {
seed: await settingsSummary(settingsSeed),
clientA: await settingsSummary(settingsA),
clientB: await settingsSummary(settingsB),
},
fileStates,
postResolutionInfo,
databaseTrees,
runtimeState,
commands: commandLog,
processLogs: logs,
remote: { endpoint: "[configured]", bucket: remote.bucket },
failure: failure instanceof Error ? redactText(failure.stack ?? failure.message, secrets) : String(failure ?? ""),
};
await writeFile(join(evidenceDir, "evidence.json"), JSON.stringify(evidence, null, 2));
} catch (error) {
if (failure === undefined) failure = error;
else if (failure instanceof Error) {
failure = new Error(`${failure.message}\nEvidence capture failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
await workDir[Symbol.asyncDispose]();
if (failure !== undefined) throw failure;
assert.equal(observedFinalFileA?.exists, true, "Client A file is absent after the resolved journal was applied");
assert.equal(observedFinalContentA, finalContent, "Client A lost the fetched file after remote conflict resolution");
if (!VERIFY_LEGACY_FAILURE) {
const infoA = postResolutionInfo.clientA;
const infoB = postResolutionInfo.clientB;
assert(infoA && infoB, "Could not inspect both local databases after the remote resolution");
assert.equal(infoA.conflicts, "N/A", "Client A still has a duplicate root revision after the resolution");
assert.equal(infoA.revision, infoB.revision, "Client A did not apply the revision selected by Client B after the resolved journal");
assert.equal(infoA.revision, settingsTimeline.seedRevision, "Daemon startup replaced the fetched revision ancestry");
assert.equal(processes.clientB?.combined.includes(RESOLUTION_MARKER), false, "Client A created an unnecessary competing root");
}
}
test("CLI fetch: files remain after same-content duplicate root resolution", async () => {
await runScenario("reproduction", await scenarioRemoteSettings("reproduction"));
});
if (!EXPLICIT_FIXTURE || process.env["LIVESYNC_ISSUE1304_POSITIVE_CONTROL"] === "1") {
test("CLI fetch: --write-settings keeps the selected database across daemon startup", async () => {
await runScenario("write-settings-control", await scenarioRemoteSettings("write-settings-control"));
});
}
+212
View File
@@ -0,0 +1,212 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { TempDir } from "./helpers/temp.ts";
import { assertFilesEqual, runCliOrFail, sanitiseCatStdout } from "./helpers/cli.ts";
import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts";
import { startMinio, stopMinio } from "./helpers/docker.ts";
function requireEnv(...keys: string[]): string {
for (const key of keys) {
const value = process.env[key]?.trim();
if (value) return value;
}
throw new Error(`Required env var is missing: ${keys.join(" or ")}`);
}
function createJournalRecoveryBucketName(baseBucket: string, suffix: string): string {
const bucketSuffix = `journal-recovery-${suffix}`;
const maxBaseLength = 63 - bucketSuffix.length - 1;
const truncatedBase = baseBucket.slice(0, maxBaseLength).replace(/-+$/, "");
return truncatedBase ? `${truncatedBase}-${bucketSuffix}` : bucketSuffix;
}
function createJournalFixture(): string {
return [
"Journal chunk recovery fixture: UTF-8, newlines, and backslashes.\n",
...Array.from(
{ length: 128 },
(_, index) =>
`record ${String(index).padStart(3, "0")}: prefix\\path café 日本語 \u001f middle-${index} \u001f suffix-${index} remains after the separator.\n`
),
"Final suffix after every control separator remains present.\n",
].join("");
}
async function runScenario(encrypt: boolean): Promise<void> {
const suffix = `${encrypt ? "enc1" : "enc0"}-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
const minioEndpoint = requireEnv("MINIO_ENDPOINT", "minioEndpoint").replace(/\/$/, "");
const minioAccessKey = requireEnv("MINIO_ACCESS_KEY", "accessKey");
const minioSecretKey = requireEnv("MINIO_SECRET_KEY", "secretKey");
const minioBucket = createJournalRecoveryBucketName(requireEnv("MINIO_BUCKET_NAME", "bucketName"), suffix);
const passphrase = "e2e-passphrase";
const fileContent = createJournalFixture();
const expectedBytes = Buffer.from(fileContent, "utf8");
const targetPath = "e2e/journal-chunk-recovery.md";
const keepDocker = process.env["LIVESYNC_DEBUG_KEEP_DOCKER"] === "1";
await using workDir = await TempDir.create(`livesync-cli-journal-recovery-${suffix}`);
const sourceDatabase = workDir.join("source-db");
const receiverDatabase = workDir.join("receiver-db");
const sourceVault = workDir.join("source-vault");
const receiverVault = workDir.join("receiver-vault");
const sourceSettings = workDir.join("source-settings.json");
const receiverSettings = workDir.join("receiver-settings.json");
const sourceFile = join(sourceVault, targetPath);
const receiverFile = join(receiverVault, targetPath);
await Promise.all([
mkdir(sourceDatabase, { recursive: true }),
mkdir(receiverDatabase, { recursive: true }),
mkdir(sourceVault, { recursive: true }),
mkdir(receiverVault, { recursive: true }),
]);
await mkdir(dirname(sourceFile), { recursive: true });
await writeFile(sourceFile, expectedBytes);
try {
await startMinio(minioEndpoint, minioAccessKey, minioSecretKey, minioBucket);
await initSettingsFile(sourceSettings);
await initSettingsFile(receiverSettings);
await applyRemoteSyncSettings(sourceSettings, {
remoteType: "MINIO",
minioBucket,
minioEndpoint,
minioAccessKey,
minioSecretKey,
encrypt,
passphrase,
enableCompression: false,
});
await applyRemoteSyncSettings(receiverSettings, {
remoteType: "MINIO",
minioBucket,
minioEndpoint,
minioAccessKey,
minioSecretKey,
encrypt,
passphrase,
enableCompression: false,
});
await runCliOrFail(
sourceDatabase,
"--settings",
sourceSettings,
"--vault",
sourceVault,
"rebuild",
"--force",
"--write-settings"
);
await runCliOrFail(
receiverDatabase,
"--settings",
receiverSettings,
"--vault",
receiverVault,
"fetch",
"--force",
"--write-settings"
);
await assertFilesEqual(sourceFile, receiverFile, "Fetched Vault file does not match the rebuild source");
const fetchedContent = sanitiseCatStdout(
await runCliOrFail(receiverDatabase, "--settings", receiverSettings, "cat", targetPath)
);
assert.equal(fetchedContent, fileContent, "A fresh CLI process should read the exact fetched file content");
const damagedContent = "Damaged receiver Vault copy; the remote still has the complete source.\n";
await writeFile(receiverFile, damagedContent);
await runCliOrFail(
receiverDatabase,
"--settings",
receiverSettings,
"--vault",
receiverVault,
"fetch",
"--force",
"--write-settings"
);
assert.equal(
await readFile(receiverFile, "utf8"),
damagedContent,
"Fetch does not forcibly overwrite a newer local file"
);
const info: unknown = JSON.parse(
await runCliOrFail(receiverDatabase, "--settings", receiverSettings, "info", targetPath)
);
assert.ok(info && typeof info === "object" && "revision" in info && "conflicts" in info);
assert.ok(typeof info.revision === "string" && typeof info.conflicts === "string");
assert.match(info.revision, /^1-[\da-f]+$/, "The fetched file should retain an independent root");
assert.match(info.conflicts, /^1-[\da-f]+$/, "The differing local file should be preserved as a conflict");
assert.notEqual(info.revision, info.conflicts);
const retainedContents: string[] = [];
for (const revision of [info.revision, info.conflicts]) {
retainedContents.push(
sanitiseCatStdout(
await runCliOrFail(
receiverDatabase,
"--settings",
receiverSettings,
"cat-rev",
targetPath,
revision
)
)
);
}
assert.deepEqual(
retainedContents.sort(),
[fileContent, damagedContent].sort(),
"Fetch should retain both the intact remote and differing local contents without inferring their ancestry"
);
await rename(receiverFile, workDir.join("damaged-vault-backup.md"));
await runCliOrFail(
receiverDatabase,
"--settings",
receiverSettings,
"--vault",
receiverVault,
"fetch",
"--force",
"--write-settings"
);
await assertFilesEqual(sourceFile, receiverFile, "Fetching into an empty Vault should restore the remote file");
assert.equal(await readFile(workDir.join("damaged-vault-backup.md"), "utf8"), damagedContent);
const restoredContent = sanitiseCatStdout(
await runCliOrFail(receiverDatabase, "--settings", receiverSettings, "cat", targetPath)
);
assert.equal(restoredContent, fileContent, "A repeated fetch should retain the remote file content");
assert.deepEqual(
await readFile(sourceFile),
expectedBytes,
"The complete source Vault fixture should remain intact"
);
} finally {
if (!keepDocker) {
await stopMinio().catch(() => {});
}
}
}
test("journal recovery bucket name construction stays within S3 limits", () => {
const ciBucket = createJournalRecoveryBucketName("livesync-test-bucket-ci", "enc0-1793522628122-62377");
assert.ok(ciBucket.length <= 63, "The CI fixture must stay within the S3 bucket-name length limit");
assert.match(ciBucket, /^[a-z0-9][a-z0-9-]*[a-z0-9]$/);
const longBase = "a".repeat(63);
const unencryptedBucket = createJournalRecoveryBucketName(longBase, "enc0-1793522628122-62377");
const encryptedBucket = createJournalRecoveryBucketName(longBase, "enc1-1793522628122-62377");
assert.ok(unencryptedBucket.length <= 63);
assert.ok(encryptedBucket.length <= 63);
assert.match(unencryptedBucket, /^[a-z0-9][a-z0-9-]*[a-z0-9]$/);
assert.match(encryptedBucket, /^[a-z0-9][a-z0-9-]*[a-z0-9]$/);
assert.notEqual(unencryptedBucket, encryptedBucket, "Encryption scenarios should have distinct buckets");
});
test("journal chunk recovery through MINIO", async (t) => {
await t.test("without encryption", async () => runScenario(false));
await t.test("with encryption", async () => runScenario(true));
});
+15
View File
@@ -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.test("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 writeFile(source, content);
await run("push", source, remotePath);
await run("pull", remotePath, restored);
assert.deepStrictEqual(new Uint8Array(await readFile(restored)), content, "Binary push/pull must preserve every byte");
const info = JSON.parse(await run("info", remotePath)) as Record<string, unknown>;
assert.deepStrictEqual(info.size, content.byteLength, "Binary Metadata must retain the original byte length");
});
// ------------------------------------------------------------------
// ls: single file
// ------------------------------------------------------------------
+8
View File
@@ -174,6 +174,8 @@ The same workflow checks the two remote-activity status boundaries. It first hol
`test:e2e:obsidian:couchdb-http-cache` checks replication after rebuilding an encrypted CouchDB remote while an earlier HTTP response remains cached. It enables internal Metadata encryption with Hidden File Sync and Customisation Sync, verifies that the Rebuild replaces the Security Seed and ciphertext while retaining the document revision, and requires the replication pre-check and subsequent sends to succeed without clearing the browser cache. It uses the CouchDB fixture variables, an isolated Vault and profile, and a unique remote database. The raw command uses the current built plug-in by default; `E2E_OBSIDIAN_ARTIFACT_ROOT` can select an exact earlier build for regression comparison.
Run `npm run test:e2e:obsidian:focused -- couchdb-http-cache --request-api` to warm Obsidian's Internal API cache and perform the same workflow with **Use Internal API** enabled. Both modes require an explicit `no-store` read to return the rebuilt ciphertext. The default mode also retains the stale browser-cache assertion; the Internal API mode permits reads during Rebuild to refresh that cache.
`npm run test:e2e:obsidian:focused -- chunk-fetch-retry` checks delayed Chunk availability through a real CouchDB service and Obsidian. It creates a Metadata-only remote fixture, starts ordinary one-shot replication with `readChunksOnline`, and inserts the missing Chunk only after a real fetch has returned an empty result. A pass-through observer records the replicator's call times and results without substituting responses or adding waits. The fixture sets the existing minimum request interval to 500 ms to keep the real retry status observable even if finite completion expedites the final probe. The actual status bar must show zero initial requests (`🛄`) and one retry (`🔁`), and both counts must return to zero after delivery ends. On-demand replication excludes Chunk documents from the ordinary pull, so the delayed Chunk must arrive through the observed fetch and produce the exact Vault content.
If finite replication was already inactive when the initial lookup began, the scenario requires a retry at least two seconds later and no physical request slot occupied during backoff. If finite replication ends during the initial lookup or the following backoff, the retry must instead be a post-completion final probe before the two-second delay would expire. This distinction is determined from the observed finite-count transitions, not an assumed ordering between replication and HTTP completion.
@@ -270,6 +272,12 @@ 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.
The conflict cases also verify same-file dialogue replacement and received resolutions. A newer check closes the older JSON comparison, reads the latest revisions, and preserves both leaves until selection. A resolution delivered to the registered optional-file replication handler closes only the affected dialogue, drains its queue, and retains the winner and Vault bytes; an unrelated file is a control. This isolates the Obsidian receive boundary from peer and transport setup. Use `--case=replacement` or `--case=external-resolution` to run one of these cases alone, with the CouchDB fixture already running.
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}`);
}
});
+18 -10
View File
@@ -28,6 +28,8 @@ import { createTemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000";
const useRequestAPI = process.argv.includes("--request-api");
const customPath = ".obsidian/snippets/http-cache.css";
const nextCustomPath = ".obsidian/snippets/http-cache-new.css";
const settings = {
@@ -45,7 +47,7 @@ const settings = {
syncInternalFilesInterval: 0,
syncInternalFilesTargetPatterns: "^\\.http-cache(?:/|$)",
watchInternalFileChanges: false,
useRequestAPI: false,
useRequestAPI,
autoAcceptCompatibleTweak: false,
doctorProcessedVersion: DoctorRegulation.version,
deviceAndVaultName: "http-cache-source",
@@ -74,10 +76,11 @@ async function main(): Promise<void> {
.storeCustomizationFiles(path,core.services.setting.getDeviceAndVaultName());
return JSON.stringify(true);`);
};
const browserRead = async (id: string, cache: RequestCache): Promise<CouchDbDocument> => {
const readCachedDocument = async (id: string, cache: RequestCache): Promise<CouchDbDocument> => {
const url = `${couchDb.uri}/${encodeURIComponent(dbName)}/${encodeURIComponent(id)}`;
const authorization = `Basic ${Buffer.from(`${couchDb.username}:${couchDb.password}`).toString("base64")}`;
return await evaluate(`const response=await fetch(${JSON.stringify(url)},{
const fetchFunction = useRequestAPI ? "core.services.API.nativeFetch.bind(core.services.API)" : "fetch";
return await evaluate(`const response=await ${fetchFunction}(${JSON.stringify(url)},{
headers:{authorization:${JSON.stringify(authorization)}},credentials:'include',cache:${JSON.stringify(cache)}});
if(!response.ok)throw new Error('Fixture read failed: '+response.status);
return JSON.stringify(await response.json());`);
@@ -110,7 +113,7 @@ async function main(): Promise<void> {
assert.ok(original?.path, "The encrypted ordinary Metadata fixture is missing.");
const initialParameters = await fetchCouchDbDocument(couchDb, dbName, DOCID_SYNC_PARAMETERS);
// Populate the HTTP cache as an earlier plug-in version would have done.
assert.deepEqual(await browserRead(original._id, "reload"), original);
assert.deepEqual(await readCachedDocument(original._id, "reload"), original);
await evaluate(`await core.services.setting.applyPartial({encryptInternalMetadata:true},true);
await core.services.control.applySettings();
@@ -128,11 +131,13 @@ async function main(): Promise<void> {
assert.notEqual(rebuiltParameters.pbkdf2salt, initialParameters.pbkdf2salt, "The Seed did not change.");
assert.equal(rebuilt._rev, original._rev, "The document revision was not retained.");
assert.notEqual(rebuilt.path, original.path, "The remote ciphertext did not change.");
assert.equal(
(await browserRead(original._id, "default")).path,
original.path,
"The stale browser HTTP cache fixture is no longer present."
);
if (!useRequestAPI) {
assert.equal(
(await readCachedDocument(original._id, "default")).path,
original.path,
"The stale browser HTTP cache fixture is no longer present."
);
}
await evaluate(`await core.services.setting.applyPartial({usePluginSync:true,syncInternalFiles:true},true);
await core.services.control.applySettings();return JSON.stringify(true);`);
@@ -143,9 +148,12 @@ async function main(): Promise<void> {
if(opened)await opened.close();return JSON.stringify({admitted:!!opened});
}catch(error){return JSON.stringify({admitted:false,error:error.name});}`);
assert.ok(admission.admitted, `Replication admission failed: ${JSON.stringify(admission)}`);
assert.equal((await readCachedDocument(original._id, "no-store")).path, rebuilt.path);
await pushLocalChanges(cliBinary, session.cliEnv);
await pushLocalChanges(cliBinary, session.cliEnv);
console.log("Rebuild and subsequent replication succeeded with stale ciphertext still in the HTTP cache.");
console.log(
`Rebuild and subsequent replication succeeded after warming the ${useRequestAPI ? "Internal API" : "browser"} HTTP cache.`
);
} finally {
await session?.app.stop();
await vault.dispose();
@@ -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}`,
File diff suppressed because it is too large. Load diff
+373
View File
@@ -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();
+1
View File
@@ -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",