# 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.)