Skip to content

test(e2e): add GitHub Actions CI + Playwright E2E suite for user journeys - #617

Open
Nuctori wants to merge 23 commits into
agegr:mainfrom
Nuctori:fix/test-harness-react-instance
Open

test(e2e): add GitHub Actions CI + Playwright E2E suite for user journeys#617
Nuctori wants to merge 23 commits into
agegr:mainfrom
Nuctori:fix/test-harness-react-instance

Conversation

@Nuctori

@Nuctori Nuctori commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Closes #599 — example implementation of the CI + E2E proposal (items A and C of the issue; item B / harness fix already landed via #588).

What this adds

1. GitHub Actions CI (.github/workflows/ci.yml)

  • Four jobs on push(main) + pull_request: lint / test / build / e2e
  • Node 22 (aligned with engines >=22.19), cache: npm
  • e2e job boots the dev server, waits for readiness, runs the full E2E suite

2. Script-style Playwright E2E suite (e2e/, zero config)

Suite Coverage
run.mjs 5000-message linear session: tail window + sentinel + no crash (#509/#555 baseline)
j0-shell-nav App shell renders, theme control present
j1-session-lifecycle Long-session tail enforcement (API-level assertion: context ≤ tail cap, oldest message excluded), branch session renders active path
j2-dialogue Markdown / code block / tool-call rendering
j3-model-agent Model/tool UI presence
j4-files-project Files/project UI smoke
j5-settings-ops Settings/plugins/skills UI smoke
j6-api-health Hard 500-gate: detail/context/list return 200 + correct JSON shape; invalid id, bad leafId, and path traversal must NOT 500

Helpers isolate all fixtures under ~/.pi/agent/sessions/e2e-* with cleanup; no playwright.config.ts, each spec is a plain runnable script.

Coverage honesty: J1/J6 (+ run.mjs) are hard assertions; J2/J3 are smoke-level; J4/J5 are soft-presence checks. Sending/streaming/fork interactions need a real agent backend and are out of scope for this first pass — listed as follow-up work on #599.

Why

509 (deep-chain stack overflow) and #555 (full-history transfer) were exactly the class of regression unit tests miss. This gate now blocks them automatically on every PR — including bulk/AI-assisted contributions where "looks right, crashes at runtime" regressions are most likely.

Validation

  • Branch CI fully green: run 32946773853 — lint ✅ test ✅ build ✅ e2e ✅ (the e2e job itself caught and fixed 5 real spec bugs during development, incl. a sidebar-preview false positive and a 30s session-list cache race)
  • npm test: same pass/fail counts as upstream main (no product code touched)

Developed with AI coding assistance (Claude); all commits reviewed and tested by a human.

Nuctori and others added 17 commits August 24, 2026 04:33
- load react, react-dom/server, and the i18n provider through the same jiti instance the components use, and import useI18n via the @/ alias (matching component imports) so I18nContext resolves to a single module instance.

- previously the test entry imported React natively while jiti loaded the .tsx tree, producing two React instances and two I18nContext modules; useContext then returned null and every component test threw 'useI18n must be used inside I18nProvider'.

- suite now 591 passing / 9 failing (was 565/35). Remaining 9 are platform-conditional (symlink, Windows PATH separator) or pre-existing brittle source-structure assertions, not harness defects.

- Relates to agegr#586.
…t-instance

# Conflicts:
#	components/ChatInput.test.mjs
…olation

- add playwright devDep and e2e/helpers.mjs (isolated e2e-* sessions, BASE/env, launchPage)
- J0 shell/nav, J1 session lifecycle (tail50/sentinel/branch), J2 dialogue (markdown/code/tool), J3 model/agent, J4 files/project, J5 settings/ops - each one spec.mjs reuses helpers, zero playwright.config, rmSync cleanup
- ci.yml: node 20->22 (engines>=22.19), cache:npm, e2e job runs all journeys (run.mjs + j0-j5)
- covers Issue agegr#599 (CI+E2E, AI risk gate); existing 142 *.test.mjs unchanged
…ive cases (J6)

- helpers.mjs: assertApiOk (2xx + JSON shape, 5xx fails with body) / assertApiNot500
- j6-api-health.spec.mjs: detail/context/list 200+shape; invalid id, bad leafId/tail=0,
  path traversal must NOT 500 (catches string-content guard removal, tree-depth guard
  removal, files-route ENOENT leaks that previously passed soft UI checks)
- ci.yml e2e job runs j6 after j0-j5
…ight deps

- j6: context route returns {context,...} — assert ctxBody.context.messages (P1, was guaranteed-fail)
- helpers: AbortSignal.timeout(15s) on API fetches so a hung dev server fails fast
- ci.yml: npx playwright install --with-deps chromium (known missing-libs flake)
…' into fix/test-harness-react-instance

# Conflicts:
#	components/ChatInput.test.mjs
…ng — 30s list cache made fresh sessions invisible to ?session= restore
…vDep entry lost by taking upstream lockfile side
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

…t tail=50, big-session page render

- e2e/perf-bench.mjs: generates PERF_SESSIONS(200) small fixtures + 5000-msg session
  on a pristine server, measures cold/warm /api/sessions latency (agegr#555 cold-scan pain,
  now visible as a number), context tail=50 latency, and browser render-to-sentinel
- generous order-of-magnitude gates only (trend detection, not SLA — shared runners are noisy)
- ci.yml: dedicated perf job with pristine dev server
…nates 70s cold load

SDK SessionManager.listAll replays every byte of every session (~9.5 GiB)
to build allMessagesText, which pi-web never consumes. Replace the inner
scan with a pure-function memoization:

  F(file) -> ScannedSessionInfo  (1:1 mirror of SDK semantics for the
  fields pi-web actually reads: id/cwd/name/created/modified/
  messageCount/firstMessage/parentSessionPath)

Invariant (append-only): pi only appends lines or rewrites wholesale;
either changes (size, mtimeMs). So fingerprint fp=(size,mtimeMs)
unchanged => bytes unchanged => cached F(file) === fresh scan.
Changed/deleted/new files are always rescanned/dropped/added, so the
incremental set equals a full rescan and sort(modified desc) matches SDK.

Effect: cold /api/sessions 70s at 1992 sessions -> warm-hit stat-only
pass (~200ms) with at most changed files rescanned; persisted index
(pi-web-session-index.json, atomic 0600) survives restarts and is
revalidated by stat on every load.
…or the hot session

For append-only JSONL, grown files need only the delta bytes from the
previous size. tailUpdateForGrownFile() reads only new bytes, verifies
header id unchanged and line-boundary integrity, and composes the four
aggregates (messageCount, firstMessage, name, modified) incrementally.
Truncation/rewrite/header-change/concurrent partial line fall back to a
full rescan. Bump persisted index version to 2 to invalidate stale v1.
The 2000+ session catalogue rendered every <SessionItem/> eagerly, so
first paint paid for 2003 DOM nodes plus 2003 hover/rename/delete state
machines. Switch the list to a fixed-height (54px) window: ResizeObserver
measures the scroll viewport, a rAF-throttled onScroll computes the
visible window, and translateY positions the slice. Empty/loading/error
states render unaffected. The first paint shows a small slice; scroll
mounts the next window. No new dependency.
…B sessions

The /api/sessions/[id]/context?tail=N endpoint was calling
SessionManager.open(file).getEntries() and then walkActiveBranch. For a
multi-MB session that meant parsing the whole file just to walk back 50
entries.

Add loadRecentEntries() that reads the file backwards in 1 MiB chunks,
JSON-parses each line on the fly into a byId map, and walks the
parentId chain. The chain is bounded by tail (default 50), so a
huge session costs O(tail) bytes instead of O(file). The route uses it
on disk-backed sessions and keeps the SDK loader for live RPC sessions
whose entries aren't yet on disk.

Measured on the largest session (53 MiB):
  before: context tail=50  4795ms
  after:  context tail=50   564ms
- context-route.test: assert buildSessionContext(entries, …) instead
  of the removed sm.getEntries() leaf (L4 reverse-streams disk paths).
- session-reader.test: stub listSessionsIncremental (the scanner entry
  point), not SessionManager.listAll which is no longer called on the
  list path.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

chore(ci): add GitHub Actions with E2E to catch regressions early and reduce AI-contribution risk

2 participants