mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-03 06:48:18 +00:00
Merge shared runtime changes onto current main
This commit is contained in:
commit
6211597d2d
128 files changed
+9204
-1285
No files matched your search
@@ -270,6 +270,10 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms
|
||||
|
||||
`test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. That target-pattern case keeps a normal-file allow-list for Markdown and PDF files enabled on both devices, then requires exact hidden JSON content after the receiving device includes it. Initial enablement must open one user-visible progress Notice before the enabled setting is saved, then retain that Notice while its nested rebuild and scan phases continue in the ordinary log. The configured fixture starts with a current CouchDB remote profile, so migration from legacy remote settings remains the responsibility of the upgrade scenarios and cannot add unrelated Notices to this check. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one separate action Notice whose controls remain usable in mobile layouts; a manually dismissed group must not repeat its acknowledged rows when a later change arrives.
|
||||
|
||||
The JSON conflict cases enter through `resolveConflictOnInternalFiles()`, exercising the analysis queue and the serial manual-resolution queue. They check the stored content and remaining conflict revisions as well as the Vault files; the manual case must remain unfinished until a choice is made.
|
||||
|
||||
Separate Vault pairs begin with Hidden File Sync disabled and enable it through the visible `Merge`, `Fetch`, and `Overwrite` buttons. Each pair contains a newer local file, a newer database file, and files present on only one side. The workflow verifies the selected contents in the files and database, the saved enablement setting, persistence after restart, and convergence on the other device. `--case=conflicts` runs only the conflict cases; `--case=enablement` runs only these enablement cases. Add `--mode=Merge`, `--mode=Fetch`, or `--mode=Overwrite` to select one enablement method.
|
||||
|
||||
`test:e2e:obsidian:hidden-file-selectors` stores and restores selected JS, CSS, and JSON files with a normal-file allow-list set, and compares both directions with an empty allow-list. It uses one temporary Vault and a local database, and also runs in the complete local suite. Run it through the focused wrapper to build the current bundle first; no remote service is required. Set `E2E_HIDDEN_FILE_SELECTORS_OUTPUT` to choose the JSON report path.
|
||||
|
||||
Before enablement, the scenario directly requests initialisation while Hidden File Sync is disabled. It requires a refused result, unchanged local files, Metadata revisions, and processed markers, and no initialisation progress Notice.
|
||||
|
||||
@@ -38,7 +38,10 @@ type ObsidianTestApp = {
|
||||
| {
|
||||
core?: {
|
||||
services?: {
|
||||
conflict?: { ensureAllProcessed(): Promise<boolean> };
|
||||
conflict?: {
|
||||
ensureAllProcessed(): Promise<boolean>;
|
||||
conflictProcessQueueCount: { value: number };
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -607,11 +610,40 @@ async function main(): Promise<void> {
|
||||
await requestRepeatedConflictChecks(cliBinary, repeatedSession.cliEnv, repeatedPath, 3);
|
||||
await page.waitForFunction((element) => !element.isConnected, original, { timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await page.waitForTimeout(250);
|
||||
// All enqueue calls have settled. Only the final resolver may remain
|
||||
// while its dialogue waits for a response; earlier checks also count.
|
||||
await page.waitForFunction(
|
||||
(targetPath) => {
|
||||
const conflict = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins?.["obsidian-livesync"]
|
||||
?.core?.services?.conflict;
|
||||
const visible = Array.from(document.querySelectorAll(".modal-container")).filter(
|
||||
(element) =>
|
||||
element.getClientRects().length > 0 &&
|
||||
element.querySelector(".modal-title")?.textContent?.includes("Conflicting changes") &&
|
||||
element.textContent?.includes(targetPath)
|
||||
);
|
||||
return conflict?.conflictProcessQueueCount.value === 1 && visible.length === 1;
|
||||
},
|
||||
repeatedPath,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
const visibleCount = await conflictDialogue(page, repeatedPath).evaluateAll(
|
||||
(elements) => elements.filter((element) => element.getClientRects().length > 0).length
|
||||
);
|
||||
if (visibleCount !== 1) {
|
||||
const dialogues = await page.locator(".modal-container").evaluateAll((elements) =>
|
||||
elements.map((element) => ({
|
||||
visible: element.getClientRects().length > 0,
|
||||
text: element.textContent?.slice(0, 1200),
|
||||
}))
|
||||
);
|
||||
const fixture = await readConflictFixture(cliBinary, repeatedSession.cliEnv, repeatedPath);
|
||||
console.error(JSON.stringify({ sameFileReplacementFailure: { visibleCount, dialogues, fixture } }));
|
||||
await captureObsidianPage(
|
||||
repeatedSession.remoteDebuggingPort,
|
||||
"conflict-dialog-same-file-replacement.failure.png",
|
||||
async () => {}
|
||||
);
|
||||
throw new Error(`Expected one newest same-file dialogue, but found ${visibleCount}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -265,7 +265,51 @@ async function main(): Promise<void> {
|
||||
);
|
||||
screenshotPaths.push(await captureStep(page, "06-after-search-next-match"));
|
||||
|
||||
const highlightDiff = modal.locator('.history-highlight-diff-label input[type="checkbox"]');
|
||||
await highlightDiff.check();
|
||||
const diffBlocks = modal.locator(".history-added, .history-deleted");
|
||||
await diffBlocks.first().waitFor({ state: "visible", timeout: 10000 });
|
||||
const diffIndicator = modal.locator(".diff-nav-indicator");
|
||||
const diffCount = await diffBlocks.count();
|
||||
const assertDiffSelection = async (index: number, count: number) => {
|
||||
assertEqual(
|
||||
(await diffIndicator.innerText()).trim(),
|
||||
`${index + 1}/${count}`,
|
||||
"Unexpected diff indicator."
|
||||
);
|
||||
const focused = await diffBlocks.evaluateAll((elements) =>
|
||||
elements.flatMap((element, index) => (element.classList.contains("diff-focused") ? [index] : []))
|
||||
);
|
||||
assertEqual(
|
||||
JSON.stringify(focused),
|
||||
JSON.stringify([index]),
|
||||
"Diff focus did not move to exactly one block."
|
||||
);
|
||||
};
|
||||
await assertDiffSelection(0, diffCount);
|
||||
const diffPrev = modal.locator(".diff-nav-btn").first();
|
||||
const diffNext = modal.locator(".diff-nav-btn").last();
|
||||
await diffPrev.click();
|
||||
await assertDiffSelection(diffCount - 1, diffCount);
|
||||
await diffNext.click();
|
||||
await assertDiffSelection(0, diffCount);
|
||||
for (let index = 0; index < diffCount; index++) await diffNext.click();
|
||||
await assertDiffSelection(0, diffCount);
|
||||
|
||||
const oldBlock = await diffBlocks.first().elementHandle();
|
||||
if (oldBlock === null) throw new Error("Could not retain the previous revision's diff block");
|
||||
const changeRevision = (await nextBtn.isDisabled()) ? prevBtn : nextBtn;
|
||||
await changeRevision.click();
|
||||
await page.waitForFunction((element) => !element.isConnected, oldBlock, { timeout: 10000 });
|
||||
await diffBlocks.first().waitFor({ state: "visible", timeout: 10000 });
|
||||
const replacementDiffCount = await diffBlocks.count();
|
||||
await assertDiffSelection(0, replacementDiffCount);
|
||||
await diffPrev.click();
|
||||
await assertDiffSelection(replacementDiffCount - 1, replacementDiffCount);
|
||||
screenshotPaths.push(await captureStep(page, "07-diff-navigation-after-revision-change"));
|
||||
|
||||
return [
|
||||
`diffNavigation: ${diffCount} blocks; replacement revision: ${replacementDiffCount} blocks`,
|
||||
`initialIndicator: ${initialIndicator}`,
|
||||
`afterPrevIndicator: ${afterPrevIndicator}`,
|
||||
`afterNextIndicator: ${afterNextIndicator}`,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { decodeBinary } from "octagonal-wheels/binary";
|
||||
import { VERSIONING_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ENCRYPTED_INTERNAL_METADATA_FEATURE, REMOTE_FEATURE_GENERATION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
captureJsonResolveDialogue,
|
||||
clickJsonResolveOption,
|
||||
obsidianRemoteDebuggingPort,
|
||||
openLiveSyncSettings,
|
||||
withObsidianPage,
|
||||
} from "../runner/ui.ts";
|
||||
import { iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
|
||||
@@ -60,6 +62,7 @@ const snippetContent = [
|
||||
|
||||
const mergeJsonPath = ".obsidian/livesync-e2e-merge.json";
|
||||
const manualMergeJsonPath = ".obsidian/livesync-e2e-manual-merge.json";
|
||||
const hiddenConflictStateKey = "__livesyncE2EHiddenConflictResolution";
|
||||
const targetPath = ".obsidian/livesync-targeted/only-a.json";
|
||||
const hiddenFileCliTimeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_CLI_TIMEOUT_MS ?? 90000);
|
||||
const hiddenFileInitialisationStateKey = "__livesyncE2EHiddenFileInitialisation";
|
||||
@@ -85,6 +88,8 @@ type RunnerContext = {
|
||||
couchDb: CouchDbConfig;
|
||||
dbName: string;
|
||||
activeSessions: Set<ObsidianLiveSyncSession>;
|
||||
temporaryVaults: Set<TemporaryVault>;
|
||||
diagnosticPaths: string[];
|
||||
};
|
||||
|
||||
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
|
||||
@@ -313,72 +318,82 @@ async function resolveHiddenConflicts(cliBinary: string, env: NodeJS.ProcessEnv)
|
||||
);
|
||||
}
|
||||
|
||||
async function autoMergeHiddenJsonConflict(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
async function startHiddenConflictResolution(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
await evalObsidianJson(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
"const prefixedPath=`i:${path}`;",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const addOn=core.getAddOn('HiddenFileSync');",
|
||||
"let doc=false;",
|
||||
"for await (const entry of core.localDatabase.findEntries('i:','i;',{conflicts:true})){",
|
||||
" if(entry.path===prefixedPath){ doc=entry; break; }",
|
||||
"}",
|
||||
"if(!doc) throw new Error(`Could not find hidden conflict candidate: ${path}`);",
|
||||
"if(!doc._conflicts?.length) throw new Error(`Hidden file has no conflicts: ${path}`);",
|
||||
"const conflicts=doc._conflicts.sort((a,b)=>Number(a.split('-')[0])-Number(b.split('-')[0]));",
|
||||
"const conflictedRev=conflicts[0];",
|
||||
"const conflictedRevNo=Number(conflictedRev.split('-')[0]);",
|
||||
"const revFrom=await core.localDatabase.getRaw(doc._id,{revs_info:true});",
|
||||
"const commonBase=(revFrom._revs_info||[])",
|
||||
" .filter((rev)=>rev.status==='available'&&Number(rev.rev.split('-')[0])<conflictedRevNo)",
|
||||
" .map((rev)=>rev.rev)[0]||'';",
|
||||
"const result=await core.localDatabase.managers.conflictManager.mergeObject(",
|
||||
" doc.path, commonBase, doc._rev, conflictedRev",
|
||||
"(()=>{",
|
||||
`const key=${JSON.stringify(hiddenConflictStateKey)};`,
|
||||
"const state={done:false,error:null};",
|
||||
"globalThis[key]=state;",
|
||||
"const addOn=app.plugins.plugins['obsidian-livesync'].core.getAddOn('HiddenFileSync');",
|
||||
"void addOn.resolveConflictOnInternalFiles().then(",
|
||||
"()=>{state.done=true;},",
|
||||
"error=>{state.error=String(error);state.done=true;}",
|
||||
");",
|
||||
"if(!result){",
|
||||
" throw new Error(`Hidden JSON conflict was not auto-mergeable: ${path}; base=${commonBase}; current=${doc._rev}; conflict=${conflictedRev}`);",
|
||||
"}",
|
||||
"await addOn.ensureDir(path);",
|
||||
"const stat=await addOn.writeFile(path,result);",
|
||||
"if(!stat) throw new Error(`Could not write merged hidden file: ${path}`);",
|
||||
"await addOn.storeInternalFileToDatabase({path,mtime:stat.mtime,ctime:stat.ctime,size:stat.size},true);",
|
||||
"await core.localDatabase.removeRevision(doc._id,conflictedRev);",
|
||||
"await addOn.extractInternalFileFromDatabase(path);",
|
||||
"await addOn.scanAllDatabaseChanges(true);",
|
||||
"return JSON.stringify({ok:true,merged:JSON.parse(result)});",
|
||||
"return JSON.stringify({started:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function openHiddenJsonResolveModal(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
async function waitForHiddenConflictResolution(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
await evalObsidianJson(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const key=${JSON.stringify(hiddenConflictStateKey)};`,
|
||||
"const state=globalThis[key];",
|
||||
"if(!state)throw new Error('Hidden conflict resolution was not started');",
|
||||
"const deadline=Date.now()+60000;",
|
||||
"while(!state.done&&Date.now()<deadline)await new Promise(resolve=>setTimeout(resolve,50));",
|
||||
"if(!state.done)throw new Error('Hidden conflict pipeline did not complete');",
|
||||
"if(state.error)throw new Error(state.error);",
|
||||
"delete globalThis[key];",
|
||||
"return JSON.stringify({done:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env,
|
||||
hiddenFileCliTimeoutMs
|
||||
);
|
||||
}
|
||||
|
||||
async function assertResolvedHiddenContent(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
path: string,
|
||||
expectedContent: string
|
||||
): Promise<void> {
|
||||
const stored = await evalObsidianJson<{
|
||||
rev: string;
|
||||
currentRev: string;
|
||||
conflicts: string[];
|
||||
datatype: string;
|
||||
data: string | string[];
|
||||
}>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
"const prefixedPath=`i:${path}`;",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const addOn=core.getAddOn('HiddenFileSync');",
|
||||
"let doc=false;",
|
||||
"for await (const entry of core.localDatabase.findEntries('i:','i;',{conflicts:true})){",
|
||||
" if(entry.path===prefixedPath){ doc=entry; break; }",
|
||||
"}",
|
||||
"if(!doc?._conflicts?.length) throw new Error(`Could not find hidden JSON conflict: ${path}`);",
|
||||
"const conflicts=doc._conflicts.sort((a,b)=>Number(a.split('-')[0])-Number(b.split('-')[0]));",
|
||||
"const docA=await core.localDatabase.getDBEntry(prefixedPath,{rev:doc._rev});",
|
||||
"const docB=await core.localDatabase.getDBEntry(prefixedPath,{rev:conflicts[0]});",
|
||||
"if(docA===false||docB===false) throw new Error(`Could not load conflicted hidden JSON entries: ${path}`);",
|
||||
"void addOn.showJSONMergeDialogAndMerge(docA,docB);",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"const db=app.plugins.plugins['obsidian-livesync'].core.localDatabase;",
|
||||
"const entry=await db.getDBEntry(`i:${path}`);",
|
||||
"if(!entry)throw new Error(`Resolved hidden file is missing: ${path}`);",
|
||||
"const current=await db.getRaw(entry._id,{conflicts:true});",
|
||||
"return JSON.stringify({rev:entry._rev,currentRev:current._rev,conflicts:current._conflicts||[],datatype:entry.datatype,data:entry.data});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
assertEqual(stored.rev, stored.currentRev, `Hidden result changed while reading ${path}.`);
|
||||
assertEqual(stored.conflicts.length, 0, `Hidden conflict remains for ${path}.`);
|
||||
const content =
|
||||
stored.datatype === "plain"
|
||||
? typeof stored.data === "string"
|
||||
? stored.data
|
||||
: stored.data.join("")
|
||||
: new TextDecoder().decode(decodeBinary(stored.data));
|
||||
assertEqual(content, expectedContent, `Stored hidden content differs from the Vault file: ${path}.`);
|
||||
}
|
||||
|
||||
async function storeHiddenFileAsConflict(
|
||||
@@ -437,6 +452,15 @@ async function createHiddenJsonConflict(
|
||||
|
||||
await writeVaultFile(vault.path, path, right);
|
||||
await storeHiddenFileAsConflict(context.cliBinary, session.cliEnv, path, baseEntry.rev);
|
||||
const conflicts = await evalObsidianJson<number>(
|
||||
context.cliBinary,
|
||||
`(async()=>{
|
||||
const entry=await app.plugins.plugins['obsidian-livesync'].core.localDatabase.getDBEntryMeta(${JSON.stringify(`i:${path}`)},{conflicts:true});
|
||||
return JSON.stringify(entry?entry._conflicts?.length||0:0);
|
||||
})()`,
|
||||
session.cliEnv
|
||||
);
|
||||
assertEqual(conflicts, 1, "The hidden conflict fixture did not retain two live revisions.");
|
||||
}
|
||||
|
||||
async function startConfiguredSession(
|
||||
@@ -601,11 +625,12 @@ async function runJsonConflictRoundTrip(
|
||||
|
||||
let session = await startConfiguredSession(context, vaultB);
|
||||
await createHiddenJsonConflict(context, session, vaultB, mergeJsonPath, base, left, right);
|
||||
await autoMergeHiddenJsonConflict(context.cliBinary, session.cliEnv, mergeJsonPath);
|
||||
await resolveHiddenConflicts(context.cliBinary, session.cliEnv);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
const mergedOnB = await waitForPathContent(vaultB.path, mergeJsonPath, (content) =>
|
||||
hasJsonValues(content, { fromA: true, fromB: true })
|
||||
);
|
||||
await assertResolvedHiddenContent(context.cliBinary, session.cliEnv, mergeJsonPath, mergedOnB);
|
||||
await stopSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultA);
|
||||
@@ -613,6 +638,7 @@ async function runJsonConflictRoundTrip(
|
||||
const mergedOnA = await waitForPathContent(vaultA.path, mergeJsonPath, (content) =>
|
||||
hasJsonValues(content, { fromA: true, fromB: true })
|
||||
);
|
||||
await assertResolvedHiddenContent(context.cliBinary, session.cliEnv, mergeJsonPath, mergedOnA);
|
||||
await stopSession(context, session);
|
||||
|
||||
assertEqual(mergedOnA, mergedOnB, "Merged hidden JSON content was not consistent across both vaults.");
|
||||
@@ -626,13 +652,21 @@ async function runJsonManualConflictResolution(context: RunnerContext, vault: Te
|
||||
|
||||
const session = await startConfiguredSession(context, vault);
|
||||
await createHiddenJsonConflict(context, session, vault, manualMergeJsonPath, base, left, right);
|
||||
await openHiddenJsonResolveModal(context.cliBinary, session.cliEnv, manualMergeJsonPath);
|
||||
await startHiddenConflictResolution(context.cliBinary, session.cliEnv);
|
||||
const screenshotPath = await captureJsonResolveDialogue(obsidianRemoteDebuggingPort());
|
||||
const completedBeforeSelection = await evalObsidianJson<boolean>(
|
||||
context.cliBinary,
|
||||
`JSON.stringify(globalThis[${JSON.stringify(hiddenConflictStateKey)}].done)`,
|
||||
session.cliEnv
|
||||
);
|
||||
assertEqual(completedBeforeSelection, false, "The hidden conflict pipeline completed before selection.");
|
||||
await clickJsonResolveOption(obsidianRemoteDebuggingPort(), "AB");
|
||||
await waitForHiddenConflictResolution(context.cliBinary, session.cliEnv);
|
||||
|
||||
const merged = await waitForPathContent(vault.path, manualMergeJsonPath, (content) =>
|
||||
hasJsonValues(content, { shared: "right", fromA: true, fromB: true })
|
||||
);
|
||||
await assertResolvedHiddenContent(context.cliBinary, session.cliEnv, manualMergeJsonPath, merged);
|
||||
await stopSession(context, session);
|
||||
|
||||
const parsed = JSON.parse(merged);
|
||||
@@ -1033,7 +1067,212 @@ async function runConfigurationNoticeGrouping(context: RunnerContext, vault: Tem
|
||||
}
|
||||
}
|
||||
|
||||
type HiddenEnablementMode = "Merge" | "Fetch" | "Overwrite";
|
||||
|
||||
async function enableHiddenSyncThroughSettings(
|
||||
context: RunnerContext,
|
||||
session: ObsidianLiveSyncSession,
|
||||
vault: TemporaryVault,
|
||||
mode: HiddenEnablementMode
|
||||
): Promise<void> {
|
||||
const stateKey = "__livesyncE2EHiddenEnablement";
|
||||
await evalObsidianJson(
|
||||
context.cliBinary,
|
||||
`(()=>{
|
||||
const core=app.plugins.plugins['obsidian-livesync'].core;
|
||||
if(core.settings.syncInternalFiles)throw new Error('Hidden File Sync must start disabled');
|
||||
const setting=core.services.setting;
|
||||
const original=setting.enableOptionalFeature;
|
||||
const state={done:false,error:null,mode:null,restore:()=>{setting.enableOptionalFeature=original;}};
|
||||
globalThis[${JSON.stringify(stateKey)}]=state;
|
||||
setting.enableOptionalFeature=async function(mode){
|
||||
state.mode=mode;
|
||||
try{return await original.call(this,mode);}
|
||||
catch(error){state.error=String(error);throw error;}
|
||||
finally{state.done=true;state.restore();}
|
||||
};
|
||||
return JSON.stringify(true);
|
||||
})()`,
|
||||
session.cliEnv
|
||||
);
|
||||
try {
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const navigator = await openLiveSyncSettings(page);
|
||||
const settingsPage = await navigator.openPage("Sync Settings");
|
||||
const row = settingsPage.locator(".setting-item").filter({
|
||||
has: navigator.page.getByRole("button", { name: "Merge", exact: true }),
|
||||
});
|
||||
await row.getByRole("button", { name: mode, exact: true }).click();
|
||||
});
|
||||
const result = await evalObsidianJson<{ mode: string; enabled: boolean }>(
|
||||
context.cliBinary,
|
||||
`
|
||||
(async()=>{
|
||||
const state=globalThis[${JSON.stringify(stateKey)}];
|
||||
const deadline=Date.now()+60000;
|
||||
while(!state.done&&Date.now()<deadline)await new Promise(resolve=>setTimeout(resolve,50));
|
||||
if(!state.done)throw new Error('Hidden File Sync enablement did not complete');
|
||||
if(state.error)throw new Error(state.error);
|
||||
const core=app.plugins.plugins['obsidian-livesync'].core;
|
||||
return JSON.stringify({mode:state.mode,enabled:core.settings.syncInternalFiles});
|
||||
})()
|
||||
`,
|
||||
session.cliEnv,
|
||||
hiddenFileCliTimeoutMs
|
||||
);
|
||||
assertEqual(result.mode, mode.toUpperCase(), "The settings action invoked a different enablement mode.");
|
||||
assertEqual(result.enabled, true, "Hidden File Sync was not enabled by its settings action.");
|
||||
const saved = JSON.parse(await readVaultFile(vault.path, ".obsidian/plugins/obsidian-livesync/data.json"));
|
||||
assertEqual(saved.syncInternalFiles, true, "Hidden File Sync enablement was not persisted.");
|
||||
} finally {
|
||||
await evalObsidianJson(
|
||||
context.cliBinary,
|
||||
`(()=>{
|
||||
globalThis[${JSON.stringify(stateKey)}]?.restore();
|
||||
delete globalThis[${JSON.stringify(stateKey)}];
|
||||
return JSON.stringify(true);
|
||||
})()`,
|
||||
session.cliEnv
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function startPersistedSession(context: RunnerContext, vault: TemporaryVault): Promise<ObsidianLiveSyncSession> {
|
||||
const session = await startObsidianLiveSyncSession({ binary: context.binary, cliBinary: context.cliBinary, vault });
|
||||
context.activeSessions.add(session);
|
||||
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
|
||||
const enabled = await evalObsidianJson<boolean>(
|
||||
context.cliBinary,
|
||||
"JSON.stringify(app.plugins.plugins['obsidian-livesync'].core.settings.syncInternalFiles)",
|
||||
session.cliEnv
|
||||
);
|
||||
assertEqual(enabled, true, "Hidden File Sync enablement did not survive restart.");
|
||||
return session;
|
||||
}
|
||||
|
||||
async function verifyHiddenFiles(
|
||||
context: RunnerContext,
|
||||
session: ObsidianLiveSyncSession,
|
||||
vault: TemporaryVault,
|
||||
expected: Record<string, string>
|
||||
): Promise<void> {
|
||||
for (const [path, content] of Object.entries(expected)) {
|
||||
await waitForPathContent(vault.path, path, (actual) => actual === content);
|
||||
await assertResolvedHiddenContent(context.cliBinary, session.cliEnv, path, content);
|
||||
}
|
||||
}
|
||||
|
||||
async function runHiddenEnablement(context: RunnerContext, mode: HiddenEnablementMode): Promise<void> {
|
||||
const source = await createTemporaryVault();
|
||||
context.temporaryVaults.add(source);
|
||||
const target = await createTemporaryVault();
|
||||
context.temporaryVaults.add(target);
|
||||
const root = `.obsidian/livesync-enable-${mode.toLowerCase()}`;
|
||||
const localNewer = `${root}/local-newer.json`;
|
||||
const remoteNewer = `${root}/remote-newer.json`;
|
||||
const remoteOnly = `${root}/remote-only.json`;
|
||||
const localOnly = `${root}/local-only.json`;
|
||||
context.diagnosticPaths = [localNewer, remoteNewer, remoteOnly, localOnly];
|
||||
const remoteContent = JSON.stringify({ source: "remote", mode }) + "\n";
|
||||
const localContent = JSON.stringify({ source: "local", mode }) + "\n";
|
||||
const timestamp = Math.floor(Date.now() / 1000) - 120;
|
||||
for (const path of [localNewer, remoteNewer, remoteOnly]) {
|
||||
await writeVaultFile(source.path, path, remoteContent);
|
||||
await utimes(join(source.path, path), timestamp + 10, timestamp + 10);
|
||||
}
|
||||
const settings = {
|
||||
syncInternalFiles: false,
|
||||
useAdvancedMode: true,
|
||||
syncInternalFilesTargetPatterns: `^\\.obsidian(?:$|/livesync-enable-${mode.toLowerCase()}(?:/|$))`,
|
||||
};
|
||||
let session = await startConfiguredSession(context, source, settings);
|
||||
await enableHiddenSyncThroughSettings(context, session, source, "Overwrite");
|
||||
await verifyHiddenFiles(context, session, source, {
|
||||
[localNewer]: remoteContent,
|
||||
[remoteNewer]: remoteContent,
|
||||
[remoteOnly]: remoteContent,
|
||||
});
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
const uploaded: LocalDatabaseEntry[] = [];
|
||||
for (const path of [localNewer, remoteNewer, remoteOnly]) {
|
||||
uploaded.push(await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path, { hidden: true }));
|
||||
}
|
||||
await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) =>
|
||||
uploaded.every((entry) => docs.some((doc) => doc._id === entry.id && doc._rev === entry.rev))
|
||||
);
|
||||
await stopSession(context, session);
|
||||
|
||||
for (const [path, time] of [
|
||||
[localNewer, timestamp + 30],
|
||||
[remoteNewer, timestamp],
|
||||
[localOnly, timestamp + 30],
|
||||
] as const) {
|
||||
await writeVaultFile(target.path, path, localContent);
|
||||
await utimes(join(target.path, path), time, time);
|
||||
}
|
||||
session = await startConfiguredSession(context, target, settings);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
for (const path of [localNewer, remoteNewer]) {
|
||||
assertEqual(await readVaultFile(target.path, path), localContent, "Disabled sync replaced a local fixture.");
|
||||
}
|
||||
const receivedRevisions = await evalObsidianJson<string[]>(
|
||||
context.cliBinary,
|
||||
`(async()=>{
|
||||
const db=app.plugins.plugins['obsidian-livesync'].core.localDatabase;
|
||||
const revisions=[];
|
||||
for(const id of ${JSON.stringify(uploaded.map((entry) => entry.id))}) {
|
||||
const doc=await db.getRaw(id);
|
||||
revisions.push(doc._rev);
|
||||
}
|
||||
return JSON.stringify(revisions);
|
||||
})()`,
|
||||
session.cliEnv
|
||||
);
|
||||
assertEqual(
|
||||
JSON.stringify(receivedRevisions),
|
||||
JSON.stringify(uploaded.map((entry) => entry.rev)),
|
||||
"Disabled Hidden File Sync did not retain the uploaded revisions in its database."
|
||||
);
|
||||
assertEqual(await pathExists(target.path, remoteOnly), false, "Disabled sync reflected a remote-only file.");
|
||||
|
||||
await enableHiddenSyncThroughSettings(context, session, target, mode);
|
||||
const expected = {
|
||||
[localNewer]: mode === "Fetch" ? remoteContent : localContent,
|
||||
[remoteNewer]: mode === "Overwrite" ? localContent : remoteContent,
|
||||
[remoteOnly]: remoteContent,
|
||||
[localOnly]: localContent,
|
||||
};
|
||||
await verifyHiddenFiles(context, session, target, expected);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await stopSession(context, session);
|
||||
|
||||
session = await startPersistedSession(context, target);
|
||||
await verifyHiddenFiles(context, session, target, expected);
|
||||
await stopSession(context, session);
|
||||
|
||||
session = await startPersistedSession(context, source);
|
||||
await pullAndApplyHiddenFiles(context, session);
|
||||
await verifyHiddenFiles(context, session, source, expected);
|
||||
await stopSession(context, session);
|
||||
console.log(
|
||||
`Hidden File Sync ${mode} preserved the selected contents in files and the database, after restart, and on the other device.`
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const selectedCase = process.argv.find((argument) => argument.startsWith("--case="))?.slice(7);
|
||||
if (selectedCase && selectedCase !== "conflicts" && selectedCase !== "enablement") {
|
||||
throw new Error("Use --case=conflicts or --case=enablement.");
|
||||
}
|
||||
const conflictsOnly = selectedCase === "conflicts";
|
||||
const enablementOnly = selectedCase === "enablement";
|
||||
const selectedMode = process.argv.find((argument) => argument.startsWith("--mode="))?.slice(7);
|
||||
const enablementModes = (["Merge", "Fetch", "Overwrite"] as const).filter(
|
||||
(mode) => !selectedMode || selectedMode === mode
|
||||
);
|
||||
if (selectedMode && (!enablementOnly || enablementModes.length !== 1)) {
|
||||
throw new Error("Use --case=enablement with --mode=Merge, --mode=Fetch, or --mode=Overwrite.");
|
||||
}
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) {
|
||||
@@ -1044,7 +1283,15 @@ async function main(): Promise<void> {
|
||||
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "hidden-roundtrip");
|
||||
const vaultA = await createTemporaryVault();
|
||||
const vaultB = await createTemporaryVault();
|
||||
const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName, activeSessions: new Set() };
|
||||
const context: RunnerContext = {
|
||||
binary,
|
||||
cliBinary: cli.binary,
|
||||
couchDb,
|
||||
dbName,
|
||||
activeSessions: new Set(),
|
||||
temporaryVaults: new Set([vaultA, vaultB]),
|
||||
diagnosticPaths: [mergeJsonPath, manualMergeJsonPath, snippetPath],
|
||||
};
|
||||
|
||||
try {
|
||||
await assertCouchDbReachable(couchDb);
|
||||
@@ -1055,17 +1302,56 @@ async function main(): Promise<void> {
|
||||
console.log(`Temporary vault B: ${vaultB.path}`);
|
||||
console.log(`Temporary CouchDB database: ${dbName}`);
|
||||
|
||||
await runCreateRoundTrip(context, vaultA, vaultB);
|
||||
await runDeleteRoundTrip(context, vaultA, vaultB);
|
||||
await runJsonConflictRoundTrip(context, vaultA, vaultB);
|
||||
await runJsonManualConflictResolution(context, vaultB);
|
||||
await runTargetMismatch(context, vaultA, vaultB);
|
||||
await runInitialisationNoticeGrouping(context, vaultB);
|
||||
await runConfigurationNoticeGrouping(context, vaultB);
|
||||
if (!conflictsOnly && !enablementOnly) {
|
||||
await runCreateRoundTrip(context, vaultA, vaultB);
|
||||
await runDeleteRoundTrip(context, vaultA, vaultB);
|
||||
}
|
||||
if (!enablementOnly) {
|
||||
await runJsonConflictRoundTrip(context, vaultA, vaultB);
|
||||
await runJsonManualConflictResolution(context, vaultB);
|
||||
}
|
||||
if (!conflictsOnly && !enablementOnly) {
|
||||
await runTargetMismatch(context, vaultA, vaultB);
|
||||
await runInitialisationNoticeGrouping(context, vaultB);
|
||||
await runConfigurationNoticeGrouping(context, vaultB);
|
||||
}
|
||||
if (!conflictsOnly) {
|
||||
for (const mode of enablementModes) await runHiddenEnablement(context, mode);
|
||||
}
|
||||
} catch (error) {
|
||||
for (const session of context.activeSessions) {
|
||||
await captureObsidianPage(
|
||||
session.remoteDebuggingPort,
|
||||
"hidden-file-workflow-failure.png",
|
||||
async () => {}
|
||||
).catch((captureError: unknown) => console.error(String(captureError)));
|
||||
await evalObsidianJson(
|
||||
cli.binary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const addOn=core.getAddOn('HiddenFileSync');",
|
||||
`const paths=${JSON.stringify(context.diagnosticPaths)};`,
|
||||
"const files=await addOn.scanInternalFileNames();",
|
||||
"const observations=[];",
|
||||
"for(const path of paths){",
|
||||
"const stat=await core.storageAccess.statHidden(path);",
|
||||
"const entry=await core.localDatabase.getDBEntryMeta(`i:${path}`,{conflicts:true});",
|
||||
"observations.push({path,selected:files.includes(path),target:await addOn.isTargetFile(path),stat,processed:addOn.getLastProcessedFileKey(path),entry:entry?{rev:entry._rev,conflicts:entry._conflicts||[]}:false});",
|
||||
"}",
|
||||
`const operation=globalThis[${JSON.stringify(hiddenConflictStateKey)}];`,
|
||||
"return JSON.stringify({enabled:core.settings.syncInternalFiles,ready:core.localDatabase.isReady,remaining:addOn.conflictResolutionProcessor.totalRemaining,operation,observations});",
|
||||
"})()",
|
||||
].join(""),
|
||||
session.cliEnv
|
||||
)
|
||||
.then((snapshot) => console.error("Hidden-file failure state:", JSON.stringify(snapshot)))
|
||||
.catch((snapshotError: unknown) => console.error(String(snapshotError)));
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
for (const session of context.activeSessions) await stopSession(context, session);
|
||||
await vaultA.dispose();
|
||||
await vaultB.dispose();
|
||||
for (const vault of context.temporaryVaults) 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);
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
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 { requireObsidianBinary, requireObsidianCli } from "../runner/environment.ts";
|
||||
import {
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObsidianDeviceLocalState,
|
||||
waitForLiveSyncCoreReady,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000";
|
||||
|
||||
type FixtureLog = {
|
||||
message: string;
|
||||
level?: number;
|
||||
key?: string;
|
||||
};
|
||||
|
||||
type ShutdownObservation = {
|
||||
closeCalls: number;
|
||||
disposeCalls: number;
|
||||
disposeCompleted: boolean;
|
||||
fixtureLogs: FixtureLog[];
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
function createPluginData(dbName: string): Record<string, unknown> {
|
||||
return createE2eCouchDbPluginData(
|
||||
{ uri: "http://127.0.0.1:1", username: "unused", password: "unused", dbName },
|
||||
{
|
||||
syncInternalFiles: true,
|
||||
usePluginSync: true,
|
||||
usePluginSyncV2: true,
|
||||
deviceAndVaultName: "log-lifecycle-device",
|
||||
autoSweepPlugins: false,
|
||||
autoSweepPluginsPeriodic: false,
|
||||
syncInternalFilesInterval: 0,
|
||||
liveSync: false,
|
||||
periodicReplication: false,
|
||||
syncOnStart: false,
|
||||
syncOnSave: false,
|
||||
syncOnFileOpen: false,
|
||||
syncOnEditorSave: false,
|
||||
syncAfterMerge: false,
|
||||
showVerboseLog: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function startSession(
|
||||
binary: string,
|
||||
cliBinary: string,
|
||||
vault: TemporaryVault
|
||||
): Promise<ObsidianLiveSyncSession> {
|
||||
const session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary,
|
||||
vault,
|
||||
pluginData: createPluginData(`log-lifecycle-${vault.name}`),
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
lifecycle: {
|
||||
beforeLaunch: async () => {
|
||||
const registryPath = join(vault.userDataPath, "obsidian.json");
|
||||
const registry = JSON.parse(await readFile(registryPath, "utf8"));
|
||||
registry.updateDisabled = true;
|
||||
await writeFile(registryPath, JSON.stringify(registry));
|
||||
},
|
||||
},
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
function createShutdownScript(options: {
|
||||
duringMarker: string;
|
||||
failureMarker: string;
|
||||
reloadMarker: string;
|
||||
injectFailure: boolean;
|
||||
urgentLevel: number;
|
||||
}): string {
|
||||
const fixtures = JSON.stringify(options);
|
||||
return `
|
||||
const fixtures=${fixtures};
|
||||
const plugin=app.plugins.plugins["obsidian-livesync"];
|
||||
const core=plugin?.core;
|
||||
require(core,"The LiveSync plug-in did not expose its core");
|
||||
const module=core.modules.find(candidate=>candidate.constructor.name==="ModuleLog");
|
||||
require(module,"The loaded core did not contain ModuleLog");
|
||||
const api=core.services.API;
|
||||
const database=core.services.database.localDatabaseDirect;
|
||||
require(database,"The loaded core did not expose its local database");
|
||||
|
||||
let releaseCloseGate;
|
||||
const closeGate=new Promise(resolve=>{releaseCloseGate=resolve;});
|
||||
const state={
|
||||
core,
|
||||
api,
|
||||
module,
|
||||
fixtureLogs:[],
|
||||
events:[],
|
||||
closeStarted:false,
|
||||
closeCalls:0,
|
||||
disposeCalls:0,
|
||||
disposeCompleted:false,
|
||||
closeGateReleased:false,
|
||||
disableError:"",
|
||||
};
|
||||
app.__livesyncLogLifecycleState=state;
|
||||
|
||||
const originalAddLog=module.__addLog;
|
||||
module.__addLog=function(message,level,key) {
|
||||
if(typeof message==="string" && [
|
||||
fixtures.duringMarker,
|
||||
fixtures.failureMarker,
|
||||
fixtures.reloadMarker
|
||||
].some(marker=>marker && message.includes(marker))) {
|
||||
state.fixtureLogs.push({message,level,key});
|
||||
const marker=[fixtures.duringMarker,fixtures.failureMarker,fixtures.reloadMarker]
|
||||
.find(candidate=>candidate && message.includes(candidate));
|
||||
state.events.push("log:"+marker);
|
||||
}
|
||||
return originalAddLog.apply(this,arguments);
|
||||
};
|
||||
|
||||
const resource=module.logResource;
|
||||
const originalDispose=resource.dispose;
|
||||
resource.dispose=async function() {
|
||||
state.disposeCalls++;
|
||||
state.events.push("dispose-start");
|
||||
try {
|
||||
return await originalDispose.apply(this,arguments);
|
||||
} finally {
|
||||
state.disposeCompleted=true;
|
||||
state.events.push("dispose-complete");
|
||||
}
|
||||
};
|
||||
|
||||
const originalClose=database.close;
|
||||
database.close=async function() {
|
||||
state.closeCalls++;
|
||||
if(state.closeCalls===1) {
|
||||
state.closeStarted=true;
|
||||
state.events.push("close-start");
|
||||
await closeGate;
|
||||
}
|
||||
const result=await originalClose.apply(this,arguments);
|
||||
if(state.closeCalls===1) {
|
||||
state.events.push("close-finished");
|
||||
if(fixtures.injectFailure) {
|
||||
state.events.push("close-failure-injected");
|
||||
throw new Error(fixtures.failureMarker);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
state.restore=()=>{
|
||||
module.__addLog=originalAddLog;
|
||||
resource.dispose=originalDispose;
|
||||
database.close=originalClose;
|
||||
};
|
||||
|
||||
const run=async()=>{
|
||||
state.disablePromise=app.plugins.disablePlugin("obsidian-livesync");
|
||||
state.disablePromise.catch(error=>{state.disableError=String(error);});
|
||||
await waitFor(()=>state.closeStarted,"The real local database close did not start");
|
||||
require(state.disposeCalls===0 && !state.disposeCompleted,
|
||||
"The log resource was released before database shutdown completed");
|
||||
api.addLog(fixtures.duringMarker,undefined,"e2e-log-lifecycle-during-close");
|
||||
await waitFor(
|
||||
()=>state.fixtureLogs.some(entry=>entry.message.includes(fixtures.duringMarker)),
|
||||
"ModuleLog did not receive the during-close log"
|
||||
);
|
||||
require(state.disposeCalls===0,"The log resource was released while database close was gated");
|
||||
releaseCloseGate();
|
||||
state.closeGateReleased=true;
|
||||
await waitFor(()=>state.disposeCompleted,"The log resource was not released after database close");
|
||||
|
||||
require(state.closeCalls===1,"The local database close was not observed exactly once");
|
||||
require(state.disposeCalls===1,"The log resource was not disposed exactly once");
|
||||
require(state.events.indexOf("close-finished")>=0 &&
|
||||
state.events.indexOf("close-finished")<state.events.indexOf("dispose-start"),
|
||||
"The log resource was disposed before the local database close completed");
|
||||
|
||||
const failedLog=fixtures.failureMarker
|
||||
? state.fixtureLogs.find(entry=>entry.message.includes(fixtures.failureMarker))
|
||||
: undefined;
|
||||
if(fixtures.injectFailure) {
|
||||
require(failedLog,"The injected post-close failure was not logged");
|
||||
require(failedLog.key==="shutdown","The shutdown failure log used the wrong key");
|
||||
require(failedLog.level===fixtures.urgentLevel,"The shutdown failure log was not urgent");
|
||||
require(
|
||||
state.events.indexOf("close-finished")<state.events.indexOf("log:"+fixtures.failureMarker) &&
|
||||
state.events.indexOf("log:"+fixtures.failureMarker)<state.events.indexOf("dispose-start"),
|
||||
"The shutdown failure was not logged between database close and log resource disposal"
|
||||
);
|
||||
} else {
|
||||
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");
|
||||
await state.disablePromise;
|
||||
require(!state.disableError,"Disabling the plug-in failed: "+state.disableError);
|
||||
return JSON.stringify({
|
||||
closeCalls:state.closeCalls,
|
||||
disposeCalls:state.disposeCalls,
|
||||
disposeCompleted:state.disposeCompleted,
|
||||
fixtureLogs:state.fixtureLogs,
|
||||
events:state.events
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
releaseCloseGate();
|
||||
if(state.closeStarted && !state.disposeCompleted) {
|
||||
try {
|
||||
await waitFor(()=>state.disposeCompleted,"Cleanup timed out waiting for log resource disposal");
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
async function runCase(options: {
|
||||
binary: string;
|
||||
cliBinary: string;
|
||||
caseName: string;
|
||||
injectFailure: boolean;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const vault = await createTemporaryVault(`obsidian-log-lifecycle-${options.caseName}-`);
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
const markers = {
|
||||
duringMarker: `log-lifecycle-${options.caseName}-during-${randomUUID()}`,
|
||||
failureMarker: options.injectFailure
|
||||
? `log-lifecycle-${options.caseName}-injected-close-failure-${randomUUID()}`
|
||||
: "",
|
||||
reloadMarker: options.injectFailure ? "" : `log-lifecycle-${options.caseName}-reload-${randomUUID()}`,
|
||||
injectFailure: options.injectFailure,
|
||||
urgentLevel: LOG_LEVEL_URGENT,
|
||||
};
|
||||
|
||||
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));
|
||||
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;
|
||||
require(state,"The old core observation was not retained across plug-in reload");
|
||||
const plugin=app.plugins.plugins["obsidian-livesync"];
|
||||
const core=plugin?.core;
|
||||
require(core && core!==state.core,"Enabling the plug-in did not create a new core");
|
||||
const module=core.modules.find(candidate=>candidate.constructor.name==="ModuleLog");
|
||||
require(module,"The reloaded core did not contain ModuleLog");
|
||||
const originalAddLog=module.__addLog;
|
||||
const observed=[];
|
||||
module.__addLog=function(message,level,key) {
|
||||
if(typeof message==="string" && message.includes(${JSON.stringify(markers.reloadMarker)})) {
|
||||
observed.push({message,level,key});
|
||||
}
|
||||
return originalAddLog.apply(this,arguments);
|
||||
};
|
||||
try {
|
||||
core.services.API.addLog(${JSON.stringify(markers.reloadMarker)},undefined,"e2e-log-lifecycle-reload");
|
||||
await waitFor(()=>observed.length>0,"The reloaded ModuleLog did not receive its API log");
|
||||
require(observed.length===1,"The reloaded ModuleLog received the fixture log more than once");
|
||||
require(
|
||||
!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");
|
||||
return JSON.stringify({
|
||||
coreChanged:core!==state.core,
|
||||
observedByNewModule:observed.length,
|
||||
observedByOldModule:state.fixtureLogs.filter(
|
||||
entry=>entry.message.includes(${JSON.stringify(markers.reloadMarker)})
|
||||
).length
|
||||
});
|
||||
} finally {
|
||||
module.__addLog=originalAddLog;
|
||||
state.restore();
|
||||
delete app.__livesyncLogLifecycleState;
|
||||
}
|
||||
`
|
||||
);
|
||||
return { caseName: options.caseName, shutdown, reload };
|
||||
} finally {
|
||||
if (session) {
|
||||
try {
|
||||
await evaluate(
|
||||
options.cliBinary,
|
||||
session,
|
||||
`
|
||||
const state=app.__livesyncLogLifecycleState;
|
||||
state?.restore?.();
|
||||
delete app.__livesyncLogLifecycleState;
|
||||
return JSON.stringify({cleaned:true});
|
||||
`
|
||||
);
|
||||
} catch {}
|
||||
try {
|
||||
await session.app.stop();
|
||||
} finally {
|
||||
await vault.dispose();
|
||||
}
|
||||
} else {
|
||||
await vault.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cliBinary = requireObsidianCli();
|
||||
const bundleHash = createHash("sha256")
|
||||
.update(new Uint8Array(await readFile("main.js")))
|
||||
.digest("hex");
|
||||
|
||||
const observations = [
|
||||
await runCase({ binary, cliBinary, caseName: "normal", injectFailure: false }),
|
||||
await runCase({ binary, cliBinary, caseName: "controlled-failure", injectFailure: true }),
|
||||
];
|
||||
console.log(JSON.stringify({ ok: true, bundleHash, observations }, null, 2));
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -49,6 +49,7 @@ const focusedScenarios = new Set([
|
||||
"customisation-sync",
|
||||
"customisation-catalogue-reload",
|
||||
"optional-file-save-completion",
|
||||
"log-lifecycle",
|
||||
"livesync-batch",
|
||||
"received-change-readiness",
|
||||
"sync-guard",
|
||||
|
||||
Reference in new issue
Block a user