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..806ac1aa --- /dev/null +++ b/docs/e2e.md @@ -0,0 +1,104 @@ +# 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`, 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 + 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 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 + +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..055836cd --- /dev/null +++ b/e2e/changes.spec.js @@ -0,0 +1,100 @@ +// 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(); + expect(await rowCounts(row)).toEqual({ added: 1, deleted: 0 }); + + 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.poll(() => rowCounts(row)).toEqual({ added: 2, deleted: 0 }); +}); diff --git a/e2e/fixtures.js b/e2e/fixtures.js new file mode 100644 index 00000000..68e89ac4 --- /dev/null +++ b/e2e/fixtures.js @@ -0,0 +1,170 @@ +// 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', +}; + +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]) => !DROPPED_ENV.test(k))); + 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); + }); +} + +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; + killTree(proc); + 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",