Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .ai/shared-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions .c8rc.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"exclude": [
"**/*.test.js",
"test/**",
"e2e/**",
"scripts/**",
"workers/**",
"public/codemirror-bundle.js",
Expand Down
46 changes: 45 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down Expand Up @@ -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/**'
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ coverage/

# task test-pr worktrees
.worktrees/

# Playwright output (docs/e2e.md)
e2e/test-results/
e2e/playwright-report/
11 changes: 11 additions & 0 deletions Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
104 changes: 104 additions & 0 deletions docs/e2e.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 7 additions & 8 deletions docs/live-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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) {
Expand Down Expand Up @@ -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

Expand Down
100 changes: 100 additions & 0 deletions e2e/changes.spec.js
Original file line number Diff line number Diff line change
@@ -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 });
});
Loading
Loading