mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-27 03:23:20 +00:00
Merge main and validate local saves with Commonlib 0.2.1
This commit is contained in:
commit
6d289a8824
26 files changed
+1858
-83
No files matched your search
@@ -127,7 +127,7 @@ The underlying `test:e2e:obsidian:<scenario>` scripts remain available for an im
|
||||
|
||||
`test:e2e:obsidian:joined-devices` uses a temporary Vault and isolated CouchDB database to open Joined devices from Remote Configuration, a version Notice, and its registered command. `test:e2e:obsidian:joined-devices-object-storage` runs the same assertions against a unique Object Storage prefix, including the conditional removal supported by the test S3 service. It checks that newer and older Self-hosted LiveSync versions share one Notice with one recommendation and review action, that the current device appears first with current metadata, and that locked-out and other-device records are shown. It confirms that a manual refresh reads an updated remote record and that cancelling the inline confirmation leaves the remote unchanged before forgetting one device. It verifies the raw milestone retains the other device, remote state, and preferred-ID tweaks, then checks mobile scrolling, safe-area bounds, and touch targets. It captures the version Notice, desktop list, forget confirmation, completed removal, and mobile dialogue; unrelated Notices are dismissed before the list captures.
|
||||
|
||||
`test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then enables Advanced mode and persists one numeric Advanced setting. On Obsidian before 1.13, this covers the imperative settings fallback used by the `SettingSpec` proof. On Obsidian 1.13 or later, it verifies the task-oriented landing order, the separate Synchronisation group containing Remote Configuration and Sync Settings, and the General Settings group containing Appearance, Logging, and Extra menus. It reopens and cancels onboarding from Quick Setup, opens all 14 nested settings pages, searches globally for the Advanced control, captures the landing page, search result, Advanced page, and mobile landing page, and verifies that the value remains after the settings dialogue is closed and reopened. The mobile landing check confirms that Remote Configuration is reachable without initial scrolling. Finally, it selects the Synchronisation Settings page and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour.
|
||||
`test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then enables Advanced mode and persists one numeric Advanced setting. It also checks that batching starts disabled, that each Sync Mode shows its own batching choice and the appropriate shared delay controls, and that switching modes and reopening settings preserves both choices. It reads the saved plug-in data to confirm that the choices are persisted independently. On Obsidian before 1.13, this covers the imperative settings fallback used by the `SettingSpec` proof. On Obsidian 1.13 or later, it verifies the task-oriented landing order, the separate Synchronisation group containing Remote Configuration and Sync Settings, and the General Settings group containing Appearance, Logging, and Extra menus. It reopens and cancels onboarding from Quick Setup, opens all 14 nested settings pages, searches globally for the Advanced control, captures the landing page, search result, Advanced page, and mobile landing page, and verifies that the value remains after the settings dialogue is closed and reopened. The mobile landing check confirms that Remote Configuration is reachable without initial scrolling. Finally, it selects the Synchronisation Settings page and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour.
|
||||
|
||||
The default runner uses the maintained pre-1.13 Obsidian fixture and therefore exercises the complete compatibility-review, mobile-layout, and imperative-settings path. To exercise only the native settings contract against an additional Obsidian 1.13-or-later installation, supply its executable and companion CLI explicitly:
|
||||
|
||||
@@ -252,10 +252,18 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms
|
||||
|
||||
`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.
|
||||
|
||||
`test:e2e:obsidian:customisation-sync` runs a two-vault Customisation Sync workflow. It scans a real snippet CSS file, config JSON file, and sample plug-in fixture into per-file Customisation Sync data, synchronises the entries through CouchDB, applies them on the second vault, verifies the resulting `.obsidian` files, propagates a snippet update, and verifies deletion of the source-vault snippet sync data without confusing it with the target vault's own applied copy.
|
||||
|
||||
`test:e2e:obsidian:customisation-catalogue-reload` opens the Customisation Sync dialogue, enables Maintenance mode, and clicks Reload. It verifies that this first Reload lists all three files of a sample plug-in, then selects the source copy and clicks Apply to restore their contents. It also checks a second enumeration and Apply through the existing methods in one temporary Vault with a local database. Run it through the focused wrapper to build the current bundle first; no remote service is required. Set `E2E_CUSTOMISATION_CATALOGUE_OUTPUT` to choose the JSON report path.
|
||||
|
||||
The workflow also opens the Customisation Sync dialogue. `--case=visibility` checks that **Hide not applicable items** hides an identical snippet, preserves a different snippet and its source selector, and restores the identical snippet when cleared. `--case=mtime` gives the multi-file plug-in fixture modern millisecond timestamps and requires an older remote copy to remain labelled **Older** and unselected by **Select All Shiny**. With no case argument, both checks run before the existing apply, update, and deletion workflow.
|
||||
|
||||
`--case=automatic` separately checks that choosing **Automatic** while Hidden File Sync is disabled displays a Notice without changing modes, files, Metadata revisions, or processed markers. The fixture starts with a saved Automatic selection and requires it to survive start-up and both rejected actions. It also checks disabling the feature after opening **Initial Action**. After enablement, selecting a whole plug-in must initialise both **MAIN** and **DATA**, and transfer their files to the other Vault. Separate temporary Vaults exercise **Overwrite Remote**, **Overwrite Local**, and **Use newer**. The latter two start with different local and database contents; deterministic per-file modification times require **Use newer** to keep the newer database MAIN content and the newer local DATA content.
|
||||
|
||||
`test:e2e:obsidian:livesync-batch` uses two real Obsidian Vaults and a disposable local CouchDB to compare batching disabled, the older `batchSave` choice alone, and the explicit LiveSync batching choice. It covers repeated edits in both directions, template-like writes, deletion and recreation, incoming changes, maximum delay, concurrent paths, manual flush, and restart recovery before and after a database write. Restarts use the saved profile with natural plug-in start-up. `ISSUE1207_CASE_FILTER` selects case names; `ISSUE1207_RESTART_BUCKET=same|exact|different` exercises interrupted processing at the selected timestamp condition. `ISSUE1207_RESTART_SAVE_AFTER_EDIT=true` separately checks a settings save after the pending edit. Use `npm run test:e2e:obsidian:focused -- livesync-batch` to build and run the default matrix. This scenario requires a localhost CouchDB and is outside the complete local suite.
|
||||
|
||||
`test:e2e:obsidian:optional-file-save-completion` exercises Hidden File Sync and both Customisation Sync formats in one isolated real Obsidian Vault. It holds a local database write to verify that its event stays in the snapshot and that flush waits for the write, then checks unchanged notifications, subsequent edits, and repeated deletions. Injected save failures remain pending and must replay successfully before the next start reaches readiness. This local scenario disables replication and requires no remote service. It is outside `test:e2e:obsidian:local-suite`.
|
||||
|
||||
`test:e2e:obsidian:received-change-readiness` uses two sequential real Obsidian sessions and isolated CouchDB databases. The source creates ordinary notes and their Chunks; the target starts continuous replication, resets readiness through the public lifecycle service, and receives those documents while its Vault files remain absent. Marking the target ready twice must emit one readiness event and reflect the queued content. A second note remains absent after readiness while database reflecting is explicitly suspended, then appears when that setting is resumed. This focused scenario is outside `test:e2e:obsidian:local-suite`.
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import {
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObsidianDeviceLocalState,
|
||||
waitForLiveSyncCoreReady,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { waitForVisibleObsidianDialogue, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const pluginName = "livesync-catalogue-example";
|
||||
const sourceTerm = "catalogue-source";
|
||||
const pluginRoot = `.obsidian/plugins/${pluginName}`;
|
||||
const pluginFiles = {
|
||||
[`${pluginRoot}/main.js`]: "module.exports = class ExamplePlugin {};\n",
|
||||
[`${pluginRoot}/manifest.json`]: JSON.stringify({
|
||||
id: pluginName,
|
||||
name: "Catalogue Example",
|
||||
version: "1.0.0",
|
||||
minAppVersion: "1.0.0",
|
||||
description: "An isolated test fixture",
|
||||
author: "Self-hosted LiveSync",
|
||||
}),
|
||||
[`${pluginRoot}/styles.css`]: ".livesync-catalogue-example { color: red; }\n",
|
||||
};
|
||||
const allowList = "\\.(?:md|png|pdf)$";
|
||||
const coreCode = "const core=app.plugins.plugins['obsidian-livesync'].core;";
|
||||
const configCode = `${coreCode}const addOn=core.getAddOn('ConfigSync');`;
|
||||
const settleCode = `
|
||||
const settle=async()=>{
|
||||
await new Promise(resolve=>setTimeout(resolve,100));
|
||||
for(let attempt=0;attempt<200;attempt++){
|
||||
if(addOn.updatingV2Count===0)return;
|
||||
await new Promise(resolve=>setTimeout(resolve,25));
|
||||
}
|
||||
throw new Error('Customisation Sync did not settle');
|
||||
};
|
||||
`;
|
||||
const sourceRowCode = `
|
||||
const row=addOn.pluginList.find(entry=>entry.category==='PLUGIN_MAIN'&&entry.name===${JSON.stringify(pluginName)}&&entry.term===${JSON.stringify(sourceTerm)});
|
||||
if(!row)throw new Error('The source catalogue row is missing');
|
||||
`;
|
||||
|
||||
type Observation = { name: string; expected: unknown; actual: unknown; passed: boolean };
|
||||
|
||||
async function writeFixtures(vaultPath: string, files: Record<string, string>) {
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
await mkdir(dirname(join(vaultPath, path)), { recursive: true });
|
||||
await writeFile(join(vaultPath, path), content);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFixtures(vaultPath: string, files: Record<string, string>) {
|
||||
for (const path of Object.keys(files)) await rm(join(vaultPath, path), { force: true });
|
||||
}
|
||||
|
||||
async function matchingFiles(vaultPath: string, files: Record<string, string>) {
|
||||
const matches: string[] = [];
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
try {
|
||||
if ((await readFile(join(vaultPath, path), "utf8")) === content) matches.push(path);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
return matches.sort();
|
||||
}
|
||||
|
||||
async function waitForMatchingFiles(vaultPath: string, files: Record<string, string>) {
|
||||
const deadline = Date.now() + 10_000;
|
||||
let matches = await matchingFiles(vaultPath, files);
|
||||
while (matches.length !== Object.keys(files).length && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
matches = await matchingFiles(vaultPath, files);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) throw new Error("The Obsidian CLI is unavailable");
|
||||
const cliBinary = cli.binary;
|
||||
const output =
|
||||
process.env.E2E_CUSTOMISATION_CATALOGUE_OUTPUT ?? join(tmpdir(), "livesync-customisation-catalogue.json");
|
||||
const observations: Observation[] = [];
|
||||
const report: Record<string, unknown> = {
|
||||
revision: execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(),
|
||||
sourceDiffSha256: createHash("sha256")
|
||||
.update(new Uint8Array(execFileSync("git", ["diff", "--binary", "HEAD"])))
|
||||
.digest("hex"),
|
||||
scenarioSha256: createHash("sha256")
|
||||
.update(new Uint8Array(await readFile(fileURLToPath(import.meta.url))))
|
||||
.digest("hex"),
|
||||
commonlibEntrySha256: createHash("sha256")
|
||||
.update(
|
||||
new Uint8Array(
|
||||
await readFile(
|
||||
"node_modules/@vrtmrz/livesync-commonlib/dist/managers/EntryManager/EntryManagerImpls.js"
|
||||
)
|
||||
)
|
||||
)
|
||||
.digest("hex"),
|
||||
bundleSha256: createHash("sha256")
|
||||
.update(new Uint8Array(await readFile("main.js")))
|
||||
.digest("hex"),
|
||||
commonlibVersion: JSON.parse(await readFile("node_modules/@vrtmrz/livesync-commonlib/package.json", "utf8"))
|
||||
.version,
|
||||
observations,
|
||||
};
|
||||
const vault = await createTemporaryVault("livesync-customisation-catalogue-");
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
const record = (name: string, actual: unknown, expected: unknown) => {
|
||||
const observation = { name, expected, actual, passed: isDeepStrictEqual(actual, expected) };
|
||||
observations.push(observation);
|
||||
console.log(JSON.stringify(observation));
|
||||
};
|
||||
const evaluate = async <T>(code: string): Promise<T> => {
|
||||
if (!session) throw new Error("The isolated Obsidian session has not started");
|
||||
return await evalObsidianJson<T>(cliBinary, `(async()=>{${code}})()`, session.cliEnv, 60_000);
|
||||
};
|
||||
try {
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary,
|
||||
vault,
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
pluginData: createE2eCouchDbPluginData(
|
||||
{ uri: "http://127.0.0.1:9", username: "", password: "", dbName: "catalogue-local-only" },
|
||||
{ periodicReplication: false, syncOnFileOpen: false, useOnlyLocalChunk: true }
|
||||
),
|
||||
});
|
||||
report.pluginVersion = session.readiness.pluginVersion;
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
report.runtime = await evaluate(`${coreCode}
|
||||
core.services.setting.setDeviceAndVaultName('catalogue-local');
|
||||
Object.assign(core.settings,{
|
||||
isConfigured:true,usePluginSync:true,usePluginSyncV2:true,syncInternalFiles:false,
|
||||
autoSweepPlugins:false,autoSweepPluginsPeriodic:false,watchInternalFileChanges:false,
|
||||
syncOnlyRegEx:${JSON.stringify(allowList)},syncIgnoreRegEx:''
|
||||
});
|
||||
return JSON.stringify({userAgent:navigator.userAgent,databaseReady:core.localDatabase.isReady});
|
||||
`);
|
||||
|
||||
await writeFixtures(vault.path, pluginFiles);
|
||||
await evaluate(`${configCode}${settleCode}
|
||||
for(const path of ${JSON.stringify(Object.keys(pluginFiles))}){
|
||||
const stored=await addOn.storeCustomisationFileV2(path,${JSON.stringify(sourceTerm)});
|
||||
if(stored===false)throw new Error('Could not seed a Customisation Sync document');
|
||||
}
|
||||
await settle();
|
||||
return JSON.stringify({seeded:true});
|
||||
`);
|
||||
const opened = await evaluate<{ opened: boolean }>(
|
||||
`return JSON.stringify({opened:app.commands.executeCommandById('obsidian-livesync:livesync-plugin-dialog-ex')});`
|
||||
);
|
||||
if (!opened.opened) throw new Error("Could not open the Customisation Sync dialogue command");
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Customization Sync (Beta3)");
|
||||
await dialogue
|
||||
.locator("label")
|
||||
.filter({ hasText: "Maintenance mode" })
|
||||
.locator('input[type="checkbox"]')
|
||||
.check();
|
||||
await dialogue.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
});
|
||||
const catalogue = await evaluate<{ files: string[]; documents: number }>(`${configCode}${settleCode}
|
||||
await settle();
|
||||
${sourceRowCode}
|
||||
let documents=0;
|
||||
for await(const doc of core.localDatabase.findEntries('ix:','ix;')){
|
||||
if(doc.path.startsWith(${JSON.stringify(`ix:${sourceTerm}/PLUGIN_MAIN/${pluginName}%`)})&&!doc.deleted&&!doc._deleted)documents++;
|
||||
}
|
||||
return JSON.stringify({files:row.files.map(file=>file.filename).sort(),documents});
|
||||
`);
|
||||
record("#1271: all source documents remain in the database", catalogue.documents, 3);
|
||||
record(
|
||||
"#1271: one reload lists all files",
|
||||
catalogue.files,
|
||||
Object.keys(pluginFiles)
|
||||
.map((path) => path.substring(".obsidian/".length))
|
||||
.sort()
|
||||
);
|
||||
await removeFixtures(vault.path, pluginFiles);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Customization Sync (Beta3)");
|
||||
const row = dialogue.locator(`.filerow:has(select option[value="${sourceTerm}"])`);
|
||||
await row.locator("select").selectOption(sourceTerm);
|
||||
await row.locator(".body").getByRole("button", { name: "✓", exact: true }).click();
|
||||
});
|
||||
report.firstApply = { action: "UI Apply", sourceTerm };
|
||||
record(
|
||||
"#1271: first Apply restores all file contents",
|
||||
await waitForMatchingFiles(vault.path, pluginFiles),
|
||||
Object.keys(pluginFiles).sort()
|
||||
);
|
||||
|
||||
const secondPass = await evaluate<string[]>(`${configCode}${settleCode}
|
||||
await addOn.updatePluginList(false);await settle();${sourceRowCode}
|
||||
return JSON.stringify(row.files.map(file=>file.filename).sort());
|
||||
`);
|
||||
record(
|
||||
"#1271 control: a second pass lists all files",
|
||||
secondPass,
|
||||
Object.keys(pluginFiles)
|
||||
.map((path) => path.substring(".obsidian/".length))
|
||||
.sort()
|
||||
);
|
||||
await removeFixtures(vault.path, pluginFiles);
|
||||
await evaluate(`${configCode}${sourceRowCode}return JSON.stringify({applied:await addOn.applyDataV2(row)});`);
|
||||
record(
|
||||
"#1271 control: Apply after a second pass restores all contents",
|
||||
await matchingFiles(vault.path, pluginFiles),
|
||||
Object.keys(pluginFiles).sort()
|
||||
);
|
||||
} catch (error) {
|
||||
report.runtimeError = error instanceof Error ? error.stack : String(error);
|
||||
if (session) report.processOutput = JSON.stringify(session.app.output()).slice(-4_000);
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
await writeFile(output, JSON.stringify(report, null, 2) + "\n");
|
||||
console.log(`Verification report: ${output}`);
|
||||
} finally {
|
||||
try {
|
||||
if (session) await session.app.stop();
|
||||
} finally {
|
||||
await vault.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (observations.some((observation) => !observation.passed)) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,6 +1,12 @@
|
||||
import { mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { VERSIONING_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
MODE_AUTOMATIC,
|
||||
VERSIONING_DOCID,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type LoadedEntry,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { ENCRYPTED_INTERNAL_METADATA_FEATURE, REMOTE_FEATURE_GENERATION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import {
|
||||
@@ -23,11 +29,12 @@ import {
|
||||
prepareRemote,
|
||||
pushLocalChanges,
|
||||
waitForLiveSyncCoreReady,
|
||||
waitForLocalDatabaseEntry,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { waitForVisibleObsidianDialogue, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||
import type { Locator } from "playwright";
|
||||
import type { Locator, Page } from "playwright";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
|
||||
process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "20000";
|
||||
@@ -59,6 +66,18 @@ const pluginDir = ".obsidian/plugins/livesync-e2e-sample";
|
||||
const pluginManifestPath = `${pluginDir}/manifest.json`;
|
||||
const pluginMainPath = `${pluginDir}/main.js`;
|
||||
const pluginStylesPath = `${pluginDir}/styles.css`;
|
||||
const pluginDisplayName = "LiveSync E2E Sample";
|
||||
const pluginDataPath = `${pluginDir}/data.json`;
|
||||
const pluginDataContent = JSON.stringify({ source: "customisation-sync-a", enabled: true }, null, 4) + "\n";
|
||||
const targetPluginDataContent = JSON.stringify({ source: "customisation-sync-b", enabled: true }, null, 4) + "\n";
|
||||
const existingAutomaticKey = "SNIPPET/livesync-e2e-existing-automatic.css";
|
||||
const existingAutomaticSelections: ObsidianLiveSyncSettings["pluginSyncExtendedSetting"] = {
|
||||
[existingAutomaticKey]: {
|
||||
key: existingAutomaticKey,
|
||||
mode: MODE_AUTOMATIC,
|
||||
files: ["snippets/livesync-e2e-existing-automatic.css"],
|
||||
},
|
||||
};
|
||||
const identicalSnippetPath = ".obsidian/snippets/livesync-customisation-e2e-identical.css";
|
||||
const identicalSnippetContent = ".livesync-customisation-identical { color: #3d6f54; }\n";
|
||||
const differentSnippetPath = ".obsidian/snippets/livesync-customisation-e2e-different.css";
|
||||
@@ -104,7 +123,8 @@ const pluginFixtures = [
|
||||
{ path: pluginStylesPath, source: pluginStylesContent, target: targetPluginStylesContent },
|
||||
] as const;
|
||||
|
||||
type CustomisationSyncCase = "all" | "visibility" | "mtime";
|
||||
type CustomisationSyncCase = "all" | "visibility" | "mtime" | "automatic";
|
||||
type AutomaticInitialAction = "Overwrite Remote" | "Overwrite Local" | "Use newer";
|
||||
|
||||
type CustomisationSyncTestGlobal = typeof globalThis & {
|
||||
app?: { commands?: { executeCommandById(commandId: string): boolean } };
|
||||
@@ -131,6 +151,14 @@ type CustomisationScanResult = {
|
||||
files: string[];
|
||||
};
|
||||
|
||||
type AutomaticSyncSnapshot = {
|
||||
syncInternalFiles: boolean;
|
||||
pluginSyncExtendedSetting: Record<string, { key: string; mode: number; files: string[] }>;
|
||||
hiddenFiles: { path: string; rev: string; deleted: boolean }[];
|
||||
processedFiles: [string, string | undefined][];
|
||||
processedDatabaseFiles: [string, string | undefined][];
|
||||
};
|
||||
|
||||
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
|
||||
const fullPath = join(vaultPath, path);
|
||||
await mkdir(dirname(fullPath), { recursive: true });
|
||||
@@ -147,10 +175,11 @@ function selectedCase(): CustomisationSyncCase {
|
||||
if (args.length === 0) return "all";
|
||||
const arg = args[0];
|
||||
if (args.length !== 1 || !arg?.startsWith("--case=")) {
|
||||
throw new Error("Usage: test:e2e:obsidian:customisation-sync [--case=all|visibility|mtime]");
|
||||
throw new Error("Usage: test:e2e:obsidian:customisation-sync [--case=all|visibility|mtime|automatic]");
|
||||
}
|
||||
const selected = arg.slice("--case=".length);
|
||||
if (selected === "all" || selected === "visibility" || selected === "mtime") return selected;
|
||||
if (selected === "all" || selected === "visibility" || selected === "mtime" || selected === "automatic")
|
||||
return selected;
|
||||
throw new Error(`Unknown Customisation Sync E2E case: ${selected}`);
|
||||
}
|
||||
|
||||
@@ -244,6 +273,282 @@ async function inspectPluginFreshnessAndNewestSelection(session: ObsidianLiveSyn
|
||||
});
|
||||
}
|
||||
|
||||
async function openAutomaticModeForPlugin(page: Page, pluginName: string): Promise<Locator> {
|
||||
const opened = await page.evaluate(() =>
|
||||
(globalThis as CustomisationSyncTestGlobal).app?.commands?.executeCommandById(
|
||||
"obsidian-livesync:livesync-plugin-dialog-ex"
|
||||
)
|
||||
);
|
||||
if (opened !== true) throw new Error("Could not open the Customisation Sync dialogue command.");
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Customization Sync (Beta3)");
|
||||
const pluginRow = dialogue.locator(".labelrow").filter({ hasText: pluginName }).first();
|
||||
await pluginRow.waitFor({ state: "visible", timeout: 10000 });
|
||||
await pluginRow.locator("button.status").click();
|
||||
const automaticOption = page.locator(".menu-item").filter({ hasText: "Automatic" }).last();
|
||||
await automaticOption.waitFor({ state: "visible", timeout: 10000 });
|
||||
await automaticOption.click();
|
||||
return dialogue;
|
||||
}
|
||||
|
||||
async function selectAutomaticInitialAction(page: Page, label: AutomaticInitialAction): Promise<void> {
|
||||
const action = page.locator(".menu-item").filter({ hasText: label }).last();
|
||||
await action.waitFor({ state: "visible", timeout: 10000 });
|
||||
await action.click();
|
||||
}
|
||||
|
||||
async function readAutomaticSyncSnapshot(cliBinary: string, env: NodeJS.ProcessEnv): Promise<AutomaticSyncSnapshot> {
|
||||
return await evalObsidianJson<AutomaticSyncSnapshot>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const hidden=core.getAddOn('HiddenFileSync');",
|
||||
"const files=await hidden.getAllDatabaseFiles();",
|
||||
"const processed=(cache)=>Array.from(cache.keys()).sort().map((path)=>[path,cache.get(path)]);",
|
||||
"return JSON.stringify({",
|
||||
"syncInternalFiles:core.settings.syncInternalFiles,",
|
||||
"pluginSyncExtendedSetting:core.settings.pluginSyncExtendedSetting,",
|
||||
"hiddenFiles:files.map((entry)=>{const path=hidden.getPath(entry);return {path:path.startsWith('i:')?path.slice(2):path,rev:entry._rev??'',deleted:!!(entry.deleted||entry._deleted)};}).sort((a,b)=>a.path.localeCompare(b.path)),",
|
||||
"processedFiles:processed(hidden._fileInfoLastProcessed),",
|
||||
"processedDatabaseFiles:processed(hidden._databaseInfoLastProcessed),",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function readAutomaticFileContents(context: RunnerContext, session: ObsidianLiveSyncSession) {
|
||||
const files = await evalObsidianJson<
|
||||
{ path: string; entry: LoadedEntry | false; local: string; localMtime: number }[]
|
||||
>(
|
||||
context.cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
`const paths=${JSON.stringify([pluginStylesPath, pluginDataPath])};`,
|
||||
"const files=await Promise.all(paths.map(async(path)=>{",
|
||||
"const entry=await core.localDatabase.getDBEntry('i:'+path,undefined,false,true);",
|
||||
"const stat=await app.vault.adapter.stat(path);",
|
||||
"if(!stat) throw new Error('Missing Automatic fixture file: '+path);",
|
||||
"return {path,entry,local:await app.vault.adapter.read(path),localMtime:stat.mtime};",
|
||||
"}));",
|
||||
"return JSON.stringify(files);",
|
||||
"})()",
|
||||
].join(""),
|
||||
session.cliEnv
|
||||
);
|
||||
return await Promise.all(
|
||||
files.map(async (file) => ({
|
||||
path: file.path,
|
||||
stored: file.entry ? await readAsBlob(file.entry).text() : null,
|
||||
storedMtime: file.entry ? file.entry.mtime : 0,
|
||||
local: file.local,
|
||||
localMtime: file.localMtime,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
async function setHiddenSyncEnabledForFixture(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
enabled: boolean
|
||||
): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
`core.settings.syncInternalFiles=${JSON.stringify(enabled)};`,
|
||||
"return JSON.stringify({syncInternalFiles:core.settings.syncInternalFiles});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function inspectAutomaticSyncSelection(
|
||||
context: RunnerContext,
|
||||
session: ObsidianLiveSyncSession,
|
||||
vault: TemporaryVault,
|
||||
pluginName: string,
|
||||
initialAction: AutomaticInitialAction
|
||||
): Promise<void> {
|
||||
const noticeText =
|
||||
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.";
|
||||
const trackedFiles = [pluginStylesPath, pluginDataPath];
|
||||
const contentsBefore = await Promise.all(trackedFiles.map((path) => readVaultFile(vault.path, path)));
|
||||
const disabledBefore = await readAutomaticSyncSnapshot(context.cliBinary, session.cliEnv);
|
||||
assertEqual(
|
||||
JSON.stringify(disabledBefore.pluginSyncExtendedSetting[existingAutomaticKey]),
|
||||
JSON.stringify(existingAutomaticSelections[existingAutomaticKey]),
|
||||
"The fixture did not retain the saved Automatic selection while Hidden File Sync was disabled."
|
||||
);
|
||||
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const dialogue = await openAutomaticModeForPlugin(page, pluginDisplayName);
|
||||
const notice = page.locator(".notice:visible").filter({ hasText: noticeText }).last();
|
||||
await notice.waitFor({ state: "visible", timeout: 10000 });
|
||||
const initialActionMenu = page.locator(".menu:visible").filter({ hasText: "Initial Action" });
|
||||
if ((await initialActionMenu.count()) !== 0) {
|
||||
throw new Error("Automatic displayed an Initial Action menu while Hidden File Sync was disabled.");
|
||||
}
|
||||
await page.keyboard.press("Escape");
|
||||
await dialogue.waitFor({ state: "hidden", timeout: 10000 });
|
||||
await notice.click({ force: true });
|
||||
await notice.waitFor({ state: "hidden", timeout: 10000 });
|
||||
});
|
||||
|
||||
const disabledAfter = await readAutomaticSyncSnapshot(context.cliBinary, session.cliEnv);
|
||||
const contentsAfter = await Promise.all(trackedFiles.map((path) => readVaultFile(vault.path, path)));
|
||||
if (JSON.stringify(disabledAfter) !== JSON.stringify(disabledBefore)) {
|
||||
throw new Error(
|
||||
`Automatic changed Hidden File Sync state while it was disabled: ${JSON.stringify({
|
||||
before: disabledBefore,
|
||||
after: disabledAfter,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
assertEqual(
|
||||
JSON.stringify(contentsAfter),
|
||||
JSON.stringify(contentsBefore),
|
||||
"Automatic changed plug-in files while Hidden File Sync was disabled."
|
||||
);
|
||||
|
||||
// Exercise the second guard: the user may open Initial Action while enabled,
|
||||
// then disable Hidden File Sync before choosing a direction.
|
||||
await setHiddenSyncEnabledForFixture(context.cliBinary, session.cliEnv, true);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await openAutomaticModeForPlugin(page, pluginDisplayName);
|
||||
const initialActionMenu = page.locator(".menu:visible").filter({ hasText: "Initial Action" });
|
||||
await initialActionMenu.waitFor({ state: "visible", timeout: 10000 });
|
||||
});
|
||||
await setHiddenSyncEnabledForFixture(context.cliBinary, session.cliEnv, false);
|
||||
const beforeDisabledAction = await readAutomaticSyncSnapshot(context.cliBinary, session.cliEnv);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await selectAutomaticInitialAction(page, "Overwrite Remote");
|
||||
const notice = page.locator(".notice:visible").filter({ hasText: noticeText }).last();
|
||||
await notice.waitFor({ state: "visible", timeout: 10000 });
|
||||
await notice.click({ force: true });
|
||||
await notice.waitFor({ state: "hidden", timeout: 10000 });
|
||||
});
|
||||
const afterDisabledAction = await readAutomaticSyncSnapshot(context.cliBinary, session.cliEnv);
|
||||
if (JSON.stringify(afterDisabledAction) !== JSON.stringify(beforeDisabledAction)) {
|
||||
throw new Error(
|
||||
`Automatic changed Hidden File Sync state after it was disabled from Initial Action: ${JSON.stringify({
|
||||
before: beforeDisabledAction,
|
||||
after: afterDisabledAction,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
assertEqual(
|
||||
JSON.stringify(await Promise.all(trackedFiles.map((path) => readVaultFile(vault.path, path)))),
|
||||
JSON.stringify(contentsBefore),
|
||||
"Automatic changed plug-in files after Hidden File Sync was disabled from Initial Action."
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
await setHiddenSyncEnabledForFixture(context.cliBinary, session.cliEnv, true);
|
||||
if (initialAction !== "Overwrite Remote") {
|
||||
const fixtures = [
|
||||
{
|
||||
path: pluginStylesPath,
|
||||
stored: pluginStylesContent,
|
||||
local: targetPluginStylesContent,
|
||||
storedIsNewer: true,
|
||||
},
|
||||
{
|
||||
path: pluginDataPath,
|
||||
stored: pluginDataContent,
|
||||
local: targetPluginDataContent,
|
||||
storedIsNewer: false,
|
||||
},
|
||||
];
|
||||
const actualFiles = await readAutomaticFileContents(context, session);
|
||||
for (const fixture of fixtures) {
|
||||
const actual = actualFiles.find((file) => file.path === fixture.path);
|
||||
if (
|
||||
!actual ||
|
||||
actual.stored !== fixture.stored ||
|
||||
actual.local !== fixture.local ||
|
||||
actual.storedMtime > actual.localMtime !== fixture.storedIsNewer ||
|
||||
actual.storedMtime === actual.localMtime
|
||||
) {
|
||||
throw new Error(
|
||||
`Automatic direction fixture did not retain distinct contents and modification times: ${JSON.stringify(actual)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const dialogue = await openAutomaticModeForPlugin(page, pluginDisplayName);
|
||||
await selectAutomaticInitialAction(page, initialAction);
|
||||
for (const label of ["MAIN", "DATA"]) {
|
||||
const row = dialogue.locator(".filerow").filter({ hasText: label }).first();
|
||||
await row.locator(".statusnote").filter({ hasText: "Automatic" }).waitFor({ state: "visible" });
|
||||
}
|
||||
await page.keyboard.press("Escape");
|
||||
await dialogue.waitFor({ state: "hidden", timeout: 10000 });
|
||||
});
|
||||
|
||||
for (const path of [pluginManifestPath, pluginMainPath, pluginStylesPath, pluginDataPath]) {
|
||||
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path, { hidden: true });
|
||||
}
|
||||
const enabled = await readAutomaticSyncSnapshot(context.cliBinary, session.cliEnv);
|
||||
const mainMode = enabled.pluginSyncExtendedSetting[`PLUGIN_MAIN/${pluginName}`];
|
||||
const dataMode = enabled.pluginSyncExtendedSetting[`PLUGIN_DATA/${pluginName}`];
|
||||
if (mainMode?.mode !== MODE_AUTOMATIC || dataMode?.mode !== MODE_AUTOMATIC) {
|
||||
throw new Error(
|
||||
`Automatic did not save both plug-in group child settings: ${JSON.stringify({ mainMode, dataMode })}`
|
||||
);
|
||||
}
|
||||
assertEqual(
|
||||
JSON.stringify([...mainMode.files].sort()),
|
||||
JSON.stringify([
|
||||
`plugins/${pluginName}/main.js`,
|
||||
`plugins/${pluginName}/manifest.json`,
|
||||
`plugins/${pluginName}/styles.css`,
|
||||
]),
|
||||
"Automatic did not select the PLUGIN_MAIN files."
|
||||
);
|
||||
assertEqual(
|
||||
JSON.stringify([...dataMode.files].sort()),
|
||||
JSON.stringify([`plugins/${pluginName}/data.json`]),
|
||||
"Automatic did not select the PLUGIN_DATA file."
|
||||
);
|
||||
assertEqual(
|
||||
enabled.hiddenFiles.filter((entry) => entry.path.startsWith(pluginDir + "/")).length,
|
||||
4,
|
||||
"Automatic did not initialise all files in the PLUGIN_ALL group."
|
||||
);
|
||||
const expectedFiles = [
|
||||
{
|
||||
path: pluginStylesPath,
|
||||
content: initialAction === "Overwrite Remote" ? targetPluginStylesContent : pluginStylesContent,
|
||||
},
|
||||
{
|
||||
path: pluginDataPath,
|
||||
content: initialAction === "Overwrite Local" ? pluginDataContent : targetPluginDataContent,
|
||||
},
|
||||
];
|
||||
const deadline = Date.now() + 15000;
|
||||
let actualFiles = await readAutomaticFileContents(context, session);
|
||||
const contentsMatch = () =>
|
||||
actualFiles.every(
|
||||
(file, index) => file.stored === expectedFiles[index].content && file.local === expectedFiles[index].content
|
||||
);
|
||||
while (!contentsMatch() && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
actualFiles = await readAutomaticFileContents(context, session);
|
||||
}
|
||||
if (!contentsMatch()) {
|
||||
throw new Error(`${initialAction} did not apply both MAIN and DATA: ${JSON.stringify(actualFiles)}`);
|
||||
}
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
}
|
||||
|
||||
async function removeVaultFile(vaultPath: string, path: string): Promise<void> {
|
||||
await rm(join(vaultPath, path), { force: true });
|
||||
}
|
||||
@@ -287,7 +592,8 @@ async function waitForPathContent(
|
||||
async function startConfiguredSession(
|
||||
context: RunnerContext,
|
||||
vault: TemporaryVault,
|
||||
deviceName: string
|
||||
deviceName: string,
|
||||
settings: Partial<Pick<ObsidianLiveSyncSettings, "syncInternalFiles" | "pluginSyncExtendedSetting">> = {}
|
||||
): Promise<ObsidianLiveSyncSession> {
|
||||
const couchDbSettings = {
|
||||
uri: context.couchDb.uri,
|
||||
@@ -306,6 +612,7 @@ async function startConfiguredSession(
|
||||
autoSweepPlugins: false,
|
||||
autoSweepPluginsPeriodic: false,
|
||||
syncInternalFiles: false,
|
||||
...settings,
|
||||
};
|
||||
const session = await startObsidianLiveSyncSession({
|
||||
binary: context.binary,
|
||||
@@ -319,6 +626,18 @@ async function startConfiguredSession(
|
||||
});
|
||||
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
|
||||
await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv);
|
||||
if (Object.keys(settings.pluginSyncExtendedSetting ?? {}).length > 0) {
|
||||
const restoredSelections = await evalObsidianJson<ObsidianLiveSyncSettings["pluginSyncExtendedSetting"]>(
|
||||
context.cliBinary,
|
||||
"JSON.stringify(app.plugins.plugins['obsidian-livesync'].core.settings.pluginSyncExtendedSetting)",
|
||||
session.cliEnv
|
||||
);
|
||||
assertEqual(
|
||||
JSON.stringify(restoredSelections),
|
||||
JSON.stringify(settings.pluginSyncExtendedSetting),
|
||||
"The saved Automatic selection changed during start-up."
|
||||
);
|
||||
}
|
||||
await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, customisationSettings);
|
||||
await evalObsidianJson<unknown>(
|
||||
context.cliBinary,
|
||||
@@ -389,7 +708,7 @@ async function storeCustomisationFile(cliBinary: string, env: NodeJS.ProcessEnv,
|
||||
async function deleteCustomisationSyncEntry(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
|
||||
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN" | "PLUGIN_DATA",
|
||||
name: string,
|
||||
term?: string
|
||||
): Promise<void> {
|
||||
@@ -418,7 +737,7 @@ async function deleteCustomisationSyncEntry(
|
||||
async function waitForCustomisationEntry(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
|
||||
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN" | "PLUGIN_DATA",
|
||||
name: string,
|
||||
term?: string,
|
||||
timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000)
|
||||
@@ -430,7 +749,7 @@ async function waitForCustomisationEntry(
|
||||
async function waitForCustomisationEntries(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
|
||||
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN" | "PLUGIN_DATA",
|
||||
name: string,
|
||||
count: number,
|
||||
term?: string,
|
||||
@@ -570,10 +889,13 @@ async function applyRemoteCustomisationGroup(
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const testCase = selectedCase();
|
||||
async function runWorkflow(
|
||||
testCase: CustomisationSyncCase,
|
||||
initialAction: AutomaticInitialAction = "Overwrite Remote"
|
||||
): Promise<void> {
|
||||
const testVisibility = testCase === "all" || testCase === "visibility";
|
||||
const testMtime = testCase === "all" || testCase === "mtime";
|
||||
const testAutomatic = testCase === "automatic";
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) {
|
||||
@@ -604,6 +926,11 @@ async function main(): Promise<void> {
|
||||
await writeVaultFile(vaultA.path, pluginManifestPath, pluginManifestContent);
|
||||
await writeVaultFile(vaultA.path, pluginMainPath, pluginMainContent);
|
||||
await writeVaultFile(vaultA.path, pluginStylesPath, pluginStylesContent);
|
||||
if (testAutomatic) {
|
||||
await writeVaultFile(vaultA.path, pluginDataPath, pluginDataContent);
|
||||
await setVaultFileMtime(vaultA.path, pluginStylesPath, targetPluginMtime);
|
||||
await setVaultFileMtime(vaultA.path, pluginDataPath, sourcePluginMtime);
|
||||
}
|
||||
if (testVisibility) {
|
||||
for (const fixture of visibilityFixtures) {
|
||||
await writeVaultFile(vaultA.path, fixture.path, fixture.source);
|
||||
@@ -627,6 +954,14 @@ async function main(): Promise<void> {
|
||||
await setVaultFileMtime(vaultB.path, fixture.path, targetPluginMtime);
|
||||
}
|
||||
}
|
||||
if (testAutomatic) {
|
||||
for (const fixture of pluginFixtures) {
|
||||
await writeVaultFile(vaultB.path, fixture.path, fixture.target);
|
||||
}
|
||||
await writeVaultFile(vaultB.path, pluginDataPath, targetPluginDataContent);
|
||||
await setVaultFileMtime(vaultB.path, pluginStylesPath, sourcePluginMtime);
|
||||
await setVaultFileMtime(vaultB.path, pluginDataPath, targetPluginMtime);
|
||||
}
|
||||
|
||||
let session = await startConfiguredSession(context, vaultA, sourceDeviceName);
|
||||
const scanResult = await scanCustomisations(context.cliBinary, session.cliEnv);
|
||||
@@ -636,6 +971,9 @@ async function main(): Promise<void> {
|
||||
for (const fixture of pluginFixtures) {
|
||||
await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path);
|
||||
}
|
||||
if (testAutomatic) {
|
||||
await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginDataPath);
|
||||
}
|
||||
if (testVisibility) {
|
||||
for (const fixture of visibilityFixtures) {
|
||||
await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path);
|
||||
@@ -650,15 +988,35 @@ async function main(): Promise<void> {
|
||||
pluginName,
|
||||
3
|
||||
);
|
||||
const pluginDataEntry = testAutomatic
|
||||
? await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "PLUGIN_DATA", pluginName)
|
||||
: undefined;
|
||||
if (testAutomatic && initialAction !== "Overwrite Remote") {
|
||||
await setHiddenSyncEnabledForFixture(context.cliBinary, session.cliEnv, true);
|
||||
await evalObsidianJson<unknown>(
|
||||
context.cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const hidden=app.plugins.plugins['obsidian-livesync'].core.getAddOn('HiddenFileSync');",
|
||||
`const paths=${JSON.stringify([pluginManifestPath, pluginMainPath, pluginStylesPath, pluginDataPath])};`,
|
||||
"const result=await hidden.initialiseInternalFileSync('pushForce',false,paths);",
|
||||
"if(result!==true) throw new Error('Could not prepare source hidden-file documents.');",
|
||||
"return JSON.stringify({initialised:result});",
|
||||
"})()",
|
||||
].join(""),
|
||||
session.cliEnv
|
||||
);
|
||||
await setHiddenSyncEnabledForFixture(context.cliBinary, session.cliEnv, false);
|
||||
}
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => {
|
||||
const ids = new Set(docs.map((doc) => doc._id));
|
||||
const entries = [entry, configEntry, ...pluginEntries];
|
||||
const entries = [entry, configEntry, ...pluginEntries, ...(pluginDataEntry ? [pluginDataEntry] : [])];
|
||||
return entries.every(
|
||||
(target) => ids.has(target.id) && target.children.every((childId) => ids.has(childId))
|
||||
);
|
||||
});
|
||||
for (const target of [entry, configEntry, ...pluginEntries]) {
|
||||
for (const target of [entry, configEntry, ...pluginEntries, ...(pluginDataEntry ? [pluginDataEntry] : [])]) {
|
||||
const remoteEntry = await fetchCouchDbDocument(context.couchDb, context.dbName, target.id);
|
||||
if (
|
||||
!remoteEntry.path?.startsWith("/\\:") ||
|
||||
@@ -678,8 +1036,15 @@ async function main(): Promise<void> {
|
||||
throw new Error("The remote feature list does not declare encrypted internal Metadata.");
|
||||
}
|
||||
await session.app.stop();
|
||||
if (testAutomatic && initialAction === "Overwrite Remote") {
|
||||
for (const path of [pluginManifestPath, pluginMainPath, pluginStylesPath, pluginDataPath]) {
|
||||
await removeVaultFile(vaultA.path, path);
|
||||
}
|
||||
}
|
||||
|
||||
session = await startConfiguredSession(context, vaultB, targetDeviceName);
|
||||
session = await startConfiguredSession(context, vaultB, targetDeviceName, {
|
||||
pluginSyncExtendedSetting: testAutomatic ? existingAutomaticSelections : {},
|
||||
});
|
||||
if (testVisibility) {
|
||||
for (const fixture of visibilityFixtures) {
|
||||
await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path);
|
||||
@@ -692,6 +1057,15 @@ async function main(): Promise<void> {
|
||||
}
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName, sourceDeviceName);
|
||||
if (testAutomatic) {
|
||||
await waitForCustomisationEntry(
|
||||
context.cliBinary,
|
||||
session.cliEnv,
|
||||
"PLUGIN_DATA",
|
||||
pluginName,
|
||||
sourceDeviceName
|
||||
);
|
||||
}
|
||||
if (testVisibility) {
|
||||
for (const fixture of visibilityFixtures) {
|
||||
const name = fixture.path.split("/").pop() ?? fixture.path;
|
||||
@@ -721,10 +1095,43 @@ async function main(): Promise<void> {
|
||||
try {
|
||||
if (testVisibility) await inspectCustomisationVisibility(session);
|
||||
if (testMtime) await inspectPluginFreshnessAndNewestSelection(session);
|
||||
if (testAutomatic) {
|
||||
await inspectAutomaticSyncSelection(context, session, vaultB, pluginName, initialAction);
|
||||
}
|
||||
} catch (error) {
|
||||
await session.app.stop().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
if (testAutomatic) {
|
||||
await session.app.stop();
|
||||
const receivingVault = initialAction === "Overwrite Remote" ? vaultA : await createTemporaryVault();
|
||||
session = await startConfiguredSession(context, receivingVault, sourceDeviceName, {
|
||||
syncInternalFiles: true,
|
||||
});
|
||||
try {
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
const expectedStyles =
|
||||
initialAction === "Overwrite Remote" ? targetPluginStylesContent : pluginStylesContent;
|
||||
const expectedData = initialAction === "Overwrite Local" ? pluginDataContent : targetPluginDataContent;
|
||||
const receivedStyles = await waitForPathContent(
|
||||
receivingVault.path,
|
||||
pluginStylesPath,
|
||||
(content) => content === expectedStyles
|
||||
);
|
||||
const receivedData = await waitForPathContent(
|
||||
receivingVault.path,
|
||||
pluginDataPath,
|
||||
(content) => content === expectedData
|
||||
);
|
||||
assertEqual(receivedStyles, expectedStyles, "Automatic plug-in group lost PLUGIN_MAIN files.");
|
||||
assertEqual(receivedData, expectedData, "Automatic plug-in group lost the PLUGIN_DATA file.");
|
||||
} finally {
|
||||
await session.app.stop();
|
||||
if (receivingVault !== vaultA) await receivingVault.dispose();
|
||||
}
|
||||
console.log(`Automatic ${initialAction} initialised and transferred both plug-in group children.`);
|
||||
return;
|
||||
}
|
||||
if (testCase !== "all") {
|
||||
await session.app.stop();
|
||||
console.log(`Customisation Sync ${testCase} regression case passed.`);
|
||||
@@ -841,6 +1248,18 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const testCase = selectedCase();
|
||||
if (testCase === "automatic") {
|
||||
for (const action of ["Overwrite Remote", "Overwrite Local", "Use newer"] as const) {
|
||||
console.log(`Checking Automatic initial action: ${action}`);
|
||||
await runWorkflow(testCase, action);
|
||||
}
|
||||
} else {
|
||||
await runWorkflow(testCase);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exit(1);
|
||||
|
||||
@@ -63,6 +63,21 @@ const manualMergeJsonPath = ".obsidian/livesync-e2e-manual-merge.json";
|
||||
const targetPath = ".obsidian/livesync-targeted/only-a.json";
|
||||
const hiddenFileCliTimeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_CLI_TIMEOUT_MS ?? 90000);
|
||||
const hiddenFileInitialisationStateKey = "__livesyncE2EHiddenFileInitialisation";
|
||||
const disabledInitialisationProbeKey = "__livesyncE2EDisabledHiddenInitialisation";
|
||||
|
||||
type DisabledInitialisationSnapshot = {
|
||||
syncInternalFiles: boolean;
|
||||
hiddenFiles: { id: string; path: string; rev: string; deleted: boolean }[];
|
||||
processedFiles: [string, string | undefined][];
|
||||
processedDatabaseFiles: [string, string | undefined][];
|
||||
};
|
||||
|
||||
type DisabledInitialisationObservation = {
|
||||
result: boolean | null;
|
||||
before: DisabledInitialisationSnapshot;
|
||||
after: DisabledInitialisationSnapshot;
|
||||
progressNotices: string[];
|
||||
};
|
||||
|
||||
type RunnerContext = {
|
||||
binary: string;
|
||||
@@ -133,6 +148,114 @@ async function waitForPathDeleted(
|
||||
throw new Error(`Timed out waiting for deleted file: ${join(vaultPath, path)}`);
|
||||
}
|
||||
|
||||
async function assertDisabledInitialisationIsNoOp(
|
||||
context: RunnerContext,
|
||||
session: ObsidianLiveSyncSession,
|
||||
vault: TemporaryVault
|
||||
): Promise<void> {
|
||||
const snippetExisted = await pathExists(vault.path, snippetPath);
|
||||
const originalSnippet = snippetExisted ? await readVaultFile(vault.path, snippetPath) : undefined;
|
||||
const originalJson = await readVaultFile(vault.path, mergeJsonPath);
|
||||
const preservedSnippet = "body { --disabled-hidden-initialisation: preserved; }\n";
|
||||
const preservedJson = JSON.stringify({ disabledInitialisation: "preserved" }, null, 4) + "\n";
|
||||
await writeVaultFile(vault.path, snippetPath, preservedSnippet);
|
||||
await writeVaultFile(vault.path, mergeJsonPath, preservedJson);
|
||||
|
||||
try {
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.evaluate((stateKey) => {
|
||||
const state = { progressNotices: [] as string[] };
|
||||
const observer = new MutationObserver(() => {
|
||||
for (const notice of document.querySelectorAll<HTMLElement>(".notice")) {
|
||||
const text = notice.textContent?.trim() ?? "";
|
||||
if (text.includes("[⚙") && !state.progressNotices.includes(text)) {
|
||||
state.progressNotices.push(text);
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
||||
(globalThis as unknown as Record<string, { progressNotices: string[]; observer: MutationObserver }>)[
|
||||
stateKey
|
||||
] = { ...state, observer };
|
||||
}, disabledInitialisationProbeKey);
|
||||
});
|
||||
|
||||
const observation = await evalObsidianJson<DisabledInitialisationObservation>(
|
||||
context.cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const targetFiles=${JSON.stringify([snippetPath, mergeJsonPath])};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const addOn=core.getAddOn('HiddenFileSync');",
|
||||
"const snapshot=async()=>{",
|
||||
"const files=await addOn.getAllDatabaseFiles();",
|
||||
"const markers=(cache)=>Array.from(cache.keys()).sort().map((path)=>[path,cache.get(path)]);",
|
||||
"return {",
|
||||
"syncInternalFiles:core.settings.syncInternalFiles,",
|
||||
"hiddenFiles:files.map((entry)=>({id:entry._id,path:addOn.getPath(entry),rev:entry._rev??'',deleted:!!(entry.deleted||entry._deleted)})).sort((a,b)=>a.path.localeCompare(b.path)),",
|
||||
"processedFiles:markers(addOn._fileInfoLastProcessed),",
|
||||
"processedDatabaseFiles:markers(addOn._databaseInfoLastProcessed),",
|
||||
"};};",
|
||||
"const before=await snapshot();",
|
||||
"const result=await addOn.initialiseInternalFileSync('pullForce',true,targetFiles);",
|
||||
"const after=await snapshot();",
|
||||
"return JSON.stringify({result:result===undefined?null:result,before,after});",
|
||||
"})()",
|
||||
].join(""),
|
||||
session.cliEnv,
|
||||
hiddenFileCliTimeoutMs
|
||||
);
|
||||
const progressNotices = await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
return await page.evaluate((stateKey) => {
|
||||
const state = (
|
||||
globalThis as unknown as Record<
|
||||
string,
|
||||
{ progressNotices: string[]; observer: MutationObserver } | undefined
|
||||
>
|
||||
)[stateKey];
|
||||
state?.observer.disconnect();
|
||||
return state?.progressNotices ?? [];
|
||||
}, disabledInitialisationProbeKey);
|
||||
});
|
||||
|
||||
const failures: string[] = [];
|
||||
if (observation.result !== false)
|
||||
failures.push(`expected false result, received ${String(observation.result)}`);
|
||||
if (observation.before.syncInternalFiles !== false || observation.after.syncInternalFiles !== false) {
|
||||
failures.push("Hidden File Sync setting changed while initialisation was disabled");
|
||||
}
|
||||
if (JSON.stringify(observation.before) !== JSON.stringify(observation.after)) {
|
||||
failures.push("local hidden database state or processed markers changed");
|
||||
}
|
||||
if ((await readVaultFile(vault.path, snippetPath)) !== preservedSnippet) {
|
||||
failures.push("the existing local snippet was deleted or replaced");
|
||||
}
|
||||
if ((await readVaultFile(vault.path, mergeJsonPath)) !== preservedJson) {
|
||||
failures.push("the existing local JSON file was replaced");
|
||||
}
|
||||
if (progressNotices.length > 0)
|
||||
failures.push(`initialisation created progress Notices: ${JSON.stringify(progressNotices)}`);
|
||||
if (failures.length > 0) {
|
||||
throw new Error(
|
||||
`Disabled Hidden File Sync initialisation was not a no-op: ${failures.join("; ")}\n${JSON.stringify({
|
||||
observation,
|
||||
progressNotices,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
"Disabled Hidden File Sync initialisation returned false without changing hidden files or cached state."
|
||||
);
|
||||
} finally {
|
||||
await writeVaultFile(vault.path, mergeJsonPath, originalJson);
|
||||
if (snippetExisted && originalSnippet !== undefined) {
|
||||
await writeVaultFile(vault.path, snippetPath, originalSnippet);
|
||||
} else {
|
||||
await removeVaultFile(vault.path, snippetPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasJsonValues(content: string, values: Record<string, unknown>): boolean {
|
||||
try {
|
||||
const parsed = JSON.parse(content) as Record<string, unknown>;
|
||||
@@ -649,6 +772,7 @@ async function runInitialisationNoticeGrouping(context: RunnerContext, vault: Te
|
||||
"Transient start-up Notices remained before the Hidden File Sync initialisation check."
|
||||
);
|
||||
});
|
||||
await assertDisabledInitialisationIsNoOp(context, session, vault);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page.evaluate((stateKey) => {
|
||||
const obsidianApp = (globalThis as typeof globalThis & { app: any }).app;
|
||||
|
||||
@@ -286,8 +286,9 @@ async function main(): Promise<void> {
|
||||
artifactRoot,
|
||||
vault: slot.vault,
|
||||
startupGraceMs: 1000,
|
||||
pluginStartup: restart ? "natural" : undefined,
|
||||
pluginData: restart ? undefined : createE2eCouchDbPluginData(couch, overrides),
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(slot.vault.name),
|
||||
localStorageEntries: restart ? undefined : createE2eObsidianDeviceLocalState(slot.vault.name),
|
||||
env: { E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(await availablePort()) },
|
||||
lifecycle: {
|
||||
beforeLaunch: async () => {
|
||||
@@ -710,7 +711,7 @@ async function main(): Promise<void> {
|
||||
if (restartBucket)
|
||||
await evaluate(
|
||||
pair.a,
|
||||
`const deadline=Date.now()+3000;
|
||||
`const deadline=Date.now()+core.services.setting.currentSettings().batchSaveMinimumDelay*1000+3000;
|
||||
while(!globalThis.__issue1207Batch.inflightPaused&&Date.now()<deadline) await new Promise(resolve=>setTimeout(resolve,10));
|
||||
if(!globalThis.__issue1207Batch.inflightPaused) throw new Error('The in-flight test gate did not start');`
|
||||
);
|
||||
|
||||
@@ -43,6 +43,9 @@ const focusedScenarios = new Set([
|
||||
"hidden-file-snippet-sync",
|
||||
"hidden-file-selectors",
|
||||
"customisation-sync",
|
||||
"customisation-catalogue-reload",
|
||||
"optional-file-save-completion",
|
||||
"livesync-batch",
|
||||
"received-change-readiness",
|
||||
"internal-metadata-doctor",
|
||||
"setting-markdown-export",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { assertLocatorWithinViewport, assertNoHorizontalOverflow } from "@vrtmrz/obsidian-test-session";
|
||||
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
@@ -59,6 +60,10 @@ type LiveSyncTestPlugin = {
|
||||
useEdgeCaseMode: boolean;
|
||||
hashCacheMaxCount: number;
|
||||
customChunkSize: number;
|
||||
liveSync: boolean;
|
||||
periodicReplication: boolean;
|
||||
batchSave: boolean;
|
||||
batchSaveInLiveSync: boolean;
|
||||
};
|
||||
config: { getValue(key: string, defaultValue: string): Promise<string> };
|
||||
};
|
||||
@@ -643,6 +648,105 @@ async function verifyConfigDoctorFollowsCompatibilityReview(): Promise<string> {
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
async function verifyBatchSettings(vaultPath: string): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
let navigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
let settingsPage = await navigator.openPage("Sync Settings");
|
||||
const row = (name: string) =>
|
||||
settingsPage.locator(".setting-item").filter({ has: page.getByText(name, { exact: true }) });
|
||||
const legacyName = "Batch database update";
|
||||
const liveName = "Batch database updates in LiveSync mode";
|
||||
const delays = ["Minimum delay for batch database updating", "Maximum delay for batch database updating"];
|
||||
|
||||
async function assertState(liveSync: boolean, batchSave: boolean, batchSaveInLiveSync: boolean) {
|
||||
await page.waitForFunction(
|
||||
(expected) => {
|
||||
const settings = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins[
|
||||
"obsidian-livesync"
|
||||
]?.core.services.setting.currentSettings();
|
||||
return (
|
||||
settings?.liveSync === expected.liveSync &&
|
||||
settings?.batchSave === expected.batchSave &&
|
||||
settings?.batchSaveInLiveSync === expected.batchSaveInLiveSync
|
||||
);
|
||||
},
|
||||
{ liveSync, batchSave, batchSaveInLiveSync },
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
const visibleName = liveSync ? liveName : legacyName;
|
||||
await row(visibleName).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await row(liveSync ? legacyName : liveName).waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
const enabled = liveSync ? batchSaveInLiveSync : batchSave;
|
||||
if ((await row(visibleName).locator(".checkbox-container.is-enabled").count()) !== Number(enabled)) {
|
||||
throw new Error(`The displayed batching choice did not match ${visibleName}: ${enabled}`);
|
||||
}
|
||||
for (const name of delays) {
|
||||
await row(name).waitFor({ state: enabled ? "visible" : "hidden", timeout: uiTimeoutMs });
|
||||
}
|
||||
}
|
||||
|
||||
async function mode(value: "ONEVENTS" | "PERIODIC" | "LIVESYNC") {
|
||||
await row("Sync Mode").locator("select").selectOption(value);
|
||||
await page.waitForFunction(
|
||||
(value) => {
|
||||
const settings = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins[
|
||||
"obsidian-livesync"
|
||||
]?.core.services.setting.currentSettings();
|
||||
return (
|
||||
settings?.liveSync === (value === "LIVESYNC") &&
|
||||
settings?.periodicReplication === (value === "PERIODIC")
|
||||
);
|
||||
},
|
||||
value,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
}
|
||||
|
||||
await assertState(false, false, false);
|
||||
await row(legacyName).locator(".checkbox-container").click();
|
||||
await assertState(false, true, false);
|
||||
await mode("LIVESYNC");
|
||||
await assertState(true, true, false);
|
||||
await row(liveName).locator(".checkbox-container").click();
|
||||
await assertState(true, true, true);
|
||||
await mode("PERIODIC");
|
||||
await assertState(false, true, true);
|
||||
await row(legacyName).locator(".checkbox-container").click();
|
||||
await assertState(false, false, true);
|
||||
await mode("LIVESYNC");
|
||||
await assertState(true, false, true);
|
||||
await row(liveName).locator(".checkbox-container").click();
|
||||
await assertState(true, false, false);
|
||||
await mode("ONEVENTS");
|
||||
await assertState(false, false, false);
|
||||
await row(legacyName).locator(".checkbox-container").click();
|
||||
await assertState(false, true, false);
|
||||
await navigator.close();
|
||||
navigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
settingsPage = await navigator.openPage("Sync Settings");
|
||||
await assertState(false, true, false);
|
||||
await mode("LIVESYNC");
|
||||
await assertState(true, true, false);
|
||||
await navigator.close();
|
||||
const saved = JSON.parse(
|
||||
await readFile(join(vaultPath, ".obsidian/plugins/obsidian-livesync/data.json"), "utf8")
|
||||
);
|
||||
if (saved.batchSave !== true || saved.batchSaveInLiveSync !== false || saved.liveSync !== true) {
|
||||
throw new Error("The independent batching choices were not saved to plug-in data.");
|
||||
}
|
||||
navigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
settingsPage = await navigator.openPage("Sync Settings");
|
||||
await assertState(true, true, false);
|
||||
await mode("ONEVENTS");
|
||||
await row(legacyName).locator(".checkbox-container").click();
|
||||
await assertState(false, false, false);
|
||||
await navigator.close();
|
||||
console.log(
|
||||
"Batching controls start disabled, show the current mode's delays, and retain independent saved choices."
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyEffectiveSettings(): Promise<"declarative" | "imperative"> {
|
||||
return await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const sleepPreferences = await page.evaluate(() => {
|
||||
@@ -1080,6 +1184,7 @@ async function main(): Promise<void> {
|
||||
console.log(`Config Doctor screenshot: ${configDoctorScreenshot}`);
|
||||
}
|
||||
settingsRenderer = await verifyEffectiveSettings();
|
||||
await verifyBatchSettings(vault.path);
|
||||
const initialisation = await verifyPendingSettingsInitialisationFlow();
|
||||
const p2pInitialisation = await captureP2PSettingsInitialisationDialogue();
|
||||
console.log(
|
||||
|
||||
Reference in new issue
Block a user