mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-27 06:52:59 +00:00
Verify P2P pane in a fresh mobile session
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
platform: {
|
||||
isMobile: false,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
Platform: mocks.platform,
|
||||
requestUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/deps", () => ({
|
||||
Platform: mocks.platform,
|
||||
requestUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/essentialObsidian/APILib/ObsHttpHandler", () => ({
|
||||
ObsHttpHandler: class {},
|
||||
}));
|
||||
|
||||
vi.mock("./ObsidianConfirm", () => ({
|
||||
ObsidianConfirm: class {},
|
||||
}));
|
||||
|
||||
import { ObsidianAPIService } from "./ObsidianAPIService";
|
||||
import type { ObsidianServiceContext } from "./ObsidianServiceContext";
|
||||
|
||||
function createService(workspace: Record<string, unknown>, isMobile = false): ObsidianAPIService {
|
||||
return new ObsidianAPIService({
|
||||
app: { workspace, isMobile },
|
||||
} as unknown as ObsidianServiceContext);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.platform.isMobile = false;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ObsidianAPIService.showWindowOnRight", () => {
|
||||
it("keeps the status view in the right leaf on mobile", async () => {
|
||||
mocks.platform.isMobile = true;
|
||||
const rightLeaf = {
|
||||
setViewState: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const workspace = {
|
||||
getLeavesOfType: vi.fn(() => []),
|
||||
getLeaf: vi.fn(),
|
||||
getRightLeaf: vi.fn(() => rightLeaf),
|
||||
revealLeaf: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const service = createService(workspace, true);
|
||||
|
||||
expect(service.isMobile()).toBe(true);
|
||||
await service.showWindowOnRight("p2p-status");
|
||||
|
||||
expect(workspace.getLeavesOfType).toHaveBeenCalledWith("p2p-status");
|
||||
expect(workspace.getRightLeaf).toHaveBeenCalledWith(false);
|
||||
expect(workspace.getLeaf).not.toHaveBeenCalled();
|
||||
expect(rightLeaf.setViewState).toHaveBeenCalledWith({
|
||||
type: "p2p-status",
|
||||
active: false,
|
||||
});
|
||||
expect(workspace.revealLeaf).toHaveBeenCalledWith(rightLeaf);
|
||||
});
|
||||
});
|
||||
@@ -7,12 +7,12 @@ The generic application discovery, isolated-vault, plug-in installation, process
|
||||
The current smoke runner verifies the launch path and the loaded plug-in's Service Context composition:
|
||||
|
||||
1. create a temporary vault,
|
||||
2. install the built Self-hosted LiveSync plug-in artifacts,
|
||||
2. install the built Self-hosted LiveSync plug-in artefacts,
|
||||
3. launch real Obsidian,
|
||||
4. open the temporary vault through `obsidian-cli`,
|
||||
5. enable Obsidian community plug-ins for the temporary app profile,
|
||||
6. reload Self-hosted LiveSync through `obsidian-cli`,
|
||||
7. verify through `obsidian-cli eval` that the plug-in is loaded,
|
||||
5. prepare the isolated Vault trust state and handle any Obsidian trust prompt,
|
||||
6. preserve natural plug-in loading, or complete requested pre-load work before loading the plug-in once in controlled start-up,
|
||||
7. verify through the active renderer that the plug-in is loaded,
|
||||
8. observe event and translation results from the actual `ObsidianServiceContext`,
|
||||
9. verify that the Service Hub and every exposed service retain that exact Context,
|
||||
10. optionally drive a real vault or CouchDB workflow through Obsidian's own API, and
|
||||
@@ -24,6 +24,8 @@ Obsidian 1.12 stores the global community plug-in switch outside `.obsidian/comm
|
||||
|
||||
Future workflows should use `startObsidianLiveSyncSession()` from `runner/session.ts` rather than repeating the launch and plug-in readiness sequence. Add generic Obsidian bootstrap improvements to Fancy Kit; keep LiveSync behaviour and scenario helpers here.
|
||||
|
||||
When a LiveSync-owned scenario must establish application state before the plug-in's first load, pass an instance-scoped `lifecycle.beforePluginStart` callback through that wrapper. For example, the P2P pane scenario calls `setObsidianMobileTestModeBeforePluginStart()` there so LiveSync observes the mobile application state while registering its command and view. Mobile emulation reopens Obsidian's workspace layout; this helper waits for both the `is-mobile` body state and `workspace.layoutReady` before controlled loading continues. The shared package owns the controlled start-up order and guarantees that the plug-in loads once; the LiveSync scenario owns the resulting command, workspace placement, and visible UI assertions. Changing the state only after loading the plug-in is not evidence of its mobile start-up behaviour.
|
||||
|
||||
Each test vault uses an isolated Obsidian profile. The runner creates temporary directories for `HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, and Electron `--user-data-dir`, writes the vault registry into those directories, pre-seeds the temporary Chromium local storage so community plug-ins are trusted for that generated vault ID, and passes the same environment to `obsidian-cli`. This is intended to keep real Obsidian E2E runs separate from a developer's daily Obsidian profile and vault registry.
|
||||
|
||||
On macOS, `@vrtmrz/obsidian-test-session` keeps the generated Vault and profile below `/tmp` so Obsidian's Unix-domain CLI socket remains below the platform path limit. It also gives only the isolated Obsidian process Chromium's mock-keychain flag, preventing the empty test HOME from opening a blocking login-keychain dialogue. LiveSync's deterministic fixture selects the built-in default language so a host-language translation prompt cannot pause plug-in readiness. The case-only rename check enumerates the parent directory and compares exact spellings because an old-path lookup still resolves the renamed file on the default case-insensitive macOS filesystem.
|
||||
@@ -110,7 +112,7 @@ The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixe
|
||||
|
||||
`test:e2e:obsidian:review-harness` exercises only the boundaries owned by the opt-in maintainer Harness. It retains a real compatibility pause, uses the fixed Harness restart action to persist a device-local continuation and reload Obsidian, and requires the Harness to delete that state before reopening. It also runs the bounded local observations, confirms the dedicated Vault fixture root is removed, captures the copied privacy-bounded Markdown report, and checks the Harness layout and touch targets in mobile test mode. Compatibility explanation and persistence details remain owned by `settings-ui`, real P2P transfer remains owned by the dedicated P2P suites, and general Vault reflection remains owned by `vault-reflection`; the Harness test does not duplicate those workflows.
|
||||
|
||||
`test:e2e:obsidian:p2p-pane` starts one unconfigured CouchDB-only session and one configured P2P session. It proves that the command remains registered while the retired command, automatic pane, and unconfigured ribbon entry are absent. For the configured profile, it verifies that the ribbon and current status command reach the pane without opening it at start-up, checks its connection control and horizontal layout, and captures unobstructed desktop and mobile screenshots. It deliberately uses no relay or peer: replacement of the active replicator is covered by focused unit tests, the Deno and Compose CLI P2P lifecycle suite covers the headless transport, and `p2p-setup-uri-workflow` owns the visible transfer path between two real Obsidian sessions.
|
||||
`test:e2e:obsidian:p2p-pane` starts one configured CouchDB-only session with no P2P profile and separate configured P2P sessions for desktop and mobile. It proves that the command remains registered while the retired command, automatic pane, and ribbon entry without a P2P configuration are absent. For the configured P2P profiles, it verifies that the desktop ribbon is available, the current status command reaches the pane without it opening at start-up, checks its connection control and horizontal layout, and captures unobstructed desktop and mobile screenshots. The mobile session uses a fresh Vault, profile, and Obsidian process, enters `app.emulateMobile(true)` through `lifecycle.beforePluginStart`, and requires the P2P view to belong to the right drawer rather than inheriting desktop workspace state. It deliberately uses no relay or peer: replacement of the active replicator is covered by focused unit tests, the Deno and Compose CLI P2P lifecycle suite covers the headless transport, and `p2p-setup-uri-workflow` owns the visible transfer path between two real Obsidian sessions.
|
||||
|
||||
`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, revision repair, settings UI, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload and manual setup, CLI-to-Obsidian synchronisation, Object Storage upload and Setup URI round-trip, P2P Setup URI round-trip, startup scan, provisioned CouchDB Setup URI, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export in sequence. Start the local CouchDB, MinIO, and P2P relay fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run.
|
||||
|
||||
@@ -126,6 +128,8 @@ The two-Vault workflow performs the missing-marker review once for each isolated
|
||||
|
||||
`test:e2e:obsidian:cli-to-obsidian-sync` is the cross-runtime compatibility check for the official LiveSync CLI and the real Obsidian plug-in. Build the plug-in first, and build the local CLI too when no external CLI command is selected. The script uses E2EE, Path Obfuscation, and the current preferred chunk settings to create and synchronise a note through the CLI, starts real Obsidian with an isolated Vault and profile, synchronises the same CouchDB database, and verifies that the plug-in materialises identical note content. This covers the boundary that CLI-only and plug-in-only round trips do not exercise.
|
||||
|
||||
The isolated Obsidian session starts with its CouchDB settings and device-local compatibility acknowledgement already in place. This keeps the scenario focused on cross-runtime data compatibility; unconfigured start-up and visible CouchDB onboarding are covered by their dedicated workflows.
|
||||
|
||||
By default, the compatibility check runs `node src/apps/cli/dist/index.cjs`. Set `LIVESYNC_CLI_COMMAND` to test another CLI build or distribution. The value may be a quoted command line or a JSON array of executable and prefix arguments; the scenario arguments are appended without going through a shell.
|
||||
|
||||
For example, to test an executable on `PATH`:
|
||||
@@ -141,7 +145,7 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
|
||||
npm run test:e2e:obsidian:cli-to-obsidian-sync
|
||||
```
|
||||
|
||||
`test:e2e:obsidian:minio-upload` reuses the Object Storage variables from `.test.env` or the process environment. It expects a reachable S3-compatible service, configures Self-hosted LiveSync for Object Storage through `obsidian-cli eval`, creates a note in real Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written under a unique bucket prefix. Adapter tests separately observe an in-progress SDK command, while this real-runtime workflow verifies the resulting request counters advance and rebalance.
|
||||
`test:e2e:obsidian:minio-upload` reuses the Object Storage variables from `.test.env` or the process environment. It expects a reachable S3-compatible service and starts with isolated Object Storage settings and the device-local compatibility acknowledgement already in place, keeping the scenario focused on upload rather than unconfigured start-up or setup. It confirms those settings through `obsidian-cli eval`, creates a note in real Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written under a unique bucket prefix. Adapter tests separately observe an in-progress SDK command, while this real-runtime workflow verifies the resulting request counters advance and rebalance.
|
||||
|
||||
`test:e2e:obsidian:object-storage-setup-uri-workflow` uses the public Commonlib-backed tool to generate the initial Setup URI for a unique MinIO prefix, completes visible initialisation on the first device, and then asks that working real Obsidian device to create a new Setup URI through the registered command. A second real Obsidian device imports only the device-generated URI. The workflow verifies A-to-B and B-to-A notes, captures the documented onboarding choices, and removes the Object Storage prefix only after both sessions have stopped.
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
assertLocatorHasMinimumTouchTarget,
|
||||
assertLocatorWithinSafeArea,
|
||||
@@ -12,13 +14,20 @@ export const desktopViewport = { width: 1024, height: 768 } as const;
|
||||
export const iPhoneSafeArea = { top: 47, right: 0, bottom: 34, left: 0 } as const;
|
||||
|
||||
type ObsidianTestApp = {
|
||||
isMobile?: boolean;
|
||||
emulateMobile?: (mobile: boolean) => void;
|
||||
plugins?: { plugins: Record<string, unknown> };
|
||||
workspace?: { layoutReady?: boolean };
|
||||
};
|
||||
|
||||
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
|
||||
|
||||
export async function setObsidianMobileTestMode(port: number, enabled: boolean, timeoutMs: number): Promise<void> {
|
||||
async function applyObsidianMobileTestMode(
|
||||
port: number,
|
||||
enabled: boolean,
|
||||
timeoutMs: number,
|
||||
waitForLiveSync: boolean
|
||||
): Promise<void> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page.setViewportSize(enabled ? mobileViewport : desktopViewport);
|
||||
await page.evaluate((nextEnabled) => {
|
||||
@@ -28,17 +37,49 @@ export async function setObsidianMobileTestMode(port: number, enabled: boolean,
|
||||
}
|
||||
obsidianApp.emulateMobile(nextEnabled);
|
||||
}, enabled);
|
||||
await page.waitForFunction(
|
||||
(nextEnabled) => {
|
||||
// Obsidian reopens its workspace layout when platform emulation
|
||||
// changes. Loading a controlled plug-in before that transition has
|
||||
// completed can leave the plug-in enabled but absent from the active
|
||||
// renderer.
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
({ nextEnabled, waitForLiveSync }) => {
|
||||
const obsidianApp = (globalThis as ObsidianTestGlobal).app;
|
||||
return (
|
||||
document.body.classList.contains("is-mobile") === nextEnabled &&
|
||||
obsidianApp?.workspace?.layoutReady === true &&
|
||||
(!waitForLiveSync || obsidianApp?.plugins?.plugins["obsidian-livesync"] !== undefined)
|
||||
);
|
||||
},
|
||||
{ nextEnabled: enabled, waitForLiveSync },
|
||||
{ timeout: timeoutMs }
|
||||
);
|
||||
} catch (error) {
|
||||
const state = await page.evaluate(() => {
|
||||
const obsidianApp = (globalThis as ObsidianTestGlobal).app;
|
||||
return (
|
||||
document.body.classList.contains("is-mobile") === nextEnabled &&
|
||||
obsidianApp?.plugins?.plugins["obsidian-livesync"] !== undefined
|
||||
);
|
||||
},
|
||||
enabled,
|
||||
{ timeout: timeoutMs }
|
||||
);
|
||||
return {
|
||||
appIsMobile: obsidianApp?.isMobile ?? null,
|
||||
bodyClasses: document.body.className,
|
||||
documentReadyState: document.readyState,
|
||||
liveSyncLoaded: obsidianApp?.plugins?.plugins["obsidian-livesync"] !== undefined,
|
||||
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||||
workspaceLayoutReady: obsidianApp?.workspace?.layoutReady ?? null,
|
||||
};
|
||||
});
|
||||
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
const screenshotPath = join(
|
||||
outputDirectory,
|
||||
waitForLiveSync
|
||||
? "mobile-mode-transition.failure.png"
|
||||
: "mobile-mode-before-plugin-start.failure.png"
|
||||
);
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Obsidian mobile-mode transition did not settle: ${JSON.stringify(state)}; screenshot=${screenshotPath}; cause=${detail}`
|
||||
);
|
||||
}
|
||||
await page.evaluate(
|
||||
(safeArea) => {
|
||||
for (const edge of ["top", "right", "bottom", "left"] as const) {
|
||||
@@ -52,6 +93,19 @@ export async function setObsidianMobileTestMode(port: number, enabled: boolean,
|
||||
});
|
||||
}
|
||||
|
||||
/** Enters mobile emulation before LiveSync's first load in a controlled session. */
|
||||
export async function setObsidianMobileTestModeBeforePluginStart(
|
||||
port: number,
|
||||
enabled: boolean,
|
||||
timeoutMs: number
|
||||
): Promise<void> {
|
||||
await applyObsidianMobileTestMode(port, enabled, timeoutMs, false);
|
||||
}
|
||||
|
||||
export async function setObsidianMobileTestMode(port: number, enabled: boolean, timeoutMs: number): Promise<void> {
|
||||
await applyObsidianMobileTestMode(port, enabled, timeoutMs, true);
|
||||
}
|
||||
|
||||
export async function assertMobileDialogueLayout(page: Page, container: Locator, label: string): Promise<void> {
|
||||
const dialogue = container.locator(".modal").last();
|
||||
const closeButton = dialogue.locator(".modal-close-button");
|
||||
|
||||
@@ -52,4 +52,36 @@ describe("LiveSync real-Obsidian session", () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards instance-scoped lifecycle hooks and the selected plug-in start mode", async () => {
|
||||
const beforePluginStart = vi.fn(async () => undefined);
|
||||
const vault = {
|
||||
path: "/tmp/mobile-vault",
|
||||
statePath: "/tmp/mobile-state",
|
||||
name: "mobile-vault",
|
||||
id: "mobile-vault-id",
|
||||
homePath: "/tmp/mobile-state/home",
|
||||
xdgConfigPath: "/tmp/mobile-state/xdg-config",
|
||||
xdgCachePath: "/tmp/mobile-state/xdg-cache",
|
||||
xdgDataPath: "/tmp/mobile-state/xdg-data",
|
||||
userDataPath: "/tmp/mobile-state/user-data",
|
||||
processMarker: "/tmp/mobile-state",
|
||||
dispose: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
await startObsidianLiveSyncSession({
|
||||
binary: "/Applications/Obsidian",
|
||||
cliBinary: "obsidian-cli",
|
||||
vault,
|
||||
pluginStartup: "controlled",
|
||||
lifecycle: { beforePluginStart },
|
||||
});
|
||||
|
||||
expect(startObsidianPluginSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lifecycle: { beforePluginStart },
|
||||
pluginStartup: "controlled",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { startObsidianPluginSession, type ObsidianPluginSession } from "@vrtmrz/obsidian-test-session";
|
||||
import {
|
||||
startObsidianPluginSession,
|
||||
type ObsidianPluginSession,
|
||||
type ObsidianPluginSessionLifecycle,
|
||||
type ObsidianPluginStartupMode,
|
||||
} from "@vrtmrz/obsidian-test-session";
|
||||
import type { TemporaryVault } from "./vault.ts";
|
||||
|
||||
export type ObsidianLiveSyncSession = ObsidianPluginSession;
|
||||
@@ -11,6 +16,8 @@ export type StartObsidianLiveSyncSessionOptions = {
|
||||
startupGraceMs?: number;
|
||||
pluginData?: Record<string, unknown>;
|
||||
localStorageEntries?: Readonly<Record<string, string>>;
|
||||
pluginStartup?: ObsidianPluginStartupMode;
|
||||
lifecycle?: ObsidianPluginSessionLifecycle;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
@@ -26,6 +33,8 @@ export async function startObsidianLiveSyncSession(
|
||||
startupGraceMs: options.startupGraceMs,
|
||||
pluginData: options.pluginData,
|
||||
localStorageEntries: options.localStorageEntries,
|
||||
pluginStartup: options.pluginStartup,
|
||||
lifecycle: options.lifecycle,
|
||||
env: options.env,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,46 +1,181 @@
|
||||
/**
|
||||
* Verifies the complete user-visible contract of the P2P status pane in real
|
||||
* Obsidian: a configured CouchDB-only Vault with no P2P profile is not
|
||||
* presented with P2P controls, while configured P2P devices can deliberately
|
||||
* open the current pane in the appropriate workspace area.
|
||||
*
|
||||
* Desktop and mobile use separate Vaults, profiles, and Obsidian processes.
|
||||
* Mobile mode is enabled before LiveSync's first load so that command and view
|
||||
* registration observe the mobile application state, and no desktop workspace
|
||||
* state can make a misplaced or restored pane appear correct.
|
||||
*
|
||||
* Command registration, automatic-opening policy, ribbon availability,
|
||||
* workspace ownership, layout, and screenshots are kept in one scenario
|
||||
* because together they describe one navigation path. Checking them in
|
||||
* isolation could miss a pane which is registered correctly but opens in the
|
||||
* wrong area, or one which is visible only because another session restored it.
|
||||
*/
|
||||
import { assertLocatorWithinViewport, assertNoHorizontalOverflow } from "@vrtmrz/obsidian-test-session";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
|
||||
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import type { Page } from "playwright";
|
||||
import type { ConsoleMessage, Page } from "playwright";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import {
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObsidianDeviceLocalState,
|
||||
waitForLiveSyncCoreReady,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { setObsidianMobileTestMode } from "../runner/mobileUi.ts";
|
||||
import { setObsidianMobileTestModeBeforePluginStart } from "../runner/mobileUi.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { captureObsidianPage, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS ?? 10000);
|
||||
|
||||
type ObsidianTestLeaf = {
|
||||
containerEl?: HTMLElement;
|
||||
view?: { getViewType?: () => string };
|
||||
};
|
||||
|
||||
type ObsidianTestWorkspace = {
|
||||
activeLeaf?: ObsidianTestLeaf;
|
||||
getLeavesOfType?: (type: string) => ObsidianTestLeaf[];
|
||||
getRightLeaf?: (split: boolean) => ObsidianTestLeaf | null;
|
||||
rightSplit?: { containerEl?: HTMLElement };
|
||||
};
|
||||
|
||||
type ObsidianTestApp = {
|
||||
commands?: {
|
||||
commands?: Record<string, unknown>;
|
||||
executeCommandById(commandId: string): boolean;
|
||||
};
|
||||
isMobile?: boolean;
|
||||
plugins?: {
|
||||
plugins?: Record<
|
||||
string,
|
||||
{
|
||||
core?: {
|
||||
services?: {
|
||||
API?: {
|
||||
isMobile?: () => boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
>;
|
||||
};
|
||||
workspace?: ObsidianTestWorkspace;
|
||||
};
|
||||
|
||||
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
|
||||
|
||||
async function openP2PStatusPane(): Promise<void> {
|
||||
const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
return await page.evaluate(
|
||||
(commandId) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true,
|
||||
"obsidian-livesync:open-p2p-server-status"
|
||||
);
|
||||
async function openP2PStatusPane(page: Page) {
|
||||
return await page.evaluate((commandId) => {
|
||||
const app = (globalThis as ObsidianTestGlobal).app;
|
||||
const plugin = app?.plugins?.plugins?.["obsidian-livesync"];
|
||||
return {
|
||||
opened: app?.commands?.executeCommandById(commandId) === true,
|
||||
appIsMobile: app?.isMobile ?? null,
|
||||
apiIsMobile: plugin?.core?.services?.API?.isMobile?.() ?? null,
|
||||
bodyIsMobile: document.body.classList.contains("is-mobile"),
|
||||
};
|
||||
}, "obsidian-livesync:open-p2p-server-status");
|
||||
}
|
||||
|
||||
async function collectP2PWorkspaceState(page: Page) {
|
||||
return await page.evaluate(() => {
|
||||
const workspace = (globalThis as ObsidianTestGlobal).app?.workspace;
|
||||
const activeLeaf = workspace?.activeLeaf;
|
||||
const p2pLeaves = workspace?.getLeavesOfType?.("p2p-server-status") ?? [];
|
||||
const rightLeaf = workspace?.getRightLeaf?.(false);
|
||||
return {
|
||||
bodyClasses: document.body.className,
|
||||
activeLeaf: {
|
||||
type: activeLeaf?.view?.getViewType?.() ?? null,
|
||||
visible: activeLeaf?.containerEl?.checkVisibility?.() ?? null,
|
||||
classes: activeLeaf?.containerEl?.className ?? null,
|
||||
},
|
||||
p2pLeaves: p2pLeaves.map((leaf) => ({
|
||||
type: leaf.view?.getViewType?.() ?? null,
|
||||
visible: leaf.containerEl?.checkVisibility?.() ?? null,
|
||||
classes: leaf.containerEl?.className ?? null,
|
||||
})),
|
||||
rightLeaf: {
|
||||
type: rightLeaf?.view?.getViewType?.() ?? null,
|
||||
visible: rightLeaf?.containerEl?.checkVisibility?.() ?? null,
|
||||
classes: rightLeaf?.containerEl?.className ?? null,
|
||||
},
|
||||
visibleP2PContents: document.querySelectorAll(
|
||||
".workspace-leaf-content[data-type='p2p-server-status']:not(.is-hidden)"
|
||||
).length,
|
||||
};
|
||||
});
|
||||
if (!opened) {
|
||||
throw new Error("The P2P status command was not registered or could not be executed.");
|
||||
}
|
||||
|
||||
async function assertMobileP2PPlacement(page: Page): Promise<void> {
|
||||
const placement = await page.evaluate(() => {
|
||||
const workspace = (globalThis as ObsidianTestGlobal).app?.workspace;
|
||||
const p2pLeaves = workspace?.getLeavesOfType?.("p2p-server-status") ?? [];
|
||||
const rightSplit = workspace?.rightSplit?.containerEl;
|
||||
const rightLeaf = workspace?.getRightLeaf?.(false);
|
||||
return {
|
||||
p2pLeafCount: p2pLeaves.length,
|
||||
inRightSplit: p2pLeaves.some(
|
||||
(leaf) =>
|
||||
(rightSplit?.contains(leaf.containerEl ?? null) ?? false) ||
|
||||
(leaf.containerEl?.closest(".mod-right-split, .workspace-drawer.mod-right") ?? null) !== null
|
||||
),
|
||||
rightLeafType: rightLeaf?.view?.getViewType?.() ?? null,
|
||||
p2pLeafClasses: p2pLeaves.map((leaf) => leaf.containerEl?.className ?? null),
|
||||
rightSplitClasses: rightSplit?.className ?? null,
|
||||
};
|
||||
});
|
||||
if (!placement.inRightSplit) {
|
||||
throw new Error(`The mobile P2P status view was not opened in the right leaf: ${JSON.stringify(placement)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyP2PStatusPane(filename: string, mobile: boolean): Promise<string> {
|
||||
await openP2PStatusPane();
|
||||
return await captureObsidianPage(obsidianRemoteDebuggingPort(), filename, async (page) => {
|
||||
const runtimeErrors: string[] = [];
|
||||
const onPageError = (error: Error) => runtimeErrors.push(`pageerror: ${error.message}`);
|
||||
const onConsole = (message: ConsoleMessage) => {
|
||||
if (message.type() === "error") runtimeErrors.push(`console: ${message.text()}`);
|
||||
};
|
||||
page.on("pageerror", onPageError);
|
||||
page.on("console", onConsole);
|
||||
let dispatchState: Awaited<ReturnType<typeof openP2PStatusPane>> | undefined;
|
||||
const heading = page.getByRole("heading", { name: "Signalling Status" }).last();
|
||||
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
try {
|
||||
dispatchState = await openP2PStatusPane(page);
|
||||
if (!dispatchState.opened) {
|
||||
throw new Error("The P2P status command was not registered or could not be executed.");
|
||||
}
|
||||
if (
|
||||
mobile &&
|
||||
(dispatchState.appIsMobile !== true ||
|
||||
dispatchState.apiIsMobile !== true ||
|
||||
dispatchState.bodyIsMobile !== true)
|
||||
) {
|
||||
throw new Error(
|
||||
`The mobile P2P command did not observe a fully mobile application state: ${JSON.stringify(dispatchState)}`
|
||||
);
|
||||
}
|
||||
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
} catch (error) {
|
||||
const workspaceState = await collectP2PWorkspaceState(page);
|
||||
console.error(
|
||||
`P2P command state after failed open: ${JSON.stringify({ dispatchState, runtimeErrors })}`
|
||||
);
|
||||
console.error(`P2P workspace state after failed open: ${JSON.stringify(workspaceState)}`);
|
||||
throw error;
|
||||
} finally {
|
||||
page.off("pageerror", onPageError);
|
||||
page.off("console", onConsole);
|
||||
}
|
||||
if (mobile) {
|
||||
await assertMobileP2PPlacement(page);
|
||||
}
|
||||
const pane = heading.locator(
|
||||
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
|
||||
);
|
||||
@@ -51,7 +186,14 @@ async function verifyP2PStatusPane(filename: string, mobile: boolean): Promise<s
|
||||
});
|
||||
const remoteSelector = pane.getByRole("combobox", { name: "Select active P2P remote" });
|
||||
await remoteSelector.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if ((await remoteSelector.inputValue()).trim() === "") {
|
||||
const remoteSelectionDeadline = Date.now() + uiTimeoutMs;
|
||||
let remoteConfigurationId = "";
|
||||
while (Date.now() < remoteSelectionDeadline) {
|
||||
remoteConfigurationId = (await remoteSelector.inputValue()).trim();
|
||||
if (remoteConfigurationId !== "") break;
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
if (remoteConfigurationId === "") {
|
||||
throw new Error("The configured P2P status pane did not select an active P2P remote.");
|
||||
}
|
||||
if (
|
||||
@@ -84,7 +226,7 @@ async function assertP2PUIIsOptIn(): Promise<void> {
|
||||
throw new Error("The retired P2P pane command is still exposed.");
|
||||
}
|
||||
if ((await page.locator(".workspace-leaf-content[data-type='p2p-server-status']:visible").count()) !== 0) {
|
||||
throw new Error("The P2P status pane opened automatically for an unconfigured CouchDB user.");
|
||||
throw new Error("The P2P status pane opened automatically for a CouchDB user without P2P configured.");
|
||||
}
|
||||
if ((await page.locator(".livesync-ribbon-p2p-server-status").count()) !== 0) {
|
||||
throw new Error("The P2P ribbon icon was shown without a P2P configuration.");
|
||||
@@ -104,12 +246,43 @@ async function assertConfiguredP2PUIIsAvailable(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function assertConfiguredP2PCommandIsAvailable(): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const state = await page.evaluate(() => {
|
||||
const app = (globalThis as ObsidianTestGlobal).app;
|
||||
const commands = app?.commands?.commands ?? {};
|
||||
return {
|
||||
commandRegistered: commands["obsidian-livesync:open-p2p-server-status"] !== undefined,
|
||||
openPaneCount: app?.workspace?.getLeavesOfType?.("p2p-server-status").length ?? 0,
|
||||
};
|
||||
});
|
||||
if (!state.commandRegistered) {
|
||||
throw new Error("The configured P2P status command was not registered in mobile mode.");
|
||||
}
|
||||
if (state.openPaneCount !== 0) {
|
||||
throw new Error("The configured P2P status pane opened before the mobile user requested it.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function dismissOpenNotices(page: Page): Promise<void> {
|
||||
const deadline = Date.now() + uiTimeoutMs;
|
||||
let quietSince = Date.now();
|
||||
while (Date.now() < deadline) {
|
||||
const notices = page.locator(".notice:visible");
|
||||
if ((await notices.count()) === 0) {
|
||||
const dismissed = await page.evaluate(() => {
|
||||
const notices = (Array.from(document.querySelectorAll(".notice")) as HTMLElement[]).filter(
|
||||
(notice) => notice.checkVisibility?.() ?? notice.offsetParent !== null
|
||||
);
|
||||
for (const notice of notices) {
|
||||
const closeButton = notice.querySelector(".notice-close-button") as HTMLElement | null;
|
||||
// Obsidian 1.12 does not render a separate close control for
|
||||
// every Notice; clicking the Notice itself is its standard
|
||||
// dismiss action.
|
||||
(closeButton ?? notice).click();
|
||||
}
|
||||
return notices.length;
|
||||
});
|
||||
if (dismissed === 0) {
|
||||
if (Date.now() - quietSince >= 500) {
|
||||
return;
|
||||
}
|
||||
@@ -117,14 +290,7 @@ async function dismissOpenNotices(page: Page): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
quietSince = Date.now();
|
||||
const closeButton = notices.first().locator(".notice-close-button");
|
||||
if ((await closeButton.count()) > 0) {
|
||||
await closeButton.click({ force: true, timeout: uiTimeoutMs });
|
||||
} else {
|
||||
// Obsidian 1.12 does not render a separate close control for every
|
||||
// Notice; clicking the Notice itself is its standard dismiss action.
|
||||
await notices.first().click({ force: true, position: { x: 2, y: 2 }, timeout: uiTimeoutMs });
|
||||
}
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
throw new Error("Transient Obsidian notices did not become quiet before the P2P status screenshot.");
|
||||
}
|
||||
@@ -169,7 +335,8 @@ async function withP2PSession(
|
||||
binary: string,
|
||||
cliBinary: string,
|
||||
pluginData: Record<string, unknown>,
|
||||
verify: () => Promise<void>
|
||||
verify: () => Promise<void>,
|
||||
options: { mobileBeforePluginStart?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const vault = await createTemporaryVault();
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
@@ -181,6 +348,17 @@ async function withP2PSession(
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
pluginData,
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
lifecycle: options.mobileBeforePluginStart
|
||||
? {
|
||||
beforePluginStart: async ({ remoteDebuggingPort }) => {
|
||||
await setObsidianMobileTestModeBeforePluginStart(
|
||||
remoteDebuggingPort,
|
||||
true,
|
||||
uiTimeoutMs
|
||||
);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
await verify();
|
||||
@@ -210,17 +388,25 @@ async function main(): Promise<void> {
|
||||
async () => {
|
||||
await assertConfiguredP2PUIIsAvailable();
|
||||
const desktopScreenshot = await verifyP2PStatusPane("p2p-status-pane.png", false);
|
||||
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), true, uiTimeoutMs);
|
||||
try {
|
||||
const mobileScreenshot = await verifyP2PStatusPane("p2p-status-pane-mobile.png", true);
|
||||
console.log(
|
||||
`Configured P2P status UI remained opt-in and was reachable on desktop and mobile. Screenshots: ${desktopScreenshot}, ${mobileScreenshot}`
|
||||
);
|
||||
} finally {
|
||||
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), false, uiTimeoutMs);
|
||||
}
|
||||
console.log(
|
||||
`Configured P2P status UI remained opt-in and was reachable on desktop. Screenshot: ${desktopScreenshot}`
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
await withP2PSession(
|
||||
binary,
|
||||
cli.binary,
|
||||
createConfiguredP2PPluginData(),
|
||||
async () => {
|
||||
await assertConfiguredP2PCommandIsAvailable();
|
||||
const mobileScreenshot = await verifyP2PStatusPane("p2p-status-pane-mobile.png", true);
|
||||
console.log(
|
||||
`Configured P2P status UI remained opt-in and was reachable on mobile. Screenshot: ${mobileScreenshot}`
|
||||
);
|
||||
},
|
||||
{ mobileBeforePluginStart: true }
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
|
||||
Reference in New Issue
Block a user