mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-27 13:57:07 +00:00
331 lines
14 KiB
TypeScript
331 lines
14 KiB
TypeScript
import type { Locator, Page } from "playwright";
|
||
import { captureObsidianDialogue, withObsidianPage } from "./ui.ts";
|
||
|
||
const remoteSetupStateKey = "__livesyncE2ERemoteSetup";
|
||
|
||
export type RemoteInspectionMode = "failed" | "verified";
|
||
|
||
export type RemoteSetupCall = {
|
||
journalFormat: string;
|
||
operation: "create" | "inspect" | "test";
|
||
remoteType: string;
|
||
};
|
||
|
||
type RemoteSetupBrowserState = {
|
||
calls: RemoteSetupCall[];
|
||
inspectionMode: RemoteInspectionMode;
|
||
nextProfileName: string;
|
||
unregister?: () => void;
|
||
};
|
||
|
||
type RuntimeRemoteConfiguration = {
|
||
id: string;
|
||
isEncrypted: boolean;
|
||
name: string;
|
||
uri: string;
|
||
};
|
||
|
||
type RuntimeSettings = {
|
||
activeConfigurationId: string;
|
||
remoteConfigurations: Record<string, RuntimeRemoteConfiguration>;
|
||
};
|
||
|
||
type RuntimePlugin = {
|
||
core: {
|
||
services: {
|
||
replicator: {
|
||
getNewReplicator: {
|
||
addHandler: (...args: unknown[]) => () => void;
|
||
} & ((settingOverride?: Record<string, unknown>) => Promise<unknown>);
|
||
};
|
||
setting: {
|
||
currentSettings(): RuntimeSettings;
|
||
};
|
||
UI: {
|
||
confirm: {
|
||
askString(
|
||
title: string,
|
||
label: string,
|
||
placeholder: string,
|
||
isPassword?: boolean
|
||
): Promise<string | false>;
|
||
};
|
||
};
|
||
};
|
||
};
|
||
};
|
||
|
||
type RuntimeSettingsController = {
|
||
close(): void;
|
||
open(): void;
|
||
openTabById(tabId: string): void;
|
||
};
|
||
|
||
type RuntimeApp = {
|
||
plugins?: { plugins: Record<string, RuntimePlugin | undefined> };
|
||
setting?: RuntimeSettingsController;
|
||
};
|
||
|
||
type RuntimeGlobal = typeof globalThis & {
|
||
app?: RuntimeApp;
|
||
[remoteSetupStateKey]?: RemoteSetupBrowserState;
|
||
};
|
||
|
||
export function remoteSelectionModal(page: Page): Locator {
|
||
return page.locator(".modal-container").filter({
|
||
has: page.locator(".modal-title").filter({ hasText: "Choose a synchronisation remote" }),
|
||
});
|
||
}
|
||
|
||
export function remoteProviderModal(page: Page, title: string): Locator {
|
||
return page.locator(".modal-container").filter({
|
||
has: page.locator(".modal-title").filter({ hasText: title }),
|
||
});
|
||
}
|
||
|
||
export function remoteConfigurationPanel(page: Page): Locator {
|
||
return page
|
||
.locator(".sls-setting h4.sls-setting-panel-title")
|
||
.filter({ hasText: "Connection settings" })
|
||
.locator("..");
|
||
}
|
||
|
||
function remoteProfileRow(page: Page, profileName: string): Locator {
|
||
return remoteConfigurationPanel(page).locator(".sls-remote-list .setting-item").filter({ hasText: profileName });
|
||
}
|
||
|
||
async function tooltipButton(container: Locator, label: string, fallbackText: string): Promise<Locator> {
|
||
const labelled = container.locator(`button[aria-label="${label}"], button[title="${label}"]`);
|
||
if ((await labelled.count()) > 0) return labelled.first();
|
||
return container.locator("button").filter({ hasText: fallbackText }).first();
|
||
}
|
||
|
||
export async function installRemoteSetupTestSeam(port: number): Promise<void> {
|
||
await withObsidianPage(port, async (page) => {
|
||
await page.evaluate((stateKey) => {
|
||
const runtime = globalThis as RuntimeGlobal;
|
||
const plugin = runtime.app?.plugins?.plugins["obsidian-livesync"];
|
||
if (!plugin) throw new Error("Self-hosted LiveSync is not loaded");
|
||
const state: RemoteSetupBrowserState = {
|
||
calls: [],
|
||
inspectionMode: "verified",
|
||
nextProfileName: "",
|
||
};
|
||
runtime[stateKey as typeof remoteSetupStateKey] = state;
|
||
|
||
const getNewReplicator = plugin.core.services.replicator.getNewReplicator;
|
||
const replacements = {
|
||
async createReplicator(settingOverride: Record<string, unknown> = {}) {
|
||
const remoteType = String(settingOverride.remoteType ?? "");
|
||
const journalFormat = String(settingOverride.journalFormat ?? "");
|
||
state.calls.push({ journalFormat, operation: "create", remoteType });
|
||
return {
|
||
async inspectJournalStorageConnection(settings: Record<string, unknown>) {
|
||
state.calls.push({
|
||
journalFormat: String(settings.journalFormat ?? ""),
|
||
operation: "inspect",
|
||
remoteType: String(settings.remoteType ?? ""),
|
||
});
|
||
if (state.inspectionMode === "failed") {
|
||
return {
|
||
adaptiveCapabilities: {
|
||
byteRange: { status: "not-checked" },
|
||
required: { missing: ["conditional-create"], status: "unsupported" },
|
||
},
|
||
available: false,
|
||
remoteFormat: "empty",
|
||
};
|
||
}
|
||
return {
|
||
adaptiveCapabilities: {
|
||
byteRange: { status: "verified" },
|
||
required: { status: "verified" },
|
||
},
|
||
available: true,
|
||
remoteFormat: "empty",
|
||
};
|
||
},
|
||
async tryConnectRemote(settings: Record<string, unknown>) {
|
||
state.calls.push({
|
||
journalFormat: String(settings.journalFormat ?? ""),
|
||
operation: "test",
|
||
remoteType: String(settings.remoteType ?? ""),
|
||
});
|
||
return state.inspectionMode === "verified";
|
||
},
|
||
};
|
||
},
|
||
async askString(title: string, label: string, placeholder: string, isPassword: boolean = false) {
|
||
if (title !== "Remote name") return await originalAskString(title, label, placeholder, isPassword);
|
||
const name = state.nextProfileName;
|
||
state.nextProfileName = "";
|
||
if (!name) throw new Error("The remote setup E2E did not supply a profile name");
|
||
return name;
|
||
},
|
||
};
|
||
state.unregister = getNewReplicator.addHandler(replacements.createReplicator, -1000, true);
|
||
|
||
const confirm = plugin.core.services.UI.confirm;
|
||
const originalAskString = confirm.askString.bind(confirm);
|
||
confirm.askString = replacements.askString;
|
||
}, remoteSetupStateKey);
|
||
});
|
||
}
|
||
|
||
export async function setRemoteInspectionMode(port: number, mode: RemoteInspectionMode): Promise<void> {
|
||
await withObsidianPage(port, async (page) => {
|
||
await page.evaluate(
|
||
({ mode, stateKey }) => {
|
||
const state = (globalThis as RuntimeGlobal)[stateKey as typeof remoteSetupStateKey];
|
||
if (!state) throw new Error("The remote setup E2E seam is not installed");
|
||
state.inspectionMode = mode;
|
||
},
|
||
{ mode, stateKey: remoteSetupStateKey }
|
||
);
|
||
});
|
||
}
|
||
|
||
export async function remoteSetupCalls(port: number): Promise<RemoteSetupCall[]> {
|
||
return await withObsidianPage(port, async (page) => {
|
||
return await page.evaluate((stateKey) => {
|
||
const state = (globalThis as RuntimeGlobal)[stateKey as typeof remoteSetupStateKey];
|
||
if (!state) throw new Error("The remote setup E2E seam is not installed");
|
||
return state.calls;
|
||
}, remoteSetupStateKey);
|
||
});
|
||
}
|
||
|
||
export async function openRemoteConfigurationSettings(port: number, timeoutMs: number): Promise<void> {
|
||
await withObsidianPage(port, async (page) => {
|
||
await page.evaluate(() => {
|
||
const setting = (globalThis as RuntimeGlobal).app?.setting;
|
||
if (!setting) throw new Error("Obsidian settings are unavailable");
|
||
setting.close();
|
||
});
|
||
await page.waitForTimeout(100);
|
||
await page.evaluate(() => {
|
||
const setting = (globalThis as RuntimeGlobal).app?.setting;
|
||
if (!setting) throw new Error("Obsidian settings are unavailable");
|
||
setting.open();
|
||
setting.openTabById("obsidian-livesync");
|
||
});
|
||
const settings = page.locator(".sls-setting");
|
||
try {
|
||
await settings.waitFor({ state: "visible", timeout: timeoutMs });
|
||
} catch (error) {
|
||
const modalTitles = await page.locator(".modal-title").allTextContents();
|
||
const settingTabs = await page.locator(".vertical-tab-nav-item").allTextContents();
|
||
const reason = error instanceof Error ? error.message : String(error);
|
||
throw new Error(
|
||
`The LiveSync settings pane did not become visible. Open modal titles: ${JSON.stringify(modalTitles)}. Settings tabs: ${JSON.stringify(settingTabs)}. Cause: ${reason}`
|
||
);
|
||
}
|
||
await settings.locator('.sls-setting-menu-btn[title="Remote Configuration"]').click({ timeout: timeoutMs });
|
||
await remoteConfigurationPanel(page).waitFor({ state: "visible", timeout: timeoutMs });
|
||
});
|
||
}
|
||
|
||
export async function closeRemoteConfigurationSettings(port: number, timeoutMs: number): Promise<void> {
|
||
await withObsidianPage(port, async (page) => {
|
||
await page.evaluate(() => {
|
||
const setting = (globalThis as RuntimeGlobal).app?.setting;
|
||
if (!setting) throw new Error("Obsidian settings are unavailable");
|
||
setting.close();
|
||
});
|
||
await page.locator(".sls-setting").waitFor({ state: "hidden", timeout: timeoutMs });
|
||
});
|
||
}
|
||
|
||
export async function beginRemoteProfileSetup(port: number, profileName: string, timeoutMs: number): Promise<void> {
|
||
await withObsidianPage(port, async (page) => {
|
||
await page.evaluate(
|
||
({ profileName, stateKey }) => {
|
||
const state = (globalThis as RuntimeGlobal)[stateKey as typeof remoteSetupStateKey];
|
||
if (!state) throw new Error("The remote setup E2E seam is not installed");
|
||
state.nextProfileName = profileName;
|
||
},
|
||
{ profileName, stateKey: remoteSetupStateKey }
|
||
);
|
||
const add = await tooltipButton(remoteConfigurationPanel(page), "Add new connection", "➕");
|
||
await add.click({ timeout: timeoutMs });
|
||
await remoteSelectionModal(page).waitFor({ state: "visible", timeout: timeoutMs });
|
||
});
|
||
}
|
||
|
||
export async function captureRemoteProviderChoices(
|
||
port: number,
|
||
filename: string,
|
||
labels: readonly string[],
|
||
timeoutMs: number
|
||
): Promise<string> {
|
||
return await captureObsidianDialogue(port, filename, async (page) => {
|
||
const modal = remoteSelectionModal(page);
|
||
await modal.waitFor({ state: "visible", timeout: timeoutMs });
|
||
for (const label of labels) {
|
||
await modal.getByText(label, { exact: true }).waitFor({ state: "visible", timeout: timeoutMs });
|
||
}
|
||
});
|
||
}
|
||
|
||
export async function selectRemoteProvider(
|
||
port: number,
|
||
choiceLabel: string,
|
||
proceedLabel: string,
|
||
providerTitle: string,
|
||
timeoutMs: number
|
||
): Promise<void> {
|
||
await withObsidianPage(port, async (page) => {
|
||
const selection = remoteSelectionModal(page);
|
||
await selection
|
||
.locator("label")
|
||
.filter({ hasText: choiceLabel })
|
||
.locator('input[type="radio"]')
|
||
.first()
|
||
.check({ timeout: timeoutMs });
|
||
await selection.getByRole("button", { name: proceedLabel, exact: true }).click({ timeout: timeoutMs });
|
||
await remoteProviderModal(page, providerTitle).waitFor({ state: "visible", timeout: timeoutMs });
|
||
});
|
||
}
|
||
|
||
export async function captureAndCancelRemoteProvider(
|
||
port: number,
|
||
filename: string,
|
||
providerTitle: string,
|
||
timeoutMs: number
|
||
): Promise<string> {
|
||
const screenshot = await captureObsidianDialogue(port, filename, async (page) => {
|
||
await remoteProviderModal(page, providerTitle).waitFor({ state: "visible", timeout: timeoutMs });
|
||
});
|
||
await withObsidianPage(port, async (page) => {
|
||
const modal = remoteProviderModal(page, providerTitle);
|
||
await modal.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: timeoutMs });
|
||
await modal.waitFor({ state: "hidden", timeout: timeoutMs });
|
||
});
|
||
return screenshot;
|
||
}
|
||
|
||
export async function waitForSavedRemoteProfile(port: number, profileName: string, timeoutMs: number): Promise<void> {
|
||
await withObsidianPage(port, async (page) => {
|
||
await remoteProfileRow(page, profileName).waitFor({ state: "visible", timeout: timeoutMs });
|
||
});
|
||
}
|
||
|
||
export async function openSavedRemoteProfile(port: number, profileName: string, timeoutMs: number): Promise<void> {
|
||
await withObsidianPage(port, async (page) => {
|
||
const row = remoteProfileRow(page, profileName);
|
||
await row.waitFor({ state: "visible", timeout: timeoutMs });
|
||
const configure = await tooltipButton(row, "Configure", "🔧");
|
||
await configure.click({ timeout: timeoutMs });
|
||
});
|
||
}
|
||
|
||
export async function runtimeRemoteSettings(port: number): Promise<RuntimeSettings> {
|
||
return await withObsidianPage(port, async (page) => {
|
||
return await page.evaluate(() => {
|
||
const plugin = (globalThis as RuntimeGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||
if (!plugin) throw new Error("Self-hosted LiveSync is not loaded");
|
||
return structuredClone(plugin.core.services.setting.currentSettings());
|
||
});
|
||
});
|
||
}
|