mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-25 10:33:21 +00:00
Clarify Hidden File Sync selectors and test normal allow-lists
This commit is contained in:
1 parent
ce0524d49f
commit
9d30bed52d
11 files changed
+432
-6
No files matched your search
+4
-2
@@ -750,11 +750,13 @@ If enabled, the notification of hidden files change will be suppressed.
|
|||||||
|
|
||||||
#### Synchronising files
|
#### 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
|
#### 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
|
#### Maximum file size
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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:
|
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
|
```text
|
||||||
|
|||||||
@@ -92,6 +92,7 @@
|
|||||||
"test:e2e:obsidian:two-vault-sync": "tsx test/e2e-obsidian/scripts/two-vault-sync.ts",
|
"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: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-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: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: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",
|
"test:e2e:obsidian:remote-feature-change": "tsx test/e2e-obsidian/scripts/remote-feature-change.ts",
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
* remove it from this map in the same change.
|
* remove it from this map in the same change.
|
||||||
*/
|
*/
|
||||||
export const liveSyncProvisionalEnglishMessages = {
|
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…",
|
"Preparing follow-up report…": "Preparing follow-up report…",
|
||||||
"Cancelling local search. Checked ${COUNT} entries.": "Cancelling local search. Checked ${COUNT} entries.",
|
"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.":
|
"Search current Vault paths and current local Metadata only. Older history and remote-only files are not searched.":
|
||||||
|
|||||||
@@ -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<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,7 +13,7 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
|||||||
.setName($msg("Synchronising files"))
|
.setName($msg("Synchronising files"))
|
||||||
.setDesc(
|
.setDesc(
|
||||||
$msg(
|
$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, {
|
const syncFilesControl = mount(MultipleRegExpControl, {
|
||||||
@@ -32,9 +32,7 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
|||||||
|
|
||||||
const nonSyncFilesSetting = new Setting(paneEl)
|
const nonSyncFilesSetting = new Setting(paneEl)
|
||||||
.setName($msg("Non-Synchronising files"))
|
.setName($msg("Non-Synchronising files"))
|
||||||
.setDesc(
|
.setDesc($msg("(RegExp) Skip changes to matching normal and hidden files, locally and remotely."));
|
||||||
$msg("(RegExp) If this is set, any changes to local and remote files that match this will be skipped.")
|
|
||||||
);
|
|
||||||
|
|
||||||
const nonSyncFilesControl = mount(MultipleRegExpControl, {
|
const nonSyncFilesControl = mount(MultipleRegExpControl, {
|
||||||
target: nonSyncFilesSetting.controlEl,
|
target: nonSyncFilesSetting.controlEl,
|
||||||
|
|||||||
@@ -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-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.
|
`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.
|
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.
|
||||||
|
|||||||
@@ -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<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 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<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-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 <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: "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<string, string>) => `${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<string[]>(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<string[]>(uploadCode("", controlFiles));
|
||||||
|
record(
|
||||||
|
"#1272 control: clearing the normal allow-list permits hidden uploads",
|
||||||
|
uploadControl,
|
||||||
|
Object.keys(controlFiles).sort()
|
||||||
|
);
|
||||||
|
|
||||||
|
const pullCode = (normalAllowList: string, files: Record<string, string>) => `${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;
|
||||||
|
});
|
||||||
@@ -39,6 +39,7 @@ const focusedScenarios = new Set([
|
|||||||
"two-vault-sync",
|
"two-vault-sync",
|
||||||
"security-seed-reconnect",
|
"security-seed-reconnect",
|
||||||
"hidden-file-snippet-sync",
|
"hidden-file-snippet-sync",
|
||||||
|
"hidden-file-selectors",
|
||||||
"customisation-sync",
|
"customisation-sync",
|
||||||
"received-change-readiness",
|
"received-change-readiness",
|
||||||
"internal-metadata-doctor",
|
"internal-metadata-doctor",
|
||||||
|
|||||||
+78
@@ -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<EntryDoc>(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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,6 +12,13 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
## 1.0.39
|
||||||
|
|
||||||
20th October, 2026
|
20th October, 2026
|
||||||
|
|||||||
Reference in new issue
Block a user