Merge main into CouchDB cache compatibility changes

This commit is contained in:
vorotamoroz committed 2026-11-05 09:48:00 +00:00
commit c306146dcd
78 files changed
+6177 -929

No files matched your search

+134
View File
@@ -167,6 +167,140 @@ describe("WebAppRuntime lifecycle", () => {
runtimeMocks.updateToDatabase.mockResolvedValue(undefined);
});
function settingsRoot(settings: Partial<ObsidianLiveSyncSettings>): FileSystemDirectoryHandle {
return {
name: "identity-test-vault",
getDirectoryHandle: async () => ({
getFileHandle: async () => ({
getFile: async () => new File([JSON.stringify(settings)], "settings.json"),
}),
}),
} as unknown as FileSystemDirectoryHandle;
}
it.each([undefined, 0] as const)("starts with supported legacy identity %s", async (version) => {
const runtime = new WebAppRuntime(settingsRoot({ pathNormalizationVersion: version }), {
deviceValues: new MemoryValueStore(),
});
await runtime.start();
expect(runtimeMocks.onLoad).toHaveBeenCalledOnce();
expect(runtimeMocks.scanDirectory).toHaveBeenCalledOnce();
await runtime.shutdown();
});
it.each([1, 2] as const)(
"rejects unsupported identity %s before constructing services or starting file work",
async (version) => {
const reportStatus = vi.fn();
const runtime = new WebAppRuntime(settingsRoot({ pathNormalizationVersion: version as 1 }), {
reportStatus,
deviceValues: new MemoryValueStore(),
});
await expect(runtime.start()).rejects.toThrow(/path identity|path normalisation/i);
expect(runtimeMocks.options).toBeUndefined();
expect(runtimeMocks.onLoad).not.toHaveBeenCalled();
expect(runtimeMocks.onReady).not.toHaveBeenCalled();
expect(runtimeMocks.scanDirectory).not.toHaveBeenCalled();
expect(runtimeMocks.collectFilesOnStorage).not.toHaveBeenCalled();
expect(runtimeMocks.updateToDatabase).not.toHaveBeenCalled();
expect(reportStatus).toHaveBeenCalledWith(
"error",
expect.stringMatching(/path identity|path normalisation/i)
);
}
);
it("rejects NFC during settings reload instead of substituting defaults", async () => {
const runtime = new WebAppRuntime(settingsRoot({ pathNormalizationVersion: 1 }), {
deviceValues: new MemoryValueStore(),
});
const options = (
runtime as unknown as { createServiceOptions(): LiveSyncBrowserServiceHubOptions<never> }
).createServiceOptions();
if (!options.settings) throw new Error("The runtime must provide settings callbacks");
await expect(options.settings.load()).rejects.toThrow(/WebApp supports Legacy path identity/);
});
it("rejects saving unsupported NFC settings before opening the settings directory", async () => {
const getDirectoryHandle = vi.fn();
const runtime = new WebAppRuntime(
{ name: "identity-test-vault", getDirectoryHandle } as unknown as FileSystemDirectoryHandle,
{ deviceValues: new MemoryValueStore() }
);
const options = (
runtime as unknown as { createServiceOptions(): LiveSyncBrowserServiceHubOptions<never> }
).createServiceOptions();
if (!options.settings) throw new Error("The runtime must provide settings callbacks");
await expect(
options.settings.save({ pathNormalizationVersion: 1 } as ObsidianLiveSyncSettings)
).rejects.toThrow(/WebApp supports Legacy path identity/);
expect(getDirectoryHandle).not.toHaveBeenCalled();
});
it.each([undefined, 0] as const)("saves supported Legacy settings without adopting NFC (%s)", async (version) => {
const write = vi.fn(async (_data: string) => undefined);
const close = vi.fn(async () => undefined);
const getFileHandle = vi.fn(async () => ({ createWritable: async () => ({ write, close }) }));
const getDirectoryHandle = vi.fn(async () => ({ getFileHandle }));
const runtime = new WebAppRuntime(
{ name: "legacy-settings", getDirectoryHandle } as unknown as FileSystemDirectoryHandle,
{ deviceValues: new MemoryValueStore() }
);
const options = (
runtime as unknown as { createServiceOptions(): LiveSyncBrowserServiceHubOptions<never> }
).createServiceOptions();
if (!options.settings) throw new Error("The runtime must provide settings callbacks");
const settings = {
...unconfiguredSettings,
...(version === undefined ? {} : { pathNormalizationVersion: version }),
} as ObsidianLiveSyncSettings;
await options.settings.save(settings);
expect(getDirectoryHandle).toHaveBeenCalledWith(".livesync", { create: true });
expect(getFileHandle).toHaveBeenCalledWith("settings.json", { create: true });
expect(write).toHaveBeenCalledOnce();
expect(JSON.parse(write.mock.calls[0][0])).toEqual(settings);
expect(JSON.parse(write.mock.calls[0][0]).pathNormalizationVersion).toBe(version);
expect(close).toHaveBeenCalledOnce();
});
it("reports a Legacy settings write failure without logging a successful save", async () => {
const denied = new DOMException("Settings write denied", "NotAllowedError");
const write = vi.fn(async () => {
throw denied;
});
const close = vi.fn(async () => undefined);
const runtime = new WebAppRuntime(
{
name: "denied-settings",
getDirectoryHandle: async () => ({
getFileHandle: async () => ({ createWritable: async () => ({ write, close }) }),
}),
} as unknown as FileSystemDirectoryHandle,
{ deviceValues: new MemoryValueStore() }
);
const options = (
runtime as unknown as { createServiceOptions(): LiveSyncBrowserServiceHubOptions<never> }
).createServiceOptions();
if (!options.settings) throw new Error("The runtime must provide settings callbacks");
const settings = { ...unconfiguredSettings, pathNormalizationVersion: 0 } as ObsidianLiveSyncSettings;
await runtime.start();
runtimeMocks.addLog.mockClear();
await expect(options.settings.save(settings)).resolves.toBeUndefined();
expect(write).toHaveBeenCalledOnce();
expect(close).not.toHaveBeenCalled();
expect(runtimeMocks.addLog).toHaveBeenCalledWith(
expect.stringContaining("Failed to save settings: NotAllowedError: Settings write denied"),
expect.any(Number),
"settings"
);
expect(runtimeMocks.addLog.mock.calls.some(([message]) => String(message).startsWith("Saved to "))).toBe(false);
expect(settings.pathNormalizationVersion).toBe(0);
});
it("owns core start, directory scan, status reporting, and shutdown", async () => {
const reportStatus = vi.fn();
const runtime = new WebAppRuntime(createRootHandle(), { reportStatus, deviceValues: new MemoryValueStore() });
+3 -1
View File
@@ -6,7 +6,7 @@ Application unit tests are stored in `test/apps/webapp/` and `test/apps/webpeer/
Each application has its own production-bundle smoke-test directory outside `src`:
- `test/browser-apps/webapp/` covers Vault selection, OPFS start-up, and isolation between optional P2P settings and the main remote.
- `test/browser-apps/webapp/` covers Vault selection, Legacy OPFS start-up, isolation between optional P2P settings and the main remote, and explicit NFC refusal before database, scan, file-write, or remote activity. The refusal case retains the settings and native filename content.
- `test/browser-apps/webpeer/` covers start-up, settings persistence, and reload.
- `test/browser-apps/pages/` covers the final GitHub Pages layout, application subpaths, and relative assets.
@@ -18,6 +18,8 @@ npm run test:browser-apps
`test/browser-apps/interop.test.ts` is the only cross-application scenario. It configures the real WebApp and WebPeer interfaces, transfers a file from WebApp to WebPeer, then starts the built CLI as the final peer and verifies the file through the production CLI. The CLI is a test fixture in this scenario; the PoC test and its support code are not owned by the CLI package.
The interoperability scenario uses legacy path identity on all three peers. WebApp and WebPeer remain outside the NFC support matrix, so the CLI fixture explicitly selects legacy identity instead of inheriting the NFC default for new CLI settings. The scenario still verifies the transferred file through the production CLI.
Run the isolated relay and TURN scenario in Compose with:
```bash
+2
View File
@@ -243,6 +243,8 @@ async function configureCliSettings(
encrypt: false,
isConfigured: true,
passphrase: "",
// WebApp and WebPeer retain legacy identity; new CLI settings default to NFC.
pathNormalizationVersion: 0,
remoteType: "ONLY_P2P",
usePathObfuscation: false,
});
+116 -3
View File
@@ -112,9 +112,7 @@ Deno.test({
"WebApp must not render a peer-menu action without a menu capability"
);
await runtimePage.locator(".p2p-control input[type='checkbox']").first().check();
await runtimePage
.getByPlaceholder("wss://exp-relay.vrtmrz.net, wss://xxxxx")
.fill("ws://127.0.0.1:4010/");
await runtimePage.getByPlaceholder("wss://exp-relay.vrtmrz.net, wss://xxxxx").fill("ws://127.0.0.1:4010/");
await runtimePage.getByPlaceholder("anything-you-like").fill("browser-e2e-room");
await runtimePage.getByPlaceholder("password").fill("browser-e2e-passphrase");
await runtimePage.getByPlaceholder("iphone-16").fill("browser-e2e-webapp");
@@ -159,3 +157,118 @@ Deno.test({
}
},
});
Deno.test({
name: "WebApp: rejects NFC settings before opening a database, scanning, writing, or synchronising",
sanitizeOps: false,
sanitizeResources: false,
async fn() {
const server = await startStaticServer(webAppDist);
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
const assertNoFailures = observePageFailures(page);
await page.addInitScript(`
Object.defineProperty(globalThis, "showDirectoryPicker", {
configurable: true,
value: async () => {
const originRoot = await navigator.storage.getDirectory();
const root = await originRoot.getDirectoryHandle("livesync-webapp-nfc-refusal", { create: true });
const settingsDirectory = await root.getDirectoryHandle(".livesync", { create: true });
const settingsText = JSON.stringify({
pathNormalizationVersion: 1, isConfigured: true, liveSync: true,
couchDB_URI: "https://couchdb.example.invalid", couchDB_DBNAME: "nfc-refusal-test"
});
const settingsWriter = await (await settingsDirectory.getFileHandle("settings.json", { create: true })).createWritable();
await settingsWriter.write(settingsText);
await settingsWriter.close();
const sentinelWriter = await (await root.getFileHandle("Résumé.md".normalize("NFD"), { create: true })).createWritable();
await sentinelWriter.write("retained native content");
await sentinelWriter.close();
const audit = globalThis.webAppIdentityAudit = {
settingsText, writes: 0, scans: 0, opens: [], remoteRequests: [],
databaseNamesBefore: (await indexedDB.databases()).map(item => item.name).sort()
};
const open = IDBFactory.prototype.open;
IDBFactory.prototype.open = function(...args) {
audit.opens.push(String(args[0]));
return Reflect.apply(open, this, args);
};
const createWritable = FileSystemFileHandle.prototype.createWritable;
FileSystemFileHandle.prototype.createWritable = function(...args) {
audit.writes++;
return Reflect.apply(createWritable, this, args);
};
for (const key of ["entries", "values", Symbol.asyncIterator]) {
const original = FileSystemDirectoryHandle.prototype[key];
if (typeof original !== "function") continue;
FileSystemDirectoryHandle.prototype[key] = function(...args) {
audit.scans++;
return Reflect.apply(original, this, args);
};
}
const fetch = globalThis.fetch;
globalThis.fetch = function(input, ...args) {
const url = String(input?.url ?? input);
if (url.startsWith("https://couchdb.example.invalid")) {
audit.remoteRequests.push(url);
return Promise.resolve(new Response("{}", { status: 503 }));
}
return Reflect.apply(fetch, this, [input, ...args]);
};
return root;
}
});
`);
await page.goto(new URL("webapp.html", server.baseUrl).href);
await page.locator("#status").filter({ hasText: "Select a vault folder" }).waitFor();
await page.getByRole("button", { name: "Choose new vault folder" }).click();
await page.locator("#status.error").filter({ hasText: "WebApp supports Legacy path identity" }).waitFor();
const state = await page.evaluate(async () => {
const globals = globalThis as typeof globalThis & {
webAppIdentityAudit: {
settingsText: string;
writes: number;
scans: number;
opens: string[];
remoteRequests: string[];
databaseNamesBefore: Array<string | undefined>;
};
livesyncApp: { getRuntime(): unknown };
};
const audit = globals.webAppIdentityAudit;
const root = await (
await navigator.storage.getDirectory()
).getDirectoryHandle("livesync-webapp-nfc-refusal");
const settingsDirectory = await root.getDirectoryHandle(".livesync");
return {
runtime: globals.livesyncApp.getRuntime(),
writes: audit.writes,
scans: audit.scans,
appDatabaseOpens: audit.opens.filter((name) => name !== "livesync-webapp-handles"),
remoteRequests: audit.remoteRequests,
databaseNamesBefore: audit.databaseNamesBefore,
databaseNamesAfter: (await indexedDB.databases()).map((item) => item.name).sort(),
settingsUnchanged:
(await (await (await settingsDirectory.getFileHandle("settings.json")).getFile()).text()) ===
audit.settingsText,
sentinel: await (await (await root.getFileHandle("Résumé.md".normalize("NFD"))).getFile()).text(),
};
});
assertEquals(state.runtime, null);
assertEquals(state.writes, 0);
assertEquals(state.scans, 0);
assertEquals(state.appDatabaseOpens, []);
assertEquals(state.remoteRequests, []);
assertEquals(state.databaseNamesAfter, state.databaseNamesBefore);
assertEquals(state.settingsUnchanged, true);
assertEquals(state.sentinel, "retained native content");
assertEquals(await page.locator("#vault-selector").isVisible(), true);
assertEquals(await page.getByRole("button", { name: "Scan local files", exact: true }).count(), 0);
assertNoFailures();
} finally {
await browser.close();
await server.close();
}
},
});
+99 -3
View File
@@ -21,10 +21,10 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { copyFile, mkdir, readFile, stat, utimes, writeFile } from "node:fs/promises";
import { copyFile, mkdir, readFile, readdir, rm, stat, utimes, writeFile } from "node:fs/promises";
import { TempDir } from "./helpers/temp.ts";
import { runCliOrFail, runCliWithInputOrFail } from "./helpers/cli.ts";
import { runCli, runCliOrFail, runCliWithInputOrFail } from "./helpers/cli.ts";
import { initSettingsFile, markSettingsConfigured } from "./helpers/settings.ts";
test("mirror: storage <-> DB synchronisation", async (t) => {
@@ -136,7 +136,10 @@ test("mirror: storage <-> DB synchronisation", async (t) => {
// Reflect the shared base into the actual Vault before editing it.
await runMirror();
assert.deepStrictEqual(await readFile(workDir.join("vault", "test", "sync-storage-newer.md"), "utf8"), "old content\n");
assert.deepStrictEqual(
await readFile(workDir.join("vault", "test", "sync-storage-newer.md"), "utf8"),
"old content\n"
);
// Write new content to storage with a timestamp 1 hour in the future
const storageFile = workDir.join("vault", "test", "sync-storage-newer.md");
@@ -249,3 +252,96 @@ async function exists(path: string): Promise<boolean> {
return false;
}
}
test("mirror: NFC identity retains physical Unicode spelling", async () => {
await using workDir = await TempDir.create("livesync-cli-nfc-paths");
const vault = workDir.join("vault");
const database = workDir.join("db");
const settings = workDir.join("settings.json");
const parent = "Café".normalize("NFD");
const leaf = "Résumé.md".normalize("NFD");
const logical = "Café/Résumé.md";
const physical = workDir.join("vault", parent, leaf);
await mkdir(workDir.join("vault", parent), { recursive: true });
await mkdir(database);
await initSettingsFile(settings);
await markSettingsConfigured(settings);
const data = JSON.parse(await readFile(settings, "utf8"));
data.pathNormalizationVersion = 1;
await writeFile(settings, JSON.stringify(data));
await writeFile(physical, "original Unicode content\n");
await utimes(physical, new Date(), new Date(Date.now() - 3600_000));
const run = (...args: string[]) => runCliOrFail(database, "--settings", settings, ...args);
await run("mirror", vault);
const received = workDir.join("received.md");
await run("pull", logical, received);
assert.equal(await readFile(received, "utf8"), "original Unicode content\n");
const replacement = workDir.join("replacement.md");
await writeFile(replacement, "updated Unicode content\n");
await run("push", replacement, logical);
await run("mirror", vault);
assert.equal(await readFile(physical, "utf8"), "updated Unicode content\n");
assert.deepEqual(await readdir(vault), [parent]);
assert.deepEqual(await readdir(workDir.join("vault", parent)), [leaf]);
});
test("mirror: rejects an ambiguous NFC inventory without changing prior data and recovers", async (t) => {
await using workDir = await TempDir.create("livesync-cli-nfc-scan-failure");
const vault = workDir.join("vault");
const database = workDir.join("db");
const settings = workDir.join("settings.json");
const existingContent = "retained before failed scan\n";
const existingFile = workDir.join("vault", "known.md");
const nfcParent = "Café";
const nfdParent = nfcParent.normalize("NFD");
const nfcFile = workDir.join("vault", nfcParent, "from-nfc.md");
const nfdFile = workDir.join("vault", nfdParent, "from-nfd.md");
await mkdir(vault, { recursive: true });
await mkdir(database);
await initSettingsFile(settings);
await markSettingsConfigured(settings);
const data = JSON.parse(await readFile(settings, "utf8"));
data.pathNormalizationVersion = 1;
await writeFile(settings, JSON.stringify(data));
await writeFile(existingFile, existingContent);
const runMirror = () => runCliOrFail(database, "--settings", settings, "mirror", vault);
const runDatabase = (...args: string[]) => runCliOrFail(database, "--settings", settings, ...args);
await runMirror();
const before = JSON.parse(await runDatabase("info", "known.md"));
assert.equal(await runDatabase("cat-rev", "known.md", before.revision), existingContent);
await mkdir(workDir.join("vault", nfcParent), { recursive: true });
await mkdir(workDir.join("vault", nfdParent), { recursive: true });
await writeFile(nfcFile, "NFC spelling content\n");
await writeFile(nfdFile, "NFD spelling content\n");
const inventoryNames = await readdir(vault);
if (!inventoryNames.includes(nfcParent) || !inventoryNames.includes(nfdParent)) {
t.skip("This filesystem cannot represent both NFC and NFD directory entries separately");
return;
}
const failedMirror = await runCli(database, "--settings", settings, "mirror", vault);
assert.notEqual(failedMirror.code, 0, "An ambiguous inventory must fail the mirror command");
assert(
!failedMirror.combined.includes("[Done] Command 'mirror' completed"),
"A failed inventory must not claim successful mirror completion"
);
assert.equal(await readFile(existingFile, "utf8"), existingContent);
assert.equal(await readFile(nfcFile, "utf8"), "NFC spelling content\n");
assert.equal(await readFile(nfdFile, "utf8"), "NFD spelling content\n");
assert.deepEqual(JSON.parse(await runDatabase("info", "known.md")), before);
assert.equal(await runDatabase("cat-rev", "known.md", before.revision), existingContent);
await rm(workDir.join("vault", nfdParent), { recursive: true });
const recoveredMirror = await runMirror();
assert(recoveredMirror.includes("[Done] Command 'mirror' completed"));
const recovered = JSON.parse(await runDatabase("info", `${nfcParent}/from-nfc.md`));
assert.equal(await runDatabase("cat-rev", `${nfcParent}/from-nfc.md`, recovered.revision), "NFC spelling content\n");
assert.equal(await readFile(existingFile, "utf8"), existingContent);
assert.deepEqual(JSON.parse(await runDatabase("info", "known.md")), before);
});
+256 -137
View File
@@ -19,152 +19,271 @@ import {
import { runCli, runCliOrFail, runCliWithInputOrFail, sanitiseCatStdout } from "./helpers/cli.ts";
import { getOptimalLoopbackIp } from "./helpers/net.ts";
test("p2p-sync: transfers with the same ID key and rejects a different document ID key", async () => {
const loopbackIp = await getOptimalLoopbackIp();
const loopbackHost = loopbackIp === "::1" ? "[::1]" : loopbackIp;
for (const [pathNormalizationVersion, encrypted] of [
[0, true],
[1, true],
[1, false],
] as const) {
test(`p2p-sync: identity mode ${pathNormalizationVersion}, encryption ${encrypted} transfers matching IDs and rejects incompatible peers`, async () => {
const loopbackIp = await getOptimalLoopbackIp();
const loopbackHost = loopbackIp === "::1" ? "[::1]" : loopbackIp;
const relay = process.env["RELAY"] ?? `ws://${loopbackHost}:4000/`;
const roomId = process.env["ROOM_ID"] ?? `room-${Date.now()}`;
const passphrase = process.env["PASSPHRASE"] ?? "test";
const peersTimeout = Number(process.env["PEERS_TIMEOUT"] ?? "12");
const syncTimeout = Number(process.env["SYNC_TIMEOUT"] ?? "15");
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
const hostPeerName = process.env["HOST_PEER_NAME"] ?? `p2p-host-${nonce}`;
const clientPeerName = process.env["CLIENT_PEER_NAME"] ?? `p2p-client-${nonce}`;
const useCoturn = process.env["LIVESYNC_USE_COTURN"] !== "0";
const turnServers = process.env["TURN_SERVERS"] ?? (useCoturn ? `turn:${loopbackHost}:3478` : "none");
const relay = process.env["RELAY"] ?? `ws://${loopbackHost}:4000/`;
const roomId = process.env["ROOM_ID"] ?? `room-${Date.now()}`;
const passphrase = process.env["PASSPHRASE"] ?? "test";
const peersTimeout = Number(process.env["PEERS_TIMEOUT"] ?? "12");
const syncTimeout = Number(process.env["SYNC_TIMEOUT"] ?? "15");
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
const hostPeerName = process.env["HOST_PEER_NAME"] ?? `p2p-host-${nonce}`;
const clientPeerName = process.env["CLIENT_PEER_NAME"] ?? `p2p-client-${nonce}`;
const useCoturn = process.env["LIVESYNC_USE_COTURN"] !== "0";
const turnServers = process.env["TURN_SERVERS"] ?? (useCoturn ? `turn:${loopbackHost}:3478` : "none");
await using workDir = await TempDir.create("livesync-cli-p2p-sync");
const hostVault = workDir.join("vault-host");
const hostSettings = workDir.join("settings-host.json");
const clientVault = workDir.join("vault-sync");
const clientSettings = workDir.join("settings-sync.json");
const rejectedVault = workDir.join("vault-rejected");
const rejectedSettings = workDir.join("settings-rejected.json");
await mkdir(hostVault, { recursive: true });
await mkdir(clientVault, { recursive: true });
await mkdir(rejectedVault, { recursive: true });
await using workDir = await TempDir.create("livesync-cli-p2p-sync");
const hostVault = workDir.join("vault-host");
const hostSettings = workDir.join("settings-host.json");
const clientVault = workDir.join("vault-sync");
const clientSettings = workDir.join("settings-sync.json");
const rejectedVault = workDir.join("vault-rejected");
const rejectedSettings = workDir.join("settings-rejected.json");
await mkdir(hostVault, { recursive: true });
await mkdir(clientVault, { recursive: true });
await mkdir(rejectedVault, { recursive: true });
const relayStarted = await maybeStartLocalRelay(relay);
const coturnStarted = await maybeStartCoturn(turnServers);
try {
await initSettingsFile(hostSettings);
await initSettingsFile(clientSettings);
await initSettingsFile(rejectedSettings);
await applyP2pSettings(
hostSettings,
roomId,
passphrase,
"self-hosted-livesync-cli-tests",
relay,
"~.*",
turnServers
);
await applyP2pSettings(
clientSettings,
roomId,
passphrase,
"self-hosted-livesync-cli-tests",
relay,
"~.*",
turnServers
);
await applyP2pSettings(
rejectedSettings,
roomId,
passphrase,
"self-hosted-livesync-cli-tests",
relay,
"~.*",
turnServers
);
await applyP2pTestTweaks(hostSettings, hostPeerName, passphrase);
await applyP2pTestTweaks(clientSettings, clientPeerName, passphrase);
await applyP2pTestTweaks(rejectedSettings, "p2p-rejected-" + nonce, passphrase);
for (const [vault, path, key, label] of [
[hostVault, hostSettings, "ab".repeat(32), "host"],
[clientVault, clientSettings, "ab".repeat(32), "client"],
[rejectedVault, rejectedSettings, "cd".repeat(32), "rejected"],
]) {
const settings = JSON.parse(await readFile(path, "utf8"));
settings.idDerivationVersion = 1;
settings.idDerivationKey = key;
const sourcePath = workDir.join("setup-source-" + label + ".json");
await writeFile(sourcePath, JSON.stringify(settings));
const setupPassphrase = "independent-id-setup-passphrase";
const setupUri = await generateSetupUriFromSettings(sourcePath, setupPassphrase, true);
await runCliWithInputOrFail(setupPassphrase + "\n", vault, "--settings", path, "setup", setupUri);
const persisted = JSON.parse(await readFile(path, "utf8"));
assert(persisted.idDerivationVersion === 1, "The Setup URI lost the ID derivation version.");
assert(persisted.idDerivationKey === "", "The CLI stored the ID key in plain text.");
assert(
typeof persisted.encryptedIdDerivationKey === "string" && persisted.encryptedIdDerivationKey.length > 0,
"The CLI did not encrypt the saved ID key."
);
assert(persisted.P2P_Enabled === true, "The Setup URI disabled P2P.");
assert(persisted.P2P_roomID === roomId, "The Setup URI changed the P2P room.");
assert(persisted.P2P_relays === relay, "The Setup URI changed the P2P relay.");
assert(persisted.remoteType === "ONLY_P2P", "The Setup URI changed the remote type.");
}
const notePath = "p2p/independent-id-note.md";
await runCliWithInputOrFail(
"A note transferred with the saved ID key.\n",
clientVault,
"--settings",
clientSettings,
"put",
notePath
);
const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host");
const relayStarted = await maybeStartLocalRelay(relay);
const coturnStarted = await maybeStartCoturn(turnServers);
try {
await host.waitUntilContains("P2P host is running", 20000);
const peer = await discoverPeer(
clientVault,
await initSettingsFile(hostSettings);
await initSettingsFile(clientSettings);
await initSettingsFile(rejectedSettings);
await applyP2pSettings(
hostSettings,
roomId,
passphrase,
"self-hosted-livesync-cli-tests",
relay,
"~.*",
turnServers
);
await applyP2pSettings(
clientSettings,
peersTimeout,
process.env["TARGET_PEER"] ?? hostPeerName
roomId,
passphrase,
"self-hosted-livesync-cli-tests",
relay,
"~.*",
turnServers
);
const syncResult = await runCli(
clientVault,
"--settings",
clientSettings,
"p2p-sync",
peer.id,
String(syncTimeout)
);
assert(
syncResult.code === 0,
`p2p-sync failed\nstdout: ${syncResult.stdout}\nstderr: ${syncResult.stderr}`
);
const rejectedPeer = await discoverPeer(rejectedVault, rejectedSettings, peersTimeout, hostPeerName);
const rejectedSync = await runCli(
rejectedVault,
"--settings",
await applyP2pSettings(
rejectedSettings,
"p2p-sync",
rejectedPeer.id,
String(syncTimeout)
roomId,
passphrase,
"self-hosted-livesync-cli-tests",
relay,
"~.*",
turnServers
);
await applyP2pTestTweaks(hostSettings, hostPeerName, passphrase);
await applyP2pTestTweaks(clientSettings, clientPeerName, passphrase);
await applyP2pTestTweaks(rejectedSettings, "p2p-rejected-" + nonce, passphrase);
for (const [vault, path, key, label] of [
[hostVault, hostSettings, "ab".repeat(32), "host"],
[clientVault, clientSettings, "ab".repeat(32), "client"],
[
rejectedVault,
rejectedSettings,
(pathNormalizationVersion === 1 ? "ab" : "cd").repeat(32),
"rejected",
],
]) {
const settings = JSON.parse(await readFile(path, "utf8"));
settings.pathNormalizationVersion =
pathNormalizationVersion === 1 && label === "rejected" ? 0 : pathNormalizationVersion;
settings.encrypt = encrypted;
settings.usePathObfuscation = encrypted;
settings.idDerivationVersion = 1;
settings.idDerivationKey = key;
const sourcePath = workDir.join("setup-source-" + label + ".json");
await writeFile(sourcePath, JSON.stringify(settings));
const setupPassphrase = "independent-id-setup-passphrase";
const setupUri = await generateSetupUriFromSettings(sourcePath, setupPassphrase, true);
await runCliWithInputOrFail(setupPassphrase + "\n", vault, "--settings", path, "setup", setupUri);
const persisted = JSON.parse(await readFile(path, "utf8"));
assert(persisted.idDerivationVersion === 1, "The Setup URI lost the ID derivation version.");
assert(
persisted.pathNormalizationVersion === settings.pathNormalizationVersion,
"The Setup URI lost the path identity version."
);
assert(persisted.idDerivationKey === "", "The CLI stored the ID key in plain text.");
assert(
typeof persisted.encryptedIdDerivationKey === "string" &&
persisted.encryptedIdDerivationKey.length > 0,
"The CLI did not encrypt the saved ID key."
);
assert(persisted.P2P_Enabled === true, "The Setup URI disabled P2P.");
assert(persisted.P2P_roomID === roomId, "The Setup URI changed the P2P room.");
assert(persisted.P2P_relays === relay, "The Setup URI changed the P2P relay.");
assert(persisted.remoteType === "ONLY_P2P", "The Setup URI changed the remote type.");
}
const notePath =
pathNormalizationVersion === 1 ? "Cafe\u0301/re\u0301sume\u0301.md" : "p2p/independent-id-note.md";
const receivedPath = notePath.normalize("NFC");
await runCliWithInputOrFail(
"A note transferred with the saved ID key.\n",
clientVault,
"--settings",
clientSettings,
"put",
notePath
);
const originalInfo = JSON.parse(
await runCliOrFail(clientVault, "--settings", clientSettings, "info", notePath)
) as { id: string; revision: string };
if (pathNormalizationVersion === 1) {
assert.equal(
sanitiseCatStdout(
await runCliOrFail(clientVault, "--settings", clientSettings, "cat", receivedPath)
),
"A note transferred with the saved ID key.\n",
"The NFC spelling did not address the NFD Metadata."
);
}
const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host");
try {
await host.waitUntilContains("P2P host is running", 20000);
const peer = await discoverPeer(
clientVault,
clientSettings,
peersTimeout,
process.env["TARGET_PEER"] ?? hostPeerName
);
const syncResult = await runCli(
clientVault,
"--settings",
clientSettings,
"p2p-sync",
peer.id,
String(syncTimeout)
);
assert(
syncResult.code === 0,
`p2p-sync failed\nstdout: ${syncResult.stdout}\nstderr: ${syncResult.stderr}`
);
const rejectedPeer = await discoverPeer(rejectedVault, rejectedSettings, peersTimeout, hostPeerName);
const rejectedSync = await runCli(
rejectedVault,
"--settings",
rejectedSettings,
"p2p-sync",
rejectedPeer.id,
String(syncTimeout)
);
assert(
rejectedSync.code !== 0,
`P2P accepted incompatible peer settings.\nstdout: ${rejectedSync.stdout}\nstderr: ${rejectedSync.stderr}`
);
assert(
rejectedSync.combined.includes("Tweak values are not matched"),
`P2P failed before checking peer settings.\nstdout: ${rejectedSync.stdout}\nstderr: ${rejectedSync.stderr}`
);
} finally {
await host.stop();
}
const received = sanitiseCatStdout(
await runCliOrFail(hostVault, "--settings", hostSettings, "cat", receivedPath)
).trimEnd();
assert(
rejectedSync.code !== 0,
`P2P accepted a different key for obfuscated document IDs.\nstdout: ${rejectedSync.stdout}\nstderr: ${rejectedSync.stderr}`
);
assert(
rejectedSync.combined.includes("Tweak values are not matched"),
`P2P failed before checking peer settings.\nstdout: ${rejectedSync.stdout}\nstderr: ${rejectedSync.stderr}`
received === "A note transferred with the saved ID key.",
"The host did not receive the keyed note."
);
const rejectedRead = await runCli(rejectedVault, "--settings", rejectedSettings, "cat", notePath);
assert(rejectedRead.code !== 0, "The rejected device received the keyed note.");
if (pathNormalizationVersion === 1) {
const canonicalRead = await runCli(rejectedVault, "--settings", rejectedSettings, "cat", receivedPath);
assert(
canonicalRead.code !== 0,
"The rejected device received canonical Metadata despite its different identity mode."
);
}
if (pathNormalizationVersion === 1) {
const exchange = async (
senderVault: string,
senderSettings: string,
receiverVault: string,
receiverSettings: string,
receiverName: string
) => {
const restartedHost = startCliInBackground(
receiverVault,
"--settings",
receiverSettings,
"p2p-host"
);
try {
await restartedHost.waitUntilContains("P2P host is running", 20000);
const peer = await discoverPeer(senderVault, senderSettings, peersTimeout, receiverName);
await runCliOrFail(
senderVault,
"--settings",
senderSettings,
"p2p-sync",
peer.id,
String(syncTimeout)
);
} finally {
await restartedHost.stop();
}
};
const editedBody = "Edited through the NFC spelling after process restart.\n";
await runCliWithInputOrFail(editedBody, hostVault, "--settings", hostSettings, "put", receivedPath);
await exchange(hostVault, hostSettings, clientVault, clientSettings, clientPeerName);
assert.equal(
sanitiseCatStdout(await runCliOrFail(clientVault, "--settings", clientSettings, "cat", notePath)),
editedBody
);
const storedPath = (await runCliOrFail(clientVault, "--settings", clientSettings, "ls"))
.trim()
.split("\t")[0];
assert.equal(storedPath.normalize("NFC"), receivedPath);
const editedInfo = JSON.parse(
await runCliOrFail(clientVault, "--settings", clientSettings, "info", storedPath)
) as { id: string };
assert.equal(editedInfo.id, originalInfo.id, "The reverse edit changed the canonical Metadata ID.");
assert.equal(
sanitiseCatStdout(
await runCliOrFail(
clientVault,
"--settings",
clientSettings,
"cat-rev",
notePath,
originalInfo.revision
)
),
"A note transferred with the saved ID key.\n",
"P2P discarded the original revision body."
);
const renamedPath = "Cafe\u0301/renamed.md";
await runCliWithInputOrFail(editedBody, clientVault, "--settings", clientSettings, "put", renamedPath);
await runCliOrFail(clientVault, "--settings", clientSettings, "rm", receivedPath);
await exchange(clientVault, clientSettings, hostVault, hostSettings, hostPeerName);
assert.notEqual((await runCli(hostVault, "--settings", hostSettings, "cat", notePath)).code, 0);
assert.equal(
sanitiseCatStdout(
await runCliOrFail(hostVault, "--settings", hostSettings, "cat", renamedPath.normalize("NFC"))
),
editedBody
);
await runCliOrFail(clientVault, "--settings", clientSettings, "rm", renamedPath.normalize("NFC"));
await exchange(clientVault, clientSettings, hostVault, hostSettings, hostPeerName);
assert.notEqual((await runCli(hostVault, "--settings", hostSettings, "cat", renamedPath)).code, 0);
}
} finally {
await host.stop();
await stopLocalRelayIfStarted(relayStarted);
await stopCoturnIfStarted(coturnStarted);
}
const received = sanitiseCatStdout(
await runCliOrFail(hostVault, "--settings", hostSettings, "cat", notePath)
).trimEnd();
assert(received === "A note transferred with the saved ID key.", "The host did not receive the keyed note.");
const rejectedRead = await runCli(rejectedVault, "--settings", rejectedSettings, "cat", notePath);
assert(rejectedRead.code !== 0, "The rejected device received the keyed note.");
} finally {
await stopLocalRelayIfStarted(relayStarted);
await stopCoturnIfStarted(coturnStarted);
}
});
});
}
+13
View File
@@ -0,0 +1,13 @@
# syntax=docker/dockerfile:1
FROM node:26-slim AS node26
FROM mcr.microsoft.com/playwright:v1.62.0-noble
COPY --from=node26 /usr/local/bin/node /usr/local/bin/node
ENV NODE_OPTIONS=--max-old-space-size=3072
WORKDIR /workspace
ENTRYPOINT ["node", "--import", "tsx", "test/e2e-obsidian/scripts/p2p-nfc-sync.ts"]
+16 -1
View File
@@ -230,6 +230,8 @@ Set `E2E_LIVESYNC_TARGET_ARTIFACT_ROOT` to the directory containing the exact re
Set `E2E_OBSIDIAN_INDEPENDENT_IDS=true` to run the same upload with E2EE, Path Obfuscation, and a separately derived ID key. The scenario verifies the local document and Chunk ID shapes before the Journal transfer.
Set `E2E_OBSIDIAN_CUSTOM_HTTP_HANDLER=true` when the local Object Storage fixture does not allow browser requests from Obsidian's renderer.
`test:e2e:obsidian:journal-nfc-resend` uses the same Object Storage variables with NFC path identity. Run `npm run test:e2e:obsidian:focused -- journal-nfc-resend --case all` for the plain and E2EE/Path Obfuscation cases with an independent ID key, or select `plain` or `encrypted`. It creates two conflict leaves with real Chunks with an exact shared ancestor, completes the initial upload, and deletes only its unique remote prefix while retaining the source database and populated transfer checkpoint. Rebuilding the remote must resend enough data for a fresh Obsidian Standard Fetch to recover both exact leaf bodies, Chunk references, revision IDs, and each leaf's `_revisions`. The source must retain its readable ancestor body. The receiver records whether that older body is available; ancestry IDs alone do not make a historical body readable. A full receiver restart must retain the same leaves, history, ancestor availability, and one NFC-equivalent physical control file. The restart consumes the saved settings and device state.
`test:e2e:obsidian:object-storage-setup-uri-workflow` uses the public Commonlib-backed tool to generate the initial Setup URI for a unique Object Storage prefix, completes visible initialisation on the first device, and then asks that working real Obsidian device to create a new Setup URI through the registered command. A second real Obsidian device imports only the device-generated URI. The workflow verifies the A-to-B note through explicit replication, then verifies that the B-to-A note arrives through `syncOnStart` after restarting the first device, without requesting manual replication. It captures the documented onboarding choices, and removes the Object Storage prefix only after both sessions have stopped. The test requires the current version marker and absence of a compatibility pause after Fetch and after restarting the same Vault, without accepting a review automatically.
`test:e2e:obsidian:object-storage-qr-workflow` runs the same Object Storage round trip with QR settings on the second device. It takes the first device's settings, assigns a distinct database suffix in the QR fixture, encodes them with Commonlib's QR encoder, passes the payload to the real QR decoding entry point, and selects **Join this device** in the visible dialogue. Unlike the Setup URI, the QR payload includes a database suffix; explicitly choosing one makes the namespace change independent of Obsidian's initial defaults. Before Fetch begins, the scenario verifies that the imported namespace has its current compatibility marker without a pause. Fetch then selects the receiving device's own suffix when resetting the local database. The test verifies the marker and absence of a pause again after Fetch and after a natural restart. It covers the QR settings and setup flow, without requiring a camera or exercising operating-system URI dispatch.
@@ -244,6 +246,12 @@ The P2P Setup URI workflow also schedules another Fetch and injects an exception
`test:e2e:obsidian:p2p-connection-check` owns the browser-to-Obsidian preflight path. It serves the WebPeer production build from loopback, asks the page to generate a disposable Setup URI using the local relay, starts its browser reference peer, and applies that exact URI through visible onboarding in an isolated empty real Obsidian Vault. After the first successful WebRTC diagnostic appears, it selects the action for another device in the same room, proves that the Setup URI was not regenerated, applies it to a second isolated empty real Obsidian Vault, and requires both the successful total and the baseline number of simultaneous active connections to advance. It captures the result card without Setup URI credentials and does not claim to verify note synchronisation. Run `test:e2e:obsidian:p2p-connection-check:services` to build both production artefacts and let the scenario start and stop the Compose relay.
`test:e2e:obsidian:p2p-nfc` runs the plain case and the E2EE case using Path Obfuscation and an independent ID key, serially, in an isolated Compose project with a local signalling relay and two real Obsidian processes at a time. Each case seeds an NFD-named source file, fetches it by its NFC spelling, and compares the real local Metadata ID, exact body, and referenced Chunks on both peers. It edits the note from the receiving peer, verifies that the Metadata ID remains stable and that the original revision body remains readable, then fully stops and relaunches the same Vault to verify the ID, updated body, original revision, and a further transfer after restart. A fresh path version 0 peer must show the visible P2P compatibility refusal; the test verifies that it receives no source Metadata and retains its local sentinel. Run `npm run test:e2e:obsidian:p2p-nfc -- --case plain` or `-- --case encrypted` to select one case for debugging. The host wrapper builds the plug-in, uses the existing extracted Obsidian directory and its `obsidian-cli`, mounts the repository read-only, and tears down its unique Compose project after the run. It does not download Obsidian or npm dependencies.
Each P2P case also transfers another Unicode note after restart, renames it on the receiving peer, compares the new Metadata IDs and exact content, and verifies deletion on both peers.
Add `-- --migration` to start with legacy databases and exercise source Rebuild followed by receiving-device Standard Fetch. Because Obsidian's file cache normalises names, the scenario seeds valid legacy NFD Metadata IDs using the real stored Chunks before reconstruction. It verifies replacement of those IDs, physical NFD-name retention, ordinary legacy-peer refusal, and successful migration after a source restart. It also attempts Fetch with mismatched settings, verifies the local database is reset before peer refusal while the physical note remains intact, then retries with NFC and verifies convergence. The migration phase calls the running plug-in's production Rebuild and Fetch methods and uses real peer selection; reservation through the settings dialogue has separate coverage.
`test:e2e:obsidian:startup-scan` starts from a CouchDB fixture using current settings with its device-local compatibility marker already acknowledged, stops Obsidian, writes a note directly into the Vault, restarts the same isolated Vault and profile without rewriting its plug-in data, and verifies from CouchDB that the start-up scan picked up the offline file. Onboarding remains covered by `onboarding-invitation`; this scenario owns the ordinary configured restart and start-up scan.
`test:e2e:obsidian:partial-startup-file-failure` is a focused Linux release-acceptance scenario for an ordinary configured restart. It stores one valid database-only note and one database-only note whose path component is 258 UTF-8 bytes, then restarts the same isolated Vault and profile. On a Linux test Vault which enforces the conventional 255-byte component limit, the scenario requires the valid file to be reflected, the application to become ready, the partial-failure Notice to appear, and the failed path to remain readable and eligible for a later scan with its exact path in the verbose log. It remains outside `local-suite` because the failure fixture is deliberately platform-specific.
@@ -264,7 +272,9 @@ The workflow creates a random dedicated database, records only SHA-256 Seed fing
This proves in real Obsidian the plug-in behaviour shared by supported platforms, including the encrypted bidirectional round-trip and protection against a stale client restoring the old remote Seed. It does not verify iPadOS-specific background or reconnect lifecycle behaviour, and it does not count as Android device evidence. The workflow remains outside `test:e2e:obsidian:local-suite` because it is a focused release-acceptance check.
`test:e2e:obsidian:conflict-dialog-policy` creates three real local revision leaves without a remote service and opens the pairwise merge dialogue in Obsidian. It verifies the three-version count, requires the four decision buttons to be stacked vertically, concatenates the displayed pair as a child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the same isolated Vault and profile, and confirms that only the two current versions are reconstructed. It also verifies that an ordinary repeated conflict check does not reopen a postponed dialogue, that **Resolve if conflicted.** explicitly reopens it, and that the active editor retains the appropriate unresolved-conflict warning. The scenario then invokes the same Commonlib consumer boundary used for an incoming replicated document and checks that a postponed warning disappears, an open stale dialogue closes, and the conflict-processing queue completes even when the dialogue closes immediately. This isolates the Obsidian UI contract from transport and second-device setup. The fixture owns one temporary Vault and profile, and the session runner stops Obsidian before removing them.
`test:e2e:obsidian:conflict-dialog-policy` creates three real local revision leaves without a remote service and opens the pairwise merge dialogue in Obsidian. It verifies the three-version count, requires the four decision buttons to be stacked vertically, concatenates the displayed pair as a child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the same isolated Vault and profile, and confirms that only the two current versions are reconstructed. It also verifies that an ordinary repeated conflict check does not reopen a postponed dialogue, that **Resolve if conflicted.** explicitly reopens it, and that the active editor retains the appropriate unresolved-conflict warning. The scenario then invokes the same Commonlib consumer boundary used for an incoming replicated document and checks that a postponed warning disappears, an open stale dialogue closes, and the conflict-processing queue completes even when the dialogue closes immediately. This isolates the Obsidian UI contract from transport and second-device setup. The fixture owns one temporary Vault and profile, and the session runner stops Obsidian before removing them. Its unload check requires the queue to report cancellation, closes active and waiting dialogues, and compares Vault contents, conflict leaves, and revision ancestry after reload.
`test:e2e:obsidian:log-lifecycle` delays the local database close and tests both successful and failed shutdown. It verifies that logs remain available during shutdown, that later logs go to the console after display subscriptions are released, and that logs from the previous instance do not reach the reloaded instance. It uses an isolated Vault and profile without a remote service.
`test:e2e:obsidian:revision-repair` creates an ordinary healthy logical deletion and two current leaf revisions in a temporary real Obsidian Vault, then removes a chunk used only by the non-winning revision. It proves that automatic conflict checking does not discard the unreadable branch, and that a healthy logical deletion with no Vault file is neither reported nor retained as Vault provenance. **Inspect conflicts and file/database differences** must show the winner and conflict separately, identify the exact unreadable revision and missing chunk, show the compact `Δsize` and `Δtime` diagnostics, and expose a wrench menu with the appropriate actions for each branch. The scenario opens the existing comparison dialogue in read-only mode, applies the readable winner to the Vault, shows the compact matching-winner and remaining-conflict status, records the exact winner as Vault provenance without creating a child, and confirms that retrying the unreadable branch leaves the revision tree unchanged. It then verifies both the cancellation path and the explicit confirmation path for discarding only that selected current branch, requires the winner and its Vault provenance to remain unchanged, and captures the repair card, a 360-pixel-wide reflow check, the matching-winner status, both revision menus, and the read-only comparison. The narrow capture checks responsive layout, not a mobile operating-system lifecycle. The scenario uses no remote service; a retry is therefore expected to remain unreadable unless the chunk is already available locally.
@@ -404,6 +414,11 @@ When these checks pass for the release artefacts, BRAT acceptance can be limited
- `E2E_OBSIDIAN_REVIEW_HARNESS_TIMEOUT_MS`: timeout for Review Harness view and action boundaries; default is 15 seconds.
- `E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS`: timeout for the P2P status pane and its principal connection control; default is 10 seconds.
- `E2E_OBSIDIAN_P2P_WORKFLOW_TIMEOUT_MS`: timeout for each visible P2P Setup URI, peer-discovery, approval, and replication control; default is 60 seconds.
- `E2E_OBSIDIAN_P2P_NFC_TIMEOUT_MS`: timeout for P2P NFC transfers and the incompatible-peer refusal; default is 120 seconds.
- `E2E_OBSIDIAN_P2P_NFC_UI_TIMEOUT_MS`: timeout for P2P NFC status and setup controls; default is 60 seconds.
- `E2E_P2P_NFC_KEEP_ARTIFACTS=1`: retain successful Compose screenshots and diagnostics; failures are always retained.
- `E2E_P2P_NFC_CASE`: case selected by the Compose wrapper (`all`, `plain`, or `encrypted`); default is `all`. The `--case` option overrides this value.
- `E2E_P2P_NFC_OBSIDIAN_DIR`: extracted Linux Obsidian directory mounted into the Compose runner. The wrapper normally infers it from the discovered `OBSIDIAN_BINARY` and requires the corresponding `obsidian-cli` in the same directory.
- `E2E_P2P_CHECK_CONNECTION_TIMEOUT_MS`: timeout for the browser-to-Obsidian successful WebRTC diagnostic; default is 60 seconds.
- `E2E_P2P_CHECK_SCREENSHOT`: explicit path for the successful browser result screenshot; default is `p2p-connection-check-browser-success.png` under `E2E_OBSIDIAN_DIAGNOSTICS_DIR`.
- `E2E_P2P_RELAY_URL`: signalling relay used by the real-Obsidian P2P workflow; default is the local relay at `ws://127.0.0.1:4010/`.
+36
View File
@@ -0,0 +1,36 @@
name: livesync-p2p-nfc
services:
p2p-relay:
image: ghcr.io/hoytech/strfry:latest
entrypoint: ["/app/strfry"]
command: ["--config", "/etc/strfry/strfry.conf", "relay"]
volumes:
- ../../test/fixtures/p2p-relay/strfry.conf:/etc/strfry/strfry.conf:ro
tmpfs:
- /app/strfry-db:rw,size=256m,mode=1777
p2p-nfc:
build:
context: ../..
dockerfile: test/e2e-obsidian/Dockerfile.p2p
depends_on:
p2p-relay:
condition: service_started
environment:
OBSIDIAN_BINARY: ${E2E_P2P_NFC_OBSIDIAN_BINARY:?Run this scenario through run-compose-p2p-nfc.ts}
OBSIDIAN_CLI: ${E2E_P2P_NFC_OBSIDIAN_CLI:?Run this scenario through run-compose-p2p-nfc.ts}
E2E_P2P_RELAY_URL: ws://p2p-relay:7777/
E2E_P2P_NFC_CASE: ${E2E_P2P_NFC_CASE:-all}
E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: "9222"
E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT: "9223"
E2E_OBSIDIAN_DIAGNOSTICS_DIR: /artifacts
volumes:
- ../..:/workspace:ro
- ${E2E_P2P_NFC_OBSIDIAN_DIR:?Run this scenario through run-compose-p2p-nfc.ts}:/opt/obsidian:ro
- ${E2E_P2P_NFC_ARTIFACT_DIR:?Run this scenario through run-compose-p2p-nfc.ts}:/artifacts
working_dir: /workspace
mem_limit: 6g
cpus: 2
init: true
ipc: host
@@ -0,0 +1,243 @@
import { deepStrictEqual } from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { promisify } from "node:util";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { assertEqual } from "./liveSyncWorkflow.ts";
import { deleteObjectStoragePrefix, readObjectStorageJson, type ObjectStorageConfig } from "./objectStorage.ts";
const notePath = "Cafe\u0301/re\u0301sume\u0301.md";
const noteContent = "# CLI journal migration\n\nThe NFD physical path must keep its identity during migration.\n";
type CliFileInfo = {
id: string;
revision: string;
path: string;
};
type JournalMilestone = {
node_chunk_info?: Record<string, { current?: number }>;
};
type CliResult = {
stdout: string;
stderr: string;
};
type CliRunner = (...args: string[]) => Promise<CliResult>;
function cliErrorOutput(error: unknown): string {
if (error === null || typeof error !== "object") {
return String(error);
}
const details = error as { message?: unknown; stdout?: unknown; stderr?: unknown };
return [details.message, details.stdout, details.stderr]
.filter((value): value is string => typeof value === "string")
.join("\n");
}
async function readSingleMetadata(run: CliRunner, label: string): Promise<CliFileInfo> {
const output = (await run("ls")).stdout.trimEnd();
const paths = output === "" ? [] : output.split(/\r?\n/u).map((line) => line.split("\t", 1)[0] ?? "");
assertEqual(paths.length, 1, `${label} did not contain exactly one Metadata document.`);
const storedPath = paths[0];
if (!storedPath) {
throw new Error(`${label} did not report a stored path.`);
}
return JSON.parse((await run("info", storedPath)).stdout) as CliFileInfo;
}
export async function runCliJournalMigration(
objectStorage: ObjectStorageConfig,
bucketPrefix: string,
sourceSettings: Record<string, unknown>
): Promise<void> {
const migrationPrefix = `${bucketPrefix}migration/`;
const cliRoot = await mkdtemp(join(tmpdir(), "livesync-cli-journal-migration-"));
const sourceRoot = join(cliRoot, "source");
const targetRoot = join(cliRoot, "target");
const sourceDatabasePath = join(sourceRoot, "database");
const targetDatabasePath = join(targetRoot, "database");
const sourceVaultPath = join(sourceRoot, "vault");
const targetVaultPath = join(targetRoot, "vault");
const sourceSettingsPath = join(sourceRoot, "settings.json");
const targetSettingsPath = join(targetRoot, "settings.json");
const physicalParent = notePath.split("/")[0] ?? "";
const physicalFilename = notePath.split("/").slice(-1)[0] ?? "";
const writeCliSettings = async (settingsPath: string, pathNormalizationVersion: number) => {
const previous = await readFile(settingsPath, "utf8").then(
(text) => JSON.parse(text) as Record<string, unknown>,
(error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error;
return undefined;
}
);
await writeFile(
settingsPath,
JSON.stringify({
...(previous ?? {
...DEFAULT_SETTINGS,
...objectStorage,
...sourceSettings,
remoteType: "MINIO",
bucketPrefix: migrationPrefix,
isConfigured: true,
}),
pathNormalizationVersion,
liveSync: false,
syncOnStart: false,
syncOnSave: false,
syncOnEditorSave: false,
syncOnFileOpen: false,
syncAfterMerge: false,
periodicReplication: false,
})
);
};
try {
await mkdir(sourceDatabasePath, { recursive: true });
await mkdir(targetDatabasePath, { recursive: true });
await mkdir(dirname(join(sourceVaultPath, notePath)), { recursive: true });
await mkdir(dirname(join(targetVaultPath, notePath)), { recursive: true });
await writeFile(join(sourceVaultPath, notePath), noteContent);
await writeFile(join(targetVaultPath, notePath), noteContent);
await writeCliSettings(sourceSettingsPath, 0);
await writeCliSettings(targetSettingsPath, 0);
const execute = promisify(execFile);
const createRunner =
(databasePath: string, vaultPath: string, settingsPath: string): CliRunner =>
async (...args: string[]) =>
await execute(
process.execPath,
[
resolve("src/apps/cli/dist/index.cjs"),
databasePath,
"--vault",
vaultPath,
"--settings",
settingsPath,
...args,
],
{ timeout: 60_000, maxBuffer: 4 * 1024 * 1024 }
);
const source = createRunner(sourceDatabasePath, sourceVaultPath, sourceSettingsPath);
const target = createRunner(targetDatabasePath, targetVaultPath, targetSettingsPath);
await source("--force", "--write-settings", "rebuild");
await target("mirror");
const legacySource = await readSingleMetadata(source, "Legacy CLI source");
const legacyTarget = await readSingleMetadata(target, "Legacy CLI target");
const targetVaultEntriesBeforeFetch = await readdir(targetVaultPath);
const targetParentEntriesBeforeFetch = await readdir(dirname(join(targetVaultPath, notePath)));
assertEqual(
targetVaultEntriesBeforeFetch.includes(physicalParent),
true,
"The legacy CLI target did not retain the NFD physical parent."
);
assertEqual(
targetParentEntriesBeforeFetch.includes(physicalFilename),
true,
"The legacy CLI target did not retain the NFD physical filename."
);
await writeCliSettings(sourceSettingsPath, 1);
await source("--force", "--write-settings", "rebuild");
const migratedSource = await readSingleMetadata(source, "Migrated CLI source");
if (migratedSource.id === legacySource.id) {
throw new Error("The NFC migration did not change the source Metadata ID.");
}
assertEqual(
(await source("cat", notePath.normalize("NFC"))).stdout,
noteContent,
"The migrated CLI source did not retain the canonical Chunk content."
);
const milestoneKey = `${migrationPrefix}_00000000-milestone.json`;
const milestoneBeforeFailedFetch = await readObjectStorageJson<JournalMilestone>(objectStorage, milestoneKey);
const migrationVersions = Object.values(milestoneBeforeFailedFetch.node_chunk_info ?? {}).map(
(node) => node.current
);
assertEqual(migrationVersions.includes(4), true, "The migrated Journal milestone did not declare version 4.");
let legacyFetchError: unknown;
try {
await target("--force", "--write-settings", "fetch");
} catch (error) {
legacyFetchError = error;
}
if (legacyFetchError === undefined) {
throw new Error("The legacy CLI target unexpectedly fetched a Journal with NFC path identity.");
}
const legacyFetchOutput = cliErrorOutput(legacyFetchError);
if (!/path identity/iu.test(legacyFetchOutput)) {
throw new Error(
`The legacy CLI Fetch failed without the expected path identity error. Output: ${legacyFetchOutput}`
);
}
const targetAfterFailedFetch = await readSingleMetadata(target, "Legacy CLI target after failed Fetch");
assertEqual(
targetAfterFailedFetch.id,
legacyTarget.id,
"The failed legacy Fetch changed the target Metadata ID."
);
assertEqual(
targetAfterFailedFetch.revision,
legacyTarget.revision,
"The failed legacy Fetch changed the target Metadata revision."
);
const milestoneAfterFailedFetch = await readObjectStorageJson<JournalMilestone>(objectStorage, milestoneKey);
deepStrictEqual(
milestoneAfterFailedFetch,
milestoneBeforeFailedFetch,
"The failed legacy Fetch changed the remote Journal milestone."
);
await writeCliSettings(targetSettingsPath, 1);
await target("--force", "--write-settings", "fetch");
const migratedTarget = await readSingleMetadata(target, "Migrated CLI target");
assertEqual(migratedTarget.id, migratedSource.id, "The CLI source and target Metadata IDs did not converge.");
assertEqual(
(await target("cat", notePath.normalize("NFC"))).stdout,
noteContent,
"The migrated CLI target did not retain the canonical Chunk content."
);
assertEqual(
await readFile(join(targetVaultPath, notePath), "utf8"),
noteContent,
"The migrated CLI Fetch changed the NFD physical file contents."
);
const targetVaultEntriesAfterFetch = await readdir(targetVaultPath);
const targetParentEntriesAfterFetch = await readdir(dirname(join(targetVaultPath, notePath)));
deepStrictEqual(
targetVaultEntriesAfterFetch,
targetVaultEntriesBeforeFetch,
"The migrated CLI Fetch changed the physical Vault parent names."
);
deepStrictEqual(
targetParentEntriesAfterFetch,
targetParentEntriesBeforeFetch,
"The migrated CLI Fetch changed the physical NFD filename."
);
console.log(
"CLI_JOURNAL_MIGRATION: legacy Fetch rejection, safe migration, and NFD physical path preservation verified."
);
} finally {
try {
await rm(cliRoot, { recursive: true, force: true });
} finally {
await deleteObjectStoragePrefix(objectStorage, migrationPrefix);
}
}
}
+19 -6
View File
@@ -34,6 +34,7 @@ export type RemoteActivitySnapshot = {
responseCount: number;
statusBarFound: boolean;
statusBarText: string;
indicatorElements?: Array<{ text: string; inactive: boolean; ariaHidden: string | null; display: string }>;
};
export type ExpectedRemoteActivityState =
@@ -88,14 +89,26 @@ async function readRemoteActivitySnapshotFromPage(page: Page): Promise<RemoteAct
gateKind: gate?.kind,
requestCount: Number(core.services?.API?.requestCount?.value ?? -1),
remoteOperationIndicatorVisible: statusBars.some((element) =>
(element.textContent ?? "").includes(operationIcon)
),
remoteRequestIndicatorVisible: statusBars.some((element) =>
(element.textContent ?? "").includes(requestIcon)
element.innerText.includes(operationIcon)
),
remoteRequestIndicatorVisible: statusBars.some((element) => element.innerText.includes(requestIcon)),
responseCount: Number(core.services?.API?.responseCount?.value ?? -1),
statusBarFound: statusBars.length > 0,
statusBarText: statusBars.map((element) => element.textContent ?? "").join("\n"),
statusBarText: statusBars.map((element) => element.innerText).join("\n"),
indicatorElements: Array.from(
document.querySelectorAll<HTMLElement>(".syncstatusbar .livesync-stat-item")
)
.filter(
(element) =>
(element.textContent ?? "").includes(operationIcon) ||
(element.textContent ?? "").includes(requestIcon)
)
.map((element) => ({
text: element.textContent ?? "",
inactive: element.classList.contains("livesync-stat-inactive"),
ariaHidden: element.getAttribute("aria-hidden"),
display: getComputedStyle(element).display,
})),
} satisfies RemoteActivitySnapshot;
},
{
@@ -141,7 +154,7 @@ export async function waitForRemoteActivityState(
if (!core) return false;
const gate = (globalThis as unknown as Record<string, RuntimeGate | undefined>)[stateKey];
const statusBarText = Array.from(document.querySelectorAll<HTMLElement>(".syncstatusbar"))
.map((element) => element.textContent ?? "")
.map((element) => element.innerText)
.join("\n");
const bounded = Number(core.services?.replicator?.boundedRemoteActivityCount?.value ?? -1);
const finite = Number(core.services?.replicator?.finiteReplicationActivityCount?.value ?? -1);
@@ -1,3 +1,6 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { DoctorRegulation } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { evalObsidianJson } from "../runner/cli.ts";
import {
@@ -28,6 +31,11 @@ type ConflictFixture = {
conflicts: string[];
};
type ObservedConflictService = {
ensureAllProcessed(): Promise<boolean>;
conflictProcessQueueCount: { value: number };
};
type ObsidianTestApp = {
commands?: { executeCommandById(commandId: string): boolean };
plugins?: {
@@ -38,10 +46,7 @@ type ObsidianTestApp = {
| {
core?: {
services?: {
conflict?: {
ensureAllProcessed(): Promise<boolean>;
conflictProcessQueueCount: { value: number };
};
conflict?: ObservedConflictService;
};
};
}
@@ -53,9 +58,46 @@ type ObsidianTestApp = {
type ObsidianTestGlobal = typeof globalThis & {
app?: ObsidianTestApp;
__livesyncConflictChecksCompleted?: boolean;
__livesyncConflictChecksResult?: boolean;
__livesyncWaitingConflictCompleted?: boolean;
__livesyncConflictChecksError?: string;
__livesyncWaitingConflictError?: string;
__livesyncObservedConflict?: ObservedConflictService;
};
async function recordConflictDiagnostics(port: number, label: string) {
const state = await withObsidianPage(
port,
async (page) =>
await page.evaluate(() => {
const host = globalThis as ObsidianTestGlobal;
const plugin = host.app?.plugins?.plugins?.["obsidian-livesync"];
const conflict = host.__livesyncObservedConflict ?? plugin?.core?.services?.conflict;
return {
pluginLoaded: plugin !== undefined,
queueCount: conflict?.conflictProcessQueueCount.value,
checksCompleted: host.__livesyncConflictChecksCompleted,
checksResult: host.__livesyncConflictChecksResult,
waitingCompleted: host.__livesyncWaitingConflictCompleted,
checksError: host.__livesyncConflictChecksError,
waitingError: host.__livesyncWaitingConflictError,
visibility: document.visibilityState,
dialogues: Array.from(document.querySelectorAll(".modal-container")).map((element) => ({
visible: element.getClientRects().length > 0,
title: element.querySelector(".modal-title")?.textContent,
text: element.textContent?.slice(0, 300),
actions: element.querySelectorAll(".conflict-action-button").length,
})),
};
})
);
const directory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
await mkdir(directory, { recursive: true });
await writeFile(join(directory, `conflict-${label}.json`), JSON.stringify(state, null, 2) + "\n");
console.log(`Conflict ${label}: ${JSON.stringify(state)}`);
return state;
}
async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv, targetPath = path): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
@@ -249,7 +291,7 @@ async function createTwoVersionConflict(
if (fixture.conflicts.length !== 1) {
throw new Error(`Expected exactly two live leaves for ${targetPath}: ${JSON.stringify(fixture)}`);
}
return fixture;
return { ...fixture, currentParentRev: base.rev };
}
async function setShowMergeDialogOnlyOnActive(
@@ -308,13 +350,19 @@ async function requestInteractiveConflictResolution(
`const conflictRev=${JSON.stringify(fixture.conflicts[0])};`,
`const trackCompletion=${JSON.stringify(trackCompletion)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"if(trackCompletion) globalThis.__livesyncWaitingConflictCompleted=false;",
"if(trackCompletion){",
" globalThis.__livesyncWaitingConflictCompleted=false;",
" globalThis.__livesyncWaitingConflictError=undefined;",
"}",
"const pending=core.services.conflict.resolveByUserInteraction(path,{",
"left:{rev:currentRev,data:'Current branch',ctime:1,mtime:2},",
"right:{rev:conflictRev,data:'Conflict branch',ctime:1,mtime:3},",
"diff:[[0,'Current and conflict branches']],",
"});",
"if(trackCompletion) void pending.then(()=>{globalThis.__livesyncWaitingConflictCompleted=true;});",
"if(trackCompletion) void pending.then(",
" ()=>{globalThis.__livesyncWaitingConflictCompleted=true;},",
" (error)=>{globalThis.__livesyncWaitingConflictError=error?.stack??String(error);}",
");",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
@@ -328,10 +376,20 @@ async function disableLiveSyncAndWaitForConflictChecks(port: number): Promise<vo
const host = globalThis as ObsidianTestGlobal;
const conflict = host.app?.plugins?.plugins?.["obsidian-livesync"]?.core?.services?.conflict;
if (conflict === undefined) throw new Error("LiveSync conflict service is unavailable before unload");
host.__livesyncObservedConflict = conflict;
host.__livesyncConflictChecksCompleted = false;
void conflict.ensureAllProcessed().then(() => {
host.__livesyncConflictChecksCompleted = true;
});
host.__livesyncConflictChecksResult = undefined;
host.__livesyncConflictChecksError = undefined;
void conflict.ensureAllProcessed().then(
(result) => {
host.__livesyncConflictChecksResult = result;
host.__livesyncConflictChecksCompleted = true;
},
(error) => {
host.__livesyncConflictChecksError =
error instanceof Error ? (error.stack ?? String(error)) : String(error);
}
);
});
await page.evaluate(async () => {
const plugins = (globalThis as ObsidianTestGlobal).app?.plugins;
@@ -349,6 +407,8 @@ async function disableLiveSyncAndWaitForConflictChecks(port: number): Promise<vo
undefined,
{ timeout: uiTimeoutMs }
);
const result = await page.evaluate(() => (globalThis as ObsidianTestGlobal).__livesyncConflictChecksResult);
if (result !== false) throw new Error("Unloading LiveSync did not report conflict-check cancellation");
});
}
@@ -362,6 +422,76 @@ async function enableLiveSync(port: number): Promise<void> {
});
}
type UnloadConflictFixture = { path: string; fixture: ConflictFixture };
type PreservedConflictState = {
path: string;
currentRev: string;
baseContent: string | false;
vaultContent: string;
leaves: { rev: string; parentRev: string; content: string | false; deleted: boolean }[];
};
async function recordUnloadPreservation(
cliBinary: string,
env: NodeJS.ProcessEnv,
label: string,
fixtures: UnloadConflictFixture[],
before?: PreservedConflictState[]
): Promise<PreservedConflictState[]> {
const states = await evalObsidianJson<PreservedConflictState[]>(
cliBinary,
[
"(async()=>{",
`const fixtures=${JSON.stringify(fixtures)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const contentOf=(entry)=>entry===false?false:Array.isArray(entry.data)?entry.data.join(''):entry.data;",
"const states=[];",
"for(const {path,fixture} of fixtures){",
" const id=await core.services.path.path2id(path);",
" const meta=await core.localDatabase.getRaw(id,{conflicts:true});",
" const leaves=[];",
" for(const rev of [meta._rev,...(meta._conflicts??[])].sort()){",
" const raw=await core.localDatabase.getRaw(id,{rev,revs:true});",
" const history=raw._revisions;",
" const parentRev=history?.ids?.length>1?`${history.start-1}-${history.ids[1]}`:undefined;",
" const entry=await core.localDatabase.getDBEntry(path,{rev},false,true,true);",
" leaves.push({rev,parentRev,content:contentOf(entry),deleted:!!(raw.deleted||raw._deleted)});",
" }",
" const base=await core.localDatabase.getDBEntry(path,{rev:fixture.currentParentRev},false,true,true);",
" const vaultContent=await app.vault.adapter.read(path);",
" states.push({path,currentRev:meta._rev,baseContent:contentOf(base),vaultContent,leaves});",
"}",
"return JSON.stringify(states);",
"})()",
].join(""),
env
);
const directory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
await mkdir(directory, { recursive: true });
await writeFile(join(directory, `conflict-${label}-preservation.json`), JSON.stringify(states, null, 2) + "\n");
for (const { path: targetPath, fixture } of fixtures) {
const state = states.find((entry) => entry.path === targetPath);
const expectedRevs = [fixture.currentRev, ...fixture.conflicts].sort();
if (
!state ||
state.currentRev !== fixture.currentRev ||
state.baseContent !== baseContent ||
JSON.stringify(state.leaves.map((leaf) => leaf.rev)) !== JSON.stringify(expectedRevs) ||
JSON.stringify(state.leaves.map((leaf) => leaf.content).sort()) !==
JSON.stringify([leftContent, rightContent].sort()) ||
state.leaves.some((leaf) => leaf.deleted || leaf.parentRev !== fixture.currentParentRev)
) {
throw new Error(`Conflict contents or ancestry changed across ${label}: ${JSON.stringify(state)}`);
}
const previous = before?.find((entry) => entry.path === targetPath);
if (previous && state.vaultContent !== previous.vaultContent) {
throw new Error(`Vault content changed across ${label}: ${targetPath}`);
}
}
console.log(`Verified conflict contents, leaf revisions, and ancestry: ${label}`);
return states;
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
@@ -372,14 +502,17 @@ async function main(): Promise<void> {
const vault = await createTemporaryVault("obsidian-livesync-conflict-dialog-");
let session: ObsidianLiveSyncSession | undefined;
let unloadFixtures: UnloadConflictFixture[] = [];
let unloadPreservationBefore: PreservedConflictState[] | undefined;
try {
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault,
artifactRoot: process.env.E2E_OBSIDIAN_ARTIFACT_ROOT,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: {
doctorProcessedVersion: "1.0.0",
doctorProcessedVersion: DoctorRegulation.version,
isConfigured: true,
liveSync: false,
remoteType: "",
@@ -433,34 +566,31 @@ async function main(): Promise<void> {
(page) => conflictDialogue(page).locator(".modal").first()
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const modal = conflictDialogue(page);
const actionButtons = modal.locator(".conflict-action-button");
await actionButtons.nth(3).waitFor({ state: "visible", timeout: uiTimeoutMs });
const actionButtonBounds = await actionButtons.evaluateAll((buttons) =>
buttons.map((button) => {
const bounds = button.getBoundingClientRect();
return { top: bounds.top, bottom: bounds.bottom };
})
await page.waitForFunction(
(targetPath) => {
const dialogues = Array.from(document.querySelectorAll(".modal-container")).filter(
(element) =>
element.getClientRects().length > 0 &&
element.querySelector(".modal-title")?.textContent?.includes("Conflicting changes") &&
element.textContent?.includes(targetPath)
);
if (dialogues.length !== 1) return false;
const bounds = Array.from(dialogues[0].querySelectorAll(".conflict-action-button")).map((button) =>
button.getBoundingClientRect()
);
return (
bounds.length === 4 &&
bounds.every(
(button, index) =>
button.width > 0 &&
button.height > 0 &&
(index === 0 || button.top >= bounds[index - 1].bottom)
)
);
},
path,
{ timeout: uiTimeoutMs }
);
if (
actionButtonBounds.length !== 4 ||
actionButtonBounds.some(
(bounds, index) => index > 0 && bounds.top < actionButtonBounds[index - 1].bottom
)
) {
const buttonDetails = await modal.locator("button").evaluateAll((buttons) =>
buttons.map((button) => ({
text: button.textContent,
className: button.className,
}))
);
throw new Error(
`Conflict action buttons are not stacked vertically: ${JSON.stringify({
actionButtonBounds,
buttonDetails,
})}`
);
}
});
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const modal = conflictDialogue(page);
@@ -506,6 +636,7 @@ async function main(): Promise<void> {
binary,
cliBinary,
vault,
artifactRoot: process.env.E2E_OBSIDIAN_ARTIFACT_ROOT,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
@@ -542,6 +673,7 @@ async function main(): Promise<void> {
binary,
cliBinary,
vault,
artifactRoot: process.env.E2E_OBSIDIAN_ARTIFACT_ROOT,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
@@ -734,8 +866,12 @@ async function main(): Promise<void> {
});
await waitForConflictChecks(cliBinary, session.cliEnv);
await createTwoVersionConflict(cliBinary, session.cliEnv, unloadActivePath);
const unloadActiveFixture = await createTwoVersionConflict(cliBinary, session.cliEnv, unloadActivePath);
const unloadWaitingFixture = await createTwoVersionConflict(cliBinary, session.cliEnv, unloadWaitingPath);
unloadFixtures = [
{ path: unloadActivePath, fixture: unloadActiveFixture },
{ path: unloadWaitingPath, fixture: unloadWaitingFixture },
];
await requestConflictCheck(cliBinary, session.cliEnv, false, unloadActivePath);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await conflictDialogue(page, unloadActivePath).waitFor({ state: "visible", timeout: uiTimeoutMs });
@@ -752,7 +888,14 @@ async function main(): Promise<void> {
"conflict-dialog-before-unload.png",
(page) => conflictDialogue(page, unloadActivePath).locator(".modal").first()
);
unloadPreservationBefore = await recordUnloadPreservation(
cliBinary,
session.cliEnv,
"before-unload",
unloadFixtures
);
await disableLiveSyncAndWaitForConflictChecks(session.remoteDebuggingPort);
await recordConflictDiagnostics(session.remoteDebuggingPort, "unloaded");
const afterUnloadScreenshot = await captureObsidianPage(
session.remoteDebuggingPort,
"conflict-dialog-after-unload.png",
@@ -770,6 +913,13 @@ async function main(): Promise<void> {
await enableLiveSync(session.remoteDebuggingPort);
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await recordUnloadPreservation(
cliBinary,
session.cliEnv,
"after-reload",
unloadFixtures,
unloadPreservationBefore
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.waitForTimeout(500);
if (await conflictDialogue(page).isVisible()) {
@@ -794,7 +944,7 @@ async function main(): Promise<void> {
await waitForConflictChecks(cliBinary, session.cliEnv);
console.log(
"Real Obsidian preserved pairwise conflict resolution and dialogue presentation; replaced only stale same-file dialogues; serialised different files; discarded externally resolved waiting requests; and drained active and waiting requests across unload and reload."
"Real Obsidian preserved pairwise conflict resolution and dialogue presentation; replaced only stale same-file dialogues; serialised different files; discarded externally resolved waiting requests; and reported cancellation while closing active and waiting requests across unload and reload."
);
console.log(`Dialogue screenshot: ${firstDialogueScreenshot}`);
console.log(`Postponed warning screenshot: ${warningScreenshot}`);
@@ -803,6 +953,27 @@ async function main(): Promise<void> {
console.log(`Before unload screenshot: ${beforeUnloadScreenshot}`);
console.log(`After unload screenshot: ${afterUnloadScreenshot}`);
console.log(`After reload screenshot: ${afterReloadScreenshot}`);
} catch (error) {
if (session) {
try {
const state = await recordConflictDiagnostics(session.remoteDebuggingPort, "failure");
await captureObsidianPage(session.remoteDebuggingPort, "conflict-failure.png", async () => {});
if (!state.pluginLoaded && unloadPreservationBefore) {
await enableLiveSync(session.remoteDebuggingPort);
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await recordUnloadPreservation(
cliBinary,
session.cliEnv,
"after-failed-unload-reload",
unloadFixtures,
unloadPreservationBefore
);
}
} catch (diagnosticError) {
console.error("Could not capture conflict diagnostics:", diagnosticError);
}
}
throw error;
} finally {
if (session) {
await session.app.stop();
@@ -0,0 +1,843 @@
import { randomBytes } from "node:crypto";
import { createServer } from "node:net";
import { readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import { deriveIdKey } from "@vrtmrz/livesync-commonlib/settings";
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
configureObjectStorage,
createE2eObjectStoragePluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
type LocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import {
deleteObjectStoragePrefix,
ensureObjectStorageBucket,
listObjectStorageObjects,
loadObjectStorageConfig,
makeUniqueBucketPrefix,
readObjectStorageJson,
type ObjectStorageConfig,
} from "../runner/objectStorage.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
import { withObsidianPage } from "../runner/ui.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000";
const timeoutMs = Number(process.env.E2E_OBSIDIAN_JOURNAL_NFC_TIMEOUT_MS ?? 60000);
const objectTimeoutMs = Number(process.env.E2E_OBSIDIAN_OBJECT_STORAGE_TIMEOUT_MS ?? 30000);
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_JOURNAL_NFC_UI_TIMEOUT_MS ?? 10000);
const relativeFolder = "E2E/journal-nfc";
const folderNfd = "Cafe\u0301";
const conflictNameNfd = "conflict-e\u0301.md";
const conflictPathNfd = `${relativeFolder}/${folderNfd}/${conflictNameNfd}`;
const conflictPathNfc = conflictPathNfd.normalize("NFC");
const controlNameNfd = "ordinary-e\u0301.md";
const controlPathNfd = `${relativeFolder}/${folderNfd}/${controlNameNfd}`;
const controlContent = "# NFC control\n\nThis ordinary, non-conflicted note keeps one NFC-equivalent path.\n";
const baseContent = [
"# Journal resend conflict fixture",
"",
"The common ancestor contains this exact sentence and enough bytes for real Chunks.",
"The source file begins from this baseline before either device makes a change.",
"Shared value: the original value remains here.",
"",
].join("\n");
const sourceLeafContent = [
"# Journal resend conflict fixture",
"",
"The common ancestor contains this exact sentence and enough bytes for real Chunks.",
"The source file begins from this baseline before either device makes a change.",
"Shared value: the source device replaced this line with its own edit.",
"",
].join("\n");
const competingLeafContent = [
"# Journal resend conflict fixture",
"",
"The common ancestor contains this exact sentence and enough bytes for real Chunks.",
"The source file begins from this baseline before either device makes a change.",
"Shared value: another device replaced this line differently.",
"",
].join("\n");
type ScenarioCase = { name: "plain" | "encrypted"; independentIds: boolean };
type RevisionSnapshot = {
rev: string;
revisionTree: { start: number; ids: string[] };
revisions: string[];
body: string;
children: string[];
chunksPresent: boolean;
};
type ConflictTreeSnapshot = {
id: string;
path: string;
leafRevs: string[];
leaves: RevisionSnapshot[];
ancestor?: RevisionSnapshot;
ancestorRev?: string;
ancestorBodyAvailable: boolean;
};
type TransferCheckpoint = {
lastLocalSeq: number | string | undefined;
journalEpoch: string;
knownIDs: string[];
sentIDs: string[];
receivedFiles: string[];
sentFiles: string[];
};
type ScenarioSettings = Record<string, unknown>;
function selectedCases(args: readonly string[]): ScenarioCase[] {
let requested = process.env.E2E_OBSIDIAN_INDEPENDENT_IDS === "true" ? "encrypted" : "plain";
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
if (argument === "--help" || argument === "-h") {
console.log("Usage: journal-nfc-resend.ts [--case all|plain|encrypted|independent-ids]");
process.exit(0);
}
if (argument === "--case") {
const value = args[index + 1];
if (!value) throw new Error("--case requires all, plain, encrypted, or independent-ids.");
requested = value;
index += 1;
continue;
}
if (argument.startsWith("--case=")) {
requested = argument.slice("--case=".length);
continue;
}
throw new Error(`Unknown Journal NFC resend argument: ${argument}`);
}
const cases: ScenarioCase[] = [
{ name: "plain", independentIds: false },
{ name: "encrypted", independentIds: true },
];
if (requested === "all") return cases;
if (requested === "independent-ids") requested = "encrypted";
const selected = cases.filter((candidate) => candidate.name === requested);
if (selected.length === 0) throw new Error(`Unsupported Journal NFC resend case: ${requested}`);
return selected;
}
async function allocatePort(excluded: ReadonlySet<number>): Promise<number> {
for (let attempt = 0; attempt < 10; attempt += 1) {
const server = createServer();
const port = await new Promise<number>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
reject(new Error("Could not allocate an Obsidian debugging port."));
return;
}
resolve(address.port);
});
});
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
if (!excluded.has(port)) return port;
}
throw new Error("Could not allocate distinct Obsidian debugging ports.");
}
async function createVaultNote(
cliBinary: string,
environment: NodeJS.ProcessEnv,
relativePath: string,
content: string
): Promise<void> {
await evalObsidianJson<boolean>(
cliBinary,
[
"(async()=>{",
"const path=" + JSON.stringify(relativePath.normalize("NFC")) + ";",
"const content=" + JSON.stringify(content) + ";",
"const parent=path.slice(0,path.lastIndexOf('/'));",
"if(!(await app.vault.adapter.exists(parent)))await app.vault.createFolder(parent);",
"await app.vault.create(path,content);",
"await app.plugins.plugins['obsidian-livesync'].core.services.fileProcessing.commitPendingFileEvents();",
"return JSON.stringify(true);",
"})()",
].join(""),
environment,
timeoutMs
);
}
async function assertSinglePhysicalUnicodeFile(
vaultPath: string,
relativePath: string,
expectedContent: string
): Promise<void> {
const segments = relativePath.split("/");
const stableParent = join(vaultPath, ...segments.slice(0, -2));
const expectedFolder = segments[segments.length - 2].normalize("NFC");
const expectedFilename = segments[segments.length - 1].normalize("NFC");
const physicalFolders = (await readdir(stableParent, { withFileTypes: true })).filter(
(entry) => entry.isDirectory() && entry.name.normalize("NFC") === expectedFolder
);
assertEqual(physicalFolders.length, 1, "NFC-equivalent folder spellings created duplicate physical folders.");
const actualFolder = join(stableParent, physicalFolders[0].name);
const matchingFiles = (await readdir(actualFolder)).filter((name) => name.normalize("NFC") === expectedFilename);
assertEqual(matchingFiles.length, 1, "NFC-equivalent file spellings created duplicate physical files.");
assertEqual(
await readFile(join(actualFolder, matchingFiles[0]), "utf8"),
expectedContent,
"The ordinary NFC control file has unexpected bytes."
);
}
async function waitForSinglePhysicalUnicodeFile(
vaultPath: string,
relativePath: string,
expectedContent: string
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
while (Date.now() < deadline) {
try {
await assertSinglePhysicalUnicodeFile(vaultPath, relativePath, expectedContent);
return;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
throw new Error(
`Timed out waiting for one NFC-equivalent physical file: ${lastError instanceof Error ? lastError.message : String(lastError)}`
);
}
async function makeScenarioSettings(scenario: ScenarioCase): Promise<ScenarioSettings> {
return {
pathNormalizationVersion: 1,
...(scenario.independentIds
? {
encrypt: true,
usePathObfuscation: true,
passphrase: randomBytes(32).toString("base64url"),
idDerivationVersion: 1,
idDerivationKey: await deriveIdKey(randomBytes(32).toString("base64url")),
}
: {}),
};
}
async function startSession(
binary: string,
cliBinary: string,
vault: TemporaryVault,
port: number,
storage: ObjectStorageConfig & { bucketPrefix: string },
overrides: ScenarioSettings,
configure: boolean
): Promise<ObsidianLiveSyncSession> {
const session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
env: { E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) },
...(configure
? {
pluginData: createE2eObjectStoragePluginData(storage, { pathNormalizationVersion: 1 }),
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
}
: {}),
});
try {
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv, timeoutMs);
if (configure) {
const configured = await configureObjectStorage(cliBinary, session.cliEnv, storage, overrides);
assertEqual(configured.isConfigured, true, "Self-hosted LiveSync was not marked as configured.");
assertEqual(configured.remoteType, "MINIO", "The configured remote was not Object Storage.");
assertEqual(configured.bucket, storage.bucket, "The Object Storage bucket changed during configuration.");
assertEqual(
configured.bucketPrefix,
storage.bucketPrefix,
"The Object Storage prefix changed during configuration."
);
assertEqual(configured.liveSync, false, "LiveSync should remain disabled during the one-shot workflow.");
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv, timeoutMs);
}
return session;
} catch (error) {
await session.app.stop().catch((stopError: unknown) => {
console.warn(stopError instanceof Error ? stopError.message : stopError);
});
throw error;
}
}
function readTreeSource(path: string, expectedBaseRev: string, requireAncestorBody: boolean): string {
return [
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const expectedBaseRev=${JSON.stringify(expectedBaseRev)};`,
`const requireAncestorBody=${JSON.stringify(requireAncestorBody)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const db=core.localDatabase;",
"const readRevision=async(rev)=>{",
"const metadata=await db.getDBEntryMeta(path,{rev,revs:true},true);",
"const entry=await db.getDBEntry(path,{rev},false,true,true);",
"if(!metadata||!entry) throw new Error('Could not read exact Metadata revision '+rev);",
"const tree=metadata._revisions;",
"if(!tree||!Number.isInteger(tree.start)||!Array.isArray(tree.ids)||tree.ids.length===0) throw new Error('Revision ancestry is unavailable for '+rev);",
"const revisionTree={start:tree.start,ids:[...tree.ids]};",
"const revisions=revisionTree.ids.map((id,index)=>`${revisionTree.start-index}-${id}`);",
"const data=entry.data;",
"const body=Array.isArray(data)?data.join(''):typeof data==='string'?data:data instanceof Blob?await data.text():null;",
"if(typeof body!=='string') throw new Error('Could not decode exact revision body '+rev);",
"const children=Array.isArray(metadata.children)?[...metadata.children]:Array.isArray(entry.children)?[...entry.children]:[];",
"const rows=children.length?await db.allDocsRaw({keys:children,include_docs:true}):{rows:[]};",
"const available=rows.rows.filter(row=>Boolean(row.doc)&&!Boolean(row.value?.deleted)&&!Boolean(row.doc?.deleted)&&!Boolean(row.doc?._deleted)).length;",
"const chunksPresent=children.length>0&&available===children.length;",
"if(!chunksPresent) throw new Error('Exact Metadata revision has missing referenced Chunks '+rev);",
"return {rev:entry._rev,revisionTree,revisions,body,children,chunksPresent};",
"};",
"const current=await db.getDBEntryMeta(path,{conflicts:true},true);",
"if(!current?._rev) throw new Error('Conflict Metadata is missing: '+path);",
"const leafRevs=[current._rev,...(current._conflicts??[])].sort();",
"const leaves=[];",
"for(const rev of leafRevs) leaves.push(await readRevision(rev));",
"let ancestor;let ancestorRev;let ancestorBodyAvailable=false;",
"if(leaves.length>=2){",
"const shared=leaves[0].revisions.filter(rev=>leaves.slice(1).every(leaf=>leaf.revisions.includes(rev)));",
"if(shared.length===0) throw new Error('Conflict leaves have no exact shared revision ID.');",
"ancestorRev=shared[0];",
"if(ancestorRev!==expectedBaseRev) throw new Error('Nearest common revision did not match the seeded baseline.');",
"const metadata=await db.getDBEntryMeta(path,{rev:ancestorRev,revs:true},true).catch(error=>{if(error?.status===404||error?.name==='not_found')return false;throw error;});",
"if(metadata){ancestor=await readRevision(ancestorRev);ancestorBodyAvailable=true;}",
"if(requireAncestorBody&&!ancestorBodyAvailable)throw new Error('The source lost its available common ancestor body.');",
"}",
"return JSON.stringify({id:current._id,path:current.path,leafRevs,leaves,ancestor,ancestorRev,ancestorBodyAvailable});",
"})()",
].join("");
}
async function readConflictTree(
cliBinary: string,
env: NodeJS.ProcessEnv,
path: string,
expectedBaseRev: string,
requireAncestorBody = true
): Promise<ConflictTreeSnapshot> {
return await evalObsidianJson<ConflictTreeSnapshot>(
cliBinary,
readTreeSource(path, expectedBaseRev, requireAncestorBody),
env,
timeoutMs
);
}
async function waitForConflictTree(
cliBinary: string,
env: NodeJS.ProcessEnv,
path: string,
expectedBaseRev: string,
expectedBodies: readonly string[],
requireAncestorBody = true
): Promise<ConflictTreeSnapshot> {
const deadline = Date.now() + timeoutMs;
let last: ConflictTreeSnapshot | undefined;
while (Date.now() < deadline) {
try {
last = await readConflictTree(cliBinary, env, path, expectedBaseRev, requireAncestorBody);
const bodies = last.leaves.map((leaf) => leaf.body).sort();
if (last.leafRevs.length === 2 && JSON.stringify(bodies) === JSON.stringify([...expectedBodies].sort())) {
return last;
}
} catch (error) {
if (Date.now() + 250 >= deadline) throw error;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for both exact conflict leaves: ${JSON.stringify(last)}`);
}
function assertSameConflictTree(actual: ConflictTreeSnapshot, expected: ConflictTreeSnapshot, label: string): void {
assertEqual(actual.id, expected.id, `${label}: Metadata ID changed.`);
assertEqual(
JSON.stringify(actual.leafRevs),
JSON.stringify(expected.leafRevs),
`${label}: leaf revision IDs changed.`
);
assertEqual(
JSON.stringify(
actual.leaves.map(({ rev, revisionTree, body, children, chunksPresent }) => ({
rev,
revisionTree,
body,
children,
chunksPresent,
}))
),
JSON.stringify(
expected.leaves.map(({ rev, revisionTree, body, children, chunksPresent }) => ({
rev,
revisionTree,
body,
children,
chunksPresent,
}))
),
`${label}: a leaf body, exact _revisions, or referenced Chunk changed.`
);
assertEqual(actual.ancestorRev, expected.ancestorRev, `${label}: the exact common ancestor changed.`);
if (!actual.ancestorBodyAvailable) return;
assertEqual(
JSON.stringify(
actual.ancestor && {
revisionTree: actual.ancestor.revisionTree,
revisions: actual.ancestor.revisions,
body: actual.ancestor.body,
children: actual.ancestor.children,
chunksPresent: actual.ancestor.chunksPresent,
}
),
JSON.stringify(
expected.ancestor && {
revisionTree: expected.ancestor.revisionTree,
revisions: expected.ancestor.revisions,
body: expected.ancestor.body,
children: expected.ancestor.children,
chunksPresent: expected.ancestor.chunksPresent,
}
),
`${label}: the common ancestor body or its Chunks changed.`
);
}
async function storeSourceLeaf(
cliBinary: string,
env: NodeJS.ProcessEnv,
path: string,
content: string,
baseRev: string
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const content=${JSON.stringify(content)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const file=app.vault.getFiles().find(candidate=>candidate.path.normalize('NFC')===path.normalize('NFC'));",
"if(!file) throw new Error('Could not find the NFC-equivalent source file '+path);",
"await app.vault.modify(file,content);",
"await core.services.fileProcessing.commitPendingFileEvents();",
"const deadline=Date.now()+20000;",
"const sleep=(ms)=>new Promise(resolve=>setTimeout(resolve,ms));",
"let entry=false;",
"let stored=false;",
"while(Date.now()<deadline){",
"entry=await core.localDatabase.getDBEntry(path,undefined,false,true,true).catch(()=>false);",
"const data=entry?.data;",
"const body=Array.isArray(data)?data.join(''):typeof data==='string'?data:data instanceof Blob?await data.text():null;",
`if(entry&&entry._rev!==${JSON.stringify(baseRev)}&&body===content){stored=true;break;}`,
"await sleep(250);",
"}",
"if(!stored) throw new Error('The real Obsidian source edit did not create a child revision with the expected body.');",
"const children=Array.isArray(entry.children)?entry.children:[];",
"if(children.length===0) throw new Error('The real Obsidian source revision has no Chunks.');",
"return JSON.stringify(true);",
"})()",
].join(""),
env,
timeoutMs
);
}
async function storeCompetingLeaf(
cliBinary: string,
env: NodeJS.ProcessEnv,
path: string,
content: string,
baseRev: string
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const content=${JSON.stringify(content)};`,
`const baseRev=${JSON.stringify(baseRev)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const id=await core.services.path.path2id(path);",
"const blob=new Blob([content],{type:'text/plain'});",
"const now=Date.now();",
"const result=await core.localDatabase.putDBEntry({",
"_id:id,path,data:blob,ctime:now,mtime:now,size:blob.size,children:[],datatype:'plain',type:'plain',eden:{},",
"},false,baseRev);",
"if(!result?.ok) throw new Error('Could not create the sibling conflict leaf from its exact baseline.');",
"const entry=await core.localDatabase.getDBEntry(path,{rev:result.rev},false,true,true);",
"if(!entry||!Array.isArray(entry.children)||entry.children.length===0) throw new Error('The sibling conflict leaf has no real Chunks.');",
"return JSON.stringify(true);",
"})()",
].join(""),
env,
timeoutMs
);
}
async function readTransferCheckpoint(cliBinary: string, env: NodeJS.ProcessEnv): Promise<TransferCheckpoint> {
return await evalObsidianJson<TransferCheckpoint>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const replicator=core.services.replicator.getActiveReplicator();",
"const client=replicator?.client;",
"if(!client||typeof client.getCheckpointInfo!=='function') throw new Error('The active Object Storage replicator did not expose its Journal checkpoint.');",
"const info=await client.getCheckpointInfo();",
"const values=value=>[...(value??[])].sort();",
"return JSON.stringify({lastLocalSeq:info.lastLocalSeq,journalEpoch:info.journalEpoch,knownIDs:values(info.knownIDs),sentIDs:values(info.sentIDs),receivedFiles:values(info.receivedFiles),sentFiles:values(info.sentFiles)});",
"})()",
].join(""),
env,
timeoutMs
);
}
function assertCheckpointPopulated(checkpoint: TransferCheckpoint, tree: ConflictTreeSnapshot): void {
if (checkpoint.sentFiles.length === 0 || checkpoint.sentIDs.length === 0) {
throw new Error("The initial Journal transfer did not populate its persistent send checkpoint.");
}
const localSequence = checkpoint.lastLocalSeq;
const sequenceIsPopulated =
typeof localSequence === "number"
? Number.isFinite(localSequence) && localSequence > 0
: typeof localSequence === "string" && localSequence.trim() !== "" && localSequence.trim() !== "0";
assertEqual(
sequenceIsPopulated,
true,
"The initial Journal transfer did not advance its local sequence checkpoint."
);
const sentIds = new Set(checkpoint.sentIDs);
const expectedSentIds = tree.leaves.map((leaf) => `${tree.id}-${leaf.rev}`);
for (const expectedId of expectedSentIds) {
if (!sentIds.has(expectedId))
throw new Error(`The Journal checkpoint does not record sent revision ${expectedId}.`);
}
}
function assertIndependentIdShape(tree: ConflictTreeSnapshot): void {
if (!/^f:[0-9a-f]{64}$/u.test(tree.id)) {
throw new Error(`The encrypted case did not use an independent Metadata ID: ${tree.id}`);
}
for (const revision of [...tree.leaves, ...(tree.ancestor ? [tree.ancestor] : [])]) {
if (revision.children.some((id) => !/^h:\+[0-9a-f]{64}$/u.test(id))) {
throw new Error("The encrypted case did not use independent Chunk IDs.");
}
}
}
async function waitForJournalObjects(
storage: ObjectStorageConfig,
prefix: string,
operationLabel: string
): Promise<string[]> {
const milestoneKey = `${prefix}_00000000-milestone.json`;
const deadline = Date.now() + objectTimeoutMs;
let keys: string[] = [];
while (Date.now() < deadline) {
keys = (await listObjectStorageObjects(storage, prefix)).flatMap((object) => (object.Key ? [object.Key] : []));
if (keys.includes(milestoneKey) && keys.some((key) => key.endsWith("-docs.jsonl.gz"))) return keys;
await new Promise((resolve) => setTimeout(resolve, 300));
}
throw new Error(
`${operationLabel} did not create both the Journal milestone and document objects: ${keys.join(", ")}`
);
}
async function postponeConflictIfOpen(session: ObsidianLiveSyncSession): Promise<void> {
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const dialogue = page.locator(".modal-container:visible").filter({ hasText: "Conflicting changes" });
if ((await dialogue.count()) === 0) return;
const notNow = dialogue.getByRole("button", { name: "Not now", exact: true });
if ((await notNow.count()) > 0) await notNow.click({ timeout: uiTimeoutMs });
});
}
async function rebuildRemote(cliBinary: string, env: NodeJS.ProcessEnv, port: number): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(()=>{",
"const rebuilder=app.plugins.plugins['obsidian-livesync'].core.rebuilder;",
"const state={done:false,error:null};",
"globalThis.__journalNfcRemoteRebuild=state;",
"void Promise.resolve().then(()=>rebuilder.$rebuildRemote()).then(()=>{state.done=true;},error=>{state.error=String(error?.stack??error);});",
"return JSON.stringify(true);",
"})()",
].join(""),
env,
timeoutMs
);
await withObsidianPage(port, async (page) => {
const title = "All optional features are disabled";
const deadline = Date.now() + timeoutMs;
let acknowledged = false;
while (Date.now() < deadline) {
const state = await evalObsidianJson<{ done: boolean; error: string | null }>(
cliBinary,
"JSON.stringify(globalThis.__journalNfcRemoteRebuild)",
env,
timeoutMs
);
if (state.error) throw new Error(state.error);
if (state.done) {
if (!acknowledged) {
throw new Error(`Remote-only Rebuild finished without showing the '${title}' dialogue.`);
}
return;
}
if (!acknowledged) {
for (const candidate of page.context().pages()) {
const dialogue = candidate
.locator(".modal-container")
.filter({ has: candidate.locator(".modal-title").filter({ hasText: title }) })
.last();
if (!(await dialogue.isVisible().catch(() => false))) continue;
await dialogue.getByRole("button", { name: "OK", exact: true }).click({ timeout: uiTimeoutMs });
await dialogue.waitFor({ state: "hidden", timeout: uiTimeoutMs });
acknowledged = true;
break;
}
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
const state = await evalObsidianJson<{ done: boolean; error: string | null }>(
cliBinary,
"JSON.stringify(globalThis.__journalNfcRemoteRebuild)",
env,
timeoutMs
);
if (state.error) throw new Error(state.error);
throw new Error("Timed out waiting for remote-only Rebuild and its optional-features dialogue.");
});
}
async function fetchIntoFreshVault(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const existing=await core.localDatabase.getDBEntry(path,undefined,false,true,true).catch(()=>false);",
"if(existing) throw new Error('The fresh receiving database already contains the conflict Metadata.');",
"await core.rebuilder.$fetchLocal(false,true);",
"return JSON.stringify(true);",
"})()",
].join(""),
env,
timeoutMs
);
}
async function runCase(
binary: string,
cliBinary: string,
objectStorage: ObjectStorageConfig,
scenario: ScenarioCase
): Promise<void> {
const bucketPrefix = makeUniqueBucketPrefix(`journal-nfc-resend-${scenario.name}`);
const storage = { ...objectStorage, bucketPrefix };
const overrides = await makeScenarioSettings(scenario);
const sourceVault = await createTemporaryVault(`obsidian-livesync-journal-nfc-source-${scenario.name}-`);
let receiverVault: TemporaryVault | undefined;
let sourceSession: ObsidianLiveSyncSession | undefined;
let receiverSession: ObsidianLiveSyncSession | undefined;
try {
receiverVault = await createTemporaryVault(`obsidian-livesync-journal-nfc-receiver-${scenario.name}-`);
const sourcePort = await allocatePort(new Set());
const receiverPort = await allocatePort(new Set([sourcePort]));
assertEqual(sourcePort === receiverPort, false, "Source and receiver Obsidian debugging ports must differ.");
console.log(`Journal NFC resend case: ${scenario.name}`);
console.log(`Temporary source and receiver Vaults: ${sourceVault.name}, ${receiverVault.name}`);
console.log(`Temporary Object Storage prefix: ${bucketPrefix}`);
sourceSession = await startSession(binary, cliBinary, sourceVault, sourcePort, storage, overrides, true);
await prepareRemote(cliBinary, sourceSession.cliEnv);
await createVaultNote(cliBinary, sourceSession.cliEnv, conflictPathNfd, baseContent);
await createVaultNote(cliBinary, sourceSession.cliEnv, controlPathNfd, controlContent);
await assertSinglePhysicalUnicodeFile(sourceVault.path, controlPathNfd, controlContent);
const initialEntry: LocalDatabaseEntry = await waitForLocalDatabaseEntry(
cliBinary,
sourceSession.cliEnv,
conflictPathNfc,
{
timeoutMs,
}
);
if (!initialEntry.rev || initialEntry.children.length === 0) {
throw new Error("The source baseline was not saved as a Chunk-backed Metadata revision.");
}
await storeSourceLeaf(cliBinary, sourceSession.cliEnv, conflictPathNfc, sourceLeafContent, initialEntry.rev);
await storeCompetingLeaf(
cliBinary,
sourceSession.cliEnv,
conflictPathNfc,
competingLeafContent,
initialEntry.rev
);
const sourceTree = await waitForConflictTree(
cliBinary,
sourceSession.cliEnv,
conflictPathNfc,
initialEntry.rev,
[sourceLeafContent, competingLeafContent]
);
if (!sourceTree.ancestor || sourceTree.ancestor.rev !== initialEntry.rev) {
throw new Error(
"The two source leaves did not retain the exact available baseline as their nearest common ancestor."
);
}
assertEqual(
sourceTree.ancestor.body,
baseContent,
"The source conflict ancestor did not preserve the exact seeded baseline body."
);
await postponeConflictIfOpen(sourceSession);
if (scenario.independentIds) {
assertIndependentIdShape(sourceTree);
} else {
assertEqual(
sourceTree.id,
conflictPathNfc.toLowerCase(),
"The plain case did not derive its Metadata ID from the NFC path."
);
}
await pushLocalChanges(cliBinary, sourceSession.cliEnv);
const initialKeys = await waitForJournalObjects(objectStorage, bucketPrefix, "Initial Journal upload");
const initialCheckpoint = await readTransferCheckpoint(cliBinary, sourceSession.cliEnv);
assertCheckpointPopulated(initialCheckpoint, sourceTree);
await readObjectStorageJson<Record<string, unknown>>(objectStorage, `${bucketPrefix}_00000000-milestone.json`);
await postponeConflictIfOpen(sourceSession);
// Removing this fixture prefix leaves the source PouchDB tree and its device-local transfer checkpoint intact.
await deleteObjectStoragePrefix(objectStorage, bucketPrefix);
assertEqual(
(await listObjectStorageObjects(objectStorage, bucketPrefix)).length,
0,
"The test's isolated Object Storage prefix was not empty after deletion."
);
assertEqual(
JSON.stringify(await readTransferCheckpoint(cliBinary, sourceSession.cliEnv)),
JSON.stringify(initialCheckpoint),
"Deleting the remote fixture prefix changed the source transfer checkpoint."
);
assertSameConflictTree(
await readConflictTree(cliBinary, sourceSession.cliEnv, conflictPathNfc, initialEntry.rev),
sourceTree,
"Source before remote-only Rebuild"
);
await rebuildRemote(cliBinary, sourceSession.cliEnv, sourceSession.remoteDebuggingPort);
const rebuiltKeys = await waitForJournalObjects(objectStorage, bucketPrefix, "Remote-only Rebuild");
await postponeConflictIfOpen(sourceSession);
assertSameConflictTree(
await readConflictTree(cliBinary, sourceSession.cliEnv, conflictPathNfc, initialEntry.rev),
sourceTree,
"Source after remote-only Rebuild"
);
if (scenario.independentIds) assertIndependentIdShape(sourceTree);
await sourceSession.app.stop();
sourceSession = undefined;
receiverSession = await startSession(binary, cliBinary, receiverVault, receiverPort, storage, overrides, true);
await fetchIntoFreshVault(cliBinary, receiverSession.cliEnv, conflictPathNfc);
await waitForLiveSyncCoreReady(cliBinary, receiverSession.cliEnv, timeoutMs);
await postponeConflictIfOpen(receiverSession);
const receivedTree = await waitForConflictTree(
cliBinary,
receiverSession.cliEnv,
conflictPathNfc,
initialEntry.rev,
[sourceLeafContent, competingLeafContent],
false
);
await postponeConflictIfOpen(receiverSession);
assertSameConflictTree(receivedTree, sourceTree, "Fresh receiver after Standard Fetch");
await waitForSinglePhysicalUnicodeFile(receiverVault.path, controlPathNfd, controlContent);
await receiverSession.app.stop();
receiverSession = undefined;
const restartPort = await allocatePort(new Set([sourcePort, receiverPort]));
receiverSession = await startSession(binary, cliBinary, receiverVault, restartPort, storage, overrides, false);
await waitForLiveSyncCoreReady(cliBinary, receiverSession.cliEnv, timeoutMs);
await postponeConflictIfOpen(receiverSession);
const restartedTree = await waitForConflictTree(
cliBinary,
receiverSession.cliEnv,
conflictPathNfc,
initialEntry.rev,
[sourceLeafContent, competingLeafContent],
false
);
await postponeConflictIfOpen(receiverSession);
assertSameConflictTree(restartedTree, sourceTree, "Receiver after full Obsidian restart");
assertEqual(
restartedTree.ancestorBodyAvailable,
receivedTree.ancestorBodyAvailable,
"Restart changed the common ancestor body's availability."
);
await waitForSinglePhysicalUnicodeFile(receiverVault.path, controlPathNfd, controlContent);
const initialJournalObjects = initialKeys.filter((key) => key.endsWith("-docs.jsonl.gz")).length;
const rebuiltJournalObjects = rebuiltKeys.filter((key) => key.endsWith("-docs.jsonl.gz")).length;
console.log(
`Journal NFC resend ${scenario.name}: source checkpoint survived external prefix deletion, ${rebuiltJournalObjects} rebuilt Journal object(s), exact conflict leaves and ancestry fetched, and restart verified (initial objects: ${initialJournalObjects}; receiver ancestor body available: ${receivedTree.ancestorBodyAvailable}).`
);
console.log(
"The history assertion covers this deliberately uncompacted fixture and does not claim arbitrary historical body availability."
);
} finally {
if (receiverSession) {
await receiverSession.app.stop().catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
if (sourceSession) {
await sourceSession.app.stop().catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
await sourceVault.dispose().catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
if (receiverVault) {
await receiverVault.dispose().catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
await deleteObjectStoragePrefix(objectStorage, bucketPrefix).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
}
async function main(): Promise<void> {
const scenarios = selectedCases(process.argv.slice(2));
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
const objectStorage = await loadObjectStorageConfig();
await ensureObjectStorageBucket(objectStorage);
for (const scenario of scenarios) await runCase(binary, cli.binary, objectStorage, scenario);
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
+159 -39
View File
@@ -2,7 +2,7 @@ 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 { withObsidianPage } from "../runner/ui.ts";
import { requireObsidianBinary, requireObsidianCli } from "../runner/environment.ts";
import {
createE2eCouchDbPluginData,
@@ -25,25 +25,35 @@ type ShutdownObservation = {
disposeCalls: number;
disposeCompleted: boolean;
fixtureLogs: FixtureLog[];
consoleLogs: {
marker: string;
messageType: string;
messageText: string;
level?: number;
key?: string;
}[];
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
);
async function evaluate<T>(session: ObsidianLiveSyncSession, body: string): Promise<T> {
// Observe the renderer console directly; CLI eval captures console output
// as part of its response and changes the logging surface under test.
return await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const result = await page.evaluate<string>(
`(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}
})()`
);
return JSON.parse(result) as T;
});
}
function createPluginData(dbName: string): Record<string, unknown> {
@@ -95,7 +105,9 @@ async function startSession(
function createShutdownScript(options: {
duringMarker: string;
failureMarker: string;
afterMarker: string;
reloadMarker: string;
oldReloadMarker: string;
injectFailure: boolean;
urgentLevel: number;
}): string {
@@ -118,6 +130,8 @@ function createShutdownScript(options: {
api,
module,
fixtureLogs:[],
consoleLogs:[],
uiAddLogCalls:0,
events:[],
closeStarted:false,
closeCalls:0,
@@ -129,7 +143,39 @@ function createShutdownScript(options: {
app.__livesyncLogLifecycleState=state;
const originalAddLog=module.__addLog;
const originalConsoleLog=console.log;
const consoleMarkers=[
fixtures.duringMarker,
fixtures.failureMarker,
fixtures.afterMarker,
fixtures.reloadMarker,
fixtures.oldReloadMarker
].filter(Boolean);
console.log=function(...args) {
try {
const entry=args[0];
if(entry && typeof entry==="object" && "message" in entry) {
const message=entry.message;
const messageText=typeof message==="string"
? message
: message instanceof Error ? message.message
: message && typeof message==="object" &&
typeof message.__e2eLogLifecycleMarker==="string"
? message.__e2eLogLifecycleMarker
: "";
const marker=consoleMarkers.find(candidate=>messageText.includes(candidate));
if(marker) state.consoleLogs.push({
marker,
message,
level:entry.level,
key:entry.key
});
}
} catch {}
return originalConsoleLog.apply(this,args);
};
module.__addLog=function(message,level,key) {
state.uiAddLogCalls++;
if(typeof message==="string" && [
fixtures.duringMarker,
fixtures.failureMarker,
@@ -179,6 +225,7 @@ function createShutdownScript(options: {
module.__addLog=originalAddLog;
resource.dispose=originalDispose;
database.close=originalClose;
console.log=originalConsoleLog;
};
const run=async()=>{
@@ -203,6 +250,10 @@ function createShutdownScript(options: {
state.events.indexOf("close-finished")<state.events.indexOf("dispose-start"),
"The log resource was disposed before the local database close completed");
await originalDispose.call(resource);
require(state.disposeCompleted && state.disposeCalls===1,
"Repeated log resource disposal was not idempotent");
const failedLog=fixtures.failureMarker
? state.fixtureLogs.find(entry=>entry.message.includes(fixtures.failureMarker))
: undefined;
@@ -219,13 +270,41 @@ function createShutdownScript(options: {
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");
const afterMessage=new Error(fixtures.afterMarker);
const afterKey="e2e-log-lifecycle-after-disposal";
const afterUnknownMessage={__e2eLogLifecycleMarker:fixtures.afterMarker};
const afterUnknownKey="e2e-log-lifecycle-after-disposal-unknown";
const uiCallsBeforeAfterLog=state.uiAddLogCalls;
api.addLog(afterMessage,fixtures.urgentLevel,afterKey);
api.addLog(afterUnknownMessage,fixtures.urgentLevel,afterUnknownKey);
await waitFor(
()=>state.consoleLogs.some(entry=>entry.marker===fixtures.afterMarker &&
entry.message===afterMessage) &&
state.consoleLogs.some(entry=>entry.marker===fixtures.afterMarker &&
entry.message===afterUnknownMessage),
"The old API did not forward its post-disposal messages to console.log"
);
const afterErrorLogs=state.consoleLogs.filter(entry=>entry.marker===fixtures.afterMarker &&
entry.message===afterMessage);
const afterUnknownLogs=state.consoleLogs.filter(entry=>entry.marker===fixtures.afterMarker &&
entry.message===afterUnknownMessage);
require(afterErrorLogs.length===1,"The post-disposal Error was not logged exactly once");
require(afterUnknownLogs.length===1,"The post-disposal unknown message was not logged exactly once");
require(
afterErrorLogs[0].message===afterMessage && afterErrorLogs[0].message instanceof Error &&
afterErrorLogs[0].message.message===fixtures.afterMarker,
"The post-disposal Error was not preserved in the console log"
);
require(afterUnknownLogs[0].message===afterUnknownMessage,
"The post-disposal unknown message was not preserved in the console log");
require(
afterErrorLogs[0].level===fixtures.urgentLevel && afterErrorLogs[0].key===afterKey &&
afterUnknownLogs[0].level===fixtures.urgentLevel &&
afterUnknownLogs[0].key===afterUnknownKey,
"The post-disposal console logs changed their level or key"
);
require(state.uiAddLogCalls===uiCallsBeforeAfterLog,
"The disposed ModuleLog UI handler received a post-disposal log");
await state.disablePromise;
require(!state.disableError,"Disabling the plug-in failed: "+state.disableError);
return JSON.stringify({
@@ -233,6 +312,19 @@ function createShutdownScript(options: {
disposeCalls:state.disposeCalls,
disposeCompleted:state.disposeCompleted,
fixtureLogs:state.fixtureLogs,
consoleLogs:state.consoleLogs.map(entry=>({
marker:entry.marker,
messageType:entry.message instanceof Error ? "Error" : typeof entry.message,
messageText:entry.message instanceof Error
? entry.message.message
: typeof entry.message==="string" ? entry.message
: entry.message && typeof entry.message==="object" &&
typeof entry.message.__e2eLogLifecycleMarker==="string"
? entry.message.__e2eLogLifecycleMarker
: "",
level:entry.level,
key:entry.key
})),
events:state.events
});
};
@@ -260,10 +352,14 @@ async function runCase(options: {
let session: ObsidianLiveSyncSession | undefined;
const markers = {
duringMarker: `log-lifecycle-${options.caseName}-during-${randomUUID()}`,
afterMarker: "log-lifecycle-" + options.caseName + "-after-disposal-" + randomUUID(),
failureMarker: options.injectFailure
? `log-lifecycle-${options.caseName}-injected-close-failure-${randomUUID()}`
: "",
reloadMarker: options.injectFailure ? "" : `log-lifecycle-${options.caseName}-reload-${randomUUID()}`,
oldReloadMarker: options.injectFailure
? ""
: "log-lifecycle-" + options.caseName + "-old-api-after-reload-" + randomUUID(),
injectFailure: options.injectFailure,
urgentLevel: LOG_LEVEL_URGENT,
};
@@ -271,19 +367,17 @@ async function runCase(options: {
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));
const shutdown = await evaluate<ShutdownObservation>(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;
@@ -295,8 +389,12 @@ async function runCase(options: {
require(module,"The reloaded core did not contain ModuleLog");
const originalAddLog=module.__addLog;
const observed=[];
const oldApiKey="e2e-log-lifecycle-reloaded-old-api";
module.__addLog=function(message,level,key) {
if(typeof message==="string" && message.includes(${JSON.stringify(markers.reloadMarker)})) {
if(key===oldApiKey || (typeof message==="string" && [
${JSON.stringify(markers.reloadMarker)},
${JSON.stringify(markers.oldReloadMarker)}
].some(marker=>marker && message.includes(marker)))) {
observed.push({message,level,key});
}
return originalAddLog.apply(this,arguments);
@@ -309,16 +407,36 @@ async function runCase(options: {
!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");
const oldApiMarker=${JSON.stringify(markers.oldReloadMarker)};
const oldApiMessage=new Error(oldApiMarker);
const uiCallsBeforeOldApi=state.uiAddLogCalls;
const newModuleFixtureCallsBeforeOldApi=observed.length;
state.api.addLog(oldApiMessage,${JSON.stringify(markers.urgentLevel)},oldApiKey);
await waitFor(
()=>state.consoleLogs.some(entry=>entry.marker===oldApiMarker &&
entry.message===oldApiMessage),
"The old API did not forward its post-reload Error to console.log"
);
const oldApiLogs=state.consoleLogs.filter(entry=>entry.marker===oldApiMarker);
require(oldApiLogs.length===1,"The old API post-reload Error was not logged exactly once");
require(
oldApiLogs[0].message===oldApiMessage && oldApiLogs[0].message instanceof Error &&
oldApiLogs[0].message.message===oldApiMarker,
"The old API post-reload Error was not preserved in the console log"
);
require(
oldApiLogs[0].level===${JSON.stringify(markers.urgentLevel)} &&
oldApiLogs[0].key===oldApiKey,
"The old API post-reload console log changed its level or key"
);
require(state.uiAddLogCalls===uiCallsBeforeOldApi,
"The old ModuleLog UI handler received the old API's post-reload log");
require(observed.length===newModuleFixtureCallsBeforeOldApi,
"The new ModuleLog received the old API's post-reload log");
return JSON.stringify({
coreChanged:core!==state.core,
observedByNewModule:observed.length,
oldApiConsoleLogs:oldApiLogs.length,
observedByOldModule:state.fixtureLogs.filter(
entry=>entry.message.includes(${JSON.stringify(markers.reloadMarker)})
).length
@@ -335,13 +453,15 @@ async function runCase(options: {
if (session) {
try {
await evaluate(
options.cliBinary,
session,
`
const state=app.__livesyncLogLifecycleState;
state?.restore?.();
delete app.__livesyncLogLifecycleState;
return JSON.stringify({cleaned:true});
try {
return JSON.stringify({cleaned:true});
} finally {
state?.restore?.();
delete app.__livesyncLogLifecycleState;
}
`
);
} catch {}
+153 -8
View File
@@ -16,8 +16,15 @@
* same upload.
*/
import { randomBytes } from "node:crypto";
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, readFile, readdir, rm, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { promisify } from "node:util";
import { DEFAULT_SETTINGS, TweakValuesTemplate } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { deriveIdKey } from "@vrtmrz/livesync-commonlib/settings";
import { evalObsidianJson } from "../runner/cli.ts";
import { runCliJournalMigration } from "../runner/cliJournalMigration.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
@@ -43,7 +50,8 @@ import { REMOTE_ACTIVITY_EXPECTED_STATE, waitForRemoteActivityState } from "../r
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
const notePath = "E2E/minio-upload.md";
const unicodePaths = process.env.E2E_OBSIDIAN_UNICODE_PATHS === "true";
const notePath = unicodePaths ? "Cafe\u0301/re\u0301sume\u0301.md" : "E2E/minio-upload.md";
const useIndependentIds = process.env.E2E_OBSIDIAN_INDEPENDENT_IDS === "true";
const useCustomRequestHandler = process.env.E2E_OBSIDIAN_CUSTOM_HTTP_HANDLER === "true";
const noteContent = [
@@ -63,10 +71,11 @@ async function createNoteAndWaitForLocalDb(cliBinary: string, env: NodeJS.Proces
`const path=${JSON.stringify(notePath)};`,
`const content=${JSON.stringify(noteContent)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"if(!(await app.vault.adapter.exists('E2E'))) await app.vault.createFolder('E2E');",
"const parent=path.slice(0,path.lastIndexOf('/'));",
"if(!(await app.vault.adapter.exists(parent))) await app.vault.createFolder(parent);",
"const existing=app.vault.getAbstractFileByPath(path);",
"if(existing) await app.vault.delete(existing);",
"await app.vault.create(path,content);",
"const createdFile=await app.vault.create(path,content);",
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
"let entry=false;",
"for(let i=0;i<40;i++){",
@@ -76,7 +85,7 @@ async function createNoteAndWaitForLocalDb(cliBinary: string, env: NodeJS.Proces
"await sleep(250);",
"}",
"if(!entry||!entry._id) throw new Error('Timed out waiting for local database entry');",
"return JSON.stringify({id:entry._id,path:entry.path,type:entry.type,children:entry.children||[]});",
"return JSON.stringify({id:entry._id,path:createdFile.path,type:entry.type,children:entry.children||[]});",
"})()",
].join(""),
env
@@ -124,10 +133,13 @@ async function main(): Promise<void> {
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: createE2eObjectStoragePluginData({
...objectStorage,
bucketPrefix,
}),
pluginData: createE2eObjectStoragePluginData(
{
...objectStorage,
bucketPrefix,
},
{ pathNormalizationVersion: unicodePaths ? 1 : 0 }
),
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
@@ -137,6 +149,7 @@ async function main(): Promise<void> {
session.cliEnv,
{ ...objectStorage, bucketPrefix },
{
pathNormalizationVersion: unicodePaths ? 1 : 0,
...(useIndependentIds
? {
encrypt: true,
@@ -173,6 +186,13 @@ async function main(): Promise<void> {
);
}
}
if (unicodePaths && !useIndependentIds) {
assertEqual(
localEntry.id,
notePath.normalize("NFC").toLowerCase(),
"Obsidian did not derive the canonical Metadata ID."
);
}
await pushLocalChanges(cli.binary, session.cliEnv);
const activityAfterUpload = await waitForRemoteActivityState(
session.remoteDebuggingPort,
@@ -198,6 +218,131 @@ async function main(): Promise<void> {
}
}
if (unicodePaths) {
const cliRoot = await mkdtemp(join(tmpdir(), "livesync-nfc-journal-"));
try {
const databasePath = join(cliRoot, "db");
const vaultPath = join(cliRoot, "vault");
const settingsPath = join(cliRoot, "settings.json");
await mkdir(databasePath, { recursive: true });
await mkdir(dirname(join(vaultPath, notePath)), { recursive: true });
await writeFile(join(vaultPath, notePath), noteContent);
const sourceSettings = await evalObsidianJson<Record<string, unknown>>(
cli.binary,
[
"(async()=>{",
"const service=app.plugins.plugins['obsidian-livesync'].core.services.setting;",
"const s=service.currentSettings();",
`const keys=${JSON.stringify([...Object.keys(TweakValuesTemplate), "encrypt", "passphrase", "usePathObfuscation", "E2EEAlgorithm", "idDerivationKey"])};`,
"const values=Object.fromEntries(keys.map(key=>[key,s[key]]));",
"if(s.idDerivationVersion===1){",
"values.encryptedIdDerivationKey=await service.encryptConfigurationItem(s.idDerivationKey,s,'*');",
"values.idDerivationKey='';values.configPassphraseStore='';",
"}",
"return JSON.stringify(values);",
"})()",
].join(""),
session.cliEnv
);
await runCliJournalMigration(objectStorage, bucketPrefix, sourceSettings);
await writeFile(
settingsPath,
JSON.stringify({
...DEFAULT_SETTINGS,
...objectStorage,
...sourceSettings,
remoteType: "MINIO",
bucketPrefix,
pathNormalizationVersion: 1,
isConfigured: true,
})
);
const execute = promisify(execFile);
const run = async (...args: string[]) =>
await execute(
process.execPath,
[
resolve("src/apps/cli/dist/index.cjs"),
databasePath,
"--vault",
vaultPath,
"--settings",
settingsPath,
...args,
],
{ timeout: 60_000, maxBuffer: 4 * 1024 * 1024 }
);
await run("--force", "--write-settings", "fetch");
assertEqual(
await readFile(join(vaultPath, notePath), "utf8"),
noteContent,
"CLI Fetch changed the file contents."
);
assertEqual(
(await run("cat", notePath.normalize("NFC"))).stdout,
noteContent,
"CLI Fetch did not retain the remote Chunk content."
);
const listedPaths = (await run("ls")).stdout
.trim()
.split("\n")
.map((line) => line.split("\t")[0]);
assertEqual(listedPaths.length, 1, "CLI Fetch retained duplicate Metadata.");
assertEqual(
listedPaths[0].normalize("NFC"),
notePath.normalize("NFC"),
"CLI Fetch changed the logical path."
);
const fileInfo = JSON.parse((await run("info", listedPaths[0])).stdout) as { id: string };
assertEqual(fileInfo.id, localEntry.id, "CLI Fetch used a different Metadata ID.");
const expectedNames = await readdir(dirname(join(vaultPath, notePath)));
assertEqual(expectedNames.length, 1, "CLI Fetch created a duplicate physical file.");
assertEqual(
expectedNames[0],
notePath.split("/").slice(-1)[0],
"CLI Fetch renamed the NFD physical file."
);
const editedContent = noteContent + "\nEdited after CLI restart.\n";
await writeFile(join(vaultPath, notePath), editedContent);
const nextTime = new Date(Date.now() + 5000);
await utimes(join(vaultPath, notePath), nextTime, nextTime);
await run("mirror");
await run("sync");
await pushLocalChanges(cli.binary, session.cliEnv);
const sourceFile = join(vault.path, localEntry.path);
const deadline = Date.now() + 20_000;
while ((await readFile(sourceFile, "utf8")) !== editedContent && Date.now() < deadline) {
await new Promise((resolveWait) => setTimeout(resolveWait, 200));
}
assertEqual(
await readFile(sourceFile, "utf8"),
editedContent,
"Obsidian did not receive the CLI edit."
);
await run("rm", notePath.normalize("NFC"));
await run("sync");
await pushLocalChanges(cli.binary, session.cliEnv);
const deleteDeadline = Date.now() + 20_000;
let exists = true;
while (exists && Date.now() < deleteDeadline) {
exists = await readFile(sourceFile).then(
() => true,
(error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return false;
throw error;
}
);
if (exists) await new Promise((resolveWait) => setTimeout(resolveWait, 200));
}
assertEqual(exists, false, "Obsidian did not reflect the CLI deletion.");
console.log(
"NFC_JOURNAL_ROUND_TRIP: canonical ID, NFD physical name, CLI Fetch, edit after process restart, and deletion verified."
);
} finally {
await rm(cliRoot, { recursive: true, force: true });
}
}
console.log(
`Uploaded ${localEntry.path} through Journal Sync to ${objectStorage.bucket}/${bucketPrefix} (${keys.length} object(s)); tracked requests: ${activityAfterUpload.requestCount - activityBeforeUpload.requestCount}`
);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,102 @@
import { spawn } from "node:child_process";
import { mkdtemp, realpath, rm, stat } from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { randomBytes } from "node:crypto";
import { tmpdir } from "node:os";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
type CommandResult = { code: number; signal: NodeJS.Signals | null };
function run(command: string, args: readonly string[], env: NodeJS.ProcessEnv = process.env): Promise<CommandResult> {
return new Promise((resolveResult, reject) => {
const child = spawn(command, [...args], { cwd: process.cwd(), env, stdio: "inherit" });
child.once("error", reject);
child.once("close", (code, signal) => resolveResult({ code: code ?? 1, signal }));
});
}
function pathInside(root: string, candidate: string): string {
const localPath = relative(root, candidate);
if (localPath === ".." || localPath.startsWith(".." + sep) || isAbsolute(localPath)) {
throw new Error(
"The discovered Obsidian executable paths must share one extracted application directory. " +
"Set E2E_P2P_NFC_OBSIDIAN_DIR to their common root."
);
}
return localPath.split(sep).join("/");
}
async function main(): Promise<void> {
const repoRoot = resolve(process.cwd());
const binary = await realpath(requireObsidianBinary());
const cli = discoverObsidianCli();
if (!cli.binary) {
throw new Error("Could not find obsidian-cli. Checked paths: " + cli.checked.join(", "));
}
const cliBinary = await realpath(cli.binary);
const appDirectory = await realpath(resolve(process.env.E2E_P2P_NFC_OBSIDIAN_DIR?.trim() || dirname(binary)));
if (!(await stat(appDirectory)).isDirectory()) {
throw new Error("The selected Obsidian application root is not a directory: " + appDirectory);
}
const containerBinary = "/opt/obsidian/" + pathInside(appDirectory, binary);
const containerCli = "/opt/obsidian/" + pathInside(appDirectory, cliBinary);
const build = await run(process.platform === "win32" ? "npm.cmd" : "npm", ["run", "build"]);
if (build.code !== 0) {
throw new Error("The host plug-in build failed with exit code " + build.code + ".");
}
const artifactsDirectory = await mkdtemp(join(tmpdir(), "livesync-e2e-p2p-nfc-"));
const projectName = "livesync-p2p-nfc-" + process.pid + "-" + Date.now() + "-" + randomBytes(4).toString("hex");
const composeFile = join(repoRoot, "test/e2e-obsidian/compose.p2p.yml");
const composeArgs = ["compose", "--project-name", projectName, "--file", composeFile];
const composeEnvironment: NodeJS.ProcessEnv = {
...process.env,
E2E_P2P_NFC_OBSIDIAN_DIR: appDirectory,
E2E_P2P_NFC_OBSIDIAN_BINARY: containerBinary,
E2E_P2P_NFC_OBSIDIAN_CLI: containerCli,
E2E_P2P_NFC_ARTIFACT_DIR: artifactsDirectory,
};
let exitCode = 0;
let failure: unknown;
try {
const result = await run(
"docker",
[...composeArgs, "run", "--rm", "--build", "p2p-nfc", ...process.argv.slice(2)],
composeEnvironment
);
exitCode = result.code;
if (result.signal) exitCode = 1;
} catch (error) {
failure = error;
exitCode = 1;
} finally {
try {
const teardown = await run(
"docker",
[...composeArgs, "down", "--volumes", "--remove-orphans"],
composeEnvironment
);
if (teardown.code !== 0 && exitCode === 0) exitCode = teardown.code;
} catch (error) {
if (exitCode === 0) {
failure = error;
exitCode = 1;
}
}
if (exitCode === 0 && process.env.E2E_P2P_NFC_KEEP_ARTIFACTS !== "1") {
await rm(artifactsDirectory, { recursive: true, force: true });
} else {
console.log("P2P NFC diagnostics were retained at " + artifactsDirectory);
}
}
if (failure) throw failure;
if (exitCode !== 0) process.exitCode = exitCode;
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exitCode = 1;
});
+1
View File
@@ -30,6 +30,7 @@ const focusedScenarios = new Set([
"couchdb-manual-setup-workflow",
"cli-to-obsidian-sync",
"minio-upload",
"journal-nfc-resend",
"object-storage-setup-uri-workflow",
"object-storage-compatible-setup-uri-workflow",
"object-storage-qr-workflow",