From 3e22c0a359eb84eb44fcdb0c8ec421a00ac96c3d Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sat, 3 Oct 2026 13:49:17 +0200 Subject: [PATCH 1/2] (test): run Playwright journeys against the real Electron app in CI jsdom has no layout and the unit suites run over fakes, so the four panel defects behind #304 were only found by launching the app. Five journeys now launch it with Playwright under a throwaway HOME, data dir and triggers dir, and assert structure, geometry and what reaches disk: Changes counts and totals, an untracked file opened from its row, an edit saved from the panel editor, the panel shell filling the panel without a sidebar row, and a directory with no git work tree. A new e2e job runs them on ubuntu-latest under xvfb-run, checks first that the native modules load under Electron, and uploads traces and screenshots on failure. task e2e runs them locally. The files sit in e2e/, outside node --test and c8. Refs #304. Not yet shown: a green run on the Linux job, and ten consecutive runs without an intermittent failure. --- .ai/shared-guidelines.md | 1 + .c8rc.json | 1 + .github/workflows/test.yml | 46 ++++++++++- .gitignore | 4 + Taskfile.yaml | 11 +++ docs/README.md | 1 + docs/e2e.md | 91 ++++++++++++++++++++++ docs/live-testing.md | 15 ++-- e2e/changes.spec.js | 99 +++++++++++++++++++++++ e2e/fixtures.js | 156 +++++++++++++++++++++++++++++++++++++ e2e/panel.spec.js | 41 ++++++++++ e2e/playwright.config.js | 20 +++++ eslint.config.js | 17 ++++ package-lock.json | 46 +++++++++++ package.json | 2 + 15 files changed, 542 insertions(+), 9 deletions(-) create mode 100644 docs/e2e.md create mode 100644 e2e/changes.spec.js create mode 100644 e2e/fixtures.js create mode 100644 e2e/panel.spec.js create mode 100644 e2e/playwright.config.js diff --git a/.ai/shared-guidelines.md b/.ai/shared-guidelines.md index a34fa103..fae9e51e 100644 --- a/.ai/shared-guidelines.md +++ b/.ai/shared-guidelines.md @@ -26,6 +26,7 @@ Switchboard is an **Electron desktop app**: renderer + main-process, no Domain/A | Change the window frame, the strip that replaces the title bar, its drag regions or the menu's accelerators | [contexts/window-frame.md](contexts/window-frame.md) | | Change the renderer (sidebar, terminal, app.js) | `public/*.js` — entry is `app.js` | | Write a test | `test/*.test.js` — node:test + jsdom for renderer files | +| Check something only a running app shows (layout, a full IPC round trip), in CI | [../docs/e2e.md](../docs/e2e.md) — Playwright journeys in `e2e/` | | Working practices for AI agents (HANDOFF format, shell pitfalls, review loop) | [agent-practices.md](agent-practices.md) | | Test a PR or a release candidate against a running app | [../docs/testing-a-pr.md](../docs/testing-a-pr.md) | | Cut a release | [docs/releasing.md](../docs/releasing.md) — and its fork gotchas, which are not optional | diff --git a/.c8rc.json b/.c8rc.json index bb8369be..21afb868 100644 --- a/.c8rc.json +++ b/.c8rc.json @@ -4,6 +4,7 @@ "exclude": [ "**/*.test.js", "test/**", + "e2e/**", "scripts/**", "workers/**", "public/codemirror-bundle.js", diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 78ad2478..893f1f22 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,6 +43,50 @@ jobs: GH_TOKEN: ${{ github.token }} run: node scripts/check-changelog.js --base "${{ github.event.pull_request.base.sha }}" --head "${{ github.event.pull_request.head.sha }}" --repo "${{ github.repository }}" --pr "${{ github.event.pull_request.number }}" + e2e: + # see docs/e2e.md + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + # postinstall runs electron-builder install-app-deps, which builds the + # native modules for Electron's ABI; it swallows a failure, so check. + - name: Install dependencies + run: npm ci + + - name: Check the native modules load under Electron + run: ELECTRON_RUN_AS_NODE=1 npx electron -e "require('better-sqlite3'); require('node-pty')" + + - name: Install xvfb + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq xvfb + + - name: Build the CodeMirror bundle + run: npm run bundle:codemirror + + - name: Run the journeys + env: + CI: "true" + run: xvfb-run -a -s "-screen 0 1920x1080x24" npm run e2e + + - name: Upload traces and screenshots + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-results + path: | + e2e/test-results/ + e2e/playwright-report/ + retention-days: 14 + test: runs-on: ${{ matrix.os }} @@ -94,4 +138,4 @@ jobs: --exclude 'main.js' 'preload.js' 'mcp-bridge.js' 'claude-auth.js' \ 'public/codemirror-bundle.js' 'public/codemirror-setup.js' \ 'public/terminal-manager.js' 'public/app.js' \ - 'workers/**' 'scripts/**' 'test/**' + 'workers/**' 'scripts/**' 'test/**' 'e2e/**' diff --git a/.gitignore b/.gitignore index f369df80..e9011c94 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ coverage/ # task test-pr worktrees .worktrees/ + +# Playwright output (docs/e2e.md) +e2e/test-results/ +e2e/playwright-report/ diff --git a/Taskfile.yaml b/Taskfile.yaml index 9ccc30ed..bb5e7f75 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -53,6 +53,17 @@ tasks: cmds: - npm run coverage + e2e: + desc: Run the Playwright journeys against the real Electron app (Linux; see docs/e2e.md) + cmds: + - npm run bundle:codemirror + - | + if [ -z "$DISPLAY" ] && [ -z "$WAYLAND_DISPLAY" ] && command -v xvfb-run >/dev/null 2>&1; then + xvfb-run -a -s "-screen 0 1920x1080x24" npm run e2e + else + npm run e2e + fi + lint: desc: Run ESLint across the codebase cmds: diff --git a/docs/README.md b/docs/README.md index eb6ba3fa..c08a7f96 100644 --- a/docs/README.md +++ b/docs/README.md @@ -38,6 +38,7 @@ links are in the [README](../README.md#download). | [Development](development.md) | Prerequisites, `task` commands, running from source next to an installed copy, building, project layout | | [Testing a PR live](testing-a-pr.md) | `task test-pr`: a PR's code in an isolated instance next to your own, and its pitfalls | | [Live testing with a throwaway HOME](live-testing.md) | An instance that cannot see your sessions at all, driven by Playwright | +| [End-to-end journeys](e2e.md) | The Playwright journeys run against the real app in CI, and how to add one | | [Releasing](releasing.md) | Version bump, tag, draft release, publishing | | [Changelog](changelog.md) | Writing a `CHANGELOG.md` entry, the CI check, the What's new dialog | | [Decisions](decisions/README.md) | Architecture decision records | diff --git a/docs/e2e.md b/docs/e2e.md new file mode 100644 index 00000000..7da317e5 --- /dev/null +++ b/docs/e2e.md @@ -0,0 +1,91 @@ +# End-to-end journeys + +`e2e/` holds a few Playwright journeys that launch the real Electron app and +check what only a running app shows: layout, the IPC round trips, the +main-process data behind the sidebar. The node:test suite under `test/` runs +over jsdom, which has no layout, and over injected fakes. It cannot see these. +Issue [#304](https://github.com/devsuitup/switchboard/issues/304). + +## Running them + +```bash +task e2e # Linux: builds the CodeMirror bundle, wraps the run in xvfb-run when there is no display +npm run e2e # any platform, with a display, once the bundle is built +``` + +They need an installed `node_modules` whose native modules are built for +Electron. `npm install` does that already: `postinstall` runs +`electron-builder install-app-deps`. No browser download is needed, because +Playwright drives the Electron binary from `node_modules/electron`. + +`npm test` and `npm run coverage` never run these files. +`scripts/run-tests.js` reads only `test/`, and `.c8rc.json` and the +patch-coverage gate exclude `e2e/**`. + +## In CI + +The `e2e` job of `.github/workflows/test.yml` runs on `ubuntu-latest` under +`xvfb-run` (1920x1080 screen) on every pull request and every push to `main`. +Before the run, it checks that `better-sqlite3` and `node-pty` load under +Electron's ABI. `postinstall` swallows a failed rebuild, and without that check +a failed rebuild would show up as an app that does not start. When a journey +fails, the job uploads `e2e/test-results/` (a `trace.zip` and a `failure.png` +for each failed journey) and the HTML report as the `e2e-results` artifact. +Open a trace with `npx playwright show-trace trace.zip`. It has the DOM and a +screenshot for each step. + +Whether the job is a required check is a repository setting. It is not set +here. + +## The journeys + +| Journey | File | What it pins | +|---|---|---| +| A changed tracked file is listed with its counts, and the header total matches | `changes.spec.js` | status → rows → summary, through the real git and IPC | +| Clicking an untracked file opens it and its line count appears | `changes.spec.js` | the row click, the editable content pair, the count on open | +| An edit saved in the panel editor reaches disk, and the file stays changed | `changes.spec.js` | the editor gets the panel's width (not two 225 px columns), save writes the bytes | +| The shell opened with no tab fills the panel and is not a sidebar row | `panel.spec.js` | the `.shell-only` layout, and `buildProjectsFromCache` skipping the panel shell | +| Changes on a project with no git work tree says so, with no git output | `panel.spec.js` | the not-a-repository note instead of git's raw output | + +The issue's fifth journey expected the Changes control to disappear. #310 made +it unconditional, so the journey checks what the panel says instead (see +`.ai/contexts/changes-view.md`, "Not a repository"). + +## Writing one + +- **Few and coarse.** Each journey costs an Electron launch. Add one only for + something jsdom cannot see. +- **Isolated.** The `launch` fixture in `e2e/fixtures.js` gives each test its + own temporary `HOME` (and `USERPROFILE`), its own `SWITCHBOARD_DATA_DIR` and + `SWITCHBOARD_TRIGGERS_DIR`, a git identity, and no inherited `CLAUDE*` + variable. Build the fixture projects with `makeRepo` / `makePlainDir` before + calling `launch()`, because the app reads them at startup. See + [Live testing](live-testing.md) for why each of these is needed. +- **Plain terminals only.** Open a session with `openPlainTerminal`, the + project's `+` → Terminal. Never click the fixture's session row: that would + start `claude --resume`. +- **Structure and geometry, never pixels or wording.** Assert element boxes, + classes, counts, and what reaches disk. Match a number in a label, never the + sentence around it. There are no screenshot baselines. +- **No `waitForTimeout`.** Wait on a locator assertion (`toBeVisible`, + `toHaveCount`) or on `expect.poll`. A journey that needs a sleep is written + wrong. +- **Bounded teardown.** The fixture closes the app, and kills the whole process + tree if it does not exit within 10 s. Then it deletes the temporary `HOME`. + +## Proving a journey can fail + +Each journey was turned red by reverting the behaviour it pins: + +| Mutation | Red journey | +|---|---| +| Drop the `.shell-only` region rule in `public/style.css` | the shell fills the panel (height ratio 0.26) | +| Drop both `.shell-only` rules | the shell fills the panel (the handle is visible) | +| Remove the `isPanelShellSession` skip in `session-cache.js` | the shell is not a sidebar row | +| Default the Changes editor to `side-by-side` | the saved edit (each editor 224.5 px wide in a 449 px host) | +| Skip the not-a-repository branch in `refreshChanges` | no git work tree | +| Show the deleted count as the added count in a row | the tracked file's counts | +| Turn off up-front untracked counting and the count on open | the untracked file | + +With up-front untracked counting on, removing only the count on open leaves the +untracked journey green, because status has already counted the file. diff --git a/docs/live-testing.md b/docs/live-testing.md index 0ee9a299..63c8275c 100644 --- a/docs/live-testing.md +++ b/docs/live-testing.md @@ -37,18 +37,18 @@ directory, and deleted with it. ## Driving it with Playwright -Playwright's `_electron.launch()`, from `playwright-core`, starts Electron, and +Playwright's `_electron.launch()` starts Electron, and its locators wait for elements on their own. `app.evaluate(fn)` runs `fn` in the main process, with Electron's module as its argument; `app.firstWindow()` is the renderer, a regular Playwright `Page`. -Playwright is **not** a dependency of this repository. Install it outside the -repository's `package.json`, in a scratch directory — `.work-files/` is -gitignored: +`@playwright/test` is a dev dependency of this repository, for the +[end-to-end journeys](e2e.md). A one-off script can live in `.work-files/` +(gitignored) and `require('@playwright/test')` from the checkout's +`node_modules`: ```bash mkdir -p .work-files/live && cd .work-files/live -npm init -y >/dev/null && npm install playwright-core ``` `live.js` in that directory — the whole technique in one file: @@ -59,7 +59,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { execFileSync } = require('child_process'); -const { _electron: electron } = require('playwright-core'); +const { _electron: electron } = require('@playwright/test'); // A fixture repository with one commit, and a two-line transcript so the project shows in the sidebar. function makeFixture(home, env) { @@ -152,8 +152,7 @@ the `try`, so the `finally` closes the app, if it started, and deletes the temporary `HOME` on every path, a failed `git` or a failed launch included. Keep new steps inside it, or temporary homes accumulate in `/tmp`. -Turning such journeys into a CI suite is tracked in -[#304](https://github.com/devsuitup/switchboard/issues/304). +Journeys worth keeping belong in the CI suite: see [End-to-end journeys](e2e.md). ## Which one to use diff --git a/e2e/changes.spec.js b/e2e/changes.spec.js new file mode 100644 index 00000000..522d6f49 --- /dev/null +++ b/e2e/changes.spec.js @@ -0,0 +1,99 @@ +// see docs/e2e.md +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { test, expect, makeRepo, openPlainTerminal, box } = require('./fixtures'); + +const TRACKED = { + 'alpha.txt': 'one\ntwo\nthree\n', + 'beta.txt': 'red\ngreen\n', +}; + +function count(text) { + const m = /\d+/.exec(text); + if (!m) throw new Error(`no number in ${JSON.stringify(text)}`); + return Number(m[0]); +} + +async function rowCounts(row) { + return { + added: count(await row.locator('.changes-added').textContent()), + deleted: count(await row.locator('.changes-deleted').textContent()), + }; +} + +async function openChanges(page) { + await page.locator('#changes-toggle-btn').click(); + await expect(page.locator('#file-panel')).toBeVisible(); +} + +test('a changed tracked file is listed with its counts, and the header total matches', async ({ home, env, launch }) => { + const repo = makeRepo(home, env, TRACKED); + fs.appendFileSync(path.join(repo, 'alpha.txt'), 'four\nfive\n'); + fs.writeFileSync(path.join(repo, 'beta.txt'), 'red\nblue\n'); + + const { page } = await launch(); + await openPlainTerminal(page); + await openChanges(page); + + const alpha = page.locator('.changes-file-row[data-path="alpha.txt"]'); + const beta = page.locator('.changes-file-row[data-path="beta.txt"]'); + await expect(alpha.locator('.changes-added')).toBeVisible(); + await expect(beta.locator('.changes-added')).toBeVisible(); + expect(await rowCounts(alpha)).toEqual({ added: 2, deleted: 0 }); + expect(await rowCounts(beta)).toEqual({ added: 1, deleted: 1 }); + + const summary = await page.locator('#changes-summary').textContent(); + expect(summary).toMatch(/\+3\b/); + expect(summary).toMatch(/[−-]1\b/); +}); + +test('clicking an untracked file opens it and its line count appears', async ({ home, env, launch }) => { + const repo = makeRepo(home, env, TRACKED); + fs.writeFileSync(path.join(repo, 'fresh.txt'), 'a\nb\nc\nd\n'); + + const { page } = await launch(); + await openPlainTerminal(page); + await openChanges(page); + + const row = page.locator('.changes-file-row[data-path="fresh.txt"]'); + await row.click(); + await expect(page.locator('#changes-diff-host .cm-editor').first()).toBeVisible(); + await expect(row).toHaveClass(/\bselected\b/); + await expect(row.locator('.changes-added')).toBeVisible(); + expect(await rowCounts(row)).toEqual({ added: 4, deleted: 0 }); +}); + +test('an edit saved in the panel editor reaches disk, and the file stays changed', async ({ home, env, launch }) => { + const repo = makeRepo(home, env, TRACKED); + const target = path.join(repo, 'alpha.txt'); + fs.appendFileSync(target, 'four\n'); + + const { page } = await launch(); + await openPlainTerminal(page); + await openChanges(page); + + const row = page.locator('.changes-file-row[data-path="alpha.txt"]'); + await row.click(); + const host = page.locator('#changes-diff-host'); + const editors = host.locator('.cm-editor'); + await expect(editors.first()).toBeVisible(); + + const hostBox = await box(host); + for (const editor of await editors.all()) { + expect((await box(editor)).width).toBeGreaterThan(hostBox.width * 0.8); + } + + const content = host.locator('.cm-content').last(); + await content.click(); + await page.keyboard.press('Control+End'); + await page.keyboard.type('edited-in-panel'); + const save = page.locator('#changes-diff-save-btn'); + await expect(save).toBeEnabled(); + await save.click(); + + await expect.poll(() => fs.readFileSync(target, 'utf8')).toContain('edited-in-panel'); + await expect(save).toBeDisabled(); + await expect(row).toBeVisible(); +}); diff --git a/e2e/fixtures.js b/e2e/fixtures.js new file mode 100644 index 00000000..617a5252 --- /dev/null +++ b/e2e/fixtures.js @@ -0,0 +1,156 @@ +// Launches the real app under a throwaway HOME — see docs/e2e.md +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync, spawnSync } = require('child_process'); +const { test: base, expect, _electron: electron } = require('@playwright/test'); + +const APP_DIR = path.resolve(__dirname, '..'); +const CLOSE_TIMEOUT_MS = 10_000; + +const GIT_IDENTITY = { + GIT_AUTHOR_NAME: 'Fixture', + GIT_AUTHOR_EMAIL: 'fixture@example.invalid', + GIT_COMMITTER_NAME: 'Fixture', + GIT_COMMITTER_EMAIL: 'fixture@example.invalid', +}; + +function isolatedEnv(home) { + const data = path.join(home, '.switchboard-e2e'); + const inherited = Object.fromEntries(Object.entries(process.env) + .filter(([k]) => !k.startsWith('CLAUDE') && k !== 'ELECTRON_RUN_AS_NODE')); + return { + ...inherited, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: path.join(home, '.config'), + SWITCHBOARD_DATA_DIR: data, + SWITCHBOARD_TRIGGERS_DIR: path.join(data, 'triggers'), + GIT_CONFIG_NOSYSTEM: '1', + ...GIT_IDENTITY, + }; +} + +function writeTranscript(home, projectPath) { + const sid = '00000000-0000-4000-8000-' + String(Math.floor(Math.random() * 1e12)).padStart(12, '0'); + const now = new Date().toISOString(); + const folder = path.join(home, '.claude', 'projects', projectPath.replace(/[^a-zA-Z0-9]/g, '-')); + fs.mkdirSync(folder, { recursive: true }); + const lines = [ + { type: 'user', sessionId: sid, cwd: projectPath, timestamp: now, uuid: 'u1', + message: { role: 'user', content: 'fixture' } }, + { type: 'assistant', sessionId: sid, cwd: projectPath, timestamp: now, uuid: 'a1', parentUuid: 'u1', + message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] } }, + ]; + fs.writeFileSync(path.join(folder, `${sid}.jsonl`), lines.map((l) => JSON.stringify(l)).join('\n') + '\n'); +} + +function makeRepo(home, env, files) { + const repo = path.join(home, 'work', 'repo'); + fs.mkdirSync(repo, { recursive: true }); + const git = (...args) => execFileSync('git', args, { cwd: repo, env, stdio: 'pipe' }); + git('init', '-q'); + git('config', 'core.autocrlf', 'false'); + for (const [name, content] of Object.entries(files)) fs.writeFileSync(path.join(repo, name), content); + git('add', '.'); + git('commit', '-qm', 'fixture'); + writeTranscript(home, repo); + return repo; +} + +function makePlainDir(home) { + const dir = path.join(home, 'work', 'plain'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'notes.txt'), 'not under git\n'); + writeTranscript(home, dir); + return dir; +} + +function bounded(promise, ms) { + let timer; + const timeout = new Promise((resolve) => { timer = setTimeout(resolve, ms); }); + return Promise.race([Promise.resolve(promise).catch(() => {}), timeout]).finally(() => clearTimeout(timer)); +} + +function hasExited(proc) { + return proc.exitCode !== null || proc.signalCode !== null; +} + +function waitForExit(proc, ms) { + if (hasExited(proc)) return Promise.resolve(true); + return new Promise((resolve) => { + const onExit = () => { clearTimeout(timer); resolve(true); }; + const timer = setTimeout(() => { proc.off('exit', onExit); resolve(hasExited(proc)); }, ms); + proc.once('exit', onExit); + }); +} + +async function closeApp(app) { + const proc = app.process(); + await bounded(app.close(), CLOSE_TIMEOUT_MS); + if (await waitForExit(proc, CLOSE_TIMEOUT_MS)) return; + if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(proc.pid), '/T', '/F'], { stdio: 'ignore' }); + else proc.kill('SIGKILL'); + await waitForExit(proc, CLOSE_TIMEOUT_MS); +} + +const test = base.extend({ + home: async ({}, use) => { + const home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'sb-e2e-'))); + try { + await use(home); + } finally { + fs.rmSync(home, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 }); + } + }, + + env: async ({ home }, use) => { + await use(isolatedEnv(home)); + }, + + launch: async ({ env }, use, testInfo) => { + let app = null; + let page = null; + const launch = async () => { + app = await electron.launch({ + executablePath: require('electron'), + args: [APP_DIR, '--no-sandbox'], + cwd: APP_DIR, + env, + }); + await app.context().tracing.start({ screenshots: true, snapshots: true }); + page = await app.firstWindow(); + await page.locator('.project-new-btn').first().waitFor(); + return { app, page }; + }; + try { + await use(launch); + } finally { + if (app) { + const failed = testInfo.status !== testInfo.expectedStatus; + try { + if (failed && page) await bounded(page.screenshot({ path: testInfo.outputPath('failure.png'), timeout: CLOSE_TIMEOUT_MS }), CLOSE_TIMEOUT_MS); + await bounded(app.context().tracing.stop(failed ? { path: testInfo.outputPath('trace.zip') } : undefined), CLOSE_TIMEOUT_MS * 3); + } finally { + await closeApp(app); + } + } + } + }, +}); + +async function openPlainTerminal(page) { + await page.locator('.project-new-btn').first().click(); + await page.locator('.popover-option-terminal').click(); + await expect(page.locator('#terminals .xterm-screen').first()).toBeVisible(); +} + +async function box(locator) { + const b = await locator.boundingBox(); + if (!b) throw new Error('element has no box'); + return b; +} + +module.exports = { test, expect, makeRepo, makePlainDir, openPlainTerminal, box }; diff --git a/e2e/panel.spec.js b/e2e/panel.spec.js new file mode 100644 index 00000000..9d35b17a --- /dev/null +++ b/e2e/panel.spec.js @@ -0,0 +1,41 @@ +// see docs/e2e.md +'use strict'; + +const { test, expect, makeRepo, makePlainDir, openPlainTerminal, box } = require('./fixtures'); + +test('the shell opened with no tab fills the panel and is not a sidebar row', async ({ home, env, launch }) => { + makeRepo(home, env, { 'readme.txt': 'fixture\n' }); + + const { page } = await launch(); + await openPlainTerminal(page); + const rows = page.locator('#sidebar-content .session-item[data-session-id]'); + const rowsBefore = await rows.count(); + expect(rowsBefore).toBeGreaterThan(0); + + await page.locator('#panel-terminal-toggle-btn').click(); + const content = page.locator('#file-panel-content'); + const region = page.locator('#panel-terminal-region'); + await expect(region.locator('.xterm-screen')).toBeVisible(); + await expect(page.locator('#panel-terminal-handle')).toBeHidden(); + await expect.poll(async () => (await box(region)).height / (await box(content)).height).toBeGreaterThan(0.9); + + await page.evaluate(() => window.loadProjects()); + const projects = await page.evaluate(() => window.api.getProjects(false)); + const ids = projects.flatMap((p) => p.sessions.map((s) => s.sessionId)); + expect(ids.filter((id) => id.startsWith('panel:'))).toEqual([]); + await expect(page.locator('#sidebar-content .session-item[data-session-id^="panel:"]')).toHaveCount(0); + await expect(rows).toHaveCount(rowsBefore); +}); + +test('Changes on a project with no git work tree says so, with no git output', async ({ home, launch }) => { + makePlainDir(home); + + const { page } = await launch(); + await openPlainTerminal(page); + await page.locator('#changes-toggle-btn').click(); + + const list = page.locator('#changes-list'); + await expect(list.locator('.changes-note')).toBeVisible(); + await expect(list.locator('.changes-error, .changes-file-row')).toHaveCount(0); + await expect(page.locator('body')).not.toContainText(/fatal:|usage: git/); +}); diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js new file mode 100644 index 00000000..2ab9ba0f --- /dev/null +++ b/e2e/playwright.config.js @@ -0,0 +1,20 @@ +// see docs/e2e.md +'use strict'; + +const path = require('path'); +const { defineConfig } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: __dirname, + testMatch: '*.spec.js', + outputDir: path.join(__dirname, 'test-results'), + timeout: 90_000, + expect: { timeout: 20_000 }, + workers: 1, + fullyParallel: false, + retries: 0, + forbidOnly: !!process.env.CI, + reporter: process.env.CI + ? [['list'], ['html', { open: 'never', outputFolder: path.join(__dirname, 'playwright-report') }]] + : [['list']], +}); diff --git a/eslint.config.js b/eslint.config.js index bb682ca1..ab55e1db 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -663,6 +663,23 @@ module.exports = [ }, }, + // Playwright journeys: Node, plus the renderer callbacks passed to page.evaluate + { + files: ['e2e/**/*.js'], + languageOptions: { + ecmaVersion: 2024, + sourceType: 'commonjs', + globals: { + ...globals.node, + ...globals.browser, + }, + }, + rules: { + 'no-undef': 'error', + 'no-unused-vars': ['warn', { args: 'none', varsIgnorePattern: '^_' }], + }, + }, + // ESLint config self-check { files: ['eslint.config.js'], diff --git a/package-lock.json b/package-lock.json index 7dbeb551..ac3f8a2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "@codemirror/view": "^6.39.16", "@ddietr/codemirror-themes": "^1.5.2", "@napi-rs/canvas": "^0.1.62", + "@playwright/test": "^1.63.0", "c8": "^11.0.0", "codemirror": "^6.0.2", "electron": "^41.0.0", @@ -47,6 +48,9 @@ "husky": "^9.1.7", "jsdom": "^29.1.1", "png2icons": "^2.0.1" + }, + "engines": { + "node": ">=20 <23" } }, "node_modules/@asamuzakjp/css-color": { @@ -2695,6 +2699,21 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -7044,6 +7063,33 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", diff --git a/package.json b/package.json index 2ffa3d7e..f1880476 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "pretest": "npm run lint", "test": "node scripts/run-tests.js", "coverage": "c8 node scripts/run-tests.js", + "e2e": "playwright test -c e2e/playwright.config.js", "electron": "electron .", "electron-dev": "SWITCHBOARD_DATA_DIR=\"$HOME/.switchboard-dev\" electron .", "bundle:codemirror": "esbuild public/codemirror-setup.js --bundle --outfile=public/codemirror-bundle.js --format=iife --platform=browser --minify", @@ -53,6 +54,7 @@ "@codemirror/view": "^6.39.16", "@ddietr/codemirror-themes": "^1.5.2", "@napi-rs/canvas": "^0.1.62", + "@playwright/test": "^1.63.0", "c8": "^11.0.0", "codemirror": "^6.0.2", "electron": "^41.0.0", From 406c9d70685809c4034216fc5a4a4b2358520556 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sat, 3 Oct 2026 14:01:13 +0200 Subject: [PATCH 2/2] (test): kill the e2e process group, wait for the post-save refresh On Linux and macOS the teardown sent SIGKILL to the main pid only; Playwright already starts Electron as a process-group leader there, so signal the group, falling back to the pid. Windows keeps taskkill /T. The saved-edit journey asserted a row that was already rendered. It now checks the row's counts before the edit and polls until the refresh after the save shows the new line. The launched environment drops every inherited GIT_* variable and HISTFILE, so a GIT_DIR or GIT_INDEX_FILE in the runner cannot point git at another repository. --- docs/e2e.md | 21 +++++++++++++++++---- e2e/changes.spec.js | 3 ++- e2e/fixtures.js | 20 +++++++++++++++++--- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/docs/e2e.md b/docs/e2e.md index 7da317e5..806ac1aa 100644 --- a/docs/e2e.md +++ b/docs/e2e.md @@ -57,8 +57,10 @@ it unconditional, so the journey checks what the panel says instead (see something jsdom cannot see. - **Isolated.** The `launch` fixture in `e2e/fixtures.js` gives each test its own temporary `HOME` (and `USERPROFILE`), its own `SWITCHBOARD_DATA_DIR` and - `SWITCHBOARD_TRIGGERS_DIR`, a git identity, and no inherited `CLAUDE*` - variable. Build the fixture projects with `makeRepo` / `makePlainDir` before + `SWITCHBOARD_TRIGGERS_DIR`, and a git identity. It drops every inherited + `CLAUDE*` and `GIT_*` variable (`GIT_DIR`, `GIT_WORK_TREE` or + `GIT_INDEX_FILE` would point git at another repository), plus `HISTFILE` + and `ELECTRON_RUN_AS_NODE`. Build the fixture projects with `makeRepo` / `makePlainDir` before calling `launch()`, because the app reads them at startup. See [Live testing](live-testing.md) for why each of these is needed. - **Plain terminals only.** Open a session with `openPlainTerminal`, the @@ -70,8 +72,19 @@ it unconditional, so the journey checks what the panel says instead (see - **No `waitForTimeout`.** Wait on a locator assertion (`toBeVisible`, `toHaveCount`) or on `expect.poll`. A journey that needs a sleep is written wrong. -- **Bounded teardown.** The fixture closes the app, and kills the whole process - tree if it does not exit within 10 s. Then it deletes the temporary `HOME`. +- **Bounded teardown.** The fixture asks the app to close and waits up to + 10 s for it to exit. If it has not exited, the fixture kills it: + - On Linux and macOS, Playwright starts Electron as the leader of a new + process group (`detached` on every platform but Windows). The fixture sends + `SIGKILL` to that group. This reaches every process still in the group. + It does not reach a process that has moved to another group or session. + If the group signal fails, the fixture sends `SIGKILL` to the main process + only. + - On Windows, Playwright starts Electron through `cmd.exe`. The fixture runs + `taskkill /T /F` on that pid, which kills the process and its descendants. + + Then it deletes the temporary `HOME`. If the app exits within the 10 s, no + kill is sent. ## Proving a journey can fail diff --git a/e2e/changes.spec.js b/e2e/changes.spec.js index 522d6f49..055836cd 100644 --- a/e2e/changes.spec.js +++ b/e2e/changes.spec.js @@ -79,6 +79,7 @@ test('an edit saved in the panel editor reaches disk, and the file stays changed const host = page.locator('#changes-diff-host'); const editors = host.locator('.cm-editor'); await expect(editors.first()).toBeVisible(); + expect(await rowCounts(row)).toEqual({ added: 1, deleted: 0 }); const hostBox = await box(host); for (const editor of await editors.all()) { @@ -95,5 +96,5 @@ test('an edit saved in the panel editor reaches disk, and the file stays changed await expect.poll(() => fs.readFileSync(target, 'utf8')).toContain('edited-in-panel'); await expect(save).toBeDisabled(); - await expect(row).toBeVisible(); + await expect.poll(() => rowCounts(row)).toEqual({ added: 2, deleted: 0 }); }); diff --git a/e2e/fixtures.js b/e2e/fixtures.js index 617a5252..68e89ac4 100644 --- a/e2e/fixtures.js +++ b/e2e/fixtures.js @@ -17,10 +17,12 @@ const GIT_IDENTITY = { GIT_COMMITTER_EMAIL: 'fixture@example.invalid', }; +const DROPPED_ENV = /^(CLAUDE|GIT_)|^(ELECTRON_RUN_AS_NODE|HISTFILE)$/; + function isolatedEnv(home) { const data = path.join(home, '.switchboard-e2e'); const inherited = Object.fromEntries(Object.entries(process.env) - .filter(([k]) => !k.startsWith('CLAUDE') && k !== 'ELECTRON_RUN_AS_NODE')); + .filter(([k]) => !DROPPED_ENV.test(k))); return { ...inherited, HOME: home, @@ -87,12 +89,24 @@ function waitForExit(proc, ms) { }); } +function killTree(proc) { + if (!Number.isInteger(proc.pid) || proc.pid <= 0) return; + if (process.platform === 'win32') { + spawnSync('taskkill', ['/pid', String(proc.pid), '/T', '/F'], { stdio: 'ignore' }); + return; + } + try { + process.kill(-proc.pid, 'SIGKILL'); + } catch { + proc.kill('SIGKILL'); + } +} + async function closeApp(app) { const proc = app.process(); await bounded(app.close(), CLOSE_TIMEOUT_MS); if (await waitForExit(proc, CLOSE_TIMEOUT_MS)) return; - if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(proc.pid), '/T', '/F'], { stdio: 'ignore' }); - else proc.kill('SIGKILL'); + killTree(proc); await waitForExit(proc, CLOSE_TIMEOUT_MS); }