mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-21 18:17:05 +00:00
test: cover partial start-up file failures in Obsidian
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Proves that one file which cannot be reflected during an ordinary start-up
|
||||
* does not keep the entire configured application unready.
|
||||
*
|
||||
* The fixture relies on the conventional Linux 255-byte path component
|
||||
* limit. It stores one ordinary note and one note with a 258-byte component in
|
||||
* the local database, then restarts the same real Obsidian Vault and profile.
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
assertCouchDbReachable,
|
||||
createCouchDbDatabase,
|
||||
deleteCouchDbDatabase,
|
||||
loadCouchDbConfig,
|
||||
makeUniqueDatabaseName,
|
||||
} from "../runner/couchdb.ts";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import {
|
||||
assertEqual,
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObsidianDeviceLocalState,
|
||||
prepareRemote,
|
||||
waitForLiveSyncCoreReady,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
|
||||
|
||||
const validPath = "E2E/partial-startup-valid.md";
|
||||
const oversizedComponent = `${"界".repeat(85)}.md`;
|
||||
const failedPath = `E2E/${oversizedComponent}`;
|
||||
const validContent = `# Partial start-up\n\n${"V".repeat(4096)}\n`;
|
||||
const failedContent = `# Retry this file\n\n${"R".repeat(4096)}\n`;
|
||||
const partialFailureNotice =
|
||||
"Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.";
|
||||
const failedPathLog =
|
||||
`Offline scan failed to synchronise ${failedPath} between storage and the local database; ` +
|
||||
"this path remains eligible for a later scan.";
|
||||
const assertionTimeoutMs = Number(process.env.E2E_OBSIDIAN_CORE_READY_TIMEOUT_MS ?? 20000);
|
||||
|
||||
type SeededEntry = {
|
||||
id: string;
|
||||
path: string;
|
||||
revision: string;
|
||||
children: string[];
|
||||
};
|
||||
|
||||
type FailedPathState = {
|
||||
appReady: boolean;
|
||||
databaseReady: boolean;
|
||||
fileExists: boolean;
|
||||
entryReadable: boolean;
|
||||
metadataRevision?: string;
|
||||
provenance: { revision: string; observedStorageMtime?: number } | null;
|
||||
logText: string;
|
||||
};
|
||||
|
||||
type RetryState = Omit<FailedPathState, "databaseReady" | "logText"> & {
|
||||
scanResult: string | false;
|
||||
};
|
||||
|
||||
async function seedDatabaseOnlyEntries(cliBinary: string, env: NodeJS.ProcessEnv): Promise<SeededEntry[]> {
|
||||
return await evalObsidianJson<SeededEntry[]>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const fixtures=${JSON.stringify([
|
||||
{ path: validPath, content: validContent },
|
||||
{ path: failedPath, content: failedContent },
|
||||
])};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const seeded=[];",
|
||||
"for(const {path,content} of fixtures){",
|
||||
" if(app.vault.getAbstractFileByPath(path)!==null){",
|
||||
" throw new Error(`Database-only fixture already exists in the Vault: ${path}`);",
|
||||
" }",
|
||||
" const blob=new Blob([content],{type:'text/plain'});",
|
||||
" const id=await core.services.path.path2id(path);",
|
||||
" const now=Date.now();",
|
||||
" const result=await core.localDatabase.putDBEntry({",
|
||||
" _id:id,path,data:blob,ctime:now,mtime:now,",
|
||||
" size:(await blob.arrayBuffer()).byteLength,children:[],",
|
||||
" datatype:'plain',type:'plain',eden:{},",
|
||||
" });",
|
||||
" if(!result?.ok) throw new Error(`Could not seed database-only fixture: ${path}`);",
|
||||
" const metadata=await core.localDatabase.getDBEntryMeta(path,undefined,true);",
|
||||
" if(!metadata) throw new Error(`Could not reload seeded Metadata: ${path}`);",
|
||||
" seeded.push({id,path,revision:result.rev,children:metadata.children??[]});",
|
||||
"}",
|
||||
"return JSON.stringify(seeded);",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function observePartialFailureNotice(remoteDebuggingPort: number): Promise<void> {
|
||||
await withObsidianPage(remoteDebuggingPort, async (page) => {
|
||||
await page
|
||||
.locator(".notice")
|
||||
.filter({ hasText: partialFailureNotice })
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: assertionTimeoutMs });
|
||||
});
|
||||
}
|
||||
|
||||
async function inspectFailedPathState(cliBinary: string, env: NodeJS.ProcessEnv): Promise<FailedPathState> {
|
||||
return await evalObsidianJson<FailedPathState>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(failedPath)};`,
|
||||
`const expectedLog=${JSON.stringify(failedPathLog)};`,
|
||||
`const timeoutMs=${JSON.stringify(assertionTimeoutMs)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const metadata=await core.localDatabase.getDBEntryMeta(path,undefined,true);",
|
||||
"const entry=await core.localDatabase.getDBEntry(path,undefined,false,true,true);",
|
||||
"const provenanceStore=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');",
|
||||
"const provenance=(await provenanceStore.get(path))??null;",
|
||||
"await core.services.API.showWindow('log-log');",
|
||||
"const deadline=Date.now()+timeoutMs;",
|
||||
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
|
||||
"let logText='';",
|
||||
"while(Date.now()<deadline){",
|
||||
" logText=Array.from(document.querySelectorAll('.logpane .log pre'))",
|
||||
" .map((element)=>element.textContent??'').join('\\n');",
|
||||
" if(logText.includes(expectedLog)) break;",
|
||||
" await sleep(100);",
|
||||
"}",
|
||||
"for(const leaf of app.workspace.getLeavesOfType('log-log')) leaf.detach();",
|
||||
"return JSON.stringify({",
|
||||
" appReady:core.services.appLifecycle.isReady(),",
|
||||
" databaseReady:core.services.database.isDatabaseReady(),",
|
||||
" fileExists:app.vault.getAbstractFileByPath(path)!==null,",
|
||||
" entryReadable:entry!==false,",
|
||||
" metadataRevision:metadata?._rev,",
|
||||
" provenance,",
|
||||
" logText,",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function retryFailedPath(cliBinary: string, env: NodeJS.ProcessEnv): Promise<RetryState> {
|
||||
return await evalObsidianJson<RetryState>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(failedPath)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const scanResult=await core.services.vault.scanVault(false,false,true);",
|
||||
"const metadata=await core.localDatabase.getDBEntryMeta(path,undefined,true);",
|
||||
"const entry=await core.localDatabase.getDBEntry(path,undefined,false,true,true);",
|
||||
"const provenanceStore=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');",
|
||||
"return JSON.stringify({",
|
||||
" scanResult,",
|
||||
" appReady:core.services.appLifecycle.isReady(),",
|
||||
" fileExists:app.vault.getAbstractFileByPath(path)!==null,",
|
||||
" entryReadable:entry!==false,",
|
||||
" metadataRevision:metadata?._rev,",
|
||||
" provenance:(await provenanceStore.get(path))??null,",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (process.platform !== "linux") {
|
||||
throw new Error("The partial start-up file-failure scenario currently requires a Linux test Vault.");
|
||||
}
|
||||
assertEqual(
|
||||
Buffer.byteLength(oversizedComponent, "utf8"),
|
||||
258,
|
||||
"The failing path component no longer exercises the intended UTF-8 byte boundary."
|
||||
);
|
||||
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) {
|
||||
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
|
||||
}
|
||||
|
||||
const couchDb = await loadCouchDbConfig();
|
||||
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "partial-startup-file-failure");
|
||||
const couchDbSettings = {
|
||||
uri: couchDb.uri,
|
||||
username: couchDb.username,
|
||||
password: couchDb.password,
|
||||
dbName,
|
||||
};
|
||||
const vault = await createTemporaryVault("obsidian-livesync-partial-startup-");
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
|
||||
try {
|
||||
await assertCouchDbReachable(couchDb);
|
||||
await createCouchDbDatabase(couchDb, dbName);
|
||||
|
||||
console.log(`Using Obsidian executable: ${binary}`);
|
||||
console.log(`Temporary vault: ${vault.path}`);
|
||||
console.log(`Temporary CouchDB database: ${dbName}`);
|
||||
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary: cli.binary,
|
||||
vault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
pluginData: createE2eCouchDbPluginData(couchDbSettings, {
|
||||
showVerboseLog: true,
|
||||
lessInformationInLog: false,
|
||||
}),
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
await prepareRemote(cli.binary, session.cliEnv);
|
||||
|
||||
const seeded = await seedDatabaseOnlyEntries(cli.binary, session.cliEnv);
|
||||
const validSeed = seeded.find((entry) => entry.path === validPath);
|
||||
const failedSeed = seeded.find((entry) => entry.path === failedPath);
|
||||
if (!validSeed || !failedSeed) throw new Error("The database-only start-up fixtures were incomplete.");
|
||||
if (validSeed.children.length === 0 || failedSeed.children.length === 0) {
|
||||
throw new Error("The database-only fixtures did not create independently stored chunks.");
|
||||
}
|
||||
|
||||
await session.app.stop();
|
||||
session = undefined;
|
||||
|
||||
let partialNoticeObserved = false;
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary: cli.binary,
|
||||
vault,
|
||||
pluginStartup: "natural",
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
lifecycle: {
|
||||
afterPluginLoad: async ({ remoteDebuggingPort }) => {
|
||||
await observePartialFailureNotice(remoteDebuggingPort);
|
||||
partialNoticeObserved = true;
|
||||
},
|
||||
},
|
||||
});
|
||||
const readiness = await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
assertEqual(readiness.configured, true, "Self-hosted LiveSync lost its configuration on restart.");
|
||||
assertEqual(partialNoticeObserved, true, "The partial start-up failure Notice was not observed.");
|
||||
|
||||
assertEqual(
|
||||
await readFile(join(vault.path, validPath), "utf8"),
|
||||
validContent,
|
||||
"The valid database-only file was not reflected during the same start-up scan."
|
||||
);
|
||||
|
||||
const state = await inspectFailedPathState(cli.binary, session.cliEnv);
|
||||
assertEqual(state.databaseReady, true, "The local database did not remain ready after one file failed.");
|
||||
assertEqual(state.appReady, true, "One file failure kept the application unready.");
|
||||
assertEqual(state.fileExists, false, "The overlong path was unexpectedly reflected to the Linux Vault.");
|
||||
assertEqual(state.entryReadable, true, "The failed database entry was no longer readable.");
|
||||
assertEqual(state.metadataRevision, failedSeed.revision, "The failed database entry revision changed.");
|
||||
assertEqual(state.provenance, null, "A failed reflection was recorded as successful provenance.");
|
||||
assertEqual(
|
||||
state.logText.includes(failedPathLog),
|
||||
true,
|
||||
"The verbose log did not identify the path which failed during the start-up scan."
|
||||
);
|
||||
|
||||
const retry = await retryFailedPath(cli.binary, session.cliEnv);
|
||||
assertEqual(
|
||||
retry.scanResult,
|
||||
"completed-with-file-failures",
|
||||
"A later scan did not retry and report the same individual file failure."
|
||||
);
|
||||
assertEqual(retry.appReady, true, "Retrying the failed path cleared application readiness.");
|
||||
assertEqual(retry.fileExists, false, "The overlong path was unexpectedly reflected during retry.");
|
||||
assertEqual(retry.entryReadable, true, "Retrying removed the failed database entry.");
|
||||
assertEqual(retry.metadataRevision, failedSeed.revision, "Retrying changed the failed database revision.");
|
||||
assertEqual(retry.provenance, null, "Retrying recorded a failed reflection as successful provenance.");
|
||||
|
||||
console.log(`Ordinary start-up remained ready, reflected ${validPath}, and retained ${failedPath} for retry.`);
|
||||
} finally {
|
||||
if (session) {
|
||||
await session.app.stop();
|
||||
}
|
||||
await vault.dispose();
|
||||
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
|
||||
await deleteCouchDbDatabase(couchDb, dbName).catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -22,6 +22,7 @@ const focusedScenarios = new Set([
|
||||
"minio-upload",
|
||||
"object-storage-setup-uri-workflow",
|
||||
"p2p-setup-uri-workflow",
|
||||
"partial-startup-file-failure",
|
||||
"startup-scan",
|
||||
"setup-uri-workflow",
|
||||
"two-vault-sync",
|
||||
|
||||
Reference in New Issue
Block a user