mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-09 20:27:05 +00:00
302 lines
13 KiB
TypeScript
302 lines
13 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|