mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-01 15:02:31 +00:00
Cover multi-file Customisation Sync freshness in real Obsidian
This commit is contained in:
@@ -219,6 +219,8 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms
|
||||
|
||||
`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.
|
||||
|
||||
The workflow also opens the Customisation Sync dialogue. `--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, this check runs before the existing apply, update, and deletion workflow.
|
||||
|
||||
`test:e2e:obsidian:remote-feature-change` starts real Obsidian with continuous CouchDB replication, then changes the remote version document from generation 12 to generation 13 with an unknown feature. It waits for the control document to reach the local database and the active Replicator to retire, checks that another replication is refused, and verifies that an already accepted Vault note remains intact. After restarting the same Vault, the current remote declaration still blocks finite replication and the actual continuous connection attempt. No KV feature history is involved. It is a focused test outside `test:e2e:obsidian:local-suite`; recovery with a future compatible client remains a separate validation boundary.
|
||||
|
||||
`test:e2e:obsidian:internal-metadata-migration` enables internal Metadata encryption through the settings UI without Rebuild. It checks unchanged plaintext and rewritten encrypted Hidden File Sync and Customisation Sync Metadata in CouchDB, stable document IDs, mismatch rejection on a second device, and file restoration after aligning settings. It then turns the preference OFF, runs Fast Fetch, and compares content loaded from both Metadata representations and their Chunks while retaining the remote feature declaration. These focused tests use the local CouchDB fixture and are outside `test:e2e:obsidian:local-suite`.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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 { VERSIONING_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ENCRYPTED_INTERNAL_METADATA_FEATURE, REMOTE_FEATURE_GENERATION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
waitForLiveSyncCoreReady,
|
||||
} 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";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
|
||||
@@ -80,8 +81,23 @@ const pluginMainContent = [
|
||||
"",
|
||||
].join("\n");
|
||||
const pluginStylesContent = ".livesync-e2e-sample { color: #73548f; }\n";
|
||||
const targetPluginStylesContent = ".livesync-e2e-sample { color: #3d6f54; }\n";
|
||||
// These dates straddle a signed 32-bit wrap boundary for millisecond timestamps.
|
||||
const sourcePluginMtime = new Date("2026-09-01T12:00:00.000Z");
|
||||
const targetPluginMtime = new Date("2026-09-15T12:00:00.000Z");
|
||||
const sourceDeviceName = "customisation-sync-a";
|
||||
const targetDeviceName = "customisation-sync-b";
|
||||
const pluginFixtures = [
|
||||
{ path: pluginManifestPath, source: pluginManifestContent, target: pluginManifestContent },
|
||||
{ path: pluginMainPath, source: pluginMainContent, target: pluginMainContent },
|
||||
{ path: pluginStylesPath, source: pluginStylesContent, target: targetPluginStylesContent },
|
||||
] as const;
|
||||
|
||||
type CustomisationSyncCase = "all" | "mtime";
|
||||
|
||||
type CustomisationSyncTestGlobal = typeof globalThis & {
|
||||
app?: { commands?: { executeCommandById(commandId: string): boolean } };
|
||||
};
|
||||
|
||||
type RunnerContext = {
|
||||
binary: string;
|
||||
@@ -110,6 +126,67 @@ async function writeVaultFile(vaultPath: string, path: string, content: string):
|
||||
await writeFile(fullPath, content, "utf-8");
|
||||
}
|
||||
|
||||
async function setVaultFileMtime(vaultPath: string, path: string, mtime: Date): Promise<void> {
|
||||
const fullPath = join(vaultPath, path);
|
||||
await utimes(fullPath, mtime, mtime);
|
||||
}
|
||||
|
||||
function selectedCase(): CustomisationSyncCase {
|
||||
const args = process.argv.slice(2);
|
||||
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|mtime]");
|
||||
}
|
||||
const selected = arg.slice("--case=".length);
|
||||
if (selected === "all" || selected === "mtime") return selected;
|
||||
throw new Error(`Unknown Customisation Sync E2E case: ${selected}`);
|
||||
}
|
||||
|
||||
async function inspectPluginFreshnessAndNewestSelection(session: ObsidianLiveSyncSession): Promise<void> {
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
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 pluginMainRow = dialogue.locator(`.filerow:has(select option[value="${sourceDeviceName}"])`).first();
|
||||
const sourceOption = pluginMainRow.locator(`select option[value="${sourceDeviceName}"]`);
|
||||
const sourceSelect = pluginMainRow.locator("select");
|
||||
const failures: string[] = [];
|
||||
|
||||
await sourceOption.waitFor({ state: "attached", timeout: 10000 });
|
||||
await sourceSelect.selectOption(sourceDeviceName);
|
||||
try {
|
||||
await pluginMainRow.locator(".chip.modified").filter({ hasText: "Older" }).waitFor({
|
||||
state: "visible",
|
||||
timeout: 10000,
|
||||
});
|
||||
} catch {
|
||||
const freshness = (await pluginMainRow.locator(".chip.modified").textContent())?.trim() || "(empty)";
|
||||
failures.push(`Expected the remote multi-file plug-in copy to be Older; found '${freshness}'.`);
|
||||
}
|
||||
|
||||
await dialogue.getByRole("button", { name: "Deselect all", exact: true }).click();
|
||||
await sourceSelect.waitFor({ state: "visible", timeout: 10000 });
|
||||
await page.waitForTimeout(50);
|
||||
if ((await sourceSelect.inputValue()) !== "") {
|
||||
throw new Error("Deselect all did not clear the selected Customisation Sync source.");
|
||||
}
|
||||
|
||||
await dialogue.getByRole("button", { name: "Select All Shiny", exact: true }).click();
|
||||
await page.waitForTimeout(100);
|
||||
if ((await sourceSelect.inputValue()) !== "") {
|
||||
failures.push("Select All Shiny chose the older multi-file plug-in copy.");
|
||||
}
|
||||
if (failures.length > 0) throw new Error(failures.join("\n"));
|
||||
await page.keyboard.press("Escape");
|
||||
await dialogue.waitFor({ state: "hidden", timeout: 10000 });
|
||||
});
|
||||
}
|
||||
|
||||
async function removeVaultFile(vaultPath: string, path: string): Promise<void> {
|
||||
await rm(join(vaultPath, path), { force: true });
|
||||
}
|
||||
@@ -437,6 +514,8 @@ async function applyRemoteCustomisationGroup(
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const testCase = selectedCase();
|
||||
const testMtime = testCase === "all" || testCase === "mtime";
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) {
|
||||
@@ -467,15 +546,26 @@ async function main(): Promise<void> {
|
||||
await writeVaultFile(vaultA.path, pluginManifestPath, pluginManifestContent);
|
||||
await writeVaultFile(vaultA.path, pluginMainPath, pluginMainContent);
|
||||
await writeVaultFile(vaultA.path, pluginStylesPath, pluginStylesContent);
|
||||
if (testMtime) {
|
||||
for (const fixture of pluginFixtures) {
|
||||
await setVaultFileMtime(vaultA.path, fixture.path, sourcePluginMtime);
|
||||
}
|
||||
}
|
||||
if (testMtime) {
|
||||
for (const fixture of pluginFixtures) {
|
||||
await writeVaultFile(vaultB.path, fixture.path, fixture.target);
|
||||
await setVaultFileMtime(vaultB.path, fixture.path, targetPluginMtime);
|
||||
}
|
||||
}
|
||||
|
||||
let session = await startConfiguredSession(context, vaultA, sourceDeviceName);
|
||||
const scanResult = await scanCustomisations(context.cliBinary, session.cliEnv);
|
||||
console.log(`Customisation scan files: ${scanResult.files.join(", ") || "(none)"}`);
|
||||
await storeCustomisationFile(context.cliBinary, session.cliEnv, snippetPath);
|
||||
await storeCustomisationFile(context.cliBinary, session.cliEnv, configPath);
|
||||
await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginManifestPath);
|
||||
await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginMainPath);
|
||||
await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginStylesPath);
|
||||
for (const fixture of pluginFixtures) {
|
||||
await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path);
|
||||
}
|
||||
const entry = await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName);
|
||||
const configEntry = await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "CONFIG", configName);
|
||||
const pluginEntries = await waitForCustomisationEntries(
|
||||
@@ -495,21 +585,62 @@ async function main(): Promise<void> {
|
||||
});
|
||||
for (const target of [entry, configEntry, ...pluginEntries]) {
|
||||
const remoteEntry = await fetchCouchDbDocument(context.couchDb, context.dbName, target.id);
|
||||
if (!remoteEntry.path?.startsWith("/\\:") || remoteEntry.children?.length !== 0 ||
|
||||
remoteEntry.ctime !== 0 || remoteEntry.mtime !== 0 || remoteEntry.size !== 0) {
|
||||
if (
|
||||
!remoteEntry.path?.startsWith("/\\:") ||
|
||||
remoteEntry.children?.length !== 0 ||
|
||||
remoteEntry.ctime !== 0 ||
|
||||
remoteEntry.mtime !== 0 ||
|
||||
remoteEntry.size !== 0
|
||||
) {
|
||||
throw new Error(`Customisation Sync Metadata was not encrypted for ${target.id}.`);
|
||||
}
|
||||
}
|
||||
const versionInfo = await fetchCouchDbDocument(context.couchDb, context.dbName, VERSIONING_DOCID);
|
||||
if (versionInfo.version !== REMOTE_FEATURE_GENERATION ||
|
||||
!(versionInfo.used_features as unknown[] | undefined)?.includes(ENCRYPTED_INTERNAL_METADATA_FEATURE)) {
|
||||
if (
|
||||
versionInfo.version !== REMOTE_FEATURE_GENERATION ||
|
||||
!(versionInfo.used_features as unknown[] | undefined)?.includes(ENCRYPTED_INTERNAL_METADATA_FEATURE)
|
||||
) {
|
||||
throw new Error("The remote feature list does not declare encrypted internal Metadata.");
|
||||
}
|
||||
await session.app.stop();
|
||||
|
||||
session = await startConfiguredSession(context, vaultB, targetDeviceName);
|
||||
if (testMtime) {
|
||||
for (const fixture of pluginFixtures) {
|
||||
await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path);
|
||||
}
|
||||
}
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName, sourceDeviceName);
|
||||
if (testMtime) {
|
||||
await waitForCustomisationEntries(
|
||||
context.cliBinary,
|
||||
session.cliEnv,
|
||||
"PLUGIN_MAIN",
|
||||
pluginName,
|
||||
3,
|
||||
sourceDeviceName
|
||||
);
|
||||
await waitForCustomisationEntries(
|
||||
context.cliBinary,
|
||||
session.cliEnv,
|
||||
"PLUGIN_MAIN",
|
||||
pluginName,
|
||||
3,
|
||||
targetDeviceName
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (testMtime) await inspectPluginFreshnessAndNewestSelection(session);
|
||||
} catch (error) {
|
||||
await session.app.stop().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
if (testCase !== "all") {
|
||||
await session.app.stop();
|
||||
console.log(`Customisation Sync ${testCase} regression case passed.`);
|
||||
return;
|
||||
}
|
||||
assertEqual(
|
||||
await pathExists(vaultB.path, snippetPath),
|
||||
false,
|
||||
|
||||
Reference in New Issue
Block a user