From 529c984a8a585d503d35610f73b3791e8d1afe15 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 26 Feb 2026 15:24:42 -0500 Subject: [PATCH 1/2] Refactor e2e tests: extract helpers, add step screenshots, split specs Extract inline Obsidian interaction logic into reusable helpers (commands, vault, modal, screenshots) and split smoke.spec.ts into focused spec files for plugin-load and node-creation. Add JSON reporter to playwright config. Co-Authored-By: Claude Opus 4.6 --- apps/obsidian/.gitignore | 3 + apps/obsidian/e2e/helpers/commands.ts | 50 +++++++++++ apps/obsidian/e2e/helpers/modal.ts | 52 ++++++++++++ apps/obsidian/e2e/helpers/screenshots.ts | 22 +++++ apps/obsidian/e2e/helpers/vault.ts | 61 +++++++++++++ apps/obsidian/e2e/playwright.config.ts | 21 +++++ apps/obsidian/e2e/tests/node-creation.spec.ts | 76 +++++++++++++++++ apps/obsidian/e2e/tests/plugin-load.spec.ts | 45 ++++++++++ apps/obsidian/e2e/tests/smoke.spec.ts | 85 +++++++++++++++++++ 9 files changed, 415 insertions(+) create mode 100644 apps/obsidian/.gitignore create mode 100644 apps/obsidian/e2e/helpers/commands.ts create mode 100644 apps/obsidian/e2e/helpers/modal.ts create mode 100644 apps/obsidian/e2e/helpers/screenshots.ts create mode 100644 apps/obsidian/e2e/helpers/vault.ts create mode 100644 apps/obsidian/e2e/playwright.config.ts create mode 100644 apps/obsidian/e2e/tests/node-creation.spec.ts create mode 100644 apps/obsidian/e2e/tests/plugin-load.spec.ts create mode 100644 apps/obsidian/e2e/tests/smoke.spec.ts diff --git a/apps/obsidian/.gitignore b/apps/obsidian/.gitignore new file mode 100644 index 000000000..a4569d0d5 --- /dev/null +++ b/apps/obsidian/.gitignore @@ -0,0 +1,3 @@ +e2e/test-vault*/ +e2e/test-results/ +e2e/html-report/ diff --git a/apps/obsidian/e2e/helpers/commands.ts b/apps/obsidian/e2e/helpers/commands.ts new file mode 100644 index 000000000..66b7a9e23 --- /dev/null +++ b/apps/obsidian/e2e/helpers/commands.ts @@ -0,0 +1,50 @@ +import type { Page } from "@playwright/test"; + +/** + * Execute a command via Obsidian's internal command API (fast, reliable). + * Use this for most test scenarios. + */ +export const executeCommand = async ( + page: Page, + commandId: string, +): Promise => { + await page.evaluate((id) => { + // @ts-expect-error - Obsidian's global `app` is available at runtime + app.commands.executeCommandById(`@discourse-graph/obsidian:${id}`); + }, commandId); + await page.waitForTimeout(500); +}; + +/** + * Execute a command via the command palette UI. + * Use this when testing the palette interaction itself. + */ +export const executeCommandViaPalette = async ( + page: Page, + commandLabel: string, +): Promise => { + await page.keyboard.press("Meta+p"); + await page.waitForTimeout(500); + + await page.keyboard.type(commandLabel, { delay: 30 }); + await page.waitForTimeout(500); + + await page.keyboard.press("Enter"); + await page.waitForTimeout(1_000); +}; + +/** + * Ensure an active editor exists by creating and opening a scratch file. + * Required before running editorCallback commands like "Create discourse node". + */ +export const ensureActiveEditor = async (page: Page): Promise => { + await page.evaluate(async () => { + // @ts-expect-error - Obsidian's global `app` is available at runtime + const vault = app.vault; + const fileName = `scratch-e2e-${Date.now()}.md`; + const file = await vault.create(fileName, ""); + // @ts-expect-error - Obsidian's global `app` is available at runtime + await app.workspace.openLinkText(file.path, "", false); + }); + await page.waitForTimeout(1_000); +}; diff --git a/apps/obsidian/e2e/helpers/modal.ts b/apps/obsidian/e2e/helpers/modal.ts new file mode 100644 index 000000000..607e7029b --- /dev/null +++ b/apps/obsidian/e2e/helpers/modal.ts @@ -0,0 +1,52 @@ +import type { Page } from "@playwright/test"; + +/** + * Wait for the ModifyNodeModal to appear. + */ +export const waitForModal = async (page: Page): Promise => { + await page.waitForSelector(".modal-container", { timeout: 5_000 }); +}; + +/** + * Select a node type from the dropdown in the modal. + */ +export const selectNodeType = async ( + page: Page, + label: string, +): Promise => { + const nodeTypeSelect = page.locator(".modal-container select").first(); + await nodeTypeSelect.selectOption({ label }); + await page.waitForTimeout(300); +}; + +/** + * Fill the content/title input field in the modal. + */ +export const fillNodeContent = async ( + page: Page, + content: string, +): Promise => { + const contentInput = page + .locator(".modal-container input[type='text']") + .first(); + await contentInput.click(); + await contentInput.fill(content); + await page.waitForTimeout(300); +}; + +/** + * Click the Confirm button (mod-cta) in the modal. + */ +export const confirmModal = async (page: Page): Promise => { + // Use force: true to bypass any suggestion/autocomplete overlay that may cover the button + await page.locator(".modal-container button.mod-cta").click({ force: true }); + await page.waitForTimeout(2_000); +}; + +/** + * Click the Cancel button in the modal. + */ +export const cancelModal = async (page: Page): Promise => { + await page.locator(".modal-container button:not(.mod-cta)").click(); + await page.waitForTimeout(500); +}; diff --git a/apps/obsidian/e2e/helpers/screenshots.ts b/apps/obsidian/e2e/helpers/screenshots.ts new file mode 100644 index 000000000..94c98f354 --- /dev/null +++ b/apps/obsidian/e2e/helpers/screenshots.ts @@ -0,0 +1,22 @@ +import path from "path"; +import fs from "fs"; +import type { Page } from "@playwright/test"; + +const TEST_RESULTS_DIR = path.join(__dirname, "..", "test-results"); + +/** + * Capture a step-based screenshot organized by test name. + * Saves to: test-results//.png + */ +export const captureStep = async ( + page: Page, + testName: string, + stepName: string, +): Promise => { + const dir = path.join(TEST_RESULTS_DIR, testName); + fs.mkdirSync(dir, { recursive: true }); + + const filePath = path.join(dir, `${stepName}.png`); + await page.screenshot({ path: filePath }); + return filePath; +}; diff --git a/apps/obsidian/e2e/helpers/vault.ts b/apps/obsidian/e2e/helpers/vault.ts new file mode 100644 index 000000000..80cf80de3 --- /dev/null +++ b/apps/obsidian/e2e/helpers/vault.ts @@ -0,0 +1,61 @@ +import type { Page } from "@playwright/test"; + +/** + * Find markdown files whose basename starts with the given prefix. + * Returns an array of basenames. + */ +export const findFilesByPrefix = async ( + page: Page, + prefix: string, +): Promise => { + return page.evaluate((pfx) => { + // @ts-expect-error - Obsidian's global `app` is available at runtime + const files = app?.vault?.getMarkdownFiles() || []; + return files + .filter((f: { basename: string }) => f.basename.startsWith(pfx)) + .map((f: { basename: string }) => f.basename); + }, prefix); +}; + +/** + * Read the text content of a markdown file by its basename. + * Returns null if the file is not found. + */ +export const readFileContent = async ( + page: Page, + basename: string, +): Promise => { + return page.evaluate(async (name) => { + // @ts-expect-error - Obsidian's global `app` is available at runtime + const files = app?.vault?.getMarkdownFiles() || []; + const file = files.find((f: { basename: string }) => f.basename === name); + if (!file) return null; + // @ts-expect-error - Obsidian's global `app` is available at runtime + return app.vault.read(file); + }, basename); +}; + +/** + * Check whether a plugin is loaded in Obsidian's plugin registry. + */ +export const isPluginLoaded = async ( + page: Page, + pluginId: string, +): Promise => { + return page.evaluate((id) => { + // @ts-expect-error - Obsidian's global `app` is available at runtime + const plugins = app?.plugins?.plugins; + return plugins ? id in plugins : false; + }, pluginId); +}; + +/** + * Get all markdown file basenames in the vault. + */ +export const getMarkdownFiles = async (page: Page): Promise => { + return page.evaluate(() => { + // @ts-expect-error - Obsidian's global `app` is available at runtime + const files = app?.vault?.getMarkdownFiles() || []; + return files.map((f: { basename: string }) => f.basename); + }); +}; diff --git a/apps/obsidian/e2e/playwright.config.ts b/apps/obsidian/e2e/playwright.config.ts new file mode 100644 index 000000000..91210ca4c --- /dev/null +++ b/apps/obsidian/e2e/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + timeout: 60_000, + expect: { + timeout: 10_000, + }, + retries: 0, + workers: 1, + reporter: [ + ["list"], + ["html", { outputFolder: "html-report" }], + ["json", { outputFile: "test-results/report.json" }], + ], + use: { + screenshot: "only-on-failure", + trace: "retain-on-failure", + }, + outputDir: "test-results", +}); diff --git a/apps/obsidian/e2e/tests/node-creation.spec.ts b/apps/obsidian/e2e/tests/node-creation.spec.ts new file mode 100644 index 000000000..511083d33 --- /dev/null +++ b/apps/obsidian/e2e/tests/node-creation.spec.ts @@ -0,0 +1,76 @@ +import { test, expect, type Browser, type Page } from "@playwright/test"; +import path from "path"; +import type { ChildProcess } from "child_process"; +import { + createTestVault, + cleanTestVault, + launchObsidian, +} from "../helpers/obsidian-setup"; +import { + ensureActiveEditor, + executeCommandViaPalette, +} from "../helpers/commands"; +import { findFilesByPrefix, readFileContent } from "../helpers/vault"; +import { + waitForModal, + selectNodeType, + fillNodeContent, + confirmModal, +} from "../helpers/modal"; +import { captureStep } from "../helpers/screenshots"; + +const VAULT_PATH = path.join(__dirname, "..", "test-vault-node-creation"); + +let browser: Browser; +let page: Page; +let obsidianProcess: ChildProcess; + +test.beforeAll(async () => { + createTestVault(VAULT_PATH); + const launched = await launchObsidian(VAULT_PATH); + browser = launched.browser; + page = launched.page; + obsidianProcess = launched.obsidianProcess; + + // Wait for plugins to initialize + await page.waitForTimeout(5_000); +}); + +test.afterAll(async () => { + if (browser) { + await browser.close(); + } + if (obsidianProcess) { + obsidianProcess.kill(); + } + cleanTestVault(VAULT_PATH); +}); + +test("Create a Question node via command palette", async () => { + await ensureActiveEditor(page); + + await executeCommandViaPalette( + page, + "Discourse Graph: Create discourse node", + ); + + await waitForModal(page); + await captureStep(page, "node-creation", "01-modal-open"); + + await selectNodeType(page, "Question"); + await fillNodeContent(page, `What is discourse graph testing ${Date.now()}`); + await captureStep(page, "node-creation", "02-modal-filled"); + + await confirmModal(page); + await captureStep(page, "node-creation", "03-node-created"); + + // Verify file was created with correct prefix + const files = await findFilesByPrefix(page, "QUE -"); + expect(files.length).toBeGreaterThan(0); + + // Verify frontmatter contains nodeTypeId + const content = await readFileContent(page, files[0]!); + if (content) { + expect(content).toContain("nodeTypeId"); + } +}); diff --git a/apps/obsidian/e2e/tests/plugin-load.spec.ts b/apps/obsidian/e2e/tests/plugin-load.spec.ts new file mode 100644 index 000000000..ff42d9857 --- /dev/null +++ b/apps/obsidian/e2e/tests/plugin-load.spec.ts @@ -0,0 +1,45 @@ +import { test, expect, type Browser, type Page } from "@playwright/test"; +import path from "path"; +import type { ChildProcess } from "child_process"; +import { + createTestVault, + cleanTestVault, + launchObsidian, +} from "../helpers/obsidian-setup"; +import { isPluginLoaded } from "../helpers/vault"; +import { captureStep } from "../helpers/screenshots"; + +const VAULT_PATH = path.join(__dirname, "..", "test-vault-plugin-load"); +const PLUGIN_ID = "@discourse-graph/obsidian"; + +let browser: Browser; +let page: Page; +let obsidianProcess: ChildProcess; + +test.beforeAll(async () => { + createTestVault(VAULT_PATH); + const launched = await launchObsidian(VAULT_PATH); + browser = launched.browser; + page = launched.page; + obsidianProcess = launched.obsidianProcess; +}); + +test.afterAll(async () => { + if (browser) { + await browser.close(); + } + if (obsidianProcess) { + obsidianProcess.kill(); + } + cleanTestVault(VAULT_PATH); +}); + +test("Plugin loads in Obsidian", async () => { + await page.waitForTimeout(5_000); + + const pluginLoaded = await isPluginLoaded(page, PLUGIN_ID); + + await captureStep(page, "plugin-load", "01-plugin-loaded"); + + expect(pluginLoaded).toBe(true); +}); diff --git a/apps/obsidian/e2e/tests/smoke.spec.ts b/apps/obsidian/e2e/tests/smoke.spec.ts new file mode 100644 index 000000000..7e5c567b0 --- /dev/null +++ b/apps/obsidian/e2e/tests/smoke.spec.ts @@ -0,0 +1,85 @@ +import { test, expect, type Browser, type Page } from "@playwright/test"; +import path from "path"; +import type { ChildProcess } from "child_process"; +import { + createTestVault, + cleanTestVault, + launchObsidian, +} from "../helpers/obsidian-setup"; +import { + ensureActiveEditor, + executeCommandViaPalette, +} from "../helpers/commands"; +import { + isPluginLoaded, + findFilesByPrefix, + readFileContent, +} from "../helpers/vault"; +import { + waitForModal, + selectNodeType, + fillNodeContent, + confirmModal, +} from "../helpers/modal"; +import { captureStep } from "../helpers/screenshots"; + +const VAULT_PATH = path.join(__dirname, "..", "test-vault-smoke"); +const PLUGIN_ID = "@discourse-graph/obsidian"; + +let browser: Browser; +let page: Page; +let obsidianProcess: ChildProcess; + +test.beforeAll(async () => { + createTestVault(VAULT_PATH); + const launched = await launchObsidian(VAULT_PATH); + browser = launched.browser; + page = launched.page; + obsidianProcess = launched.obsidianProcess; +}); + +test.afterAll(async () => { + if (browser) { + await browser.close(); + } + if (obsidianProcess) { + obsidianProcess.kill(); + } + cleanTestVault(VAULT_PATH); +}); + +test("Plugin loads in Obsidian", async () => { + await page.waitForTimeout(5_000); + + const pluginLoaded = await isPluginLoaded(page, PLUGIN_ID); + await captureStep(page, "smoke", "01-plugin-loaded"); + + expect(pluginLoaded).toBe(true); +}); + +test("Create a discourse node via command palette", async () => { + await ensureActiveEditor(page); + + await executeCommandViaPalette( + page, + "Discourse Graph: Create discourse node", + ); + + await waitForModal(page); + await captureStep(page, "smoke", "02-modal-open"); + + await selectNodeType(page, "Question"); + await fillNodeContent(page, `What is discourse graph testing ${Date.now()}`); + await captureStep(page, "smoke", "03-modal-filled"); + + await confirmModal(page); + await captureStep(page, "smoke", "04-node-created"); + + const files = await findFilesByPrefix(page, "QUE -"); + expect(files.length).toBeGreaterThan(0); + + const content = await readFileContent(page, files[0]!); + if (content) { + expect(content).toContain("nodeTypeId"); + } +}); From c9f522a53ff4d22c1e5b4285eca1dff49d4252a6 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 13 Apr 2026 11:20:55 -0400 Subject: [PATCH 2/2] Harden e2e test setup: vault selection, page detection, and review fixes - Fix vault selection by manipulating obsidian.json instead of unsupported --vault flag - Find correct workspace page via DOM query instead of assuming pages()[0] - Load .env via dotenv for OBSIDIAN_TEST_VAULT configuration - Replace blind waitForTimeout calls with proper selector/function waits - Add ensureVaultWithPlugin for safe setup on existing vaults - Add CDP connection retry logic - Scope eslint-disables to individual page.evaluate blocks - Use crypto.randomBytes for proper hex vault IDs Co-Authored-By: Claude Opus 4.6 --- apps/obsidian/e2e/NOTES.md | 179 ++++++++++++ apps/obsidian/e2e/helpers/commands.ts | 16 +- apps/obsidian/e2e/helpers/obsidian-setup.ts | 274 ++++++++++++++++++ apps/obsidian/e2e/helpers/vault.ts | 30 ++ apps/obsidian/e2e/playwright.config.ts | 4 + apps/obsidian/e2e/tests/node-creation.spec.ts | 34 ++- apps/obsidian/e2e/tests/plugin-load.spec.ts | 28 +- apps/obsidian/e2e/tests/smoke.spec.ts | 27 +- apps/obsidian/eslint.config.mjs | 3 + apps/obsidian/package.json | 5 +- 10 files changed, 558 insertions(+), 42 deletions(-) create mode 100644 apps/obsidian/e2e/NOTES.md create mode 100644 apps/obsidian/e2e/helpers/obsidian-setup.ts diff --git a/apps/obsidian/e2e/NOTES.md b/apps/obsidian/e2e/NOTES.md new file mode 100644 index 000000000..801713cc3 --- /dev/null +++ b/apps/obsidian/e2e/NOTES.md @@ -0,0 +1,179 @@ +# E2E Testing for Obsidian Plugin — Notes + +## Approaches Considered + +### Option 1: Playwright `electron.launch()` + +The standard Playwright approach for Electron apps — point `executablePath` at the binary and let Playwright manage the process lifecycle. + +**Pros:** + +- First-class Playwright API — `app.evaluate()` runs code in the main process, not just renderer +- Automatic process lifecycle management (launch, close, cleanup) +- Access to Electron-specific APIs (e.g., `app.evaluate(() => process.env)`) +- Well-documented, widely used for Electron testing + +**Cons:** + +- **Does not work with Obsidian.** Obsidian's executable is a launcher that loads an `.asar` package (`obsidian-1.11.7.asar`) and forks a new Electron process. Playwright connects to the initial process, which exits, causing `kill EPERM` and connection failures. +- No workaround without modifying Obsidian's startup or using a custom Electron shell + +**Verdict:** Not viable for Obsidian. + +--- + +### Option 2: CDP via `chromium.connectOverCDP()` (chosen) + +Launch Obsidian as a subprocess with `--remote-debugging-port=9222`, then connect via Chrome DevTools Protocol. + +**Pros:** + +- Works with Obsidian's forked process architecture — the debug port is inherited by the child process +- Full access to renderer via `page.evaluate()` — Obsidian's global `app` object is available +- Keyboard/mouse interaction works normally +- Can take screenshots, traces, and use all Playwright assertions +- Process is managed explicitly — clear control over startup and teardown + +**Cons:** + +- No main process access (can't call Electron APIs directly, only renderer-side `window`/`app`) +- Must manually manage process lifecycle (spawn, pkill, port polling) +- Fixed debug port (9222) means tests can't run in parallel across multiple Obsidian instances without port management +- Port polling adds ~2-5s startup overhead +- `pkill -f Obsidian` in setup is aggressive — kills ALL Obsidian instances, not just test ones + +**Verdict:** Works well for PoC. Sufficient for single-worker CI/local testing. + +--- + +### Option 3: Obsidian's built-in plugin testing (not explored) + +Obsidian has no official testing framework. Some community approaches exist (e.g., `obsidian-jest`, hot-reload-based testing), but none are mature or maintained. + +**Verdict:** Not a real option today. + +--- + +## What We Learned + +### Obsidian internals accessible via `page.evaluate()` + +- `app.plugins.plugins["@discourse-graph/obsidian"]` — check plugin loaded +- `app.vault.getMarkdownFiles()` — list files +- `app.vault.read(file)` — read file content +- `app.vault.create(name, content)` — create files +- `app.workspace.openLinkText(path, "", false)` — open a file in the editor +- `app.commands.executeCommandById(id)` — could execute commands directly (alternative to command palette UI) + +### Plugin command IDs + +Commands are registered with IDs like `@discourse-graph/obsidian:create-discourse-node`. The command palette shows them as "Discourse Graph: Create discourse node". + +### Modal DOM structure + +The `ModifyNodeModal` renders React inside Obsidian's `.modal-container`: + +- Node type: `` (`.modal-container input[type='text']`) +- Confirm: `