diff --git a/.gitignore b/.gitignore index a5208bf..75c611b 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,7 @@ dist # Cursor skill symlink (canonical skill lives under .opencode/skills/) .cursor/skills/textile-design + +# Playwright +test-results/ +playwright-report/ diff --git a/e2e/appFixture.ts b/e2e/appFixture.ts new file mode 100644 index 0000000..5a84c15 --- /dev/null +++ b/e2e/appFixture.ts @@ -0,0 +1,185 @@ +import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + _electron as electron, + type ElectronApplication, + type Page, +} from '@playwright/test'; + +const repoRoot = join(__dirname, '..'); + +/** Vault id used for the throwaway vault; the provider id becomes `vault-e2e`. */ +const VAULT_ID = 'e2e'; + +/** Enough body text that the document is comfortably taller than the viewport. */ +const filler = (label: string, lines = 40) => + Array.from({ length: lines }, (_, i) => `${label} body line ${i + 1}.`).join( + '\n', + ); + +/** + * Notes every test shares. `Target.md` is deliberately long so that a heading + * partway down cannot be at the top of the viewport by accident. + */ +export const NOTES: Record = { + 'Target.md': [ + '# Target', + filler('intro'), + '', + '## Middle Section', + filler('middle'), + '', + '## Later Section', + filler('later'), + ].join('\n'), + 'Source.md': [ + '# Source', + '', + 'A plain link to [[Target]].', + '', + 'An anchored link to [[Target#Middle Section]].', + '', + filler('source'), + ].join('\n'), + 'Other.md': ['# Other', '', filler('other')].join('\n'), +}; + +/** + * The application window — explicitly not `app.firstWindow()`. + * + * `src/main.ts` opens DevTools unconditionally, and Playwright counts the + * DevTools page as a window. It frequently arrives first, and it is a plausible + * enough `Page` that everything appears to work while actually driving DevTools: + * `localStorage` writes land on the wrong origin and screenshots fail with + * "0 width" rather than anything that names the real problem. + */ +async function appWindow(app: ElectronApplication): Promise { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const candidate = app + .windows() + .find((w) => !w.url().startsWith('devtools://')); + if (candidate) return candidate; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error('Timed out waiting for a non-DevTools application window'); +} + +export type LaunchedApp = { + app: ElectronApplication; + window: Page; + vaultDir: string; + close: () => Promise; +}; + +/** + * Launch the packaged app against a throwaway vault. + * + * The vault is seeded straight into `localStorage` and the window reloaded, + * rather than driven through the vault-manager UI: the registry reads + * `textile.openVaults` once at module load, so seeding then reloading is both + * simpler and less brittle than clicking through a dialog whose markup is not + * what these tests are about. + * + * `TEXTILE_USER_DATA` (a hook `src/main.ts` already provides for exactly this) + * points the app at a temp userData directory, so a run never inherits or + * clobbers the developer's real window state, vault list or tab layout — and it + * bypasses the single-instance lock that would otherwise make the app quit + * immediately when one is already running. + */ +export async function launchApp(): Promise { + const vaultDir = await mkdtemp(join(tmpdir(), 'textile-e2e-vault-')); + const userDataDir = await mkdtemp(join(tmpdir(), 'textile-e2e-userdata-')); + await mkdir(vaultDir, { recursive: true }); + for (const [name, body] of Object.entries(NOTES)) { + await writeFile(join(vaultDir, name), body, 'utf-8'); + } + + // `ELECTRON_RUN_AS_NODE` must not survive into the child: it makes the binary + // run as plain Node, so `require('electron').app` is undefined and Chromium + // switches (including the debugging port Playwright needs) are rejected. Some + // tool environments export it, and inheriting it fails in a very confusing way. + const { ELECTRON_RUN_AS_NODE: _ignored, ...parentEnv } = process.env; + + const app = await electron.launch({ + args: ['.'], + cwd: repoRoot, + env: { ...parentEnv, TEXTILE_USER_DATA: userDataDir }, + }); + + const window = await appWindow(app); + await window.waitForLoadState('domcontentloaded'); + + // Close the DevTools `src/main.ts` always opens, and give the window a real + // size. Both matter: docked DevTools leaves the editor a sliver, and + // CodeMirror only renders its visible viewport — so in a small window most of + // a document is genuinely absent from the DOM and assertions about content + // further down fail for reasons that have nothing to do with the behaviour + // under test. + await app.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0]; + if (!win) return; + win.webContents.closeDevTools(); + win.setBounds({ x: 0, y: 0, width: 1400, height: 900 }); + }); + + await window.evaluate( + ({ dir, id }) => { + localStorage.setItem( + 'textile.openVaults', + JSON.stringify([{ state: 'local', id, name: 'E2E Vault', path: dir }]), + ); + localStorage.setItem('textile.selectedVault', `vault-${id}`); + }, + { dir: vaultDir, id: VAULT_ID }, + ); + await window.reload(); + await window.waitForLoadState('domcontentloaded'); + + // The sidebar polls the tree, so the first file appearing is the signal that + // the vault mounted and the app is ready to drive. + await window.getByText('Target.md', { exact: true }).first().waitFor(); + + return { + app, + window, + vaultDir, + close: async () => { + // Teardown has to be unconditional, not cooperative. On macOS the app + // deliberately survives its last window closing (`src/main.ts`), and + // asking it to quit is not reliable either — a stuck instance makes + // Playwright's worker teardown hang for 60s and report the timeout + // against whichever test happened to run first, which reads as random + // flakiness somewhere else entirely. + const proc = app.process(); + await Promise.race([ + app.close().catch(() => {}), + new Promise((resolve) => setTimeout(resolve, 5_000)), + ]); + try { + if (proc.exitCode === null && proc.signalCode === null) { + proc.kill('SIGKILL'); + } + } catch { + // Already gone. + } + await rm(vaultDir, { recursive: true, force: true }); + await rm(userDataDir, { recursive: true, force: true }); + }, + }; +} + +/** Open a note by clicking it in the sidebar tree, and wait for its editor. */ +export async function openNote(window: Page, fileName: string): Promise { + await window.getByText(fileName, { exact: true }).first().click(); + await window.locator('.cm-content').first().waitFor(); +} + +/** The editor's scrollable element — CodeMirror's own, not an outer wrapper. */ +export function scroller(window: Page) { + return window.locator('.cm-scroller').first(); +} + +export const scrollTop = (window: Page) => + scroller(window).evaluate((el) => el.scrollTop); diff --git a/e2e/completion.spec.ts b/e2e/completion.spec.ts new file mode 100644 index 0000000..0ee68f1 --- /dev/null +++ b/e2e/completion.spec.ts @@ -0,0 +1,118 @@ +import { test, expect } from '@playwright/test'; +import { launchApp, openNote } from './appFixture'; + +const popup = '.cm-tooltip-autocomplete'; + +/** Put the caret at the very end of the open document. */ +async function focusEndOfDoc( + window: Awaited>['window'], +) { + await window.locator('.cm-content').first().click(); + await window.keyboard.press( + process.platform === 'darwin' ? 'Meta+End' : 'Control+End', + ); + await window.keyboard.press('Enter'); +} + +test.describe('[[ completion popup', () => { + test('offers note names, in the app font rather than monospace', async () => { + const { window, close } = await launchApp(); + try { + await openNote(window, 'Other.md'); + await focusEndOfDoc(window); + await window.keyboard.type('[['); + + await expect(window.locator(popup)).toBeVisible(); + await expect(window.locator(`${popup} li`).first()).toBeVisible(); + await expect(window.locator(popup)).toContainText('Target'); + + // CodeMirror's base theme hardcodes `font-family: monospace` on the list, + // and our override lost to it on specificity for two rounds. These are + // note names, not code. + const fontFamily = await window + .locator(`${popup} > ul`) + .evaluate((el) => getComputedStyle(el).fontFamily); + expect(fontFamily.toLowerCase()).not.toContain('monospace'); + } finally { + await close(); + } + }); + + test('shows the modifier hints pinned below the option list', async () => { + const { window, close } = await launchApp(); + try { + await openNote(window, 'Other.md'); + await focusEndOfDoc(window); + await window.keyboard.type('[['); + + const footer = window.locator('.cm-wikilink-completion-footer'); + await expect(footer).toBeVisible(); + await expect(footer).toContainText('heading'); + await expect(footer).toContainText('display text'); + + // CodeMirror re-appends the
    on every update, which lands it AFTER + // the footer in DOM order; flex `order` is what keeps the footer visually + // last. Assert the rendered geometry, not the DOM order. + await window.keyboard.type('T'); + const listBox = await window.locator(`${popup} > ul`).boundingBox(); + const footerBox = await footer.boundingBox(); + expect(listBox).not.toBeNull(); + expect(footerBox).not.toBeNull(); + expect(footerBox!.y).toBeGreaterThanOrEqual( + listBox!.y + listBox!.height - 1, + ); + } finally { + await close(); + } + }); + + test('completes headings after `#`, labelled with their level', async () => { + const { window, close } = await launchApp(); + try { + await openNote(window, 'Other.md'); + await focusEndOfDoc(window); + await window.keyboard.type('[[Target#'); + + await expect(window.locator(popup)).toBeVisible(); + await expect(window.locator(popup)).toContainText('Middle Section'); + await expect(window.locator(popup)).toContainText('Later Section'); + + const badges = window.locator('.cm-wikilink-level'); + await expect(badges.first()).toBeVisible(); + const levels = await badges.allTextContents(); + // Target.md is `# Target` then two `##` sections. + expect(levels).toContain('H1'); + expect(levels).toContain('H2'); + } finally { + await close(); + } + }); + + test('completes the current document’s headings after `[[#`', async () => { + const { window, close } = await launchApp(); + try { + await openNote(window, 'Target.md'); + await focusEndOfDoc(window); + await window.keyboard.type('[[#'); + + await expect(window.locator(popup)).toBeVisible(); + await expect(window.locator(popup)).toContainText('Middle Section'); + } finally { + await close(); + } + }); + + test('closes once a `|` alias is started', async () => { + const { window, close } = await launchApp(); + try { + await openNote(window, 'Other.md'); + await focusEndOfDoc(window); + await window.keyboard.type('[[Target'); + await expect(window.locator(popup)).toBeVisible(); + await window.keyboard.type('|'); + await expect(window.locator(popup)).toBeHidden(); + } finally { + await close(); + } + }); +}); diff --git a/e2e/globalSetup.ts b/e2e/globalSetup.ts new file mode 100644 index 0000000..b1e14a2 --- /dev/null +++ b/e2e/globalSetup.ts @@ -0,0 +1,30 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +export const repoRoot = join(__dirname, '..'); + +/** + * Build the main, preload and renderer bundles the tests launch. + * + * `electron-forge package` rather than a bare `vite build`: Forge's Vite plugin + * supplies the entry points and the `MAIN_WINDOW_VITE_*` defines that + * `src/main.ts` compiles against, so a standalone Vite build produces a main + * bundle with undefined globals. + * + * Set `E2E_SKIP_BUILD=1` to reuse the existing bundles while iterating on the + * tests themselves. It is off by default because a stale bundle silently tests + * code you are not looking at. + */ +export default function globalSetup(): void { + if ( + process.env.E2E_SKIP_BUILD === '1' && + existsSync(join(repoRoot, '.vite/build/main.js')) + ) { + return; + } + execFileSync('npx', ['electron-forge', 'package'], { + cwd: repoRoot, + stdio: 'inherit', + }); +} diff --git a/e2e/headingNavigation.spec.ts b/e2e/headingNavigation.spec.ts new file mode 100644 index 0000000..232d023 --- /dev/null +++ b/e2e/headingNavigation.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from '@playwright/test'; +import { launchApp, openNote, scroller } from './appFixture'; + +test.describe('heading navigation', () => { + test('following [[Note#Heading]] scrolls that heading to the top', async () => { + const { window, close } = await launchApp(); + try { + await openNote(window, 'Source.md'); + + // Rendered display text for `[[Target#Middle Section]]` — the brackets + // are hidden while the cursor is elsewhere. + const link = window + .locator('.cm-wikilink', { hasText: 'Target#Middle Section' }) + .first(); + await expect(link).toBeVisible(); + // Edit mode opens a link on cmd/ctrl-click; a plain click just moves the + // caret and reveals the raw markdown. + await link.click({ + modifiers: [process.platform === 'darwin' ? 'Meta' : 'Control'], + }); + + // The bug this guards: the scroll was dispatched immediately after + // `new EditorView(...)`, which CodeMirror drops while `editorHeight` is + // still zero — so the document opened at the very top. + await expect + .poll(() => scroller(window).evaluate((el) => el.scrollTop)) + .toBeGreaterThan(0); + + const heading = window + .locator('.cm-line', { hasText: 'Middle Section' }) + .first(); + const headingBox = await heading.boundingBox(); + const scrollerBox = await scroller(window).boundingBox(); + expect(headingBox).not.toBeNull(); + expect(scrollerBox).not.toBeNull(); + // "At the top" rather than merely on screen. + expect(headingBox!.y - scrollerBox!.y).toBeLessThan(80); + } finally { + await close(); + } + }); + + test('a plain [[Note]] link opens at the top', async () => { + const { window, close } = await launchApp(); + try { + await openNote(window, 'Source.md'); + const link = window + .locator('.cm-wikilink', { hasText: /^Target$/ }) + .first(); + await link.click({ + modifiers: [process.platform === 'darwin' ? 'Meta' : 'Control'], + }); + await expect(window.locator('.cm-content').first()).toContainText( + 'intro body line 1', + ); + expect(await scroller(window).evaluate((el) => el.scrollTop)).toBe(0); + } finally { + await close(); + } + }); +}); diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 0000000..3c7207f --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from '@playwright/test'; +import { launchApp, openNote } from './appFixture'; + +test('launches, mounts the seeded vault, and opens a note', async () => { + const { window, close } = await launchApp(); + try { + await expect( + window.getByText('Source.md', { exact: true }).first(), + ).toBeVisible(); + await openNote(window, 'Target.md'); + // Asserted near the top of the document: CodeMirror only renders its + // visible viewport, so content further down is legitimately not in the DOM. + await expect(window.locator('.cm-content').first()).toContainText( + 'intro body line 1', + ); + } finally { + await close(); + } +}); diff --git a/e2e/tabViewState.spec.ts b/e2e/tabViewState.spec.ts new file mode 100644 index 0000000..3fbffad --- /dev/null +++ b/e2e/tabViewState.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from '@playwright/test'; +import { launchApp, openNote, scroller } from './appFixture'; + +test.describe('per-tab view state', () => { + test('restores scroll position when returning to a tab', async () => { + const { window, close } = await launchApp(); + try { + await openNote(window, 'Target.md'); + await scroller(window).evaluate((el) => { + el.scrollTop = 600; + }); + // CodeMirror reacts to the scroll event on its own scroller; give it a + // frame to record the position before we navigate away. + await window.waitForTimeout(200); + const before = await scroller(window).evaluate((el) => el.scrollTop); + expect(before).toBeGreaterThan(0); + + await openNote(window, 'Other.md'); + await openNote(window, 'Target.md'); + + // The regression this guards: the snapshot used to be captured in a + // passive effect cleanup, after React had detached the node, so + // `scrollTop` always read 0 and every tab came back at the top. + await expect + .poll(() => scroller(window).evaluate((el) => el.scrollTop)) + .toBeGreaterThan(before / 2); + } finally { + await close(); + } + }); + + test('restores the cursor position, not just the scroll', async () => { + const { window, close } = await launchApp(); + try { + await openNote(window, 'Target.md'); + await window.locator('.cm-content').first().click(); + // Move somewhere unambiguous and away from the document start. + for (let i = 0; i < 12; i++) await window.keyboard.press('ArrowDown'); + const before = await window.evaluate( + () => document.querySelectorAll('.cm-line').length, + ); + expect(before).toBeGreaterThan(0); + const cursorLineBefore = await window.evaluate(() => { + const active = document.querySelector('.cm-activeLine'); + return active ? active.textContent : null; + }); + + await openNote(window, 'Other.md'); + await openNote(window, 'Target.md'); + + await expect + .poll(() => + window.evaluate(() => { + const active = document.querySelector('.cm-activeLine'); + return active ? active.textContent : null; + }), + ) + .toBe(cursorLineBefore); + } finally { + await close(); + } + }); +}); diff --git a/package-lock.json b/package-lock.json index ab80210..302724c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "@electron-forge/plugin-fuses": "^7.11.1", "@electron-forge/plugin-vite": "^7.11.1", "@electron/fuses": "^1.8.0", + "@playwright/test": "^1.62.1", "@tailwindcss/vite": "^4.2.4", "@types/electron-squirrel-startup": "^1.0.2", "@types/react": "^19.2.14", @@ -3719,6 +3720,22 @@ "node": ">=20.0.0" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.85.3", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.85.3.tgz", @@ -14116,6 +14133,53 @@ "node": ">=0.10.0" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", diff --git a/package.json b/package.json index bf86695..6d0035f 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "format": "prettier --write .", "format:check": "prettier --check .", "test": "vitest run", + "test:e2e": "playwright test", + "test:e2e:fast": "E2E_SKIP_BUILD=1 playwright test", "test:watch": "vitest", "prepare": "git config core.hooksPath .githooks || true" }, @@ -30,6 +32,7 @@ "@electron-forge/plugin-fuses": "^7.11.1", "@electron-forge/plugin-vite": "^7.11.1", "@electron/fuses": "^1.8.0", + "@playwright/test": "^1.62.1", "@tailwindcss/vite": "^4.2.4", "@types/electron-squirrel-startup": "^1.0.2", "@types/react": "^19.2.14", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..c916015 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from '@playwright/test'; + +/** + * End-to-end tests that drive the real Electron app. + * + * These exist because the vitest suite runs in `environment: 'node'` and + * collects `*.test.ts` only, so nothing that depends on layout, geometry or + * CodeMirror's DOM can be covered there — and that is exactly where this + * editor's bugs have been landing. + */ +export default defineConfig({ + testDir: './e2e', + // One Electron instance at a time: each test launches a real app window, and + // parallel windows fight over focus and make scroll assertions flaky. + workers: 1, + fullyParallel: false, + // Launching Electron and loading a vault is slow relative to a unit test. + timeout: 60_000, + expect: { timeout: 10_000 }, + reporter: process.env.CI ? 'list' : [['list']], + globalSetup: './e2e/globalSetup.ts', + use: { + trace: 'retain-on-failure', + }, +}); diff --git a/vite.renderer.config.ts b/vite.renderer.config.ts index 4158728..f239536 100644 --- a/vite.renderer.config.ts +++ b/vite.renderer.config.ts @@ -7,4 +7,13 @@ import { defineConfig } from 'vite'; // https://vitejs.dev/config export default defineConfig({ plugins: [tailwindcss(), react(), wasm(), topLevelAwait()], + build: { + // The renderer only ever runs in the Electron-bundled Chromium, so Vite's + // default browser target is both wrong and harmful here: esbuild cannot + // downlevel some of what `vite-plugin-top-level-await` emits to es2020, + // which failed every production build ("Transforming destructuring to the + // configured target environment is not supported yet"). Dev was unaffected, + // so this only ever broke `package`/`make`. + target: 'esnext', + }, });