docs: add E2E test refactoring spec

This commit is contained in:
Claude
2026-06-15 10:55:07 +00:00
parent 26d5c0ad71
commit eb56c5768f
+233
View File
@@ -0,0 +1,233 @@
# E2E Test Refactoring Spec
Status: Proposed
Owner: PMG maintainers
Target branch: `claude/e2e-tests-github-actions-hkxij2`
## Goal
Make PMG's end-to-end (E2E) tests **easy to write, maintain, and extend**, and
let us run them **across Linux, macOS, and Windows** without copy-pasting YAML.
Reliability of PMG is the driver: we expect to add many more E2E cases, so the
cost of adding one must be low and the structure must scale to a 3-OS matrix.
Chosen direction: **Go test harness (testify) for the package-manager matrix +
a shared composite setup action + a reusable sandbox workflow** (Option D from
the analysis). The package-manager matrix must run on **all three OS** in the
near term.
## Current state (what we are replacing)
E2E logic is spread across five places with heavy duplication:
| File | Jobs / content |
|---|---|
| `.github/workflows/pmg-e2e.yml` (808 lines) | One giant `e2e-test` job (~18 inline bash steps) + 3 near-identical sandbox jobs (macOS Seatbelt, Linux Bubblewrap, Linux Landlock) |
| `.github/workflows/action-e2e.yml` | 4 jobs testing the composite `action.yml` |
| `.github/workflows/ci.yml` | `e2e-test` job running `test/e2e.sh` (trivial dry-run smoke) |
| `test/sandbox-e2e.js`, `test/pm-e2e.js` | Node assertions invoked from sandbox jobs |
| `test/e2e.sh` | 3-line dry-run smoke test |
| `sandbox/platform/landlock_e2e_linux_test.go` | Go-based Landlock helper E2E (the pattern we extend) |
### Concrete duplication to eliminate
1. **Environment setup copy-pasted 7+ times.** The
`checkout → setup-go → corepack → setup-node → pnpm → build → add-to-PATH →
pmg setup install` block repeats in all 4 jobs of `pmg-e2e.yml`, in
`action-e2e.yml`, and in `ci.yml`. GitHub Actions has no YAML anchors, so it
is literal duplication.
2. **The 3 sandbox jobs are ~90% identical.** Same "Create Test
Directories/Files", same "Disable AppArmor", same 12-line canary-secret
`env:` block (also duplicated in the `Makefile`), same two run steps. Deltas:
`runs-on`, driver env var, two extra files on Linux, one Landlock-only step.
3. **Every PM install step has the same shape**
(`mkdir → init → install A → install B → assert node_modules + manifest →
rm lockfile → reinstall → re-assert`), hand-written ~10 times for npm, pnpm,
bun, yarn (proxy/non-proxy) and the pip/pip3/uv/poetry analog.
4. **Hardcoded fixtures repeated everywhere:** `express@5.2.1`/`lodash@4.17.21`,
`requests==2.32.4`/`numpy==2.3.5`, and malicious markers
`safedep-test-pkg@0.1.3` / `nyc-config@10.0.0`.
5. **Weak/ad-hoc assertions.** `pmg-e2e.yml:521`
(`! pmg ... npm install nyc-config@10.0.0 || echo "blocked"`) passes
regardless of outcome — a real reliability gap. Temp-dir handling is
inconsistent (`mkdir x && cd x` vs `mktemp -d`).
6. **Multi-OS goal structurally unmet.** The PM matrix is `os: [ubuntu-latest]`
only; adding an OS today means duplicating a ~500-line job.
7. **No granularity/parallelism.** ~18 PM tests run serially in one 20-minute
job; one failure masks the rest.
## Target architecture
```
.github/
actions/
setup-pmg/action.yml # composite: toolchains + build pmg + PATH + pmg setup install
workflows/
pmg-e2e.yml # slim: PM matrix (Go harness) over OS + calls sandbox workflow
e2e-sandbox.yml # reusable (workflow_call): sandbox over {os, driver}
action-e2e.yml # uses setup-pmg where applicable
ci.yml # e2e-test job replaced by harness smoke (or removed)
test/
e2e/ # NEW Go harness (testify, mirrors landlock_e2e pattern)
main_test.go # TestMain: build pmg once, locate repo root
harness.go # RunPmg, project dirs, assert helpers, env seeding
fixtures.go # benign/malicious coordinates, canary env, PM descriptors
npm_test.go pnpm_test.go bun_test.go yarn_test.go
pip_test.go uv_test.go poetry_test.go npx_test.go
modes_test.go # --dry-run, --silent, --verbose, --debug/--log, --paranoid
regression_test.go # httpx NO_PROXY #339, yarn-berry corepack pinning
sandbox-e2e.js pm-e2e.js # kept, still invoked by the sandbox workflow
```
### The Go harness (`test/e2e/`)
Reuse the proven shape from `sandbox/platform/landlock_e2e_linux_test.go`
(walk up to repo root, build/locate `bin/pmg`).
- **Build once:** `TestMain` builds `bin/pmg` a single time per `go test`
invocation (guarded so a prebuilt binary from `make` is reused in CI).
- **PM descriptor table** drives the repeated pattern instead of bespoke bash:
```go
type packageManager struct {
name string // "npm", "pnpm", "bun", "yarn", ...
bin string // executable to probe on PATH
initArgs []string // e.g. {"init", "-y"}
addArgs func(pkg string) []string
installArgs []string // manifest install
manifest string // "package.json" / "pyproject.toml"
lockfile string // "package-lock.json" / "pnpm-lock.yaml" / ...
proxyModes []bool // which proxy modes to exercise
}
```
- **Helpers** (testify `require`/`assert`, per CLAUDE.md):
- `RunPmg(t, opts, args...) result` — runs `pmg`, captures stdout/stderr/exit.
- `NewProject(t)` — `t.TempDir()`-based working dir with cleanup for free.
- `RequireInstalled(t, dir, pkg)`, `RequireManifestContains(t, manifest, pkg)`,
`RequireLockfile(t, dir, name)`.
- `RequireBlocked(t, result, marker)` — asserts **non-zero exit AND** the
"Malicious package blocked" signal AND absence in `node_modules`. This
replaces every weak `|| echo` block (fixes #5) in one place.
- `RequireTool(t, name)` — `t.Skip` when a PM is not installed on the runner.
- `SeedCanaryEnv()` — single source of truth for the scrub/keep canaries
shared with the sandbox JS scripts and `Makefile`.
- **Fixtures centralized** in `fixtures.go`: benign packages, malicious markers,
and Python/Node version pins. Changing `express@5.2.1` happens in one place.
- **Granularity & parallelism:** each PM is a table-driven test with subtests
per `{proxyMode}`; independent cases use `t.Parallel()`.
- **Skips over failures:** tools absent on a given OS (e.g. poetry, bun) are
skipped via `RequireTool`, not failed — so the same suite is portable.
### Composite setup action (`.github/actions/setup-pmg`)
Encapsulates the repeated environment block with inputs for what each job
needs (`go`, `node`, `pnpm`, `bun`, `python`, `uv`, `poetry`, `build`,
`pmg-setup`). All workflows consume it, killing duplication #1. Pin action SHAs
exactly as today.
### Reusable sandbox workflow (`.github/workflows/e2e-sandbox.yml`)
`on: workflow_call` with inputs `os` and `driver`. Defines the canary `env:`
once, the test-dir/file seeding once, and the AppArmor relaxation + Landlock
probe behind `if:` guards. `pmg-e2e.yml` calls it three times via matrix
(`{macos, seatbelt}`, `{ubuntu, bubblewrap}`, `{ubuntu, landlock}`), collapsing
3 jobs into one definition (fixes #2).
### Slimmed `pmg-e2e.yml`
```yaml
jobs:
pm-matrix:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: ./.github/actions/setup-pmg
with: { node: true, pnpm: true, bun: true, python: true, uv: true, poetry: true }
- run: go test ./test/e2e/... -count=1 -v
sandbox:
strategy:
fail-fast: false
matrix:
include:
- { os: macos-latest, driver: seatbelt }
- { os: ubuntu-latest, driver: bubblewrap }
- { os: ubuntu-latest, driver: landlock }
uses: ./.github/workflows/e2e-sandbox.yml
with: { os: '${{ matrix.os }}', driver: '${{ matrix.driver }}' }
```
Adding a PM = a new descriptor + `_test.go`. Adding an OS = a matrix entry.
## Multi-OS plan (all three soon)
- **Go is the portability lever:** the harness compiles and runs on Linux,
macOS, and Windows, so the PM matrix fans out via `runs-on` alone.
- **Per-OS tool availability** handled by `RequireTool` skips and by toggling
setup-action inputs (e.g. don't request poetry where we don't test it).
- **Sandbox is inherently OS-specific** (Seatbelt=macOS, Bubblewrap/Landlock=
Linux, none on Windows) and stays in the reusable sandbox workflow, not the
cross-OS PM matrix.
- **Windows risk to resolve in Phase 3 (see Open Questions):** today
`ci.yml`'s Windows job only runs `./internal/flows/...`, implying limited
Windows coverage. We must confirm `pmg setup install`, proxy mode, and PATH
shims behave on Windows before enabling the Windows leg; gate with
`runtime.GOOS` skips where features are unsupported rather than failing.
## Migration phases
1. **Setup action.** Add `.github/actions/setup-pmg`; switch every existing job
to it. No behavior change — pure dedupe. Verify CI stays green.
2. **Reusable sandbox workflow.** Extract `e2e-sandbox.yml`; collapse the 3
sandbox jobs into a matrix. Centralize canaries (workflow + `Makefile` +
harness reference the same list).
3. **Harness bootstrap.** Stand up `test/e2e/` with the harness, fixtures, and
**npm + pnpm** ported as proof. Run Linux only. Validate parity with the old
bash (same packages, same assertions) and fix the `nyc-config` weak check.
4. **Port the rest.** bun, yarn (classic + berry), npx/pnpx, pip, pip3, uv,
poetry, modes, and the two regression cases (#339, yarn-berry corepack).
Delete the corresponding inline bash from `pmg-e2e.yml`.
5. **Enable macOS + Windows** legs with `RequireTool`/`runtime.GOOS` skips;
resolve the Windows support questions; tune the OS-specific toolchain inputs.
6. **Consolidate leftovers.** Fold `action-e2e.yml` block tests and
`ci.yml`/`test/e2e.sh` smoke into the harness where it reduces surface;
remove dead scripts.
Each phase is independently shippable and leaves CI green.
## Cross-cutting cleanups (apply during the relevant phase)
- Single source of truth for fixtures and canary env.
- Standardized assertions and `t.TempDir()` lifecycle (no manual `cd`/cleanup).
- `fail-fast: false` + per-PM subtests for at-a-glance failure isolation.
- Fix `pmg-e2e.yml:521` weak malicious-block assertion via `RequireBlocked`.
## Non-goals
- Rewriting the sandbox policy `.js` assertion scripts (kept; only their
invocation is deduped).
- Changing what PMG itself does; this is test-infrastructure only.
- Eliminating external package-manager processes (PMs are still real
subprocesses, wrapped by the harness).
## Risks & trade-offs
- **Upfront port cost** is the largest of the options; mitigated by phasing
(npm/pnpm proof first) and by reusing the existing Landlock E2E pattern.
- **Runner cost:** macOS/Windows minutes are pricier than Linux. Keep
`fail-fast: false` but consider running the full 3-OS matrix on `main`/nightly
and a Linux-only fast leg on PRs if minutes become a concern.
- **Windows feature gaps** (above) may force `runtime.GOOS` skips initially;
acceptable as long as skips are explicit and visible.
## Open questions
1. Does `pmg setup install` + proxy mode + PATH shims work on Windows today? If
not, which PM cases can run there in Phase 5, and which are skipped?
2. Should the full 3-OS matrix run on every PR, or PR=Linux + nightly/`main`=all
three to control runner minutes?
3. Keep the sandbox assertions in Node (`test/*.js`) long-term, or eventually
port them into the Go harness for one language? (Spec assumes: keep Node.)