From 9d30bed52ddd707a7af3ac9ed867b22a195ebe67 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 23 Oct 2026 01:11:41 +0000 Subject: [PATCH] Clarify Hidden File Sync selectors and test normal allow-lists --- docs/settings.md | 6 +- docs/tips/hidden-file-sync.md | 4 + package.json | 1 + .../messages/LiveSyncProvisionalMessages.ts | 4 + .../CmdHiddenFileSync.selectors.unit.spec.ts | 127 +++++++++++ .../features/SettingDialogue/PaneSelector.ts | 6 +- test/e2e-obsidian/README.md | 2 + .../scripts/hidden-file-selectors.ts | 202 ++++++++++++++++++ test/e2e-obsidian/scripts/run-focused.ts | 1 + test/fixtures/optionalFileSyncDatabase.ts | 78 +++++++ updates.md | 7 + 11 files changed, 432 insertions(+), 6 deletions(-) create mode 100644 src/features/HiddenFileSync/CmdHiddenFileSync.selectors.unit.spec.ts create mode 100644 test/e2e-obsidian/scripts/hidden-file-selectors.ts create mode 100644 test/fixtures/optionalFileSyncDatabase.ts diff --git a/docs/settings.md b/docs/settings.md index 0885589c..f328db64 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -750,11 +750,13 @@ If enabled, the notification of hidden files change will be suppressed. #### Synchronising files -(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files. +Setting key: syncOnlyRegEx +(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns. #### Non-Synchronising files -(RegExp) If this is set, any changes to local and remote files that match this will be skipped. +Setting key: syncIgnoreRegEx +(RegExp) Skip changes to matching normal and hidden files, locally and remotely. #### Maximum file size diff --git a/docs/tips/hidden-file-sync.md b/docs/tips/hidden-file-sync.md index 6b270da2..d761bb73 100644 --- a/docs/tips/hidden-file-sync.md +++ b/docs/tips/hidden-file-sync.md @@ -32,6 +32,10 @@ Enabling Hidden File Sync requires an initialisation direction: Prefer a narrow target list. Device-specific workspace state and another plug-in's credentials are usually poor candidates for cross-device synchronisation. +`Normal Files` → `Synchronising files` limits ordinary files only. Hidden File Sync uses its own `Target patterns`. The `Non-Synchronising files` patterns under `Normal Files` also exclude matching hidden files, so review them alongside the hidden-file selectors. + +If an earlier version skipped hidden files because of the normal-file allow-list, repeat Hidden File Sync initialisation after updating. A routine scan can skip unchanged files which were already checked. Back up the Vault and choose the intended source direction as described above. + Target patterns also control directory traversal. A pattern must therefore match each parent directory as well as the intended files. For example, the following pattern admits the `.obsidian` parent and only its `snippets` subtree: ```text diff --git a/package.json b/package.json index a6ff13d4..4edfd29d 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "test:e2e:obsidian:two-vault-sync": "tsx test/e2e-obsidian/scripts/two-vault-sync.ts", "test:e2e:obsidian:security-seed-reconnect": "tsx test/e2e-obsidian/scripts/security-seed-reconnect.ts", "test:e2e:obsidian:hidden-file-snippet-sync": "tsx test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts", + "test:e2e:obsidian:hidden-file-selectors": "tsx test/e2e-obsidian/scripts/hidden-file-selectors.ts", "test:e2e:obsidian:customisation-sync": "tsx test/e2e-obsidian/scripts/customisation-sync.ts", "test:e2e:obsidian:received-change-readiness": "tsx test/e2e-obsidian/scripts/received-change-readiness.ts", "test:e2e:obsidian:remote-feature-change": "tsx test/e2e-obsidian/scripts/remote-feature-change.ts", diff --git a/src/common/messages/LiveSyncProvisionalMessages.ts b/src/common/messages/LiveSyncProvisionalMessages.ts index 34ae100b..17ebe033 100644 --- a/src/common/messages/LiveSyncProvisionalMessages.ts +++ b/src/common/messages/LiveSyncProvisionalMessages.ts @@ -7,6 +7,10 @@ * remove it from this map in the same change. */ export const liveSyncProvisionalEnglishMessages = { + "(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.": + "(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.", + "(RegExp) Skip changes to matching normal and hidden files, locally and remotely.": + "(RegExp) Skip changes to matching normal and hidden files, locally and remotely.", "Preparing follow-up report…": "Preparing follow-up report…", "Cancelling local search. Checked ${COUNT} entries.": "Cancelling local search. Checked ${COUNT} entries.", "Search current Vault paths and current local Metadata only. Older history and remote-only files are not searched.": diff --git a/src/features/HiddenFileSync/CmdHiddenFileSync.selectors.unit.spec.ts b/src/features/HiddenFileSync/CmdHiddenFileSync.selectors.unit.spec.ts new file mode 100644 index 00000000..4ec0f752 --- /dev/null +++ b/src/features/HiddenFileSync/CmdHiddenFileSync.selectors.unit.spec.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/deps.ts", () => ({})); +vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} })); +vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" })); +vi.mock("./configureHiddenFileSyncMode.ts", () => ({ configureHiddenFileSyncMode: vi.fn() })); + +import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase"; +import { HiddenFileSync } from "./CmdHiddenFileSync"; + +const hiddenPath = ".obsidian/plugins/example/main.js" as FilePath; +const prefixedPath = `i:${hiddenPath}`; +const body = "module.exports = class ExamplePlugin {};"; +const allowList = "\\.(?:md|png|pdf)$"; +const fixtureOwners: Awaited>[] = []; + +async function hiddenFiles() { + const fixture = await createOptionalFileSyncDatabase(); + fixtureOwners.push(fixture); + fixture.settings.usePluginSync = false; + const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync; + Object.assign(hiddenFileSync, { + core: { + settings: fixture.settings, + services: { + ...fixture.services, + vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) }, + }, + }, + cacheFileRegExps: new Map(), + cacheCustomisationSyncIgnoredFiles: new Map(), + }); + return { ...fixture, hiddenFileSync }; +} + +afterEach(async () => { + vi.restoreAllMocks(); + for (const fixture of fixtureOwners.splice(0)) await fixture.dispose(); +}); + +describe("issue #1272: Hidden File Sync with normal-file selectors", () => { + it("stores a selected hidden file while the normal-file allow-list is set", async () => { + const fixture = await hiddenFiles(); + fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx; + await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(true); + + await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(prefixedPath, body))).resolves.toMatchObject({ + ok: true, + }); + const stored = await fixture.localDatabase.getDBEntry(prefixedPath as FilePath); + expect(stored).not.toBe(false); + if (stored) expect(await readContent(stored)).toBe(body); + }); + + it("reads existing hidden-file content while the normal-file allow-list is set", async () => { + const fixture = await hiddenFiles(); + await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(prefixedPath, body))).resolves.toMatchObject({ + ok: true, + }); + const meta = await fixture.localDatabase.getDBEntryMeta(prefixedPath as FilePath); + if (!meta) throw new Error("The seeded hidden-file Metadata is unavailable"); + fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx; + await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(true); + + const entry = await fixture.localDatabase.getDBEntryFromMeta(meta); + expect(entry).not.toBe(false); + if (entry) expect(await readContent(entry)).toBe(body); + }); + + it("stores and reads the same hidden file when the normal-file allow-list is empty", async () => { + const fixture = await hiddenFiles(); + await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(prefixedPath, body))).resolves.toMatchObject({ + ok: true, + }); + const entry = await fixture.localDatabase.getDBEntry(prefixedPath as FilePath); + expect(entry).not.toBe(false); + if (entry) expect(await readContent(entry)).toBe(body); + }); + + it("continues to apply the normal-file allow-list to ordinary files", async () => { + const fixture = await hiddenFiles(); + fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx; + expect(fixture.localDatabase.isTargetFile("notes/example.md" as FilePath)).toBe(true); + expect(fixture.localDatabase.isTargetFile("notes/example.js" as FilePath)).toBe(false); + expect(fixture.localDatabase.isTargetFile("i:.obsidian/example.md" as FilePath)).toBe(true); + }); + + it("keeps Customisation Sync documents usable with the same normal-file allow-list", async () => { + const fixture = await hiddenFiles(); + fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx; + const path = "ix:device/PLUGIN_MAIN/example%main.js" as FilePath; + await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(path, body))).resolves.toMatchObject({ + ok: true, + }); + const entry = await fixture.localDatabase.getDBEntry(path); + expect(entry).not.toBe(false); + if (entry) expect(await readContent(entry)).toBe(body); + }); + + it("keeps Hidden File Sync target and ignore patterns effective", async () => { + const fixture = await hiddenFiles(); + fixture.settings.syncInternalFilesTargetPatterns = + "^\\.obsidian/plugins/" as typeof fixture.settings.syncInternalFilesTargetPatterns; + await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(true); + await expect(fixture.hiddenFileSync.isTargetFile(".obsidian/snippets/example.css" as FilePath)).resolves.toBe( + false + ); + fixture.settings.syncInternalFilesIgnorePatterns = + "main\\.js$" as typeof fixture.settings.syncInternalFilesIgnorePatterns; + await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(false); + }); + + it("compatibility: applies the normal-file ignore-list to hidden documents", async () => { + const fixture = await hiddenFiles(); + fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx; + fixture.settings.syncIgnoreRegEx = "^\\.obsidian/" as typeof fixture.settings.syncIgnoreRegEx; + expect(fixture.localDatabase.isTargetFile(prefixedPath as FilePath)).toBe(false); + }); + + it("rejects hidden documents when Hidden File Sync is disabled", async () => { + const fixture = await hiddenFiles(); + fixture.settings.syncInternalFiles = false; + expect(fixture.localDatabase.isTargetFile(prefixedPath as FilePath)).toBe(false); + }); +}); diff --git a/src/modules/features/SettingDialogue/PaneSelector.ts b/src/modules/features/SettingDialogue/PaneSelector.ts index bfd7c0b1..783d6e99 100644 --- a/src/modules/features/SettingDialogue/PaneSelector.ts +++ b/src/modules/features/SettingDialogue/PaneSelector.ts @@ -13,7 +13,7 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme .setName($msg("Synchronising files")) .setDesc( $msg( - "(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files." + "(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns." ) ); const syncFilesControl = mount(MultipleRegExpControl, { @@ -32,9 +32,7 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme const nonSyncFilesSetting = new Setting(paneEl) .setName($msg("Non-Synchronising files")) - .setDesc( - $msg("(RegExp) If this is set, any changes to local and remote files that match this will be skipped.") - ); + .setDesc($msg("(RegExp) Skip changes to matching normal and hidden files, locally and remotely.")); const nonSyncFilesControl = mount(MultipleRegExpControl, { target: nonSyncFilesSetting.controlEl, diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index b4e854a1..3e3093e7 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -248,6 +248,8 @@ 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. 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. +`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. 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. + `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=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. diff --git a/test/e2e-obsidian/scripts/hidden-file-selectors.ts b/test/e2e-obsidian/scripts/hidden-file-selectors.ts new file mode 100644 index 00000000..8686daa7 --- /dev/null +++ b/test/e2e-obsidian/scripts/hidden-file-selectors.ts @@ -0,0 +1,202 @@ +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 { createTemporaryVault } from "../runner/vault.ts"; + +const hiddenRoot = ".obsidian/livesync-selector-example"; +const hiddenFiles = { + [`${hiddenRoot}/main.js`]: "console.log('hidden fixture');\n", + [`${hiddenRoot}/styles.css`]: ".hidden-fixture { color: blue; }\n", + [`${hiddenRoot}/settings.json`]: '{"fixture":true}\n', +}; +const controlRoot = ".obsidian/livesync-selector-control"; +const controlFiles = { + [`${controlRoot}/main.js`]: "console.log('control fixture');\n", + [`${controlRoot}/styles.css`]: ".control-fixture { color: red; }\n", + [`${controlRoot}/settings.json`]: '{"control":true}\n', +}; +const allowList = "\\.(?:md|png|pdf)$"; +const coreCode = "const core=app.plugins.plugins['obsidian-livesync'].core;"; +const hiddenCode = `${coreCode}const addOn=core.getAddOn('HiddenFileSync');`; + +type Observation = { name: string; expected: unknown; actual: unknown; passed: boolean }; + +async function writeFixtures(vaultPath: string, files: Record) { + 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) { + for (const path of Object.keys(files)) await rm(join(vaultPath, path), { force: true }); +} + +async function matchingFiles(vaultPath: string, files: Record) { + 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 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_HIDDEN_FILE_SELECTORS_OUTPUT ?? join(tmpdir(), "livesync-hidden-file-selectors.json"); + const observations: Observation[] = []; + const report: Record = { + 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-hidden-file-selectors-"); + 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 (code: string): Promise => { + if (!session) throw new Error("The isolated Obsidian session has not started"); + return await evalObsidianJson(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: "selector-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('selector-local'); + Object.assign(core.settings,{ + isConfigured:true,usePluginSync:false,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, hiddenFiles); + const uploadCode = (normalAllowList: string, files: Record) => `${hiddenCode} + Object.assign(core.settings,{ + syncInternalFiles:true,syncOnlyRegEx:${JSON.stringify(normalAllowList)}, + syncInternalFilesTargetPatterns:'^\\\\.obsidian(?:$|/livesync-selector-(?:example|control)(?:/|$))',syncInternalFilesIgnorePatterns:'' + }); + const candidates=await addOn.scanInternalFileNames(); + const selected=candidates.filter(path=>${JSON.stringify(Object.keys(files))}.includes(path)); + if(selected.length!==3)throw new Error('Hidden-file fixture selection failed: '+JSON.stringify({selected,target:core.settings.syncInternalFilesTargetPatterns})); + for(const path of selected)await addOn.updateLastProcessedAsActualFile(path); + await addOn.rebuildFromStorage(false,${JSON.stringify(Object.keys(files))},false); + const paths=[]; + for await(const doc of core.localDatabase.findEntries('i:','i;')){ + if(${JSON.stringify(Object.keys(files).map((path) => `i:${path}`))}.includes(doc.path)&&!doc.deleted&&!doc._deleted)paths.push(doc.path.substring(2)); + } + return JSON.stringify(paths.sort()); + `; + const hiddenUpload = await evaluate(uploadCode(allowList, hiddenFiles)); + record( + "#1272: a normal allow-list permits selected hidden uploads", + hiddenUpload, + Object.keys(hiddenFiles).sort() + ); + await writeFixtures(vault.path, controlFiles); + const uploadControl = await evaluate(uploadCode("", controlFiles)); + record( + "#1272 control: clearing the normal allow-list permits hidden uploads", + uploadControl, + Object.keys(controlFiles).sort() + ); + + const pullCode = (normalAllowList: string, files: Record) => `${hiddenCode} + core.settings.syncOnlyRegEx=${JSON.stringify(normalAllowList)}; + const results=[]; + for await(const doc of core.localDatabase.findEntries('i:','i;')){ + if(${JSON.stringify(Object.keys(files).map((path) => `i:${path}`))}.includes(doc.path)&&!doc.deleted&&!doc._deleted){ + results.push(await addOn.extractInternalFileFromDatabase(doc.path.substring(2),true,doc,false)); + } + } + return JSON.stringify(results); + `; + await removeFixtures(vault.path, hiddenFiles); + report.hiddenPullWithAllowList = await evaluate(pullCode(allowList, hiddenFiles)); + record( + "#1272: a normal allow-list permits selected hidden downloads", + await matchingFiles(vault.path, hiddenFiles), + Object.keys(hiddenFiles).sort() + ); + await removeFixtures(vault.path, controlFiles); + report.hiddenPullWithoutAllowList = await evaluate(pullCode("", controlFiles)); + record( + "#1272 control: clearing the normal allow-list permits hidden downloads", + await matchingFiles(vault.path, controlFiles), + Object.keys(controlFiles).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; +}); diff --git a/test/e2e-obsidian/scripts/run-focused.ts b/test/e2e-obsidian/scripts/run-focused.ts index 7f766c67..d7da07c2 100644 --- a/test/e2e-obsidian/scripts/run-focused.ts +++ b/test/e2e-obsidian/scripts/run-focused.ts @@ -39,6 +39,7 @@ const focusedScenarios = new Set([ "two-vault-sync", "security-seed-reconnect", "hidden-file-snippet-sync", + "hidden-file-selectors", "customisation-sync", "received-change-readiness", "internal-metadata-doctor", diff --git a/test/fixtures/optionalFileSyncDatabase.ts b/test/fixtures/optionalFileSyncDatabase.ts new file mode 100644 index 00000000..dd3c6590 --- /dev/null +++ b/test/fixtures/optionalFileSyncDatabase.ts @@ -0,0 +1,78 @@ +import { randomUUID } from "node:crypto"; +import PouchDB from "pouchdb-core"; +import MemoryAdapter from "pouchdb-adapter-memory"; +import { vi } from "vitest"; +import { + DEFAULT_SETTINGS, + type EntryDoc, + type SavingEntry, + type FilePathWithPrefix, + type DocumentID, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { createTextBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import { createLiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context"; +import { LiveSyncLocalDB, type LiveSyncLocalDBEnv } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB"; + +PouchDB.plugin(MemoryAdapter); + +export function optionalFileEntry(path: string, content: string): SavingEntry { + return { + _id: path as DocumentID, + path: path as FilePathWithPrefix, + ctime: 1_000, + mtime: 2_000, + size: content.length, + type: "plain", + datatype: "plain", + data: createTextBlob(content), + children: [], + eden: {}, + }; +} + +export async function createOptionalFileSyncDatabase() { + const name = `optional-file-sync-${randomUUID()}`; + const database = new PouchDB(name, { adapter: "memory" }); + const settings = { + ...DEFAULT_SETTINGS, + syncInternalFiles: true, + useOnlyLocalChunk: true, + usePluginSync: true, + usePluginSyncV2: true, + pluginSyncExtendedSetting: {}, + }; + const services = { + API: { addLog: vi.fn(), getSystemConfigDir: () => ".obsidian" }, + setting: { currentSettings: () => settings, getDeviceAndVaultName: () => "device" }, + path: { + path2id: async (path: string) => path, + id2path: (id: string, entry?: { path?: string }) => entry?.path ?? id, + getPath: (entry: { path: FilePathWithPrefix }) => entry.path, + }, + context: { events: createLiveSyncEventHub() }, + database: { createPouchDBInstance: () => database }, + databaseEvents: { + onDatabaseInitialisation: async () => true, + onDatabaseHasReady: async () => true, + onCloseDatabase: async () => true, + onUnloadDatabase: async () => true, + }, + replicator: { onCloseActiveReplication: async () => true }, + }; + const localDatabase = new LiveSyncLocalDB(name, { services } as unknown as LiveSyncLocalDBEnv); + if (!(await localDatabase.initializeDatabase())) { + await database.destroy(); + throw new Error("The optional-file test database could not initialise"); + } + return { + database, + localDatabase, + services, + settings, + async dispose() { + localDatabase.offRemoteChunkFetchedHandler?.(); + await localDatabase.managers.teardownManagers(); + await database.destroy(); + }, + }; +} diff --git a/updates.md b/updates.md index ca2e3497..1700a53f 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,13 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi ## Unreleased +### Hidden File Sync + +#### Fixed + +- We can synchronise selected hidden files while the normal-file allow-list is set. (#1272) + - Normal-file exclusion patterns still apply to hidden files, and the Selector descriptions now explain their scope. + ## 1.0.39 20th October, 2026