From 03f401aaa704ee5b5850fbe2ccfebed0d0522e75 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Tue, 11 Aug 2026 03:32:16 +0000 Subject: [PATCH] Add validated Obsidian E2E version targets --- CONTRIBUTING.md | 4 +- README.md | 4 +- docs/proven-in-use.md | 2 + package.json | 5 +- packages/obsidian-test-session/README.md | 5 +- .../obsidian-test-session/docs/usage-guide.md | 30 +- .../src/appimage-release.ts | 366 ++++++++++++++++++ .../src/appimage.test.ts | 176 ++++++++- .../obsidian-test-session/src/appimage.ts | 309 ++++++++++++--- .../src/environment.test.ts | 54 +++ .../obsidian-test-session/src/environment.ts | 110 +++++- .../obsidian-test-session/src/session.test.ts | 70 +++- packages/obsidian-test-session/src/session.ts | 49 ++- packages/obsidian-test-session/src/ui.test.ts | 70 ++++ packages/obsidian-test-session/src/ui.ts | 88 ++++- packages/obsidian-test-session/updates.md | 13 + test/e2e-obsidian/README.md | 27 +- test/e2e-obsidian/runner/harness.ts | 2 + test/e2e-obsidian/scripts/install-appimage.ts | 13 + test/e2e-obsidian/scripts/local-suite.ts | 38 ++ test/e2e-obsidian/scripts/mobile.ts | 4 +- test/e2e-obsidian/scripts/profile-restart.ts | 2 + .../e2e-obsidian/scripts/session-lifecycle.ts | 2 + test/packed-consumer/test-session-usage.ts | 29 ++ 24 files changed, 1372 insertions(+), 100 deletions(-) create mode 100644 packages/obsidian-test-session/src/appimage-release.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b8f7e285..a315c3a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,9 +36,11 @@ The workspace root pins `@emnapi/core` and `@emnapi/runtime` because npm can oth When a local Obsidian executable and CLI are available, also run: ```bash -npm run test:e2e:obsidian:local-suite +npm run test:e2e:obsidian:validated ``` +The validated suite uses the reviewed Obsidian release catalogue and rejects the unverified-version override. A passing run with `E2E_OBSIDIAN_ALLOW_UNVERIFIED_VERSION=true` is useful for an early regression probe, but it is not release-qualification or supported-version evidence. + ## UI automation Follow [the UI automation guide](packages/obsidian-plugin-kit/docs/ui-automation.md). Scripted responses must remain instance-scoped and must not be enabled through production settings, URI parameters, or other external input. diff --git a/README.md b/README.md index b2739166..679329a2 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,11 @@ Real Obsidian E2E remains a local-only suite and is not a default CI gate: ```bash npm run test:e2e:obsidian:install-appimage -npm run test:e2e:obsidian:local-suite +npm run test:e2e:obsidian:validated ``` +The managed Linux workflow defaults to the reviewed Obsidian 1.13.6 assets, verifies their SHA-256 digests, and keeps each version and architecture in a separate directory. `E2E_OBSIDIAN_VERSION` selects another reviewed version. An exact version outside the reviewed catalogue requires `E2E_OBSIDIAN_ALLOW_UNVERIFIED_VERSION=true` and is reported only as an unverified regression probe; it is deliberately rejected by `test:e2e:obsidian:validated`. + ## Installation Install only the packages that a project needs. The commands below use npm's normal compatible version ranges. For the current `0.x` versions, those ranges accept patch releases but not the next minor release. Commit the lockfile for repeatable installations; add `--save-exact` when every dependency upgrade must be reviewed explicitly, including release qualification: diff --git a/docs/proven-in-use.md b/docs/proven-in-use.md index 4fb1feaf..a090472a 100644 --- a/docs/proven-in-use.md +++ b/docs/proven-in-use.md @@ -13,6 +13,8 @@ The projects use four complementary levels of verification: 3. Consumer tests exercise application policy through narrow injected capabilities rather than reproducing Obsidian or browser behaviour. 4. Real-Obsidian E2E and guided device review cover the rendering, process, Electron, Vault, and mobile boundaries which mocks deliberately do not own. +Repository-owned real-Obsidian runs record the API version observed from the active Vault renderer. Release-qualification runs accept only versions and AppImage digests in the reviewed catalogue. An explicitly enabled run against another exact public release remains an unverified regression probe, even when every scenario passes. + ## TagFolder [TagFolder](https://github.com/vrtmrz/obsidian-tagfolder) uses the Obsidian UI and Vault adapters at its plug-in composition root. Its [new-note workflow](https://github.com/vrtmrz/obsidian-tagfolder/blob/main/new-note-workflow.ts) narrows those adapters to the selection, text, and frontmatter methods required by that operation. The corresponding [App-free tests](https://github.com/vrtmrz/obsidian-tagfolder/blob/main/tests/new-note-workflow.test.ts) script template selection, inspect Vault transcripts, inject write failures, and verify rollback without constructing an Obsidian `App`. diff --git a/package.json b/package.json index 2d370e99..b404e8d3 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "release:prepare:harness": "npm run check:harness && npm run build:harness && node scripts/prepare-harness-release.mjs", "verify:packed-consumer": "node scripts/verify-packed-consumer.mjs", "verify:workspace": "npm run check:workspace && npm run test:workspace && npm run build:workspace && npm run build:harness && npm run pack:workspace && npm run verify:packed-consumer", - "test:e2e:obsidian:install-appimage": "tsx test/e2e-obsidian/scripts/install-appimage.ts", + "test:e2e:obsidian:install-appimage": "npm run build --workspace @vrtmrz/obsidian-test-session && tsx test/e2e-obsidian/scripts/install-appimage.ts", "test:e2e:obsidian:smoke": "npm run build:harness && tsx test/e2e-obsidian/scripts/smoke.ts", "test:e2e:obsidian:session-lifecycle": "npm run build --workspace @vrtmrz/obsidian-test-session && tsx test/e2e-obsidian/scripts/session-lifecycle.ts", "test:e2e:obsidian:profile-restart": "npm run build:harness && tsx test/e2e-obsidian/scripts/profile-restart.ts", @@ -50,7 +50,8 @@ "test:e2e:obsidian:contracts": "npm run build:harness && tsx test/e2e-obsidian/scripts/contracts.ts", "test:e2e:obsidian:progress": "npm run build:harness && tsx test/e2e-obsidian/scripts/progress.ts", "test:e2e:obsidian:mobile": "npm run build:harness && tsx test/e2e-obsidian/scripts/mobile.ts", - "test:e2e:obsidian:local-suite": "tsx test/e2e-obsidian/scripts/local-suite.ts" + "test:e2e:obsidian:local-suite": "npm run build --workspace @vrtmrz/obsidian-test-session && tsx test/e2e-obsidian/scripts/local-suite.ts", + "test:e2e:obsidian:validated": "npm run build --workspace @vrtmrz/obsidian-test-session && tsx test/e2e-obsidian/scripts/local-suite.ts --validated" }, "devDependencies": { "@emnapi/core": "1.11.2", diff --git a/packages/obsidian-test-session/README.md b/packages/obsidian-test-session/README.md index 0f669afd..c12bebac 100644 --- a/packages/obsidian-test-session/README.md +++ b/packages/obsidian-test-session/README.md @@ -32,6 +32,8 @@ import { 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. +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. + On macOS, isolated sessions use a socket-safe root below `/tmp` and Chromium's test-only mock keychain so the CLI socket and system keychain dialogue do not block start-up. See the usage guide before replacing the complete default launch arguments. Set `OBSIDIAN_BINARY` and `OBSIDIAN_CLI` when the executables are outside the built-in discovery paths. Importing the package has no side effects. AppImage download, Vault creation, artefact installation, process launch, and cleanup occur only through explicit calls. @@ -62,6 +64,7 @@ try { pluginId: "example-plugin", artifactRoot: "dist/example-plugin", pluginData: { mode: "automation" }, + versionPolicy: { expectedVersion: "1.13.6" }, localStorageEntries: { "example-plugin-device-schema": "3", }, @@ -82,7 +85,7 @@ try { } ``` -The high-level session installs `main.js`, `manifest.json`, and optional `styles.css`, writes `pluginData` as `data.json` when supplied, launches an isolated Obsidian profile, opens the exact Vault, and waits for renderer readiness. Instance-scoped lifecycle callbacks can run before and after launch, immediately before the selected plug-in starts, after it loads, and after readiness. +The high-level session installs `main.js`, `manifest.json`, and optional `styles.css`, writes `pluginData` as `data.json` when supplied, launches an isolated Obsidian profile, opens the exact Vault, and waits for renderer readiness. An optional `versionPolicy` compares the expected version with the active renderer and labels the observed version as validated or unverified. Instance-scoped lifecycle callbacks can run before and after launch, immediately before the selected plug-in starts, after it loads, and after readiness. Supplying `localStorageEntries` or `lifecycle.beforePluginStart` selects controlled start-up by default. The selected plug-in is then excluded from Obsidian's start-up list; work required before its first load completes, and the session enables it, saves its enabled state, and loads it exactly once. Sessions without work that must precede the plug-in's first load retain natural Obsidian loading by default; set `pluginStartup` explicitly when the distinction is part of the scenario. A failed bootstrap stops the launched process. After a successful start, the caller owns `session.app.stop()` and `vault.dispose()`. diff --git a/packages/obsidian-test-session/docs/usage-guide.md b/packages/obsidian-test-session/docs/usage-guide.md index 7edf3db5..5e893534 100644 --- a/packages/obsidian-test-session/docs/usage-guide.md +++ b/packages/obsidian-test-session/docs/usage-guide.md @@ -9,7 +9,7 @@ All public APIs are exported from `@vrtmrz/obsidian-test-session`: | Area | Principal APIs | Responsibility | | ----------------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | Executable discovery | `discoverObsidianBinary`, `requireObsidianBinary`, `discoverObsidianCli`, `requireObsidianCli` | Resolve explicit environment overrides or known platform paths. | -| Linux AppImage preparation | `obsidianAppImageArchitecture`, `obsidianAppImageUrl`, `installObsidianAppImage` | Select, download, and optionally extract an Obsidian AppImage through an explicit call. | +| Linux AppImage preparation | `VALIDATED_OBSIDIAN_RELEASES`, `resolveObsidianAppImageRelease`, `installObsidianAppImage` | Select, verify, download, and optionally extract an Obsidian AppImage through an explicit call. | | Isolated state | `createTemporaryVault` | Create a temporary Vault, isolated profile directories, Vault registry, process marker, and disposal operation. | | Artefact installation | `installBuiltPlugin` | Copy the required built plug-in artefacts and optional data into one Vault. | | Process lifecycle | `cleanupStaleObsidianE2EProcesses`, `launchObsidian` | Launch and stop an isolated Obsidian process tree, with optional Linux `xvfb-run`. | @@ -55,6 +55,7 @@ export async function startExampleTestSession(): Promise { pluginId: "example-plugin", artifactRoot: resolve("dist/example-plugin"), pluginData: { schemaVersion: 1, mode: "automation" }, + versionPolicy: { expectedVersion: "1.13.6" }, localStorageEntries: { "example-plugin-device-schema": "3", }, @@ -107,10 +108,10 @@ An error from a running-process callback aborts bootstrap and stops the launched 6. if CLI delivery fails, continue only when CDP confirms that the active renderer opened the exact isolated Vault path; 7. handle generic trust prompts, wait for the installed manifest, and run `beforePluginStart`; 8. either enable, persist, and load the controlled plug-in once, or preserve/load the natural plug-in without an explicit restart, then run `afterPluginLoad`; -9. await generic readiness and wait for the start-up overlay to stop blocking interaction unless `waitForUiIdle` is `false`; and +9. await generic readiness, apply any expected-version and reviewed-catalogue policy, and wait for the start-up overlay to stop blocking interaction unless `waitForUiIdle` is `false`; and 10. run `afterReady`. -The result contains the process handle, remote-debugging port, isolated CLI environment, installed artefact details, plug-in identifier, and renderer-observed readiness. If a bootstrap phase fails after launch, the function stops the process and adds captured Obsidian output to the error. The caller must dispose both process and Vault after a successful return. +The result contains the process handle, remote-debugging port, isolated CLI environment, installed artefact details, plug-in identifier, and renderer-observed readiness. Readiness includes the observed Obsidian API version. When `versionPolicy` is supplied, the session rejects an expected-version mismatch and a version outside the reviewed catalogue unless `allowUnverifiedVersion` is `true`; accepted readiness then records `obsidianVersionSupport` as `validated` or `unverified`. If a bootstrap phase fails after launch, the function stops the process and adds captured Obsidian output to the error. The caller must dispose both process and Vault after a successful return. For a successful session, `session.app.stop()` first closes the active renderer pages so Chromium can persist profile-backed state, including local storage. It then terminates any remaining process tree as a fallback. A consumer can therefore stop a session and start another one with the same `TemporaryVault` to exercise an application restart without reconstructing device-local state. The Vault and profile directories must remain present until the final session has stopped. @@ -160,20 +161,24 @@ It then writes the selected plug-in identifier to `community-plugins.json`. Set import { installObsidianAppImage } from "@vrtmrz/obsidian-test-session"; const prepared = await installObsidianAppImage({ - version: "1.12.7", + version: "1.13.6", architecture: "x86_64", }); process.env.OBSIDIAN_BINARY = prepared.extractedBinary; ``` -`installObsidianAppImage` is Linux-oriented and performs network and filesystem writes. It defaults to the package's currently tested Obsidian release, derives the official release URL unless one is supplied, reuses an existing download by default, and extracts unless `extract` is `false`. Pin `version` when a consumer needs repeatability. +`installObsidianAppImage` is Linux-oriented and performs network and filesystem writes. It defaults to the reviewed Obsidian 1.13.6 release. Reviewed releases resolve through `VALIDATED_OBSIDIAN_RELEASES`, which fixes the exact tag, architecture-specific asset name, and SHA-256 digest. The installer verifies that digest before replacing the final AppImage, then records the source, observed digest, review status, and extraction state in `release.json` below `///`. It reuses a download or extraction only when the digest and release record match. + +`version` must be an exact stable version such as `1.13.6`; aliases such as `latest`, partial versions, and pre-release labels are rejected. A version outside the catalogue is also rejected by default. Set `allowUnverifiedVersion: true` only for a deliberately labelled regression probe. The installer then looks up the exact public GitHub Release and architecture-specific AppImage, verifies its published digest when one is available, and records the observed digest. A successful probe does not add the version to the reviewed catalogue or establish support. + +The `url` option overrides only the acquisition URI and asset basename. A reviewed release still has to match its catalogue digest. An unverified URL override has no trusted digest, so a later run reuses it only when its `release.json` and observed digest still match. `extract` defaults to `true`; `forceDownload` replaces the selected asset and extraction. Pin `version` whenever repeatability matters. Importing the package, discovering executables, and starting a session with an existing binary never call this function automatically. ## Inspect the active renderer -`withObsidianPage(port, callback)` connects Playwright to Electron's DevTools endpoint, selects the Obsidian renderer page, invokes the callback, and closes the browser connection afterwards. The page belongs to the callback; consumer code chooses selectors, commands, and assertions. +`withObsidianPage(port, callback)` connects Playwright to Electron's DevTools endpoint, selects the active Vault renderer rather than a Settings pop-out, invokes the callback, and closes the browser connection afterwards. The page belongs to the callback; consumer code chooses selectors, commands, and assertions. ```ts await withObsidianPage(session.remoteDebuggingPort, async (page) => { @@ -266,14 +271,19 @@ The principal controls are: | Variable | Purpose | | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| `OBSIDIAN_BINARY`, `OBSIDIAN_CLI` | Override executable discovery. | +| `OBSIDIAN_BINARY`, `OBSIDIAN_CLI` | Override executable discovery. An explicit binary without `E2E_OBSIDIAN_VERSION` leaves the expected version to renderer observation. | +| `E2E_OBSIDIAN_VERSION` | Select an exact Obsidian version. Managed Linux discovery defaults to the reviewed 1.13.6 release. | +| `E2E_OBSIDIAN_ALLOW_UNVERIFIED_VERSION=true` | Permit a version outside the reviewed catalogue as a labelled regression probe. | +| `E2E_OBSIDIAN_DOWNLOAD_DIR`, `E2E_OBSIDIAN_APPIMAGE_ARCH` | Select the managed AppImage root and `arm64` or `x86_64` architecture. | +| `E2E_OBSIDIAN_APPIMAGE_URL` | Override the complete acquisition URI for the explicit AppImage installer. | +| `E2E_OBSIDIAN_FORCE_DOWNLOAD=true`, `E2E_OBSIDIAN_SKIP_EXTRACT=true` | Replace the selected AppImage or leave it unextracted. | | `E2E_OBSIDIAN_KEEP_VAULT=true` | Preserve the isolated Vault and profile state for debugging, including after a handled process signal. | | `E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT` | Select a fixed CDP port. | | `E2E_OBSIDIAN_USE_XVFB=false` | Disable automatic headless Linux `xvfb-run` wrapping. | | `E2E_OBSIDIAN_CLEANUP_STALE_PROCESSES=false` | Disable consumer-marker stale-process cleanup. | | `E2E_OBSIDIAN_ARGS` | Replace the complete default Obsidian argument list. | | `E2E_OBSIDIAN_CLI_TIMEOUT_MS`, `E2E_OBSIDIAN_CLI_READY_TIMEOUT_MS` | Adjust CLI process and socket readiness timeouts. | -| `E2E_OBSIDIAN_CDP_TIMEOUT_MS`, `E2E_OBSIDIAN_VAULT_TIMEOUT_MS` | Adjust renderer connection and Vault confirmation timeouts. | +| `E2E_OBSIDIAN_CDP_TIMEOUT_MS`, `E2E_OBSIDIAN_RENDERER_TIMEOUT_MS`, `E2E_OBSIDIAN_VAULT_TIMEOUT_MS` | Adjust renderer connection, active-Vault selection, and Vault confirmation timeouts. | | `E2E_OBSIDIAN_TRUST_PROMPT_TIMEOUT_MS`, `E2E_OBSIDIAN_CATALOGUE_TIMEOUT_MS`, `E2E_OBSIDIAN_READY_TIMEOUT_MS`, `E2E_OBSIDIAN_UI_IDLE_TIMEOUT_MS` | Adjust individual bootstrap phases. | `E2E_OBSIDIAN_ARGS` transfers ownership of the complete launch argument list to the consumer. A high-level session still needs a reachable remote-debugging endpoint and the intended Vault in order for later bootstrap phases to succeed, so prefer the typed options unless a test deliberately controls every argument. @@ -285,11 +295,11 @@ Real Obsidian execution is a local workflow and is not expected in ordinary CI. | Public area | Focused evidence | Maintained consumer | | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Executable discovery and explicit overrides | [`src/environment.test.ts`](../src/environment.test.ts) | High-level harness fixture in [`test/e2e-obsidian/runner/harness.ts`](../../../test/e2e-obsidian/runner/harness.ts) | -| AppImage architecture and release URL selection | [`src/appimage.test.ts`](../src/appimage.test.ts) | [`test/e2e-obsidian/scripts/install-appimage.ts`](../../../test/e2e-obsidian/scripts/install-appimage.ts) | +| Validated AppImage catalogue, exact asset selection, digest checking, and versioned installation | [`src/appimage.test.ts`](../src/appimage.test.ts) | [`test/e2e-obsidian/scripts/install-appimage.ts`](../../../test/e2e-obsidian/scripts/install-appimage.ts) | | Isolated Vault/profile creation and disposal | [`src/vault.test.ts`](../src/vault.test.ts) | [`test/e2e-obsidian/runner/harness.ts`](../../../test/e2e-obsidian/runner/harness.ts) | | Required and optional artefacts, plug-in data, and preservation | [`src/plugin-installer.test.ts`](../src/plugin-installer.test.ts) | High-level session composition in [`src/session.ts`](../src/session.ts) | | CLI socket readiness and Vault opening | [`src/cli.test.ts`](../src/cli.test.ts) | High-level session bootstrap in [`src/session.ts`](../src/session.ts) | -| Exact active-Vault confirmation and UI-idle handling | [`src/session.test.ts`](../src/session.test.ts) and [`src/ui.test.ts`](../src/ui.test.ts) | Real-Obsidian harness scripts under [`test/e2e-obsidian/scripts`](../../../test/e2e-obsidian/scripts) | +| Exact active-Vault renderer selection, observed-version policy, and UI-idle handling | [`src/session.test.ts`](../src/session.test.ts) and [`src/ui.test.ts`](../src/ui.test.ts) | Real-Obsidian harness scripts under [`test/e2e-obsidian/scripts`](../../../test/e2e-obsidian/scripts) | | State written before controlled loading, lifecycle ordering, one load, and persisted enablement | [`src/plugin-installer.test.ts`](../src/plugin-installer.test.ts), [`src/session.test.ts`](../src/session.test.ts), and [`src/ui.test.ts`](../src/ui.test.ts) | [`test/e2e-obsidian/scripts/session-lifecycle.ts`](../../../test/e2e-obsidian/scripts/session-lifecycle.ts) | | Renderer-first shutdown and profile-backed state across application restart | [`src/renderer-lifecycle.test.ts`](../src/renderer-lifecycle.test.ts) and [`src/session.test.ts`](../src/session.test.ts) | [`test/e2e-obsidian/scripts/profile-restart.ts`](../../../test/e2e-obsidian/scripts/profile-restart.ts) | | Cleanup after process signals, process-first ordering, and explicit temporary-state preservation | [`src/process-lifecycle.test.ts`](../src/process-lifecycle.test.ts) | Consumer workflows which launch detached real-Obsidian sessions | diff --git a/packages/obsidian-test-session/src/appimage-release.ts b/packages/obsidian-test-session/src/appimage-release.ts new file mode 100644 index 00000000..cc49962c --- /dev/null +++ b/packages/obsidian-test-session/src/appimage-release.ts @@ -0,0 +1,366 @@ +import { get } from "node:https"; +import { basename } from "node:path"; + +/** Supported Obsidian AppImage architecture names. */ +export type ObsidianAppImageArchitecture = "arm64" | "x86_64"; + +/** Whether the selected Obsidian release is part of the reviewed E2E matrix. */ +export type ObsidianReleaseSupport = "validated" | "unverified"; + +/** Origin of a resolved AppImage asset. */ +export type ObsidianAppImageAssetSource = + | "validated-catalogue" + | "github-release" + | "url-override"; + +/** One immutable AppImage asset in the validated release catalogue. */ +export interface ValidatedObsidianAppImageAsset { + /** Exact GitHub Release asset name. */ + name: string; + /** Reviewed SHA-256 digest without an algorithm prefix. */ + sha256: string; +} + +/** One Obsidian release whose real-application E2E coverage has been reviewed. */ +export interface ValidatedObsidianRelease { + /** Exact release tag. */ + tag: string; + /** Reviewed Linux AppImage assets by architecture. */ + appImages: Readonly< + Record + >; +} + +/** Default release used by managed local E2E sessions. */ +export const DEFAULT_VALIDATED_OBSIDIAN_VERSION = "1.13.6"; + +/** Releases currently covered by the reviewed real-Obsidian E2E matrix. */ +export const VALIDATED_OBSIDIAN_RELEASES: Readonly< + Record +> = { + "1.12.7": { + tag: "v1.12.7", + appImages: { + arm64: { + name: "Obsidian-1.12.7-arm64.AppImage", + sha256: + "2a40943a2402cf1f38e71845f294a78d300a78ff21ea4c2103335bca7fbdcbe0", + }, + x86_64: { + name: "Obsidian-1.12.7.AppImage", + sha256: + "f6d8b96fe685a8632c819cc093a248ace0f6bab410f44a6c929a2611b1ebb17c", + }, + }, + }, + "1.13.6": { + tag: "v1.13.6", + appImages: { + arm64: { + name: "Obsidian-1.13.6-arm64.AppImage", + sha256: + "61e8186fefc019693857529324f4322f506aebab4229d932128a9641580a654a", + }, + x86_64: { + name: "Obsidian-1.13.6.AppImage", + sha256: + "7f1d5829263c93ca9d166c2be7aba941ac52f50861a46adda72105411a7b541e", + }, + }, + }, +}; + +/** Minimal GitHub Release asset metadata used for unverified probes. */ +export interface ObsidianGitHubReleaseAsset { + /** Exact release asset filename. */ + name: string; + /** GitHub-provided download URL. */ + browser_download_url: string; + /** Optional GitHub-provided digest such as `sha256:`. */ + digest?: string | null; +} + +/** Minimal GitHub Release metadata used for unverified probes. */ +export interface ObsidianGitHubRelease { + /** Exact release tag requested for the probe. */ + tag_name: string; + /** Published release assets inspected for the selected architecture. */ + assets: ObsidianGitHubReleaseAsset[]; +} + +/** Injectable GitHub Release lookup boundary. */ +export type ObsidianGitHubReleaseFetcher = ( + version: string, +) => Promise; + +/** A fully resolved AppImage selection. */ +export interface ObsidianAppImageReleaseSelection { + /** Exact stable Obsidian version. */ + version: string; + /** Selected Linux AppImage architecture. */ + architecture: ObsidianAppImageArchitecture; + /** Reviewed-catalogue status. */ + support: ObsidianReleaseSupport; + /** Source used to resolve the acquisition URI. */ + source: ObsidianAppImageAssetSource; + /** Exact GitHub Release tag. */ + tag: string; + /** Exact AppImage asset filename. */ + assetName: string; + /** Complete acquisition URI. */ + url: string; + /** Trusted SHA-256 digest when one is available. */ + expectedSha256?: string; +} + +/** Options for resolving one official or explicitly overridden AppImage. */ +export interface ResolveObsidianAppImageReleaseOptions { + /** Exact stable version. Defaults to the reviewed E2E release. */ + version?: string; + /** Required Linux AppImage architecture. */ + architecture: ObsidianAppImageArchitecture; + /** Whether a version outside the reviewed catalogue may be resolved. */ + allowUnverifiedVersion?: boolean; + /** Complete acquisition URI override. */ + url?: string; + /** Injectable official GitHub Release lookup. */ + fetchGitHubRelease?: ObsidianGitHubReleaseFetcher; +} + +/** Version and review status selected before any network lookup. */ +export interface ObsidianVersionSelection { + /** Exact stable Obsidian version. */ + version: string; + /** Reviewed-catalogue status. */ + support: ObsidianReleaseSupport; +} + +function exactStableVersion(value: string): boolean { + return /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u.test(value); +} + +/** Resolves and validates an exact stable Obsidian version string. */ +export function normaliseObsidianVersion(version?: string): string { + const selected = version?.trim() || DEFAULT_VALIDATED_OBSIDIAN_VERSION; + if (!exactStableVersion(selected)) { + throw new Error( + `Obsidian E2E version must be an exact stable version such as 1.13.6: ${JSON.stringify(selected)}`, + ); + } + return selected; +} + +/** Returns whether the supplied value explicitly enables unverified probes. */ +export function allowUnverifiedObsidianVersion( + env: NodeJS.ProcessEnv = process.env, +): boolean { + return env.E2E_OBSIDIAN_ALLOW_UNVERIFIED_VERSION === "true"; +} + +/** Returns the reviewed catalogue entry for a version when one exists. */ +export function validatedObsidianRelease( + version: string, +): ValidatedObsidianRelease | undefined { + return VALIDATED_OBSIDIAN_RELEASES[version]; +} + +/** Selects a validated version or an explicitly permitted unverified probe. */ +export function selectObsidianVersion( + version?: string, + allowUnverifiedVersion = false, +): ObsidianVersionSelection { + const selectedVersion = normaliseObsidianVersion(version); + if (validatedObsidianRelease(selectedVersion) !== undefined) { + return { version: selectedVersion, support: "validated" }; + } + if (!allowUnverifiedVersion) { + throw new Error( + `Unvalidated Obsidian AppImage release ${selectedVersion}. Validated versions: ${Object.keys(VALIDATED_OBSIDIAN_RELEASES).join(", ")}. Set E2E_OBSIDIAN_ALLOW_UNVERIFIED_VERSION=true for an unverified regression probe.`, + ); + } + return { version: selectedVersion, support: "unverified" }; +} + +/** Builds the official AppImage asset name used by current public releases. */ +export function obsidianAppImageAssetName( + version: string, + architecture: ObsidianAppImageArchitecture, +): string { + const selectedVersion = normaliseObsidianVersion(version); + return architecture === "arm64" + ? `Obsidian-${selectedVersion}-arm64.AppImage` + : `Obsidian-${selectedVersion}.AppImage`; +} + +/** Builds an official GitHub Release download URL from a tag and asset name. */ +export function obsidianReleaseAssetUrl(tag: string, assetName: string): string { + return `https://github.com/obsidianmd/obsidian-releases/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(assetName)}`; +} + +/** Builds the official AppImage URL used by current public releases. */ +export function obsidianAppImageUrl( + version: string, + architecture: ObsidianAppImageArchitecture, +): string { + const selectedVersion = normaliseObsidianVersion(version); + return obsidianReleaseAssetUrl( + `v${selectedVersion}`, + obsidianAppImageAssetName(selectedVersion, architecture), + ); +} + +function readJson(url: string): Promise { + return new Promise((resolve, reject) => { + const headers: Record = { + Accept: "application/vnd.github+json", + "User-Agent": "@vrtmrz/obsidian-test-session", + "X-GitHub-Api-Version": "2022-11-28", + }; + const token = process.env.GITHUB_TOKEN?.trim(); + if (token) headers.Authorization = `Bearer ${token}`; + + const request = get(url, { headers }, (response) => { + const statusCode = response.statusCode ?? 0; + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk: string) => { + body += chunk; + }); + response.on("end", () => { + if (statusCode === 404) { + reject(new Error(`Unknown Obsidian GitHub Release: ${url}`)); + return; + } + if (statusCode !== 200) { + reject( + new Error( + `Could not inspect Obsidian GitHub Release: HTTP ${statusCode}`, + ), + ); + return; + } + try { + resolve(JSON.parse(body)); + } catch (error) { + reject( + new Error("Could not parse Obsidian GitHub Release metadata", { + cause: error, + }), + ); + } + }); + }); + request.on("error", reject); + }); +} + +/** Fetches official GitHub Release metadata for an unverified probe. */ +export async function fetchObsidianGitHubRelease( + version: string, +): Promise { + const selectedVersion = normaliseObsidianVersion(version); + const value = await readJson( + `https://api.github.com/repos/obsidianmd/obsidian-releases/releases/tags/v${encodeURIComponent(selectedVersion)}`, + ); + if ( + typeof value !== "object" || + value === null || + !("tag_name" in value) || + typeof value.tag_name !== "string" || + !("assets" in value) || + !Array.isArray(value.assets) + ) { + throw new Error("Obsidian GitHub Release metadata has an invalid shape"); + } + return value as ObsidianGitHubRelease; +} + +function sha256Digest(digest: string | null | undefined): string | undefined { + if (digest === undefined || digest === null) return undefined; + const match = /^sha256:([0-9a-f]{64})$/u.exec(digest); + return match?.[1]; +} + +function overrideAssetName(url: string): string { + const name = basename(new URL(url).pathname); + if (!name.endsWith(".AppImage")) { + throw new Error( + `Obsidian AppImage URL must end with an .AppImage asset name: ${url}`, + ); + } + return name; +} + +function selectGitHubAsset( + release: ObsidianGitHubRelease, + architecture: ObsidianAppImageArchitecture, +): ObsidianGitHubReleaseAsset { + const candidates = release.assets.filter((asset) => { + if (!asset.name.endsWith(".AppImage")) return false; + const arm64 = asset.name.endsWith("-arm64.AppImage"); + return architecture === "arm64" ? arm64 : !arm64; + }); + if (candidates.length !== 1) { + throw new Error( + `Obsidian GitHub Release ${release.tag_name} exposes ${candidates.length} ${architecture} AppImage assets`, + ); + } + return candidates[0]!; +} + +/** Resolves a validated release or an explicitly permitted unverified probe. */ +export async function resolveObsidianAppImageRelease( + options: ResolveObsidianAppImageReleaseOptions, +): Promise { + const versionSelection = selectObsidianVersion( + options.version, + options.allowUnverifiedVersion, + ); + const { version } = versionSelection; + const validated = validatedObsidianRelease(version); + if (validated !== undefined) { + const asset = validated.appImages[options.architecture]; + return { + version, + architecture: options.architecture, + support: "validated", + source: options.url ? "url-override" : "validated-catalogue", + tag: validated.tag, + assetName: options.url ? overrideAssetName(options.url) : asset.name, + url: options.url ?? obsidianReleaseAssetUrl(validated.tag, asset.name), + expectedSha256: asset.sha256, + }; + } + + if (options.url) { + return { + version, + architecture: options.architecture, + support: "unverified", + source: "url-override", + tag: `v${version}`, + assetName: overrideAssetName(options.url), + url: options.url, + }; + } + + const release = await ( + options.fetchGitHubRelease ?? fetchObsidianGitHubRelease + )(version); + if (release.tag_name !== `v${version}`) { + throw new Error( + `Obsidian GitHub Release tag mismatch: requested v${version}, received ${release.tag_name}`, + ); + } + const asset = selectGitHubAsset(release, options.architecture); + return { + version, + architecture: options.architecture, + support: "unverified", + source: "github-release", + tag: release.tag_name, + assetName: asset.name, + url: asset.browser_download_url, + expectedSha256: sha256Digest(asset.digest), + }; +} diff --git a/packages/obsidian-test-session/src/appimage.test.ts b/packages/obsidian-test-session/src/appimage.test.ts index 05f813eb..9b73875d 100644 --- a/packages/obsidian-test-session/src/appimage.test.ts +++ b/packages/obsidian-test-session/src/appimage.test.ts @@ -1,7 +1,14 @@ -import { describe, expect, it } from "vitest"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; import { + installObsidianAppImage, obsidianAppImageArchitecture, + obsidianAppImageInstallDirectory, obsidianAppImageUrl, + resolveObsidianAppImageRelease, } from "./appimage.js"; describe("Obsidian AppImage release selection", () => { @@ -23,9 +30,172 @@ describe("Obsidian AppImage release selection", () => { ); }); - it("builds the official release asset URL", () => { + it("builds the official x86_64 release asset URL without an architecture suffix", () => { expect(obsidianAppImageUrl("1.12.7", "x86_64")).toBe( - "https://github.com/obsidianmd/obsidian-releases/releases/download/v1.12.7/Obsidian-1.12.7-x86_64.AppImage", + "https://github.com/obsidianmd/obsidian-releases/releases/download/v1.12.7/Obsidian-1.12.7.AppImage", ); }); + + it("builds the official arm64 release asset URL with an architecture suffix", () => { + expect(obsidianAppImageUrl("1.13.6", "arm64")).toBe( + "https://github.com/obsidianmd/obsidian-releases/releases/download/v1.13.6/Obsidian-1.13.6-arm64.AppImage", + ); + }); + + it("rejects an unvalidated release unless the caller explicitly allows it", async () => { + const targetDirectory = await mkdtemp( + join(tmpdir(), "obsidian-appimage-unknown-unit-"), + ); + const assetName = "Obsidian-9.99.99-arm64.AppImage"; + try { + await mkdir(targetDirectory, { recursive: true }); + await writeFile(join(targetDirectory, assetName), "fixture"); + + await expect( + installObsidianAppImage({ + version: "9.99.99", + architecture: "arm64", + targetDirectory, + url: `https://example.invalid/${assetName}`, + extract: false, + }), + ).rejects.toThrowError("Unvalidated Obsidian AppImage release"); + } finally { + await rm(targetDirectory, { recursive: true, force: true }); + } + }); + + it("resolves reviewed releases from the immutable catalogue", async () => { + await expect( + resolveObsidianAppImageRelease({ + version: "1.13.6", + architecture: "x86_64", + }), + ).resolves.toMatchObject({ + version: "1.13.6", + architecture: "x86_64", + support: "validated", + source: "validated-catalogue", + assetName: "Obsidian-1.13.6.AppImage", + expectedSha256: + "7f1d5829263c93ca9d166c2be7aba941ac52f50861a46adda72105411a7b541e", + }); + }); + + it("resolves an explicitly permitted unverified release through official metadata", async () => { + const fetchGitHubRelease = vi.fn(async () => ({ + tag_name: "v1.13.7", + assets: [ + { + name: "Obsidian-1.13.7.AppImage", + browser_download_url: + "https://github.com/obsidianmd/obsidian-releases/releases/download/v1.13.7/Obsidian-1.13.7.AppImage", + digest: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + { + name: "Obsidian-1.13.7-arm64.AppImage", + browser_download_url: + "https://github.com/obsidianmd/obsidian-releases/releases/download/v1.13.7/Obsidian-1.13.7-arm64.AppImage", + digest: + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }, + ], + })); + + await expect( + resolveObsidianAppImageRelease({ + version: "1.13.7", + architecture: "arm64", + allowUnverifiedVersion: true, + fetchGitHubRelease, + }), + ).resolves.toEqual({ + version: "1.13.7", + architecture: "arm64", + support: "unverified", + source: "github-release", + tag: "v1.13.7", + assetName: "Obsidian-1.13.7-arm64.AppImage", + url: "https://github.com/obsidianmd/obsidian-releases/releases/download/v1.13.7/Obsidian-1.13.7-arm64.AppImage", + expectedSha256: + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }); + expect(fetchGitHubRelease).toHaveBeenCalledWith("1.13.7"); + }); + + it.each(["latest", "v1.13.6", "1.13", "unknown", "1.13.6-beta.1"])( + "rejects the non-exact target %s", + async (version) => { + await expect( + resolveObsidianAppImageRelease({ + version, + architecture: "arm64", + allowUnverifiedVersion: true, + }), + ).rejects.toThrowError("exact stable version"); + }, + ); + + it("keeps unverified downloads and metadata inside a versioned architecture directory", async () => { + const targetDirectory = await mkdtemp( + join(tmpdir(), "obsidian-appimage-versioned-unit-"), + ); + const version = "9.99.98"; + const architecture = "arm64"; + const assetName = `Obsidian-${version}-arm64.AppImage`; + const installDirectory = obsidianAppImageInstallDirectory( + targetDirectory, + version, + architecture, + ); + try { + await mkdir(installDirectory, { recursive: true }); + await writeFile(join(installDirectory, assetName), "fixture"); + await writeFile( + join(installDirectory, "release.json"), + JSON.stringify({ + schemaVersion: 1, + version, + architecture, + support: "unverified", + source: "url-override", + tag: `v${version}`, + assetName, + url: `https://example.invalid/${assetName}`, + sha256: createHash("sha256").update("fixture").digest("hex"), + preparedAt: "2026-08-11T00:00:00.000Z", + extracted: false, + }), + ); + + const result = await installObsidianAppImage({ + version, + architecture, + targetDirectory, + url: `https://example.invalid/${assetName}`, + allowUnverifiedVersion: true, + extract: false, + log: vi.fn(), + }); + + expect(result.installDirectory).toBe(installDirectory); + expect(result.appImagePath).toBe(join(installDirectory, assetName)); + expect(result.support).toBe("unverified"); + expect( + JSON.parse(await readFile(result.metadataPath, "utf8")), + ).toMatchObject({ + version, + architecture, + support: "unverified", + assetName, + extracted: false, + }); + expect( + JSON.parse(await readFile(result.metadataPath, "utf8")).preparedAt, + ).toMatch(/^\d{4}-\d{2}-\d{2}T/); + } finally { + await rm(targetDirectory, { recursive: true, force: true }); + } + }); }); diff --git a/packages/obsidian-test-session/src/appimage.ts b/packages/obsidian-test-session/src/appimage.ts index 3e87d405..571e6cbd 100644 --- a/packages/obsidian-test-session/src/appimage.ts +++ b/packages/obsidian-test-session/src/appimage.ts @@ -1,24 +1,67 @@ +import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; -import { createWriteStream, existsSync } from "node:fs"; -import { chmod, mkdir } from "node:fs/promises"; +import { createReadStream, createWriteStream, existsSync } from "node:fs"; +import { + chmod, + mkdir, + readFile, + rename, + rm, + writeFile, +} from "node:fs/promises"; import { get } from "node:https"; -import { basename, join, resolve } from "node:path"; +import { join, resolve } from "node:path"; import { arch as currentNodeArchitecture } from "node:process"; +import { pipeline } from "node:stream/promises"; +import { + DEFAULT_VALIDATED_OBSIDIAN_VERSION, + allowUnverifiedObsidianVersion, + resolveObsidianAppImageRelease, + type ObsidianAppImageArchitecture, + type ObsidianAppImageReleaseSelection, + type ObsidianGitHubReleaseFetcher, + type ObsidianReleaseSupport, +} from "./appimage-release.js"; -/** Supported Obsidian AppImage architecture names. */ -export type ObsidianAppImageArchitecture = "arm64" | "x86_64"; +export { + DEFAULT_VALIDATED_OBSIDIAN_VERSION, + VALIDATED_OBSIDIAN_RELEASES, + allowUnverifiedObsidianVersion, + fetchObsidianGitHubRelease, + normaliseObsidianVersion, + obsidianAppImageAssetName, + obsidianAppImageUrl, + resolveObsidianAppImageRelease, + selectObsidianVersion, + validatedObsidianRelease, + type ObsidianAppImageArchitecture, + type ObsidianAppImageAssetSource, + type ObsidianAppImageReleaseSelection, + type ObsidianGitHubRelease, + type ObsidianGitHubReleaseAsset, + type ObsidianGitHubReleaseFetcher, + type ObsidianReleaseSupport, + type ResolveObsidianAppImageReleaseOptions, + type ObsidianVersionSelection, + type ValidatedObsidianAppImageAsset, + type ValidatedObsidianRelease, +} from "./appimage-release.js"; /** Options for explicitly downloading and extracting an Obsidian AppImage. */ export interface InstallObsidianAppImageOptions { - /** Obsidian release version. Defaults to `1.12.7`. */ + /** Obsidian release version. Defaults to the reviewed E2E release. */ version?: string; /** AppImage architecture. Defaults from the current Node architecture. */ architecture?: ObsidianAppImageArchitecture; - /** Download and extraction directory. Defaults to `_testdata/obsidian`. */ + /** Managed download root. Defaults to `_testdata/obsidian`. */ targetDirectory?: string; /** Complete AppImage URL override. */ url?: string; - /** Whether to download again when the AppImage already exists. */ + /** Whether an unvalidated version may run as a labelled regression probe. */ + allowUnverifiedVersion?: boolean; + /** Injectable GitHub Release metadata lookup. */ + fetchGitHubRelease?: ObsidianGitHubReleaseFetcher; + /** Whether to download again when the verified AppImage already exists. */ forceDownload?: boolean; /** Whether to extract the AppImage after download. Defaults to `true`. */ extract?: boolean; @@ -32,20 +75,39 @@ export interface InstallObsidianAppImageResult { version: string; /** Selected AppImage architecture. */ architecture: ObsidianAppImageArchitecture; - /** Download URL. */ + /** Whether the release belongs to the reviewed E2E matrix. */ + support: ObsidianReleaseSupport; + /** Exact release asset name. */ + assetName: string; + /** Complete download URL. */ url: string; + /** Verified or observed AppImage SHA-256 digest. */ + sha256: string; + /** Version- and architecture-scoped installation directory. */ + installDirectory: string; /** Local AppImage path. */ appImagePath: string; /** Expected extracted Obsidian executable path. */ extractedBinary: string; + /** Metadata describing the selected and prepared release. */ + metadataPath: string; } -/** - * Maps a Node architecture to an Obsidian AppImage architecture. - * - * @param architecture - Node architecture name. - * @returns The corresponding AppImage architecture. - */ +interface AppImageInstallMetadata { + schemaVersion: 1; + version: string; + architecture: ObsidianAppImageArchitecture; + support: ObsidianReleaseSupport; + source: ObsidianAppImageReleaseSelection["source"]; + tag: string; + assetName: string; + url: string; + sha256: string; + preparedAt: string; + extracted: boolean; +} + +/** Maps a Node architecture to an official AppImage architecture. */ export function obsidianAppImageArchitecture( architecture: NodeJS.Architecture = currentNodeArchitecture, ): ObsidianAppImageArchitecture { @@ -56,21 +118,22 @@ export function obsidianAppImageArchitecture( ); } -/** - * Builds the official Obsidian AppImage release URL. - * - * @param version - Obsidian release version. - * @param architecture - AppImage architecture. - * @returns The official release asset URL. - */ -export function obsidianAppImageUrl( +/** Returns the managed directory for one exact version and architecture. */ +export function obsidianAppImageInstallDirectory( + targetDirectory: string, version: string, architecture: ObsidianAppImageArchitecture, ): string { - return `https://github.com/obsidianmd/obsidian-releases/releases/download/v${version}/Obsidian-${version}-${architecture}.AppImage`; + return resolve(targetDirectory, version, architecture); +} + +async function sha256File(path: string): Promise { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest("hex"); } -function download( +function downloadToFile( url: string, destination: string, redirectsLeft = 5, @@ -85,7 +148,7 @@ function download( reject(new Error(`Too many redirects while downloading ${url}`)); return; } - download( + downloadToFile( new URL(location, url).toString(), destination, redirectsLeft - 1, @@ -94,21 +157,22 @@ function download( .catch(reject); return; } + if (statusCode === 404) { + response.resume(); + reject( + new Error(`Unknown or unsupported Obsidian AppImage asset: ${url}`), + ); + return; + } if (statusCode !== 200) { response.resume(); reject(new Error(`Failed to download ${url}: HTTP ${statusCode}`)); return; } - const file = createWriteStream(destination, { mode: 0o755 }); - response.pipe(file); - file.on("finish", () => { - file.close((error) => { - if (error) reject(error); - else resolveDownload(); - }); - }); - file.on("error", reject); + pipeline(response, createWriteStream(destination, { mode: 0o755 })) + .then(resolveDownload) + .catch(reject); }); request.on("error", reject); }); @@ -133,46 +197,179 @@ function extractAppImage(appImagePath: string, cwd: string): Promise { }); } +async function readMetadata( + metadataPath: string, +): Promise { + try { + const value = JSON.parse( + await readFile(metadataPath, "utf8"), + ) as AppImageInstallMetadata; + return value.schemaVersion === 1 ? value : undefined; + } catch { + return undefined; + } +} + +async function writeMetadata( + metadataPath: string, + metadata: AppImageInstallMetadata, +): Promise { + const temporaryPath = `${metadataPath}.${process.pid}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(metadata, null, 2)}\n`); + await rename(temporaryPath, metadataPath); +} + +function metadataMatches( + metadata: AppImageInstallMetadata | undefined, + selection: ObsidianAppImageReleaseSelection, + sha256: string, +): boolean { + return ( + metadata?.version === selection.version && + metadata.architecture === selection.architecture && + metadata.assetName === selection.assetName && + metadata.url === selection.url && + metadata.sha256 === sha256 + ); +} + +async function prepareAppImage( + selection: ObsidianAppImageReleaseSelection, + appImagePath: string, + previousMetadata: AppImageInstallMetadata | undefined, + forceDownload: boolean, + log: (message: string) => void, +): Promise { + if (existsSync(appImagePath) && !forceDownload) { + const existingSha256 = await sha256File(appImagePath); + if ( + selection.expectedSha256 === existingSha256 || + (selection.expectedSha256 === undefined && + metadataMatches(previousMetadata, selection, existingSha256)) + ) { + log(`Using existing matching Obsidian AppImage: ${appImagePath}`); + return existingSha256; + } + log( + `Replacing Obsidian AppImage without a matching checksum and release record: ${appImagePath}`, + ); + } + + const partialPath = `${appImagePath}.${process.pid}.partial`; + await rm(partialPath, { force: true }); + try { + log(`Downloading Obsidian AppImage: ${selection.url}`); + log(`Destination: ${appImagePath}`); + await downloadToFile(selection.url, partialPath); + const downloadedSha256 = await sha256File(partialPath); + if ( + selection.expectedSha256 !== undefined && + selection.expectedSha256 !== downloadedSha256 + ) { + throw new Error( + `Obsidian AppImage checksum mismatch. expected=${selection.expectedSha256}, actual=${downloadedSha256}`, + ); + } + await chmod(partialPath, 0o755); + await rename(partialPath, appImagePath); + return downloadedSha256; + } finally { + await rm(partialPath, { force: true }); + } +} + /** * Explicitly downloads and optionally extracts a local Obsidian AppImage. * - * @param options - Version, architecture, location, and download options. - * @returns The selected release and local paths. - * - * @remarks Importing the package never downloads Obsidian; only this explicit operation performs network and filesystem writes. + * @remarks Importing the package never performs network or filesystem writes. */ export async function installObsidianAppImage( options: InstallObsidianAppImageOptions = {}, ): Promise { - const version = options.version?.trim() || "1.12.7"; const architecture = options.architecture ?? obsidianAppImageArchitecture(); + const selection = await resolveObsidianAppImageRelease({ + version: options.version ?? DEFAULT_VALIDATED_OBSIDIAN_VERSION, + architecture, + allowUnverifiedVersion: + options.allowUnverifiedVersion ?? allowUnverifiedObsidianVersion(), + url: options.url, + fetchGitHubRelease: options.fetchGitHubRelease, + }); const targetDirectory = resolve( options.targetDirectory?.trim() || "_testdata/obsidian", ); - const url = options.url?.trim() || obsidianAppImageUrl(version, architecture); - const appImagePath = join(targetDirectory, basename(new URL(url).pathname)); - const extractedBinary = join(targetDirectory, "squashfs-root", "obsidian"); + const installDirectory = obsidianAppImageInstallDirectory( + targetDirectory, + selection.version, + architecture, + ); + const appImagePath = join(installDirectory, selection.assetName); + const extractedRoot = join(installDirectory, "squashfs-root"); + const extractedBinary = join(extractedRoot, "obsidian"); + const metadataPath = join(installDirectory, "release.json"); const log = options.log ?? console.log; - await mkdir(targetDirectory, { recursive: true }); - if (!existsSync(appImagePath) || options.forceDownload === true) { - log(`Downloading Obsidian AppImage: ${url}`); - log(`Destination: ${appImagePath}`); - await download(url, appImagePath); - await chmod(appImagePath, 0o755); - } else { - log(`Using existing Obsidian AppImage: ${appImagePath}`); - } + await mkdir(installDirectory, { recursive: true }); + const previousMetadata = await readMetadata(metadataPath); + const sha256 = await prepareAppImage( + selection, + appImagePath, + previousMetadata, + options.forceDownload === true, + log, + ); + const reusableExtraction = + options.forceDownload !== true && + existsSync(extractedBinary) && + previousMetadata?.extracted === true && + metadataMatches(previousMetadata, selection, sha256); if (options.extract !== false) { - if (existsSync(extractedBinary)) { + if (reusableExtraction) { log(`Using existing extracted Obsidian binary: ${extractedBinary}`); } else { - log(`Extracting Obsidian AppImage in ${targetDirectory}`); - await extractAppImage(appImagePath, targetDirectory); + await rm(extractedRoot, { recursive: true, force: true }); + log(`Extracting Obsidian AppImage in ${installDirectory}`); + await extractAppImage(appImagePath, installDirectory); + if (!existsSync(extractedBinary)) { + throw new Error( + `Extracted Obsidian binary was not created: ${extractedBinary}`, + ); + } log(`Extracted Obsidian binary: ${extractedBinary}`); } } - return { version, architecture, url, appImagePath, extractedBinary }; + await writeMetadata(metadataPath, { + schemaVersion: 1, + version: selection.version, + architecture, + support: selection.support, + source: selection.source, + tag: selection.tag, + assetName: selection.assetName, + url: selection.url, + sha256, + preparedAt: new Date().toISOString(), + extracted: options.extract !== false, + }); + + if (selection.support === "unverified") { + log( + `Warning: Obsidian ${selection.version} is an unverified regression probe and does not establish supported-version status.`, + ); + } + + return { + version: selection.version, + architecture, + support: selection.support, + assetName: selection.assetName, + url: selection.url, + sha256, + installDirectory, + appImagePath, + extractedBinary, + metadataPath, + }; } diff --git a/packages/obsidian-test-session/src/environment.test.ts b/packages/obsidian-test-session/src/environment.test.ts index 7ee85f9c..4ce74571 100644 --- a/packages/obsidian-test-session/src/environment.test.ts +++ b/packages/obsidian-test-session/src/environment.test.ts @@ -1,5 +1,10 @@ import { platform } from "node:process"; +import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; +import { + DEFAULT_VALIDATED_OBSIDIAN_VERSION, + obsidianAppImageArchitecture, +} from "./appimage.js"; import { discoverObsidianBinary, discoverObsidianCli } from "./environment.js"; describe("Obsidian executable discovery", () => { @@ -19,4 +24,53 @@ describe("Obsidian executable discovery", () => { ); expect(result.checked).toContain("/usr/local/bin/obsidian"); }); + + it.runIf(platform === "linux")( + "selects the versioned managed AppImage by default", + () => { + const result = discoverObsidianBinary({}); + + expect(result.checked).toContain( + resolve( + `_testdata/obsidian/${DEFAULT_VALIDATED_OBSIDIAN_VERSION}/${obsidianAppImageArchitecture()}/squashfs-root/obsidian`, + ), + ); + expect(result.checked).not.toContain("/usr/bin/obsidian"); + }, + ); + + it.runIf(platform === "linux")( + "does not fall back to a different system version when a target is explicit", + () => { + const result = discoverObsidianBinary({ + E2E_OBSIDIAN_VERSION: "1.12.7", + E2E_OBSIDIAN_DOWNLOAD_DIR: "/tmp/missing-obsidian-target", + }); + + expect(result.checked).toEqual([ + `/tmp/missing-obsidian-target/1.12.7/${obsidianAppImageArchitecture()}/squashfs-root/obsidian`, + `/tmp/missing-obsidian-target/1.12.7/${obsidianAppImageArchitecture()}/squashfs-root/AppRun`, + ]); + }, + ); + + it.runIf(platform === "linux")( + "rejects an unvalidated managed target unless the probe is explicit", + () => { + expect(() => + discoverObsidianBinary({ E2E_OBSIDIAN_VERSION: "1.13.7" }), + ).toThrowError("Unvalidated Obsidian AppImage release"); + + expect( + discoverObsidianBinary({ + E2E_OBSIDIAN_VERSION: "1.13.7", + E2E_OBSIDIAN_ALLOW_UNVERIFIED_VERSION: "true", + }).checked, + ).toContain( + resolve( + `_testdata/obsidian/1.13.7/${obsidianAppImageArchitecture()}/squashfs-root/obsidian`, + ), + ); + }, + ); }); diff --git a/packages/obsidian-test-session/src/environment.ts b/packages/obsidian-test-session/src/environment.ts index 9e8bf5a3..e7442ca8 100644 --- a/packages/obsidian-test-session/src/environment.ts +++ b/packages/obsidian-test-session/src/environment.ts @@ -1,6 +1,13 @@ import { accessSync, constants, existsSync } from "node:fs"; -import { resolve } from "node:path"; +import { join, resolve } from "node:path"; import { platform } from "node:process"; +import { + allowUnverifiedObsidianVersion, + obsidianAppImageArchitecture, + obsidianAppImageInstallDirectory, + selectObsidianVersion, + type ObsidianVersionSelection, +} from "./appimage.js"; /** The result of locating an Obsidian executable. */ export interface ObsidianDiscoveryResult { @@ -12,6 +19,14 @@ export interface ObsidianDiscoveryResult { checked: string[]; } +/** Reviewed-version policy selected for repository-owned real-Obsidian E2E. */ +export interface ObsidianE2EVersionPolicy { + /** Exact version expected from the active renderer when selected in advance. */ + expectedVersion?: string; + /** Whether an observed version outside the reviewed catalogue may run. */ + allowUnverifiedVersion: boolean; +} + const defaultCandidatesByPlatform: Partial< Record > = { @@ -20,10 +35,6 @@ const defaultCandidatesByPlatform: Partial< "/Applications/Obsidian.app/Contents/MacOS/obsidian", ], 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", @@ -46,7 +57,6 @@ const defaultCliCandidatesByPlatform: Partial< "/usr/local/bin/obsidian", ], linux: [ - "_testdata/obsidian/squashfs-root/obsidian-cli", "/usr/bin/obsidian-cli", "/usr/local/bin/obsidian-cli", "/snap/bin/obsidian-cli", @@ -59,6 +69,60 @@ const defaultCliCandidatesByPlatform: Partial< ], }; +interface ManagedObsidianTarget extends ObsidianVersionSelection { + installDirectory: string; +} + +function managedObsidianTarget( + env: NodeJS.ProcessEnv, +): ManagedObsidianTarget | undefined { + if (platform !== "linux") return undefined; + const explicitBinary = env.OBSIDIAN_BINARY?.trim(); + const requestedVersion = env.E2E_OBSIDIAN_VERSION?.trim(); + if (explicitBinary && !requestedVersion) return undefined; + const selection = selectObsidianVersion( + requestedVersion, + allowUnverifiedObsidianVersion(env), + ); + const targetRoot = env.E2E_OBSIDIAN_DOWNLOAD_DIR?.trim() || "_testdata/obsidian"; + return { + ...selection, + installDirectory: obsidianAppImageInstallDirectory( + targetRoot, + selection.version, + obsidianAppImageArchitecture(), + ), + }; +} + +/** Resolves the strict version policy used by repository-owned E2E runners. */ +export function obsidianE2EVersionPolicy( + env: NodeJS.ProcessEnv = process.env, +): ObsidianE2EVersionPolicy { + const allowUnverifiedVersion = allowUnverifiedObsidianVersion(env); + const requestedVersion = env.E2E_OBSIDIAN_VERSION?.trim(); + const explicitBinary = env.OBSIDIAN_BINARY?.trim(); + const expectedSelection = + requestedVersion || !explicitBinary + ? selectObsidianVersion(requestedVersion, allowUnverifiedVersion) + : undefined; + return { + expectedVersion: expectedSelection?.version, + allowUnverifiedVersion, + }; +} + +function managedBinaryCandidates(target: ManagedObsidianTarget): string[] { + return [ + join(target.installDirectory, "squashfs-root", "obsidian"), + join(target.installDirectory, "squashfs-root", "AppRun"), + ]; +} + +function managedCliCandidates(target: ManagedObsidianTarget): string[] { + return [join(target.installDirectory, "squashfs-root", "obsidian-cli")]; +} + function isUsableFile(path: string): boolean { const resolvedPath = resolve(path); if (!existsSync(resolvedPath)) return false; @@ -100,10 +164,12 @@ function discover( export function discoverObsidianBinary( env: NodeJS.ProcessEnv = process.env, ): ObsidianDiscoveryResult { - return discover( - env.OBSIDIAN_BINARY, - defaultCandidatesByPlatform[platform] ?? [], - ); + const explicitBinary = env.OBSIDIAN_BINARY?.trim(); + if (explicitBinary) return discover(explicitBinary, []); + const managedTarget = managedObsidianTarget(env); + if (managedTarget !== undefined) + return discover(undefined, managedBinaryCandidates(managedTarget)); + return discover(undefined, defaultCandidatesByPlatform[platform] ?? []); } /** @@ -117,6 +183,20 @@ export function requireObsidianBinary( ): string { const result = discoverObsidianBinary(env); if (result.binary) return result.binary; + const managedTarget = managedObsidianTarget(env); + if (managedTarget !== undefined) { + const probePrefix = + managedTarget.support === "unverified" + ? "E2E_OBSIDIAN_ALLOW_UNVERIFIED_VERSION=true " + : ""; + throw new Error( + [ + `Obsidian E2E target ${managedTarget.version} (${obsidianAppImageArchitecture()}, ${managedTarget.support}) is not installed.`, + `Run: E2E_OBSIDIAN_VERSION=${managedTarget.version} ${probePrefix}npm run test:e2e:obsidian:install-appimage`, + `Checked paths: ${result.checked.join(", ")}`, + ].join("\n"), + ); + } throw new Error( [ "Could not find an Obsidian executable.", @@ -135,10 +215,12 @@ export function requireObsidianBinary( export function discoverObsidianCli( env: NodeJS.ProcessEnv = process.env, ): ObsidianDiscoveryResult { - return discover( - env.OBSIDIAN_CLI, - defaultCliCandidatesByPlatform[platform] ?? [], - ); + const explicitCli = env.OBSIDIAN_CLI?.trim(); + if (explicitCli) return discover(explicitCli, []); + const managedTarget = managedObsidianTarget(env); + if (managedTarget !== undefined) + return discover(undefined, managedCliCandidates(managedTarget)); + return discover(undefined, defaultCliCandidatesByPlatform[platform] ?? []); } /** diff --git a/packages/obsidian-test-session/src/session.test.ts b/packages/obsidian-test-session/src/session.test.ts index b92fce50..189daec4 100644 --- a/packages/obsidian-test-session/src/session.test.ts +++ b/packages/obsidian-test-session/src/session.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; const state = vi.hoisted(() => ({ order: [] as string[], entries: undefined as Readonly> | undefined, + observedObsidianVersion: "1.13.6", installOptions: undefined as | { enableOnStartup?: boolean; @@ -75,7 +76,13 @@ vi.mock("./ui.js", () => ({ ensurePluginLoaded: state.ensurePluginLoaded, waitForPluginReady: vi.fn(async () => { state.order.push("ready"); - return { pluginId: "example-plugin", enabled: true }; + return { + status: "ready", + pluginId: "example-plugin", + pluginVersion: "1.0.0", + vaultName: "vault", + obsidianVersion: state.observedObsidianVersion, + }; }), waitForObsidianUiIdle: vi.fn(async () => { state.order.push("idle"); @@ -308,4 +315,65 @@ describe("startObsidianPluginSession", () => { expect(state.ensurePluginLoaded).toHaveBeenCalledOnce(); expect(state.installOptions?.enableOnStartup).toBe(true); }); + + it("rejects a renderer whose observed Obsidian version does not match the target", async () => { + state.observedObsidianVersion = "1.12.7"; + state.processStop.mockClear(); + try { + await expect( + startObsidianPluginSession({ + binary: "/bin/obsidian", + cliBinary: "/bin/obsidian-cli", + pluginId: "example-plugin", + artifactRoot: "/artefacts", + versionPolicy: { expectedVersion: "1.13.6" }, + vault: { + id: "vault-id", + path: "/vault", + homePath: "/profile/home", + xdgConfigPath: "/profile/config", + xdgCachePath: "/profile/cache", + xdgDataPath: "/profile/data", + userDataPath: "/profile/user-data", + processMarker: "example-marker", + } as never, + }), + ).rejects.toThrowError( + "Obsidian version mismatch. expected=1.13.6, observed=1.12.7", + ); + expect(state.processStop).toHaveBeenCalledOnce(); + } finally { + state.observedObsidianVersion = "1.13.6"; + } + }); + + it("labels an explicitly allowed unverified renderer version", async () => { + state.observedObsidianVersion = "1.13.7"; + try { + const session = await startObsidianPluginSession({ + binary: "/bin/obsidian", + cliBinary: "/bin/obsidian-cli", + pluginId: "example-plugin", + artifactRoot: "/artefacts", + versionPolicy: { + expectedVersion: "1.13.7", + allowUnverifiedVersion: true, + }, + vault: { + id: "vault-id", + path: "/vault", + homePath: "/profile/home", + xdgConfigPath: "/profile/config", + xdgCachePath: "/profile/cache", + xdgDataPath: "/profile/data", + userDataPath: "/profile/user-data", + processMarker: "example-marker", + } as never, + }); + + expect(session.readiness.obsidianVersionSupport).toBe("unverified"); + } finally { + state.observedObsidianVersion = "1.13.6"; + } + }); }); diff --git a/packages/obsidian-test-session/src/session.ts b/packages/obsidian-test-session/src/session.ts index 31a688fd..c2991c47 100644 --- a/packages/obsidian-test-session/src/session.ts +++ b/packages/obsidian-test-session/src/session.ts @@ -1,4 +1,8 @@ import { openVaultWithObsidianCli } from "./cli.js"; +import { + normaliseObsidianVersion, + selectObsidianVersion, +} from "./appimage.js"; import { launchObsidian, type ObsidianProcess } from "./launch.js"; import { installBuiltPlugin, @@ -142,6 +146,16 @@ export interface StartObsidianPluginSessionOptions { startupGraceMs?: number; /** Whether to normalise a stale start-up overlay after readiness. Defaults to `true`. */ waitForUiIdle?: boolean; + /** Optional expected-version and reviewed-catalogue policy for this session. */ + versionPolicy?: ObsidianVersionPolicy; +} + +/** Version checks applied after the active renderer reports its Obsidian API version. */ +export interface ObsidianVersionPolicy { + /** Exact version expected from the active renderer. */ + expectedVersion?: string; + /** Whether a version outside the reviewed catalogue may run as a probe. */ + allowUnverifiedVersion?: boolean; } function resolvePluginStartup( @@ -160,6 +174,35 @@ function resolvePluginStartup( ); } +function applyVersionPolicy( + readiness: PluginReadiness, + policy: ObsidianVersionPolicy | undefined, +): PluginReadiness { + if (policy === undefined) return readiness; + if (readiness.obsidianVersion === "unknown") { + throw new Error("Could not observe the active Obsidian API version"); + } + const expectedVersion = policy.expectedVersion + ? normaliseObsidianVersion(policy.expectedVersion) + : undefined; + if ( + expectedVersion !== undefined && + readiness.obsidianVersion !== expectedVersion + ) { + throw new Error( + `Obsidian version mismatch. expected=${expectedVersion}, observed=${readiness.obsidianVersion}`, + ); + } + const selection = selectObsidianVersion( + readiness.obsidianVersion, + policy.allowUnverifiedVersion, + ); + return { + ...readiness, + obsidianVersionSupport: selection.support, + }; +} + async function runLifecycleHook( name: keyof ObsidianPluginSessionLifecycle, hook: ((context: Context) => void | Promise) | undefined, @@ -309,9 +352,9 @@ export async function startObsidianPluginSession( options.lifecycle?.afterPluginLoad, runningLifecycleContext, ); - const readiness = await waitForPluginReady( - remoteDebuggingPort, - options.pluginId, + const readiness = applyVersionPolicy( + await waitForPluginReady(remoteDebuggingPort, options.pluginId), + options.versionPolicy, ); if (options.waitForUiIdle !== false) await waitForObsidianUiIdle(remoteDebuggingPort); diff --git a/packages/obsidian-test-session/src/ui.test.ts b/packages/obsidian-test-session/src/ui.test.ts index 43f766fa..4ce9c05f 100644 --- a/packages/obsidian-test-session/src/ui.test.ts +++ b/packages/obsidian-test-session/src/ui.test.ts @@ -16,8 +16,10 @@ import { ensurePluginLoaded, obsidianRemoteDebuggingPort, preseedLocalStorage, + withObsidianPage, waitForObsidianPageVault, waitForObsidianPageUiIdle, + waitForPluginReady, } from "./ui.js"; afterEach(() => { @@ -44,6 +46,74 @@ describe("obsidianRemoteDebuggingPort", () => { ); }); +describe("withObsidianPage", () => { + it("selects the Vault renderer when a Settings pop-out is first", async () => { + const settingsPage = { + evaluate: vi.fn(async () => false), + url: vi.fn(() => "about:blank"), + } as unknown as Page; + const vaultPage = { + evaluate: vi.fn(async () => true), + url: vi.fn(() => "app://obsidian.md/index.html"), + } as unknown as Page; + const close = vi.fn(async () => undefined); + playwrightState.connectOverCDP.mockResolvedValue({ + contexts: () => [{ pages: () => [settingsPage, vaultPage] }], + close, + }); + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true }))); + + const selected = await withObsidianPage(9222, async (page) => page); + + expect(selected).toBe(vaultPage); + expect(close).toHaveBeenCalledOnce(); + }); +}); + +describe("waitForPluginReady", () => { + it("observes the lowercase Obsidian product token emitted by the renderer", async () => { + const page = { + url: vi.fn(() => "app://obsidian.md/index.html"), + waitForFunction: vi.fn(async () => undefined), + evaluate: vi.fn( + async ( + operation: (id: string) => unknown, + id: string, + ): Promise => { + vi.stubGlobal("app", { + plugins: { + plugins: { [id]: {} }, + manifests: { [id]: { version: "0.1.0" } }, + }, + vault: { getName: () => "Version fixture" }, + }); + vi.stubGlobal("navigator", { + userAgent: + "Mozilla/5.0 obsidian/1.13.6 Chrome/150.0.0.0 Electron/43.3.0", + }); + return operation(id); + }, + ), + } as unknown as Page; + const close = vi.fn(async () => undefined); + playwrightState.connectOverCDP.mockResolvedValue({ + contexts: () => [{ pages: () => [page] }], + close, + }); + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true }))); + + const readiness = await waitForPluginReady(9222, "example-plugin"); + + expect(readiness).toMatchObject({ + status: "ready", + pluginId: "example-plugin", + pluginVersion: "0.1.0", + vaultName: "Version fixture", + obsidianVersion: "1.13.6", + }); + }); +}); + describe("preseedLocalStorage", () => { it("writes every consumer-owned entry through the renderer before plug-in enablement", async () => { const storage = new Map(); diff --git a/packages/obsidian-test-session/src/ui.ts b/packages/obsidian-test-session/src/ui.ts index 902002bb..87bdd45c 100644 --- a/packages/obsidian-test-session/src/ui.ts +++ b/packages/obsidian-test-session/src/ui.ts @@ -1,4 +1,5 @@ -import { chromium, type Page } from "playwright"; +import { chromium, type BrowserContext, type Page } from "playwright"; +import type { ObsidianReleaseSupport } from "./appimage.js"; /** Basic readiness information read from the active Obsidian renderer. */ export interface PluginReadiness { @@ -10,6 +11,10 @@ export interface PluginReadiness { pluginVersion: string; /** Active vault name, or `unknown` when unavailable. */ vaultName: string; + /** Renderer-observed Obsidian API version, or `unknown` when unavailable. */ + obsidianVersion: string; + /** Review status after an optional session version policy has been applied. */ + obsidianVersionSupport?: ObsidianReleaseSupport; } /** @@ -50,6 +55,58 @@ async function waitForCdp(port: number): Promise { ); } +async function pageOwnsObsidianVault(page: Page): Promise { + return await page + .evaluate(() => { + const app = ( + globalThis as typeof globalThis & { + app?: { vault?: unknown }; + } + ).app; + return app?.vault !== undefined; + }) + .catch(() => false); +} + +async function selectObsidianRendererPage( + context: BrowserContext, +): Promise { + const timeoutMs = Number( + process.env.E2E_OBSIDIAN_RENDERER_TIMEOUT_MS ?? 10_000, + ); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const pages = context.pages(); + const applicationPage = pages.find((candidate) => { + try { + return candidate.url().startsWith("app://obsidian.md/"); + } catch { + return false; + } + }); + if (applicationPage !== undefined) return applicationPage; + + for (const candidate of pages) { + if (await pageOwnsObsidianVault(candidate)) return candidate; + } + + // Focused tests and consumer doubles may expose only the Page operations + // under test rather than a complete Playwright URL implementation. + const onlyPage = pages[0] as (Page & { url?: unknown }) | undefined; + if ( + pages.length === 1 && + onlyPage !== undefined && + typeof onlyPage.url !== "function" + ) { + return onlyPage; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error( + `Timed out waiting for the main Obsidian Vault renderer after ${timeoutMs}ms`, + ); +} + /** * Runs an operation against the active Obsidian Electron renderer. * @@ -67,9 +124,7 @@ export async function withObsidianPage( const context = browser.contexts()[0]; if (context === undefined) throw new Error("Obsidian did not expose a browser context"); - const page = - context.pages()[0] ?? - (await context.waitForEvent("page", { timeout: 10_000 })); + const page = await selectObsidianRendererPage(context); return await operation(page); } finally { await browser.close(); @@ -402,6 +457,30 @@ export async function waitForPluginReady( ); return await page.evaluate((id) => { + let obsidianVersion = "unknown"; + try { + const requireFunction = ( + globalThis as typeof globalThis & { + require?: (moduleId: string) => unknown; + } + ).require; + if (typeof requireFunction === "function") { + const obsidianModule = requireFunction("obsidian") as { + apiVersion?: unknown; + }; + if (typeof obsidianModule.apiVersion === "string") { + obsidianVersion = obsidianModule.apiVersion; + } + } + } catch { + // Fall back to the renderer user agent below. + } + if (obsidianVersion === "unknown") { + const match = /(?:^|\s)Obsidian\/?([0-9]+\.[0-9]+\.[0-9]+)(?:\s|$)/iu.exec( + navigator.userAgent, + ); + if (match?.[1]) obsidianVersion = match[1]; + } const app = ( globalThis as typeof globalThis & { app?: { @@ -415,6 +494,7 @@ export async function waitForPluginReady( pluginId: id, pluginVersion: app?.plugins?.manifests?.[id]?.version ?? "unknown", vaultName: app?.vault?.getName() ?? "unknown", + obsidianVersion, }; }, pluginId); }); diff --git a/packages/obsidian-test-session/updates.md b/packages/obsidian-test-session/updates.md index 8f4b47ef..be2e0831 100644 --- a/packages/obsidian-test-session/updates.md +++ b/packages/obsidian-test-session/updates.md @@ -2,6 +2,19 @@ ## Unreleased +### New features + +- Added a reviewed Obsidian AppImage catalogue with exact architecture-specific asset names, SHA-256 verification, versioned installations, and recorded release metadata. Managed Linux sessions now default to Obsidian 1.13.6. +- Added an explicit unverified-version option for exploratory regression runs. Session readiness now exposes the observed Obsidian API version and can enforce an expected version and reviewed-catalogue policy. + +### 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. + +### Documentation + +- Documented validated release qualification, unverified regression probes, managed AppImage controls, and the boundary between a passing probe and supported-version evidence. + ## 0.2.6 ### New features diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index baff5263..f06bd0aa 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -30,8 +30,11 @@ npm run test:e2e:obsidian:frontmatter npm run test:e2e:obsidian:contracts npm run test:e2e:obsidian:mobile npm run test:e2e:obsidian:local-suite +npm run test:e2e:obsidian:validated ``` +`local-suite` runs the selected target and reports its review status. `validated` runs the same scenarios but rejects the unverified-version override, making it the release-qualification command. + Set `E2E_OBSIDIAN_MOBILE_SCREENSHOT` to capture the mobile scenario while its text-prompt dialogue and simulated iPhone safe-area insets are active: ```bash @@ -53,12 +56,30 @@ export OBSIDIAN_BINARY=/path/to/obsidian export OBSIDIAN_CLI=/path/to/obsidian-cli ``` -On Linux, a reusable AppImage can be downloaded and extracted under `_testdata/obsidian` with: +On Linux, a reusable AppImage can be downloaded and extracted with: ```bash npm run test:e2e:obsidian:install-appimage ``` +The managed default is Obsidian 1.13.6. The installer verifies the reviewed SHA-256 digest and writes the asset, extraction, and `release.json` below `_testdata/obsidian/1.13.6//`. E2E commands discover this exact target but never download it automatically. + +Select another reviewed target with `E2E_OBSIDIAN_VERSION`. For example: + +```bash +E2E_OBSIDIAN_VERSION=1.12.7 npm run test:e2e:obsidian:install-appimage +E2E_OBSIDIAN_VERSION=1.12.7 npm run test:e2e:obsidian:validated +``` + +For a quick regression probe against an exact public release which is not yet in the reviewed catalogue, both selection and execution require the explicit override: + +```bash +E2E_OBSIDIAN_VERSION=1.13.4 E2E_OBSIDIAN_ALLOW_UNVERIFIED_VERSION=true npm run test:e2e:obsidian:install-appimage +E2E_OBSIDIAN_VERSION=1.13.4 E2E_OBSIDIAN_ALLOW_UNVERIFIED_VERSION=true npm run test:e2e:obsidian:local-suite +``` + +The output labels this run `unverified`. It still checks the exact release tag and AppImage asset, observes the running Obsidian version, and runs all E2E assertions, but a pass does not establish supported-version status. `test:e2e:obsidian:validated` rejects the override. + Headless Linux automatically uses `xvfb-run` when available. Set `E2E_OBSIDIAN_KEEP_VAULT=true` to preserve temporary state for inspection. ## Runner lifecycle @@ -67,9 +88,9 @@ For each session, the runner: 1. creates an isolated vault, HOME, XDG, and Electron user-data directory; 2. installs the built harness plug-in and its Automation-mode `data.json`; -3. launches Obsidian on a session-specific DevTools port; +3. launches the selected exact Obsidian version on a session-specific DevTools port; 4. runs any consumer-supplied lifecycle callbacks and starts the target through its selected natural or controlled mode; -5. enables the harness and invokes Automation-only story commands through the active renderer; +5. selects the active Vault renderer rather than any Settings pop-out, verifies the observed Obsidian version, enables the harness, and invokes Automation-only story commands through that renderer; 6. operates the real Modal, SuggestModal, and Notice DOM through Playwright; 7. reads story and contract results from the harness state; 8. terminates Obsidian and removes temporary state unless preservation is enabled. diff --git a/test/e2e-obsidian/runner/harness.ts b/test/e2e-obsidian/runner/harness.ts index 3abf9566..7414b77c 100644 --- a/test/e2e-obsidian/runner/harness.ts +++ b/test/e2e-obsidian/runner/harness.ts @@ -2,6 +2,7 @@ import { resolve } from "node:path"; import { createTemporaryVault, discoverObsidianCli, + obsidianE2EVersionPolicy, requireObsidianBinary, startObsidianPluginSession, withObsidianPage, @@ -77,6 +78,7 @@ export async function startHarnessTestSession( pluginId: HARNESS_PLUGIN_ID, artifactRoot: resolve("apps/obsidian-harness"), pluginData, + versionPolicy: obsidianE2EVersionPolicy(), startupGraceMs: Number( process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1_000, ), diff --git a/test/e2e-obsidian/scripts/install-appimage.ts b/test/e2e-obsidian/scripts/install-appimage.ts index d799d61d..c54ba85f 100644 --- a/test/e2e-obsidian/scripts/install-appimage.ts +++ b/test/e2e-obsidian/scripts/install-appimage.ts @@ -1,4 +1,5 @@ import { + allowUnverifiedObsidianVersion, installObsidianAppImage, obsidianAppImageArchitecture, type ObsidianAppImageArchitecture, @@ -18,12 +19,24 @@ async function main(): Promise { architecture: requestedArchitecture(), targetDirectory: process.env.E2E_OBSIDIAN_DOWNLOAD_DIR, url: process.env.E2E_OBSIDIAN_APPIMAGE_URL, + allowUnverifiedVersion: allowUnverifiedObsidianVersion(), forceDownload: process.env.E2E_OBSIDIAN_FORCE_DOWNLOAD === "true", extract: process.env.E2E_OBSIDIAN_SKIP_EXTRACT !== "true", }); console.log( `Set OBSIDIAN_BINARY=${result.extractedBinary} to use the extracted binary explicitly.`, ); + console.log( + JSON.stringify({ + version: result.version, + architecture: result.architecture, + support: result.support, + assetName: result.assetName, + url: result.url, + sha256: result.sha256, + extractedBinary: result.extractedBinary, + }), + ); } main().catch((error: unknown) => { diff --git a/test/e2e-obsidian/scripts/local-suite.ts b/test/e2e-obsidian/scripts/local-suite.ts index 5e18561f..4534352e 100644 --- a/test/e2e-obsidian/scripts/local-suite.ts +++ b/test/e2e-obsidian/scripts/local-suite.ts @@ -1,4 +1,8 @@ import { spawn } from "node:child_process"; +import { + obsidianE2EVersionPolicy, + selectObsidianVersion, +} from "@vrtmrz/obsidian-test-session"; const steps = [ "session-lifecycle.ts", @@ -32,6 +36,32 @@ function run(command: string, args: string[]): Promise { } async function main(): Promise { + const versionPolicy = obsidianE2EVersionPolicy(); + const requireValidated = process.argv.includes("--validated"); + if (requireValidated && versionPolicy.allowUnverifiedVersion) { + throw new Error( + "The validated Obsidian E2E suite does not permit E2E_OBSIDIAN_ALLOW_UNVERIFIED_VERSION=true", + ); + } + const target = versionPolicy.expectedVersion + ? selectObsidianVersion( + versionPolicy.expectedVersion, + versionPolicy.allowUnverifiedVersion, + ) + : undefined; + console.log( + JSON.stringify({ + event: "obsidian-e2e-target", + requestedVersion: target?.version ?? null, + support: target?.support ?? "observed-at-runtime", + validatedSuite: requireValidated, + }), + ); + if (target?.support === "unverified") { + console.warn( + `Warning: Obsidian ${target.version} is not in the validated E2E release catalogue. A passing probe does not establish supported-version status.`, + ); + } const npm = process.platform === "win32" ? "npm.cmd" : "npm"; const npx = process.platform === "win32" ? "npx.cmd" : "npx"; await run(npm, ["run", "build:harness"]); @@ -39,6 +69,14 @@ async function main(): Promise { console.log(`\n# ${step}`); await run(npx, ["tsx", `test/e2e-obsidian/scripts/${step}`]); } + console.log( + JSON.stringify({ + event: "obsidian-e2e-result", + requestedVersion: target?.version ?? null, + support: target?.support ?? "observed-at-runtime", + suite: "passed", + }), + ); } main().catch((error: unknown) => { diff --git a/test/e2e-obsidian/scripts/mobile.ts b/test/e2e-obsidian/scripts/mobile.ts index da4ffdca..a0393f1d 100644 --- a/test/e2e-obsidian/scripts/mobile.ts +++ b/test/e2e-obsidian/scripts/mobile.ts @@ -130,7 +130,9 @@ async function assertFitsMobileModal( ): Promise { await assertFitsViewport(page, modal, description); await assertLocatorWithinSafeArea(page, modal, { label: description }); - const closeButton = modal.locator(".modal-close-button"); + const closeButton = modal.locator( + ".modal-close-button, .modal-header-button", + ); await assertLocatorWithinSafeArea(page, closeButton, { label: `${description} close button`, }); diff --git a/test/e2e-obsidian/scripts/profile-restart.ts b/test/e2e-obsidian/scripts/profile-restart.ts index 4758bc34..e5ff1856 100644 --- a/test/e2e-obsidian/scripts/profile-restart.ts +++ b/test/e2e-obsidian/scripts/profile-restart.ts @@ -2,6 +2,7 @@ import { resolve } from "node:path"; import { createTemporaryVault, discoverObsidianCli, + obsidianE2EVersionPolicy, requireObsidianBinary, startObsidianPluginSession, withObsidianPage, @@ -25,6 +26,7 @@ async function startSession( pluginId: HARNESS_PLUGIN_ID, artifactRoot: resolve("apps/obsidian-harness"), pluginData, + versionPolicy: obsidianE2EVersionPolicy(), startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1_000), }); } diff --git a/test/e2e-obsidian/scripts/session-lifecycle.ts b/test/e2e-obsidian/scripts/session-lifecycle.ts index 7e411782..00701f4f 100644 --- a/test/e2e-obsidian/scripts/session-lifecycle.ts +++ b/test/e2e-obsidian/scripts/session-lifecycle.ts @@ -16,6 +16,7 @@ import { join, resolve } from "node:path"; import { createTemporaryVault, discoverObsidianCli, + obsidianE2EVersionPolicy, requireObsidianBinary, startObsidianPluginSession, withObsidianPage, @@ -106,6 +107,7 @@ async function main(): Promise { }); }, }, + versionPolicy: obsidianE2EVersionPolicy(), startupGraceMs: Number( process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1_000, ), diff --git a/test/packed-consumer/test-session-usage.ts b/test/packed-consumer/test-session-usage.ts index 647ea075..950caac7 100644 --- a/test/packed-consumer/test-session-usage.ts +++ b/test/packed-consumer/test-session-usage.ts @@ -10,18 +10,44 @@ import { assertLocatorWithinSafeArea, assertLocatorWithinViewport, assertNoHorizontalOverflow, + DEFAULT_VALIDATED_OBSIDIAN_VERSION, enablePluginAndSave, ensurePluginLoaded, + installObsidianAppImage, inspectLocatorLayout, + VALIDATED_OBSIDIAN_RELEASES, + type InstallObsidianAppImageResult, type LayoutAssertionOptions, type LayoutInsets, type LocatorLayoutInspection, type SafeAreaAssertionOptions, type ObsidianPluginSessionLifecycle, + type PluginReadiness, type StartObsidianPluginSessionOptions, type TouchTargetAssertionOptions, } from "@vrtmrz/obsidian-test-session"; +export const defaultReviewedObsidianRelease = + VALIDATED_OBSIDIAN_RELEASES[DEFAULT_VALIDATED_OBSIDIAN_VERSION]; + +export async function prepareReviewedObsidianAppImage( + targetDirectory: string, +): Promise { + return await installObsidianAppImage({ + version: DEFAULT_VALIDATED_OBSIDIAN_VERSION, + targetDirectory, + extract: false, + }); +} + +export function requireReviewedReadiness( + readiness: PluginReadiness, +): string { + if (readiness.obsidianVersionSupport !== "validated") + throw new Error("Expected reviewed Obsidian readiness"); + return readiness.obsidianVersion; +} + export async function startControlledPlugin( remoteDebuggingPort: number, pluginId: string, @@ -47,6 +73,9 @@ export function withDeviceLocalState( }; return { ...options, + versionPolicy: { + expectedVersion: DEFAULT_VALIDATED_OBSIDIAN_VERSION, + }, localStorageEntries: { "example-plugin-device-schema": "3", },