diff --git a/packages/obsidian-test-session/README.md b/packages/obsidian-test-session/README.md index c12beba..0dee61a 100644 --- a/packages/obsidian-test-session/README.md +++ b/packages/obsidian-test-session/README.md @@ -30,7 +30,7 @@ import { - a local Obsidian executable and the matching `obsidian-cli`; and - a local test environment that can launch Electron and connect to its remote-debugging port. -Executable discovery is implemented for Linux, macOS, and Windows. This project exercises complete real-Obsidian sessions on Linux and macOS; Windows discovery exists but the end-to-end workflow remains unverified. Automated AppImage download and optional `xvfb-run` wrapping are Linux-specific. +Executable discovery is implemented for Linux, macOS, and Windows. On Windows it checks the official per-user installation below `LOCALAPPDATA`, the all-user installation directories, and then `PATH`, including the `Obsidian.com` terminal redirector. The reviewed complete real-Obsidian session matrix covers Linux and macOS. Windows executable discovery has independent Windows coverage. Automated AppImage download and optional `xvfb-run` wrapping are Linux-specific. Managed Linux sessions default to the reviewed Obsidian 1.13.6 AppImage. The package keeps an immutable catalogue of reviewed asset names and SHA-256 digests, installs each version and architecture separately, and never downloads during executable discovery or session start-up. An exact version outside that catalogue is rejected unless the caller deliberately enables an unverified regression probe. A passing unverified probe does not establish supported-version status. diff --git a/packages/obsidian-test-session/src/environment.test.ts b/packages/obsidian-test-session/src/environment.test.ts index 4ce7457..3794836 100644 --- a/packages/obsidian-test-session/src/environment.test.ts +++ b/packages/obsidian-test-session/src/environment.test.ts @@ -1,5 +1,7 @@ import { platform } from "node:process"; -import { resolve } from "node:path"; +import { join, resolve } from "node:path"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { describe, expect, it } from "vitest"; import { DEFAULT_VALIDATED_OBSIDIAN_VERSION, @@ -25,6 +27,60 @@ describe("Obsidian executable discovery", () => { expect(result.checked).toContain("/usr/local/bin/obsidian"); }); + it.runIf(platform === "win32")( + "finds the official per-user Windows executable and terminal redirector outside PATH", + async () => { + const localAppData = await mkdtemp(join(tmpdir(), "obsidian-discovery-")); + const installDirectory = join(localAppData, "Programs", "Obsidian"); + await mkdir(installDirectory, { recursive: true }); + await Promise.all([ + writeFile(join(installDirectory, "Obsidian.exe"), "fixture"), + writeFile(join(installDirectory, "Obsidian.com"), "fixture"), + ]); + try { + const env = { LOCALAPPDATA: localAppData, Path: "" }; + + expect(discoverObsidianBinary(env).binary).toBe( + join(installDirectory, "Obsidian.exe"), + ); + expect(discoverObsidianCli(env).binary).toBe( + join(installDirectory, "Obsidian.com"), + ); + } finally { + await rm(localAppData, { recursive: true, force: true }); + } + }, + ); + + it.runIf(platform === "win32")( + "checks every standard Windows location before PATH candidates", + () => { + const localAppData = join(tmpdir(), "missing-obsidian-local-app-data"); + const programFiles = "C:\\Missing Program Files"; + const programFilesX86 = "C:\\Missing Program Files (x86)"; + const pathEntry = "C:\\Missing Path Entry"; + const result = discoverObsidianCli({ + LOCALAPPDATA: localAppData, + ProgramFiles: programFiles, + "ProgramFiles(x86)": programFilesX86, + Path: pathEntry, + }); + + expect(result.binary).toBeUndefined(); + expect(result.checked).toEqual( + [ + join(localAppData, "Programs", "Obsidian"), + join(programFiles, "Obsidian"), + join(programFilesX86, "Obsidian"), + pathEntry, + ].flatMap((directory) => [ + join(directory, "Obsidian.com"), + join(directory, "obsidian-cli.exe"), + ]), + ); + }, + ); + it.runIf(platform === "linux")( "selects the versioned managed AppImage by default", () => { diff --git a/packages/obsidian-test-session/src/environment.ts b/packages/obsidian-test-session/src/environment.ts index e7442ca..b6f41ed 100644 --- a/packages/obsidian-test-session/src/environment.ts +++ b/packages/obsidian-test-session/src/environment.ts @@ -1,5 +1,5 @@ import { accessSync, constants, existsSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { delimiter, join, resolve } from "node:path"; import { platform } from "node:process"; import { allowUnverifiedObsidianVersion, @@ -42,10 +42,6 @@ const defaultCandidatesByPlatform: Partial< "/opt/obsidian/obsidian", "/app/bin/obsidian", ], - win32: [ - "C:\\Program Files\\Obsidian\\Obsidian.exe", - "C:\\Program Files (x86)\\Obsidian\\Obsidian.exe", - ], }; const defaultCliCandidatesByPlatform: Partial< @@ -63,12 +59,54 @@ const defaultCliCandidatesByPlatform: Partial< "/opt/Obsidian/obsidian-cli", "/opt/obsidian/obsidian-cli", ], - win32: [ - "C:\\Program Files\\Obsidian\\obsidian-cli.exe", - "C:\\Program Files (x86)\\Obsidian\\obsidian-cli.exe", - ], }; +function windowsInstallRoots(env: NodeJS.ProcessEnv): string[] { + const programFiles64 = env.ProgramW6432?.trim(); + const programFilesX86 = env["ProgramFiles(x86)"]?.trim(); + const roots = [ + env.LOCALAPPDATA?.trim() + ? join(env.LOCALAPPDATA, "Programs", "Obsidian") + : undefined, + programFiles64 ? join(programFiles64, "Obsidian") : undefined, + env.ProgramFiles?.trim() + ? join(env.ProgramFiles, "Obsidian") + : "C:\\Program Files\\Obsidian", + programFilesX86 + ? join(programFilesX86, "Obsidian") + : "C:\\Program Files (x86)\\Obsidian", + ]; + return [...new Set(roots.filter((root): root is string => Boolean(root)))]; +} + +function pathDirectories(env: NodeJS.ProcessEnv): string[] { + return (env.Path ?? env.PATH ?? "") + .split(delimiter) + .map((entry) => entry.trim().replace(/^"|"$/gu, "")) + .filter(Boolean); +} + +function defaultBinaryCandidates(env: NodeJS.ProcessEnv): string[] { + if (platform !== "win32") + return [...(defaultCandidatesByPlatform[platform] ?? [])]; + return [ + ...windowsInstallRoots(env).map((directory) => + join(directory, "Obsidian.exe"), + ), + ...pathDirectories(env).map((directory) => join(directory, "Obsidian.exe")), + ]; +} + +function defaultCliCandidates(env: NodeJS.ProcessEnv): string[] { + if (platform !== "win32") + return [...(defaultCliCandidatesByPlatform[platform] ?? [])]; + const roots = [...windowsInstallRoots(env), ...pathDirectories(env)]; + return roots.flatMap((directory) => [ + join(directory, "Obsidian.com"), + join(directory, "obsidian-cli.exe"), + ]); +} + interface ManagedObsidianTarget extends ObsidianVersionSelection { installDirectory: string; } @@ -169,7 +207,7 @@ export function discoverObsidianBinary( const managedTarget = managedObsidianTarget(env); if (managedTarget !== undefined) return discover(undefined, managedBinaryCandidates(managedTarget)); - return discover(undefined, defaultCandidatesByPlatform[platform] ?? []); + return discover(undefined, defaultBinaryCandidates(env)); } /** @@ -220,7 +258,7 @@ export function discoverObsidianCli( const managedTarget = managedObsidianTarget(env); if (managedTarget !== undefined) return discover(undefined, managedCliCandidates(managedTarget)); - return discover(undefined, defaultCliCandidatesByPlatform[platform] ?? []); + return discover(undefined, defaultCliCandidates(env)); } /** diff --git a/packages/obsidian-test-session/src/environment.windows.test.ts b/packages/obsidian-test-session/src/environment.windows.test.ts new file mode 100644 index 0000000..8ba3d03 --- /dev/null +++ b/packages/obsidian-test-session/src/environment.windows.test.ts @@ -0,0 +1,78 @@ +import { win32 } from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const files = vi.hoisted(() => new Set()); + +vi.mock("node:process", async (importOriginal) => ({ + ...(await importOriginal()), + platform: "win32", +})); + +vi.mock("node:path", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + join: actual.win32.join, + resolve: actual.win32.resolve, + delimiter: actual.win32.delimiter, + }; +}); + +vi.mock("node:fs", async (importOriginal) => ({ + ...(await importOriginal()), + existsSync: (path: string) => files.has(path.toLowerCase()), +})); + +import { discoverObsidianBinary, discoverObsidianCli } from "./environment.js"; + +const nativeDirectory = "C:\\Program Files\\Obsidian"; +const env = { + LOCALAPPDATA: "C:\\Users\\test\\AppData\\Local", + ProgramFiles: "C:\\Program Files (x86)", + "ProgramFiles(x86)": "C:\\Program Files (x86)", + ProgramW6432: "C:\\Program Files", + Path: "", +}; + +beforeEach(() => files.clear()); + +function addFile(directory: string, filename: string): string { + const path = win32.join(directory, filename); + files.add(path.toLowerCase()); + return path; +} + +describe("Windows executable discovery", () => { + it("finds native all-user executables from a 64-bit environment", () => { + const binary = addFile(nativeDirectory, "Obsidian.exe"); + const cli = addFile(nativeDirectory, "obsidian-cli.exe"); + const nativeEnv = { ...env, ProgramFiles: env.ProgramW6432 }; + + expect(discoverObsidianBinary(nativeEnv).binary).toBe(binary); + expect(discoverObsidianCli(nativeEnv).binary).toBe(cli); + }); + + it.each([ + ["Obsidian.exe", discoverObsidianBinary], + ["Obsidian.com", discoverObsidianCli], + ["obsidian-cli.exe", discoverObsidianCli], + ] as const)( + "finds native all-user %s from a WOW64 environment without PATH registration", + (filename, discover) => { + const path = addFile(nativeDirectory, filename); + + expect(discover(env).binary).toBe(path); + }, + ); + + it("prefers per-user executables over native all-user executables", () => { + const perUserDirectory = win32.join(env.LOCALAPPDATA, "Programs", "Obsidian"); + const binary = addFile(perUserDirectory, "Obsidian.exe"); + const cli = addFile(perUserDirectory, "Obsidian.com"); + addFile(nativeDirectory, "Obsidian.exe"); + addFile(nativeDirectory, "Obsidian.com"); + + expect(discoverObsidianBinary(env).binary).toBe(binary); + expect(discoverObsidianCli(env).binary).toBe(cli); + }); +}); diff --git a/packages/obsidian-test-session/src/vault.test.ts b/packages/obsidian-test-session/src/vault.test.ts index 0f344e9..df50038 100644 --- a/packages/obsidian-test-session/src/vault.test.ts +++ b/packages/obsidian-test-session/src/vault.test.ts @@ -1,11 +1,20 @@ import { existsSync } from "node:fs"; import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, relative, sep } from "node:path"; import { describe, expect, it } from "vitest"; import { createTemporaryVault } from "./vault.js"; describe("createTemporaryVault", () => { + function isBelow(parentPath: string, childPath: string): boolean { + const pathFromParent = relative(parentPath, childPath); + return ( + pathFromParent.length > 0 && + pathFromParent !== ".." && + !pathFromParent.startsWith(`..${sep}`) + ); + } + it("creates isolated profile state and removes it on disposal", async () => { const previousKeep = process.env.E2E_OBSIDIAN_KEEP_VAULT; delete process.env.E2E_OBSIDIAN_KEEP_VAULT; @@ -55,8 +64,8 @@ describe("createTemporaryVault", () => { }; const vault = await createTemporaryVault(options); try { - expect(vault.path.startsWith(`${temporaryRoot}/`)).toBe(true); - expect(vault.statePath.startsWith(`${temporaryRoot}/`)).toBe(true); + expect(isBelow(temporaryRoot, vault.path)).toBe(true); + expect(isBelow(temporaryRoot, vault.statePath)).toBe(true); } finally { await vault.dispose(); await rm(temporaryRoot, { recursive: true, force: true }); diff --git a/packages/obsidian-test-session/updates.md b/packages/obsidian-test-session/updates.md index be2e083..2855368 100644 --- a/packages/obsidian-test-session/updates.md +++ b/packages/obsidian-test-session/updates.md @@ -10,6 +10,7 @@ ### Fixes - Selected the active Vault renderer when Obsidian exposes a Settings pop-out first, and recognised the current mobile Modal header control when checking safe-area and touch-target behaviour. +- Discovered official per-user and all-user Windows installations beyond `PATH`, including the `Obsidian.com` terminal redirector. ### Documentation