From ddc1971c02a8b56c601ee384756e0642a99b7310 Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sun, 21 Dec 2025 00:22:55 +0900 Subject: [PATCH 01/17] Add detection for more package managers --- README.md | 10 +-- src/why_core.nim | 98 ++++++++++++++++++++++++- tests/test_why_core.nim | 155 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 255 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 842456a..c36f7d1 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,13 @@ **"Why is this command here? Who installed it?"** `why` is a CLI tool that identifies the installation source (provider) of a command. -It resolves symlinks, checks path patterns, and queries system package managers to tell you if a command is managed by Homebrew, apt, npm, Mise, Flatpak, or simply a system file. +It resolves symlinks, checks path patterns, and queries system package managers to tell you if a command is managed by Homebrew, apt, npm, Mise, Flatpak, asdf, SDKMAN!, or simply a system file. ## Features -- Provider Identification: Instantly detects if a binary is from Homebrew, apt/dpkg, yum/rpm, npm, pip, Cargo, Go, etc. +- Provider Identification: Instantly detects if a binary is from Homebrew, apt/dpkg, yum/rpm, zypper/rpm, apk, pacman, Nix, MacPorts, npm, pip, Cargo, Go, etc. - Symlink Resolution: Traces the "Origin Path" (where your shell finds it) to the "Real Path" (where the binary actually lives). -- System Package Manager Integration: Automatically queries `dpkg` or `rpm` for system files to identify the package name. +- System Package Manager Integration: Automatically queries `dpkg`, `rpm`/`zypper`, `apk`, `pacman`, `pkg`, or Portage tools for system files to identify the package name. ## Installation @@ -77,8 +77,8 @@ Real Path: /var/lib/flatpak/app/com.valvesoftware.Steam/current/active/export/ `why` currently supports detection for: -- Package Managers: Homebrew, apt (Debian/Ubuntu), yum/rpm (RHEL/CentOS), Snap, Flatpak -- Version Managers: Mise, Volta +- Package Managers: Homebrew, apt (Debian/Ubuntu), yum/rpm (RHEL/CentOS), zypper/rpm (SUSE), apk (Alpine), pacman (Arch), Portage (Gentoo), pkg (FreeBSD), MacPorts, Nix, Snap, Flatpak, Scoop, Chocolatey, winget +- Version Managers: Mise, Volta, asdf, SDKMAN!, nvm, fnm, pyenv, rbenv, rvm, Rustup, Conda - Language Managers: npm (Global), pip/pipx (Python), Cargo (Rust), Go - System: Standard system paths (`/usr/bin`, etc.) diff --git a/src/why_core.nim b/src/why_core.nim index 07cf213..25058cf 100644 --- a/src/why_core.nim +++ b/src/why_core.nim @@ -50,6 +50,12 @@ proc defaultRules*(homeDir: string): seq[ProviderRule] = "/opt/homebrew", "/usr/local/cellar", "/home/linuxbrew/.linuxbrew", "/.linuxbrew/cellar", "/.linuxbrew/caskroom" ]), + ProviderRule(name: "MacPorts", kind: mkStartsWith, patterns: @[ + "/opt/local/" + ]), + ProviderRule(name: "Nix", kind: mkStartsWith, patterns: @[ + "/nix/store", "/run/current-system/sw", "/nix/var/nix/profiles" + ]), ProviderRule(name: "Flatpak", kind: mkStartsWith, patterns: @[ "/var/lib/flatpak/exports/bin", homeDir / ".local/share/flatpak/exports/bin" @@ -60,6 +66,45 @@ proc defaultRules*(homeDir: string): seq[ProviderRule] = ProviderRule(name: "Snap", kind: mkContains, patterns: @[ "/snap/", "snap/bin" ]), + ProviderRule(name: "asdf", kind: mkContains, patterns: @[ + ".asdf/shims", ".asdf/installs" + ]), + ProviderRule(name: "SDKMAN!", kind: mkContains, patterns: @[ + ".sdkman" + ]), + ProviderRule(name: "Volta", kind: mkContains, patterns: @[ + ".volta" + ]), + ProviderRule(name: "nvm", kind: mkContains, patterns: @[ + ".nvm" + ]), + ProviderRule(name: "fnm", kind: mkContains, patterns: @[ + ".fnm", ".local/share/fnm", "fnm_multishells" + ]), + ProviderRule(name: "pyenv", kind: mkContains, patterns: @[ + ".pyenv", "pyenv/shims" + ]), + ProviderRule(name: "rbenv", kind: mkContains, patterns: @[ + ".rbenv", "rbenv/shims" + ]), + ProviderRule(name: "rvm", kind: mkContains, patterns: @[ + ".rvm", "/usr/local/rvm" + ]), + ProviderRule(name: "Rustup", kind: mkContains, patterns: @[ + ".rustup", "rustup/toolchains" + ]), + ProviderRule(name: "Conda", kind: mkContains, patterns: @[ + ".conda", "/miniconda", "/anaconda", "/mambaforge", "/miniforge" + ]), + ProviderRule(name: "Scoop", kind: mkContains, patterns: @[ + "scoop/shims", "scoop/apps", "scoop\\shims", "scoop\\apps" + ]), + ProviderRule(name: "Chocolatey", kind: mkContains, patterns: @[ + "chocolatey/bin", "chocolatey/lib", "chocolatey\\bin", "chocolatey\\lib" + ]), + ProviderRule(name: "winget", kind: mkContains, patterns: @[ + "WindowsApps", "Microsoft\\WindowsApps" + ]), ProviderRule(name: "Cargo", kind: mkContains, patterns: @[ ".cargo/bin" ]), @@ -70,9 +115,6 @@ proc defaultRules*(homeDir: string): seq[ProviderRule] = "site-packages", "dist-packages", "/pipx/", ".local/bin/pipx", "/bin/pip", "/bin/pip3" ]), - ProviderRule(name: "Volta", kind: mkContains, patterns: @[ - ".volta" - ]), ProviderRule(name: "Go", kind: mkContains, patterns: @[ "go/bin" ]), @@ -143,11 +185,61 @@ proc checkSystemPackageManager*(path: string, ctx: WhyCtx): string = if parts.len > 0: return "apt/dpkg (" & parts[0].strip() & ")" + if ctx.findExe("zypper").len > 0 and ctx.findExe("rpm").len > 0: + let (outp, exitCode) = ctx.execCmd("rpm -qf " & quoteShell(path)) + if exitCode == 0: + return "zypper/rpm (" & outp.strip() & ")" + if ctx.findExe("rpm").len > 0: let (outp, exitCode) = ctx.execCmd("rpm -qf " & quoteShell(path)) if exitCode == 0: return "yum/rpm (" & outp.strip() & ")" + if ctx.findExe("apk").len > 0: + let (outp, exitCode) = ctx.execCmd("apk info -W " & quoteShell(path)) + if exitCode == 0: + let lines = outp.splitLines() + if lines.len > 0 and lines[0].strip().len > 0: + return "apk (" & lines[0].strip() & ")" + + if ctx.findExe("pacman").len > 0: + let (outp, exitCode) = ctx.execCmd("pacman -Qo " & quoteShell(path)) + if exitCode == 0: + let trimmed = outp.strip() + let marker = " is owned by " + if trimmed.contains(marker): + let parts = trimmed.split(marker) + if parts.len > 1: + return "pacman (" & parts[1].strip() & ")" + if trimmed.len > 0: + return "pacman (" & trimmed & ")" + + if ctx.findExe("pkg").len > 0: + let (outp, exitCode) = ctx.execCmd("pkg which -q " & quoteShell(path)) + if exitCode == 0: + let lines = outp.splitLines() + if lines.len > 0 and lines[0].strip().len > 0: + return "pkg (" & lines[0].strip() & ")" + + if ctx.findExe("qfile").len > 0: + let (outp, exitCode) = ctx.execCmd("qfile -qv " & quoteShell(path)) + if exitCode == 0: + let lines = outp.splitLines() + if lines.len > 0: + let tokens = lines[0].splitWhitespace() + if tokens.len > 0: + return "portage (" & tokens[0].strip() & ")" + + if ctx.findExe("equery").len > 0: + let (outp, exitCode) = ctx.execCmd("equery b " & quoteShell(path)) + if exitCode == 0: + for line in outp.splitLines(): + let idx = line.find(" (") + if idx > 0: + let pkg = line[0.. 0 and not pkg.startsWith("*"): + return "portage (" & pkg & ")" + return "" proc findFlatpakFallback*(shortName: string, ctx: WhyCtx, homeDir: string): string = diff --git a/tests/test_why_core.nim b/tests/test_why_core.nim index 7386cc7..7d0ac40 100644 --- a/tests/test_why_core.nim +++ b/tests/test_why_core.nim @@ -76,6 +76,32 @@ suite "whyCore": check err.msg.len == 0 check res.provider == "apt/dpkg (bash)" + test "system package manager detection via zypper": + var files = {"/usr/bin/ls": true}.toTable + + let ctx = WhyCtx( + getEnv: proc(key: string): string = + if key == "PATH": "/usr/bin:/bin" else: "", + getCurrentDir: proc(): string = "/work", + getHomeDir: proc(): string = "/home/test", + fileExists: proc(p: string): bool = files.getOrDefault(p, false), + symlinkExists: proc(p: string): bool = false, + expandSymlink: proc(p: string): string = "", + dirExists: proc(p: string): bool = false, + listDir: proc(dir: string): seq[(DirEntryKind, string)] = @[], + findExe: proc(name: string): string = + if name == "zypper": "/usr/bin/zypper" + elif name == "rpm": "/usr/bin/rpm" + else: "", + execCmd: proc(cmd: string): ExecResult = ("coreutils-9.2-1\n", 0), + paramStr0: proc(): string = "/usr/bin/why" + ) + + let (ok, res, err) = whyCore("ls", ctx) + check ok + check err.msg.len == 0 + check res.provider == "zypper/rpm (coreutils-9.2-1)" + test "detectProviderByPath prefers real path": let rules = defaultRules("/home/test") let provider = detectProviderByPath( @@ -84,3 +110,132 @@ suite "whyCore": rules ) check provider == "System" + + test "detects common version managers by path": + let rules = defaultRules("/home/test") + let cases = { + "asdf": ("/home/test/.asdf/shims/node", "/home/test/.asdf/installs/nodejs/20.0.0/bin/node"), + "SDKMAN!": ("/home/test/.sdkman/candidates/java/current/bin/java", "/home/test/.sdkman/candidates/java/17.0.9/bin/java"), + "nvm": ("/home/test/.nvm/versions/node/v20.2.0/bin/node", "/home/test/.nvm/versions/node/v20.2.0/bin/node"), + "fnm": ("/home/test/.local/share/fnm/node-versions/v20.2.0/installation/bin/node", "/home/test/.local/share/fnm/node-versions/v20.2.0/installation/bin/node"), + "pyenv": ("/home/test/.pyenv/shims/python", "/home/test/.pyenv/versions/3.11.4/bin/python"), + "rbenv": ("/home/test/.rbenv/shims/ruby", "/home/test/.rbenv/versions/3.2.2/bin/ruby"), + "rvm": ("/home/test/.rvm/rubies/ruby-3.2.2/bin/ruby", "/home/test/.rvm/rubies/ruby-3.2.2/bin/ruby"), + "Rustup": ("/home/test/.cargo/bin/rustc", "/home/test/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/rustc"), + "Conda": ("/home/test/miniconda3/bin/python", "/home/test/miniconda3/bin/python") + }.toTable + + for expected, paths in cases: + let provider = detectProviderByPath(paths[0], paths[1], rules) + check provider == expected + + test "detects package managers by path": + let rules = defaultRules("/home/test") + let cases = { + "MacPorts": ("/opt/local/bin/port", "/opt/local/bin/port"), + "Nix": ("/nix/store/abc123/bin/nix", "/nix/store/abc123/bin/nix"), + "Scoop": ("C:\\Users\\bob\\scoop\\shims\\node.exe", "C:\\Users\\bob\\scoop\\apps\\nodejs\\current\\node.exe"), + "Chocolatey": ("C:\\ProgramData\\chocolatey\\bin\\git.exe", "C:\\ProgramData\\chocolatey\\lib\\git\\tools\\git.exe"), + "winget": ("C:\\Program Files\\WindowsApps\\Microsoft.WindowsTerminal_1.20.0.0_x64__8wekyb3d8bbwe\\wt.exe", + "C:\\Program Files\\WindowsApps\\Microsoft.WindowsTerminal_1.20.0.0_x64__8wekyb3d8bbwe\\wt.exe") + }.toTable + + for expected, paths in cases: + let provider = detectProviderByPath(paths[0], paths[1], rules) + check provider == expected + + test "system package manager detection via apk": + var files = {"/usr/bin/ls": true}.toTable + + let ctx = WhyCtx( + getEnv: proc(key: string): string = + if key == "PATH": "/usr/bin:/bin" else: "", + getCurrentDir: proc(): string = "/work", + getHomeDir: proc(): string = "/home/test", + fileExists: proc(p: string): bool = files.getOrDefault(p, false), + symlinkExists: proc(p: string): bool = false, + expandSymlink: proc(p: string): string = "", + dirExists: proc(p: string): bool = false, + listDir: proc(dir: string): seq[(DirEntryKind, string)] = @[], + findExe: proc(name: string): string = + if name == "apk": "/sbin/apk" else: "", + execCmd: proc(cmd: string): ExecResult = ("busybox-1.36.1-r0\n", 0), + paramStr0: proc(): string = "/usr/bin/why" + ) + + let (ok, res, err) = whyCore("ls", ctx) + check ok + check err.msg.len == 0 + check res.provider == "apk (busybox-1.36.1-r0)" + + test "system package manager detection via pacman": + var files = {"/usr/bin/ls": true}.toTable + + let ctx = WhyCtx( + getEnv: proc(key: string): string = + if key == "PATH": "/usr/bin:/bin" else: "", + getCurrentDir: proc(): string = "/work", + getHomeDir: proc(): string = "/home/test", + fileExists: proc(p: string): bool = files.getOrDefault(p, false), + symlinkExists: proc(p: string): bool = false, + expandSymlink: proc(p: string): string = "", + dirExists: proc(p: string): bool = false, + listDir: proc(dir: string): seq[(DirEntryKind, string)] = @[], + findExe: proc(name: string): string = + if name == "pacman": "/usr/bin/pacman" else: "", + execCmd: proc(cmd: string): ExecResult = ("/usr/bin/ls is owned by coreutils 9.2-1\n", 0), + paramStr0: proc(): string = "/usr/bin/why" + ) + + let (ok, res, err) = whyCore("ls", ctx) + check ok + check err.msg.len == 0 + check res.provider == "pacman (coreutils 9.2-1)" + + test "system package manager detection via pkg": + var files = {"/usr/bin/ls": true}.toTable + + let ctx = WhyCtx( + getEnv: proc(key: string): string = + if key == "PATH": "/usr/bin:/bin" else: "", + getCurrentDir: proc(): string = "/work", + getHomeDir: proc(): string = "/home/test", + fileExists: proc(p: string): bool = files.getOrDefault(p, false), + symlinkExists: proc(p: string): bool = false, + expandSymlink: proc(p: string): string = "", + dirExists: proc(p: string): bool = false, + listDir: proc(dir: string): seq[(DirEntryKind, string)] = @[], + findExe: proc(name: string): string = + if name == "pkg": "/usr/sbin/pkg" else: "", + execCmd: proc(cmd: string): ExecResult = ("coreutils\n", 0), + paramStr0: proc(): string = "/usr/bin/why" + ) + + let (ok, res, err) = whyCore("ls", ctx) + check ok + check err.msg.len == 0 + check res.provider == "pkg (coreutils)" + + test "system package manager detection via portage qfile": + var files = {"/usr/bin/ls": true}.toTable + + let ctx = WhyCtx( + getEnv: proc(key: string): string = + if key == "PATH": "/usr/bin:/bin" else: "", + getCurrentDir: proc(): string = "/work", + getHomeDir: proc(): string = "/home/test", + fileExists: proc(p: string): bool = files.getOrDefault(p, false), + symlinkExists: proc(p: string): bool = false, + expandSymlink: proc(p: string): string = "", + dirExists: proc(p: string): bool = false, + listDir: proc(dir: string): seq[(DirEntryKind, string)] = @[], + findExe: proc(name: string): string = + if name == "qfile": "/usr/bin/qfile" else: "", + execCmd: proc(cmd: string): ExecResult = ("sys-apps/coreutils-9.2 /usr/bin/ls\n", 0), + paramStr0: proc(): string = "/usr/bin/why" + ) + + let (ok, res, err) = whyCore("ls", ctx) + check ok + check err.msg.len == 0 + check res.provider == "portage (sys-apps/coreutils-9.2)" From 8cfd0f11d1b07ee4dde5306aa394ced02049285c Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sun, 21 Dec 2025 01:30:57 +0900 Subject: [PATCH 02/17] Add Docker-based e2e test harness --- tests/e2e/README.md | 29 +++++++++++ tests/e2e/alpine/Dockerfile | 13 +++++ tests/e2e/alpine/test.sh | 20 ++++++++ tests/e2e/arch/Dockerfile | 14 ++++++ tests/e2e/arch/test.sh | 20 ++++++++ tests/e2e/fedora/Dockerfile | 14 ++++++ tests/e2e/fedora/test.sh | 20 ++++++++ tests/e2e/opensuse/Dockerfile | 14 ++++++ tests/e2e/opensuse/test.sh | 20 ++++++++ tests/e2e/run.sh | 27 ++++++++++ tests/e2e/ubuntu/Dockerfile | 15 ++++++ tests/e2e/ubuntu/test.sh | 93 +++++++++++++++++++++++++++++++++++ 12 files changed, 299 insertions(+) create mode 100644 tests/e2e/README.md create mode 100644 tests/e2e/alpine/Dockerfile create mode 100755 tests/e2e/alpine/test.sh create mode 100644 tests/e2e/arch/Dockerfile create mode 100755 tests/e2e/arch/test.sh create mode 100644 tests/e2e/fedora/Dockerfile create mode 100755 tests/e2e/fedora/test.sh create mode 100644 tests/e2e/opensuse/Dockerfile create mode 100755 tests/e2e/opensuse/test.sh create mode 100755 tests/e2e/run.sh create mode 100644 tests/e2e/ubuntu/Dockerfile create mode 100755 tests/e2e/ubuntu/test.sh diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..73687a5 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,29 @@ +# E2E Tests (Docker) + +These tests run `why` inside distro containers to exercise real package-manager lookups. + +## Run + +```bash +./tests/e2e/run.sh +``` + +Run a subset: + +```bash +./tests/e2e/run.sh ubuntu alpine +``` + +## Coverage + +- ubuntu: apt/dpkg + path-based providers (asdf/SDKMAN!/nvm/fnm/pyenv/rbenv/rvm/rustup/conda/mise/volta/macports/nix) +- fedora: yum/rpm +- opensuse: zypper/rpm +- alpine: apk +- arch: pacman + +## Notes + +- Alpine uses a glibc compatibility layer (`gcompat`/`libc6-compat`) to run the binary built in the builder stage. +- `pkg` (FreeBSD) and Portage (Gentoo) are not covered here due to container limitations; they remain covered by unit tests. +- Windows-only providers (Scoop/Chocolatey/winget) are covered by unit tests. diff --git a/tests/e2e/alpine/Dockerfile b/tests/e2e/alpine/Dockerfile new file mode 100644 index 0000000..2d0dbaf --- /dev/null +++ b/tests/e2e/alpine/Dockerfile @@ -0,0 +1,13 @@ +FROM nimlang/nim:2.2.6 AS builder +WORKDIR /work +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY . . +RUN nimble build -d:release + +FROM alpine:3.20 +RUN apk add --no-cache bash ca-certificates gcompat libc6-compat libstdc++ +COPY --from=builder /work/why /usr/local/bin/why +COPY tests/e2e/alpine/test.sh /test.sh +ENTRYPOINT ["/bin/bash", "/test.sh"] diff --git a/tests/e2e/alpine/test.sh b/tests/e2e/alpine/test.sh new file mode 100755 index 0000000..14ce5ad --- /dev/null +++ b/tests/e2e/alpine/test.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +WHY=/usr/local/bin/why + +assert_contains() { + local haystack="$1" + local needle="$2" + if ! grep -qF "$needle" <<<"$haystack"; then + echo "Expected output to contain: $needle" >&2 + echo "Actual output:" >&2 + echo "$haystack" >&2 + exit 1 + fi +} + +out=$($WHY ls) +assert_contains "$out" "Provider: apk (" + +echo "alpine e2e OK" diff --git a/tests/e2e/arch/Dockerfile b/tests/e2e/arch/Dockerfile new file mode 100644 index 0000000..646542f --- /dev/null +++ b/tests/e2e/arch/Dockerfile @@ -0,0 +1,14 @@ +FROM nimlang/nim:2.2.6 AS builder +WORKDIR /work +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY . . +RUN nimble build -d:release + +FROM archlinux:base +RUN pacman -Syu --noconfirm bash ca-certificates coreutils \ + && pacman -Scc --noconfirm +COPY --from=builder /work/why /usr/local/bin/why +COPY tests/e2e/arch/test.sh /test.sh +ENTRYPOINT ["/usr/bin/bash", "/test.sh"] diff --git a/tests/e2e/arch/test.sh b/tests/e2e/arch/test.sh new file mode 100755 index 0000000..f530c59 --- /dev/null +++ b/tests/e2e/arch/test.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +WHY=/usr/local/bin/why + +assert_contains() { + local haystack="$1" + local needle="$2" + if ! grep -qF "$needle" <<<"$haystack"; then + echo "Expected output to contain: $needle" >&2 + echo "Actual output:" >&2 + echo "$haystack" >&2 + exit 1 + fi +} + +out=$($WHY ls) +assert_contains "$out" "Provider: pacman (" + +echo "arch e2e OK" diff --git a/tests/e2e/fedora/Dockerfile b/tests/e2e/fedora/Dockerfile new file mode 100644 index 0000000..f8c404a --- /dev/null +++ b/tests/e2e/fedora/Dockerfile @@ -0,0 +1,14 @@ +FROM nimlang/nim:2.2.6 AS builder +WORKDIR /work +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY . . +RUN nimble build -d:release + +FROM fedora:40 +RUN dnf -y install bash ca-certificates coreutils \ + && dnf clean all +COPY --from=builder /work/why /usr/local/bin/why +COPY tests/e2e/fedora/test.sh /test.sh +ENTRYPOINT ["/usr/bin/bash", "/test.sh"] diff --git a/tests/e2e/fedora/test.sh b/tests/e2e/fedora/test.sh new file mode 100755 index 0000000..7ed3ef1 --- /dev/null +++ b/tests/e2e/fedora/test.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +WHY=/usr/local/bin/why + +assert_contains() { + local haystack="$1" + local needle="$2" + if ! grep -qF "$needle" <<<"$haystack"; then + echo "Expected output to contain: $needle" >&2 + echo "Actual output:" >&2 + echo "$haystack" >&2 + exit 1 + fi +} + +out=$($WHY ls) +assert_contains "$out" "Provider: yum/rpm (" + +echo "fedora e2e OK" diff --git a/tests/e2e/opensuse/Dockerfile b/tests/e2e/opensuse/Dockerfile new file mode 100644 index 0000000..ee54291 --- /dev/null +++ b/tests/e2e/opensuse/Dockerfile @@ -0,0 +1,14 @@ +FROM nimlang/nim:2.2.6 AS builder +WORKDIR /work +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY . . +RUN nimble build -d:release + +FROM opensuse/leap:15.6 +RUN zypper -n install bash ca-certificates coreutils \ + && zypper clean -a +COPY --from=builder /work/why /usr/local/bin/why +COPY tests/e2e/opensuse/test.sh /test.sh +ENTRYPOINT ["/bin/bash", "/test.sh"] diff --git a/tests/e2e/opensuse/test.sh b/tests/e2e/opensuse/test.sh new file mode 100755 index 0000000..e37a929 --- /dev/null +++ b/tests/e2e/opensuse/test.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +WHY=/usr/local/bin/why + +assert_contains() { + local haystack="$1" + local needle="$2" + if ! grep -qF "$needle" <<<"$haystack"; then + echo "Expected output to contain: $needle" >&2 + echo "Actual output:" >&2 + echo "$haystack" >&2 + exit 1 + fi +} + +out=$($WHY ls) +assert_contains "$out" "Provider: zypper/rpm (" + +echo "opensuse e2e OK" diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh new file mode 100755 index 0000000..31a909f --- /dev/null +++ b/tests/e2e/run.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +DISTROS=(ubuntu fedora opensuse alpine arch) + +if [[ $# -gt 0 ]]; then + DISTROS=($@) +fi + +for distro in "${DISTROS[@]}"; do + image="why-e2e-${distro}" + dockerfile="$ROOT_DIR/tests/e2e/${distro}/Dockerfile" + if [[ ! -f "$dockerfile" ]]; then + echo "Missing Dockerfile for ${distro}: ${dockerfile}" >&2 + exit 1 + fi + + echo "==> Building ${image}" + docker build -f "$dockerfile" -t "$image" "$ROOT_DIR" + echo "==> Running ${image}" + docker run --rm "$image" + echo "==> ${distro} OK" + echo + + +done diff --git a/tests/e2e/ubuntu/Dockerfile b/tests/e2e/ubuntu/Dockerfile new file mode 100644 index 0000000..eb38855 --- /dev/null +++ b/tests/e2e/ubuntu/Dockerfile @@ -0,0 +1,15 @@ +FROM nimlang/nim:2.2.6 AS builder +WORKDIR /work +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY . . +RUN nimble build -d:release + +FROM ubuntu:22.04 +RUN apt-get update \ + && apt-get install -y --no-install-recommends bash ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=builder /work/why /usr/local/bin/why +COPY tests/e2e/ubuntu/test.sh /test.sh +ENTRYPOINT ["/bin/bash", "/test.sh"] diff --git a/tests/e2e/ubuntu/test.sh b/tests/e2e/ubuntu/test.sh new file mode 100755 index 0000000..d2aab11 --- /dev/null +++ b/tests/e2e/ubuntu/test.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +WHY=/usr/local/bin/why + +assert_contains() { + local haystack="$1" + local needle="$2" + if ! grep -qF "$needle" <<<"$haystack"; then + echo "Expected output to contain: $needle" >&2 + echo "Actual output:" >&2 + echo "$haystack" >&2 + exit 1 + fi +} + +make_bin() { + local path="$1" + mkdir -p "$(dirname "$path")" + cat <<'SCRIPT' > "$path" +#!/usr/bin/env sh +echo ok +SCRIPT + chmod +x "$path" +} + +run_and_assert_provider() { + local expected="$1" + local cmd="$2" + local output + output=$("$WHY" "$cmd") + assert_contains "$output" "Provider: $expected" +} + +# dpkg +out=$($WHY ls) +assert_contains "$out" "Provider: apt/dpkg (" + +# MacPorts +make_bin /opt/local/bin/port +PATH="/opt/local/bin:$PATH" run_and_assert_provider "MacPorts" "port" + +# Nix +make_bin /nix/store/test/bin/nix +PATH="/nix/store/test/bin:$PATH" run_and_assert_provider "Nix" "nix" + +# asdf +make_bin /root/.asdf/shims/node +PATH="/root/.asdf/shims:$PATH" run_and_assert_provider "asdf" "node" + +# SDKMAN! +make_bin /root/.sdkman/candidates/java/current/bin/java +PATH="/root/.sdkman/candidates/java/current/bin:$PATH" run_and_assert_provider "SDKMAN!" "java" + +# nvm +make_bin /root/.nvm/versions/node/v20.0.0/bin/node +PATH="/root/.nvm/versions/node/v20.0.0/bin:$PATH" run_and_assert_provider "nvm" "node" + +# fnm +make_bin /root/.local/share/fnm/node-versions/v20.0.0/installation/bin/node +PATH="/root/.local/share/fnm/node-versions/v20.0.0/installation/bin:$PATH" run_and_assert_provider "fnm" "node" + +# pyenv +make_bin /root/.pyenv/shims/python +PATH="/root/.pyenv/shims:$PATH" run_and_assert_provider "pyenv" "python" + +# rbenv +make_bin /root/.rbenv/shims/ruby +PATH="/root/.rbenv/shims:$PATH" run_and_assert_provider "rbenv" "ruby" + +# rvm +make_bin /root/.rvm/rubies/ruby-3.2.2/bin/ruby +PATH="/root/.rvm/rubies/ruby-3.2.2/bin:$PATH" run_and_assert_provider "rvm" "ruby" + +# rustup (symlink from cargo bin to rustup toolchain) +make_bin /root/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/rustc +mkdir -p /root/.cargo/bin +ln -sf /root/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/rustc /root/.cargo/bin/rustc +PATH="/root/.cargo/bin:$PATH" run_and_assert_provider "Rustup" "rustc" + +# conda +make_bin /root/miniconda3/bin/python +PATH="/root/miniconda3/bin:$PATH" run_and_assert_provider "Conda" "python" + +# mise +make_bin /root/.local/share/mise/shims/node +PATH="/root/.local/share/mise/shims:$PATH" run_and_assert_provider "Mise" "node" + +# volta +make_bin /root/.volta/bin/node +PATH="/root/.volta/bin:$PATH" run_and_assert_provider "Volta" "node" + +echo "ubuntu e2e OK" From a55a2e2034f4255d9c66b654ecaa8402fc27b862 Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sun, 21 Dec 2025 01:37:57 +0900 Subject: [PATCH 03/17] Fix e2e base images and dpkg check --- tests/e2e/alpine/Dockerfile | 2 +- tests/e2e/arch/Dockerfile | 2 +- tests/e2e/fedora/Dockerfile | 2 +- tests/e2e/opensuse/Dockerfile | 2 +- tests/e2e/ubuntu/Dockerfile | 2 +- tests/e2e/ubuntu/test.sh | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/e2e/alpine/Dockerfile b/tests/e2e/alpine/Dockerfile index 2d0dbaf..e28cb7c 100644 --- a/tests/e2e/alpine/Dockerfile +++ b/tests/e2e/alpine/Dockerfile @@ -1,4 +1,4 @@ -FROM nimlang/nim:2.2.6 AS builder +FROM nimlang/nim:latest AS builder WORKDIR /work RUN apt-get update \ && apt-get install -y --no-install-recommends git ca-certificates \ diff --git a/tests/e2e/arch/Dockerfile b/tests/e2e/arch/Dockerfile index 646542f..a760f66 100644 --- a/tests/e2e/arch/Dockerfile +++ b/tests/e2e/arch/Dockerfile @@ -1,4 +1,4 @@ -FROM nimlang/nim:2.2.6 AS builder +FROM nimlang/nim:latest AS builder WORKDIR /work RUN apt-get update \ && apt-get install -y --no-install-recommends git ca-certificates \ diff --git a/tests/e2e/fedora/Dockerfile b/tests/e2e/fedora/Dockerfile index f8c404a..22607db 100644 --- a/tests/e2e/fedora/Dockerfile +++ b/tests/e2e/fedora/Dockerfile @@ -1,4 +1,4 @@ -FROM nimlang/nim:2.2.6 AS builder +FROM nimlang/nim:latest AS builder WORKDIR /work RUN apt-get update \ && apt-get install -y --no-install-recommends git ca-certificates \ diff --git a/tests/e2e/opensuse/Dockerfile b/tests/e2e/opensuse/Dockerfile index ee54291..4fd83d0 100644 --- a/tests/e2e/opensuse/Dockerfile +++ b/tests/e2e/opensuse/Dockerfile @@ -1,4 +1,4 @@ -FROM nimlang/nim:2.2.6 AS builder +FROM nimlang/nim:latest AS builder WORKDIR /work RUN apt-get update \ && apt-get install -y --no-install-recommends git ca-certificates \ diff --git a/tests/e2e/ubuntu/Dockerfile b/tests/e2e/ubuntu/Dockerfile index eb38855..d605f01 100644 --- a/tests/e2e/ubuntu/Dockerfile +++ b/tests/e2e/ubuntu/Dockerfile @@ -1,4 +1,4 @@ -FROM nimlang/nim:2.2.6 AS builder +FROM nimlang/nim:latest AS builder WORKDIR /work RUN apt-get update \ && apt-get install -y --no-install-recommends git ca-certificates \ diff --git a/tests/e2e/ubuntu/test.sh b/tests/e2e/ubuntu/test.sh index d2aab11..897f95d 100755 --- a/tests/e2e/ubuntu/test.sh +++ b/tests/e2e/ubuntu/test.sh @@ -33,7 +33,7 @@ run_and_assert_provider() { } # dpkg -out=$($WHY ls) +PATH="/bin:$PATH" out=$($WHY bash) assert_contains "$out" "Provider: apt/dpkg (" # MacPorts From e9f799588afcedfc9dceecbe9937afaa7e945261 Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sun, 21 Dec 2025 01:44:12 +0900 Subject: [PATCH 04/17] Use Nim 2.2.4 images and drop pkg support --- README.md | 4 ++-- src/why_core.nim | 7 ------- tests/e2e/README.md | 2 +- tests/e2e/alpine/Dockerfile | 2 +- tests/e2e/arch/Dockerfile | 2 +- tests/e2e/fedora/Dockerfile | 2 +- tests/e2e/opensuse/Dockerfile | 2 +- tests/e2e/ubuntu/Dockerfile | 2 +- tests/test_why_core.nim | 24 ------------------------ 9 files changed, 8 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index c36f7d1..04690d9 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ It resolves symlinks, checks path patterns, and queries system package managers - Provider Identification: Instantly detects if a binary is from Homebrew, apt/dpkg, yum/rpm, zypper/rpm, apk, pacman, Nix, MacPorts, npm, pip, Cargo, Go, etc. - Symlink Resolution: Traces the "Origin Path" (where your shell finds it) to the "Real Path" (where the binary actually lives). -- System Package Manager Integration: Automatically queries `dpkg`, `rpm`/`zypper`, `apk`, `pacman`, `pkg`, or Portage tools for system files to identify the package name. +- System Package Manager Integration: Automatically queries `dpkg`, `rpm`/`zypper`, `apk`, `pacman`, or Portage tools for system files to identify the package name. ## Installation @@ -77,7 +77,7 @@ Real Path: /var/lib/flatpak/app/com.valvesoftware.Steam/current/active/export/ `why` currently supports detection for: -- Package Managers: Homebrew, apt (Debian/Ubuntu), yum/rpm (RHEL/CentOS), zypper/rpm (SUSE), apk (Alpine), pacman (Arch), Portage (Gentoo), pkg (FreeBSD), MacPorts, Nix, Snap, Flatpak, Scoop, Chocolatey, winget +- Package Managers: Homebrew, apt (Debian/Ubuntu), yum/rpm (RHEL/CentOS), zypper/rpm (SUSE), apk (Alpine), pacman (Arch), Portage (Gentoo), MacPorts, Nix, Snap, Flatpak, Scoop, Chocolatey, winget - Version Managers: Mise, Volta, asdf, SDKMAN!, nvm, fnm, pyenv, rbenv, rvm, Rustup, Conda - Language Managers: npm (Global), pip/pipx (Python), Cargo (Rust), Go - System: Standard system paths (`/usr/bin`, etc.) diff --git a/src/why_core.nim b/src/why_core.nim index 25058cf..63fa498 100644 --- a/src/why_core.nim +++ b/src/why_core.nim @@ -214,13 +214,6 @@ proc checkSystemPackageManager*(path: string, ctx: WhyCtx): string = if trimmed.len > 0: return "pacman (" & trimmed & ")" - if ctx.findExe("pkg").len > 0: - let (outp, exitCode) = ctx.execCmd("pkg which -q " & quoteShell(path)) - if exitCode == 0: - let lines = outp.splitLines() - if lines.len > 0 and lines[0].strip().len > 0: - return "pkg (" & lines[0].strip() & ")" - if ctx.findExe("qfile").len > 0: let (outp, exitCode) = ctx.execCmd("qfile -qv " & quoteShell(path)) if exitCode == 0: diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 73687a5..42df853 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -25,5 +25,5 @@ Run a subset: ## Notes - Alpine uses a glibc compatibility layer (`gcompat`/`libc6-compat`) to run the binary built in the builder stage. -- `pkg` (FreeBSD) and Portage (Gentoo) are not covered here due to container limitations; they remain covered by unit tests. +- Portage (Gentoo) is not covered here due to container limitations; it remains covered by unit tests. - Windows-only providers (Scoop/Chocolatey/winget) are covered by unit tests. diff --git a/tests/e2e/alpine/Dockerfile b/tests/e2e/alpine/Dockerfile index e28cb7c..83e51e4 100644 --- a/tests/e2e/alpine/Dockerfile +++ b/tests/e2e/alpine/Dockerfile @@ -1,4 +1,4 @@ -FROM nimlang/nim:latest AS builder +FROM nimlang/nim:2.2.4-ubuntu-regular AS builder WORKDIR /work RUN apt-get update \ && apt-get install -y --no-install-recommends git ca-certificates \ diff --git a/tests/e2e/arch/Dockerfile b/tests/e2e/arch/Dockerfile index a760f66..bbe479c 100644 --- a/tests/e2e/arch/Dockerfile +++ b/tests/e2e/arch/Dockerfile @@ -1,4 +1,4 @@ -FROM nimlang/nim:latest AS builder +FROM nimlang/nim:2.2.4-ubuntu-regular AS builder WORKDIR /work RUN apt-get update \ && apt-get install -y --no-install-recommends git ca-certificates \ diff --git a/tests/e2e/fedora/Dockerfile b/tests/e2e/fedora/Dockerfile index 22607db..5085d9f 100644 --- a/tests/e2e/fedora/Dockerfile +++ b/tests/e2e/fedora/Dockerfile @@ -1,4 +1,4 @@ -FROM nimlang/nim:latest AS builder +FROM nimlang/nim:2.2.4-ubuntu-regular AS builder WORKDIR /work RUN apt-get update \ && apt-get install -y --no-install-recommends git ca-certificates \ diff --git a/tests/e2e/opensuse/Dockerfile b/tests/e2e/opensuse/Dockerfile index 4fd83d0..6ea4dbe 100644 --- a/tests/e2e/opensuse/Dockerfile +++ b/tests/e2e/opensuse/Dockerfile @@ -1,4 +1,4 @@ -FROM nimlang/nim:latest AS builder +FROM nimlang/nim:2.2.4-ubuntu-regular AS builder WORKDIR /work RUN apt-get update \ && apt-get install -y --no-install-recommends git ca-certificates \ diff --git a/tests/e2e/ubuntu/Dockerfile b/tests/e2e/ubuntu/Dockerfile index d605f01..bdfa0ff 100644 --- a/tests/e2e/ubuntu/Dockerfile +++ b/tests/e2e/ubuntu/Dockerfile @@ -1,4 +1,4 @@ -FROM nimlang/nim:latest AS builder +FROM nimlang/nim:2.2.4-ubuntu-regular AS builder WORKDIR /work RUN apt-get update \ && apt-get install -y --no-install-recommends git ca-certificates \ diff --git a/tests/test_why_core.nim b/tests/test_why_core.nim index 7d0ac40..485a45d 100644 --- a/tests/test_why_core.nim +++ b/tests/test_why_core.nim @@ -192,30 +192,6 @@ suite "whyCore": check err.msg.len == 0 check res.provider == "pacman (coreutils 9.2-1)" - test "system package manager detection via pkg": - var files = {"/usr/bin/ls": true}.toTable - - let ctx = WhyCtx( - getEnv: proc(key: string): string = - if key == "PATH": "/usr/bin:/bin" else: "", - getCurrentDir: proc(): string = "/work", - getHomeDir: proc(): string = "/home/test", - fileExists: proc(p: string): bool = files.getOrDefault(p, false), - symlinkExists: proc(p: string): bool = false, - expandSymlink: proc(p: string): string = "", - dirExists: proc(p: string): bool = false, - listDir: proc(dir: string): seq[(DirEntryKind, string)] = @[], - findExe: proc(name: string): string = - if name == "pkg": "/usr/sbin/pkg" else: "", - execCmd: proc(cmd: string): ExecResult = ("coreutils\n", 0), - paramStr0: proc(): string = "/usr/bin/why" - ) - - let (ok, res, err) = whyCore("ls", ctx) - check ok - check err.msg.len == 0 - check res.provider == "pkg (coreutils)" - test "system package manager detection via portage qfile": var files = {"/usr/bin/ls": true}.toTable From a5e09f0417a34846d2674e8fd5ef39aaaadc15d1 Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sun, 21 Dec 2025 01:53:13 +0900 Subject: [PATCH 05/17] Deduplicate e2e assert helper --- tests/e2e/alpine/Dockerfile | 1 + tests/e2e/alpine/test.sh | 12 +----------- tests/e2e/arch/Dockerfile | 1 + tests/e2e/arch/test.sh | 12 +----------- tests/e2e/common.sh | 13 +++++++++++++ tests/e2e/fedora/Dockerfile | 1 + tests/e2e/fedora/test.sh | 12 +----------- tests/e2e/opensuse/Dockerfile | 1 + tests/e2e/opensuse/test.sh | 12 +----------- tests/e2e/ubuntu/Dockerfile | 1 + tests/e2e/ubuntu/test.sh | 12 +----------- 11 files changed, 23 insertions(+), 55 deletions(-) create mode 100755 tests/e2e/common.sh diff --git a/tests/e2e/alpine/Dockerfile b/tests/e2e/alpine/Dockerfile index 83e51e4..baa9430 100644 --- a/tests/e2e/alpine/Dockerfile +++ b/tests/e2e/alpine/Dockerfile @@ -9,5 +9,6 @@ RUN nimble build -d:release FROM alpine:3.20 RUN apk add --no-cache bash ca-certificates gcompat libc6-compat libstdc++ COPY --from=builder /work/why /usr/local/bin/why +COPY tests/e2e/common.sh /test-common.sh COPY tests/e2e/alpine/test.sh /test.sh ENTRYPOINT ["/bin/bash", "/test.sh"] diff --git a/tests/e2e/alpine/test.sh b/tests/e2e/alpine/test.sh index 14ce5ad..cdf621f 100755 --- a/tests/e2e/alpine/test.sh +++ b/tests/e2e/alpine/test.sh @@ -2,17 +2,7 @@ set -euo pipefail WHY=/usr/local/bin/why - -assert_contains() { - local haystack="$1" - local needle="$2" - if ! grep -qF "$needle" <<<"$haystack"; then - echo "Expected output to contain: $needle" >&2 - echo "Actual output:" >&2 - echo "$haystack" >&2 - exit 1 - fi -} +source /test-common.sh out=$($WHY ls) assert_contains "$out" "Provider: apk (" diff --git a/tests/e2e/arch/Dockerfile b/tests/e2e/arch/Dockerfile index bbe479c..a4917e2 100644 --- a/tests/e2e/arch/Dockerfile +++ b/tests/e2e/arch/Dockerfile @@ -10,5 +10,6 @@ FROM archlinux:base RUN pacman -Syu --noconfirm bash ca-certificates coreutils \ && pacman -Scc --noconfirm COPY --from=builder /work/why /usr/local/bin/why +COPY tests/e2e/common.sh /test-common.sh COPY tests/e2e/arch/test.sh /test.sh ENTRYPOINT ["/usr/bin/bash", "/test.sh"] diff --git a/tests/e2e/arch/test.sh b/tests/e2e/arch/test.sh index f530c59..7508258 100755 --- a/tests/e2e/arch/test.sh +++ b/tests/e2e/arch/test.sh @@ -2,17 +2,7 @@ set -euo pipefail WHY=/usr/local/bin/why - -assert_contains() { - local haystack="$1" - local needle="$2" - if ! grep -qF "$needle" <<<"$haystack"; then - echo "Expected output to contain: $needle" >&2 - echo "Actual output:" >&2 - echo "$haystack" >&2 - exit 1 - fi -} +source /test-common.sh out=$($WHY ls) assert_contains "$out" "Provider: pacman (" diff --git a/tests/e2e/common.sh b/tests/e2e/common.sh new file mode 100755 index 0000000..11246f2 --- /dev/null +++ b/tests/e2e/common.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +assert_contains() { + local haystack="$1" + local needle="$2" + if ! grep -qF "$needle" <<<"$haystack"; then + echo "Expected output to contain: $needle" >&2 + echo "Actual output:" >&2 + echo "$haystack" >&2 + exit 1 + fi +} diff --git a/tests/e2e/fedora/Dockerfile b/tests/e2e/fedora/Dockerfile index 5085d9f..816468e 100644 --- a/tests/e2e/fedora/Dockerfile +++ b/tests/e2e/fedora/Dockerfile @@ -10,5 +10,6 @@ FROM fedora:40 RUN dnf -y install bash ca-certificates coreutils \ && dnf clean all COPY --from=builder /work/why /usr/local/bin/why +COPY tests/e2e/common.sh /test-common.sh COPY tests/e2e/fedora/test.sh /test.sh ENTRYPOINT ["/usr/bin/bash", "/test.sh"] diff --git a/tests/e2e/fedora/test.sh b/tests/e2e/fedora/test.sh index 7ed3ef1..3129a89 100755 --- a/tests/e2e/fedora/test.sh +++ b/tests/e2e/fedora/test.sh @@ -2,17 +2,7 @@ set -euo pipefail WHY=/usr/local/bin/why - -assert_contains() { - local haystack="$1" - local needle="$2" - if ! grep -qF "$needle" <<<"$haystack"; then - echo "Expected output to contain: $needle" >&2 - echo "Actual output:" >&2 - echo "$haystack" >&2 - exit 1 - fi -} +source /test-common.sh out=$($WHY ls) assert_contains "$out" "Provider: yum/rpm (" diff --git a/tests/e2e/opensuse/Dockerfile b/tests/e2e/opensuse/Dockerfile index 6ea4dbe..e9b0a37 100644 --- a/tests/e2e/opensuse/Dockerfile +++ b/tests/e2e/opensuse/Dockerfile @@ -10,5 +10,6 @@ FROM opensuse/leap:15.6 RUN zypper -n install bash ca-certificates coreutils \ && zypper clean -a COPY --from=builder /work/why /usr/local/bin/why +COPY tests/e2e/common.sh /test-common.sh COPY tests/e2e/opensuse/test.sh /test.sh ENTRYPOINT ["/bin/bash", "/test.sh"] diff --git a/tests/e2e/opensuse/test.sh b/tests/e2e/opensuse/test.sh index e37a929..c8356c7 100755 --- a/tests/e2e/opensuse/test.sh +++ b/tests/e2e/opensuse/test.sh @@ -2,17 +2,7 @@ set -euo pipefail WHY=/usr/local/bin/why - -assert_contains() { - local haystack="$1" - local needle="$2" - if ! grep -qF "$needle" <<<"$haystack"; then - echo "Expected output to contain: $needle" >&2 - echo "Actual output:" >&2 - echo "$haystack" >&2 - exit 1 - fi -} +source /test-common.sh out=$($WHY ls) assert_contains "$out" "Provider: zypper/rpm (" diff --git a/tests/e2e/ubuntu/Dockerfile b/tests/e2e/ubuntu/Dockerfile index bdfa0ff..1fd992c 100644 --- a/tests/e2e/ubuntu/Dockerfile +++ b/tests/e2e/ubuntu/Dockerfile @@ -11,5 +11,6 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends bash ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY --from=builder /work/why /usr/local/bin/why +COPY tests/e2e/common.sh /test-common.sh COPY tests/e2e/ubuntu/test.sh /test.sh ENTRYPOINT ["/bin/bash", "/test.sh"] diff --git a/tests/e2e/ubuntu/test.sh b/tests/e2e/ubuntu/test.sh index 897f95d..a68bf8c 100755 --- a/tests/e2e/ubuntu/test.sh +++ b/tests/e2e/ubuntu/test.sh @@ -2,17 +2,7 @@ set -euo pipefail WHY=/usr/local/bin/why - -assert_contains() { - local haystack="$1" - local needle="$2" - if ! grep -qF "$needle" <<<"$haystack"; then - echo "Expected output to contain: $needle" >&2 - echo "Actual output:" >&2 - echo "$haystack" >&2 - exit 1 - fi -} +source /test-common.sh make_bin() { local path="$1" From fab346b7052cf9f55107f618583150127957a428 Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sun, 21 Dec 2025 01:54:15 +0900 Subject: [PATCH 06/17] Standardize e2e bash path and args handling --- tests/e2e/arch/Dockerfile | 2 +- tests/e2e/fedora/Dockerfile | 2 +- tests/e2e/run.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/e2e/arch/Dockerfile b/tests/e2e/arch/Dockerfile index a4917e2..44b499f 100644 --- a/tests/e2e/arch/Dockerfile +++ b/tests/e2e/arch/Dockerfile @@ -12,4 +12,4 @@ RUN pacman -Syu --noconfirm bash ca-certificates coreutils \ COPY --from=builder /work/why /usr/local/bin/why COPY tests/e2e/common.sh /test-common.sh COPY tests/e2e/arch/test.sh /test.sh -ENTRYPOINT ["/usr/bin/bash", "/test.sh"] +ENTRYPOINT ["/bin/bash", "/test.sh"] diff --git a/tests/e2e/fedora/Dockerfile b/tests/e2e/fedora/Dockerfile index 816468e..d34df7d 100644 --- a/tests/e2e/fedora/Dockerfile +++ b/tests/e2e/fedora/Dockerfile @@ -12,4 +12,4 @@ RUN dnf -y install bash ca-certificates coreutils \ COPY --from=builder /work/why /usr/local/bin/why COPY tests/e2e/common.sh /test-common.sh COPY tests/e2e/fedora/test.sh /test.sh -ENTRYPOINT ["/usr/bin/bash", "/test.sh"] +ENTRYPOINT ["/bin/bash", "/test.sh"] diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index 31a909f..bd70d5a 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -5,7 +5,7 @@ ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) DISTROS=(ubuntu fedora opensuse alpine arch) if [[ $# -gt 0 ]]; then - DISTROS=($@) + DISTROS=("$@") fi for distro in "${DISTROS[@]}"; do From 72e2b2fb96a4721b4d33de9540a13e7e80b26757 Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sun, 21 Dec 2025 01:55:06 +0900 Subject: [PATCH 07/17] Avoid duplicate rpm queries --- src/why_core.nim | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/why_core.nim b/src/why_core.nim index 63fa498..8d53a74 100644 --- a/src/why_core.nim +++ b/src/why_core.nim @@ -185,14 +185,13 @@ proc checkSystemPackageManager*(path: string, ctx: WhyCtx): string = if parts.len > 0: return "apt/dpkg (" & parts[0].strip() & ")" - if ctx.findExe("zypper").len > 0 and ctx.findExe("rpm").len > 0: - let (outp, exitCode) = ctx.execCmd("rpm -qf " & quoteShell(path)) - if exitCode == 0: - return "zypper/rpm (" & outp.strip() & ")" - - if ctx.findExe("rpm").len > 0: + let hasRpm = ctx.findExe("rpm").len > 0 + let hasZypper = ctx.findExe("zypper").len > 0 + if hasRpm: let (outp, exitCode) = ctx.execCmd("rpm -qf " & quoteShell(path)) if exitCode == 0: + if hasZypper: + return "zypper/rpm (" & outp.strip() & ")" return "yum/rpm (" & outp.strip() & ")" if ctx.findExe("apk").len > 0: From f9c6963483cd2fd28be97bbab712f74c69d66021 Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sun, 21 Dec 2025 01:59:36 +0900 Subject: [PATCH 08/17] Add Gentoo e2e coverage --- tests/e2e/README.md | 3 ++- tests/e2e/gentoo/Dockerfile | 16 ++++++++++++++++ tests/e2e/gentoo/test.sh | 10 ++++++++++ tests/e2e/run.sh | 2 +- 4 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/gentoo/Dockerfile create mode 100755 tests/e2e/gentoo/test.sh diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 42df853..188aff8 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -21,9 +21,10 @@ Run a subset: - opensuse: zypper/rpm - alpine: apk - arch: pacman +- gentoo: portage (qfile) ## Notes - Alpine uses a glibc compatibility layer (`gcompat`/`libc6-compat`) to run the binary built in the builder stage. -- Portage (Gentoo) is not covered here due to container limitations; it remains covered by unit tests. +- Gentoo coverage is provided via a stage3 image with portage-utils (qfile). - Windows-only providers (Scoop/Chocolatey/winget) are covered by unit tests. diff --git a/tests/e2e/gentoo/Dockerfile b/tests/e2e/gentoo/Dockerfile new file mode 100644 index 0000000..9e3b1ef --- /dev/null +++ b/tests/e2e/gentoo/Dockerfile @@ -0,0 +1,16 @@ +FROM nimlang/nim:2.2.4-ubuntu-regular AS builder +WORKDIR /work +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY . . +RUN nimble build -d:release + +FROM gentoo/stage3:latest +ENV FEATURES="-sandbox -usersandbox" +RUN emerge --sync \ + && emerge --quiet app-portage/portage-utils +COPY --from=builder /work/why /usr/local/bin/why +COPY tests/e2e/common.sh /test-common.sh +COPY tests/e2e/gentoo/test.sh /test.sh +ENTRYPOINT ["/bin/bash", "/test.sh"] diff --git a/tests/e2e/gentoo/test.sh b/tests/e2e/gentoo/test.sh new file mode 100755 index 0000000..39bdbdd --- /dev/null +++ b/tests/e2e/gentoo/test.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +WHY=/usr/local/bin/why +source /test-common.sh + +PATH="/bin:$PATH" out=$($WHY bash) +assert_contains "$out" "Provider: portage (" + +echo "gentoo e2e OK" diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index bd70d5a..71647ca 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -2,7 +2,7 @@ set -euo pipefail ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) -DISTROS=(ubuntu fedora opensuse alpine arch) +DISTROS=(ubuntu fedora opensuse alpine arch gentoo) if [[ $# -gt 0 ]]; then DISTROS=("$@") From 6da7c53b697e667c16e5af050a40e574aaa6f7f0 Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sun, 21 Dec 2025 02:04:07 +0900 Subject: [PATCH 09/17] Run e2e in parallel when possible --- tests/e2e/run.sh | 38 ++++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index 71647ca..ea1f2d0 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -8,12 +8,15 @@ if [[ $# -gt 0 ]]; then DISTROS=("$@") fi -for distro in "${DISTROS[@]}"; do - image="why-e2e-${distro}" - dockerfile="$ROOT_DIR/tests/e2e/${distro}/Dockerfile" +jobs=${E2E_JOBS:-$(nproc 2>/dev/null || echo 4)} + +run_one() { + local distro="$1" + local image="why-e2e-${distro}" + local dockerfile="$ROOT_DIR/tests/e2e/${distro}/Dockerfile" if [[ ! -f "$dockerfile" ]]; then echo "Missing Dockerfile for ${distro}: ${dockerfile}" >&2 - exit 1 + return 1 fi echo "==> Building ${image}" @@ -22,6 +25,29 @@ for distro in "${DISTROS[@]}"; do docker run --rm "$image" echo "==> ${distro} OK" echo - +} + +if command -v parallel >/dev/null 2>&1; then + export ROOT_DIR + export -f run_one + parallel --halt now,fail=1 --jobs "$jobs" run_one ::: "${DISTROS[@]}" +else + printf '%s\n' "${DISTROS[@]}" | xargs -I{} -P "$jobs" bash -c ' + set -euo pipefail + ROOT_DIR="$1" + distro="$2" + image="why-e2e-${distro}" + dockerfile="$ROOT_DIR/tests/e2e/${distro}/Dockerfile" + if [[ ! -f "$dockerfile" ]]; then + echo "Missing Dockerfile for ${distro}: ${dockerfile}" >&2 + exit 1 + fi -done + echo "==> Building ${image}" + docker build -f "$dockerfile" -t "$image" "$ROOT_DIR" + echo "==> Running ${image}" + docker run --rm "$image" + echo "==> ${distro} OK" + echo + ' _ "$ROOT_DIR" {} +fi From 74819cb7c6562501d277517cab4e63a5239e4d27 Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sun, 21 Dec 2025 02:06:30 +0900 Subject: [PATCH 10/17] Stream e2e output in parallel runs --- tests/e2e/run.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index ea1f2d0..52f0665 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -20,7 +20,7 @@ run_one() { fi echo "==> Building ${image}" - docker build -f "$dockerfile" -t "$image" "$ROOT_DIR" + docker build --progress=plain -f "$dockerfile" -t "$image" "$ROOT_DIR" echo "==> Running ${image}" docker run --rm "$image" echo "==> ${distro} OK" @@ -30,7 +30,7 @@ run_one() { if command -v parallel >/dev/null 2>&1; then export ROOT_DIR export -f run_one - parallel --halt now,fail=1 --jobs "$jobs" run_one ::: "${DISTROS[@]}" + parallel --line-buffer --tag --halt now,fail=1 --jobs "$jobs" run_one ::: "${DISTROS[@]}" else printf '%s\n' "${DISTROS[@]}" | xargs -I{} -P "$jobs" bash -c ' set -euo pipefail @@ -44,7 +44,7 @@ else fi echo "==> Building ${image}" - docker build -f "$dockerfile" -t "$image" "$ROOT_DIR" + docker build --progress=plain -f "$dockerfile" -t "$image" "$ROOT_DIR" echo "==> Running ${image}" docker run --rm "$image" echo "==> ${distro} OK" From a6493d3545b4112c30c2ea79eed50e032e9e32f4 Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sun, 21 Dec 2025 02:12:18 +0900 Subject: [PATCH 11/17] Refactor system package manager checks --- src/why_core.nim | 147 +++++++++++++++++++++++++++++++---------------- 1 file changed, 96 insertions(+), 51 deletions(-) diff --git a/src/why_core.nim b/src/why_core.nim index 8d53a74..bd3f327 100644 --- a/src/why_core.nim +++ b/src/why_core.nim @@ -177,60 +177,105 @@ proc detectProviderByPath*(originPath, realPath: string, rules: seq[ProviderRule return "Unknown" -proc checkSystemPackageManager*(path: string, ctx: WhyCtx): string = - if ctx.findExe("dpkg").len > 0: - let (outp, exitCode) = ctx.execCmd("dpkg -S " & quoteShell(path)) - if exitCode == 0: - let parts = outp.split(":") - if parts.len > 0: - return "apt/dpkg (" & parts[0].strip() & ")" +type + PkgManagerRef = object + name: string + cmd: proc(path: string, ctx: WhyCtx): string {.nimcall.} + +proc checkPkgManagerDpkg(path: string, ctx: WhyCtx): string = + if ctx.findExe("dpkg").len == 0: + return "" + let (outp, exitCode) = ctx.execCmd("dpkg -S " & quoteShell(path)) + if exitCode != 0: + return "" + let parts = outp.split(":") + if parts.len == 0: + return "" + return "apt/dpkg (" & parts[0].strip() & ")" +proc checkPkgManagerRpm(path: string, ctx: WhyCtx): string = let hasRpm = ctx.findExe("rpm").len > 0 let hasZypper = ctx.findExe("zypper").len > 0 - if hasRpm: - let (outp, exitCode) = ctx.execCmd("rpm -qf " & quoteShell(path)) - if exitCode == 0: - if hasZypper: - return "zypper/rpm (" & outp.strip() & ")" - return "yum/rpm (" & outp.strip() & ")" - - if ctx.findExe("apk").len > 0: - let (outp, exitCode) = ctx.execCmd("apk info -W " & quoteShell(path)) - if exitCode == 0: - let lines = outp.splitLines() - if lines.len > 0 and lines[0].strip().len > 0: - return "apk (" & lines[0].strip() & ")" - - if ctx.findExe("pacman").len > 0: - let (outp, exitCode) = ctx.execCmd("pacman -Qo " & quoteShell(path)) - if exitCode == 0: - let trimmed = outp.strip() - let marker = " is owned by " - if trimmed.contains(marker): - let parts = trimmed.split(marker) - if parts.len > 1: - return "pacman (" & parts[1].strip() & ")" - if trimmed.len > 0: - return "pacman (" & trimmed & ")" - - if ctx.findExe("qfile").len > 0: - let (outp, exitCode) = ctx.execCmd("qfile -qv " & quoteShell(path)) - if exitCode == 0: - let lines = outp.splitLines() - if lines.len > 0: - let tokens = lines[0].splitWhitespace() - if tokens.len > 0: - return "portage (" & tokens[0].strip() & ")" - - if ctx.findExe("equery").len > 0: - let (outp, exitCode) = ctx.execCmd("equery b " & quoteShell(path)) - if exitCode == 0: - for line in outp.splitLines(): - let idx = line.find(" (") - if idx > 0: - let pkg = line[0.. 0 and not pkg.startsWith("*"): - return "portage (" & pkg & ")" + if not hasRpm: + return "" + let (outp, exitCode) = ctx.execCmd("rpm -qf " & quoteShell(path)) + if exitCode != 0: + return "" + if hasZypper: + return "zypper/rpm (" & outp.strip() & ")" + return "yum/rpm (" & outp.strip() & ")" + +proc checkPkgManagerApk(path: string, ctx: WhyCtx): string = + if ctx.findExe("apk").len == 0: + return "" + let (outp, exitCode) = ctx.execCmd("apk info -W " & quoteShell(path)) + if exitCode != 0: + return "" + let lines = outp.splitLines() + if lines.len == 0: + return "" + let pkg = lines[0].strip() + if pkg.len == 0: + return "" + return "apk (" & pkg & ")" + +proc checkPkgManagerPacman(path: string, ctx: WhyCtx): string = + if ctx.findExe("pacman").len == 0: + return "" + let (outp, exitCode) = ctx.execCmd("pacman -Qo " & quoteShell(path)) + if exitCode != 0: + return "" + let trimmed = outp.strip() + let marker = " is owned by " + if trimmed.contains(marker): + let parts = trimmed.split(marker) + if parts.len > 1: + return "pacman (" & parts[1].strip() & ")" + if trimmed.len > 0: + return "pacman (" & trimmed & ")" + return "" + +proc checkPkgManagerPortageQfile(path: string, ctx: WhyCtx): string = + if ctx.findExe("qfile").len == 0: + return "" + let (outp, exitCode) = ctx.execCmd("qfile -qv " & quoteShell(path)) + if exitCode != 0: + return "" + let lines = outp.splitLines() + if lines.len == 0: + return "" + let tokens = lines[0].splitWhitespace() + if tokens.len == 0: + return "" + return "portage (" & tokens[0].strip() & ")" + +proc checkPkgManagerPortageEquery(path: string, ctx: WhyCtx): string = + if ctx.findExe("equery").len == 0: + return "" + let (outp, exitCode) = ctx.execCmd("equery b " & quoteShell(path)) + if exitCode != 0: + return "" + for line in outp.splitLines(): + let idx = line.find(" (") + if idx > 0: + let pkg = line[0.. 0 and not pkg.startsWith("*"): + return "portage (" & pkg & ")" + return "" + +proc checkSystemPackageManager*(path: string, ctx: WhyCtx): string = + let checks = @[ + PkgManagerRef(name: "dpkg", cmd: checkPkgManagerDpkg), + PkgManagerRef(name: "rpm", cmd: checkPkgManagerRpm), + PkgManagerRef(name: "apk", cmd: checkPkgManagerApk), + PkgManagerRef(name: "pacman", cmd: checkPkgManagerPacman), + PkgManagerRef(name: "qfile", cmd: checkPkgManagerPortageQfile), + PkgManagerRef(name: "equery", cmd: checkPkgManagerPortageEquery), + ] + for check in checks: + let detected = check.cmd(path, ctx) + if detected.len > 0: + return detected return "" From bc4cfa51dba682c831cf8806181794c4e2e5e88c Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sun, 21 Dec 2025 02:18:15 +0900 Subject: [PATCH 12/17] Normalize provider paths and speed up e2e builds --- .gitignore | 3 ++- src/why_core.nim | 15 +++++++++------ tests/e2e/Builder.Dockerfile | 8 ++++++++ tests/e2e/README.md | 3 ++- tests/e2e/alpine/Dockerfile | 10 +--------- tests/e2e/arch/Dockerfile | 10 +--------- tests/e2e/fedora/Dockerfile | 10 +--------- tests/e2e/gentoo/Dockerfile | 10 +--------- tests/e2e/opensuse/Dockerfile | 10 +--------- tests/e2e/run.sh | 22 ++++++++++++++++++++++ tests/e2e/ubuntu/Dockerfile | 10 +--------- 11 files changed, 49 insertions(+), 62 deletions(-) create mode 100644 tests/e2e/Builder.Dockerfile diff --git a/.gitignore b/.gitignore index b5f491a..b27089b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ why why.exe tests/test_why_core tests/test_why_core.exe +tests/e2e/why-linux-amd64 # Nimble files nimble.develop @@ -11,4 +12,4 @@ nimble.paths nimbledeps # LLM utilities -repomix-output.xml \ No newline at end of file +repomix-output.xml diff --git a/src/why_core.nim b/src/why_core.nim index bd3f327..075e5c7 100644 --- a/src/why_core.nim +++ b/src/why_core.nim @@ -97,13 +97,13 @@ proc defaultRules*(homeDir: string): seq[ProviderRule] = ".conda", "/miniconda", "/anaconda", "/mambaforge", "/miniforge" ]), ProviderRule(name: "Scoop", kind: mkContains, patterns: @[ - "scoop/shims", "scoop/apps", "scoop\\shims", "scoop\\apps" + "scoop/shims", "scoop/apps" ]), ProviderRule(name: "Chocolatey", kind: mkContains, patterns: @[ - "chocolatey/bin", "chocolatey/lib", "chocolatey\\bin", "chocolatey\\lib" + "chocolatey/bin", "chocolatey/lib" ]), ProviderRule(name: "winget", kind: mkContains, patterns: @[ - "WindowsApps", "Microsoft\\WindowsApps" + "WindowsApps", "Microsoft/WindowsApps" ]), ProviderRule(name: "Cargo", kind: mkContains, patterns: @[ ".cargo/bin" @@ -159,7 +159,9 @@ proc resolveSymlinkChain*(path: string, ctx: WhyCtx): string = return current proc detectProviderByPath*(originPath, realPath: string, rules: seq[ProviderRule]): string = - let checkPaths = @[realPath, originPath] + let normalizedReal = realPath.replace('\\', '/') + let normalizedOrigin = originPath.replace('\\', '/') + let checkPaths = @[normalizedReal, normalizedOrigin] for rule in rules: for path in checkPaths: @@ -167,12 +169,13 @@ proc detectProviderByPath*(originPath, realPath: string, rules: seq[ProviderRule continue for pattern in rule.patterns: + let normalizedPattern = pattern.replace('\\', '/') case rule.kind of mkContains: - if pattern in path: + if normalizedPattern in path: return rule.name of mkStartsWith: - if path.startsWith(pattern): + if path.startsWith(normalizedPattern): return rule.name return "Unknown" diff --git a/tests/e2e/Builder.Dockerfile b/tests/e2e/Builder.Dockerfile new file mode 100644 index 0000000..2d7bcf3 --- /dev/null +++ b/tests/e2e/Builder.Dockerfile @@ -0,0 +1,8 @@ +FROM nimlang/nim:2.2.4-ubuntu-regular +WORKDIR /work +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY . . +RUN nimble build -d:release \ + && install -m 0755 /work/why /usr/local/bin/why diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 188aff8..1900b74 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -25,6 +25,7 @@ Run a subset: ## Notes -- Alpine uses a glibc compatibility layer (`gcompat`/`libc6-compat`) to run the binary built in the builder stage. +- The `why` binary is built once via `tests/e2e/Builder.Dockerfile` and copied into each distro image. +- Alpine uses a glibc compatibility layer (`gcompat`/`libc6-compat`) to run the Ubuntu-built binary. - Gentoo coverage is provided via a stage3 image with portage-utils (qfile). - Windows-only providers (Scoop/Chocolatey/winget) are covered by unit tests. diff --git a/tests/e2e/alpine/Dockerfile b/tests/e2e/alpine/Dockerfile index baa9430..13d359c 100644 --- a/tests/e2e/alpine/Dockerfile +++ b/tests/e2e/alpine/Dockerfile @@ -1,14 +1,6 @@ -FROM nimlang/nim:2.2.4-ubuntu-regular AS builder -WORKDIR /work -RUN apt-get update \ - && apt-get install -y --no-install-recommends git ca-certificates \ - && rm -rf /var/lib/apt/lists/* -COPY . . -RUN nimble build -d:release - FROM alpine:3.20 RUN apk add --no-cache bash ca-certificates gcompat libc6-compat libstdc++ -COPY --from=builder /work/why /usr/local/bin/why +COPY tests/e2e/why-linux-amd64 /usr/local/bin/why COPY tests/e2e/common.sh /test-common.sh COPY tests/e2e/alpine/test.sh /test.sh ENTRYPOINT ["/bin/bash", "/test.sh"] diff --git a/tests/e2e/arch/Dockerfile b/tests/e2e/arch/Dockerfile index 44b499f..923e33a 100644 --- a/tests/e2e/arch/Dockerfile +++ b/tests/e2e/arch/Dockerfile @@ -1,15 +1,7 @@ -FROM nimlang/nim:2.2.4-ubuntu-regular AS builder -WORKDIR /work -RUN apt-get update \ - && apt-get install -y --no-install-recommends git ca-certificates \ - && rm -rf /var/lib/apt/lists/* -COPY . . -RUN nimble build -d:release - FROM archlinux:base RUN pacman -Syu --noconfirm bash ca-certificates coreutils \ && pacman -Scc --noconfirm -COPY --from=builder /work/why /usr/local/bin/why +COPY tests/e2e/why-linux-amd64 /usr/local/bin/why COPY tests/e2e/common.sh /test-common.sh COPY tests/e2e/arch/test.sh /test.sh ENTRYPOINT ["/bin/bash", "/test.sh"] diff --git a/tests/e2e/fedora/Dockerfile b/tests/e2e/fedora/Dockerfile index d34df7d..dca914e 100644 --- a/tests/e2e/fedora/Dockerfile +++ b/tests/e2e/fedora/Dockerfile @@ -1,15 +1,7 @@ -FROM nimlang/nim:2.2.4-ubuntu-regular AS builder -WORKDIR /work -RUN apt-get update \ - && apt-get install -y --no-install-recommends git ca-certificates \ - && rm -rf /var/lib/apt/lists/* -COPY . . -RUN nimble build -d:release - FROM fedora:40 RUN dnf -y install bash ca-certificates coreutils \ && dnf clean all -COPY --from=builder /work/why /usr/local/bin/why +COPY tests/e2e/why-linux-amd64 /usr/local/bin/why COPY tests/e2e/common.sh /test-common.sh COPY tests/e2e/fedora/test.sh /test.sh ENTRYPOINT ["/bin/bash", "/test.sh"] diff --git a/tests/e2e/gentoo/Dockerfile b/tests/e2e/gentoo/Dockerfile index 9e3b1ef..14c2058 100644 --- a/tests/e2e/gentoo/Dockerfile +++ b/tests/e2e/gentoo/Dockerfile @@ -1,16 +1,8 @@ -FROM nimlang/nim:2.2.4-ubuntu-regular AS builder -WORKDIR /work -RUN apt-get update \ - && apt-get install -y --no-install-recommends git ca-certificates \ - && rm -rf /var/lib/apt/lists/* -COPY . . -RUN nimble build -d:release - FROM gentoo/stage3:latest ENV FEATURES="-sandbox -usersandbox" RUN emerge --sync \ && emerge --quiet app-portage/portage-utils -COPY --from=builder /work/why /usr/local/bin/why +COPY tests/e2e/why-linux-amd64 /usr/local/bin/why COPY tests/e2e/common.sh /test-common.sh COPY tests/e2e/gentoo/test.sh /test.sh ENTRYPOINT ["/bin/bash", "/test.sh"] diff --git a/tests/e2e/opensuse/Dockerfile b/tests/e2e/opensuse/Dockerfile index e9b0a37..f134e2f 100644 --- a/tests/e2e/opensuse/Dockerfile +++ b/tests/e2e/opensuse/Dockerfile @@ -1,15 +1,7 @@ -FROM nimlang/nim:2.2.4-ubuntu-regular AS builder -WORKDIR /work -RUN apt-get update \ - && apt-get install -y --no-install-recommends git ca-certificates \ - && rm -rf /var/lib/apt/lists/* -COPY . . -RUN nimble build -d:release - FROM opensuse/leap:15.6 RUN zypper -n install bash ca-certificates coreutils \ && zypper clean -a -COPY --from=builder /work/why /usr/local/bin/why +COPY tests/e2e/why-linux-amd64 /usr/local/bin/why COPY tests/e2e/common.sh /test-common.sh COPY tests/e2e/opensuse/test.sh /test.sh ENTRYPOINT ["/bin/bash", "/test.sh"] diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index 52f0665..b9d1c9a 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -3,6 +3,9 @@ set -euo pipefail ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) DISTROS=(ubuntu fedora opensuse alpine arch gentoo) +BUILDER_IMAGE="why-e2e-builder" +BUILDER_DOCKERFILE="$ROOT_DIR/tests/e2e/Builder.Dockerfile" +BIN_PATH="$ROOT_DIR/tests/e2e/why-linux-amd64" if [[ $# -gt 0 ]]; then DISTROS=("$@") @@ -10,6 +13,23 @@ fi jobs=${E2E_JOBS:-$(nproc 2>/dev/null || echo 4)} +build_binary() { + if [[ ! -f "$BUILDER_DOCKERFILE" ]]; then + echo "Missing builder Dockerfile: ${BUILDER_DOCKERFILE}" >&2 + return 1 + fi + + echo "==> Building why binary" + docker build --progress=plain -f "$BUILDER_DOCKERFILE" -t "$BUILDER_IMAGE" "$ROOT_DIR" + local container_id + container_id=$(docker create "$BUILDER_IMAGE") + docker cp "$container_id":/usr/local/bin/why "$BIN_PATH" + docker rm -v "$container_id" >/dev/null + chmod +x "$BIN_PATH" + echo "==> Binary ready: ${BIN_PATH}" + echo +} + run_one() { local distro="$1" local image="why-e2e-${distro}" @@ -27,6 +47,8 @@ run_one() { echo } +build_binary + if command -v parallel >/dev/null 2>&1; then export ROOT_DIR export -f run_one diff --git a/tests/e2e/ubuntu/Dockerfile b/tests/e2e/ubuntu/Dockerfile index 1fd992c..919f4f8 100644 --- a/tests/e2e/ubuntu/Dockerfile +++ b/tests/e2e/ubuntu/Dockerfile @@ -1,16 +1,8 @@ -FROM nimlang/nim:2.2.4-ubuntu-regular AS builder -WORKDIR /work -RUN apt-get update \ - && apt-get install -y --no-install-recommends git ca-certificates \ - && rm -rf /var/lib/apt/lists/* -COPY . . -RUN nimble build -d:release - FROM ubuntu:22.04 RUN apt-get update \ && apt-get install -y --no-install-recommends bash ca-certificates \ && rm -rf /var/lib/apt/lists/* -COPY --from=builder /work/why /usr/local/bin/why +COPY tests/e2e/why-linux-amd64 /usr/local/bin/why COPY tests/e2e/common.sh /test-common.sh COPY tests/e2e/ubuntu/test.sh /test.sh ENTRYPOINT ["/bin/bash", "/test.sh"] From 4ba51acf37f4165e1f63600ff87856d39f9576f9 Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sun, 21 Dec 2025 02:25:55 +0900 Subject: [PATCH 13/17] Rename pkg manager strategy type --- src/why_core.nim | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/why_core.nim b/src/why_core.nim index 075e5c7..ecac0b0 100644 --- a/src/why_core.nim +++ b/src/why_core.nim @@ -181,7 +181,7 @@ proc detectProviderByPath*(originPath, realPath: string, rules: seq[ProviderRule return "Unknown" type - PkgManagerRef = object + PkgManagerStrategy = object name: string cmd: proc(path: string, ctx: WhyCtx): string {.nimcall.} @@ -268,12 +268,12 @@ proc checkPkgManagerPortageEquery(path: string, ctx: WhyCtx): string = proc checkSystemPackageManager*(path: string, ctx: WhyCtx): string = let checks = @[ - PkgManagerRef(name: "dpkg", cmd: checkPkgManagerDpkg), - PkgManagerRef(name: "rpm", cmd: checkPkgManagerRpm), - PkgManagerRef(name: "apk", cmd: checkPkgManagerApk), - PkgManagerRef(name: "pacman", cmd: checkPkgManagerPacman), - PkgManagerRef(name: "qfile", cmd: checkPkgManagerPortageQfile), - PkgManagerRef(name: "equery", cmd: checkPkgManagerPortageEquery), + PkgManagerStrategy(name: "dpkg", cmd: checkPkgManagerDpkg), + PkgManagerStrategy(name: "rpm", cmd: checkPkgManagerRpm), + PkgManagerStrategy(name: "apk", cmd: checkPkgManagerApk), + PkgManagerStrategy(name: "pacman", cmd: checkPkgManagerPacman), + PkgManagerStrategy(name: "qfile", cmd: checkPkgManagerPortageQfile), + PkgManagerStrategy(name: "equery", cmd: checkPkgManagerPortageEquery), ] for check in checks: let detected = check.cmd(path, ctx) From 8088283e030ed0300f2afde27e6912d4e9f8099f Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sun, 21 Dec 2025 02:29:59 +0900 Subject: [PATCH 14/17] Add e2e job to CI --- .github/workflows/ci.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f986123..02a3e46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,3 +42,14 @@ jobs: - name: Run Tests run: nimble test + + e2e: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Run E2E Tests + run: ./tests/e2e/run.sh + env: + E2E_JOBS: 2 From e5a09f3d713e7a0f8e3fa878e745fb7205b2034e Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sun, 21 Dec 2025 02:30:12 +0900 Subject: [PATCH 15/17] Ignore .serena artifacts --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index b27089b..3b02b8b 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ nimbledeps # LLM utilities repomix-output.xml + +# Serena +.serena/ From d312940f8ec4a851c02ff3dcbcf9f66350603c74 Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sun, 21 Dec 2025 02:38:34 +0900 Subject: [PATCH 16/17] Make e2e xargs path fail fast --- tests/e2e/run.sh | 72 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 16 deletions(-) diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index b9d1c9a..5449cfc 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -54,22 +54,62 @@ if command -v parallel >/dev/null 2>&1; then export -f run_one parallel --line-buffer --tag --halt now,fail=1 --jobs "$jobs" run_one ::: "${DISTROS[@]}" else - printf '%s\n' "${DISTROS[@]}" | xargs -I{} -P "$jobs" bash -c ' - set -euo pipefail - ROOT_DIR="$1" - distro="$2" - image="why-e2e-${distro}" - dockerfile="$ROOT_DIR/tests/e2e/${distro}/Dockerfile" - if [[ ! -f "$dockerfile" ]]; then - echo "Missing Dockerfile for ${distro}: ${dockerfile}" >&2 - exit 1 + pids=() + fail=0 + + prune_pids() { + local alive=() + local pid + for pid in "${pids[@]}"; do + if kill -0 "$pid" 2>/dev/null; then + alive+=("$pid") + fi + done + pids=("${alive[@]}") + } + + wait_any() { + if wait -n 2>/dev/null; then + prune_pids + return 0 + fi + local pid="${pids[0]:-}" + if [[ -n "$pid" ]]; then + wait "$pid" + local status=$? + prune_pids + return "$status" + fi + return 0 + } + + for distro in "${DISTROS[@]}"; do + run_one "$distro" & + pids+=("$!") + while ((${#pids[@]} >= jobs)); do + if ! wait_any; then + fail=1 + break + fi + done + if ((fail)); then + break fi + done - echo "==> Building ${image}" - docker build --progress=plain -f "$dockerfile" -t "$image" "$ROOT_DIR" - echo "==> Running ${image}" - docker run --rm "$image" - echo "==> ${distro} OK" - echo - ' _ "$ROOT_DIR" {} + if ((fail)); then + for pid in "${pids[@]}"; do + kill "$pid" 2>/dev/null || true + done + fi + + for pid in "${pids[@]}"; do + if ! wait "$pid"; then + fail=1 + fi + done + + if ((fail)); then + exit 1 + fi fi From 15037f2bb0ae72313ead86c6e4071e4ca94237c9 Mon Sep 17 00:00:00 2001 From: akira ueno Date: Sun, 21 Dec 2025 02:39:58 +0900 Subject: [PATCH 17/17] Add .dockerignore for faster e2e builds --- .dockerignore | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c592b34 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.github +.serena +.DS_Store +.idea +.vscode + +# Local build artifacts +why +why.exe +tests/test_why_core +tests/test_why_core.exe +nimcache/ + +# Editor/OS noise +Thumbs.db