mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-27 22:07:07 +00:00
Complete declarative settings runtime integration
This commit is contained in:
@@ -106,7 +106,18 @@ The underlying `test:e2e:obsidian:<scenario>` scripts remain available for an im
|
||||
|
||||
`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: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, covering the imperative settings fallback used by the `SettingSpec` proof. Finally, it selects the Synchronisation Settings pane and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour.
|
||||
`test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then enables Advanced mode and persists one numeric Advanced setting. On Obsidian before 1.13, this covers the imperative settings fallback used by the `SettingSpec` proof. On Obsidian 1.13 or later, it opens all 12 pages from the native page catalogue, searches globally for the Advanced control, captures the catalogue, search result, and Advanced page, and verifies that the value remains after the settings dialogue is closed and reopened. Finally, it selects the Synchronisation Settings page and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour.
|
||||
|
||||
The default runner uses the maintained pre-1.13 Obsidian fixture and therefore exercises the complete compatibility-review, mobile-layout, and imperative-settings path. To exercise only the native settings contract against an additional Obsidian 1.13-or-later installation, supply its executable and companion CLI explicitly:
|
||||
|
||||
```bash
|
||||
OBSIDIAN_BINARY=/path/to/obsidian \
|
||||
OBSIDIAN_CLI=/path/to/obsidian-cli \
|
||||
E2E_OBSIDIAN_SETTINGS_ONLY=true \
|
||||
npm run test:e2e:obsidian:settings-ui
|
||||
```
|
||||
|
||||
The native run writes `settings-declarative-catalogue.png`, `settings-declarative-search.png`, and `settings-declarative-advanced.png` to `E2E_OBSIDIAN_DIAGNOSTICS_DIR`. All settings E2E scenarios open pages through the shared navigator in `runner/ui.ts`; scenario code must not select the legacy tab menu directly.
|
||||
|
||||
The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixel viewport, and explicit iPhone-style safe-area insets of 47 pixels at the top and 34 pixels at the bottom. The public `@vrtmrz/obsidian-test-session` layout assertions require each modal to remain within the viewport and safe area without horizontal overflow. They also require the Obsidian Close control to remain within the safe area and provide at least a 44 by 44 CSS-pixel touch target. The runner clicks that control to verify actionability, then completes the explicit cancellation path. These simulated checks cover deterministic layout and interaction boundaries; they do not claim to reproduce a native operating-system overlay.
|
||||
|
||||
@@ -224,6 +235,7 @@ Useful environment variables:
|
||||
- `E2E_OBSIDIAN_DIALOG_TIMEOUT_MS`: timeout for a representative Svelte dialogue to mount, expose its principal controls, and close; default is 10 seconds.
|
||||
- `E2E_OBSIDIAN_REVISION_REPAIR_TIMEOUT_MS`: timeout for each visible revision-repair control and result; default is 15 seconds.
|
||||
- `E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS`: timeout for the settings pane and its deletion controls to become visible; default is 10 seconds.
|
||||
- `E2E_OBSIDIAN_SETTINGS_ONLY=true`: skip compatibility-review and mobile-layout coverage when running `settings-ui` against an additional Obsidian 1.13-or-later installation.
|
||||
- `E2E_OBSIDIAN_REVIEW_HARNESS_TIMEOUT_MS`: timeout for Review Harness view and action boundaries; default is 15 seconds.
|
||||
- `E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS`: timeout for the P2P status pane and its principal connection control; default is 10 seconds.
|
||||
- `E2E_OBSIDIAN_P2P_WORKFLOW_TIMEOUT_MS`: timeout for each visible P2P Setup URI, peer-discovery, approval, and replication control; default is 60 seconds.
|
||||
|
||||
@@ -3,6 +3,31 @@ import { dirname, join } from "node:path";
|
||||
import { withObsidianPage } from "@vrtmrz/obsidian-test-session";
|
||||
import type { Locator, Page } from "playwright";
|
||||
|
||||
type ObsidianSettingsHost = typeof globalThis & {
|
||||
app?: {
|
||||
setting?: {
|
||||
open(): void;
|
||||
openTabById(tabId: string): void;
|
||||
close(): void;
|
||||
};
|
||||
vault?: {
|
||||
adapter?: { getBasePath?: () => string; basePath?: string };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type LiveSyncSettingsRenderer = "imperative" | "declarative";
|
||||
|
||||
export type LiveSyncSettingsNavigator = {
|
||||
dialogue: Locator;
|
||||
page: Page;
|
||||
renderer: LiveSyncSettingsRenderer;
|
||||
close(): Promise<void>;
|
||||
openPage(name: string): Promise<Locator>;
|
||||
returnToCatalogue(): Promise<void>;
|
||||
isPageListed(name: string): Promise<boolean>;
|
||||
};
|
||||
|
||||
export {
|
||||
obsidianRemoteDebuggingPort,
|
||||
preseedTrustedVaultState,
|
||||
@@ -42,6 +67,181 @@ export async function captureObsidianDialogue(
|
||||
return await captureObsidianPage(port, filename, assertReady);
|
||||
}
|
||||
|
||||
function declarativePageEntry(dialogue: Locator, name: string): Locator {
|
||||
return dialogue
|
||||
.locator(".setting-item.mod-navigable")
|
||||
.filter({ hasText: new RegExp(`${escapeRegExp(name)}\\s*$`, "u") });
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the Self-hosted LiveSync settings tab through the renderer selected by
|
||||
* the running Obsidian version.
|
||||
*
|
||||
* Obsidian 1.13 may open settings in a separate window. The returned navigator
|
||||
* owns that renderer difference for both supported settings implementations.
|
||||
*/
|
||||
export async function openLiveSyncSettings(page: Page, timeoutMs = 10_000): Promise<LiveSyncSettingsNavigator> {
|
||||
await page.evaluate(() => {
|
||||
const host = globalThis as ObsidianSettingsHost;
|
||||
const setting = host.app?.setting;
|
||||
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
|
||||
setting.open();
|
||||
setting.openTabById("obsidian-livesync");
|
||||
});
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let settingsPage: Page | undefined;
|
||||
while (Date.now() < deadline && settingsPage === undefined) {
|
||||
for (const candidate of page.context().pages()) {
|
||||
if (
|
||||
await candidate
|
||||
.locator(".modal.mod-settings:visible")
|
||||
.last()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
settingsPage = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (settingsPage === undefined) await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
if (settingsPage === undefined) throw new Error("Obsidian did not open its settings interface");
|
||||
|
||||
const dialogue = settingsPage.locator(".modal.mod-settings:visible").last();
|
||||
const imperativeRoot = dialogue.locator(".sls-setting:visible").last();
|
||||
const firstDeclarativeEntry = declarativePageEntry(dialogue, "Change Log");
|
||||
await settingsPage.waitForFunction(
|
||||
() =>
|
||||
document.querySelector(".modal.mod-settings .sls-setting") !== null ||
|
||||
Array.from(document.querySelectorAll(".modal.mod-settings .setting-item-name")).some(
|
||||
(element) => element.textContent?.trim().endsWith("Change Log") === true
|
||||
),
|
||||
undefined,
|
||||
{ timeout: timeoutMs }
|
||||
);
|
||||
const renderer: LiveSyncSettingsRenderer = (await imperativeRoot.isVisible()) ? "imperative" : "declarative";
|
||||
|
||||
const returnToCatalogue = async (): Promise<void> => {
|
||||
if (renderer === "imperative" || (await firstDeclarativeEntry.isVisible())) return;
|
||||
const backButton = dialogue.locator(".setting-page-back-button:visible, .modal-setting-back-button:visible");
|
||||
await backButton.last().click({ timeout: timeoutMs });
|
||||
await firstDeclarativeEntry.waitFor({ state: "visible", timeout: timeoutMs });
|
||||
};
|
||||
|
||||
const openPage = async (name: string): Promise<Locator> => {
|
||||
if (renderer === "imperative") {
|
||||
const root = dialogue.locator(".sls-setting:visible").last();
|
||||
await root.locator(`.sls-setting-menu-btn[title="${name}"]`).click({ timeout: timeoutMs });
|
||||
return root;
|
||||
}
|
||||
|
||||
await returnToCatalogue();
|
||||
const entry = declarativePageEntry(dialogue, name);
|
||||
await entry.waitFor({ state: "visible", timeout: timeoutMs });
|
||||
await entry.click({ timeout: timeoutMs });
|
||||
await entry.waitFor({ state: "hidden", timeout: timeoutMs });
|
||||
const content = dialogue.locator(".vertical-tab-content:visible").last();
|
||||
await content.waitFor({ state: "visible", timeout: timeoutMs });
|
||||
return content;
|
||||
};
|
||||
|
||||
const isPageListed = async (name: string): Promise<boolean> => {
|
||||
if (renderer === "imperative") {
|
||||
return await dialogue
|
||||
.locator(`.sls-setting-menu-btn[title="${name}"]`)
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
}
|
||||
await returnToCatalogue();
|
||||
return await declarativePageEntry(dialogue, name)
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
};
|
||||
|
||||
const close = async (): Promise<void> => {
|
||||
await page
|
||||
.evaluate(() => {
|
||||
const setting = (globalThis as ObsidianSettingsHost).app?.setting;
|
||||
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
|
||||
setTimeout(() => setting.close(), 0);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!page.isClosed() && !settingsPage.isClosed()) throw error;
|
||||
});
|
||||
await Promise.race([
|
||||
dialogue.waitFor({ state: "hidden", timeout: timeoutMs }),
|
||||
settingsPage.waitForEvent("close", { timeout: timeoutMs }),
|
||||
]).catch((error: unknown) => {
|
||||
if (!settingsPage.isClosed()) throw error;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
dialogue,
|
||||
page: settingsPage,
|
||||
renderer,
|
||||
close,
|
||||
openPage,
|
||||
returnToCatalogue,
|
||||
isPageListed,
|
||||
};
|
||||
}
|
||||
|
||||
/** Allow only the isolated E2E Vault path in Obsidian's external-action prompt. */
|
||||
export async function allowPendingObsidianTestVaultOpenAction(
|
||||
port: number,
|
||||
expectedVaultPath: string,
|
||||
timeoutMs = 10_000
|
||||
): Promise<void> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const context = page.context();
|
||||
const action = page.locator(".modal.mod-uri-action:visible").last();
|
||||
const visible = await action
|
||||
.waitFor({ state: "visible", timeout: Math.min(timeoutMs, 2_000) })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (visible) {
|
||||
const actionText = await action.innerText();
|
||||
if (!actionText.includes(expectedVaultPath)) {
|
||||
throw new Error(`Refusing an unexpected Obsidian URI action: ${actionText}`);
|
||||
}
|
||||
await action.locator(".mod-checkbox").click({ timeout: timeoutMs });
|
||||
await action.getByRole("button", { name: "Continue" }).click({ timeout: timeoutMs });
|
||||
await action.waitFor({ state: "hidden", timeout: timeoutMs });
|
||||
}
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let vaultPage: Page | undefined;
|
||||
while (Date.now() < deadline && vaultPage === undefined) {
|
||||
for (const candidate of context.pages()) {
|
||||
const activePath = await candidate
|
||||
.evaluate(() => {
|
||||
const host = globalThis as ObsidianSettingsHost;
|
||||
const adapter = host.app?.vault?.adapter;
|
||||
return adapter?.getBasePath?.() ?? adapter?.basePath ?? null;
|
||||
})
|
||||
.catch(() => null);
|
||||
if (activePath === expectedVaultPath) {
|
||||
vaultPage = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (vaultPage === undefined) await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
if (vaultPage === undefined) {
|
||||
throw new Error(`Obsidian did not open the approved E2E Vault: ${expectedVaultPath}`);
|
||||
}
|
||||
for (const candidate of context.pages()) {
|
||||
if (candidate !== vaultPage && !candidate.isClosed()) await candidate.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function captureObsidianElement(
|
||||
port: number,
|
||||
filename: string,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
captureObsidianElement,
|
||||
captureObsidianPage,
|
||||
obsidianRemoteDebuggingPort,
|
||||
openLiveSyncSettings,
|
||||
withObsidianPage,
|
||||
} from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
@@ -46,11 +47,6 @@ type LiveSyncTestPlugin = {
|
||||
};
|
||||
};
|
||||
|
||||
type ObsidianSettingsController = {
|
||||
open(): void;
|
||||
openTabById(tabId: string): void;
|
||||
};
|
||||
|
||||
type ObsidianVaultFile = {
|
||||
path: string;
|
||||
};
|
||||
@@ -58,7 +54,6 @@ type ObsidianVaultFile = {
|
||||
type ObsidianTestApp = {
|
||||
commands?: { executeCommandById(commandId: string): boolean };
|
||||
plugins?: { plugins: Record<string, LiveSyncTestPlugin | undefined> };
|
||||
setting?: ObsidianSettingsController;
|
||||
vault?: {
|
||||
delete(file: ObsidianVaultFile, force: boolean): Promise<void>;
|
||||
getFiles(): ObsidianVaultFile[];
|
||||
@@ -748,17 +743,10 @@ async function verifyCompatibleAlignmentSettingDefault(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
await page.evaluate(() => {
|
||||
const setting = (globalThis as ObsidianTestGlobal).app?.setting;
|
||||
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
|
||||
setting.open();
|
||||
setting.openTabById("obsidian-livesync");
|
||||
});
|
||||
const liveSyncSettings = page.locator(".sls-setting");
|
||||
await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Advanced"]').click({ timeout: uiTimeoutMs });
|
||||
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
const liveSyncSettings = await settingsNavigator.openPage("Advanced");
|
||||
const settingItem = liveSyncSettings.locator(".setting-item").filter({
|
||||
has: page.getByText("Auto-accept compatible tweak mismatches", { exact: true }),
|
||||
has: settingsNavigator.page.getByText("Auto-accept compatible tweak mismatches", { exact: true }),
|
||||
});
|
||||
await settingItem.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const toggle = settingItem.locator(".checkbox-container");
|
||||
@@ -944,15 +932,8 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
||||
obsidianRemoteDebuggingPort(),
|
||||
"troubleshooting-hatch.png",
|
||||
async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const setting = (globalThis as ObsidianTestGlobal).app?.setting;
|
||||
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
|
||||
setting.open();
|
||||
setting.openTabById("obsidian-livesync");
|
||||
});
|
||||
const liveSyncSettings = page.locator(".sls-setting");
|
||||
await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Hatch"]').click({ timeout: uiTimeoutMs });
|
||||
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
const liveSyncSettings = await settingsNavigator.openPage("Hatch");
|
||||
for (const label of [
|
||||
"Write logs into the file",
|
||||
"Recreate chunks for current Vault files",
|
||||
|
||||
@@ -9,7 +9,12 @@ import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { assertMobileDialogueLayout, iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { captureObsidianDialogue, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
|
||||
import {
|
||||
captureObsidianDialogue,
|
||||
obsidianRemoteDebuggingPort,
|
||||
openLiveSyncSettings,
|
||||
withObsidianPage,
|
||||
} from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_ONBOARDING_TIMEOUT_MS ?? 15000);
|
||||
@@ -25,15 +30,6 @@ type UnconfiguredStartupEvidence = {
|
||||
};
|
||||
};
|
||||
|
||||
type ObsidianTestApp = {
|
||||
setting?: {
|
||||
open(): void;
|
||||
openTabById(tabId: string): void;
|
||||
};
|
||||
};
|
||||
|
||||
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
|
||||
|
||||
async function writeMarker(vaultPath: string): Promise<void> {
|
||||
const fullPath = join(vaultPath, markerPath);
|
||||
await mkdir(dirname(fullPath), { recursive: true });
|
||||
@@ -158,25 +154,17 @@ async function captureAndCloseIntro(filename: string, mobile: boolean): Promise<
|
||||
|
||||
async function openOnboardingFromSettings(): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const setting = (globalThis as ObsidianTestGlobal).app?.setting;
|
||||
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
|
||||
setting.open();
|
||||
setting.openTabById("obsidian-livesync");
|
||||
});
|
||||
|
||||
const liveSyncSettings = page.locator(".sls-setting");
|
||||
await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Setup"]').click({ timeout: uiTimeoutMs });
|
||||
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
const liveSyncSettings = await settingsNavigator.openPage("Setup");
|
||||
|
||||
const onboardingSetting = liveSyncSettings.locator(".setting-item").filter({
|
||||
has: page.locator(".setting-item-name").filter({ hasText: "Rerun Onboarding Wizard" }),
|
||||
has: settingsNavigator.page.locator(".setting-item-name").filter({ hasText: "Rerun Onboarding Wizard" }),
|
||||
});
|
||||
await onboardingSetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await onboardingSetting
|
||||
.getByRole("button", { name: "Rerun Wizard", exact: true })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
await onboardingDialogue(page).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await onboardingDialogue(settingsNavigator.page).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -197,11 +185,8 @@ async function dismissVisibleNotices(): Promise<void> {
|
||||
|
||||
async function closeSettings(): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const settingsContainer = page.locator(".modal-container").filter({
|
||||
has: page.locator(".sls-setting"),
|
||||
});
|
||||
await settingsContainer.locator(".modal-close-button").click({ timeout: uiTimeoutMs });
|
||||
await settingsContainer.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
await settingsNavigator.close();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
waitForLocalDatabaseEntry,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { captureObsidianElement, withObsidianPage } from "../runner/ui.ts";
|
||||
import { captureObsidianElement, openLiveSyncSettings, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
import type { Locator, Page } from "playwright";
|
||||
|
||||
@@ -35,17 +35,6 @@ type VaultWinnerState = {
|
||||
winnerRevision: string;
|
||||
};
|
||||
|
||||
type ObsidianSettingsController = {
|
||||
open(): void;
|
||||
openTabById(tabId: string): void;
|
||||
};
|
||||
|
||||
type ObsidianTestGlobal = typeof globalThis & {
|
||||
app?: {
|
||||
setting?: ObsidianSettingsController;
|
||||
};
|
||||
};
|
||||
|
||||
async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
@@ -359,17 +348,10 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const setting = (globalThis as ObsidianTestGlobal).app?.setting;
|
||||
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
|
||||
setting.open();
|
||||
setting.openTabById("obsidian-livesync");
|
||||
});
|
||||
const settings = page.locator(".sls-setting");
|
||||
await settings.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await settings.locator('.sls-setting-menu-btn[title="Hatch"]').click({ timeout: uiTimeoutMs });
|
||||
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
const settings = await settingsNavigator.openPage("Hatch");
|
||||
const verifySetting = settings.locator(".setting-item").filter({
|
||||
has: page.getByText("Inspect conflicts and file/database differences", {
|
||||
has: settingsNavigator.page.getByText("Inspect conflicts and file/database differences", {
|
||||
exact: true,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
|
||||
import { assertMobileDialogueLayout, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { captureObsidianDialogue, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
|
||||
import {
|
||||
allowPendingObsidianTestVaultOpenAction,
|
||||
captureObsidianDialogue,
|
||||
obsidianRemoteDebuggingPort,
|
||||
openLiveSyncSettings,
|
||||
preseedTrustedVaultState,
|
||||
withObsidianPage,
|
||||
} from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS ?? 10000);
|
||||
const compatibilityReviewMessage = "Review the internal database compatibility change before synchronisation resumes.";
|
||||
|
||||
type ObsidianSettingsController = {
|
||||
open(): void;
|
||||
openTabById(tabId: string): void;
|
||||
const settingsOnly = process.env.E2E_OBSIDIAN_SETTINGS_ONLY === "true";
|
||||
const diagnosticsDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
|
||||
const settingsScreenshotOptions = {
|
||||
animations: "disabled" as const,
|
||||
style: ".notice-container { visibility: hidden !important; }",
|
||||
};
|
||||
const compatibilityReviewMessage = "Review the internal database compatibility change before synchronisation resumes.";
|
||||
|
||||
type LiveSyncTestPlugin = {
|
||||
core: {
|
||||
@@ -34,12 +43,39 @@ type LiveSyncTestPlugin = {
|
||||
};
|
||||
|
||||
type ObsidianTestApp = {
|
||||
setting?: ObsidianSettingsController;
|
||||
plugins?: { plugins: Record<string, LiveSyncTestPlugin | undefined> };
|
||||
};
|
||||
|
||||
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
|
||||
|
||||
const settingsPageNames = [
|
||||
"Change Log",
|
||||
"Setup",
|
||||
"General Settings",
|
||||
"Remote Configuration",
|
||||
"Sync Settings",
|
||||
"Selector",
|
||||
"Customisation sync",
|
||||
"Hatch",
|
||||
"Advanced",
|
||||
"Power users",
|
||||
"Patches",
|
||||
"Maintenance",
|
||||
] as const;
|
||||
|
||||
async function resumePendingCompatibilityReviewForSettings(): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const review = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({
|
||||
hasText: "Synchronisation paused for compatibility review",
|
||||
}),
|
||||
});
|
||||
if (!(await review.isVisible())) return;
|
||||
await review.getByRole("button", { name: "Resume synchronisation" }).click({ timeout: uiTimeoutMs });
|
||||
await review.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyCompatibilityReview(): Promise<void> {
|
||||
const port = obsidianRemoteDebuggingPort();
|
||||
const summaryScreenshot = await captureObsidianDialogue(port, "compatibility-review-summary.png", async (page) => {
|
||||
@@ -223,41 +259,26 @@ async function verifyEffectiveSettings(): Promise<void> {
|
||||
sleepPreferences.powerUserMode !== false ||
|
||||
sleepPreferences.edgeCaseMode !== false
|
||||
) {
|
||||
throw new Error(
|
||||
`Unexpected effective sleep preferences: ${JSON.stringify(sleepPreferences)}`
|
||||
);
|
||||
throw new Error(`Unexpected effective sleep preferences: ${JSON.stringify(sleepPreferences)}`);
|
||||
}
|
||||
|
||||
await page.evaluate(() => {
|
||||
const setting = (globalThis as ObsidianTestGlobal).app?.setting;
|
||||
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
|
||||
setting.open();
|
||||
setting.openTabById("obsidian-livesync");
|
||||
});
|
||||
|
||||
const liveSyncSettings = page.locator(".sls-setting");
|
||||
await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const settingsClass = await liveSyncSettings.getAttribute("class");
|
||||
for (const modeClass of [
|
||||
"menu-setting-advanced-disabled",
|
||||
"menu-setting-poweruser-disabled",
|
||||
"menu-setting-edgecase-disabled",
|
||||
]) {
|
||||
if (!settingsClass?.split(/\s+/u).includes(modeClass)) {
|
||||
throw new Error(`The settings UI did not disable ${modeClass}.`);
|
||||
let settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
for (const hiddenPage of ["Selector", "Customisation sync", "Advanced", "Power users", "Patches"]) {
|
||||
if (await settingsNavigator.isPageListed(hiddenPage)) {
|
||||
throw new Error(`${hiddenPage} was visible before its feature level was enabled.`);
|
||||
}
|
||||
}
|
||||
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Change Log"]').click();
|
||||
const removedAcknowledgements = liveSyncSettings.getByRole("button", {
|
||||
let settingsPage = await settingsNavigator.openPage("Change Log");
|
||||
const removedAcknowledgements = settingsPage.getByRole("button", {
|
||||
name: /I got it and updated|OK, I have read everything/u,
|
||||
});
|
||||
if ((await removedAcknowledgements.count()) !== 0) {
|
||||
throw new Error("The Change Log still contains a compatibility or release-note acknowledgement control.");
|
||||
}
|
||||
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Remote Configuration"]').click();
|
||||
const connectionPanel = liveSyncSettings
|
||||
settingsPage = await settingsNavigator.openPage("Remote Configuration");
|
||||
const connectionPanel = settingsPage
|
||||
.locator("h4.sls-setting-panel-title")
|
||||
.filter({ hasText: "Connection settings" })
|
||||
.locator("..");
|
||||
@@ -267,41 +288,74 @@ async function verifyEffectiveSettings(): Promise<void> {
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Sync Settings"]').click();
|
||||
const generalSleepSetting = liveSyncSettings.locator(".setting-item").filter({
|
||||
has: page.getByText("Allow sleep during synchronisation", { exact: true }),
|
||||
settingsPage = await settingsNavigator.openPage("Sync Settings");
|
||||
const generalSleepSetting = settingsPage.locator(".setting-item").filter({
|
||||
has: settingsNavigator.page.getByText("Allow sleep during synchronisation", { exact: true }),
|
||||
});
|
||||
await generalSleepSetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if ((await generalSleepSetting.locator(".checkbox-container.is-enabled").count()) !== 0) {
|
||||
throw new Error("The general sleep preference must be disabled by default.");
|
||||
}
|
||||
|
||||
const desktopSleepSetting = liveSyncSettings.locator(".setting-item").filter({
|
||||
has: page.getByText("Allow sleep during synchronisation on the desktop", { exact: true }),
|
||||
const desktopSleepSetting = settingsPage.locator(".setting-item").filter({
|
||||
has: settingsNavigator.page.getByText("Allow sleep during synchronisation on the desktop", {
|
||||
exact: true,
|
||||
}),
|
||||
});
|
||||
await desktopSleepSetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if ((await desktopSleepSetting.locator(".checkbox-container.is-enabled").count()) !== 1) {
|
||||
throw new Error("The desktop sleep preference must be enabled by default.");
|
||||
}
|
||||
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Setup"]').click();
|
||||
const advancedModeSetting = liveSyncSettings.locator(".setting-item").filter({
|
||||
has: page.getByText("Enable advanced features", { exact: true }),
|
||||
settingsPage = await settingsNavigator.openPage("Setup");
|
||||
const advancedModeSetting = settingsPage.locator(".setting-item").filter({
|
||||
has: settingsNavigator.page.getByText("Enable advanced features", { exact: true }),
|
||||
});
|
||||
await advancedModeSetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await advancedModeSetting.locator(".checkbox-container").click();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector(".sls-setting")?.classList.contains("menu-setting-advanced-enabled") === true,
|
||||
() => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
return plugin?.core.services.setting.currentSettings().useAdvancedMode === true;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Advanced"]').click();
|
||||
const cacheSizeSetting = liveSyncSettings.locator(".setting-item").filter({
|
||||
has: page.getByText("Memory cache size (by total items)", { exact: true }),
|
||||
if (settingsNavigator.renderer === "declarative") {
|
||||
for (const mode of [
|
||||
{ label: "Enable poweruser features", key: "usePowerUserMode" },
|
||||
{ label: "Enable edge case treatment features", key: "useEdgeCaseMode" },
|
||||
] as const) {
|
||||
const modeSetting = settingsPage.locator(".setting-item").filter({
|
||||
has: settingsNavigator.page.getByText(mode.label, { exact: true }),
|
||||
});
|
||||
await modeSetting.locator(".checkbox-container").click({ timeout: uiTimeoutMs });
|
||||
await page.waitForFunction(
|
||||
(key) => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
return plugin?.core.services.setting.currentSettings()[key] === true;
|
||||
},
|
||||
mode.key,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
settingsPage = await settingsNavigator.openPage("Advanced");
|
||||
const cacheSizeSetting = settingsPage.locator(".setting-item").filter({
|
||||
has: settingsNavigator.page.getByText("Memory cache size (by total items)", { exact: true }),
|
||||
});
|
||||
await cacheSizeSetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await cacheSizeSetting.locator('input[type="number"]').fill("321");
|
||||
const cacheSizeInput = cacheSizeSetting.locator('input[type="number"]');
|
||||
if (settingsNavigator.renderer === "declarative") {
|
||||
await cacheSizeInput.click();
|
||||
await cacheSizeInput.press("ControlOrMeta+A");
|
||||
await cacheSizeInput.pressSequentially("321");
|
||||
await cacheSizeInput.press("Enter");
|
||||
} else {
|
||||
await cacheSizeInput.fill("321");
|
||||
}
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
@@ -311,9 +365,44 @@ async function verifyEffectiveSettings(): Promise<void> {
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Sync Settings"]').click();
|
||||
if (settingsNavigator.renderer === "declarative") {
|
||||
await settingsNavigator.returnToCatalogue();
|
||||
await settingsNavigator.dialogue
|
||||
.locator(".vertical-tab-content:visible")
|
||||
.last()
|
||||
.evaluate((element) => {
|
||||
element.scrollTop = 0;
|
||||
});
|
||||
await settingsNavigator.dialogue.screenshot({
|
||||
...settingsScreenshotOptions,
|
||||
path: `${diagnosticsDirectory}/settings-declarative-catalogue.png`,
|
||||
});
|
||||
const search = settingsNavigator.dialogue.locator(".setting-search-container input");
|
||||
await search.fill("Memory cache size (by total items)");
|
||||
const searchResult = settingsNavigator.dialogue.locator(".setting-search-result-item").filter({
|
||||
has: settingsNavigator.page.getByText("Memory cache size (by total items)", { exact: true }),
|
||||
});
|
||||
await settingsNavigator.dialogue.screenshot({
|
||||
...settingsScreenshotOptions,
|
||||
path: `${diagnosticsDirectory}/settings-declarative-search.png`,
|
||||
});
|
||||
await searchResult.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await search.fill("");
|
||||
|
||||
const deletionPanel = liveSyncSettings
|
||||
for (const pageName of settingsPageNames) {
|
||||
const pageRoot = await settingsNavigator.openPage(pageName);
|
||||
await pageRoot.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
}
|
||||
settingsPage = await settingsNavigator.openPage("Advanced");
|
||||
await settingsNavigator.dialogue.screenshot({
|
||||
...settingsScreenshotOptions,
|
||||
path: `${diagnosticsDirectory}/settings-declarative-advanced.png`,
|
||||
});
|
||||
}
|
||||
|
||||
settingsPage = await settingsNavigator.openPage("Sync Settings");
|
||||
|
||||
const deletionPanel = settingsPage
|
||||
.locator("h4.sls-setting-panel-title")
|
||||
.filter({ hasText: "Deletion Propagation" })
|
||||
.locator("..");
|
||||
@@ -328,6 +417,22 @@ async function verifyEffectiveSettings(): Promise<void> {
|
||||
`The obsolete LiveSync trash toggle is still present in the settings UI (${obsoleteToggleCount} found).`
|
||||
);
|
||||
}
|
||||
|
||||
if (settingsNavigator.renderer === "declarative") {
|
||||
await settingsNavigator.close();
|
||||
settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
settingsPage = await settingsNavigator.openPage("Advanced");
|
||||
const restoredValue = await settingsPage
|
||||
.locator(".setting-item")
|
||||
.filter({
|
||||
has: settingsNavigator.page.getByText("Memory cache size (by total items)", { exact: true }),
|
||||
})
|
||||
.locator('input[type="number"]')
|
||||
.inputValue();
|
||||
if (restoredValue !== "321") {
|
||||
throw new Error(`The declarative Advanced value was not restored after reopening: ${restoredValue}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -338,6 +443,7 @@ async function main(): Promise<void> {
|
||||
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
|
||||
}
|
||||
const vault = await createTemporaryVault();
|
||||
await mkdir(diagnosticsDirectory, { recursive: true });
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
try {
|
||||
session = await startObsidianLiveSyncSession({
|
||||
@@ -346,10 +452,10 @@ async function main(): Promise<void> {
|
||||
vault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
pluginData: {
|
||||
doctorProcessedVersion: "0.25.27",
|
||||
doctorProcessedVersion: settingsOnly ? "1.0.0" : "0.25.27",
|
||||
isConfigured: true,
|
||||
liveSync: false,
|
||||
versionUpFlash: compatibilityReviewMessage,
|
||||
versionUpFlash: settingsOnly ? "" : compatibilityReviewMessage,
|
||||
notifyThresholdOfRemoteStorageSize: 0,
|
||||
syncOnStart: false,
|
||||
syncOnSave: false,
|
||||
@@ -362,10 +468,22 @@ async function main(): Promise<void> {
|
||||
usePowerUserMode: false,
|
||||
useEdgeCaseMode: false,
|
||||
},
|
||||
lifecycle: settingsOnly
|
||||
? {
|
||||
afterLaunch: async ({ remoteDebuggingPort }) => {
|
||||
await preseedTrustedVaultState(remoteDebuggingPort, vault.id);
|
||||
await allowPendingObsidianTestVaultOpenAction(remoteDebuggingPort, vault.path, uiTimeoutMs);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
await verifyCompatibilityReview();
|
||||
await verifyConfigDoctorFollowsCompatibilityReview();
|
||||
if (settingsOnly) {
|
||||
await resumePendingCompatibilityReviewForSettings();
|
||||
} else {
|
||||
await verifyCompatibilityReview();
|
||||
await verifyConfigDoctorFollowsCompatibilityReview();
|
||||
}
|
||||
await verifyEffectiveSettings();
|
||||
console.log("Compatibility review and settings expose only effective user controls.");
|
||||
} finally {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
captureObsidianDialogue,
|
||||
captureObsidianElement,
|
||||
captureObsidianPage,
|
||||
openLiveSyncSettings,
|
||||
withObsidianPage,
|
||||
} from "../runner/ui.ts";
|
||||
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||
@@ -90,12 +91,10 @@ function modalByTitle(page: Page, title: string): Locator {
|
||||
});
|
||||
}
|
||||
|
||||
function settingPanelByTitle(page: Page, title: string): Locator {
|
||||
return page
|
||||
.locator(".sls-setting")
|
||||
.locator("h4.sls-setting-panel-title:visible")
|
||||
.filter({ hasText: title })
|
||||
.locator("..");
|
||||
async function liveSyncSettingPanelByTitle(page: Page, pageName: string, title: string): Promise<Locator> {
|
||||
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
const settingsPage = await settingsNavigator.openPage(pageName);
|
||||
return settingsPage.locator("h4.sls-setting-panel-title:visible").filter({ hasText: title }).locator("..");
|
||||
}
|
||||
|
||||
async function captureGuideDialogue(port: number, filename: string, title: string): Promise<string> {
|
||||
@@ -535,58 +534,27 @@ async function captureHiddenFileGuideSettings(
|
||||
environment
|
||||
);
|
||||
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const obsidian = globalThis as typeof globalThis & {
|
||||
app?: {
|
||||
setting?: {
|
||||
open(): void;
|
||||
openTabById(tabId: string): void;
|
||||
};
|
||||
};
|
||||
};
|
||||
const setting = obsidian.app?.setting;
|
||||
if (!setting) throw new Error("Obsidian settings are unavailable");
|
||||
setting.open();
|
||||
setting.openTabById("obsidian-livesync");
|
||||
});
|
||||
const settings = page.locator(".sls-setting");
|
||||
await settings.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await settings.locator('.sls-setting-menu-btn[title="Setup"]').click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
const screenshots = [
|
||||
await captureObsidianElement(port, "guide-hidden-file-advanced-features.png", (page) =>
|
||||
settingPanelByTitle(page, "Enable extra and advanced features")
|
||||
liveSyncSettingPanelByTitle(page, "Setup", "Enable extra and advanced features")
|
||||
),
|
||||
];
|
||||
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page
|
||||
.locator(".sls-setting")
|
||||
.locator('.sls-setting-menu-btn[title="Selector"]')
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
screenshots.push(
|
||||
await captureObsidianElement(port, "guide-hidden-file-selector.png", (page) =>
|
||||
settingPanelByTitle(page, "Hidden Files")
|
||||
liveSyncSettingPanelByTitle(page, "Selector", "Hidden Files")
|
||||
)
|
||||
);
|
||||
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page
|
||||
.locator(".sls-setting")
|
||||
.locator('.sls-setting-menu-btn[title="Sync Settings"]')
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
screenshots.push(
|
||||
await captureObsidianElement(port, "guide-hidden-file-enable.png", (page) =>
|
||||
settingPanelByTitle(page, "Hidden Files")
|
||||
liveSyncSettingPanelByTitle(page, "Sync Settings", "Hidden Files")
|
||||
)
|
||||
);
|
||||
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page.keyboard.press("Escape");
|
||||
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
await settingsNavigator.close();
|
||||
});
|
||||
return screenshots;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user