mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-26 11:03:21 +00:00
Merge main and verify all Automatic initial actions
This commit is contained in:
commit
40696aa8bc
112 files changed
+6548
-741
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
import { LOG_LEVEL_INFO, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { LiveSyncBrowserServiceHubOptions } from "@/apps/browser/createLiveSyncBrowserServiceHub";
|
||||
@@ -168,7 +169,7 @@ describe("WebAppRuntime lifecycle", () => {
|
||||
|
||||
it("owns core start, directory scan, status reporting, and shutdown", async () => {
|
||||
const reportStatus = vi.fn();
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { reportStatus });
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { reportStatus, deviceValues: new MemoryValueStore() });
|
||||
|
||||
await runtime.start();
|
||||
|
||||
@@ -193,7 +194,7 @@ describe("WebAppRuntime lifecycle", () => {
|
||||
});
|
||||
|
||||
it("finalises application readiness after importing local files for optional P2P", async () => {
|
||||
const runtime = new WebAppRuntime(createRootHandle());
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { deviceValues: new MemoryValueStore() });
|
||||
await runtime.start();
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.currentSettings.mockReturnValue(unconfiguredSettings);
|
||||
@@ -239,7 +240,7 @@ describe("WebAppRuntime lifecycle", () => {
|
||||
});
|
||||
|
||||
it("does not publish readiness after a partial local-file import", async () => {
|
||||
const runtime = new WebAppRuntime(createRootHandle());
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { deviceValues: new MemoryValueStore() });
|
||||
await runtime.start();
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.collectFilesOnStorage.mockResolvedValue({
|
||||
@@ -269,7 +270,7 @@ describe("WebAppRuntime lifecycle", () => {
|
||||
});
|
||||
|
||||
it("does not publish readiness when database initialisation rejects manual preparation", async () => {
|
||||
const runtime = new WebAppRuntime(createRootHandle());
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { deviceValues: new MemoryValueStore() });
|
||||
await runtime.start();
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.onDatabaseInitialised.mockResolvedValue(false);
|
||||
@@ -282,7 +283,7 @@ describe("WebAppRuntime lifecycle", () => {
|
||||
});
|
||||
|
||||
it("does not publish readiness when pending file events cannot be committed", async () => {
|
||||
const runtime = new WebAppRuntime(createRootHandle());
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { deviceValues: new MemoryValueStore() });
|
||||
await runtime.start();
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.commitPendingFileEvents.mockResolvedValue(false);
|
||||
@@ -295,7 +296,7 @@ describe("WebAppRuntime lifecycle", () => {
|
||||
});
|
||||
|
||||
it("does not repeat readiness finalisation after a successful manual scan", async () => {
|
||||
const runtime = new WebAppRuntime(createRootHandle());
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { deviceValues: new MemoryValueStore() });
|
||||
await runtime.start();
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -311,7 +312,7 @@ describe("WebAppRuntime lifecycle", () => {
|
||||
it("rejects a failed core start after cleaning up the partial runtime", async () => {
|
||||
runtimeMocks.onLoad.mockResolvedValue(false);
|
||||
const reportStatus = vi.fn();
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { reportStatus });
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { reportStatus, deviceValues: new MemoryValueStore() });
|
||||
|
||||
await expect(runtime.start()).rejects.toThrow("Failed to initialise LiveSync");
|
||||
|
||||
@@ -325,7 +326,7 @@ describe("WebAppRuntime lifecycle", () => {
|
||||
|
||||
it("shuts down once before scheduling a host reload", async () => {
|
||||
const scheduleReload = vi.fn();
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { scheduleReload });
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { scheduleReload, deviceValues: new MemoryValueStore() });
|
||||
await runtime.start();
|
||||
|
||||
runtimeMocks.options?.restart?.schedule();
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { DEFAULT_SETTINGS, SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { WebPeerRuntime } from "@/apps/webpeer/src/WebPeerRuntime";
|
||||
import { WebPeerRuntime, type WebPeerRuntimeOptions } from "@/apps/webpeer/src/WebPeerRuntime";
|
||||
|
||||
function createMemoryStore(): SimpleStore<unknown> {
|
||||
const values = new Map<string, unknown>();
|
||||
@@ -22,11 +23,31 @@ function createMemoryStore(): SimpleStore<unknown> {
|
||||
}
|
||||
|
||||
describe("WebPeer runtime composition", () => {
|
||||
const runtimes = new Set<WebPeerRuntime>();
|
||||
|
||||
function createRuntime(options: WebPeerRuntimeOptions): WebPeerRuntime {
|
||||
const runtime = new WebPeerRuntime(options);
|
||||
runtimes.add(runtime);
|
||||
return runtime;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
for (const runtime of runtimes) {
|
||||
await runtime.shutdown();
|
||||
}
|
||||
} finally {
|
||||
runtimes.clear();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves one context and live P2P result across the service graph and pane host", () => {
|
||||
const context = createServiceContext({
|
||||
translate: (key) => `webpeer:${key}`,
|
||||
});
|
||||
const runtime = new WebPeerRuntime({
|
||||
const runtime = createRuntime({
|
||||
deviceValues: new MemoryValueStore(),
|
||||
context,
|
||||
store: createMemoryStore(),
|
||||
});
|
||||
@@ -42,7 +63,8 @@ describe("WebPeer runtime composition", () => {
|
||||
});
|
||||
|
||||
it("loads settings and opens the local database before announcing layout readiness", async () => {
|
||||
const runtime = new WebPeerRuntime({
|
||||
const runtime = createRuntime({
|
||||
deviceValues: new MemoryValueStore(),
|
||||
store: createMemoryStore(),
|
||||
});
|
||||
const loadSettings = vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
|
||||
@@ -67,7 +89,8 @@ describe("WebPeer runtime composition", () => {
|
||||
it("delegates automatic P2P startup to the resumed lifecycle handler", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const runtime = new WebPeerRuntime({
|
||||
const runtime = createRuntime({
|
||||
deviceValues: new MemoryValueStore(),
|
||||
store: createMemoryStore(),
|
||||
});
|
||||
vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
|
||||
@@ -77,9 +100,7 @@ describe("WebPeer runtime composition", () => {
|
||||
P2P_Enabled: true,
|
||||
});
|
||||
vi.spyOn(runtime.services.database, "openDatabase").mockResolvedValue(true);
|
||||
const onResumed = vi
|
||||
.spyOn(runtime.services.appLifecycle, "onResumed")
|
||||
.mockResolvedValue(true);
|
||||
const onResumed = vi.spyOn(runtime.services.appLifecycle, "onResumed").mockResolvedValue(true);
|
||||
const open = vi.spyOn(runtime.p2p.transportLifecycle, "connect").mockResolvedValue(undefined);
|
||||
|
||||
await runtime.start();
|
||||
@@ -93,17 +114,20 @@ describe("WebPeer runtime composition", () => {
|
||||
});
|
||||
|
||||
it("rejects start when the browser-local database cannot be opened", async () => {
|
||||
const runtime = new WebPeerRuntime({
|
||||
const runtime = createRuntime({
|
||||
deviceValues: new MemoryValueStore(),
|
||||
store: createMemoryStore(),
|
||||
});
|
||||
vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
|
||||
vi.spyOn(runtime.services.setting, "currentSettings").mockReturnValue({ ...DEFAULT_SETTINGS });
|
||||
vi.spyOn(runtime.services.database, "openDatabase").mockResolvedValue(false);
|
||||
|
||||
await expect(runtime.start()).rejects.toThrow("WebPeer local database could not be opened");
|
||||
});
|
||||
|
||||
it("isolates a specialised runtime and applies its device name before opening the database", async () => {
|
||||
const runtime = new WebPeerRuntime({
|
||||
const runtime = createRuntime({
|
||||
deviceValues: new MemoryValueStore(),
|
||||
store: createMemoryStore(),
|
||||
deviceName: " p2p-check-browser-desktop-abc ",
|
||||
systemVaultName: "p2p-check-vault",
|
||||
@@ -114,15 +138,39 @@ describe("WebPeer runtime composition", () => {
|
||||
P2P_AutoStart: false,
|
||||
P2P_Enabled: false,
|
||||
});
|
||||
const setSmallConfig = vi.spyOn(runtime.services.config, "setSmallConfig").mockImplementation(() => undefined);
|
||||
let finishWrite!: () => void;
|
||||
const setValue = vi.spyOn(runtime.services.config, "setValue").mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishWrite = resolve;
|
||||
})
|
||||
);
|
||||
const openDatabase = vi.spyOn(runtime.services.database, "openDatabase").mockImplementation(async () => {
|
||||
expect(setSmallConfig).toHaveBeenCalledWith(SETTING_KEY_P2P_DEVICE_NAME, "p2p-check-browser-desktop-abc");
|
||||
expect(setValue).toHaveBeenCalledWith(SETTING_KEY_P2P_DEVICE_NAME, "p2p-check-browser-desktop-abc");
|
||||
return true;
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
const starting = runtime.start();
|
||||
await vi.waitFor(() => expect(setValue).toHaveBeenCalledOnce());
|
||||
expect(openDatabase).not.toHaveBeenCalled();
|
||||
finishWrite();
|
||||
await starting;
|
||||
|
||||
expect(runtime.services.API.getSystemVaultName()).toBe("p2p-check-vault");
|
||||
expect(openDatabase).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reads the current device name after the pane updates the store", async () => {
|
||||
const runtime = createRuntime({
|
||||
deviceValues: new MemoryValueStore(),
|
||||
store: createMemoryStore(),
|
||||
systemVaultName: "webpeer-vault",
|
||||
});
|
||||
vi.spyOn(runtime.services.setting, "currentSettings").mockReturnValue({ ...DEFAULT_SETTINGS });
|
||||
expect(await runtime.getDeviceName()).toBe("webpeer-vault");
|
||||
await runtime.services.config.setValue(SETTING_KEY_P2P_DEVICE_NAME, "first-name");
|
||||
expect(await runtime.getDeviceName()).toBe("first-name");
|
||||
await runtime.services.config.setValue(SETTING_KEY_P2P_DEVICE_NAME, "renamed-device");
|
||||
expect(await runtime.getDeviceName()).toBe("renamed-device");
|
||||
});
|
||||
});
|
||||
@@ -17,13 +17,12 @@ export const SERVICE_CONTEXT_MEMBERS = [
|
||||
"vault",
|
||||
"test",
|
||||
"UI",
|
||||
"config",
|
||||
"keyValueDB",
|
||||
"control",
|
||||
] as const satisfies readonly Exclude<keyof ServiceHub, "context">[];
|
||||
] as const satisfies readonly Exclude<keyof ServiceHub, "context" | "config">[];
|
||||
|
||||
export type ServiceContextMember = (typeof SERVICE_CONTEXT_MEMBERS)[number];
|
||||
type MissingServiceContextMember = Exclude<Exclude<keyof ServiceHub, "context">, ServiceContextMember>;
|
||||
type MissingServiceContextMember = Exclude<Exclude<keyof ServiceHub, "context" | "config">, ServiceContextMember>;
|
||||
const serviceContextMembersAreExhaustive: [MissingServiceContextMember] extends [never] ? true : never = true;
|
||||
void serviceContextMembersAreExhaustive;
|
||||
|
||||
|
||||
@@ -123,7 +123,9 @@ The underlying `test:e2e:obsidian:<scenario>` scripts remain available for an im
|
||||
|
||||
`configuration-encryption` needs no remote service. It applies a custom configuration key through the settings interface, restarts the same isolated Vault, and applies a change to only the custom string. It then copies the encrypted settings into a fresh profile without that key and checks masked input, wrong-input retry, cancellation without a settings write or database initialisation, re-enabling for another attempt, access to the settings tab, and restoration after a further restart. Connection, E2EE, profile, and independent ID values must remain unchanged. These checks establish ordinary Apply and restart behaviour, without simulating process termination between the device-key and settings writes. It also checks changes from custom and ASK modes to Default through the settings interface, and recovery from a wrong saved device key, including cancellation, the validated replacement key, and another restart.
|
||||
|
||||
`test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises remote selection and CouchDB settings through `SetupManager`, plus Setup URI entry through the registered command. It verifies the compatibility pause and remote-size review, the distinction between a central data-storage server and P2P signalling, the explicit tested and untested CouchDB save actions, the internal-API warning, the Setup URI controls, automatic adjustment when differences are limited to compatible chunk settings, and both manual configuration-mismatch routes. The same session opens the live log and generated full report, reaches the `Hatch` recovery controls, writes and removes its own persistent log, and runs the missing-chunk recreation and file-verification actions against the empty disposable Vault. It captures representative desktop and mobile dialogues, checks the mobile layout and vertically stacked actions, closes each route through its normal controls, and verifies that each mounted operation settles without an error. It does not apply a remote configuration, contact a remote service, or claim to repair a deliberately damaged database.
|
||||
`test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises remote selection and CouchDB settings through `SetupManager`, plus Setup URI entry through the registered command. It verifies the compatibility pause and remote-size review, the distinction between a central data-storage server and P2P signalling, the explicit tested and untested CouchDB save actions, the internal-API warning, the Setup URI controls, automatic adjustment when differences are limited to compatible chunk settings, and both manual configuration-mismatch routes. The P2P setup check delays the saved device-name read, requires the name field and both continuation controls to remain disabled, then verifies the saved name and selected connection settings reach the setup workflow. The same session opens the live log and generated full report, reaches the `Hatch` recovery controls, writes and removes its own persistent log, and runs the missing-chunk recreation and file-verification actions against the empty disposable Vault. It captures representative desktop and mobile dialogues, checks the mobile layout and vertically stacked actions, closes each route through its normal controls, and verifies that each mounted operation settles without an error. It does not apply a remote configuration, contact a remote service, or claim to repair a deliberately damaged database.
|
||||
|
||||
`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.
|
||||
|
||||
@@ -246,7 +248,9 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms
|
||||
|
||||
`test:e2e:obsidian:document-history-restore` creates a normal note, records a logical deletion while retaining readable chunks, and restores the deleted content through the visible Document History dialogue. It requires the action itself to create and reflect a new non-deleted successor revision, reopens the history at that successor, and captures the file picker, readable deleted revision, restored Vault file, and new successor revision. This scenario owns the ordinary-history restoration boundary; conflict resolution remains with **Inspect conflicts and file/database differences**.
|
||||
|
||||
`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. That target-pattern case keeps a normal-file allow-list for Markdown and PDF files enabled on both devices, then requires exact hidden JSON content after the receiving device includes it. Initial enablement must open one user-visible progress Notice before the enabled setting is saved, then retain that Notice while its nested rebuild and scan phases continue in the ordinary log. The configured fixture starts with a current CouchDB remote profile, so migration from legacy remote settings remains the responsibility of the upgrade scenarios and cannot add unrelated Notices to this check. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one separate action Notice whose controls remain usable in mobile layouts; a manually dismissed group must not repeat its acknowledged rows when a later change arrives.
|
||||
|
||||
`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.
|
||||
|
||||
@@ -254,7 +258,7 @@ Before enablement, the scenario directly requests initialisation while Hidden Fi
|
||||
|
||||
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.
|
||||
`--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: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`.
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ export async function readE2eCompatibilityMarker(
|
||||
return await evalObsidianJson<CompatibilityMarkerState>(
|
||||
cliBinary,
|
||||
[
|
||||
"(()=>{",
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const setting=core.services.setting;",
|
||||
"const settings=setting.currentSettings();",
|
||||
@@ -122,7 +122,7 @@ export async function readE2eCompatibilityMarker(
|
||||
"return JSON.stringify({",
|
||||
"vaultName,additionalSuffix,expectedStorageKey,",
|
||||
"rawStorageValue:localStorage.getItem(expectedStorageKey),",
|
||||
"serviceValue:setting.getSmallConfig(markerKey),",
|
||||
"serviceValue:await setting.config.getValue(markerKey,''),",
|
||||
"versionUpFlash:settings.versionUpFlash,",
|
||||
"});",
|
||||
"})()",
|
||||
|
||||
@@ -288,7 +288,7 @@ export async function readRuntimeUpgradeState(
|
||||
"localDatabaseDocumentCount:databaseInfo.doc_count,",
|
||||
"nodeId:nodeInfo?.nodeid??replicator?.nodeid??'',",
|
||||
"legacyCompatibilityMarker:localStorage.getItem(`obsidian-live-sync-ver${vaultName}`),",
|
||||
"compatibilityMarker:setting.getSmallConfig('database-compatibility-version')??'',",
|
||||
"compatibilityMarker:await (setting.config?.getValue?.('database-compatibility-version','')??Promise.resolve(setting.getSmallConfig?.('database-compatibility-version')??'')),",
|
||||
"compatibilityStorageEntries:Object.fromEntries(Array.from({length:localStorage.length},(_,index)=>localStorage.key(index))",
|
||||
".filter((key)=>key!==null)",
|
||||
".filter(key=>key.startsWith('obsidian-live-sync-ver')||key.endsWith('-database-compatibility-version'))",
|
||||
@@ -335,7 +335,7 @@ export async function readRuntimeSettingsUpgradeState(
|
||||
"const migrationState=setting.getSettingsMigrationState?.();",
|
||||
"return JSON.stringify({",
|
||||
"pluginVersion:app.plugins.manifests['obsidian-livesync']?.version??'unknown',",
|
||||
"compatibilityMarker:setting.getSmallConfig?.('database-compatibility-version')??'',",
|
||||
"compatibilityMarker:await (setting.config?.getValue?.('database-compatibility-version','')??Promise.resolve(setting.getSmallConfig?.('database-compatibility-version')??'')),",
|
||||
"migrationState,",
|
||||
"settings:{",
|
||||
"isConfigured:settings.isConfigured,settingVersion:settings.settingVersion,",
|
||||
|
||||
@@ -47,6 +47,16 @@ const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 3000
|
||||
const e2eePassphrase = `%${randomBytes(24).toString("base64url")}`;
|
||||
const notePath = "E2E/manual-couchdb/from-first-device.md";
|
||||
const noteContent = "# Manual CouchDB setup\n\nThis note was sent by the manually configured first device.\n";
|
||||
const binaryPath = "E2E/manual-couchdb/content-boundaries.png";
|
||||
const binaryBlockSize = 32 * 1024;
|
||||
function binaryFixture(): Uint8Array {
|
||||
const bytes = new Uint8Array(binaryBlockSize * 8);
|
||||
for (let index = 0; index < 8; index++) {
|
||||
bytes[index * binaryBlockSize] = index + 2;
|
||||
bytes[(index + 1) * binaryBlockSize - 1] = 1;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
const returnNotePath = "E2E/manual-couchdb/from-second-device.md";
|
||||
const returnNoteContent =
|
||||
"# Manual CouchDB return journey\n\nThis note returned through a Setup URI generated by the first device.\n";
|
||||
@@ -395,6 +405,32 @@ async function waitForVaultFile(
|
||||
throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`);
|
||||
}
|
||||
|
||||
async function waitForBinaryFile(vault: TemporaryVault): Promise<void> {
|
||||
const expected = Buffer.from(binaryFixture());
|
||||
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000);
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
if ((await readFile(join(vault.path, binaryPath))).equals(expected)) return;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error("The fetched binary did not match its exact original bytes.");
|
||||
}
|
||||
|
||||
async function readChunkInventory(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<string[]> {
|
||||
return await evalObsidianJson<string[]>(
|
||||
context.cliBinary,
|
||||
`(async()=>{
|
||||
const core=app.plugins.plugins['obsidian-livesync'].core;
|
||||
const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;
|
||||
return JSON.stringify(rows.filter(row=>row.doc?.type==='leaf').map(row=>row.id).sort());
|
||||
})()`,
|
||||
session.cliEnv
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForRemoteEntry(context: RunnerContext, entry: { id: string; children: string[] }): Promise<void> {
|
||||
await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => {
|
||||
const ids = new Set(docs.map((doc) => doc._id));
|
||||
@@ -681,6 +717,8 @@ async function main(): Promise<void> {
|
||||
process.env.E2E_OBSIDIAN_INDEPENDENT_IDS === "true" ? randomBytes(32).toString("base64url") : undefined;
|
||||
let firstEntryId: string | undefined;
|
||||
let returnEntryId: string | undefined;
|
||||
let binaryRevision: string | undefined;
|
||||
let chunkInventory: string[] = [];
|
||||
|
||||
try {
|
||||
await assertCouchDbReachable(couchDb);
|
||||
@@ -712,10 +750,34 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, notePath, noteContent);
|
||||
const size = await evalObsidianJson<number>(
|
||||
context.cliBinary,
|
||||
`JSON.stringify(app.plugins.plugins['obsidian-livesync'].core.settings.customChunkSize)`,
|
||||
session.cliEnv
|
||||
);
|
||||
assertEqual(size, 0, "New manual setup did not retain the common Chunk-size recommendation.");
|
||||
await evalObsidianJson(
|
||||
context.cliBinary,
|
||||
`(async()=>{
|
||||
const bytes=new Uint8Array(${binaryBlockSize}*8);
|
||||
for(let index=0;index<8;index++){
|
||||
bytes[index*${binaryBlockSize}]=index+2;
|
||||
bytes[(index+1)*${binaryBlockSize}-1]=1;
|
||||
}
|
||||
await app.vault.createBinary(${JSON.stringify(binaryPath)},bytes.buffer);
|
||||
return JSON.stringify(true);
|
||||
})()`,
|
||||
session.cliEnv
|
||||
);
|
||||
const binaryEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, binaryPath);
|
||||
assertEqual(binaryEntry.children.length, 8, "V3 size 0 did not use the expected content boundaries.");
|
||||
binaryRevision = binaryEntry.rev;
|
||||
const entry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath);
|
||||
firstEntryId = entry.id;
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await waitForRemoteEntry(context, entry);
|
||||
await waitForRemoteEntry(context, binaryEntry);
|
||||
chunkInventory = await readChunkInventory(context, session);
|
||||
} catch (error) {
|
||||
await captureFailure(session, "first-device");
|
||||
throw error;
|
||||
@@ -729,6 +791,13 @@ async function main(): Promise<void> {
|
||||
session = await startUnconfiguredSession(context, vaultA);
|
||||
try {
|
||||
await assertRestoredE2EEPassphrase(session, context.cliBinary);
|
||||
const unchanged = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, binaryPath);
|
||||
assertEqual(unchanged.rev, binaryRevision, "Restart rewrote unchanged binary Metadata.");
|
||||
assertEqual(
|
||||
JSON.stringify(await readChunkInventory(context, session)),
|
||||
JSON.stringify(chunkInventory),
|
||||
"Restart created Chunks for unchanged Vault files."
|
||||
);
|
||||
await scheduleRemoteOverwrite(session.remoteDebuggingPort);
|
||||
screenshots.push(await confirmRebuild(session.remoteDebuggingPort, e2eeRebuildCaptures));
|
||||
screenshots.push(
|
||||
@@ -775,6 +844,7 @@ async function main(): Promise<void> {
|
||||
await assertPersistedE2EE(vaultB, independentIdSource);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await waitForVaultFile(vaultB, notePath, noteContent);
|
||||
await waitForBinaryFile(vaultB);
|
||||
if (independentIdSource) {
|
||||
assertEqual(
|
||||
await readDocumentId(context.cliBinary, session.cliEnv, notePath),
|
||||
@@ -815,7 +885,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Manual CouchDB onboarding created and tested its database, generated a second-device Setup URI, and completed a bidirectional note round-trip. Screenshots: ${screenshots.join(", ")}`
|
||||
`Manual CouchDB onboarding retained size 0, used binary content boundaries, preserved unchanged revisions and Chunks across restart, fetched exact binary bytes, and completed a bidirectional note round-trip. Screenshots: ${screenshots.join(", ")}`
|
||||
);
|
||||
} finally {
|
||||
await stopTrackedSessions(context).catch((error: unknown) => {
|
||||
|
||||
@@ -4,7 +4,9 @@ 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 {
|
||||
@@ -122,6 +124,7 @@ const pluginFixtures = [
|
||||
] as const;
|
||||
|
||||
type CustomisationSyncCase = "all" | "visibility" | "mtime" | "automatic";
|
||||
type AutomaticInitialAction = "Overwrite Remote" | "Overwrite Local" | "Use newer";
|
||||
|
||||
type CustomisationSyncTestGlobal = typeof globalThis & {
|
||||
app?: { commands?: { executeCommandById(commandId: string): boolean } };
|
||||
@@ -287,7 +290,7 @@ async function openAutomaticModeForPlugin(page: Page, pluginName: string): Promi
|
||||
return dialogue;
|
||||
}
|
||||
|
||||
async function selectAutomaticInitialAction(page: Page, label: "Overwrite Remote" | "Overwrite Local"): Promise<void> {
|
||||
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();
|
||||
@@ -315,6 +318,37 @@ async function readAutomaticSyncSnapshot(cliBinary: string, env: NodeJS.ProcessE
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -337,7 +371,8 @@ async function inspectAutomaticSyncSelection(
|
||||
context: RunnerContext,
|
||||
session: ObsidianLiveSyncSession,
|
||||
vault: TemporaryVault,
|
||||
pluginName: string
|
||||
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.";
|
||||
@@ -416,9 +451,40 @@ async function inspectAutomaticSyncSelection(
|
||||
});
|
||||
|
||||
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, "Overwrite Remote");
|
||||
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" });
|
||||
@@ -457,6 +523,29 @@ async function inspectAutomaticSyncSelection(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -800,8 +889,10 @@ 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";
|
||||
@@ -835,7 +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);
|
||||
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);
|
||||
@@ -864,6 +959,8 @@ async function main(): Promise<void> {
|
||||
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);
|
||||
@@ -894,6 +991,23 @@ async function main(): Promise<void> {
|
||||
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));
|
||||
@@ -922,7 +1036,7 @@ async function main(): Promise<void> {
|
||||
throw new Error("The remote feature list does not declare encrypted internal Metadata.");
|
||||
}
|
||||
await session.app.stop();
|
||||
if (testAutomatic) {
|
||||
if (testAutomatic && initialAction === "Overwrite Remote") {
|
||||
for (const path of [pluginManifestPath, pluginMainPath, pluginStylesPath, pluginDataPath]) {
|
||||
await removeVaultFile(vaultA.path, path);
|
||||
}
|
||||
@@ -981,40 +1095,41 @@ async function main(): Promise<void> {
|
||||
try {
|
||||
if (testVisibility) await inspectCustomisationVisibility(session);
|
||||
if (testMtime) await inspectPluginFreshnessAndNewestSelection(session);
|
||||
if (testAutomatic) await inspectAutomaticSyncSelection(context, session, vaultB, pluginName);
|
||||
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();
|
||||
session = await startConfiguredSession(context, vaultA, sourceDeviceName, { syncInternalFiles: true });
|
||||
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(
|
||||
vaultA.path,
|
||||
receivingVault.path,
|
||||
pluginStylesPath,
|
||||
(content) => content === targetPluginStylesContent
|
||||
(content) => content === expectedStyles
|
||||
);
|
||||
const receivedData = await waitForPathContent(
|
||||
vaultA.path,
|
||||
receivingVault.path,
|
||||
pluginDataPath,
|
||||
(content) => content === targetPluginDataContent
|
||||
);
|
||||
assertEqual(
|
||||
receivedStyles,
|
||||
targetPluginStylesContent,
|
||||
"Automatic plug-in group lost PLUGIN_MAIN files."
|
||||
);
|
||||
assertEqual(
|
||||
receivedData,
|
||||
targetPluginDataContent,
|
||||
"Automatic plug-in group lost the PLUGIN_DATA file."
|
||||
(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 Customisation Sync initialised and transferred both plug-in group children.");
|
||||
console.log(`Automatic ${initialAction} initialised and transferred both plug-in group children.`);
|
||||
return;
|
||||
}
|
||||
if (testCase !== "all") {
|
||||
@@ -1133,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);
|
||||
|
||||
@@ -31,6 +31,18 @@ type DiagnosticPageGlobal = typeof globalThis & {
|
||||
diagnosticDownload?: { name: string; content: string };
|
||||
};
|
||||
|
||||
async function findVisibleElement(page: Page, selector: string): Promise<Locator> {
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
for (const candidate of page.context().pages()) {
|
||||
const element = candidate.locator(selector);
|
||||
if (await element.isVisible()) return element;
|
||||
}
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
throw new Error(`No Obsidian renderer contains the visible element: ${selector}`);
|
||||
}
|
||||
|
||||
async function openCommand(page: Page, kind: "usage" | "integrity") {
|
||||
const id = kind === "usage" ? "analyse-database" : "inspect-database-integrity";
|
||||
const opened = await page.evaluate(
|
||||
@@ -243,11 +255,14 @@ async function assertDiagnosticFileLookup(
|
||||
session: ObsidianLiveSyncSession,
|
||||
mobile = false
|
||||
): Promise<void> {
|
||||
const dialoguePage = hatch.page();
|
||||
const viewport = page.viewportSize();
|
||||
if (viewport) await dialoguePage.setViewportSize(viewport);
|
||||
const setting = hatch.locator(".setting-item").filter({
|
||||
has: page.getByText("Find a file from a report pseudonym", { exact: true }),
|
||||
has: hatch.page().getByText("Find a file from a report pseudonym", { exact: true }),
|
||||
});
|
||||
await setting.getByRole("button", { name: "Find file", exact: true }).click();
|
||||
const dialogue = page.locator(".diagnostic-file-lookup");
|
||||
const dialogue = dialoguePage.locator(".diagnostic-file-lookup");
|
||||
await dialogue.waitFor({ state: "visible", timeout });
|
||||
const input = dialogue.getByLabel("Report path pseudonym", { exact: true });
|
||||
await input.fill("file-001");
|
||||
@@ -259,8 +274,8 @@ async function assertDiagnosticFileLookup(
|
||||
await candidates.first().waitFor({ timeout });
|
||||
assert.equal(await candidates.count(), 2, "Distinct Metadata IDs claiming the same path must remain selectable");
|
||||
if (mobile) {
|
||||
await assertNoHorizontalOverflow(page, dialogue, { label: "Pseudonym file lookup matches" });
|
||||
await assertNoHorizontalOverflow(page, dialogue.locator(".lookup-candidates"), {
|
||||
await assertNoHorizontalOverflow(dialoguePage, dialogue, { label: "Pseudonym file lookup matches" });
|
||||
await assertNoHorizontalOverflow(dialoguePage, dialogue.locator(".lookup-candidates"), {
|
||||
label: "Pseudonym file lookup candidate list",
|
||||
});
|
||||
assert.ok(
|
||||
@@ -270,7 +285,7 @@ async function assertDiagnosticFileLookup(
|
||||
await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"database-diagnostics-file-matches-mobile.png",
|
||||
(page) => page.locator(".modal-container").filter({ has: page.locator(".diagnostic-file-lookup") })
|
||||
(page) => findVisibleElement(page, ".modal-container:has(.diagnostic-file-lookup)")
|
||||
);
|
||||
}
|
||||
const shadow = candidates.filter({ hasText: shadowId });
|
||||
@@ -294,28 +309,30 @@ async function assertDiagnosticFileLookup(
|
||||
for (const privateValue of [fixturePath, shadowId, fixtureContent.trim()]) {
|
||||
assert.ok(!json.includes(privateValue), "A follow-up report must preserve the original report's pseudonyms");
|
||||
}
|
||||
await installDiagnosticExportInterception(page);
|
||||
await installDiagnosticExportInterception(dialoguePage);
|
||||
await dialogue.getByRole("button", { name: "Copy report", exact: true }).click();
|
||||
assert.equal(await page.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticClipboard), json);
|
||||
assert.equal(await dialoguePage.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticClipboard), json);
|
||||
await dialogue.getByRole("button", { name: "Save report", exact: true }).click();
|
||||
await page.waitForFunction(() => !!(globalThis as DiagnosticPageGlobal).diagnosticDownload, undefined, { timeout });
|
||||
assert.deepEqual(await page.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticDownload), {
|
||||
await dialoguePage.waitForFunction(() => !!(globalThis as DiagnosticPageGlobal).diagnosticDownload, undefined, {
|
||||
timeout,
|
||||
});
|
||||
assert.deepEqual(await dialoguePage.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticDownload), {
|
||||
name: "diagnostic-file-report.json",
|
||||
content: json,
|
||||
});
|
||||
await assertDesktopDiagnosticResultLayout(page, dialogue, "saving a follow-up report");
|
||||
await assertDesktopDiagnosticResultLayout(dialoguePage, dialogue, "saving a follow-up report");
|
||||
if (mobile) {
|
||||
const modal = page.locator(".modal-container").filter({ has: dialogue });
|
||||
await assertMobileDialogueLayout(page, modal, "Pseudonym file follow-up report");
|
||||
await assertNoHorizontalOverflow(page, dialogue, { label: "Pseudonym file follow-up content" });
|
||||
await assertNoHorizontalOverflow(page, dialogue.locator(".report-token"), {
|
||||
const modal = dialoguePage.locator(".modal-container").filter({ has: dialogue });
|
||||
await assertMobileDialogueLayout(dialoguePage, modal, "Pseudonym file follow-up report");
|
||||
await assertNoHorizontalOverflow(dialoguePage, dialogue, { label: "Pseudonym file follow-up content" });
|
||||
await assertNoHorizontalOverflow(dialoguePage, dialogue.locator(".report-token"), {
|
||||
label: "Pseudonym file follow-up token",
|
||||
});
|
||||
}
|
||||
await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
mobile ? "database-diagnostics-file-follow-up-mobile.png" : "database-diagnostics-file-follow-up.png",
|
||||
(page) => page.locator(".modal-container").filter({ has: page.locator(".diagnostic-file-lookup") })
|
||||
(page) => findVisibleElement(page, ".modal-container:has(.diagnostic-file-lookup)")
|
||||
);
|
||||
await dialogue.getByRole("radio", { name: "MARKDOWN", exact: true }).check();
|
||||
const markdown = await dialogue.locator("textarea").inputValue();
|
||||
@@ -379,10 +396,10 @@ async function assertFollowUpStopsAfterClose(page: Page, hatch: Locator, token:
|
||||
})()`);
|
||||
try {
|
||||
const setting = hatch.locator(".setting-item").filter({
|
||||
has: page.getByText("Find a file from a report pseudonym", { exact: true }),
|
||||
has: hatch.page().getByText("Find a file from a report pseudonym", { exact: true }),
|
||||
});
|
||||
await setting.getByRole("button", { name: "Find file", exact: true }).click();
|
||||
const dialogue = page.locator(".diagnostic-file-lookup");
|
||||
const dialogue = hatch.page().locator(".diagnostic-file-lookup");
|
||||
await dialogue.getByLabel("Report path pseudonym", { exact: true }).fill(token);
|
||||
await dialogue.getByRole("button", { name: "Find file", exact: true }).click();
|
||||
const candidate = dialogue.locator("[data-diagnostic-candidate]").filter({ hasText: shadowId });
|
||||
@@ -466,7 +483,7 @@ async function main(): Promise<void> {
|
||||
const activeSession = session;
|
||||
let usageJSON = "";
|
||||
let usageFixtureRow: { path: string; id: string; revision: string } | undefined;
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const desktopScreenshot = await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.setViewportSize({ width: 800, height: 600 });
|
||||
const choice = await openCommand(page, "usage");
|
||||
await choice.getByRole("radio", { name: "Pseudonyms (recommended)", exact: true }).focus();
|
||||
@@ -560,13 +577,15 @@ async function main(): Promise<void> {
|
||||
hatch = await (await openLiveSyncSettings(page, timeout)).openPage("Hatch");
|
||||
await assertFollowUpStopsAfterClose(page, hatch, usageFixtureRow.path);
|
||||
await captureObsidianElement(activeSession.remoteDebuggingPort, "database-diagnostics-hatch.png", (page) =>
|
||||
page.locator(".modal.mod-settings")
|
||||
findVisibleElement(page, ".modal.mod-settings")
|
||||
);
|
||||
const setting = hatch
|
||||
.locator(".setting-item")
|
||||
.filter({ has: page.getByText("Inspect database integrity", { exact: true }) });
|
||||
.filter({ has: hatch.page().getByText("Inspect database integrity", { exact: true }) });
|
||||
await setting.getByRole("button", { name: "Inspect", exact: true }).click();
|
||||
const progressDialogue = page.locator(".database-diagnostic");
|
||||
const progressDialogue = await findVisibleElement(page, ".database-diagnostic");
|
||||
const progressPage = progressDialogue.page();
|
||||
await progressPage.setViewportSize({ width: 800, height: 600 });
|
||||
assert.equal(
|
||||
await progressDialogue
|
||||
.getByRole("radio", { name: "Pseudonyms (recommended)", exact: true })
|
||||
@@ -579,7 +598,7 @@ async function main(): Promise<void> {
|
||||
.isChecked(),
|
||||
true
|
||||
);
|
||||
await assertDesktopDiagnosticChoiceLayout(page, progressDialogue);
|
||||
await assertDesktopDiagnosticChoiceLayout(progressPage, progressDialogue);
|
||||
await installDiagnosticReadGate(cli, activeSession, shadowId, "metadata");
|
||||
try {
|
||||
await progressDialogue.getByRole("button", { name: "Begin inspection", exact: true }).click();
|
||||
@@ -593,7 +612,7 @@ async function main(): Promise<void> {
|
||||
await captureObsidianElement(
|
||||
activeSession.remoteDebuggingPort,
|
||||
"database-diagnostics-progress.png",
|
||||
(page) => page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") })
|
||||
(page) => findVisibleElement(page, ".modal-container:has(.database-diagnostic)")
|
||||
);
|
||||
await progressDialogue.getByRole("button", { name: "Cancel inspection", exact: true }).click();
|
||||
await evalObsidianJson(
|
||||
@@ -601,7 +620,7 @@ async function main(): Promise<void> {
|
||||
`(async()=>{globalThis.databaseDiagnosticReadGate?.release();return JSON.stringify(true);})()`,
|
||||
activeSession.cliEnv
|
||||
);
|
||||
await page.locator('[data-diagnostic-phase="result"]').waitFor({ timeout });
|
||||
await progressPage.locator('[data-diagnostic-phase="result"]').waitFor({ timeout });
|
||||
await assertFormatChoices(progressDialogue, ["MARKDOWN", "JSON"]);
|
||||
await chooseFormat(progressDialogue, "json");
|
||||
assert.equal(JSON.parse(await progressDialogue.locator("textarea").inputValue()).status, "cancelled");
|
||||
@@ -612,33 +631,41 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
await setting.getByRole("button", { name: "Inspect", exact: true }).click();
|
||||
const integrity = page.locator(".database-diagnostic");
|
||||
const integrity = await findVisibleElement(page, ".database-diagnostic");
|
||||
const integrityPage = integrity.page();
|
||||
assert.equal(
|
||||
await integrity.getByRole("radio", { name: "Pseudonyms (recommended)", exact: true }).isChecked(),
|
||||
true
|
||||
);
|
||||
await integrity.getByRole("button", { name: "Begin inspection", exact: true }).click();
|
||||
await page.locator('[data-diagnostic-phase="result"]').waitFor({ timeout });
|
||||
await integrityPage.locator('[data-diagnostic-phase="result"]').waitFor({ timeout });
|
||||
await assertFormatChoices(integrity, ["MARKDOWN", "JSON"]);
|
||||
assert.equal(await integrity.getByRole("radio", { name: "MARKDOWN", exact: true }).isChecked(), true);
|
||||
const markdownReport = await integrity.locator("textarea").inputValue();
|
||||
const markdownDetailsWithCodeBlock =
|
||||
/<details>\s*<summary>Database integrity report \(partial\)<\/summary>\s*(`{3,})text\r?\n[\s\S]*?\1\s*<\/details>/;
|
||||
assert.match(markdownReport, markdownDetailsWithCodeBlock);
|
||||
await installDiagnosticExportInterception(page);
|
||||
await installDiagnosticExportInterception(integrityPage);
|
||||
await integrity.getByRole("button", { name: "Copy report", exact: true }).click();
|
||||
assert.equal(
|
||||
await page.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticClipboard),
|
||||
await integrityPage.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticClipboard),
|
||||
markdownReport
|
||||
);
|
||||
await integrity.getByRole("button", { name: "Save report", exact: true }).click();
|
||||
await page.waitForFunction(() => !!(globalThis as DiagnosticPageGlobal).diagnosticDownload, undefined, {
|
||||
timeout,
|
||||
});
|
||||
assert.deepEqual(await page.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticDownload), {
|
||||
name: "database-integrity-partial.md",
|
||||
content: markdownReport,
|
||||
});
|
||||
await integrityPage.waitForFunction(
|
||||
() => !!(globalThis as DiagnosticPageGlobal).diagnosticDownload,
|
||||
undefined,
|
||||
{
|
||||
timeout,
|
||||
}
|
||||
);
|
||||
assert.deepEqual(
|
||||
await integrityPage.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticDownload),
|
||||
{
|
||||
name: "database-integrity-partial.md",
|
||||
content: markdownReport,
|
||||
}
|
||||
);
|
||||
await chooseFormat(integrity, "json");
|
||||
const reportText = await integrity.locator("textarea").inputValue();
|
||||
const integrityReport = JSON.parse(reportText);
|
||||
@@ -670,15 +697,17 @@ async function main(): Promise<void> {
|
||||
);
|
||||
for (const privateValue of [fixturePath, shadowId, fixtureContent.trim()])
|
||||
assert.ok(!reportText.includes(privateValue));
|
||||
const screenshot = await captureObsidianElement(
|
||||
activeSession.remoteDebuggingPort,
|
||||
"database-diagnostics-desktop.png",
|
||||
(page) => findVisibleElement(page, ".modal-container:has(.database-diagnostic)")
|
||||
);
|
||||
await integrity.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await integrity.waitFor({ state: "hidden", timeout });
|
||||
await settingsNavigator.close();
|
||||
return screenshot;
|
||||
});
|
||||
const desktopScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"database-diagnostics-desktop.png",
|
||||
(page) => page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") })
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.locator(".database-diagnostic").getByRole("button", { name: "Close", exact: true }).click();
|
||||
await page.keyboard.press("Escape");
|
||||
const localOnly = await openCommand(page, "integrity");
|
||||
await localOnly.getByRole("checkbox", { name: "Include remote database", exact: true }).uncheck();
|
||||
await localOnly.getByRole("button", { name: "Begin inspection", exact: true }).click();
|
||||
@@ -780,7 +809,7 @@ async function main(): Promise<void> {
|
||||
const mobileScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"database-diagnostics-mobile.png",
|
||||
(page) => page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") })
|
||||
(page) => findVisibleElement(page, ".modal-container:has(.database-diagnostic)")
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
@@ -828,7 +857,7 @@ async function main(): Promise<void> {
|
||||
const mobileResultScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"database-diagnostics-mobile-result.png",
|
||||
(page) => page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") })
|
||||
(page) => findVisibleElement(page, ".modal-container:has(.database-diagnostic)")
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
@@ -857,7 +886,7 @@ async function main(): Promise<void> {
|
||||
await captureObsidianElement(
|
||||
activeSession.remoteDebuggingPort,
|
||||
"database-diagnostics-mobile-saved.png",
|
||||
(page) => page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") })
|
||||
(page) => findVisibleElement(page, ".modal-container:has(.database-diagnostic)")
|
||||
);
|
||||
await dialogue.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await dialogue.waitFor({ state: "hidden", timeout });
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { Page } from "playwright";
|
||||
import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/setup-uri";
|
||||
import { decryptString } from "@vrtmrz/livesync-commonlib/compat/encryption/stringEncryption";
|
||||
import { SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { $msg } from "../../../src/common/translation.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { createE2eCouchDbPluginData, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
|
||||
@@ -13,18 +15,37 @@ import {
|
||||
captureObsidianPage,
|
||||
obsidianRemoteDebuggingPort,
|
||||
openLiveSyncSettings,
|
||||
withObsidianPage,
|
||||
waitForVisibleObsidianDialogue,
|
||||
withObsidianPage as withObsidianSessionPage,
|
||||
} from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const dialogRunStateKey = "__livesyncE2EDialogMount";
|
||||
const savedP2PDeviceName = "e2e-p2p-saved-device";
|
||||
const repairRunStateKey = "__livesyncE2ETroubleshootingRepair";
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_DIALOG_TIMEOUT_MS ?? 10000);
|
||||
|
||||
async function withObsidianPage<T>(port: number, operation: (page: Page) => Promise<T>): Promise<T> {
|
||||
return await withObsidianSessionPage(port, async (page) => {
|
||||
for (const candidate of page.context().pages()) {
|
||||
const hasPlugin = await candidate
|
||||
.evaluate(
|
||||
() => (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"] !== undefined
|
||||
)
|
||||
.catch(() => false);
|
||||
if (hasPlugin) return await operation(candidate);
|
||||
}
|
||||
throw new Error("The LiveSync Vault renderer is unavailable.");
|
||||
});
|
||||
}
|
||||
|
||||
type DialogueMode = "desktop" | "mobile";
|
||||
|
||||
type DialogueRunState = {
|
||||
done: boolean;
|
||||
deviceNameRead?: boolean;
|
||||
deviceNameReadPending?: boolean;
|
||||
releaseDeviceNameRead?: () => void;
|
||||
error?: string;
|
||||
expected?: unknown;
|
||||
kind: string;
|
||||
@@ -46,6 +67,14 @@ type LiveSyncTestPlugin = {
|
||||
createAllChunks(force: boolean): Promise<unknown>;
|
||||
};
|
||||
modules: SetupManagerHandle[];
|
||||
services: {
|
||||
setting: {
|
||||
config: {
|
||||
getValue(key: string, defaultValue?: string): Promise<string>;
|
||||
setValue(key: string, value: string): Promise<void>;
|
||||
};
|
||||
};
|
||||
};
|
||||
settings: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
@@ -91,38 +120,81 @@ async function openRemoteSelectionDialogue(): Promise<void> {
|
||||
|
||||
async function openP2PRemoteSelectionDialogueForInspection(): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
await page.evaluate((stateKey) => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded");
|
||||
const manager = plugin.core.modules.find((module) => module.constructor.name === "SetupManager");
|
||||
if (
|
||||
typeof manager?.onSelectServer !== "function" ||
|
||||
typeof manager.onConfirmApplySettingsFromWizard !== "function"
|
||||
) {
|
||||
throw new Error("Could not find the P2P setup workflow");
|
||||
}
|
||||
const originalConfirm = manager.onConfirmApplySettingsFromWizard;
|
||||
const state: DialogueRunState = { kind: "p2p-compatibility-settings", done: false };
|
||||
(globalThis as unknown as Record<string, DialogueRunState>)[stateKey] = state;
|
||||
manager.onConfirmApplySettingsFromWizard = async (settings: unknown) => {
|
||||
state.expected = settings;
|
||||
return true;
|
||||
};
|
||||
void manager
|
||||
.onSelectServer(plugin.core.settings, "unknown")
|
||||
.then(
|
||||
(result) => {
|
||||
state.result = result;
|
||||
state.done = true;
|
||||
},
|
||||
(error: unknown) => {
|
||||
state.error = error instanceof Error ? error.message : String(error);
|
||||
state.done = true;
|
||||
}
|
||||
)
|
||||
.finally(() => {
|
||||
manager.onConfirmApplySettingsFromWizard = originalConfirm;
|
||||
await page.evaluate(
|
||||
async ({ stateKey, deviceNameKey, deviceName }) => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded");
|
||||
const initialPeerName = plugin.core.settings.P2P_DevicePeerName;
|
||||
if (typeof initialPeerName === "string" && initialPeerName.trim() !== "") {
|
||||
throw new Error("The P2P setup fixture unexpectedly has an initial device name.");
|
||||
}
|
||||
const manager = plugin.core.modules.find((module) => module.constructor.name === "SetupManager");
|
||||
if (
|
||||
typeof manager?.onSelectServer !== "function" ||
|
||||
typeof manager.onConfirmApplySettingsFromWizard !== "function"
|
||||
) {
|
||||
throw new Error("Could not find the P2P setup workflow");
|
||||
}
|
||||
const config = plugin.core.services.setting.config;
|
||||
await config.setValue(deviceNameKey, deviceName);
|
||||
const originalGetValue = config.getValue;
|
||||
const originalConfirm = manager.onConfirmApplySettingsFromWizard;
|
||||
const state: DialogueRunState = {
|
||||
kind: "p2p-compatibility-settings",
|
||||
done: false,
|
||||
deviceNameRead: false,
|
||||
deviceNameReadPending: false,
|
||||
};
|
||||
(globalThis as unknown as Record<string, DialogueRunState>)[stateKey] = state;
|
||||
const deviceNameReadGate = new Promise<void>((resolve) => {
|
||||
state.releaseDeviceNameRead = resolve;
|
||||
});
|
||||
config.getValue = async (key, defaultValue) => {
|
||||
const value = await originalGetValue.call(config, key, defaultValue);
|
||||
if (key === deviceNameKey) {
|
||||
state.deviceNameReadPending = true;
|
||||
await deviceNameReadGate;
|
||||
state.deviceNameReadPending = false;
|
||||
if (value === deviceName) state.deviceNameRead = true;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
manager.onConfirmApplySettingsFromWizard = async (settings: unknown) => {
|
||||
state.expected = settings;
|
||||
return true;
|
||||
};
|
||||
void manager
|
||||
.onSelectServer(plugin.core.settings, "unknown")
|
||||
.then(
|
||||
(result) => {
|
||||
state.result = result;
|
||||
},
|
||||
(error: unknown) => {
|
||||
state.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
)
|
||||
.finally(() => {
|
||||
state.releaseDeviceNameRead?.();
|
||||
state.releaseDeviceNameRead = undefined;
|
||||
manager.onConfirmApplySettingsFromWizard = originalConfirm;
|
||||
config.getValue = originalGetValue;
|
||||
state.done = true;
|
||||
});
|
||||
},
|
||||
{
|
||||
stateKey: dialogRunStateKey,
|
||||
deviceNameKey: SETTING_KEY_P2P_DEVICE_NAME,
|
||||
deviceName: savedP2PDeviceName,
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function releasePendingP2PDeviceNameRead(): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
await page.evaluate((stateKey) => {
|
||||
const state = (globalThis as unknown as Record<string, DialogueRunState | undefined>)[stateKey];
|
||||
state?.releaseDeviceNameRead?.();
|
||||
}, dialogRunStateKey);
|
||||
});
|
||||
}
|
||||
@@ -388,7 +460,7 @@ async function verifyRemoteSelectionDialogue(mode: DialogueMode): Promise<string
|
||||
if (previousDialogue === null) {
|
||||
throw new Error("The remote selection dialogue did not expose a close control.");
|
||||
}
|
||||
await modal.locator(".modal-close-button").click({ timeout: uiTimeoutMs });
|
||||
await modal.locator(".modal-close-button, .modal-header-button").last().click({ timeout: uiTimeoutMs });
|
||||
await page.waitForFunction((element) => !element.isConnected, previousDialogue, { timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
}
|
||||
@@ -473,9 +545,11 @@ async function verifyCouchDBSettingsDialogue(mode: DialogueMode): Promise<string
|
||||
async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
|
||||
await openP2PRemoteSelectionDialogueForInspection();
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const remoteSelection = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Choose a synchronisation remote" }),
|
||||
});
|
||||
const remoteSelection = await waitForVisibleObsidianDialogue(
|
||||
page,
|
||||
"Choose a synchronisation remote",
|
||||
uiTimeoutMs
|
||||
);
|
||||
await remoteSelection
|
||||
.locator("label")
|
||||
.filter({ hasText: "Peer-to-Peer (P2P)" })
|
||||
@@ -491,16 +565,51 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
|
||||
obsidianRemoteDebuggingPort(),
|
||||
"setup-p2p-compatibility-dialogue.png",
|
||||
async (page) => {
|
||||
const modal = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "P2P Configuration" }),
|
||||
});
|
||||
const modal = await waitForVisibleObsidianDialogue(page, "P2P Configuration", uiTimeoutMs);
|
||||
const modalPage = modal.page();
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const peerName = modal.getByLabel("Device Peer ID", { exact: true });
|
||||
await peerName.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (statePage) => {
|
||||
await statePage.waitForFunction(
|
||||
(stateKey) =>
|
||||
(globalThis as unknown as Record<string, DialogueRunState | undefined>)[stateKey]
|
||||
?.deviceNameReadPending === true,
|
||||
dialogRunStateKey,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
});
|
||||
const testAndContinue = modal.getByRole("button", { name: "Test Settings and Continue", exact: true });
|
||||
const continueAnyway = modal.getByRole("button", { name: "Continue anyway", exact: true });
|
||||
await testAndContinue.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await continueAnyway.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if (
|
||||
!(await peerName.isDisabled()) ||
|
||||
!(await testAndContinue.isDisabled()) ||
|
||||
!(await continueAnyway.isDisabled())
|
||||
) {
|
||||
throw new Error(
|
||||
"The P2P device name and setup actions must remain disabled while the saved device name is loading."
|
||||
);
|
||||
}
|
||||
|
||||
await releasePendingP2PDeviceNameRead();
|
||||
await modalPage.waitForFunction(
|
||||
(expectedName) => {
|
||||
const input = document.querySelector('input[name="p2p-device-peer-id"]');
|
||||
return input instanceof HTMLInputElement && !input.disabled && input.value === expectedName;
|
||||
},
|
||||
savedP2PDeviceName,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
if ((await peerName.inputValue()) !== savedP2PDeviceName) {
|
||||
throw new Error("The saved P2P device name was not loaded before submitting the setup dialogue.");
|
||||
}
|
||||
await modal.locator('input[name="p2p-room-id"]').fill("e2e-p2p-compatibility");
|
||||
await modal.locator('input[name="p2p-password"]').fill("e2e-passphrase");
|
||||
await modal.locator('input[name="p2p-device-peer-id"]').fill("e2e-device");
|
||||
|
||||
const compatibility = modal.locator("details").filter({
|
||||
has: page.locator("summary").filter({ hasText: "Connection compatibility" }),
|
||||
has: modalPage.locator("summary").filter({ hasText: "Connection compatibility" }),
|
||||
});
|
||||
await compatibility.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if (!(await compatibility.evaluate((element) => (element as HTMLDetailsElement).open))) {
|
||||
@@ -541,7 +650,7 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
|
||||
}
|
||||
|
||||
const advanced = modal.locator("details").filter({
|
||||
has: page.locator("summary").filter({ hasText: "Advanced Settings" }),
|
||||
has: modalPage.locator("summary").filter({ hasText: "Advanced Settings" }),
|
||||
});
|
||||
if (!(await advanced.evaluate((element) => (element as HTMLDetailsElement).open))) {
|
||||
await advanced.locator("summary").click({ timeout: uiTimeoutMs });
|
||||
@@ -553,20 +662,19 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
|
||||
if (relayOnlyElement === null || connectionPathElement === null) {
|
||||
throw new Error("P2P connection-path controls were removed whilst validating TURN settings.");
|
||||
}
|
||||
await page.waitForFunction((element) => !(element as HTMLOptionElement).disabled, relayOnlyElement, {
|
||||
await modalPage.waitForFunction((element) => !(element as HTMLOptionElement).disabled, relayOnlyElement, {
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await connectionPath.selectOption("relay");
|
||||
|
||||
await turnServers.fill("");
|
||||
await page.waitForFunction(
|
||||
await modalPage.waitForFunction(
|
||||
(element) => (element as HTMLSelectElement).value === "automatic",
|
||||
connectionPathElement,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
const resetNotice = compatibility.locator(".sls-info-note-notice").filter({
|
||||
hasText:
|
||||
"TURN relay only requires TURN configuration. Connection path has been restored to Automatic.",
|
||||
hasText: "TURN relay only requires TURN configuration. Connection path has been restored to Automatic.",
|
||||
});
|
||||
await resetNotice.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await resetNotice
|
||||
@@ -575,7 +683,7 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
|
||||
await turnServers.fill("turn:turn.example.com:3478");
|
||||
await page.waitForFunction((element) => !(element as HTMLOptionElement).disabled, relayOnlyElement, {
|
||||
await modalPage.waitForFunction((element) => !(element as HTMLOptionElement).disabled, relayOnlyElement, {
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await connectionPath.selectOption("relay");
|
||||
@@ -583,21 +691,25 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
|
||||
await advanced.locator("summary").click({ timeout: uiTimeoutMs });
|
||||
await compatibility.scrollIntoViewIfNeeded();
|
||||
}
|
||||
);
|
||||
).finally(releasePendingP2PDeviceNameRead);
|
||||
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const modal = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "P2P Configuration" }),
|
||||
});
|
||||
const modal = await waitForVisibleObsidianDialogue(page, "P2P Configuration", uiTimeoutMs);
|
||||
await modal.getByRole("button", { name: "Continue anyway", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
const state = await assertDialogueRunCompleted();
|
||||
if (state.deviceNameRead !== true) {
|
||||
throw new Error("The saved P2P device name was not read through setting.config.");
|
||||
}
|
||||
if (state.result !== true || typeof state.expected !== "object" || state.expected === null) {
|
||||
throw new Error("The P2P compatibility settings were not passed to the setup workflow.");
|
||||
}
|
||||
const settings = state.expected as Record<string, unknown>;
|
||||
if (settings.P2P_DevicePeerName !== savedP2PDeviceName) {
|
||||
throw new Error("The saved P2P device name was not passed through the setup workflow.");
|
||||
}
|
||||
if (settings.P2P_maxWirePayloadBytes !== 800 || settings.P2P_connectionPath !== "relay") {
|
||||
throw new Error(
|
||||
`The P2P compatibility choices did not round-trip through the setup dialogue: ${JSON.stringify({
|
||||
@@ -672,15 +784,14 @@ async function verifyGenerateSetupUriDialogue(mode: DialogueMode): Promise<strin
|
||||
const app = (globalThis as ObsidianTestGlobal).app;
|
||||
return Boolean(
|
||||
app?.plugins?.plugins["obsidian-livesync"]?.core?.settings.isConfigured &&
|
||||
app.commands?.commands?.[commandId]
|
||||
app.commands?.commands?.[commandId]
|
||||
);
|
||||
},
|
||||
"obsidian-livesync:livesync-copysetupuri",
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
return await page.evaluate(
|
||||
(commandId) =>
|
||||
(globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true,
|
||||
(commandId) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true,
|
||||
"obsidian-livesync:livesync-copysetupuri"
|
||||
);
|
||||
});
|
||||
@@ -714,7 +825,8 @@ async function verifyGenerateSetupUriDialogue(mode: DialogueMode): Promise<strin
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await choice.getByRole("button", { name: "Time-bound", exact: true }).waitFor({
|
||||
state: "visible", timeout: uiTimeoutMs,
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
if (mode === "mobile") await assertMobileDialogueLayout(page, choice, "Setup URI availability dialogue");
|
||||
await choice.getByRole("button", { name: selected, exact: true }).click({ timeout: uiTimeoutMs });
|
||||
@@ -866,6 +978,7 @@ async function verifyCompatibleAlignmentSettingDefault(): Promise<void> {
|
||||
if (!(await toggle.evaluate((element) => element.classList.contains("is-enabled")))) {
|
||||
throw new Error("The automatic compatible-setting policy was displayed as disabled while still undefined.");
|
||||
}
|
||||
await settingsNavigator.close();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1094,9 +1207,10 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
||||
);
|
||||
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const liveSyncSettings = page.locator(".sls-setting");
|
||||
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
const liveSyncSettings = await settingsNavigator.openPage("Hatch");
|
||||
const logSetting = liveSyncSettings.locator(".setting-item").filter({
|
||||
has: page.getByText("Write logs into the file", { exact: true }),
|
||||
has: settingsNavigator.page.getByText("Write logs into the file", { exact: true }),
|
||||
});
|
||||
await logSetting.locator(".checkbox-container").click({ timeout: uiTimeoutMs });
|
||||
await page.waitForFunction(
|
||||
@@ -1130,10 +1244,10 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
||||
|
||||
// Saving a toggle refreshes the settings pane. Resolve the visible control again so the
|
||||
// second action does not target the detached pre-save element.
|
||||
const refreshedLogToggle = page
|
||||
.locator(".sls-setting:visible .setting-item:visible")
|
||||
const refreshedLogToggle = liveSyncSettings
|
||||
.locator(".setting-item:visible")
|
||||
.filter({
|
||||
has: page.getByText("Write logs into the file", { exact: true }),
|
||||
has: settingsNavigator.page.getByText("Write logs into the file", { exact: true }),
|
||||
})
|
||||
.locator(".checkbox-container:visible")
|
||||
.last();
|
||||
@@ -1179,13 +1293,9 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
||||
}
|
||||
};
|
||||
}, repairRunStateKey);
|
||||
await page
|
||||
.locator(".sls-setting:visible")
|
||||
.last()
|
||||
.getByRole("button", { name: "Recreate current chunks", exact: true })
|
||||
.click({
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await liveSyncSettings.getByRole("button", { name: "Recreate current chunks", exact: true }).click({
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
(stateKey) =>
|
||||
(globalThis as unknown as Record<string, DialogueRunState | undefined>)[stateKey]?.done === true,
|
||||
@@ -1200,13 +1310,9 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
||||
throw new Error(`Recreate missing chunks failed: ${repairState.error}`);
|
||||
}
|
||||
|
||||
await page
|
||||
.locator(".sls-setting:visible")
|
||||
.last()
|
||||
.getByRole("button", { name: "Begin inspection", exact: true })
|
||||
.click({
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await liveSyncSettings.getByRole("button", { name: "Begin inspection", exact: true }).click({
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await page
|
||||
.locator(".notice")
|
||||
.filter({ hasText: /^done$/u })
|
||||
@@ -1214,6 +1320,7 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await settingsNavigator.close();
|
||||
});
|
||||
|
||||
return screenshotPath;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -84,6 +84,7 @@ type RunnerContext = {
|
||||
cliBinary: string;
|
||||
couchDb: CouchDbConfig;
|
||||
dbName: string;
|
||||
activeSessions: Set<ObsidianLiveSyncSession>;
|
||||
};
|
||||
|
||||
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
|
||||
@@ -471,6 +472,7 @@ async function startConfiguredSession(
|
||||
pluginData: createE2eCouchDbPluginData(couchDbSettings, hiddenFileSettings),
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
});
|
||||
context.activeSessions.add(session);
|
||||
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
|
||||
await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv);
|
||||
await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, hiddenFileSettings);
|
||||
@@ -478,6 +480,11 @@ async function startConfiguredSession(
|
||||
return session;
|
||||
}
|
||||
|
||||
async function stopSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<void> {
|
||||
await session.app.stop();
|
||||
context.activeSessions.delete(session);
|
||||
}
|
||||
|
||||
async function uploadHiddenFile(
|
||||
context: RunnerContext,
|
||||
session: ObsidianLiveSyncSession,
|
||||
@@ -553,12 +560,12 @@ async function runCreateRoundTrip(
|
||||
throw new Error("Remote Rebuild did not declare and encrypt internal Metadata for its accepted writer.");
|
||||
}
|
||||
console.log("Remote Rebuild declared the feature and preserved encrypted Hidden File Sync Metadata.");
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultB);
|
||||
await pullAndApplyHiddenFiles(context, session);
|
||||
const received = await waitForPathContent(vaultB.path, snippetPath, (content) => content === snippetContent);
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
|
||||
assertEqual(received, snippetContent, "Hidden snippet content did not round-trip to the second vault.");
|
||||
console.log(`Hidden create round-trip copied ${entry.id} to the second vault.`);
|
||||
@@ -573,12 +580,12 @@ async function runDeleteRoundTrip(
|
||||
let session = await startConfiguredSession(context, vaultA);
|
||||
await scanHiddenStorage(context.cliBinary, session.cliEnv);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultB);
|
||||
await pullAndApplyHiddenFiles(context, session);
|
||||
await waitForPathDeleted(vaultB.path, snippetPath);
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
|
||||
console.log("Hidden delete round-trip removed the snippet from the second vault.");
|
||||
}
|
||||
@@ -599,14 +606,14 @@ async function runJsonConflictRoundTrip(
|
||||
const mergedOnB = await waitForPathContent(vaultB.path, mergeJsonPath, (content) =>
|
||||
hasJsonValues(content, { fromA: true, fromB: true })
|
||||
);
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultA);
|
||||
await pullAndApplyHiddenFiles(context, session);
|
||||
const mergedOnA = await waitForPathContent(vaultA.path, mergeJsonPath, (content) =>
|
||||
hasJsonValues(content, { fromA: true, fromB: true })
|
||||
);
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
|
||||
assertEqual(mergedOnA, mergedOnB, "Merged hidden JSON content was not consistent across both vaults.");
|
||||
console.log("Hidden JSON conflict was automatically merged and round-tripped.");
|
||||
@@ -626,7 +633,7 @@ async function runJsonManualConflictResolution(context: RunnerContext, vault: Te
|
||||
const merged = await waitForPathContent(vault.path, manualMergeJsonPath, (content) =>
|
||||
hasJsonValues(content, { shared: "right", fromA: true, fromB: true })
|
||||
);
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
|
||||
const parsed = JSON.parse(merged);
|
||||
assertEqual(parsed.shared, "right", "Manual JSON conflict resolution did not apply the selected merged result.");
|
||||
@@ -640,17 +647,19 @@ async function runTargetMismatch(
|
||||
vaultA: TemporaryVault,
|
||||
vaultB: TemporaryVault
|
||||
): Promise<void> {
|
||||
const selectors = { syncOnlyRegEx: String.raw`\.(md|pdf)$` };
|
||||
const targetContent = JSON.stringify({ onlyA: true, targetMismatch: true }, null, 4) + "\n";
|
||||
await writeVaultFile(vaultA.path, targetPath, targetContent);
|
||||
|
||||
let session = await startConfiguredSession(context, vaultA);
|
||||
let session = await startConfiguredSession(context, vaultA, selectors);
|
||||
try {
|
||||
await uploadHiddenFile(context, session, targetPath);
|
||||
} finally {
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
}
|
||||
|
||||
session = await startConfiguredSession(context, vaultB, {
|
||||
...selectors,
|
||||
syncInternalFilesTargetPatterns: "snippets",
|
||||
});
|
||||
try {
|
||||
@@ -661,10 +670,11 @@ async function runTargetMismatch(
|
||||
"Hidden file was applied on a device where it was not a target file."
|
||||
);
|
||||
} finally {
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
}
|
||||
|
||||
session = await startConfiguredSession(context, vaultB, {
|
||||
...selectors,
|
||||
syncInternalFilesTargetPatterns: "",
|
||||
});
|
||||
let received = "";
|
||||
@@ -672,11 +682,17 @@ async function runTargetMismatch(
|
||||
await pullAndApplyHiddenFiles(context, session, { resolveConflicts: false });
|
||||
received = await waitForPathContent(vaultB.path, targetPath, (content) => content === targetContent);
|
||||
} finally {
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
}
|
||||
|
||||
assertEqual(received, targetContent, "Hidden file was not applied after it became a target file.");
|
||||
console.log("Hidden target mismatch respected per-device target patterns, then applied after enabling the target.");
|
||||
assertEqual(
|
||||
received,
|
||||
targetContent,
|
||||
"The normal file allow-list blocked a selected hidden file from the other device."
|
||||
);
|
||||
console.log(
|
||||
"A normal Markdown/PDF allow-list preserved hidden target exclusion and exact remote restoration after enabling the target."
|
||||
);
|
||||
}
|
||||
|
||||
async function setHiddenFileNoticeFixtures(port: number, itemIds: string[], includeRestart: boolean): Promise<void> {
|
||||
@@ -952,7 +968,7 @@ async function runInitialisationNoticeGrouping(context: RunnerContext, vault: Te
|
||||
`Hidden File Sync showed one progress Notice before settings were saved and retained it throughout initialisation. Screenshot: ${screenshotPath}`
|
||||
);
|
||||
} finally {
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1013,7 +1029,7 @@ async function runConfigurationNoticeGrouping(context: RunnerContext, vault: Tem
|
||||
} finally {
|
||||
await clearHiddenFileNoticeFixtures(port).catch(() => undefined);
|
||||
await setObsidianMobileTestMode(port, false, timeoutMs).catch(() => undefined);
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1028,7 +1044,7 @@ async function main(): Promise<void> {
|
||||
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "hidden-roundtrip");
|
||||
const vaultA = await createTemporaryVault();
|
||||
const vaultB = await createTemporaryVault();
|
||||
const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName };
|
||||
const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName, activeSessions: new Set() };
|
||||
|
||||
try {
|
||||
await assertCouchDbReachable(couchDb);
|
||||
@@ -1047,6 +1063,7 @@ async function main(): Promise<void> {
|
||||
await runInitialisationNoticeGrouping(context, vaultB);
|
||||
await runConfigurationNoticeGrouping(context, vaultB);
|
||||
} finally {
|
||||
for (const session of context.activeSessions) await stopSession(context, session);
|
||||
await vaultA.dispose();
|
||||
await vaultB.dispose();
|
||||
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { DEVICE_ID_PREFERRED, MILESTONE_DOCID, NODEINFO_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import {
|
||||
assertCouchDbReachable,
|
||||
createCouchDbDatabase,
|
||||
deleteCouchDbDatabase,
|
||||
fetchCouchDbDocument,
|
||||
loadCouchDbConfig,
|
||||
makeUniqueDatabaseName,
|
||||
putCouchDbDocument,
|
||||
type CouchDbDocument,
|
||||
} from "../runner/couchdb.ts";
|
||||
import {
|
||||
createObjectStorageClient,
|
||||
deleteObjectStoragePrefix,
|
||||
ensureObjectStorageBucket,
|
||||
loadObjectStorageConfig,
|
||||
makeUniqueBucketPrefix,
|
||||
readObjectStorageJson,
|
||||
} from "../runner/objectStorage.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import {
|
||||
assertEqual,
|
||||
assertE2eCompatibilityUnpaused,
|
||||
configureCouchDb,
|
||||
configureObjectStorage,
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObjectStoragePluginData,
|
||||
prepareRemote,
|
||||
waitForLiveSyncCoreReady,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import {
|
||||
assertLocatorHasMinimumTouchTarget,
|
||||
assertLocatorWithinSafeArea,
|
||||
assertLocatorWithinViewport,
|
||||
assertNoHorizontalOverflow,
|
||||
} from "@vrtmrz/obsidian-test-session";
|
||||
import { iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { openLiveSyncSettings, waitForVisibleObsidianDialogue, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
import type { Locator, Page } from "playwright";
|
||||
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_JOINED_DEVICES_TIMEOUT_MS ?? 10000);
|
||||
const commandId = "obsidian-livesync:joined-devices";
|
||||
const targetId = "joined-devices-target";
|
||||
const retainedId = "joined-devices-retained";
|
||||
const initialPlatform = "E2E Android tablet";
|
||||
const refreshedPlatform = "E2E refreshed platform";
|
||||
const connectedAt = Date.UTC(2026, 0, 12, 9, 30, 0);
|
||||
|
||||
type SelfDeviceInfo = {
|
||||
id: string;
|
||||
platform: string;
|
||||
pluginVersion: string;
|
||||
appVersion: string;
|
||||
};
|
||||
|
||||
type DeviceInfo = {
|
||||
device_name: string;
|
||||
vault_name: string;
|
||||
app_version: string;
|
||||
plugin_version: string;
|
||||
platform: string;
|
||||
progress: string;
|
||||
last_connected: number;
|
||||
};
|
||||
|
||||
type JoinedDevicesMilestone = CouchDbDocument & {
|
||||
accepted_nodes: string[];
|
||||
node_info: Record<string, DeviceInfo>;
|
||||
locked: boolean;
|
||||
cleaned: boolean;
|
||||
node_chunk_info: Record<string, { min: number; max: number; current: number }>;
|
||||
tweak_values: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
|
||||
type DeviceFixture = {
|
||||
milestone: JoinedDevicesMilestone;
|
||||
preferredTweaks: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type JoinedDevicesRemote = {
|
||||
label: string;
|
||||
pluginData: Record<string, unknown>;
|
||||
prepare(): Promise<void>;
|
||||
configure(cliBinary: string, env: NodeJS.ProcessEnv): Promise<unknown>;
|
||||
read(): Promise<JoinedDevicesMilestone>;
|
||||
write(milestone: JoinedDevicesMilestone): Promise<void>;
|
||||
cleanup(): Promise<void>;
|
||||
};
|
||||
|
||||
async function createRemoteFixture(): Promise<JoinedDevicesRemote> {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.some((argument) => argument !== "--object-storage")) {
|
||||
throw new Error("Usage: joined-devices.ts [--object-storage]");
|
||||
}
|
||||
if (args.includes("--object-storage")) {
|
||||
const config = await loadObjectStorageConfig();
|
||||
const bucketPrefix = makeUniqueBucketPrefix("joined-devices");
|
||||
const settings = { ...config, bucketPrefix };
|
||||
const key = `${bucketPrefix}_00000000-milestone.json`;
|
||||
return {
|
||||
label: "Object Storage",
|
||||
pluginData: createE2eObjectStoragePluginData(settings),
|
||||
prepare: () => ensureObjectStorageBucket(config),
|
||||
configure: (cliBinary, env) => configureObjectStorage(cliBinary, env, settings),
|
||||
read: () => readObjectStorageJson<JoinedDevicesMilestone>(config, key),
|
||||
write: async (milestone) => {
|
||||
const client = createObjectStorageClient(config);
|
||||
try {
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: key,
|
||||
Body: JSON.stringify(milestone),
|
||||
ContentType: "application/json",
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
client.destroy();
|
||||
}
|
||||
},
|
||||
cleanup: () => deleteObjectStoragePrefix(config, bucketPrefix),
|
||||
};
|
||||
}
|
||||
const config = await loadCouchDbConfig();
|
||||
const dbName = makeUniqueDatabaseName(config.dbPrefix, "joined-devices");
|
||||
const settings = { uri: config.uri, username: config.username, password: config.password, dbName };
|
||||
return {
|
||||
label: "CouchDB",
|
||||
pluginData: createE2eCouchDbPluginData(settings),
|
||||
prepare: async () => {
|
||||
await assertCouchDbReachable(config);
|
||||
await createCouchDbDatabase(config, dbName);
|
||||
},
|
||||
configure: (cliBinary, env) => configureCouchDb(cliBinary, env, settings),
|
||||
read: async () => {
|
||||
const milestone = await fetchCouchDbDocument(config, dbName, MILESTONE_DOCID);
|
||||
assert(Boolean(milestone._rev), "The remote milestone did not have a revision.");
|
||||
return milestone as JoinedDevicesMilestone;
|
||||
},
|
||||
write: async (milestone) => {
|
||||
await putCouchDbDocument(config, dbName, milestone);
|
||||
},
|
||||
cleanup: () => deleteCouchDbDatabase(config, dbName),
|
||||
};
|
||||
}
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function deviceCard(dialogue: Locator, id: string): Locator {
|
||||
return dialogue.locator(".device-card").filter({ hasText: id });
|
||||
}
|
||||
|
||||
async function assertDetail(card: Locator, label: string, expected: string): Promise<void> {
|
||||
const rows = card.locator(".device-details > div");
|
||||
for (let index = 0; index < (await rows.count()); index++) {
|
||||
const row = rows.nth(index);
|
||||
if ((await row.locator("dt").innerText()).trim() === label) {
|
||||
assertEqual((await row.locator("dd").innerText()).trim(), expected, `${label} did not match.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error(`The device details did not contain '${label}'.`);
|
||||
}
|
||||
|
||||
async function readSelfDeviceInfo(cliBinary: string, env: NodeJS.ProcessEnv): Promise<SelfDeviceInfo> {
|
||||
return await evalObsidianJson<SelfDeviceInfo>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
`const nodeInfo=await core.services.database.localDatabase.localDatabase.get(${JSON.stringify(NODEINFO_DOCID)});`,
|
||||
"return JSON.stringify({",
|
||||
"id:nodeInfo.nodeid,",
|
||||
"platform:core.services.API.getPlatform(),",
|
||||
"pluginVersion:core.services.API.getPluginVersion(),",
|
||||
"appVersion:core.services.API.getAppVersion(),",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
function createFixture(self: SelfDeviceInfo): DeviceFixture {
|
||||
const preferredTweaks = { customChunkSize: 60, chunkSplitterVersion: "v3-rabin-karp" };
|
||||
const milestone: JoinedDevicesMilestone = {
|
||||
_id: MILESTONE_DOCID,
|
||||
type: "milestoneinfo",
|
||||
created: Date.now(),
|
||||
// The current device is deliberately absent: it must still be listed first and identified as locked out.
|
||||
accepted_nodes: [targetId, retainedId],
|
||||
node_info: {
|
||||
[targetId]: {
|
||||
device_name: "E2E target device",
|
||||
vault_name: "Joined devices target fixture",
|
||||
app_version: "1.99.0-test",
|
||||
plugin_version: "999.0.0",
|
||||
platform: initialPlatform,
|
||||
progress: "ready",
|
||||
last_connected: connectedAt,
|
||||
},
|
||||
[retainedId]: {
|
||||
device_name: "E2E retained device",
|
||||
vault_name: "Joined devices retained fixture",
|
||||
app_version: "1.88.0-test",
|
||||
plugin_version: "0.1.0",
|
||||
platform: "E2E Linux desktop",
|
||||
progress: "ready",
|
||||
last_connected: connectedAt - 86400000,
|
||||
},
|
||||
},
|
||||
locked: true,
|
||||
cleaned: true,
|
||||
node_chunk_info: {
|
||||
[targetId]: { min: 1, max: 3, current: 3 },
|
||||
[retainedId]: { min: 1, max: 2, current: 2 },
|
||||
},
|
||||
tweak_values: {
|
||||
[targetId]: { customChunkSize: 48 },
|
||||
[retainedId]: { customChunkSize: 60 },
|
||||
[DEVICE_ID_PREFERRED]: preferredTweaks,
|
||||
},
|
||||
};
|
||||
assert(self.id !== targetId && self.id !== retainedId, "The local device identity collided with a fixture ID.");
|
||||
return { milestone, preferredTweaks };
|
||||
}
|
||||
|
||||
async function openFromRemoteSettings(page: Page): Promise<Locator> {
|
||||
const settings = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
const remoteConfiguration = await settings.openPage("Remote Configuration");
|
||||
await remoteConfiguration
|
||||
.getByRole("button", { name: "Review devices", exact: true })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
||||
await dialogue.getByText(targetId, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
return dialogue;
|
||||
}
|
||||
|
||||
async function assertInitialDialogue(dialogue: Locator, self: SelfDeviceInfo, page: Page): Promise<void> {
|
||||
const cards = dialogue.locator(".device-card");
|
||||
assertEqual(
|
||||
await cards.count(),
|
||||
3,
|
||||
"The joined devices dialogue did not show the self, target, and retained records."
|
||||
);
|
||||
const selfCard = cards.nth(0);
|
||||
assert(Boolean(self.id), "The current device identity was not read from the local NODEINFO_DOCID record.");
|
||||
assertEqual(
|
||||
(await selfCard.locator("code.device-id").innerText()).trim(),
|
||||
self.id,
|
||||
"The current device was not listed first."
|
||||
);
|
||||
await selfCard.getByText("This device", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await selfCard.getByText("Locked out", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await selfCard.getByText("Not accepted", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await selfCard
|
||||
.getByText("This device has been locked out.", { exact: false })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await assertDetail(selfCard, "Platform", self.platform);
|
||||
await assertDetail(selfCard, "Plug-in version", self.pluginVersion);
|
||||
await assertDetail(selfCard, "App version", self.appVersion);
|
||||
assertEqual(
|
||||
await selfCard.getByRole("button", { name: "Forget device", exact: true }).count(),
|
||||
0,
|
||||
"The current device exposed a forget control."
|
||||
);
|
||||
|
||||
const targetCard = deviceCard(dialogue, targetId);
|
||||
await assertDetail(targetCard, "Platform", initialPlatform);
|
||||
await assertDetail(targetCard, "Plug-in version", "999.0.0");
|
||||
await assertDetail(targetCard, "App version", "1.99.0-test");
|
||||
const targetDate = await page.evaluate((timestamp) => new Date(timestamp).toLocaleString(), connectedAt);
|
||||
await assertDetail(targetCard, "Last recorded", targetDate);
|
||||
await targetCard.getByText("Accepted", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
|
||||
const retainedCard = deviceCard(dialogue, retainedId);
|
||||
await assertDetail(retainedCard, "Platform", "E2E Linux desktop");
|
||||
await assertDetail(retainedCard, "Plug-in version", "0.1.0");
|
||||
await assertDetail(retainedCard, "App version", "1.88.0-test");
|
||||
await dialogue
|
||||
.getByText(
|
||||
"This list shows device information recorded by the remote. It does not show whether a device is online now.",
|
||||
{ exact: true }
|
||||
)
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await dialogue
|
||||
.locator(".sls-info-note")
|
||||
.getByText(
|
||||
"Synchronisation between different plug-in versions is generally safe unless the connection is explicitly blocked. However, it may be less efficient or use more storage, and P2P synchronisation may be unavailable.",
|
||||
{ exact: true }
|
||||
)
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await dialogue
|
||||
.getByText("This remote is locked.", { exact: false })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await dialogue
|
||||
.getByText("This remote has been cleaned.", { exact: false })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
}
|
||||
|
||||
async function refreshRemotePlatform(page: Page, remote: JoinedDevicesRemote): Promise<void> {
|
||||
const milestone = await remote.read();
|
||||
await remote.write({
|
||||
...milestone,
|
||||
node_info: {
|
||||
...milestone.node_info,
|
||||
[targetId]: { ...milestone.node_info[targetId], platform: refreshedPlatform },
|
||||
},
|
||||
});
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
||||
await dialogue.getByRole("button", { name: "Refresh", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await deviceCard(dialogue, targetId).getByText(refreshedPlatform, { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
async function showForgetConfirmation(dialogue: Locator): Promise<void> {
|
||||
const targetCard = deviceCard(dialogue, targetId);
|
||||
await targetCard.getByRole("button", { name: "Forget device", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await targetCard.getByText("Forget this device record?", { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
async function assertCancelledForget(dialogue: Locator, remote: JoinedDevicesRemote): Promise<void> {
|
||||
const before = await remote.read();
|
||||
await showForgetConfirmation(dialogue);
|
||||
const confirmation = deviceCard(dialogue, targetId).locator(".forget-confirmation");
|
||||
await confirmation.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await confirmation.waitFor({ state: "detached", timeout: uiTimeoutMs });
|
||||
assertEqual(JSON.stringify(await remote.read()), JSON.stringify(before), "Cancelling Forget changed the remote.");
|
||||
}
|
||||
|
||||
async function confirmForgetTarget(dialogue: Locator): Promise<void> {
|
||||
const targetCard = deviceCard(dialogue, targetId);
|
||||
await targetCard.getByRole("button", { name: "Forget device", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await dialogue.getByText("The device record was forgotten.", { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await targetCard.waitFor({ state: "detached", timeout: uiTimeoutMs });
|
||||
}
|
||||
|
||||
async function assertTargetForgotten(remote: JoinedDevicesRemote, fixture: DeviceFixture): Promise<void> {
|
||||
const milestone = await remote.read();
|
||||
assertEqual(
|
||||
JSON.stringify(milestone.accepted_nodes),
|
||||
JSON.stringify([retainedId]),
|
||||
"Forgetting changed the wrong accepted device records."
|
||||
);
|
||||
assertEqual(
|
||||
JSON.stringify(Object.keys(milestone.node_info)),
|
||||
JSON.stringify([retainedId]),
|
||||
"Forgetting changed the wrong device metadata records."
|
||||
);
|
||||
assertEqual(
|
||||
JSON.stringify(Object.keys(milestone.node_chunk_info)),
|
||||
JSON.stringify([retainedId]),
|
||||
"Forgetting changed the wrong chunk metadata records."
|
||||
);
|
||||
assertEqual(
|
||||
JSON.stringify(Object.keys(milestone.tweak_values).sort()),
|
||||
JSON.stringify([DEVICE_ID_PREFERRED, retainedId].sort()),
|
||||
"Forgetting changed the wrong tweak metadata records."
|
||||
);
|
||||
assertEqual(milestone.locked, true, "Forgetting changed the remote lock state.");
|
||||
assertEqual(milestone.cleaned, true, "Forgetting changed the remote cleaned state.");
|
||||
assertEqual(
|
||||
JSON.stringify(milestone.tweak_values[DEVICE_ID_PREFERRED]),
|
||||
JSON.stringify(fixture.preferredTweaks),
|
||||
"Forgetting changed the preferred-ID tweak record."
|
||||
);
|
||||
assertEqual(
|
||||
JSON.stringify(milestone.node_info[retainedId]),
|
||||
JSON.stringify(fixture.milestone.node_info[retainedId]),
|
||||
"Forgetting changed the retained device metadata."
|
||||
);
|
||||
}
|
||||
|
||||
async function assertScrollableMobileDialogue(page: Page, container: Locator): Promise<void> {
|
||||
const dialogue = container.locator(".modal").last();
|
||||
const closeButton = container.locator(".modal-close-button, .modal-header-button").last();
|
||||
await assertLocatorWithinViewport(page, dialogue, { label: "Joined devices dialogue" });
|
||||
await assertNoHorizontalOverflow(page, dialogue, { label: "Joined devices dialogue" });
|
||||
await assertLocatorWithinSafeArea(page, dialogue, {
|
||||
label: "Joined devices dialogue",
|
||||
safeAreaInsets: iPhoneSafeArea,
|
||||
});
|
||||
const buttons = [closeButton, ...(await dialogue.locator("button:visible").all())];
|
||||
for (const button of buttons) {
|
||||
// Device records form a scrollable list; every control must be reachable after scrolling.
|
||||
await button.scrollIntoViewIfNeeded();
|
||||
const label = (await button.innerText()).trim() || "Close dialogue";
|
||||
await assertLocatorWithinViewport(page, button, { label });
|
||||
await assertLocatorWithinSafeArea(page, button, { label, safeAreaInsets: iPhoneSafeArea });
|
||||
await assertNoHorizontalOverflow(page, button, { label });
|
||||
await assertLocatorHasMinimumTouchTarget(page, button, { label });
|
||||
}
|
||||
await container.locator(".modal-content").evaluate((element) => element.scrollTo(0, 0));
|
||||
}
|
||||
|
||||
async function dismissNotices(page: Page): Promise<void> {
|
||||
const notices = page.locator(".notice:visible");
|
||||
for (let remaining = 20; remaining > 0 && (await notices.count()) > 0; remaining--) {
|
||||
const notice = await notices.first().elementHandle();
|
||||
if (!notice) continue;
|
||||
try {
|
||||
await notice.click({ position: { x: 20, y: 20 }, timeout: uiTimeoutMs });
|
||||
await notice.waitForElementState("hidden", { timeout: uiTimeoutMs });
|
||||
} finally {
|
||||
await notice.dispose();
|
||||
}
|
||||
}
|
||||
assertEqual(await notices.count(), 0, "Notices still obscured the dialogue screenshot.");
|
||||
}
|
||||
|
||||
async function captureJoinedDevices(
|
||||
port: number,
|
||||
filename: string,
|
||||
assertReady: (page: Page) => Promise<Locator>
|
||||
): Promise<void> {
|
||||
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
await withObsidianPage(port, async (page) => {
|
||||
try {
|
||||
const dialogue = await assertReady(page);
|
||||
await dialogue.page().screenshot({ path: join(outputDirectory, filename), fullPage: true });
|
||||
} catch (error) {
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", 500).catch(() => undefined);
|
||||
const failurePath = join(outputDirectory, filename.replace(/\.png$/u, ".failure.png"));
|
||||
await (dialogue?.page() ?? page).screenshot({ path: failurePath, fullPage: true }).catch(() => undefined);
|
||||
console.error(`Joined devices UI failure screenshot: ${failurePath}`);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
|
||||
|
||||
const remote = await createRemoteFixture();
|
||||
const vault = await createTemporaryVault();
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
|
||||
try {
|
||||
await remote.prepare();
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary: cli.binary,
|
||||
vault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
pluginData: remote.pluginData,
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
await assertE2eCompatibilityUnpaused(cli.binary, session.cliEnv, session.remoteDebuggingPort);
|
||||
await remote.configure(cli.binary, session.cliEnv);
|
||||
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
await prepareRemote(cli.binary, session.cliEnv);
|
||||
|
||||
const self = await readSelfDeviceInfo(cli.binary, session.cliEnv);
|
||||
const fixture = createFixture(self);
|
||||
const preparedMilestone = await remote.read();
|
||||
await remote.write({
|
||||
...fixture.milestone,
|
||||
...(preparedMilestone._rev ? { _rev: preparedMilestone._rev } : {}),
|
||||
});
|
||||
|
||||
await captureJoinedDevices(session.remoteDebuggingPort, "joined-devices-notice.png", async (page) => {
|
||||
const dialogue = await openFromRemoteSettings(page);
|
||||
const dialoguePage = dialogue.page();
|
||||
await dialoguePage.setViewportSize({ width: 1280, height: 1100 });
|
||||
await assertInitialDialogue(dialogue, self, dialoguePage);
|
||||
const notice = dialoguePage.locator(".notice").filter({
|
||||
hasText: "Newer Self-hosted LiveSync versions detected: 999.0.0.",
|
||||
});
|
||||
await notice.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await notice
|
||||
.getByText("Older Self-hosted LiveSync versions detected: 0.1.0.", { exact: true })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
assertEqual(
|
||||
await notice
|
||||
.getByText("Reviewing and aligning Self-hosted LiveSync versions across devices is recommended.", {
|
||||
exact: true,
|
||||
})
|
||||
.count(),
|
||||
1,
|
||||
"The version Notice did not contain exactly one recommendation."
|
||||
);
|
||||
assertEqual(
|
||||
await notice.getByRole("button", { name: "Review devices", exact: true }).count(),
|
||||
1,
|
||||
"The version Notice did not contain exactly one shared review action."
|
||||
);
|
||||
return dialogue;
|
||||
});
|
||||
await captureJoinedDevices(session.remoteDebuggingPort, "joined-devices-desktop.png", async (page) => {
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
||||
await dialogue.getByRole("button", { name: "Close", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await dialogue
|
||||
.page()
|
||||
.locator(".notice")
|
||||
.getByRole("button", { name: "Review devices", exact: true })
|
||||
.click();
|
||||
const reopened = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
||||
await reopened.getByText(targetId, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await dismissNotices(reopened.page());
|
||||
return reopened;
|
||||
});
|
||||
await captureJoinedDevices(
|
||||
session.remoteDebuggingPort,
|
||||
"joined-devices-forget-confirmation.png",
|
||||
async (page) => {
|
||||
await refreshRemotePlatform(page, remote);
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
||||
await assertCancelledForget(dialogue, remote);
|
||||
await showForgetConfirmation(dialogue);
|
||||
await deviceCard(dialogue, targetId).locator(".forget-confirmation").scrollIntoViewIfNeeded();
|
||||
return dialogue;
|
||||
}
|
||||
);
|
||||
await captureJoinedDevices(session.remoteDebuggingPort, "joined-devices-after-forget.png", async (page) => {
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
||||
await confirmForgetTarget(dialogue);
|
||||
await dialogue.locator(".modal-content").evaluate((element) => element.scrollTo(0, 0));
|
||||
return dialogue;
|
||||
});
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
||||
await dialogue.getByRole("button", { name: "Close", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
const settings = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
await settings.close();
|
||||
});
|
||||
await assertTargetForgotten(remote, fixture);
|
||||
|
||||
await setObsidianMobileTestMode(session.remoteDebuggingPort, true, uiTimeoutMs);
|
||||
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
await captureJoinedDevices(session.remoteDebuggingPort, "joined-devices-mobile.png", async (page) => {
|
||||
const opened = await page.evaluate((id) => {
|
||||
const app = (
|
||||
window as typeof window & {
|
||||
app?: { commands?: { executeCommandById?(commandId: string): boolean } };
|
||||
}
|
||||
).app;
|
||||
return app?.commands?.executeCommandById?.(id) === true;
|
||||
}, commandId);
|
||||
assert(opened, "The Joined devices command did not open the dialogue.");
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
||||
await dialogue.getByText(retainedId, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await assertScrollableMobileDialogue(dialogue.page(), dialogue);
|
||||
assertEqual(
|
||||
await dialogue.locator(".device-card").count(),
|
||||
2,
|
||||
"The refreshed mobile list did not retain both remaining devices."
|
||||
);
|
||||
await dismissNotices(dialogue.page());
|
||||
return dialogue;
|
||||
});
|
||||
|
||||
console.log(`Joined devices scenario passed against ${remote.label}.`);
|
||||
} finally {
|
||||
try {
|
||||
if (session) await session.app.stop();
|
||||
} finally {
|
||||
try {
|
||||
await vault.dispose();
|
||||
} finally {
|
||||
await remote.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -26,6 +26,7 @@ const testSteps: Step[] = [
|
||||
{ name: "Review Harness", args: ["run", "test:e2e:obsidian:review-harness"] },
|
||||
{ name: "P2P status pane", args: ["run", "test:e2e:obsidian:p2p-pane"] },
|
||||
{ name: "vault reflection", args: ["run", "test:e2e:obsidian:vault-reflection"] },
|
||||
{ name: "joined devices with CouchDB", args: ["run", "test:e2e:obsidian:joined-devices"] },
|
||||
{ name: "CouchDB upload", args: ["run", "test:e2e:obsidian:couchdb-upload"] },
|
||||
{
|
||||
name: "manual CouchDB setup workflow",
|
||||
@@ -35,6 +36,7 @@ const testSteps: Step[] = [
|
||||
name: "CLI to real Obsidian synchronisation",
|
||||
args: ["run", "test:e2e:obsidian:cli-to-obsidian-sync"],
|
||||
},
|
||||
{ name: "joined devices with Object Storage", args: ["run", "test:e2e:obsidian:joined-devices-object-storage"] },
|
||||
{ name: "Object Storage upload", args: ["run", "test:e2e:obsidian:minio-upload"] },
|
||||
{
|
||||
name: "Object Storage Setup URI workflow",
|
||||
@@ -56,6 +58,7 @@ const testSteps: Step[] = [
|
||||
{ name: "startup scan", args: ["run", "test:e2e:obsidian:startup-scan"] },
|
||||
{ name: "provisioned Setup URI workflow", args: ["run", "test:e2e:obsidian:setup-uri-workflow"] },
|
||||
{ name: "two-vault synchronisation", args: ["run", "test:e2e:obsidian:two-vault-sync"] },
|
||||
{ name: "hidden file selectors", args: ["run", "test:e2e:obsidian:hidden-file-selectors"] },
|
||||
{ name: "hidden file snippet synchronisation", args: ["run", "test:e2e:obsidian:hidden-file-snippet-sync"] },
|
||||
{ name: "Customisation Sync", args: ["run", "test:e2e:obsidian:customisation-sync"] },
|
||||
{ name: "internal Metadata Doctor", args: ["run", "test:e2e:obsidian:internal-metadata-doctor"] },
|
||||
|
||||
@@ -372,13 +372,21 @@ async function restartAndResumeHarness(): Promise<string> {
|
||||
await harness
|
||||
.locator('[data-testid="review-harness-resumed"]')
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const continuationRemoved = await page.evaluate((stateKey) => {
|
||||
const continuationRemoved = await page.evaluate(async (stateKey) => {
|
||||
const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
if (typeof plugin !== "object" || plugin === null || !("core" in plugin)) {
|
||||
throw new Error("Self-hosted LiveSync is unavailable after restart.");
|
||||
}
|
||||
const core = (plugin as { core: { services: { setting: { getSmallConfig(key: string): string } } } }).core;
|
||||
return core.services.setting.getSmallConfig(stateKey) === "";
|
||||
const core = (
|
||||
plugin as {
|
||||
core: {
|
||||
services: {
|
||||
setting: { config: { getValue(key: string, defaultValue: string): Promise<string> } };
|
||||
};
|
||||
};
|
||||
}
|
||||
).core;
|
||||
return (await core.services.setting.config.getValue(stateKey, "")) === "";
|
||||
}, REVIEW_HARNESS_STATE_KEY);
|
||||
if (!continuationRemoved) throw new Error("The one-shot continuation was not removed before use.");
|
||||
await assertNoHorizontalOverflow(page, harness, { label: "resumed Review Harness" });
|
||||
|
||||
@@ -8,6 +8,8 @@ const focusedScenarios = new Set([
|
||||
"smoke",
|
||||
"onboarding-invitation",
|
||||
"dialog-mounts",
|
||||
"joined-devices",
|
||||
"joined-devices-object-storage",
|
||||
"database-diagnostics",
|
||||
"database-diagnostics-couchdb",
|
||||
"conflict-dialog-policy",
|
||||
@@ -39,6 +41,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",
|
||||
|
||||
@@ -58,8 +58,9 @@ type LiveSyncTestPlugin = {
|
||||
usePowerUserMode: boolean;
|
||||
useEdgeCaseMode: boolean;
|
||||
hashCacheMaxCount: number;
|
||||
customChunkSize: number;
|
||||
};
|
||||
getSmallConfig(key: string): string | null;
|
||||
config: { getValue(key: string, defaultValue: string): Promise<string> };
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -484,12 +485,12 @@ async function verifyCompatibilityReview(): Promise<void> {
|
||||
});
|
||||
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const markerBeforeAcknowledgement = await page.evaluate(() => {
|
||||
const markerBeforeAcknowledgement = await page.evaluate(async () => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable");
|
||||
return plugin.core.services.setting.getSmallConfig("database-compatibility-version");
|
||||
return await plugin.core.services.setting.config.getValue("database-compatibility-version", "");
|
||||
});
|
||||
if (markerBeforeAcknowledgement !== null && markerBeforeAcknowledgement !== "") {
|
||||
if (markerBeforeAcknowledgement !== "") {
|
||||
throw new Error(
|
||||
`The database version was marked as acknowledged before review: ${markerBeforeAcknowledgement}`
|
||||
);
|
||||
@@ -579,12 +580,12 @@ async function verifyCompatibilityReview(): Promise<void> {
|
||||
.click();
|
||||
await summary.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
await page.waitForFunction(
|
||||
(expectedVersion) => {
|
||||
async (expectedVersion) => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
if (plugin === undefined) return false;
|
||||
const setting = plugin.core.services.setting;
|
||||
return (
|
||||
setting.getSmallConfig("database-compatibility-version") === expectedVersion &&
|
||||
(await setting.config.getValue("database-compatibility-version", "")) === expectedVersion &&
|
||||
setting.currentSettings().versionUpFlash === ""
|
||||
);
|
||||
},
|
||||
@@ -611,10 +612,16 @@ async function verifyConfigDoctorFollowsCompatibilityReview(): Promise<string> {
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await doctor.getByText("Enhance chunk size", { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
const chunkSize = await page.evaluate(() => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
return plugin?.core.services.setting.currentSettings().customChunkSize;
|
||||
});
|
||||
if (chunkSize !== 0) {
|
||||
throw new Error("Compatibility review must retain the fixture's zero chunk-size value.");
|
||||
}
|
||||
if ((await doctor.getByText("Enhance chunk size", { exact: true }).count()) !== 0) {
|
||||
throw new Error("Config Doctor must accept the recommended zero chunk-size value.");
|
||||
}
|
||||
if ((await doctor.getByText("Data Compression", { exact: true }).count()) !== 0) {
|
||||
throw new Error("Config Doctor still treats supported Data Compression as a problem.");
|
||||
}
|
||||
@@ -978,6 +985,7 @@ function createSettingsPluginData(settingsOnlyRun: boolean): Record<string, unkn
|
||||
liveSync: false,
|
||||
versionUpFlash: settingsOnlyRun ? "" : compatibilityReviewMessage,
|
||||
notifyThresholdOfRemoteStorageSize: 0,
|
||||
customChunkSize: 0,
|
||||
syncOnStart: false,
|
||||
syncOnSave: false,
|
||||
syncOnEditorSave: false,
|
||||
|
||||
+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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user