Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion scripts/obsidian-e2e-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import {
waitForInstanceReady,
} from "./start-obsidian-e2e-instance.mjs";

/** @typedef {import("./obsidian-e2e-types").InstanceOptions} InstanceOptions */
/** @typedef {import("./obsidian-e2e-types").WrapperArgs} WrapperArgs */

const VALUE_OPTIONS = new Set([
"--vault",
"--root",
Expand Down Expand Up @@ -47,8 +50,14 @@ Instance options:
`);
}

/**
* @param {readonly string[]} argv
* @returns {WrapperArgs}
*/
export function parseArgs(argv) {
/** @type {string[]} */
const instanceArgs = [];
/** @type {string[]} */
const commandArgs = [];

for (let index = 0; index < argv.length; index += 1) {
Expand Down Expand Up @@ -92,21 +101,29 @@ export function parseArgs(argv) {
};
}

/**
* @param {Pick<InstanceOptions, "obsidianHome">} options
* @returns {NodeJS.ProcessEnv}
*/
export function obsidianEnv(options) {
return {
...process.env,
HOME: options.obsidianHome,
};
}

/**
* @param {Pick<InstanceOptions, "vaultName">} options
* @param {readonly string[]} commandArgs
*/
export function obsidianCommandArgs(options, commandArgs) {
return [`vault=${options.vaultName}`, ...commandArgs];
}

/** @param {InstanceOptions} options */
export async function ensureObsidianInstance(options) {
const provisionResult = await provisionVault(options);
const profileResult = await prepareObsidianProfile(options);
options.userDataPath = profileResult.userDataPath;

const reused = await isInstanceReady(options);
if (reused) {
Expand All @@ -130,6 +147,11 @@ export async function ensureObsidianInstance(options) {
};
}

/**
* @param {InstanceOptions} options
* @param {readonly string[]} commandArgs
* @returns {Promise<number>}
*/
function spawnObsidian(options, commandArgs) {
return new Promise((resolve) => {
const child = spawn(options.obsidianBin, obsidianCommandArgs(options, commandArgs), {
Expand Down
40 changes: 40 additions & 0 deletions scripts/obsidian-e2e-errors.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* @param {unknown} error
* @returns {error is { code: unknown }}
*/
function hasErrorCode(error) {
return typeof error === "object" && error !== null && "code" in error;
}

/**
* @param {unknown} error
* @param {string} code
*/
export function errorHasCode(error, code) {
return hasErrorCode(error) && error.code === code;
}

/**
* @param {unknown} value
* @returns {value is Record<string, unknown>}
*/
export function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

/**
* Extract the useful output attached to a rejected child-process command.
*
* @param {unknown} error
*/
export function commandErrorMessage(error) {
if (typeof error === "object" && error !== null) {
if ("stderr" in error && typeof error.stderr === "string" && error.stderr.trim()) {
return error.stderr.trim();
}
if ("stdout" in error && typeof error.stdout === "string" && error.stdout.trim()) {
return error.stdout.trim();
}
}
return error instanceof Error ? error.message : String(error);
}
130 changes: 130 additions & 0 deletions scripts/obsidian-e2e-types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import type { Dirent } from "node:fs";

export interface ProvisionRawOptions {
data?: string;
force?: boolean;
help?: boolean;
json?: boolean;
printEnv?: boolean;
root?: string;
vault?: string;
worktree?: string;
}

export interface ProvisionOptions {
dataPath: string | undefined;
force: boolean;
json: boolean;
printEnv: boolean;
rootPath: string;
vaultName: string;
vaultPath: string;
worktreePath: string;
}

export interface ProvisionResult {
pluginPath: string;
vaultName: string;
vaultPath: string;
worktreePath: string;
}

export interface InstanceRawOptions extends ProvisionRawOptions {
launch?: boolean;
obsidianApp?: string;
obsidianBin?: string;
profileRoot?: string;
}

export interface InstanceOptions extends ProvisionOptions {
instanceId: string;
instancePath: string;
launch: boolean;
obsidianApp: string;
obsidianBin: string;
obsidianHome: string;
profileRoot: string;
userDataPath: string;
}

export interface ProfileResult {
obsidianJsonPath: string;
userDataPath: string;
vaultId: string;
}

export interface LaunchResult {
pid: null;
pidPath: null;
}

export interface InstanceShellResult {
obsidianBin?: string;
obsidianHome: string;
vaultName: string;
vaultPath: string;
}

export interface WrapperArgs {
commandArgs: string[];
help: boolean;
instanceArgs: string[];
}

export interface ProcessInfo {
command: string;
pid: number;
ppid: number;
}

export interface CollectInstanceOptions {
selfPid?: number;
}

export type KillFunction = (pid: number, signal: NodeJS.Signals | 0) => void;
export type RunPsFunction = () => Promise<string>;
export type RemoveDirFunction = (dir: string) => Promise<void>;
export type ReadFileFunction = (file: string) => Promise<string>;
export type ExistsFunction = (target: string) => Promise<boolean>;

export interface StopOptions {
dryRun?: boolean;
graceMs?: number;
kill?: KillFunction;
pollMs?: number;
profileRoot?: string;
removeDir?: RemoveDirFunction;
runPs?: RunPsFunction;
selfPid?: number;
}

export interface StopResult {
instancePath: string;
killed: number[];
pids: number[];
removed: boolean;
terminated: number[];
}

export interface InstanceReadDependencies {
exists?: ExistsFunction;
readFile?: ReadFileFunction;
}

export interface ReapOptions extends StopOptions, InstanceReadDependencies {
exceptInstancePath?: string;
log?: (message: string) => void;
profileRoot?: string;
readdir?: (dir: string) => Promise<Dirent[]>;
}

export interface ReapResult {
reaped: string[];
scanned: number;
}

export interface StopRawOptions extends InstanceRawOptions {
dryRun: boolean;
json: boolean;
prune: boolean;
}
54 changes: 52 additions & 2 deletions scripts/provision-obsidian-e2e-vault.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
import fs from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { errorHasCode } from "./obsidian-e2e-errors.mjs";

/** @typedef {import("./obsidian-e2e-types").ProvisionOptions} ProvisionOptions */
/** @typedef {import("./obsidian-e2e-types").ProvisionRawOptions} ProvisionRawOptions */
/** @typedef {import("./obsidian-e2e-types").ProvisionResult} ProvisionResult */

// PodNotes bundles its CSS into main.js (svelte compilerOptions css: "injected"),
// so there is no styles.css artifact to symlink — only the manifest and bundle.
Expand Down Expand Up @@ -115,6 +120,7 @@ export const DEFAULT_PODNOTES_DATA = {
},
};

/** @param {string} value */
function slugify(value) {
return (
value
Expand All @@ -140,7 +146,12 @@ Options:
`);
}

/**
* @param {string[]} argv
* @returns {ProvisionRawOptions}
*/
export function parseArgs(argv) {
/** @type {ProvisionRawOptions} */
const options = {
force: false,
json: false,
Expand Down Expand Up @@ -184,10 +195,28 @@ export function parseArgs(argv) {
return options;
}

/**
* @param {"--vault" | "--root" | "--worktree" | "--data"} arg
* @returns {"vault" | "root" | "worktree" | "data"}
*/
function toOptionKey(arg) {
return arg.slice(2);
switch (arg) {
case "--vault":
return "vault";
case "--root":
return "root";
case "--worktree":
return "worktree";
case "--data":
return "data";
}
}

/**
* @param {ProvisionRawOptions} rawOptions
* @param {string} [cwd]
* @returns {ProvisionOptions}
*/
export function resolveProvisionOptions(rawOptions, cwd = process.cwd()) {
const worktreePath = path.resolve(cwd, rawOptions.worktree ?? ".");
const vaultName =
Expand Down Expand Up @@ -215,16 +244,18 @@ export function resolveProvisionOptions(rawOptions, cwd = process.cwd()) {
};
}

/** @param {string} filePath */
async function pathExists(filePath) {
try {
await fs.lstat(filePath);
return true;
} catch (error) {
if (error?.code === "ENOENT") return false;
if (errorHasCode(error, "ENOENT")) return false;
throw error;
}
}

/** @param {string} worktreePath */
async function assertRequiredPluginFiles(worktreePath) {
const missing = [];
for (const fileName of REQUIRED_PLUGIN_FILES) {
Expand All @@ -242,17 +273,30 @@ async function assertRequiredPluginFiles(worktreePath) {
}
}

/**
* @param {string} filePath
* @param {unknown} value
*/
async function writeJson(filePath, value) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(`${filePath}.tmp`, `${JSON.stringify(value, null, "\t")}\n`);
await fs.rename(`${filePath}.tmp`, filePath);
}

/**
* @param {string} filePath
* @param {unknown} value
*/
async function writeJsonIfMissing(filePath, value) {
if (await pathExists(filePath)) return;
await writeJson(filePath, value);
}

/**
* @param {string} sourcePath
* @param {string} destinationPath
* @param {boolean} force
*/
async function linkPluginFile(sourcePath, destinationPath, force) {
const existing = await pathExists(destinationPath);
if (existing && force) {
Expand All @@ -276,6 +320,10 @@ async function linkPluginFile(sourcePath, destinationPath, force) {
await fs.symlink(sourcePath, destinationPath);
}

/**
* @param {ProvisionOptions} options
* @returns {Promise<ProvisionResult>}
*/
export async function provisionVault(options) {
await assertRequiredPluginFiles(options.worktreePath);

Expand Down Expand Up @@ -316,13 +364,15 @@ export async function provisionVault(options) {
};
}

/** @param {Pick<ProvisionResult, "vaultName" | "vaultPath">} result */
export function toShellExports(result) {
return [
`export PODNOTES_E2E_VAULT=${shellQuote(result.vaultName)}`,
`export PODNOTES_E2E_VAULT_PATH=${shellQuote(result.vaultPath)}`,
].join("\n");
}

/** @param {unknown} value */
function shellQuote(value) {
return `'${String(value).replaceAll("'", "'\\''")}'`;
}
Expand Down
Loading