mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-25 02:23:22 +00:00
test: cover Commonlib 0.2 consumer workflows
This commit is contained in:
1 parent
b40784e66f
commit
1a54d21e71
8 files changed
+276
-81
No files matched your search
@@ -15,6 +15,7 @@ import {
|
||||
captureObsidianPage,
|
||||
obsidianRemoteDebuggingPort,
|
||||
openLiveSyncSettings,
|
||||
waitForVisibleObsidianDialogue,
|
||||
withObsidianPage as withObsidianSessionPage,
|
||||
} from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
@@ -43,6 +44,8 @@ type DialogueMode = "desktop" | "mobile";
|
||||
type DialogueRunState = {
|
||||
done: boolean;
|
||||
deviceNameRead?: boolean;
|
||||
deviceNameReadPending?: boolean;
|
||||
releaseDeviceNameRead?: () => void;
|
||||
error?: string;
|
||||
expected?: unknown;
|
||||
kind: string;
|
||||
@@ -140,11 +143,20 @@ async function openP2PRemoteSelectionDialogueForInspection(): Promise<void> {
|
||||
kind: "p2p-compatibility-settings",
|
||||
done: false,
|
||||
deviceNameRead: false,
|
||||
deviceNameReadPending: false,
|
||||
};
|
||||
(globalThis as unknown as Record<string, DialogueRunState>)[stateKey] = state;
|
||||
const deviceNameReadGate = new Promise<void>((resolve) => {
|
||||
state.releaseDeviceNameRead = resolve;
|
||||
});
|
||||
config.getValue = async (key, defaultValue) => {
|
||||
const value = await originalGetValue.call(config, key, defaultValue);
|
||||
if (key === deviceNameKey && value === deviceName) state.deviceNameRead = true;
|
||||
if (key === deviceNameKey) {
|
||||
state.deviceNameReadPending = true;
|
||||
await deviceNameReadGate;
|
||||
state.deviceNameReadPending = false;
|
||||
if (value === deviceName) state.deviceNameRead = true;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
manager.onConfirmApplySettingsFromWizard = async (settings: unknown) => {
|
||||
@@ -156,16 +168,17 @@ async function openP2PRemoteSelectionDialogueForInspection(): Promise<void> {
|
||||
.then(
|
||||
(result) => {
|
||||
state.result = result;
|
||||
state.done = true;
|
||||
},
|
||||
(error: unknown) => {
|
||||
state.error = error instanceof Error ? error.message : String(error);
|
||||
state.done = true;
|
||||
}
|
||||
)
|
||||
.finally(() => {
|
||||
state.releaseDeviceNameRead?.();
|
||||
state.releaseDeviceNameRead = undefined;
|
||||
manager.onConfirmApplySettingsFromWizard = originalConfirm;
|
||||
config.getValue = originalGetValue;
|
||||
state.done = true;
|
||||
});
|
||||
},
|
||||
{
|
||||
@@ -177,6 +190,15 @@ async function openP2PRemoteSelectionDialogueForInspection(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function releasePendingP2PDeviceNameRead(): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
await page.evaluate((stateKey) => {
|
||||
const state = (globalThis as unknown as Record<string, DialogueRunState | undefined>)[stateKey];
|
||||
state?.releaseDeviceNameRead?.();
|
||||
}, dialogRunStateKey);
|
||||
});
|
||||
}
|
||||
|
||||
async function openSetupUriDialogue(): Promise<void> {
|
||||
const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
return await page.evaluate(
|
||||
@@ -523,9 +545,11 @@ async function verifyCouchDBSettingsDialogue(mode: DialogueMode): Promise<string
|
||||
async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
|
||||
await openP2PRemoteSelectionDialogueForInspection();
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const remoteSelection = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Choose a synchronisation remote" }),
|
||||
});
|
||||
const remoteSelection = await waitForVisibleObsidianDialogue(
|
||||
page,
|
||||
"Choose a synchronisation remote",
|
||||
uiTimeoutMs
|
||||
);
|
||||
await remoteSelection
|
||||
.locator("label")
|
||||
.filter({ hasText: "Peer-to-Peer (P2P)" })
|
||||
@@ -541,13 +565,36 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
|
||||
obsidianRemoteDebuggingPort(),
|
||||
"setup-p2p-compatibility-dialogue.png",
|
||||
async (page) => {
|
||||
const modal = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "P2P Configuration" }),
|
||||
});
|
||||
const modal = await waitForVisibleObsidianDialogue(page, "P2P Configuration", uiTimeoutMs);
|
||||
const modalPage = modal.page();
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const peerName = modal.locator('input[name="p2p-device-peer-id"]');
|
||||
const peerName = modal.getByLabel("Device Peer ID", { exact: true });
|
||||
await peerName.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await page.waitForFunction(
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (statePage) => {
|
||||
await statePage.waitForFunction(
|
||||
(stateKey) =>
|
||||
(globalThis as unknown as Record<string, DialogueRunState | undefined>)[stateKey]
|
||||
?.deviceNameReadPending === true,
|
||||
dialogRunStateKey,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
});
|
||||
const testAndContinue = modal.getByRole("button", { name: "Test Settings and Continue", exact: true });
|
||||
const continueAnyway = modal.getByRole("button", { name: "Continue anyway", exact: true });
|
||||
await testAndContinue.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await continueAnyway.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if (
|
||||
!(await peerName.isDisabled()) ||
|
||||
!(await testAndContinue.isDisabled()) ||
|
||||
!(await continueAnyway.isDisabled())
|
||||
) {
|
||||
throw new Error(
|
||||
"The P2P device name and setup actions must remain disabled while the saved device name is loading."
|
||||
);
|
||||
}
|
||||
|
||||
await releasePendingP2PDeviceNameRead();
|
||||
await modalPage.waitForFunction(
|
||||
(expectedName) => {
|
||||
const input = document.querySelector('input[name="p2p-device-peer-id"]');
|
||||
return input instanceof HTMLInputElement && !input.disabled && input.value === expectedName;
|
||||
@@ -562,7 +609,7 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
|
||||
await modal.locator('input[name="p2p-password"]').fill("e2e-passphrase");
|
||||
|
||||
const compatibility = modal.locator("details").filter({
|
||||
has: page.locator("summary").filter({ hasText: "Connection compatibility" }),
|
||||
has: modalPage.locator("summary").filter({ hasText: "Connection compatibility" }),
|
||||
});
|
||||
await compatibility.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if (!(await compatibility.evaluate((element) => (element as HTMLDetailsElement).open))) {
|
||||
@@ -603,7 +650,7 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
|
||||
}
|
||||
|
||||
const advanced = modal.locator("details").filter({
|
||||
has: page.locator("summary").filter({ hasText: "Advanced Settings" }),
|
||||
has: modalPage.locator("summary").filter({ hasText: "Advanced Settings" }),
|
||||
});
|
||||
if (!(await advanced.evaluate((element) => (element as HTMLDetailsElement).open))) {
|
||||
await advanced.locator("summary").click({ timeout: uiTimeoutMs });
|
||||
@@ -615,13 +662,13 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
|
||||
if (relayOnlyElement === null || connectionPathElement === null) {
|
||||
throw new Error("P2P connection-path controls were removed whilst validating TURN settings.");
|
||||
}
|
||||
await page.waitForFunction((element) => !(element as HTMLOptionElement).disabled, relayOnlyElement, {
|
||||
await modalPage.waitForFunction((element) => !(element as HTMLOptionElement).disabled, relayOnlyElement, {
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await connectionPath.selectOption("relay");
|
||||
|
||||
await turnServers.fill("");
|
||||
await page.waitForFunction(
|
||||
await modalPage.waitForFunction(
|
||||
(element) => (element as HTMLSelectElement).value === "automatic",
|
||||
connectionPathElement,
|
||||
{ timeout: uiTimeoutMs }
|
||||
@@ -636,7 +683,7 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
|
||||
await turnServers.fill("turn:turn.example.com:3478");
|
||||
await page.waitForFunction((element) => !(element as HTMLOptionElement).disabled, relayOnlyElement, {
|
||||
await modalPage.waitForFunction((element) => !(element as HTMLOptionElement).disabled, relayOnlyElement, {
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await connectionPath.selectOption("relay");
|
||||
@@ -644,12 +691,10 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
|
||||
await advanced.locator("summary").click({ timeout: uiTimeoutMs });
|
||||
await compatibility.scrollIntoViewIfNeeded();
|
||||
}
|
||||
);
|
||||
).finally(releasePendingP2PDeviceNameRead);
|
||||
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const modal = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "P2P Configuration" }),
|
||||
});
|
||||
const modal = await waitForVisibleObsidianDialogue(page, "P2P Configuration", uiTimeoutMs);
|
||||
await modal.getByRole("button", { name: "Continue anyway", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
@@ -69,6 +69,7 @@ type RunnerContext = {
|
||||
cliBinary: string;
|
||||
couchDb: CouchDbConfig;
|
||||
dbName: string;
|
||||
activeSessions: Set<ObsidianLiveSyncSession>;
|
||||
};
|
||||
|
||||
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
|
||||
@@ -348,6 +349,7 @@ async function startConfiguredSession(
|
||||
pluginData: createE2eCouchDbPluginData(couchDbSettings, hiddenFileSettings),
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
});
|
||||
context.activeSessions.add(session);
|
||||
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
|
||||
await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv);
|
||||
await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, hiddenFileSettings);
|
||||
@@ -355,6 +357,11 @@ async function startConfiguredSession(
|
||||
return session;
|
||||
}
|
||||
|
||||
async function stopSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<void> {
|
||||
await session.app.stop();
|
||||
context.activeSessions.delete(session);
|
||||
}
|
||||
|
||||
async function uploadHiddenFile(
|
||||
context: RunnerContext,
|
||||
session: ObsidianLiveSyncSession,
|
||||
@@ -430,12 +437,12 @@ async function runCreateRoundTrip(
|
||||
throw new Error("Remote Rebuild did not declare and encrypt internal Metadata for its accepted writer.");
|
||||
}
|
||||
console.log("Remote Rebuild declared the feature and preserved encrypted Hidden File Sync Metadata.");
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultB);
|
||||
await pullAndApplyHiddenFiles(context, session);
|
||||
const received = await waitForPathContent(vaultB.path, snippetPath, (content) => content === snippetContent);
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
|
||||
assertEqual(received, snippetContent, "Hidden snippet content did not round-trip to the second vault.");
|
||||
console.log(`Hidden create round-trip copied ${entry.id} to the second vault.`);
|
||||
@@ -450,12 +457,12 @@ async function runDeleteRoundTrip(
|
||||
let session = await startConfiguredSession(context, vaultA);
|
||||
await scanHiddenStorage(context.cliBinary, session.cliEnv);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultB);
|
||||
await pullAndApplyHiddenFiles(context, session);
|
||||
await waitForPathDeleted(vaultB.path, snippetPath);
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
|
||||
console.log("Hidden delete round-trip removed the snippet from the second vault.");
|
||||
}
|
||||
@@ -476,14 +483,14 @@ async function runJsonConflictRoundTrip(
|
||||
const mergedOnB = await waitForPathContent(vaultB.path, mergeJsonPath, (content) =>
|
||||
hasJsonValues(content, { fromA: true, fromB: true })
|
||||
);
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultA);
|
||||
await pullAndApplyHiddenFiles(context, session);
|
||||
const mergedOnA = await waitForPathContent(vaultA.path, mergeJsonPath, (content) =>
|
||||
hasJsonValues(content, { fromA: true, fromB: true })
|
||||
);
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
|
||||
assertEqual(mergedOnA, mergedOnB, "Merged hidden JSON content was not consistent across both vaults.");
|
||||
console.log("Hidden JSON conflict was automatically merged and round-tripped.");
|
||||
@@ -503,7 +510,7 @@ async function runJsonManualConflictResolution(context: RunnerContext, vault: Te
|
||||
const merged = await waitForPathContent(vault.path, manualMergeJsonPath, (content) =>
|
||||
hasJsonValues(content, { shared: "right", fromA: true, fromB: true })
|
||||
);
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
|
||||
const parsed = JSON.parse(merged);
|
||||
assertEqual(parsed.shared, "right", "Manual JSON conflict resolution did not apply the selected merged result.");
|
||||
@@ -517,17 +524,19 @@ async function runTargetMismatch(
|
||||
vaultA: TemporaryVault,
|
||||
vaultB: TemporaryVault
|
||||
): Promise<void> {
|
||||
const selectors = { syncOnlyRegEx: String.raw`\.(md|pdf)$` };
|
||||
const targetContent = JSON.stringify({ onlyA: true, targetMismatch: true }, null, 4) + "\n";
|
||||
await writeVaultFile(vaultA.path, targetPath, targetContent);
|
||||
|
||||
let session = await startConfiguredSession(context, vaultA);
|
||||
let session = await startConfiguredSession(context, vaultA, selectors);
|
||||
try {
|
||||
await uploadHiddenFile(context, session, targetPath);
|
||||
} finally {
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
}
|
||||
|
||||
session = await startConfiguredSession(context, vaultB, {
|
||||
...selectors,
|
||||
syncInternalFilesTargetPatterns: "snippets",
|
||||
});
|
||||
try {
|
||||
@@ -538,10 +547,11 @@ async function runTargetMismatch(
|
||||
"Hidden file was applied on a device where it was not a target file."
|
||||
);
|
||||
} finally {
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
}
|
||||
|
||||
session = await startConfiguredSession(context, vaultB, {
|
||||
...selectors,
|
||||
syncInternalFilesTargetPatterns: "",
|
||||
});
|
||||
let received = "";
|
||||
@@ -549,11 +559,17 @@ async function runTargetMismatch(
|
||||
await pullAndApplyHiddenFiles(context, session, { resolveConflicts: false });
|
||||
received = await waitForPathContent(vaultB.path, targetPath, (content) => content === targetContent);
|
||||
} finally {
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
}
|
||||
|
||||
assertEqual(received, targetContent, "Hidden file was not applied after it became a target file.");
|
||||
console.log("Hidden target mismatch respected per-device target patterns, then applied after enabling the target.");
|
||||
assertEqual(
|
||||
received,
|
||||
targetContent,
|
||||
"The normal file allow-list blocked a selected hidden file from the other device."
|
||||
);
|
||||
console.log(
|
||||
"A normal Markdown/PDF allow-list preserved hidden target exclusion and exact remote restoration after enabling the target."
|
||||
);
|
||||
}
|
||||
|
||||
async function setHiddenFileNoticeFixtures(port: number, itemIds: string[], includeRestart: boolean): Promise<void> {
|
||||
@@ -828,7 +844,7 @@ async function runInitialisationNoticeGrouping(context: RunnerContext, vault: Te
|
||||
`Hidden File Sync showed one progress Notice before settings were saved and retained it throughout initialisation. Screenshot: ${screenshotPath}`
|
||||
);
|
||||
} finally {
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -889,7 +905,7 @@ async function runConfigurationNoticeGrouping(context: RunnerContext, vault: Tem
|
||||
} finally {
|
||||
await clearHiddenFileNoticeFixtures(port).catch(() => undefined);
|
||||
await setObsidianMobileTestMode(port, false, timeoutMs).catch(() => undefined);
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -904,7 +920,7 @@ async function main(): Promise<void> {
|
||||
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "hidden-roundtrip");
|
||||
const vaultA = await createTemporaryVault();
|
||||
const vaultB = await createTemporaryVault();
|
||||
const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName };
|
||||
const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName, activeSessions: new Set() };
|
||||
|
||||
try {
|
||||
await assertCouchDbReachable(couchDb);
|
||||
@@ -923,6 +939,7 @@ async function main(): Promise<void> {
|
||||
await runInitialisationNoticeGrouping(context, vaultB);
|
||||
await runConfigurationNoticeGrouping(context, vaultB);
|
||||
} finally {
|
||||
for (const session of context.activeSessions) await stopSession(context, session);
|
||||
await vaultA.dispose();
|
||||
await vaultB.dispose();
|
||||
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { DEVICE_ID_PREFERRED, MILESTONE_DOCID, NODEINFO_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
@@ -12,12 +13,22 @@ import {
|
||||
putCouchDbDocument,
|
||||
type CouchDbDocument,
|
||||
} from "../runner/couchdb.ts";
|
||||
import {
|
||||
createObjectStorageClient,
|
||||
deleteObjectStoragePrefix,
|
||||
ensureObjectStorageBucket,
|
||||
loadObjectStorageConfig,
|
||||
makeUniqueBucketPrefix,
|
||||
readObjectStorageJson,
|
||||
} from "../runner/objectStorage.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import {
|
||||
assertEqual,
|
||||
assertE2eCompatibilityUnpaused,
|
||||
configureCouchDb,
|
||||
configureObjectStorage,
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObjectStoragePluginData,
|
||||
prepareRemote,
|
||||
waitForLiveSyncCoreReady,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
@@ -72,6 +83,73 @@ type DeviceFixture = {
|
||||
preferredTweaks: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type JoinedDevicesRemote = {
|
||||
label: string;
|
||||
pluginData: Record<string, unknown>;
|
||||
prepare(): Promise<void>;
|
||||
configure(cliBinary: string, env: NodeJS.ProcessEnv): Promise<unknown>;
|
||||
read(): Promise<JoinedDevicesMilestone>;
|
||||
write(milestone: JoinedDevicesMilestone): Promise<void>;
|
||||
cleanup(): Promise<void>;
|
||||
};
|
||||
|
||||
async function createRemoteFixture(): Promise<JoinedDevicesRemote> {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.some((argument) => argument !== "--object-storage")) {
|
||||
throw new Error("Usage: joined-devices.ts [--object-storage]");
|
||||
}
|
||||
if (args.includes("--object-storage")) {
|
||||
const config = await loadObjectStorageConfig();
|
||||
const bucketPrefix = makeUniqueBucketPrefix("joined-devices");
|
||||
const settings = { ...config, bucketPrefix };
|
||||
const key = `${bucketPrefix}_00000000-milestone.json`;
|
||||
return {
|
||||
label: "Object Storage",
|
||||
pluginData: createE2eObjectStoragePluginData(settings),
|
||||
prepare: () => ensureObjectStorageBucket(config),
|
||||
configure: (cliBinary, env) => configureObjectStorage(cliBinary, env, settings),
|
||||
read: () => readObjectStorageJson<JoinedDevicesMilestone>(config, key),
|
||||
write: async (milestone) => {
|
||||
const client = createObjectStorageClient(config);
|
||||
try {
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: key,
|
||||
Body: JSON.stringify(milestone),
|
||||
ContentType: "application/json",
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
client.destroy();
|
||||
}
|
||||
},
|
||||
cleanup: () => deleteObjectStoragePrefix(config, bucketPrefix),
|
||||
};
|
||||
}
|
||||
const config = await loadCouchDbConfig();
|
||||
const dbName = makeUniqueDatabaseName(config.dbPrefix, "joined-devices");
|
||||
const settings = { uri: config.uri, username: config.username, password: config.password, dbName };
|
||||
return {
|
||||
label: "CouchDB",
|
||||
pluginData: createE2eCouchDbPluginData(settings),
|
||||
prepare: async () => {
|
||||
await assertCouchDbReachable(config);
|
||||
await createCouchDbDatabase(config, dbName);
|
||||
},
|
||||
configure: (cliBinary, env) => configureCouchDb(cliBinary, env, settings),
|
||||
read: async () => {
|
||||
const milestone = await fetchCouchDbDocument(config, dbName, MILESTONE_DOCID);
|
||||
assert(Boolean(milestone._rev), "The remote milestone did not have a revision.");
|
||||
return milestone as JoinedDevicesMilestone;
|
||||
},
|
||||
write: async (milestone) => {
|
||||
await putCouchDbDocument(config, dbName, milestone);
|
||||
},
|
||||
cleanup: () => deleteCouchDbDatabase(config, dbName),
|
||||
};
|
||||
}
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
@@ -228,13 +306,9 @@ async function assertInitialDialogue(dialogue: Locator, self: SelfDeviceInfo, pa
|
||||
.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, {
|
||||
async function refreshRemotePlatform(page: Page, remote: JoinedDevicesRemote): Promise<void> {
|
||||
const milestone = await remote.read();
|
||||
await remote.write({
|
||||
...milestone,
|
||||
node_info: {
|
||||
...milestone.node_info,
|
||||
@@ -258,6 +332,15 @@ async function showForgetConfirmation(dialogue: Locator): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function assertCancelledForget(dialogue: Locator, remote: JoinedDevicesRemote): Promise<void> {
|
||||
const before = await remote.read();
|
||||
await showForgetConfirmation(dialogue);
|
||||
const confirmation = deviceCard(dialogue, targetId).locator(".forget-confirmation");
|
||||
await confirmation.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await confirmation.waitFor({ state: "detached", timeout: uiTimeoutMs });
|
||||
assertEqual(JSON.stringify(await remote.read()), JSON.stringify(before), "Cancelling Forget changed the remote.");
|
||||
}
|
||||
|
||||
async function confirmForgetTarget(dialogue: Locator): Promise<void> {
|
||||
const targetCard = deviceCard(dialogue, targetId);
|
||||
await targetCard.getByRole("button", { name: "Forget device", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
@@ -268,12 +351,8 @@ async function confirmForgetTarget(dialogue: Locator): Promise<void> {
|
||||
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;
|
||||
async function assertTargetForgotten(remote: JoinedDevicesRemote, fixture: DeviceFixture): Promise<void> {
|
||||
const milestone = await remote.read();
|
||||
assertEqual(
|
||||
JSON.stringify(milestone.accepted_nodes),
|
||||
JSON.stringify([retainedId]),
|
||||
@@ -371,42 +450,32 @@ async function main(): Promise<void> {
|
||||
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 remote = await createRemoteFixture();
|
||||
const vault = await createTemporaryVault();
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
|
||||
try {
|
||||
await assertCouchDbReachable(couchDb);
|
||||
await createCouchDbDatabase(couchDb, dbName);
|
||||
await remote.prepare();
|
||||
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,
|
||||
}),
|
||||
pluginData: remote.pluginData,
|
||||
});
|
||||
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 remote.configure(cli.binary, session.cliEnv);
|
||||
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
await prepareRemote(cli.binary, session.cliEnv);
|
||||
|
||||
const self = await readSelfDeviceInfo(cli.binary, session.cliEnv);
|
||||
const fixture = createFixture(self);
|
||||
const preparedMilestone = await 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 });
|
||||
const preparedMilestone = await remote.read();
|
||||
await remote.write({
|
||||
...fixture.milestone,
|
||||
...(preparedMilestone._rev ? { _rev: preparedMilestone._rev } : {}),
|
||||
});
|
||||
|
||||
await captureJoinedDevices(session.remoteDebuggingPort, "joined-devices-notice.png", async (page) => {
|
||||
const dialogue = await openFromRemoteSettings(page);
|
||||
@@ -440,8 +509,9 @@ async function main(): Promise<void> {
|
||||
session.remoteDebuggingPort,
|
||||
"joined-devices-forget-confirmation.png",
|
||||
async (page) => {
|
||||
await refreshRemotePlatform(page, couchDb, dbName);
|
||||
await refreshRemotePlatform(page, remote);
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
||||
await assertCancelledForget(dialogue, remote);
|
||||
await showForgetConfirmation(dialogue);
|
||||
await deviceCard(dialogue, targetId).locator(".forget-confirmation").scrollIntoViewIfNeeded();
|
||||
return dialogue;
|
||||
@@ -459,7 +529,7 @@ async function main(): Promise<void> {
|
||||
const settings = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
await settings.close();
|
||||
});
|
||||
await assertTargetForgotten(couchDb, dbName, fixture);
|
||||
await assertTargetForgotten(remote, fixture);
|
||||
|
||||
await setObsidianMobileTestMode(session.remoteDebuggingPort, true, uiTimeoutMs);
|
||||
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
@@ -485,14 +555,16 @@ async function main(): Promise<void> {
|
||||
return dialogue;
|
||||
});
|
||||
|
||||
console.log(`Joined devices scenario passed against CouchDB database ${dbName}.`);
|
||||
console.log(`Joined devices scenario passed against ${remote.label}.`);
|
||||
} 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);
|
||||
});
|
||||
try {
|
||||
if (session) await session.app.stop();
|
||||
} finally {
|
||||
try {
|
||||
await vault.dispose();
|
||||
} finally {
|
||||
await remote.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ const testSteps: Step[] = [
|
||||
{ name: "Review Harness", args: ["run", "test:e2e:obsidian:review-harness"] },
|
||||
{ name: "P2P status pane", args: ["run", "test:e2e:obsidian:p2p-pane"] },
|
||||
{ name: "vault reflection", args: ["run", "test:e2e:obsidian:vault-reflection"] },
|
||||
{ name: "joined devices with CouchDB", args: ["run", "test:e2e:obsidian:joined-devices"] },
|
||||
{ name: "CouchDB upload", args: ["run", "test:e2e:obsidian:couchdb-upload"] },
|
||||
{
|
||||
name: "manual CouchDB setup workflow",
|
||||
@@ -35,6 +36,7 @@ const testSteps: Step[] = [
|
||||
name: "CLI to real Obsidian synchronisation",
|
||||
args: ["run", "test:e2e:obsidian:cli-to-obsidian-sync"],
|
||||
},
|
||||
{ name: "joined devices with Object Storage", args: ["run", "test:e2e:obsidian:joined-devices-object-storage"] },
|
||||
{ name: "Object Storage upload", args: ["run", "test:e2e:obsidian:minio-upload"] },
|
||||
{
|
||||
name: "Object Storage Setup URI workflow",
|
||||
@@ -56,6 +58,7 @@ const testSteps: Step[] = [
|
||||
{ name: "startup scan", args: ["run", "test:e2e:obsidian:startup-scan"] },
|
||||
{ name: "provisioned Setup URI workflow", args: ["run", "test:e2e:obsidian:setup-uri-workflow"] },
|
||||
{ name: "two-vault synchronisation", args: ["run", "test:e2e:obsidian:two-vault-sync"] },
|
||||
{ name: "hidden file selectors", args: ["run", "test:e2e:obsidian:hidden-file-selectors"] },
|
||||
{ name: "hidden file snippet synchronisation", args: ["run", "test:e2e:obsidian:hidden-file-snippet-sync"] },
|
||||
{ name: "Customisation Sync", args: ["run", "test:e2e:obsidian:customisation-sync"] },
|
||||
{ name: "internal Metadata Doctor", args: ["run", "test:e2e:obsidian:internal-metadata-doctor"] },
|
||||
|
||||
@@ -9,6 +9,7 @@ const focusedScenarios = new Set([
|
||||
"onboarding-invitation",
|
||||
"dialog-mounts",
|
||||
"joined-devices",
|
||||
"joined-devices-object-storage",
|
||||
"database-diagnostics",
|
||||
"database-diagnostics-couchdb",
|
||||
"conflict-dialog-policy",
|
||||
|
||||
Reference in new issue
Block a user