mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-10 14:53:09 +00:00
test: share Obsidian E2E session infrastructure
This commit is contained in:
Generated
+14
@@ -58,6 +58,7 @@
|
||||
"@vitest/browser": "^4.1.8",
|
||||
"@vitest/browser-playwright": "^4.1.8",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"@vrtmrz/obsidian-test-session": "https://github.com/vrtmrz/fancy-kit/releases/download/consumer-preview-2026-07-10.2/vrtmrz-obsidian-test-session-0.0.0.tgz",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"esbuild": "0.28.1",
|
||||
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||
@@ -4944,6 +4945,19 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/obsidian-test-session": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://github.com/vrtmrz/fancy-kit/releases/download/consumer-preview-2026-07-10.2/vrtmrz-obsidian-test-session-0.0.0.tgz",
|
||||
"integrity": "sha512-XHWFORR8Q7vQsbKxrj8RBgpyC7DHFw5PSUXkBOKJoHnx9abgCkuQp4gYYa64RO7sOdRx/Xj799JOop+McQSqsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"playwright": ">=1.50.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@wdio/config": {
|
||||
"version": "9.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.27.0.tgz",
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"@vitest/browser": "^4.1.8",
|
||||
"@vitest/browser-playwright": "^4.1.8",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"@vrtmrz/obsidian-test-session": "https://github.com/vrtmrz/fancy-kit/releases/download/consumer-preview-2026-07-10.2/vrtmrz-obsidian-test-session-0.0.0.tgz",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"esbuild": "0.28.1",
|
||||
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
This directory contains the experimental real Obsidian end-to-end runner.
|
||||
|
||||
The generic application discovery, isolated-vault, plug-in installation, process lifecycle, CLI, CDP, and readiness implementation comes from `@vrtmrz/obsidian-test-session`. The small modules under `runner/` preserve LiveSync's existing imports and supply its plug-in ID and artefact location. LiveSync-specific fixtures, services, settings, workflows, and assertions remain in this repository.
|
||||
|
||||
The current smoke runner verifies only the launch path:
|
||||
|
||||
1. create a temporary vault,
|
||||
@@ -18,7 +20,7 @@ The runner does not require Self-hosted LiveSync to expose an E2E-only bridge. R
|
||||
|
||||
Obsidian 1.12 stores the global community plug-in switch outside `.obsidian/community-plugins.json`. The smoke runner enables it through `app.plugins.setEnable(true)` after the vault window is available.
|
||||
|
||||
Future workflows should use `startObsidianLiveSyncSession()` from `runner/session.ts` rather than repeating the launch and plug-in readiness sequence.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -1,103 +1,6 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export type ObsidianCliResult = {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
function parseEvalJson(stdout: string): unknown {
|
||||
const marker = "=> ";
|
||||
const markerIndex = stdout.indexOf(marker);
|
||||
const text = markerIndex >= 0 ? stdout.slice(markerIndex + marker.length) : stdout;
|
||||
return JSON.parse(text.trim());
|
||||
}
|
||||
|
||||
export async function runObsidianCli(
|
||||
cliBinary: string,
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
timeoutMs = Number(process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ?? 10000)
|
||||
): Promise<ObsidianCliResult> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(cliBinary, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error(`Obsidian CLI timed out: ${cliBinary} ${args.join(" ")}`));
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ code, signal, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function openVaultWithObsidianCli(
|
||||
cliBinary: string,
|
||||
vaultPath: string,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): Promise<void> {
|
||||
const result = await runObsidianCli(cliBinary, [`obsidian://open?path=${encodeURIComponent(vaultPath)}`], env);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
[
|
||||
`Failed to open Obsidian vault through CLI. code=${result.code}, signal=${result.signal}`,
|
||||
result.stdout ? `stdout:\n${result.stdout}` : undefined,
|
||||
result.stderr ? `stderr:\n${result.stderr}` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function evalObsidianJson<T>(
|
||||
cliBinary: string,
|
||||
code: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
timeoutMs?: number
|
||||
): Promise<T> {
|
||||
const result = await runObsidianCli(cliBinary, ["eval", `code=${code}`], env, timeoutMs);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
[
|
||||
`Failed to evaluate Obsidian JavaScript through CLI. code=${result.code}, signal=${result.signal}`,
|
||||
result.stdout ? `stdout:\n${result.stdout}` : undefined,
|
||||
result.stderr ? `stderr:\n${result.stderr}` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
try {
|
||||
return parseEvalJson(result.stdout) as T;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
[
|
||||
`Failed to parse Obsidian CLI eval JSON. code=${result.code}, signal=${result.signal}`,
|
||||
error instanceof Error ? `parse error: ${error.message}` : undefined,
|
||||
result.stdout ? `stdout:\n${result.stdout}` : undefined,
|
||||
result.stderr ? `stderr:\n${result.stderr}` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
export {
|
||||
evalObsidianJson,
|
||||
openVaultWithObsidianCli,
|
||||
runObsidianCli,
|
||||
type ObsidianCliResult,
|
||||
} from "@vrtmrz/obsidian-test-session";
|
||||
|
||||
@@ -1,149 +1,7 @@
|
||||
import { accessSync, constants, existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { platform } from "node:process";
|
||||
|
||||
export type ObsidianDiscoveryResult = {
|
||||
binary?: string;
|
||||
source?: string;
|
||||
checked: string[];
|
||||
};
|
||||
|
||||
const defaultCandidatesByPlatform: Record<NodeJS.Platform, string[]> = {
|
||||
aix: [],
|
||||
android: [],
|
||||
darwin: [
|
||||
"/Applications/Obsidian.app/Contents/MacOS/Obsidian",
|
||||
"/Applications/Obsidian.app/Contents/MacOS/obsidian",
|
||||
],
|
||||
freebsd: [],
|
||||
haiku: [],
|
||||
linux: [
|
||||
"_testdata/obsidian/squashfs-root/obsidian",
|
||||
"_testdata/obsidian/squashfs-root/AppRun",
|
||||
"_testdata/obsidian/Obsidian-1.12.7-arm64.AppImage",
|
||||
"_testdata/obsidian/Obsidian-1.12.7-x86_64.AppImage",
|
||||
"/usr/bin/obsidian",
|
||||
"/usr/local/bin/obsidian",
|
||||
"/snap/bin/obsidian",
|
||||
"/opt/Obsidian/obsidian",
|
||||
"/opt/obsidian/obsidian",
|
||||
"/app/bin/obsidian",
|
||||
],
|
||||
openbsd: [],
|
||||
sunos: [],
|
||||
win32: ["C:\\Program Files\\Obsidian\\Obsidian.exe", "C:\\Program Files (x86)\\Obsidian\\Obsidian.exe"],
|
||||
cygwin: [],
|
||||
netbsd: [],
|
||||
};
|
||||
|
||||
const defaultCliCandidatesByPlatform: Record<NodeJS.Platform, string[]> = {
|
||||
aix: [],
|
||||
android: [],
|
||||
darwin: [
|
||||
"/Applications/Obsidian.app/Contents/MacOS/obsidian-cli",
|
||||
"/Applications/Obsidian.app/Contents/Resources/obsidian-cli",
|
||||
],
|
||||
freebsd: [],
|
||||
haiku: [],
|
||||
linux: [
|
||||
"_testdata/obsidian/squashfs-root/obsidian-cli",
|
||||
"/usr/bin/obsidian-cli",
|
||||
"/usr/local/bin/obsidian-cli",
|
||||
"/snap/bin/obsidian-cli",
|
||||
"/opt/Obsidian/obsidian-cli",
|
||||
"/opt/obsidian/obsidian-cli",
|
||||
],
|
||||
openbsd: [],
|
||||
sunos: [],
|
||||
win32: ["C:\\Program Files\\Obsidian\\obsidian-cli.exe", "C:\\Program Files (x86)\\Obsidian\\obsidian-cli.exe"],
|
||||
cygwin: [],
|
||||
netbsd: [],
|
||||
};
|
||||
|
||||
function isUsableFile(path: string): boolean {
|
||||
const resolvedPath = resolve(path);
|
||||
if (!existsSync(resolvedPath)) {
|
||||
return false;
|
||||
}
|
||||
if (platform === "win32") {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
accessSync(resolvedPath, constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function discoverObsidianBinary(env: NodeJS.ProcessEnv = process.env): ObsidianDiscoveryResult {
|
||||
const checked: string[] = [];
|
||||
const envBinary = env.OBSIDIAN_BINARY?.trim();
|
||||
if (envBinary) {
|
||||
checked.push(envBinary);
|
||||
if (isUsableFile(envBinary)) {
|
||||
return {
|
||||
binary: resolve(envBinary),
|
||||
source: "OBSIDIAN_BINARY",
|
||||
checked,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = defaultCandidatesByPlatform[platform] ?? [];
|
||||
for (const candidate of candidates) {
|
||||
checked.push(candidate);
|
||||
if (isUsableFile(candidate)) {
|
||||
return {
|
||||
binary: resolve(candidate),
|
||||
source: "default-path",
|
||||
checked,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { checked };
|
||||
}
|
||||
|
||||
export function requireObsidianBinary(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const result = discoverObsidianBinary(env);
|
||||
if (!result.binary) {
|
||||
throw new Error(
|
||||
[
|
||||
"Could not find an Obsidian executable.",
|
||||
"Set OBSIDIAN_BINARY to the installed Obsidian executable path.",
|
||||
`Checked paths: ${result.checked.length > 0 ? result.checked.join(", ") : "(none)"}`,
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
return result.binary;
|
||||
}
|
||||
|
||||
export function discoverObsidianCli(env: NodeJS.ProcessEnv = process.env): ObsidianDiscoveryResult {
|
||||
const checked: string[] = [];
|
||||
const envBinary = env.OBSIDIAN_CLI?.trim();
|
||||
if (envBinary) {
|
||||
checked.push(envBinary);
|
||||
if (isUsableFile(envBinary)) {
|
||||
return {
|
||||
binary: resolve(envBinary),
|
||||
source: "OBSIDIAN_CLI",
|
||||
checked,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = defaultCliCandidatesByPlatform[platform] ?? [];
|
||||
for (const candidate of candidates) {
|
||||
checked.push(candidate);
|
||||
if (isUsableFile(candidate)) {
|
||||
return {
|
||||
binary: resolve(candidate),
|
||||
source: "default-path",
|
||||
checked,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { checked };
|
||||
}
|
||||
export {
|
||||
discoverObsidianBinary,
|
||||
discoverObsidianCli,
|
||||
requireObsidianBinary,
|
||||
requireObsidianCli,
|
||||
type ObsidianDiscoveryResult,
|
||||
} from "@vrtmrz/obsidian-test-session";
|
||||
|
||||
@@ -1,196 +1,26 @@
|
||||
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";
|
||||
import {
|
||||
cleanupStaleObsidianE2EProcesses as cleanupStaleProcesses,
|
||||
launchObsidian as launchObsidianSession,
|
||||
type LaunchObsidianOptions,
|
||||
type ObsidianProcess,
|
||||
type ObsidianProcessOutput,
|
||||
} from "@vrtmrz/obsidian-test-session";
|
||||
|
||||
export type ObsidianProcess = {
|
||||
process: ChildProcess;
|
||||
output: () => { stdout: string; stderr: string };
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
export type { LaunchObsidianOptions, ObsidianProcess, ObsidianProcessOutput };
|
||||
|
||||
export type LaunchObsidianOptions = {
|
||||
binary: string;
|
||||
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);
|
||||
}
|
||||
|
||||
function launchArgs(options: LaunchObsidianOptions): string[] {
|
||||
const explicitArgs = process.env.E2E_OBSIDIAN_ARGS;
|
||||
if (explicitArgs) {
|
||||
return splitArgs(explicitArgs);
|
||||
}
|
||||
return [
|
||||
"--no-sandbox",
|
||||
"--disable-gpu",
|
||||
"--disable-software-rasterizer",
|
||||
...(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)}`,
|
||||
];
|
||||
}
|
||||
|
||||
function shouldUseXvfb(): boolean {
|
||||
if (process.env.E2E_OBSIDIAN_USE_XVFB === "false") {
|
||||
return false;
|
||||
}
|
||||
if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) {
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
}
|
||||
const STALE_PROCESS_PATTERN = "obsidian-livesync-e2e-state";
|
||||
|
||||
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");
|
||||
await cleanupStaleProcesses(STALE_PROCESS_PATTERN);
|
||||
}
|
||||
|
||||
export async function launchObsidian(options: LaunchObsidianOptions): Promise<ObsidianProcess> {
|
||||
await cleanupStaleObsidianE2EProcesses();
|
||||
const startupGraceMs = options.startupGraceMs ?? 1000;
|
||||
const args = launchArgs(options);
|
||||
const useXvfb = shouldUseXvfb();
|
||||
const command = useXvfb ? "/usr/bin/xvfb-run" : options.binary;
|
||||
const commandArgs = useXvfb ? ["-a", options.binary, ...args] : args;
|
||||
const child = spawn(command, commandArgs, {
|
||||
cwd: dirname(options.binary),
|
||||
detached: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...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",
|
||||
},
|
||||
const configuredPort =
|
||||
options.env?.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT ?? process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT;
|
||||
return await launchObsidianSession({
|
||||
...options,
|
||||
remoteDebuggingPort:
|
||||
options.remoteDebuggingPort ?? (configuredPort === undefined ? undefined : Number(configuredPort)),
|
||||
staleProcessPattern: options.staleProcessPattern ?? STALE_PROCESS_PATTERN,
|
||||
});
|
||||
|
||||
let stderr = "";
|
||||
let stdout = "";
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
const exitPromise = once(child, "exit").then(([code, signal]) => ({ code, signal }));
|
||||
const timer = new Promise<"timeout">((resolve) => {
|
||||
setTimeout(() => resolve("timeout"), startupGraceMs);
|
||||
});
|
||||
const firstResult = await Promise.race([exitPromise, timer]);
|
||||
if (firstResult !== "timeout") {
|
||||
throw new Error(
|
||||
[
|
||||
`Obsidian exited before the smoke timeout. code=${firstResult.code}, signal=${firstResult.signal}`,
|
||||
stdout ? `stdout:\n${stdout}` : undefined,
|
||||
stderr ? `stderr:\n${stderr}` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
try {
|
||||
process.kill(-child.pid, "SIGTERM");
|
||||
} catch {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
} else {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
await killPids(descendantPids.reverse(), "SIGTERM");
|
||||
const stopResult = await waitForExit(exitPromise, 5000);
|
||||
if (stopResult === "timeout") {
|
||||
if (child.pid) {
|
||||
try {
|
||||
process.kill(-child.pid, "SIGKILL");
|
||||
} catch {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
} else {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
await killPids(descendantPids, "SIGKILL");
|
||||
await exitPromise;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,39 +1,13 @@
|
||||
import { copyFile, mkdir, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import {
|
||||
installBuiltPlugin as installGenericBuiltPlugin,
|
||||
type PluginInstallResult,
|
||||
} from "@vrtmrz/obsidian-test-session";
|
||||
|
||||
export type PluginInstallResult = {
|
||||
pluginDir: string;
|
||||
copied: string[];
|
||||
};
|
||||
|
||||
const pluginId = "obsidian-livesync";
|
||||
export type { PluginInstallResult };
|
||||
|
||||
export async function installBuiltPlugin(vaultPath: string, rootDir = process.cwd()): Promise<PluginInstallResult> {
|
||||
const pluginDir = join(vaultPath, ".obsidian", "plugins", pluginId);
|
||||
const copied: string[] = [];
|
||||
await mkdir(pluginDir, { recursive: true });
|
||||
|
||||
const requiredArtifacts = ["main.js", "manifest.json"];
|
||||
for (const artifact of requiredArtifacts) {
|
||||
const source = resolve(rootDir, artifact);
|
||||
if (!existsSync(source)) {
|
||||
throw new Error(`Required plug-in artifact is missing: ${source}`);
|
||||
}
|
||||
await copyFile(source, join(pluginDir, artifact));
|
||||
copied.push(artifact);
|
||||
}
|
||||
|
||||
const optionalArtifacts = ["styles.css"];
|
||||
for (const artifact of optionalArtifacts) {
|
||||
const source = resolve(rootDir, artifact);
|
||||
if (!existsSync(source)) {
|
||||
continue;
|
||||
}
|
||||
await copyFile(source, join(pluginDir, artifact));
|
||||
copied.push(artifact);
|
||||
}
|
||||
|
||||
await writeFile(join(vaultPath, ".obsidian", "community-plugins.json"), JSON.stringify([pluginId], null, 4));
|
||||
return { pluginDir, copied };
|
||||
return await installGenericBuiltPlugin(vaultPath, {
|
||||
pluginId: "obsidian-livesync",
|
||||
artifactRoot: rootDir,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,41 +1 @@
|
||||
import { evalObsidianJson } from "./cli.ts";
|
||||
|
||||
export type PluginReadiness = {
|
||||
status: "ready";
|
||||
pluginId: string;
|
||||
pluginVersion: string;
|
||||
vaultName: string;
|
||||
};
|
||||
|
||||
export async function waitForPluginReady(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
timeoutMs = Number(process.env.E2E_OBSIDIAN_READY_TIMEOUT_MS ?? 20000)
|
||||
): Promise<PluginReadiness> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastOutput = "";
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const readiness = await evalObsidianJson<PluginReadiness>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>JSON.stringify({",
|
||||
"status:!!app.plugins.plugins['obsidian-livesync']?'ready':'pending',",
|
||||
"pluginId:'obsidian-livesync',",
|
||||
"pluginVersion:app.plugins.manifests['obsidian-livesync']?.version,",
|
||||
"vaultName:app.vault.getName()",
|
||||
"}))()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
if (readiness.status === "ready") {
|
||||
return readiness;
|
||||
}
|
||||
} catch (error) {
|
||||
lastOutput = error instanceof Error ? error.message : String(error);
|
||||
// Keep polling until Obsidian exposes the vault-side CLI and plug-in state.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(`Timed out waiting for Self-hosted LiveSync readiness through Obsidian CLI.\n${lastOutput}`);
|
||||
}
|
||||
export { waitForPluginReady, type PluginReadiness } from "@vrtmrz/obsidian-test-session";
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
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 { startObsidianPluginSession, type ObsidianPluginSession } from "@vrtmrz/obsidian-test-session";
|
||||
import type { TemporaryVault } from "./vault.ts";
|
||||
import { obsidianRemoteDebuggingPort, preseedTrustedVaultState, trustVaultIfPrompted } from "./ui.ts";
|
||||
|
||||
export type ObsidianLiveSyncSession = {
|
||||
app: ObsidianProcess;
|
||||
cliEnv: NodeJS.ProcessEnv;
|
||||
install: PluginInstallResult;
|
||||
readiness: PluginReadiness;
|
||||
};
|
||||
export type ObsidianLiveSyncSession = ObsidianPluginSession;
|
||||
|
||||
export type StartObsidianLiveSyncSessionOptions = {
|
||||
binary: string;
|
||||
@@ -19,101 +10,15 @@ export type StartObsidianLiveSyncSessionOptions = {
|
||||
startupGraceMs?: number;
|
||||
};
|
||||
|
||||
async function waitForPluginCatalogue(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_CLI_READY_TIMEOUT_MS ?? 60000);
|
||||
let lastOutput = "";
|
||||
while (Date.now() < deadline) {
|
||||
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));
|
||||
}
|
||||
throw new Error(`Timed out waiting for Obsidian plug-in catalogue through CLI.\n${lastOutput}`);
|
||||
}
|
||||
|
||||
async function enableCommunityPlugins(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
const result = await runObsidianCli(cliBinary, ["eval", "code=(async()=>app.plugins.setEnable(true))()"], env);
|
||||
if (result.code !== 0 || result.stdout.includes("Error:")) {
|
||||
throw new Error(
|
||||
[
|
||||
`Failed to enable Obsidian community plug-ins through CLI. code=${result.code}, signal=${result.signal}`,
|
||||
result.stdout ? `stdout:\n${result.stdout}` : undefined,
|
||||
result.stderr ? `stderr:\n${result.stderr}` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadLiveSyncPlugin(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
const reload = await runObsidianCli(cliBinary, ["plugin:reload", "id=obsidian-livesync"], env);
|
||||
if (reload.code !== 0 || !reload.stdout.includes("Reloaded: obsidian-livesync")) {
|
||||
throw new Error(
|
||||
[
|
||||
`Failed to reload Self-hosted LiveSync through Obsidian CLI. code=${reload.code}, signal=${reload.signal}`,
|
||||
reload.stdout ? `stdout:\n${reload.stdout}` : undefined,
|
||||
reload.stderr ? `stderr:\n${reload.stderr}` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startObsidianLiveSyncSession(
|
||||
options: StartObsidianLiveSyncSessionOptions
|
||||
): Promise<ObsidianLiveSyncSession> {
|
||||
const install = await installBuiltPlugin(options.vault.path);
|
||||
const remoteDebuggingPort = obsidianRemoteDebuggingPort();
|
||||
const app = await launchObsidian({
|
||||
return await startObsidianPluginSession({
|
||||
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,
|
||||
cliBinary: options.cliBinary,
|
||||
vault: options.vault,
|
||||
pluginId: "obsidian-livesync",
|
||||
artifactRoot: process.cwd(),
|
||||
startupGraceMs: options.startupGraceMs,
|
||||
});
|
||||
const cliEnv = {
|
||||
...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 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +1,11 @@
|
||||
import { chromium, type Page } from "playwright";
|
||||
import { withObsidianPage } from "@vrtmrz/obsidian-test-session";
|
||||
|
||||
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 {
|
||||
obsidianRemoteDebuggingPort,
|
||||
preseedTrustedVaultState,
|
||||
trustVaultIfPrompted,
|
||||
withObsidianPage,
|
||||
} from "@vrtmrz/obsidian-test-session";
|
||||
|
||||
export async function clickJsonResolveOption(port: number, mode: "AB" | "BA"): Promise<void> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
|
||||
@@ -1,94 +1,14 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
createTemporaryVault as createGenericTemporaryVault,
|
||||
type TemporaryVault,
|
||||
} from "@vrtmrz/obsidian-test-session";
|
||||
|
||||
export type TemporaryVault = {
|
||||
path: string;
|
||||
name: string;
|
||||
id: string;
|
||||
homePath: string;
|
||||
xdgConfigPath: string;
|
||||
xdgCachePath: string;
|
||||
xdgDataPath: string;
|
||||
userDataPath: string;
|
||||
dispose: () => Promise<void>;
|
||||
};
|
||||
export type { TemporaryVault };
|
||||
|
||||
export async function createTemporaryVault(prefix = "obsidian-livesync-e2e-"): Promise<TemporaryVault> {
|
||||
const vaultPath = await mkdtemp(join(tmpdir(), prefix));
|
||||
const statePath = await mkdtemp(join(tmpdir(), `${prefix}state-`));
|
||||
const name = vaultPath.split(/[\\/]/).pop() ?? "obsidian-livesync-e2e";
|
||||
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 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") {
|
||||
console.log(`Keeping temporary vault: ${vaultPath}`);
|
||||
console.log(`Keeping temporary Obsidian state: ${statePath}`);
|
||||
return;
|
||||
}
|
||||
await Promise.all([
|
||||
rm(vaultPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }),
|
||||
rm(statePath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }),
|
||||
]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function writeObsidianVaultRegistry(
|
||||
vaultId: string,
|
||||
vaultPath: string,
|
||||
vaultName: string,
|
||||
homePath: string,
|
||||
xdgConfigPath: string,
|
||||
userDataPath: string
|
||||
): Promise<void> {
|
||||
const vaultRecord = {
|
||||
path: vaultPath,
|
||||
ts: Date.now(),
|
||||
open: true,
|
||||
name: vaultName,
|
||||
};
|
||||
const registry = {
|
||||
cli: true,
|
||||
vaults: {
|
||||
[vaultId]: vaultRecord,
|
||||
},
|
||||
};
|
||||
const registryText = JSON.stringify(registry, null, 4);
|
||||
for (const configRoot of [join(homePath, ".config"), xdgConfigPath]) {
|
||||
const obsidianConfigDir = join(configRoot, "obsidian");
|
||||
await mkdir(obsidianConfigDir, { recursive: true });
|
||||
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));
|
||||
return await createGenericTemporaryVault({
|
||||
prefix,
|
||||
pluginIds: ["obsidian-livesync"],
|
||||
idPrefix: "livesync-e2e",
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user