11 KiB
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
- Environment setup copy-pasted 7+ times. The
checkout → setup-go → corepack → setup-node → pnpm → build → add-to-PATH → pmg setup installblock repeats in all 4 jobs ofpmg-e2e.yml, inaction-e2e.yml, and inci.yml. GitHub Actions has no YAML anchors, so it is literal duplication. - 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 theMakefile), same two run steps. Deltas:runs-on, driver env var, two extra files on Linux, one Landlock-only step. - 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. - Hardcoded fixtures repeated everywhere:
express@5.2.1/lodash@4.17.21,requests==2.32.4/numpy==2.3.5, and malicious markerssafedep-test-pkg@0.1.3/nyc-config@10.0.0. - 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 xvsmktemp -d). - Multi-OS goal structurally unmet. The PM matrix is
os: [ubuntu-latest]only; adding an OS today means duplicating a ~500-line job. - 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:
TestMainbuildsbin/pmga single time pergo testinvocation (guarded so a prebuilt binary frommakeis reused in CI). -
PM descriptor table drives the repeated pattern instead of bespoke bash:
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— runspmg, 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 innode_modules. This replaces every weak|| echoblock (fixes #5) in one place.RequireTool(t, name)—t.Skipwhen a PM is not installed on the runner.SeedCanaryEnv()— single source of truth for the scrub/keep canaries shared with the sandbox JS scripts andMakefile.
-
Fixtures centralized in
fixtures.go: benign packages, malicious markers, and Python/Node version pins. Changingexpress@5.2.1happens in one place. -
Granularity & parallelism: each PM is a table-driven test with subtests per
{proxyMode}; independent cases uset.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
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-onalone. - Per-OS tool availability handled by
RequireToolskips 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 confirmpmg setup install, proxy mode, and PATH shims behave on Windows before enabling the Windows leg; gate withruntime.GOOSskips where features are unsupported rather than failing.
Migration phases
- Setup action. Add
.github/actions/setup-pmg; switch every existing job to it. No behavior change — pure dedupe. Verify CI stays green. - Reusable sandbox workflow. Extract
e2e-sandbox.yml; collapse the 3 sandbox jobs into a matrix. Centralize canaries (workflow +Makefile+ harness reference the same list). - 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 thenyc-configweak check. - 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. - Enable macOS + Windows legs with
RequireTool/runtime.GOOSskips; resolve the Windows support questions; tune the OS-specific toolchain inputs. - Consolidate leftovers. Fold
action-e2e.ymlblock tests andci.yml/test/e2e.shsmoke 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 manualcd/cleanup). fail-fast: false+ per-PM subtests for at-a-glance failure isolation.- Fix
pmg-e2e.yml:521weak malicious-block assertion viaRequireBlocked.
Non-goals
- Rewriting the sandbox policy
.jsassertion 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: falsebut consider running the full 3-OS matrix onmain/nightly and a Linux-only fast leg on PRs if minutes become a concern. - Windows feature gaps (above) may force
runtime.GOOSskips initially; acceptable as long as skips are explicit and visible.
Open questions
- 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? - Should the full 3-OS matrix run on every PR, or PR=Linux + nightly/
main=all three to control runner minutes? - 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.)