From 96c2b8b1c2684b1c5bbd988ae6237bbc5ce92933 Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Wed, 8 Jul 2026 11:38:59 -0400 Subject: [PATCH] test(desktop): Playwright electron e2e harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launches the built app (out/main/index.js) via Playwright's _electron API — no browser project, no chromium download. Adds a launch fixture (electronApp/window/app), an AppPage page object with a screenshot helper for autonomous visual review, and a boot smoke suite (shell renders, @commitea/core label-parse runs in the renderer, preload API exposed). Scripts: e2e (build+run), e2e:only, e2e:report. Artifacts gitignored. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 6 ++++ README.md | 14 ++++++++ apps/desktop/e2e/fixtures.ts | 48 ++++++++++++++++++++++++++ apps/desktop/e2e/pages/app.page.ts | 40 ++++++++++++++++++++++ apps/desktop/e2e/smoke.spec.ts | 19 +++++++++++ apps/desktop/e2e/tsconfig.json | 10 ++++++ apps/desktop/package.json | 6 +++- apps/desktop/playwright.config.ts | 26 ++++++++++++++ yarn.lock | 55 ++++++++++++++++++++++++++++++ 9 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/e2e/fixtures.ts create mode 100644 apps/desktop/e2e/pages/app.page.ts create mode 100644 apps/desktop/e2e/smoke.spec.ts create mode 100644 apps/desktop/e2e/tsconfig.json create mode 100644 apps/desktop/playwright.config.ts diff --git a/.gitignore b/.gitignore index 79f0335..08c279b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,12 @@ dist/ out/ *.log .DS_Store + +# playwright e2e artifacts +.artifacts/ +test-results/ +playwright-report/ +.last-run.json .env .env.* !.env.example diff --git a/README.md b/README.md index 4ef66b1..c9cfa86 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,20 @@ yarn test # unit tests (vitest) yarn typecheck ``` +### End-to-end (Electron + Playwright) + +```sh +yarn workspace @commitea/desktop e2e # build, then drive the built app +yarn workspace @commitea/desktop e2e:only # reuse existing out/ build (tight loop) +yarn workspace @commitea/desktop e2e:report # open the last HTML report +``` + +Tests launch the built app (`out/main/index.js`) through Playwright's +`_electron` API — no browser project, no chromium download. Fixtures and page +objects live in `apps/desktop/e2e/`; screenshots land in +`e2e/.artifacts/screens/` for visual review. Page objects use user-facing +locators (`getByRole`/`getByText`), never CSS/DOM structure. + ## Conventions - Yarn 4 workspaces; ESM everywhere; `.js` extensions on relative imports diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts new file mode 100644 index 0000000..1c65e4f --- /dev/null +++ b/apps/desktop/e2e/fixtures.ts @@ -0,0 +1,48 @@ +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { + _electron as electron, + expect, + test as base, + type ElectronApplication, + type Page, +} from '@playwright/test' + +import { AppPage } from './pages/app.page.js' + +const here = dirname(fileURLToPath(import.meta.url)) +/** The built main-process entry Playwright launches. `yarn e2e` builds it first. */ +const MAIN_ENTRY = join(here, '..', 'out', 'main', 'index.js') + +interface CommiteaFixtures { + /** The launched Electron application. */ + electronApp: ElectronApplication + /** The app's first (and only) BrowserWindow, as a Playwright Page. */ + window: Page + /** Page Object over the app shell. */ + app: AppPage +} + +export const test = base.extend({ + electronApp: async ({}, use) => { + const electronApp = await electron.launch({ + args: [MAIN_ENTRY], + env: { ...process.env, NODE_ENV: 'test', COMMITEA_E2E: '1' }, + }) + await use(electronApp) + await electronApp.close() + }, + + window: async ({ electronApp }, use) => { + const window = await electronApp.firstWindow() + await window.waitForLoadState('domcontentloaded') + await use(window) + }, + + app: async ({ window }, use) => { + await use(new AppPage(window)) + }, +}) + +export { expect } diff --git a/apps/desktop/e2e/pages/app.page.ts b/apps/desktop/e2e/pages/app.page.ts new file mode 100644 index 0000000..fadb0c4 --- /dev/null +++ b/apps/desktop/e2e/pages/app.page.ts @@ -0,0 +1,40 @@ +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { expect, type Locator, type Page } from '@playwright/test' + +const here = dirname(fileURLToPath(import.meta.url)) +/** Where `screenshot()` drops PNGs — gitignored, readable for visual review. */ +const SCREENS_DIR = join(here, '..', '.artifacts', 'screens') + +/** + * Page Object over the CommiTea app shell. As real screens land (P3), add + * per-screen page objects and accessor methods here; keep locators + * user-facing (getByRole/getByText), never CSS/DOM structure. + */ +export class AppPage { + constructor(readonly page: Page) {} + + get heading(): Locator { + return this.page.getByRole('heading', { name: 'CommiTea' }) + } + + /** Assert the shell has rendered. */ + async expectLoaded(): Promise { + await expect(this.heading).toBeVisible() + } + + /** Read the `commitea` preload API surface from the renderer. */ + async preloadApi(): Promise | undefined> { + return this.page.evaluate( + () => (globalThis as unknown as { commitea?: Record }).commitea, + ) + } + + /** Capture a full-page screenshot for visual review; returns the path. */ + async screenshot(name: string): Promise { + const path = join(SCREENS_DIR, `${name}.png`) + await this.page.screenshot({ path, fullPage: true }) + return path + } +} diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts new file mode 100644 index 0000000..5c22a72 --- /dev/null +++ b/apps/desktop/e2e/smoke.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from './fixtures.js' + +test('boots and renders the CommiTea shell', async ({ app }) => { + await app.expectLoaded() + await app.screenshot('boot') +}) + +test('renders the label-parse check from @commitea/core', async ({ window }) => { + // Proves the core package is bundled + running in the renderer, not just + // that the window opened. Updates as the scaffold gives way to real screens. + await expect(window.getByText(/label parse check/i)).toBeVisible() + await expect(window.getByText(/est\/3d · p\/2 · hard true/)).toBeVisible() +}) + +test('exposes the commitea preload API', async ({ app }) => { + const api = await app.preloadApi() + expect(api).toBeDefined() + expect(api).toHaveProperty('platform') +}) diff --git a/apps/desktop/e2e/tsconfig.json b/apps/desktop/e2e/tsconfig.json new file mode 100644 index 0000000..ffa38d1 --- /dev/null +++ b/apps/desktop/e2e/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node"], + "moduleResolution": "Bundler", + "noEmit": true + }, + "include": ["."] +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5f128b0..f2e04af 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -7,7 +7,10 @@ "dev": "electron-vite dev 2>&1 | tee desktop.log", "build": "electron-vite build", "start": "electron-vite preview", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "e2e": "electron-vite build && playwright test", + "e2e:only": "playwright test", + "e2e:report": "playwright show-report e2e/.artifacts/report" }, "dependencies": { "@commitea/core": "workspace:*", @@ -15,6 +18,7 @@ "react-dom": "^18.3.1" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@types/node": "^22.13.1", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts new file mode 100644 index 0000000..f6ec82a --- /dev/null +++ b/apps/desktop/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from '@playwright/test' + +/** + * E2E harness for the Electron app. Tests launch the *built* app + * (`out/main/index.js`) through Playwright's `_electron` API — see + * `e2e/fixtures.ts`. There is no browser project and no chromium download: + * `_electron` drives the app's own bundled electron. + * + * Run `yarn e2e` (builds first) for a fresh run, or `yarn e2e:only` to reuse + * the existing `out/` build during a tight iteration loop. + */ +export default defineConfig({ + testDir: './e2e', + outputDir: './e2e/.artifacts/test-results', + fullyParallel: false, + workers: 1, // one electron instance at a time — deterministic, avoids window races + forbidOnly: !!process.env.CI, + retries: 0, + timeout: 30_000, + expect: { timeout: 5_000 }, + reporter: [['list'], ['html', { outputFolder: './e2e/.artifacts/report', open: 'never' }]], + use: { + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, +}) diff --git a/yarn.lock b/yarn.lock index 67e76e1..d4cec25 100644 --- a/yarn.lock +++ b/yarn.lock @@ -241,6 +241,7 @@ __metadata: resolution: "@commitea/desktop@workspace:apps/desktop" dependencies: "@commitea/core": "workspace:*" + "@playwright/test": "npm:^1.61.1" "@types/node": "npm:^22.13.1" "@types/react": "npm:^18.3.18" "@types/react-dom": "npm:^18.3.5" @@ -720,6 +721,17 @@ __metadata: languageName: node linkType: hard +"@playwright/test@npm:^1.61.1": + version: 1.61.1 + resolution: "@playwright/test@npm:1.61.1" + dependencies: + playwright: "npm:1.61.1" + bin: + playwright: cli.js + checksum: 10c0/45004139eaa7c7ec8129e4ed77a9d734aa09ed40b69162b871e4123f1d70217b7b73651431a9343ed5090d5fed11c6df1bff2a56ff4b87dc7b0371b5da87cf9c + languageName: node + linkType: hard + "@rolldown/pluginutils@npm:1.0.0-beta.27": version: 1.0.0-beta.27 resolution: "@rolldown/pluginutils@npm:1.0.0-beta.27" @@ -1890,6 +1902,16 @@ __metadata: languageName: node linkType: hard +"fsevents@npm:2.3.2": + version: 2.3.2 + resolution: "fsevents@npm:2.3.2" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/be78a3efa3e181cda3cf7a4637cb527bcebb0bd0ea0440105a3bb45b86f9245b307dc10a2507e8f4498a7d4ec349d1910f4d73e4d4495b16103106e07eee735b + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": version: 2.3.3 resolution: "fsevents@npm:2.3.3" @@ -1900,6 +1922,15 @@ __metadata: languageName: node linkType: hard +"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin": + version: 2.3.2 + resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" @@ -2458,6 +2489,30 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.61.1": + version: 1.61.1 + resolution: "playwright-core@npm:1.61.1" + bin: + playwright-core: cli.js + checksum: 10c0/c28896ba82a602182e240ed4f9c467fb0dd9cb57d510f8aba9f25183fe1cde3a0105725a29baffa4157fe885bbb11e228032a2aad0424111584fde45303f07bd + languageName: node + linkType: hard + +"playwright@npm:1.61.1": + version: 1.61.1 + resolution: "playwright@npm:1.61.1" + dependencies: + fsevents: "npm:2.3.2" + playwright-core: "npm:1.61.1" + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: 10c0/cea1bc4d2a64ec3ef683891606774029da7b8a8036ed98332565cec2f8e89549ca1fab9a89fbfba4e08e27e0d7e7d148031e965af66d5ff97bb3c34058cfcad0 + languageName: node + linkType: hard + "postcss-import@npm:^15.1.0": version: 15.1.0 resolution: "postcss-import@npm:15.1.0"