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

@@ -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",