From 85aee2fa83c580a0025f6f4317a2e7311d15fbd0 Mon Sep 17 00:00:00 2001 From: Ethan Graf Date: Mon, 3 Aug 2026 22:20:36 -0400 Subject: [PATCH] Isolate e2e tests properly and share one app per spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite was intermittently failing, with different tests each time and runs stretching past two minutes. Two separate causes, neither of them the behaviour under test. State leaked between tests. The editor persists edits to disk, so any test that typed into a note permanently changed the vault for every test after it — which made results depend on execution order and read as timing flakiness. Chasing it as a timing problem produced nonsense: the same scenario appeared to fail at 0ms, then only at 120ms, then not at all, because each probe was measuring accumulated file corruption rather than typing speed. `resetWorkspace` now restores the fixture notes as well as clearing the persisted tab layout. Enter was pressed inside CodeMirror's `interactionDelay`. `acceptCompletion` deliberately refuses a completion whose popup opened less than 75ms ago, so a fast typist cannot accept something they never saw. Synthetic input is quick enough to land inside that window; a human is not. The test now waits past it. Specs also share a single Electron instance rather than launching per test. On macOS a quitting Electron regularly gets stuck mid-exit — `ps` shows `UNEs`, reparented to launchd, where not even SIGKILL reaches it — and those hung processes accumulate until launches slow enough to time out. Four launches per suite instead of fourteen keeps that rare; it is a mitigation, not a cure. Three consecutive full runs: 14/14 in ~8s, down from 2.4m with failures. Co-Authored-By: Claude Opus 5 --- e2e/appFixture.ts | 73 ++++-------- e2e/completion.spec.ts | 209 ++++++++++++++++++++-------------- e2e/globalSetup.ts | 12 +- e2e/headingNavigation.spec.ts | 117 ++++++++++--------- e2e/smoke.spec.ts | 33 ++++-- e2e/tabViewState.spec.ts | 114 ++++++++++--------- playwright.config.ts | 13 +-- vite.renderer.config.ts | 9 +- 8 files changed, 303 insertions(+), 277 deletions(-) diff --git a/e2e/appFixture.ts b/e2e/appFixture.ts index 5a84c15..1448fc0 100644 --- a/e2e/appFixture.ts +++ b/e2e/appFixture.ts @@ -9,19 +9,13 @@ import { 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', @@ -45,15 +39,7 @@ export const NOTES: Record = { '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. - */ +/** Not `firstWindow()`: DevTools is a window too, and driving it fails silently. */ async function appWindow(app: ElectronApplication): Promise { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { @@ -74,19 +60,8 @@ export type LaunchedApp = { }; /** - * 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. + * Launch against a throwaway vault. The registry reads `localStorage` once at + * load, so seed-then-reload beats driving the vault dialog. */ export async function launchApp(): Promise { const vaultDir = await mkdtemp(join(tmpdir(), 'textile-e2e-vault-')); @@ -96,10 +71,7 @@ export async function launchApp(): Promise { 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. + // Inherited, this runs the binary as plain Node: no `electron.app`. const { ELECTRON_RUN_AS_NODE: _ignored, ...parentEnv } = process.env; const app = await electron.launch({ @@ -111,12 +83,7 @@ export async function launchApp(): Promise { 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. + // CM renders only its viewport, so a sliver of a window hides the document. await app.evaluate(({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0]; if (!win) return; @@ -137,8 +104,6 @@ export async function launchApp(): Promise { 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 { @@ -146,12 +111,7 @@ export async function launchApp(): Promise { 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. + // The app outlives its last window on macOS, so `close()` alone can hang. const proc = app.process(); await Promise.race([ app.close().catch(() => {}), @@ -170,13 +130,30 @@ export async function launchApp(): Promise { }; } -/** Open a note by clicking it in the sidebar tree, and wait for its editor. */ +/** + * Reset between tests. Specs share one app because quitting Electron often + * hangs mid-exit on macOS. Restoring the notes matters: the editor persists + * edits, so a test that types corrupts the vault for later ones. + */ +export async function resetWorkspace(app: LaunchedApp): Promise { + for (const [name, body] of Object.entries(NOTES)) { + await writeFile(join(app.vaultDir, name), body, 'utf-8'); + } + await app.window.evaluate(() => { + for (const key of Object.keys(localStorage)) { + if (key.startsWith('textile.tiling.')) localStorage.removeItem(key); + } + }); + await app.window.reload(); + await app.window.waitForLoadState('domcontentloaded'); + await app.window.getByText('Target.md', { exact: true }).first().waitFor(); +} + 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(); } diff --git a/e2e/completion.spec.ts b/e2e/completion.spec.ts index 0ee68f1..5d6faf0 100644 --- a/e2e/completion.spec.ts +++ b/e2e/completion.spec.ts @@ -1,9 +1,13 @@ import { test, expect } from '@playwright/test'; -import { launchApp, openNote } from './appFixture'; +import { + launchApp, + resetWorkspace, + type LaunchedApp, + 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'], ) { @@ -15,104 +19,133 @@ async function focusEndOfDoc( } test.describe('[[ completion popup', () => { + let app: LaunchedApp; + let window: LaunchedApp['window']; + + test.beforeAll(async () => { + app = await launchApp(); + window = app.window; + }); + test.afterAll(async () => { + await app.close(); + }); + test.beforeEach(async () => { + await resetWorkspace(app); + }); + 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(); - } + 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'); + + // CM's base theme hardcodes monospace here; our override has to out-specify it. + const fontFamily = await window + .locator(`${popup} > ul`) + .evaluate((el) => getComputedStyle(el).fontFamily); + expect(fontFamily.toLowerCase()).not.toContain('monospace'); }); 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(); - } + 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'); + + // CM re-appends the
      after the footer in DOM order, so assert geometry. + 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, + ); }); 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(); - } + 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(); + expect(levels).toContain('H1'); + expect(levels).toContain('H2'); }); 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(); - } + 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'); }); 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(); - } + 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(); + }); + + test('`#` accepts the highlighted note and goes straight to headings', async () => { + await openNote(window, 'Other.md'); + await focusEndOfDoc(window); + await window.keyboard.type('[[Targ'); + await expect(window.locator(popup)).toBeVisible(); + + await window.keyboard.type('#'); + + await expect(window.locator('.cm-content').first()).toContainText( + '[[Target#', + ); + await expect(window.locator(popup)).toContainText('Middle Section'); + }); + + test('Enter after choosing a heading writes the whole link', async () => { + await openNote(window, 'Other.md'); + await focusEndOfDoc(window); + await window.keyboard.type('[[Targ'); + await expect(window.locator(popup)).toBeVisible(); + await window.keyboard.type('#Middle'); + // Highlighted, not merely present: the heading list loads asynchronously. + await expect(window.locator(`${popup} li[aria-selected]`)).toContainText( + 'Middle Section', + ); + // Past CM's 75ms `interactionDelay`, which refuses a just-opened popup. + // Synthetic input lands inside that window; a human does not. + await window.waitForTimeout(150); + + await window.keyboard.press('Enter'); + + await expect(window.locator('.cm-content').first()).toContainText( + '[[Target#Middle Section]]', + ); + await expect(window.locator(popup)).toBeHidden(); + }); + + test('`#` is ordinary text when no option is highlighted', async () => { + await openNote(window, 'Other.md'); + await focusEndOfDoc(window); + await window.keyboard.type('plain # text'); + await expect(window.locator('.cm-content').first()).toContainText( + 'plain # text', + ); }); }); diff --git a/e2e/globalSetup.ts b/e2e/globalSetup.ts index b1e14a2..fa6ed2c 100644 --- a/e2e/globalSetup.ts +++ b/e2e/globalSetup.ts @@ -5,16 +5,8 @@ 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. + * `electron-forge package`, not a bare `vite build`, which lacks Forge's entry + * points and defines. `E2E_SKIP_BUILD=1` reuses the last build. */ export default function globalSetup(): void { if ( diff --git a/e2e/headingNavigation.spec.ts b/e2e/headingNavigation.spec.ts index 232d023..9b7a024 100644 --- a/e2e/headingNavigation.spec.ts +++ b/e2e/headingNavigation.spec.ts @@ -1,61 +1,76 @@ import { test, expect } from '@playwright/test'; -import { launchApp, openNote, scroller } from './appFixture'; +import { + launchApp, + resetWorkspace, + type LaunchedApp, + openNote, + scroller, +} from './appFixture'; test.describe('heading navigation', () => { + let app: LaunchedApp; + let window: LaunchedApp['window']; + + test.beforeAll(async () => { + app = await launchApp(); + window = app.window; + }); + test.afterAll(async () => { + await app.close(); + }); + test.beforeEach(async () => { + await resetWorkspace(app); + }); + test('following [[Note#Heading]] scrolls that heading to the top', async () => { - const { window, close } = await launchApp(); - try { - await openNote(window, 'Source.md'); + 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(); - } + // Display text: 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 on cmd/ctrl-click; a plain click just moves the caret. + await link.click({ + modifiers: [process.platform === 'darwin' ? 'Meta' : 'Control'], + }); + + // Guards: a scroll dispatched before the view has height is dropped. + 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(); + expect(headingBox!.y - scrollerBox!.y).toBeLessThan(80); }); 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(); - } + 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); + }); + + test('renders a heading link with the `#` separator intact', async () => { + await openNote(window, 'Source.md'); + // Without the separator this collapsed to `TargetMiddle Section`. + await expect( + window + .locator('.cm-wikilink', { hasText: 'Target#Middle Section' }) + .first(), + ).toBeVisible(); }); }); diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 3c7207f..d734a10 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -1,19 +1,34 @@ import { test, expect } from '@playwright/test'; -import { launchApp, openNote } from './appFixture'; +import { + launchApp, + openNote, + resetWorkspace, + type LaunchedApp, +} from './appFixture'; -test('launches, mounts the seeded vault, and opens a note', async () => { - const { window, close } = await launchApp(); - try { +test.describe('app harness', () => { + let app: LaunchedApp; + let window: LaunchedApp['window']; + + test.beforeAll(async () => { + app = await launchApp(); + window = app.window; + }); + test.afterAll(async () => { + await app.close(); + }); + test.beforeEach(async () => { + await resetWorkspace(app); + }); + + test('launches, mounts the seeded vault, and opens a note', async () => { 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. + // Near the top: CM only renders its visible viewport. 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 index 3fbffad..cd0e1c6 100644 --- a/e2e/tabViewState.spec.ts +++ b/e2e/tabViewState.spec.ts @@ -1,63 +1,69 @@ import { test, expect } from '@playwright/test'; -import { launchApp, openNote, scroller } from './appFixture'; +import { + launchApp, + resetWorkspace, + type LaunchedApp, + openNote, + scroller, +} from './appFixture'; test.describe('per-tab view state', () => { + let app: LaunchedApp; + let window: LaunchedApp['window']; + + test.beforeAll(async () => { + app = await launchApp(); + window = app.window; + }); + test.afterAll(async () => { + await app.close(); + }); + test.beforeEach(async () => { + await resetWorkspace(app); + }); + 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(); - } + await openNote(window, 'Target.md'); + await scroller(window).evaluate((el) => { + el.scrollTop = 600; + }); + // Let CM record the position before navigating 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'); + + // Guards: capturing after React detaches the node reads `scrollTop` 0. + await expect + .poll(() => scroller(window).evaluate((el) => el.scrollTop)) + .toBeGreaterThan(before / 2); }); 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(); - } + await openNote(window, 'Target.md'); + await window.locator('.cm-content').first().click(); + 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); }); }); diff --git a/playwright.config.ts b/playwright.config.ts index c916015..338f6af 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,20 +1,11 @@ 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. - */ +/** Drives the real Electron app; the vitest suite runs in node and cannot. */ 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. + // Parallel app 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']], diff --git a/vite.renderer.config.ts b/vite.renderer.config.ts index f239536..8b4fccb 100644 --- a/vite.renderer.config.ts +++ b/vite.renderer.config.ts @@ -8,12 +8,9 @@ import { defineConfig } from 'vite'; 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`. + // The renderer only runs in Electron's Chromium. Vite's default browser + // target broke every production build, since esbuild cannot downlevel what + // `vite-plugin-top-level-await` emits to es2020. Dev was unaffected. target: 'esnext', }, }); -- 2.51.2