mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-06 08:18:18 +00:00
Merge main into CouchDB cache compatibility changes
This commit is contained in:
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);
|
||||
});
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user