mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-25 02:23:22 +00:00
463 lines
21 KiB
TypeScript
463 lines
21 KiB
TypeScript
import { DEVICE_ID_PREFERRED, MILESTONE_DOCID, NODEINFO_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
|
import { evalObsidianJson } from "../runner/cli.ts";
|
|
import {
|
|
assertCouchDbReachable,
|
|
createCouchDbDatabase,
|
|
deleteCouchDbDatabase,
|
|
fetchCouchDbDocument,
|
|
loadCouchDbConfig,
|
|
makeUniqueDatabaseName,
|
|
putCouchDbDocument,
|
|
type CouchDbDocument,
|
|
} from "../runner/couchdb.ts";
|
|
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
|
import {
|
|
assertEqual,
|
|
assertE2eCompatibilityUnpaused,
|
|
configureCouchDb,
|
|
createE2eCouchDbPluginData,
|
|
prepareRemote,
|
|
waitForLiveSyncCoreReady,
|
|
} from "../runner/liveSyncWorkflow.ts";
|
|
import {
|
|
assertLocatorHasMinimumTouchTarget,
|
|
assertLocatorWithinSafeArea,
|
|
assertLocatorWithinViewport,
|
|
assertNoHorizontalOverflow,
|
|
} from "@vrtmrz/obsidian-test-session";
|
|
import { iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
|
|
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
|
import {
|
|
captureObsidianDialogue,
|
|
openLiveSyncSettings,
|
|
waitForVisibleObsidianDialogue,
|
|
withObsidianPage,
|
|
} from "../runner/ui.ts";
|
|
import { createTemporaryVault } from "../runner/vault.ts";
|
|
import type { Locator, Page } from "playwright";
|
|
|
|
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_JOINED_DEVICES_TIMEOUT_MS ?? 10000);
|
|
const commandId = "obsidian-livesync:joined-devices";
|
|
const targetId = "joined-devices-target";
|
|
const retainedId = "joined-devices-retained";
|
|
const initialPlatform = "E2E Android tablet";
|
|
const refreshedPlatform = "E2E refreshed platform";
|
|
const connectedAt = Date.UTC(2026, 0, 12, 9, 30, 0);
|
|
|
|
type SelfDeviceInfo = {
|
|
id: string;
|
|
platform: string;
|
|
pluginVersion: string;
|
|
appVersion: string;
|
|
};
|
|
|
|
type DeviceInfo = {
|
|
device_name: string;
|
|
vault_name: string;
|
|
app_version: string;
|
|
plugin_version: string;
|
|
platform: string;
|
|
progress: string;
|
|
last_connected: number;
|
|
};
|
|
|
|
type JoinedDevicesMilestone = CouchDbDocument & {
|
|
accepted_nodes: string[];
|
|
node_info: Record<string, DeviceInfo>;
|
|
locked: boolean;
|
|
cleaned: boolean;
|
|
node_chunk_info: Record<string, { min: number; max: number; current: number }>;
|
|
tweak_values: Record<string, Record<string, unknown>>;
|
|
};
|
|
|
|
type DeviceFixture = {
|
|
milestone: JoinedDevicesMilestone;
|
|
preferredTweaks: Record<string, unknown>;
|
|
};
|
|
|
|
function assert(condition: boolean, message: string): void {
|
|
if (!condition) throw new Error(message);
|
|
}
|
|
|
|
function deviceCard(dialogue: Locator, id: string): Locator {
|
|
return dialogue.locator(".device-card").filter({ hasText: id });
|
|
}
|
|
|
|
async function assertDetail(card: Locator, label: string, expected: string): Promise<void> {
|
|
const rows = card.locator(".device-details > div");
|
|
for (let index = 0; index < (await rows.count()); index++) {
|
|
const row = rows.nth(index);
|
|
if ((await row.locator("dt").innerText()).trim() === label) {
|
|
assertEqual((await row.locator("dd").innerText()).trim(), expected, `${label} did not match.`);
|
|
return;
|
|
}
|
|
}
|
|
throw new Error(`The device details did not contain '${label}'.`);
|
|
}
|
|
|
|
async function readSelfDeviceInfo(cliBinary: string, env: NodeJS.ProcessEnv): Promise<SelfDeviceInfo> {
|
|
return await evalObsidianJson<SelfDeviceInfo>(
|
|
cliBinary,
|
|
[
|
|
"(async()=>{",
|
|
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
|
`const nodeInfo=await core.services.database.localDatabase.localDatabase.get(${JSON.stringify(NODEINFO_DOCID)});`,
|
|
"return JSON.stringify({",
|
|
"id:nodeInfo.nodeid,",
|
|
"platform:core.services.API.getPlatform(),",
|
|
"pluginVersion:core.services.API.getPluginVersion(),",
|
|
"appVersion:core.services.API.getAppVersion(),",
|
|
"});",
|
|
"})()",
|
|
].join(""),
|
|
env
|
|
);
|
|
}
|
|
|
|
function createFixture(self: SelfDeviceInfo): DeviceFixture {
|
|
const preferredTweaks = { customChunkSize: 60, chunkSplitterVersion: "v3-rabin-karp" };
|
|
const milestone: JoinedDevicesMilestone = {
|
|
_id: MILESTONE_DOCID,
|
|
type: "milestoneinfo",
|
|
created: Date.now(),
|
|
// The current device is deliberately absent: it must still be listed first and identified as locked out.
|
|
accepted_nodes: [targetId, retainedId],
|
|
node_info: {
|
|
[targetId]: {
|
|
device_name: "E2E target device",
|
|
vault_name: "Joined devices target fixture",
|
|
app_version: "1.99.0-test",
|
|
plugin_version: "999.0.0",
|
|
platform: initialPlatform,
|
|
progress: "ready",
|
|
last_connected: connectedAt,
|
|
},
|
|
[retainedId]: {
|
|
device_name: "E2E retained device",
|
|
vault_name: "Joined devices retained fixture",
|
|
app_version: "1.88.0-test",
|
|
plugin_version: "0.1.0",
|
|
platform: "E2E Linux desktop",
|
|
progress: "ready",
|
|
last_connected: connectedAt - 86400000,
|
|
},
|
|
},
|
|
locked: true,
|
|
cleaned: true,
|
|
node_chunk_info: {
|
|
[targetId]: { min: 1, max: 3, current: 3 },
|
|
[retainedId]: { min: 1, max: 2, current: 2 },
|
|
},
|
|
tweak_values: {
|
|
[targetId]: { customChunkSize: 48 },
|
|
[retainedId]: { customChunkSize: 60 },
|
|
[DEVICE_ID_PREFERRED]: preferredTweaks,
|
|
},
|
|
};
|
|
assert(self.id !== targetId && self.id !== retainedId, "The local device identity collided with a fixture ID.");
|
|
return { milestone, preferredTweaks };
|
|
}
|
|
|
|
async function openFromRemoteSettings(page: Page): Promise<Locator> {
|
|
const settings = await openLiveSyncSettings(page, uiTimeoutMs);
|
|
const remoteConfiguration = await settings.openPage("Remote Configuration");
|
|
await remoteConfiguration.getByRole("button", { name: "Open", exact: true }).click({ timeout: uiTimeoutMs });
|
|
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
|
await dialogue.getByText(targetId, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
|
return dialogue;
|
|
}
|
|
|
|
async function assertInitialDialogue(dialogue: Locator, self: SelfDeviceInfo, page: Page): Promise<void> {
|
|
const cards = dialogue.locator(".device-card");
|
|
assertEqual(
|
|
await cards.count(),
|
|
3,
|
|
"The joined devices dialogue did not show the self, target, and retained records."
|
|
);
|
|
const selfCard = cards.nth(0);
|
|
assert(Boolean(self.id), "The current device identity was not read from the local NODEINFO_DOCID record.");
|
|
assertEqual(
|
|
(await selfCard.locator("code.device-id").innerText()).trim(),
|
|
self.id,
|
|
"The current device was not listed first."
|
|
);
|
|
await selfCard.getByText("This device", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
|
await selfCard.getByText("Locked out", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
|
await selfCard.getByText("Not accepted", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
|
await selfCard
|
|
.getByText("This device is locked out.", { exact: false })
|
|
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
|
await assertDetail(selfCard, "Platform", self.platform);
|
|
await assertDetail(selfCard, "Plug-in version", self.pluginVersion);
|
|
await assertDetail(selfCard, "App version", self.appVersion);
|
|
assertEqual(
|
|
await selfCard.getByRole("button", { name: "Forget device", exact: true }).count(),
|
|
0,
|
|
"The current device exposed a forget control."
|
|
);
|
|
|
|
const targetCard = deviceCard(dialogue, targetId);
|
|
await assertDetail(targetCard, "Platform", initialPlatform);
|
|
await assertDetail(targetCard, "Plug-in version", "999.0.0");
|
|
await assertDetail(targetCard, "App version", "1.99.0-test");
|
|
const targetDate = await page.evaluate((timestamp) => new Date(timestamp).toLocaleString(), connectedAt);
|
|
await assertDetail(targetCard, "Last recorded", targetDate);
|
|
await targetCard.getByText("Accepted", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
|
|
|
const retainedCard = deviceCard(dialogue, retainedId);
|
|
await assertDetail(retainedCard, "Platform", "E2E Linux desktop");
|
|
await assertDetail(retainedCard, "Plug-in version", "0.1.0");
|
|
await assertDetail(retainedCard, "App version", "1.88.0-test");
|
|
await dialogue
|
|
.getByText(
|
|
"This list shows device information recorded by the remote. It does not show whether a device is online now.",
|
|
{ exact: true }
|
|
)
|
|
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
|
await dialogue
|
|
.getByText("This remote is locked.", { exact: false })
|
|
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
|
await dialogue
|
|
.getByText("This remote has been cleaned.", { exact: false })
|
|
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
|
}
|
|
|
|
async function refreshRemotePlatform(
|
|
page: Page,
|
|
config: Awaited<ReturnType<typeof loadCouchDbConfig>>,
|
|
dbName: string
|
|
): Promise<void> {
|
|
const milestone = (await fetchCouchDbDocument(config, dbName, MILESTONE_DOCID)) as JoinedDevicesMilestone;
|
|
await putCouchDbDocument(config, dbName, {
|
|
...milestone,
|
|
node_info: {
|
|
...milestone.node_info,
|
|
[targetId]: { ...milestone.node_info[targetId], platform: refreshedPlatform },
|
|
},
|
|
});
|
|
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
|
await dialogue.getByRole("button", { name: "Refresh", exact: true }).click({ timeout: uiTimeoutMs });
|
|
await deviceCard(dialogue, targetId).getByText(refreshedPlatform, { exact: true }).waitFor({
|
|
state: "visible",
|
|
timeout: uiTimeoutMs,
|
|
});
|
|
}
|
|
|
|
async function showForgetConfirmation(dialogue: Locator): Promise<void> {
|
|
const targetCard = deviceCard(dialogue, targetId);
|
|
await targetCard.getByRole("button", { name: "Forget device", exact: true }).click({ timeout: uiTimeoutMs });
|
|
await targetCard.getByText("Forget this device record?", { exact: true }).waitFor({
|
|
state: "visible",
|
|
timeout: uiTimeoutMs,
|
|
});
|
|
}
|
|
|
|
async function confirmForgetTarget(dialogue: Locator): Promise<void> {
|
|
const targetCard = deviceCard(dialogue, targetId);
|
|
await targetCard.getByRole("button", { name: "Forget device", exact: true }).click({ timeout: uiTimeoutMs });
|
|
await dialogue.getByText("The device record was forgotten.", { exact: true }).waitFor({
|
|
state: "visible",
|
|
timeout: uiTimeoutMs,
|
|
});
|
|
await targetCard.waitFor({ state: "detached", timeout: uiTimeoutMs });
|
|
}
|
|
|
|
async function assertTargetForgotten(
|
|
config: Awaited<ReturnType<typeof loadCouchDbConfig>>,
|
|
dbName: string,
|
|
fixture: DeviceFixture
|
|
): Promise<void> {
|
|
const milestone = (await fetchCouchDbDocument(config, dbName, MILESTONE_DOCID)) as JoinedDevicesMilestone;
|
|
assertEqual(
|
|
JSON.stringify(milestone.accepted_nodes),
|
|
JSON.stringify([retainedId]),
|
|
"Forgetting changed the wrong accepted device records."
|
|
);
|
|
assertEqual(
|
|
JSON.stringify(Object.keys(milestone.node_info)),
|
|
JSON.stringify([retainedId]),
|
|
"Forgetting changed the wrong device metadata records."
|
|
);
|
|
assertEqual(
|
|
JSON.stringify(Object.keys(milestone.node_chunk_info)),
|
|
JSON.stringify([retainedId]),
|
|
"Forgetting changed the wrong chunk metadata records."
|
|
);
|
|
assertEqual(
|
|
JSON.stringify(Object.keys(milestone.tweak_values).sort()),
|
|
JSON.stringify([DEVICE_ID_PREFERRED, retainedId].sort()),
|
|
"Forgetting changed the wrong tweak metadata records."
|
|
);
|
|
assertEqual(milestone.locked, true, "Forgetting changed the remote lock state.");
|
|
assertEqual(milestone.cleaned, true, "Forgetting changed the remote cleaned state.");
|
|
assertEqual(
|
|
JSON.stringify(milestone.tweak_values[DEVICE_ID_PREFERRED]),
|
|
JSON.stringify(fixture.preferredTweaks),
|
|
"Forgetting changed the preferred-ID tweak record."
|
|
);
|
|
assertEqual(
|
|
JSON.stringify(milestone.node_info[retainedId]),
|
|
JSON.stringify(fixture.milestone.node_info[retainedId]),
|
|
"Forgetting changed the retained device metadata."
|
|
);
|
|
}
|
|
|
|
async function assertScrollableMobileDialogue(page: Page, container: Locator): Promise<void> {
|
|
const dialogue = container.locator(".modal").last();
|
|
const closeButton = container.locator(".modal-close-button, .modal-header-button").last();
|
|
await assertLocatorWithinViewport(page, dialogue, { label: "Joined devices dialogue" });
|
|
await assertNoHorizontalOverflow(page, dialogue, { label: "Joined devices dialogue" });
|
|
await assertLocatorWithinSafeArea(page, dialogue, {
|
|
label: "Joined devices dialogue",
|
|
safeAreaInsets: iPhoneSafeArea,
|
|
});
|
|
const buttons = [closeButton, ...(await dialogue.locator("button:visible").all())];
|
|
for (const button of buttons) {
|
|
// Device records form a scrollable list; every control must be reachable after scrolling.
|
|
await button.scrollIntoViewIfNeeded();
|
|
const label = (await button.innerText()).trim() || "Close dialogue";
|
|
await assertLocatorWithinViewport(page, button, { label });
|
|
await assertLocatorWithinSafeArea(page, button, { label, safeAreaInsets: iPhoneSafeArea });
|
|
await assertNoHorizontalOverflow(page, button, { label });
|
|
await assertLocatorHasMinimumTouchTarget(page, button, { label });
|
|
}
|
|
await container.locator(".modal-content").evaluate((element) => element.scrollTo(0, 0));
|
|
}
|
|
|
|
async function dismissNotices(page: Page): Promise<void> {
|
|
const notices = page.locator(".notice:visible");
|
|
for (let remaining = 20; remaining > 0 && (await notices.count()) > 0; remaining--) {
|
|
const notice = await notices.first().elementHandle();
|
|
if (!notice) continue;
|
|
try {
|
|
await notice.click({ position: { x: 20, y: 20 }, timeout: uiTimeoutMs });
|
|
await notice.waitForElementState("hidden", { timeout: uiTimeoutMs });
|
|
} finally {
|
|
await notice.dispose();
|
|
}
|
|
}
|
|
assertEqual(await notices.count(), 0, "Notices still obscured the dialogue screenshot.");
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const binary = requireObsidianBinary();
|
|
const cli = discoverObsidianCli();
|
|
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
|
|
|
|
const couchDb = await loadCouchDbConfig();
|
|
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "joined-devices");
|
|
const vault = await createTemporaryVault();
|
|
let session: ObsidianLiveSyncSession | undefined;
|
|
|
|
try {
|
|
await assertCouchDbReachable(couchDb);
|
|
await createCouchDbDatabase(couchDb, dbName);
|
|
session = await startObsidianLiveSyncSession({
|
|
binary,
|
|
cliBinary: cli.binary,
|
|
vault,
|
|
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
|
pluginData: createE2eCouchDbPluginData({
|
|
uri: couchDb.uri,
|
|
username: couchDb.username,
|
|
password: couchDb.password,
|
|
dbName,
|
|
}),
|
|
});
|
|
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
|
await assertE2eCompatibilityUnpaused(cli.binary, session.cliEnv, session.remoteDebuggingPort);
|
|
await configureCouchDb(cli.binary, session.cliEnv, {
|
|
uri: couchDb.uri,
|
|
username: couchDb.username,
|
|
password: couchDb.password,
|
|
dbName,
|
|
});
|
|
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
|
await prepareRemote(cli.binary, session.cliEnv);
|
|
|
|
const self = await readSelfDeviceInfo(cli.binary, session.cliEnv);
|
|
const fixture = createFixture(self);
|
|
const preparedMilestone = await fetchCouchDbDocument(couchDb, dbName, MILESTONE_DOCID);
|
|
assert(Boolean(preparedMilestone._rev), "Remote preparation did not create a revisioned milestone document.");
|
|
await putCouchDbDocument(couchDb, dbName, { ...fixture.milestone, _rev: preparedMilestone._rev });
|
|
|
|
await captureObsidianDialogue(session.remoteDebuggingPort, "joined-devices-notice.png", async (page) => {
|
|
await page.setViewportSize({ width: 1280, height: 1100 });
|
|
const dialogue = await openFromRemoteSettings(page);
|
|
await assertInitialDialogue(dialogue, self, page);
|
|
await page
|
|
.locator(".notice")
|
|
.getByText("Other joined devices last reported a newer plug-in version: 999.0.0.", { exact: true })
|
|
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
|
});
|
|
await captureObsidianDialogue(session.remoteDebuggingPort, "joined-devices-desktop.png", async (page) => {
|
|
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
|
await dialogue.getByRole("button", { name: "Close", exact: true }).click({ timeout: uiTimeoutMs });
|
|
await page.locator(".notice").getByRole("button", { name: "Joined devices", exact: true }).first().click();
|
|
const reopened = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
|
await reopened.getByText(targetId, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
|
await dismissNotices(page);
|
|
});
|
|
await captureObsidianDialogue(
|
|
session.remoteDebuggingPort,
|
|
"joined-devices-forget-confirmation.png",
|
|
async (page) => {
|
|
await refreshRemotePlatform(page, couchDb, dbName);
|
|
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
|
await showForgetConfirmation(dialogue);
|
|
await deviceCard(dialogue, targetId).locator(".forget-confirmation").scrollIntoViewIfNeeded();
|
|
}
|
|
);
|
|
await captureObsidianDialogue(session.remoteDebuggingPort, "joined-devices-after-forget.png", async (page) => {
|
|
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
|
await confirmForgetTarget(dialogue);
|
|
await dialogue.locator(".modal-content").evaluate((element) => element.scrollTo(0, 0));
|
|
});
|
|
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
|
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
|
await dialogue.getByRole("button", { name: "Close", exact: true }).click({ timeout: uiTimeoutMs });
|
|
const settings = await openLiveSyncSettings(page, uiTimeoutMs);
|
|
await settings.close();
|
|
});
|
|
await assertTargetForgotten(couchDb, dbName, fixture);
|
|
|
|
await setObsidianMobileTestMode(session.remoteDebuggingPort, true, uiTimeoutMs);
|
|
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
|
await captureObsidianDialogue(session.remoteDebuggingPort, "joined-devices-mobile.png", async (page) => {
|
|
const opened = await page.evaluate((id) => {
|
|
const app = (
|
|
window as typeof window & {
|
|
app?: { commands?: { executeCommandById?(commandId: string): boolean } };
|
|
}
|
|
).app;
|
|
return app?.commands?.executeCommandById?.(id) === true;
|
|
}, commandId);
|
|
assert(opened, "The Joined devices command did not open the dialogue.");
|
|
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
|
await dialogue.getByText(retainedId, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
|
await assertScrollableMobileDialogue(page, dialogue);
|
|
assertEqual(
|
|
await dialogue.locator(".device-card").count(),
|
|
2,
|
|
"The refreshed mobile list did not retain both remaining devices."
|
|
);
|
|
await dismissNotices(page);
|
|
});
|
|
|
|
console.log(`Joined devices scenario passed against CouchDB database ${dbName}.`);
|
|
} finally {
|
|
if (session) await session.app.stop();
|
|
await vault.dispose();
|
|
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
|
|
await deleteCouchDbDatabase(couchDb, dbName).catch((error: unknown) => {
|
|
console.warn(error instanceof Error ? error.message : error);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
main().catch((error: unknown) => {
|
|
console.error(error instanceof Error ? error.stack : error);
|
|
process.exit(1);
|
|
});
|