test(e2e): add full smoketest covering install, search, upgrade, remove

Creates tests/e2e/smoketest.sh — a hermetic end-to-end test that:
- Sets up a mock ZUUR (python3 http.server serving local index/recipes)
- Creates fake zeta-makepkg and zeta scripts (no real Lua tools needed)
- Tests the full pipeline: search → install → upgrade → info → remove
- Verifies exit codes (0, 2 for not-found)
- Verifies installed.json state tracking
- Verifies binary installation/removal via ZETA_ROOT

BUG FOUND (documented in problems.md, not fixed):
- info.d scanDepsArray stores ptrdiff_t indexOf() result in size_t,
  causing ArrayIndexError when recipe lacks a deps field.
  Workaround: recipe includes deps = {}.

Evidence: .omo/evidence/task-27-tofu-core.log — 16/16 checks pass.
This commit is contained in:
2026-08-08 18:52:19 -04:00
parent 70ab40abb9
commit fc979b650d
4 changed files with 478 additions and 0 deletions
+55
View File
@@ -1344,3 +1344,58 @@ The sandbox test (test 2) creates a sentinel file, serves an index containing `o
- `dub test` passes — 23 modules, including upgrade.d's 6 unittests.
- `dub build` passes with `warningsAsErrors`.
- Evidence logged to `.omo/evidence/task-22-tofu-core.log`.
## Task 27 — End-to-end smoketest (`tests/e2e/smoketest.sh`)
### Mock ZUUR pattern
- Python3 HTTP server from a temp directory serving `index.lua`, `recipes/<name>/<name>.recipe`, `recipes/<name>/package.lua`.
- `fetchRecipe` downloads via `cfg.recipesUrl(name)` → `zuur_url/recipes/<name>/<name>.recipe`.
- `fetchIndex` fetches `zuur_url/index.lua`, passes through Lua sandbox, outputs JSON parsed by `parseIndexJson`.
- Index entries use fields `name`, `ver`, `summary`, `pool` (matching `PackageIndex` struct).
- The server must cd into the ZUUR root directory: `(cd "$MOCK_ZUUR" && python3 -m http.server ...) &`
### Fake tool pattern (script-level, not D unittests)
- `TOFU_ZETA_TOOLCHAIN_PATH` overrides the zeta-makepkg binary path — must be an absolute path to the fake script.
- `TOFU_ZETA_PATH` overrides the zeta binary path.
- Fake zeta-makepkg args: `<recipePath> --output <dir> -j<N> --no-index --repo <url>`. Parse `--output` by scanning argv.
- Fake zeta args: `-LocalProvide <pkg> --pass` or `-Remove <pkg> --pass [--force]`.
- `$TMP/bin` must be in `PATH` for the fake executables to be found.
- Tarball in `$output_dir/packages/<name>/<name>-<ver>.tar.gz` with the built binary.
- `package.lua` must be copied to `$output_dir/packages/<name>/package.lua` (build.d verifies this exists).
### Shell patterns for robustness
- `set -euo pipefail` is critical but causes false negatives when a command in a pipeline exits non-zero but produces valid stdout (grep matches but pipefail kills the `if` condition).
- **Fix**: capture output first, then grep: `OUT=$("$BIN" args 2>/dev/null) || true; echo "$OUT" | grep -q "needle"`. This avoids pipefail entirely.
- `2>/dev/null` on a pipeline command redirects only that command's stderr — the grep still sees stdout.
- For pure exit-code checks: `if "$BIN" args 2>&1; then` — no pipe involved.
### Bug discovered: info.d scanDepsArray unsigned type bug
- `size_t pos = content.indexOf("deps")` at line 93 stores a signed return value in unsigned type, wrapping -1 to SIZE_MAX.
- The guard `if (pos < 0)` is always false for unsigned. Subsequent `content[pos - 1]` causes ArrayIndexError.
- Crash is an `ArrayIndexError` (D `Error`, not `Exception`), bypassing main.d's `catch (Exception e)`.
- Workaround: recipe must include `deps = {}` to ensure the "found" code path is taken.
- Documented in problems.md for fix dispatch.
### Lock file handling
- tofu acquires/releases a lock per invocation. The smoketest runs sequential commands — no concurrent lock issues.
- Lock file path: `$TOFU_CACHE_DIR/.lock`.
### Upgrade semantics verified
- `-Syu` with an already-up-to-date package prints "nothing to do" and exits 0.
- Installed packages tracked in `$TOFU_CACHE_DIR/installed.json` as a JSON array.
### Recipe format requirements for smoketest
- All values MUST be double-quoted strings (single quotes are silently ignored by the light parser).
- `deps = {}` required as workaround for info.d bug.
- `build_system = "make"` is one of the recognized build system values.
- Package.lua uses `version` (not `ver`) at manifest level.
### Exit codes verified
- `-Ss <found>` → exit 0
- `-S <pkg> --noconfirm` → exit 0 on success
- `-Syu --noconfirm` → exit 0
- `-Si <pkg>` → exit 0 (with the deps workaround)
- `-R <pkg> --noconfirm` → exit 0
- `-S nonexistent --noconfirm` → exit 2 (package not found in index)
- No-args → exit 1, `--help` → exit 0
+25
View File
@@ -0,0 +1,25 @@
# Problems — tofu-core
Unresolved blockers and technical debt discovered during work on this plan.
_Auto-scaffolded by /start-work. Append new entries below - never overwrite._
---
## BUG FOUND (task 27 smoketest): info.d scanDepsArray — unsigned type stores signed indexOf result
**Severity:** High (crash on any recipe without `deps` field)
**Location:** `src/tofu/commands/info.d:93`
**Root cause:**
`size_t pos = content.indexOf("deps")` — indexOf returns ptrdiff_t (-1 for not found), storing it in size_t (unsigned) wraps -1 to SIZE_MAX. The guard `pos < 0` is always false for unsigned types. When deps is absent, `content[pos - 1]` accesses far out of bounds → ArrayIndexError.
**Why it escaped unit tests:** All 8 info.d unittests use recipes containing a `deps` field. The crash only triggers when `deps` is completely absent.
**Impact:** `tofu -Si` crashes on recipes without `deps` with an uncaught ArrayIndexError (D Error, not Exception — bypasses catch blocks).
**Fix (to be dispatched):** Change `size_t pos` to `ptrdiff_t pos` at line 93.
**Workaround in smoketest:** Recipe includes `deps = {}` to avoid triggering this bug.