mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-06-30 18:05:20 +00:00
(test): add local Obsidian E2E suite
This commit is contained in:
@@ -71,9 +71,10 @@ export async function openVaultWithObsidianCli(
|
||||
export async function evalObsidianJson<T>(
|
||||
cliBinary: string,
|
||||
code: string,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
timeoutMs?: number
|
||||
): Promise<T> {
|
||||
const result = await runObsidianCli(cliBinary, ["eval", `code=${code}`], env);
|
||||
const result = await runObsidianCli(cliBinary, ["eval", `code=${code}`], env, timeoutMs);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
[
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { platform } from "node:process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
export type ObsidianProcess = {
|
||||
process: ChildProcess;
|
||||
output: () => { stdout: string; stderr: string };
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -14,10 +16,14 @@ export type LaunchObsidianOptions = {
|
||||
vaultPath: string;
|
||||
homePath?: string;
|
||||
xdgConfigPath?: string;
|
||||
xdgCachePath?: string;
|
||||
xdgDataPath?: string;
|
||||
userDataPath?: string;
|
||||
startupGraceMs?: number;
|
||||
};
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function splitArgs(args: string): string[] {
|
||||
return args.split(" ").filter((arg) => arg.length > 0);
|
||||
}
|
||||
@@ -31,9 +37,13 @@ function launchArgs(options: LaunchObsidianOptions): string[] {
|
||||
"--no-sandbox",
|
||||
"--disable-gpu",
|
||||
"--disable-software-rasterizer",
|
||||
...(process.env.E2E_OBSIDIAN_USE_USER_DATA_DIR === "true" && options.userDataPath
|
||||
...(process.env.E2E_OBSIDIAN_USE_USER_DATA_DIR !== "false" && options.userDataPath
|
||||
? [`--user-data-dir=${options.userDataPath}`]
|
||||
: []),
|
||||
...(process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT
|
||||
? [`--remote-debugging-port=${process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT}`]
|
||||
: []),
|
||||
`obsidian://open?path=${encodeURIComponent(options.vaultPath)}`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -47,7 +57,63 @@ function shouldUseXvfb(): boolean {
|
||||
return platform === "linux" && existsSync("/usr/bin/xvfb-run");
|
||||
}
|
||||
|
||||
async function listChildPids(pid: number): Promise<number[]> {
|
||||
if (platform === "win32") {
|
||||
return [];
|
||||
}
|
||||
const { stdout } = await execFileAsync("ps", ["-o", "pid=", "--ppid", String(pid)]).catch(() => ({
|
||||
stdout: "",
|
||||
}));
|
||||
const directChildren = stdout
|
||||
.split("\n")
|
||||
.map((line) => Number(line.trim()))
|
||||
.filter((childPid) => Number.isInteger(childPid) && childPid > 0);
|
||||
const descendants = await Promise.all(directChildren.map((childPid) => listChildPids(childPid)));
|
||||
return [...directChildren, ...descendants.flat()];
|
||||
}
|
||||
|
||||
async function killPids(pids: number[], signal: NodeJS.Signals): Promise<void> {
|
||||
for (const pid of pids) {
|
||||
if (pid === process.pid) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
} catch {
|
||||
// The process may have exited between discovery and signalling.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForExit(exitPromise: Promise<unknown>, timeoutMs: number): Promise<"exited" | "timeout"> {
|
||||
const stopTimer = new Promise<"timeout">((resolve) => {
|
||||
setTimeout(() => resolve("timeout"), timeoutMs);
|
||||
});
|
||||
const stopResult = await Promise.race([exitPromise.then(() => "exited" as const), stopTimer]);
|
||||
return stopResult;
|
||||
}
|
||||
|
||||
export async function cleanupStaleObsidianE2EProcesses(): Promise<void> {
|
||||
if (process.env.E2E_OBSIDIAN_CLEANUP_STALE_PROCESSES === "false" || platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const { stdout } = await execFileAsync("pgrep", ["-f", "obsidian-livesync-e2e-state"]).catch(() => ({
|
||||
stdout: "",
|
||||
}));
|
||||
const pids = stdout
|
||||
.split("\n")
|
||||
.map((line) => Number(line.trim()))
|
||||
.filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid);
|
||||
if (pids.length === 0) {
|
||||
return;
|
||||
}
|
||||
await killPids(pids, "SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
await killPids(pids, "SIGKILL");
|
||||
}
|
||||
|
||||
export async function launchObsidian(options: LaunchObsidianOptions): Promise<ObsidianProcess> {
|
||||
await cleanupStaleObsidianE2EProcesses();
|
||||
const startupGraceMs = options.startupGraceMs ?? 1000;
|
||||
const args = launchArgs(options);
|
||||
const useXvfb = shouldUseXvfb();
|
||||
@@ -61,6 +127,8 @@ export async function launchObsidian(options: LaunchObsidianOptions): Promise<Ob
|
||||
...process.env,
|
||||
...(options.homePath ? { HOME: options.homePath } : {}),
|
||||
...(options.xdgConfigPath ? { XDG_CONFIG_HOME: options.xdgConfigPath } : {}),
|
||||
...(options.xdgCachePath ? { XDG_CACHE_HOME: options.xdgCachePath } : {}),
|
||||
...(options.xdgDataPath ? { XDG_DATA_HOME: options.xdgDataPath } : {}),
|
||||
OBSIDIAN_DISABLE_GPU: process.env.OBSIDIAN_DISABLE_GPU ?? "1",
|
||||
},
|
||||
});
|
||||
@@ -93,25 +161,34 @@ export async function launchObsidian(options: LaunchObsidianOptions): Promise<Ob
|
||||
|
||||
return {
|
||||
process: child,
|
||||
output: () => ({ stdout, stderr }),
|
||||
stop: async () => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
const descendantPids = child.pid ? await listChildPids(child.pid) : [];
|
||||
if (child.pid) {
|
||||
process.kill(-child.pid, "SIGTERM");
|
||||
try {
|
||||
process.kill(-child.pid, "SIGTERM");
|
||||
} catch {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
} else {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
const stopTimer = new Promise<"timeout">((resolve) => {
|
||||
setTimeout(() => resolve("timeout"), 5000);
|
||||
});
|
||||
const stopResult = await Promise.race([exitPromise, stopTimer]);
|
||||
await killPids(descendantPids.reverse(), "SIGTERM");
|
||||
const stopResult = await waitForExit(exitPromise, 5000);
|
||||
if (stopResult === "timeout") {
|
||||
if (child.pid) {
|
||||
process.kill(-child.pid, "SIGKILL");
|
||||
try {
|
||||
process.kill(-child.pid, "SIGKILL");
|
||||
} catch {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
} else {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
await killPids(descendantPids, "SIGKILL");
|
||||
await exitPromise;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { evalObsidianJson } from "./cli.ts";
|
||||
import type { CouchDbConfig } from "./couchdb.ts";
|
||||
import type { ObjectStorageConfig } from "./objectStorage.ts";
|
||||
|
||||
export type ConfiguredSettings = {
|
||||
isConfigured: boolean;
|
||||
liveSync: boolean;
|
||||
syncOnStart: boolean;
|
||||
syncOnSave: boolean;
|
||||
remoteType: string;
|
||||
couchDB_URI: string;
|
||||
couchDB_DBNAME: string;
|
||||
endpoint?: string;
|
||||
bucket?: string;
|
||||
bucketPrefix?: string;
|
||||
};
|
||||
|
||||
export type CoreReadiness = {
|
||||
@@ -23,6 +28,30 @@ export type LocalDatabaseEntry = {
|
||||
children: string[];
|
||||
};
|
||||
|
||||
function e2ePreferredSettingsSource(): string[] {
|
||||
return [
|
||||
"liveSync:false,",
|
||||
"syncOnStart:false,",
|
||||
"syncOnSave:false,",
|
||||
"usePluginSync:false,",
|
||||
"usePluginSyncV2:true,",
|
||||
"useEden:false,",
|
||||
"customChunkSize:60,",
|
||||
"sendChunksBulk:false,",
|
||||
"sendChunksBulkMaxSize:1,",
|
||||
"chunkSplitterVersion:'v3-rabin-karp',",
|
||||
"readChunksOnline:true,",
|
||||
"disableCheckingConfigMismatch:false,",
|
||||
"enableCompression:false,",
|
||||
"hashAlg:'xxhash64',",
|
||||
"handleFilenameCaseSensitive:false,",
|
||||
"doNotUseFixedRevisionForChunks:true,",
|
||||
"E2EEAlgorithm:'v2',",
|
||||
"doctorProcessedVersion:'0.25.27',",
|
||||
"isConfigured:true,",
|
||||
];
|
||||
}
|
||||
|
||||
export function assertEqual(actual: unknown, expected: unknown, message: string): void {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`);
|
||||
@@ -47,18 +76,7 @@ export async function configureCouchDb(
|
||||
`couchDB_PASSWORD:${JSON.stringify(settings.password)},`,
|
||||
`couchDB_DBNAME:${JSON.stringify(settings.dbName)},`,
|
||||
"remoteType:'',",
|
||||
"liveSync:false,",
|
||||
"syncOnStart:false,",
|
||||
"syncOnSave:false,",
|
||||
"usePluginSync:false,",
|
||||
"usePluginSyncV2:true,",
|
||||
"useEden:false,",
|
||||
"customChunkSize:1,",
|
||||
"sendChunksBulkMaxSize:1,",
|
||||
"chunkSplitterVersion:'v3-rabin-karp',",
|
||||
"readChunksOnline:false,",
|
||||
"disableCheckingConfigMismatch:true,",
|
||||
"isConfigured:true,",
|
||||
...e2ePreferredSettingsSource(),
|
||||
...Object.entries(overrides).map(([key, value]) => `${JSON.stringify(key)}:${JSON.stringify(value)},`),
|
||||
"};",
|
||||
"await core.services.setting.applyExternalSettings(nextSettings,true);",
|
||||
@@ -69,6 +87,7 @@ export async function configureCouchDb(
|
||||
"liveSync:current.liveSync,",
|
||||
"syncOnStart:current.syncOnStart,",
|
||||
"syncOnSave:current.syncOnSave,",
|
||||
"remoteType:current.remoteType,",
|
||||
"couchDB_URI:current.couchDB_URI,",
|
||||
"couchDB_DBNAME:current.couchDB_DBNAME,",
|
||||
"});",
|
||||
@@ -78,6 +97,52 @@ export async function configureCouchDb(
|
||||
);
|
||||
}
|
||||
|
||||
export async function configureObjectStorage(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
settings: ObjectStorageConfig & { bucketPrefix: string },
|
||||
overrides: Record<string, unknown> = {}
|
||||
): Promise<ConfiguredSettings> {
|
||||
return await evalObsidianJson<ConfiguredSettings>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const plugin=app.plugins.plugins['obsidian-livesync'];",
|
||||
"const core=plugin.core;",
|
||||
"const nextSettings={",
|
||||
"remoteType:'MINIO',",
|
||||
`endpoint:${JSON.stringify(settings.endpoint)},`,
|
||||
`accessKey:${JSON.stringify(settings.accessKey)},`,
|
||||
`secretKey:${JSON.stringify(settings.secretKey)},`,
|
||||
`bucket:${JSON.stringify(settings.bucket)},`,
|
||||
`region:${JSON.stringify(settings.region)},`,
|
||||
`forcePathStyle:${JSON.stringify(settings.forcePathStyle)},`,
|
||||
`bucketPrefix:${JSON.stringify(settings.bucketPrefix)},`,
|
||||
"bucketCustomHeaders:'',",
|
||||
...e2ePreferredSettingsSource(),
|
||||
...Object.entries(overrides).map(([key, value]) => `${JSON.stringify(key)}:${JSON.stringify(value)},`),
|
||||
"};",
|
||||
"await core.services.setting.applyExternalSettings(nextSettings,true);",
|
||||
"await core.services.control.applySettings();",
|
||||
"const current=core.services.setting.currentSettings();",
|
||||
"return JSON.stringify({",
|
||||
"isConfigured:current.isConfigured,",
|
||||
"liveSync:current.liveSync,",
|
||||
"syncOnStart:current.syncOnStart,",
|
||||
"syncOnSave:current.syncOnSave,",
|
||||
"remoteType:current.remoteType,",
|
||||
"couchDB_URI:current.couchDB_URI,",
|
||||
"couchDB_DBNAME:current.couchDB_DBNAME,",
|
||||
"endpoint:current.endpoint,",
|
||||
"bucket:current.bucket,",
|
||||
"bucketPrefix:current.bucketPrefix,",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForLiveSyncCoreReady(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
CreateBucketCommand,
|
||||
DeleteObjectsCommand,
|
||||
ListObjectsV2Command,
|
||||
S3Client,
|
||||
type _Object,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export type ObjectStorageConfig = {
|
||||
endpoint: string;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
bucket: string;
|
||||
region: string;
|
||||
forcePathStyle: boolean;
|
||||
};
|
||||
|
||||
function parseEnvFile(content: string): Record<string, string> {
|
||||
const entries = content
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith("#"))
|
||||
.map((line) => {
|
||||
const equalsAt = line.indexOf("=");
|
||||
if (equalsAt < 0) {
|
||||
return undefined;
|
||||
}
|
||||
const key = line.slice(0, equalsAt).trim();
|
||||
const rawValue = line.slice(equalsAt + 1).trim();
|
||||
const value = rawValue.replace(/^['"]|['"]$/gu, "");
|
||||
return [key, value] as const;
|
||||
})
|
||||
.filter((entry): entry is readonly [string, string] => entry !== undefined);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
function getEnvValue(values: Record<string, string | undefined>, ...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = values[key]?.trim();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
throw new Error(`Required Object Storage environment value is missing: ${keys.join(" or ")}`);
|
||||
}
|
||||
|
||||
export async function loadObjectStorageConfig(envFile = ".test.env"): Promise<ObjectStorageConfig> {
|
||||
let fileValues: Record<string, string> = {};
|
||||
try {
|
||||
fileValues = parseEnvFile(await readFile(resolve(envFile), "utf-8"));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const values = { ...fileValues, ...process.env };
|
||||
return {
|
||||
endpoint: getEnvValue(values, "MINIO_ENDPOINT", "minioEndpoint").replace(/\/+$/u, ""),
|
||||
accessKey: getEnvValue(values, "MINIO_ACCESS_KEY", "accessKey"),
|
||||
secretKey: getEnvValue(values, "MINIO_SECRET_KEY", "secretKey"),
|
||||
bucket: getEnvValue(values, "MINIO_BUCKET", "bucketName"),
|
||||
region: values.MINIO_REGION?.trim() || values.region?.trim() || "us-east-1",
|
||||
forcePathStyle: values.MINIO_FORCE_PATH_STYLE?.trim() !== "false",
|
||||
};
|
||||
}
|
||||
|
||||
export function makeUniqueBucketPrefix(label: string): string {
|
||||
const random = Math.random().toString(36).slice(2, 8);
|
||||
return `obsidian-e2e/${label}-${Date.now()}-${random}/`;
|
||||
}
|
||||
|
||||
export function createObjectStorageClient(config: ObjectStorageConfig): S3Client {
|
||||
return new S3Client({
|
||||
endpoint: config.endpoint,
|
||||
region: config.region,
|
||||
forcePathStyle: config.forcePathStyle,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKey,
|
||||
secretAccessKey: config.secretKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureObjectStorageBucket(config: ObjectStorageConfig): Promise<void> {
|
||||
const client = createObjectStorageClient(config);
|
||||
try {
|
||||
await client.send(new CreateBucketCommand({ Bucket: config.bucket }));
|
||||
} catch (error) {
|
||||
const name = (error as { name?: string }).name;
|
||||
if (name !== "BucketAlreadyOwnedByYou" && name !== "BucketAlreadyExists") {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export async function listObjectStorageObjects(config: ObjectStorageConfig, prefix: string): Promise<_Object[]> {
|
||||
const client = createObjectStorageClient(config);
|
||||
try {
|
||||
const objects: _Object[] = [];
|
||||
let continuationToken: string | undefined;
|
||||
do {
|
||||
const response = await client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: config.bucket,
|
||||
Prefix: prefix,
|
||||
ContinuationToken: continuationToken,
|
||||
})
|
||||
);
|
||||
objects.push(...(response.Contents ?? []));
|
||||
continuationToken = response.NextContinuationToken;
|
||||
} while (continuationToken);
|
||||
return objects;
|
||||
} finally {
|
||||
client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteObjectStoragePrefix(config: ObjectStorageConfig, prefix: string): Promise<void> {
|
||||
const client = createObjectStorageClient(config);
|
||||
try {
|
||||
const objects = await listObjectStorageObjects(config, prefix);
|
||||
const keys = objects.flatMap((object) => (object.Key ? [{ Key: object.Key }] : []));
|
||||
for (let index = 0; index < keys.length; index += 1000) {
|
||||
await client.send(
|
||||
new DeleteObjectsCommand({
|
||||
Bucket: config.bucket,
|
||||
Delete: {
|
||||
Objects: keys.slice(index, index + 1000),
|
||||
Quiet: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
client.destroy();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { openVaultWithObsidianCli, runObsidianCli } from "./cli.ts";
|
||||
import { evalObsidianJson, openVaultWithObsidianCli, runObsidianCli } from "./cli.ts";
|
||||
import { launchObsidian, type ObsidianProcess } from "./launch.ts";
|
||||
import { installBuiltPlugin, type PluginInstallResult } from "./pluginInstaller.ts";
|
||||
import { waitForPluginReady, type PluginReadiness } from "./readiness.ts";
|
||||
import type { TemporaryVault } from "./vault.ts";
|
||||
import { obsidianRemoteDebuggingPort, preseedTrustedVaultState, trustVaultIfPrompted } from "./ui.ts";
|
||||
|
||||
export type ObsidianLiveSyncSession = {
|
||||
app: ObsidianProcess;
|
||||
@@ -19,13 +20,21 @@ export type StartObsidianLiveSyncSessionOptions = {
|
||||
};
|
||||
|
||||
async function waitForPluginCatalogue(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_CLI_READY_TIMEOUT_MS ?? 15000);
|
||||
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_CLI_READY_TIMEOUT_MS ?? 60000);
|
||||
let lastOutput = "";
|
||||
while (Date.now() < deadline) {
|
||||
const result = await runObsidianCli(cliBinary, ["plugins", "filter=community"], env);
|
||||
lastOutput = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
||||
if (result.stdout.includes("obsidian-livesync")) {
|
||||
return;
|
||||
try {
|
||||
const result = await evalObsidianJson<{ hasLiveSync: boolean }>(
|
||||
cliBinary,
|
||||
["JSON.stringify({", "hasLiveSync:!!app.plugins?.manifests?.['obsidian-livesync']", "})"].join(""),
|
||||
env
|
||||
);
|
||||
lastOutput = JSON.stringify(result);
|
||||
if (result.hasLiveSync) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
lastOutput = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
@@ -66,11 +75,14 @@ export async function startObsidianLiveSyncSession(
|
||||
options: StartObsidianLiveSyncSessionOptions
|
||||
): Promise<ObsidianLiveSyncSession> {
|
||||
const install = await installBuiltPlugin(options.vault.path);
|
||||
const remoteDebuggingPort = obsidianRemoteDebuggingPort();
|
||||
const app = await launchObsidian({
|
||||
binary: options.binary,
|
||||
vaultPath: options.vault.path,
|
||||
homePath: options.vault.homePath,
|
||||
xdgConfigPath: options.vault.xdgConfigPath,
|
||||
xdgCachePath: options.vault.xdgCachePath,
|
||||
xdgDataPath: options.vault.xdgDataPath,
|
||||
userDataPath: options.vault.userDataPath,
|
||||
startupGraceMs: options.startupGraceMs,
|
||||
});
|
||||
@@ -78,17 +90,30 @@ export async function startObsidianLiveSyncSession(
|
||||
...process.env,
|
||||
HOME: options.vault.homePath,
|
||||
XDG_CONFIG_HOME: options.vault.xdgConfigPath,
|
||||
XDG_CACHE_HOME: options.vault.xdgCachePath,
|
||||
XDG_DATA_HOME: options.vault.xdgDataPath,
|
||||
};
|
||||
|
||||
try {
|
||||
await preseedTrustedVaultState(remoteDebuggingPort, options.vault.id);
|
||||
await openVaultWithObsidianCli(options.cliBinary, options.vault.path, cliEnv);
|
||||
await trustVaultIfPrompted(remoteDebuggingPort);
|
||||
await waitForPluginCatalogue(options.cliBinary, cliEnv);
|
||||
await enableCommunityPlugins(options.cliBinary, cliEnv);
|
||||
await reloadLiveSyncPlugin(options.cliBinary, cliEnv);
|
||||
const readiness = await waitForPluginReady(options.cliBinary, cliEnv);
|
||||
return { app, cliEnv, install, readiness };
|
||||
} catch (error) {
|
||||
const output = app.output();
|
||||
await app.stop();
|
||||
throw error;
|
||||
throw new Error(
|
||||
[
|
||||
error instanceof Error ? error.message : String(error),
|
||||
output.stdout ? `Obsidian stdout:\n${output.stdout}` : undefined,
|
||||
output.stderr ? `Obsidian stderr:\n${output.stderr}` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { chromium, type Page } from "playwright";
|
||||
|
||||
export function obsidianRemoteDebuggingPort(): number {
|
||||
const port = Number(process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT ?? 9222);
|
||||
process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT = String(port);
|
||||
return port;
|
||||
}
|
||||
|
||||
async function waitForCdp(port: number): Promise<void> {
|
||||
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_CDP_TIMEOUT_MS ?? 30000);
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/json/version`);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Keep polling until Obsidian exposes the debugging endpoint.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(`Timed out waiting for Obsidian DevTools endpoint on port ${port}`);
|
||||
}
|
||||
|
||||
export async function withObsidianPage<T>(port: number, operation: (page: Page) => Promise<T>): Promise<T> {
|
||||
await waitForCdp(port);
|
||||
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
|
||||
try {
|
||||
const context = browser.contexts()[0];
|
||||
const page = context.pages()[0] ?? (await context.waitForEvent("page", { timeout: 10000 }));
|
||||
return await operation(page);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function preseedTrustedVaultState(port: number, vaultId: string): Promise<void> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page.evaluate((id) => {
|
||||
localStorage.setItem(`enable-plugin-${id}`, "true");
|
||||
}, vaultId);
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: 10000 }).catch(() => undefined);
|
||||
await page.waitForTimeout(1000);
|
||||
});
|
||||
}
|
||||
|
||||
export async function trustVaultIfPrompted(port: number): Promise<void> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_TRUST_PROMPT_TIMEOUT_MS ?? 30000);
|
||||
while (Date.now() < deadline) {
|
||||
const yesButton = page.getByRole("button", { name: "Yes" });
|
||||
if (await yesButton.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await yesButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
continue;
|
||||
}
|
||||
|
||||
const trustButton = page.getByText("Trust author and enable plugins");
|
||||
if (await trustButton.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await trustButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
continue;
|
||||
}
|
||||
|
||||
const workspace = page.locator(".workspace");
|
||||
if (await workspace.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function clickJsonResolveOption(port: number, mode: "AB" | "BA"): Promise<void> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const option = page.locator(`label:has(input[name="disp"][value="${mode}"])`);
|
||||
await option.click({ timeout: 10000 });
|
||||
const checked = await page.locator(`input[name="disp"][value="${mode}"]`).isChecked({ timeout: 10000 });
|
||||
if (!checked) {
|
||||
throw new Error(`JSON Resolve option was not selected: ${mode}`);
|
||||
}
|
||||
await page.getByRole("button", { name: "Apply" }).click({ timeout: 10000 });
|
||||
});
|
||||
}
|
||||
@@ -5,8 +5,11 @@ import { tmpdir } from "node:os";
|
||||
export type TemporaryVault = {
|
||||
path: string;
|
||||
name: string;
|
||||
id: string;
|
||||
homePath: string;
|
||||
xdgConfigPath: string;
|
||||
xdgCachePath: string;
|
||||
xdgDataPath: string;
|
||||
userDataPath: string;
|
||||
dispose: () => Promise<void>;
|
||||
};
|
||||
@@ -18,21 +21,33 @@ export async function createTemporaryVault(prefix = "obsidian-livesync-e2e-"): P
|
||||
await mkdir(join(vaultPath, ".obsidian"), { recursive: true });
|
||||
const homePath = join(statePath, "home");
|
||||
const xdgConfigPath = join(statePath, "xdg-config");
|
||||
const xdgCachePath = join(statePath, "xdg-cache");
|
||||
const xdgDataPath = join(statePath, "xdg-data");
|
||||
const userDataPath = join(statePath, "user-data");
|
||||
const id = `livesync-e2e-${Date.now()}`;
|
||||
await mkdir(homePath, { recursive: true });
|
||||
await mkdir(xdgConfigPath, { recursive: true });
|
||||
await mkdir(xdgCachePath, { recursive: true });
|
||||
await mkdir(xdgDataPath, { recursive: true });
|
||||
await mkdir(userDataPath, { recursive: true });
|
||||
await writeFile(
|
||||
join(vaultPath, ".obsidian", "app.json"),
|
||||
JSON.stringify({ legacyEditor: false, safeMode: false }, null, 4)
|
||||
);
|
||||
await writeObsidianVaultRegistry(vaultPath, name, homePath, xdgConfigPath, userDataPath);
|
||||
await writeFile(
|
||||
join(vaultPath, ".obsidian", "community-plugins.json"),
|
||||
JSON.stringify(["obsidian-livesync"], null, 4)
|
||||
);
|
||||
await writeObsidianVaultRegistry(id, vaultPath, name, homePath, xdgConfigPath, userDataPath);
|
||||
|
||||
return {
|
||||
path: vaultPath,
|
||||
name,
|
||||
id,
|
||||
homePath,
|
||||
xdgConfigPath,
|
||||
xdgCachePath,
|
||||
xdgDataPath,
|
||||
userDataPath,
|
||||
dispose: async () => {
|
||||
if (process.env.E2E_OBSIDIAN_KEEP_VAULT === "true") {
|
||||
@@ -49,22 +64,23 @@ export async function createTemporaryVault(prefix = "obsidian-livesync-e2e-"): P
|
||||
}
|
||||
|
||||
async function writeObsidianVaultRegistry(
|
||||
vaultId: string,
|
||||
vaultPath: string,
|
||||
vaultName: string,
|
||||
homePath: string,
|
||||
xdgConfigPath: string,
|
||||
userDataPath: string
|
||||
): Promise<void> {
|
||||
const vaultId = `livesync-e2e-${Date.now()}`;
|
||||
const vaultRecord = {
|
||||
path: vaultPath,
|
||||
ts: Date.now(),
|
||||
open: true,
|
||||
name: vaultName,
|
||||
};
|
||||
const registry = {
|
||||
cli: true,
|
||||
vaults: {
|
||||
[vaultId]: {
|
||||
path: vaultPath,
|
||||
ts: Date.now(),
|
||||
open: true,
|
||||
name: vaultName,
|
||||
},
|
||||
[vaultId]: vaultRecord,
|
||||
},
|
||||
};
|
||||
const registryText = JSON.stringify(registry, null, 4);
|
||||
@@ -74,4 +90,5 @@ async function writeObsidianVaultRegistry(
|
||||
await writeFile(join(obsidianConfigDir, "obsidian.json"), registryText);
|
||||
}
|
||||
await writeFile(join(userDataPath, "obsidian.json"), registryText);
|
||||
await writeFile(join(userDataPath, `${vaultId}.json`), JSON.stringify(vaultRecord, null, 4));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user