mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
* fix(doctor): report missing npm native binary in wrapper Co-authored-by: Cursor <[email protected]> * fix(doctor): preserve JSON output for missing native binary * fix(doctor): preserve doctor CLI behavior in wrapper fallback Co-authored-by: Cursor <[email protected]> * style: gofmt npmwrapper doctor fallback tests Co-authored-by: Cursor <[email protected]> * fix(doctor): reuse shared Bun recovery helpers in wrapper fallback Extract bunRecoveryParagraph and related helpers so the doctor missing-binary text path and the generic wrapper bail share the same copy. Add regression tests with ZERO_WRAPPER_SIMULATE_BUN to guard against future drift. Co-authored-by: Cursor <[email protected]> --------- Co-authored-by: Cursor <[email protected]>
861 lines
33 KiB
Go
861 lines
33 KiB
Go
package npmwrapper
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// Mirror internal/cli/exec.go exit codes so wrapper doctor fallback tests assert
|
|
// the same CLI contract as the Go doctor command.
|
|
const (
|
|
wrapperExitSuccess = 0
|
|
wrapperExitUsage = 2
|
|
wrapperExitDoctor = 1
|
|
)
|
|
|
|
// Canonical Bun recovery copy from bin/zero.js bunRecoveryParagraph() — shared
|
|
// by the generic missing-binary path and the doctor text fallback.
|
|
const (
|
|
bunRecoveryLead = "You installed with Bun, which does not run dependency lifecycle scripts"
|
|
bunPmTrustProject = "bun pm trust @gitlawb/zero"
|
|
bunPmTrustGlobal = "bun pm -g trust @gitlawb/zero"
|
|
bunRecoveryTrustedDeps = `"trustedDependencies": ["@gitlawb/zero"]`
|
|
buildFromSourceLead = "If this platform has no prebuilt binary, build from source:"
|
|
)
|
|
|
|
func runWrapperFixture(t *testing.T, wrapperPath string, args ...string) (stdout string, stderr string, exitCode int) {
|
|
return runWrapperFixtureWithEnv(t, wrapperPath, nil, args...)
|
|
}
|
|
|
|
func runWrapperFixtureWithEnv(t *testing.T, wrapperPath string, extraEnv []string, args ...string) (stdout string, stderr string, exitCode int) {
|
|
t.Helper()
|
|
node := requireNode(t)
|
|
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
|
|
defer cancel()
|
|
command := nodeWrapperCommand(ctx, node, wrapperPath, args...)
|
|
command.Env = append(withoutEnvKey(command.Env, "ZERO_LOCAL_CONTROL_HELPERS"), "ZERO_LOCAL_CONTROL_HELPERS=")
|
|
command.Env = append(withoutEnvKey(command.Env, "ZERO_WRAPPER_SIMULATE_BUN"), extraEnv...)
|
|
var stdoutBuf, stderrBuf strings.Builder
|
|
command.Stdout = &stdoutBuf
|
|
command.Stderr = &stderrBuf
|
|
err := command.Run()
|
|
if ctx.Err() != nil {
|
|
t.Fatalf("wrapper timed out: %v; stdout: %s stderr: %s", ctx.Err(), stdoutBuf.String(), stderrBuf.String())
|
|
}
|
|
exitCode = wrapperExitSuccess
|
|
if err != nil {
|
|
exitErr, ok := err.(*exec.ExitError)
|
|
if !ok {
|
|
t.Fatalf("wrapper err = %v, want *exec.ExitError; stdout=%s stderr=%s", err, stdoutBuf.String(), stderrBuf.String())
|
|
}
|
|
exitCode = exitErr.ExitCode()
|
|
}
|
|
return stdoutBuf.String(), stderrBuf.String(), exitCode
|
|
}
|
|
|
|
func TestPackageBinPointsToNodeWrapper(t *testing.T) {
|
|
root := repoRoot(t)
|
|
bytes, err := os.ReadFile(filepath.Join(root, "package.json"))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile package.json: %v", err)
|
|
}
|
|
var pkg struct {
|
|
Name string `json:"name"`
|
|
Module string `json:"module"`
|
|
Bin map[string]string `json:"bin"`
|
|
Scripts map[string]string `json:"scripts"`
|
|
License string `json:"license"`
|
|
Files []string `json:"files"`
|
|
Deps map[string]string `json:"dependencies"`
|
|
Repository json.RawMessage `json:"repository"`
|
|
Engines map[string]string `json:"engines"`
|
|
}
|
|
if err := json.Unmarshal(bytes, &pkg); err != nil {
|
|
t.Fatalf("Unmarshal package.json: %v", err)
|
|
}
|
|
if pkg.Name != "@gitlawb/zero" {
|
|
t.Fatalf("name = %q, want @gitlawb/zero", pkg.Name)
|
|
}
|
|
if pkg.Bin["zero"] != "bin/zero.js" {
|
|
t.Fatalf("bin.zero = %q, want bin/zero.js", pkg.Bin["zero"])
|
|
}
|
|
if pkg.Module != "bin/zero.js" {
|
|
t.Fatalf("module = %q, want bin/zero.js", pkg.Module)
|
|
}
|
|
// The published package must carry NO lifecycle scripts at all: the native
|
|
// binary arrives as a platform optionalDependency, with scripts/postinstall.mjs
|
|
// kept only as a first-run fallback the wrapper invokes itself. Any script
|
|
// here would resurrect the npm/Bun/pnpm install-script warnings the platform
|
|
//-package model exists to eliminate (see docs/NPM_PACKAGING.md).
|
|
for name := range pkg.Scripts {
|
|
t.Fatalf("package.json scripts contains %q; the published package must have no lifecycle scripts", name)
|
|
}
|
|
if pkg.License == "" {
|
|
t.Fatalf("package.json license is empty; set it (ties to the pending LICENSE file) so npm publish is not unlicensed")
|
|
}
|
|
if len(pkg.Repository) == 0 {
|
|
t.Fatalf("package.json repository is missing; npm needs it for provenance")
|
|
}
|
|
if pkg.Engines["node"] == "" {
|
|
t.Fatalf("package.json engines.node is empty; the wrapper and installer require a modern Node")
|
|
}
|
|
for _, name := range []string{"agent-browser", "tuistory"} {
|
|
if pkg.Deps[name] == "" {
|
|
t.Fatalf("package.json dependencies is missing %q", name)
|
|
}
|
|
}
|
|
wantFiles := map[string]bool{"bin/zero.js": false, "scripts/postinstall.mjs": false}
|
|
for _, f := range pkg.Files {
|
|
if _, ok := wantFiles[f]; ok {
|
|
wantFiles[f] = true
|
|
}
|
|
}
|
|
for f, present := range wantFiles {
|
|
if !present {
|
|
t.Fatalf("package.json files is missing %q; it would not be published in the tarball", f)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPostinstallComputesAssetPlan(t *testing.T) {
|
|
version := packageVersion(t)
|
|
cases := []struct {
|
|
platform, arch string
|
|
wantAsset, wantBinary string
|
|
}{
|
|
{"linux", "x64", "zero-v" + version + "-linux-x64.tar.gz", "zero"},
|
|
{"darwin", "arm64", "zero-v" + version + "-macos-arm64.tar.gz", "zero"},
|
|
{"win32", "x64", "zero-v" + version + "-windows-x64.zip", "zero.exe"},
|
|
}
|
|
for _, tc := range cases {
|
|
stdout, stderr, err := runPostinstall(t,
|
|
"ZERO_INSTALL_DRY_RUN=1",
|
|
"ZERO_INSTALL_PLATFORM="+tc.platform,
|
|
"ZERO_INSTALL_ARCH="+tc.arch,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("%s/%s: dry-run err=%v stderr=%s", tc.platform, tc.arch, err, stderr)
|
|
}
|
|
var plan struct {
|
|
AssetName string `json:"assetName"`
|
|
AssetURL string `json:"assetUrl"`
|
|
BinaryName string `json:"binaryName"`
|
|
Tag string `json:"tag"`
|
|
}
|
|
if err := json.Unmarshal([]byte(stdout), &plan); err != nil {
|
|
t.Fatalf("%s/%s: parse plan %q: %v", tc.platform, tc.arch, stdout, err)
|
|
}
|
|
if plan.AssetName != tc.wantAsset {
|
|
t.Fatalf("%s/%s: assetName=%q want %q", tc.platform, tc.arch, plan.AssetName, tc.wantAsset)
|
|
}
|
|
if plan.BinaryName != tc.wantBinary {
|
|
t.Fatalf("%s/%s: binaryName=%q want %q", tc.platform, tc.arch, plan.BinaryName, tc.wantBinary)
|
|
}
|
|
wantURL := "https://github.com/Gitlawb/zero/releases/download/v" + version + "/" + tc.wantAsset
|
|
if plan.AssetURL != wantURL {
|
|
t.Fatalf("%s/%s: assetUrl=%q want %q", tc.platform, tc.arch, plan.AssetURL, wantURL)
|
|
}
|
|
if plan.Tag != "v"+version {
|
|
t.Fatalf("%s/%s: tag=%q want v%s", tc.platform, tc.arch, plan.Tag, version)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPostinstallSkipsUnsupportedPlatform(t *testing.T) {
|
|
stdout, stderr, err := runPostinstall(t,
|
|
"ZERO_INSTALL_DRY_RUN=1",
|
|
"ZERO_INSTALL_PLATFORM=plan9",
|
|
"ZERO_INSTALL_ARCH=x64",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("unsupported platform should exit 0, got err=%v stderr=%s", err, stderr)
|
|
}
|
|
if strings.TrimSpace(stdout) != "" {
|
|
t.Fatalf("unsupported platform should not print a plan, got %q", stdout)
|
|
}
|
|
if !strings.Contains(stderr, "no prebuilt binary") {
|
|
t.Fatalf("stderr=%q, want it to mention no prebuilt binary", stderr)
|
|
}
|
|
}
|
|
|
|
func TestPostinstallSkipsWindowsArm64(t *testing.T) {
|
|
// (win32, arm64) resolves to a valid platform/arch but the release matrix has
|
|
// no windows-arm64 artifact, so the install must skip gracefully (exit 0)
|
|
// rather than hard-fail on a 404 download.
|
|
stdout, stderr, err := runPostinstall(t,
|
|
"ZERO_INSTALL_DRY_RUN=1",
|
|
"ZERO_INSTALL_PLATFORM=win32",
|
|
"ZERO_INSTALL_ARCH=arm64",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("windows-arm64 should exit 0, got err=%v stderr=%s", err, stderr)
|
|
}
|
|
if strings.TrimSpace(stdout) != "" {
|
|
t.Fatalf("windows-arm64 should not print a plan, got %q", stdout)
|
|
}
|
|
if !strings.Contains(stderr, "no prebuilt binary for windows-arm64") {
|
|
t.Fatalf("stderr=%q, want the windows-arm64 skip message", stderr)
|
|
}
|
|
}
|
|
|
|
func TestPostinstallHonorsSkipEnv(t *testing.T) {
|
|
stdout, stderr, err := runPostinstall(t, "ZERO_SKIP_DOWNLOAD=1")
|
|
if err != nil {
|
|
t.Fatalf("ZERO_SKIP_DOWNLOAD should exit 0, got err=%v stderr=%s", err, stderr)
|
|
}
|
|
if strings.TrimSpace(stdout) != "" {
|
|
t.Fatalf("skip should print nothing to stdout, got %q", stdout)
|
|
}
|
|
if !strings.Contains(stderr, "skipping native binary download") {
|
|
t.Fatalf("stderr=%q, want skip message", stderr)
|
|
}
|
|
}
|
|
|
|
func runPostinstall(t *testing.T, env ...string) (string, string, error) {
|
|
t.Helper()
|
|
node := requireNode(t)
|
|
root := repoRoot(t)
|
|
script := filepath.Join(root, "scripts", "postinstall.mjs")
|
|
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
|
|
defer cancel()
|
|
command := exec.CommandContext(ctx, node, script)
|
|
command.Env = append(append(os.Environ(), "NODE_OPTIONS="), env...)
|
|
var stdout, stderr strings.Builder
|
|
command.Stdout = &stdout
|
|
command.Stderr = &stderr
|
|
runErr := command.Run()
|
|
if ctx.Err() != nil {
|
|
t.Fatalf("postinstall timed out: %v; stderr: %s", ctx.Err(), stderr.String())
|
|
}
|
|
return stdout.String(), stderr.String(), runErr
|
|
}
|
|
|
|
func packageVersion(t *testing.T) string {
|
|
t.Helper()
|
|
root := repoRoot(t)
|
|
data, err := os.ReadFile(filepath.Join(root, "package.json"))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile package.json: %v", err)
|
|
}
|
|
var pkg struct {
|
|
Version string `json:"version"`
|
|
}
|
|
if err := json.Unmarshal(data, &pkg); err != nil {
|
|
t.Fatalf("Unmarshal package.json: %v", err)
|
|
}
|
|
if pkg.Version == "" {
|
|
t.Fatal("package.json version is empty")
|
|
}
|
|
return pkg.Version
|
|
}
|
|
|
|
func TestNodeWrapperIsExecutableAndDoesNotImportBun(t *testing.T) {
|
|
root := repoRoot(t)
|
|
wrapperPath := filepath.Join(root, "bin", "zero.js")
|
|
bytes, err := os.ReadFile(wrapperPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile wrapper: %v", err)
|
|
}
|
|
source := string(bytes)
|
|
firstLine := strings.TrimSuffix(strings.SplitN(source, "\n", 2)[0], "\r")
|
|
if firstLine != "#!/usr/bin/env node" {
|
|
t.Fatalf("wrapper shebang = %q, want node", firstLine)
|
|
}
|
|
for _, forbidden := range []string{"#!/usr/bin/env bun", "Bun.", "../scripts/npm-wrapper", "bun run build"} {
|
|
if strings.Contains(source, forbidden) {
|
|
t.Fatalf("wrapper still contains %q", forbidden)
|
|
}
|
|
}
|
|
info, err := os.Stat(wrapperPath)
|
|
if err != nil {
|
|
t.Fatalf("Stat wrapper: %v", err)
|
|
}
|
|
if runtime.GOOS != "windows" && info.Mode()&0o111 == 0 {
|
|
t.Fatalf("wrapper mode = %v, want executable bit", info.Mode())
|
|
}
|
|
}
|
|
|
|
func TestNodeWrapperReportsMissingNativeBinary(t *testing.T) {
|
|
node := requireNode(t)
|
|
wrapperPath := copyWrapperFixture(t)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
|
|
defer cancel()
|
|
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
|
|
command.Env = append(withoutEnvKey(command.Env, "ZERO_LOCAL_CONTROL_HELPERS"), "ZERO_LOCAL_CONTROL_HELPERS=")
|
|
output, err := command.CombinedOutput()
|
|
if ctx.Err() != nil {
|
|
t.Fatalf("wrapper timed out reporting missing native binary: %v; output: %s", ctx.Err(), output)
|
|
}
|
|
if err == nil {
|
|
t.Fatalf("wrapper exited successfully without native binary: %s", output)
|
|
}
|
|
exitErr, ok := err.(*exec.ExitError)
|
|
if !ok || exitErr.ExitCode() != 1 {
|
|
t.Fatalf("wrapper err = %v, want exit 1; output: %s", err, output)
|
|
}
|
|
if !strings.Contains(string(output), "No native binary is available for this install") {
|
|
t.Fatalf("missing-native output = %q", string(output))
|
|
}
|
|
}
|
|
|
|
func TestNodeWrapperPrefersPlatformPackageBinary(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("mock executable fixture uses a POSIX shell script")
|
|
}
|
|
node := requireNode(t)
|
|
wrapperPath := copyWrapperFixture(t)
|
|
root := filepath.Dir(filepath.Dir(wrapperPath))
|
|
|
|
// A stale downloaded binary next to the wrapper must lose to the platform
|
|
// package: the platform version is pinned to the wrapper release, the
|
|
// downloaded copy is whatever a previous fallback fetched.
|
|
if err := os.WriteFile(filepath.Join(root, "zero"), []byte("#!/usr/bin/env sh\nprintf 'downloaded-zero\\n'\n"), 0o755); err != nil {
|
|
t.Fatalf("WriteFile downloaded fixture: %v", err)
|
|
}
|
|
|
|
platformDir := filepath.Join(root, "node_modules", "@gitlawb", "zero-"+nodePlatformName()+"-"+nodeArchName())
|
|
if err := os.MkdirAll(platformDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll platform package: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(platformDir, "package.json"), []byte(`{"name":"@gitlawb/zero"}`), 0o644); err != nil {
|
|
t.Fatalf("WriteFile platform package.json: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(platformDir, "zero"), []byte("#!/usr/bin/env sh\nprintf 'platform-zero'; for arg in \"$@\"; do printf ' %s' \"$arg\"; done; printf '\\n'\n"), 0o755); err != nil {
|
|
t.Fatalf("WriteFile platform binary: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
|
|
defer cancel()
|
|
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
|
|
output, err := command.CombinedOutput()
|
|
if ctx.Err() != nil {
|
|
t.Fatalf("wrapper timed out launching platform binary: %v; output: %s", ctx.Err(), output)
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("wrapper returned error: %v; output: %s", err, output)
|
|
}
|
|
if got := strings.TrimSpace(string(output)); got != "platform-zero --version" {
|
|
t.Fatalf("wrapper output = %q, want platform-zero --version", got)
|
|
}
|
|
}
|
|
|
|
func TestNodeWrapperFallsBackToDownloadedBinary(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("mock executable fixture uses a POSIX shell script")
|
|
}
|
|
node := requireNode(t)
|
|
wrapperPath := copyWrapperFixture(t)
|
|
root := filepath.Dir(filepath.Dir(wrapperPath))
|
|
if err := os.WriteFile(filepath.Join(root, "zero"), []byte("#!/usr/bin/env sh\nprintf 'downloaded-zero\\n'\n"), 0o755); err != nil {
|
|
t.Fatalf("WriteFile downloaded fixture: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
|
|
defer cancel()
|
|
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
|
|
output, err := command.CombinedOutput()
|
|
if ctx.Err() != nil {
|
|
t.Fatalf("wrapper timed out launching downloaded binary: %v; output: %s", ctx.Err(), output)
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("wrapper returned error: %v; output: %s", err, output)
|
|
}
|
|
if got := strings.TrimSpace(string(output)); got != "downloaded-zero" {
|
|
t.Fatalf("wrapper output = %q, want downloaded-zero", got)
|
|
}
|
|
}
|
|
|
|
func TestNodeWrapperRunsFirstRunDownloaderWhenBinaryMissing(t *testing.T) {
|
|
node := requireNode(t)
|
|
wrapperPath := copyWrapperFixture(t)
|
|
root := filepath.Dir(filepath.Dir(wrapperPath))
|
|
|
|
// Give the fixture the real downloader so the wrapper's first-run path
|
|
// executes it; ZERO_SKIP_DOWNLOAD keeps the test offline, so the download
|
|
// "succeeds" without producing a binary and the wrapper must exit 1 with
|
|
// guidance.
|
|
scriptsDir := filepath.Join(root, "scripts")
|
|
if err := os.MkdirAll(scriptsDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll scripts: %v", err)
|
|
}
|
|
postinstall, err := os.ReadFile(filepath.Join(repoRoot(t), "scripts", "postinstall.mjs"))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile postinstall: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(scriptsDir, "postinstall.mjs"), postinstall, 0o644); err != nil {
|
|
t.Fatalf("WriteFile postinstall fixture: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"type":"module","name":"@gitlawb/zero","version":"0.0.0"}`), 0o644); err != nil {
|
|
t.Fatalf("WriteFile package fixture: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
|
|
defer cancel()
|
|
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
|
|
command.Env = append(command.Env, "ZERO_SKIP_DOWNLOAD=1")
|
|
output, err := command.CombinedOutput()
|
|
if ctx.Err() != nil {
|
|
t.Fatalf("wrapper timed out on first-run download path: %v; output: %s", ctx.Err(), output)
|
|
}
|
|
exitErr, ok := err.(*exec.ExitError)
|
|
if !ok || exitErr.ExitCode() != 1 {
|
|
t.Fatalf("wrapper err = %v, want exit 1; output: %s", err, output)
|
|
}
|
|
text := string(output)
|
|
if !strings.Contains(text, "fetching the native binary from the GitHub Release") {
|
|
t.Fatalf("output missing fallback download notice: %q", text)
|
|
}
|
|
if !strings.Contains(text, "skipping native binary download") {
|
|
t.Fatalf("output shows the downloader did not run: %q", text)
|
|
}
|
|
if !strings.Contains(text, "No native binary is available for this install") {
|
|
t.Fatalf("output missing guidance: %q", text)
|
|
}
|
|
}
|
|
|
|
func nodePlatformName() string {
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
return "darwin"
|
|
case "windows":
|
|
return "win32"
|
|
default:
|
|
return runtime.GOOS
|
|
}
|
|
}
|
|
|
|
func nodeArchName() string {
|
|
switch runtime.GOARCH {
|
|
case "amd64":
|
|
return "x64"
|
|
default:
|
|
return runtime.GOARCH
|
|
}
|
|
}
|
|
|
|
// Issue #405: `zero doctor` is the diagnostic command, so when the native
|
|
// binary is the thing that's broken it must NOT bail with the generic wrapper
|
|
// error; that's exactly the blind alley the bug report calls out. Instead it
|
|
// emits a doctor-shaped FAIL line for the runtime so the user's own diagnostic
|
|
// surfaces the real cause. We assert on the doctor-report shape (so the doctor
|
|
// UX matches what the Go-side doctor.Format produces) and on exit 1 (a missing
|
|
// binary is still a hard failure, not a pass).
|
|
func TestNodeWrapperDoctorReportsMissingNativeBinaryAsDoctorFail(t *testing.T) {
|
|
wrapperPath := copyWrapperFixture(t)
|
|
stdout, stderr, exitCode := runWrapperFixture(t, wrapperPath, "doctor")
|
|
if exitCode != wrapperExitDoctor {
|
|
t.Fatalf("doctor exit = %d, want %d; stdout=%s stderr=%s", exitCode, wrapperExitDoctor, stdout, stderr)
|
|
}
|
|
if strings.TrimSpace(stderr) != "" {
|
|
t.Fatalf("doctor text report must go to stdout only, got stderr=%q", stderr)
|
|
}
|
|
// Doctor-shaped report, not the generic wrapper bail. Matches the shape the
|
|
// Go-side doctor.Format emits: a header, "Overall: <pass/fail>", then
|
|
// "[<status>] <id> - <message>" lines.
|
|
if !strings.Contains(stdout, "Zero doctor report (") {
|
|
t.Fatalf("doctor output should start with a doctor report header, got stdout=%q", stdout)
|
|
}
|
|
if !strings.Contains(stdout, "Overall: fail") {
|
|
t.Fatalf("doctor overall must be fail when the native binary is missing, got stdout=%q", stdout)
|
|
}
|
|
if !strings.Contains(stdout, "[fail] runtime.go") {
|
|
t.Fatalf("doctor must report a failing runtime.go check, got stdout=%q", stdout)
|
|
}
|
|
if !strings.Contains(stdout, "Native zero binary is missing next to the npm wrapper") {
|
|
t.Fatalf("doctor must name the actual cause (missing native binary), got stdout=%q", stdout)
|
|
}
|
|
// The actionable remedy must point at the postinstall script that would fix
|
|
// the install, not just "build from source".
|
|
if !strings.Contains(stdout, "postinstall.mjs") {
|
|
t.Fatalf("doctor remedy should name the postinstall script, got stdout=%q", stdout)
|
|
}
|
|
// Regression guard for the original blind-alley bug: the doctor path must
|
|
// NOT emit the generic wrapper bail (that's what sent users debugging the
|
|
// wrong thing).
|
|
if strings.Contains(stdout, "[zero] No native binary is available for this install") {
|
|
t.Fatalf("doctor must not emit the generic wrapper bail, got stdout=%q", stdout)
|
|
}
|
|
}
|
|
|
|
func TestNodeWrapperDoctorJSONReportsMissingNativeBinaryAsJSONFail(t *testing.T) {
|
|
wrapperPath := copyWrapperFixture(t)
|
|
stdout, stderr, exitCode := runWrapperFixture(t, wrapperPath, "doctor", "--json")
|
|
if exitCode != wrapperExitDoctor {
|
|
t.Fatalf("doctor --json exit = %d, want %d; stdout=%s stderr=%s", exitCode, wrapperExitDoctor, stdout, stderr)
|
|
}
|
|
if strings.TrimSpace(stderr) != "" {
|
|
t.Fatalf("doctor --json should write machine-readable output to stdout only, got stderr=%q", stderr)
|
|
}
|
|
var report struct {
|
|
GeneratedAt string `json:"generatedAt"`
|
|
OK bool `json:"ok"`
|
|
Checks []struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
Status string `json:"status"`
|
|
Message string `json:"message"`
|
|
Details map[string]any `json:"details"`
|
|
} `json:"checks"`
|
|
}
|
|
if err := json.Unmarshal([]byte(stdout), &report); err != nil {
|
|
t.Fatalf("doctor --json stdout should be valid JSON, got %q: %v", stdout, err)
|
|
}
|
|
if report.GeneratedAt == "" {
|
|
t.Fatalf("doctor --json report should include generatedAt: %#v", report)
|
|
}
|
|
if report.OK {
|
|
t.Fatalf("doctor --json ok = true, want false: %#v", report)
|
|
}
|
|
if len(report.Checks) != 1 {
|
|
t.Fatalf("doctor --json checks length = %d, want 1: %#v", len(report.Checks), report.Checks)
|
|
}
|
|
check := report.Checks[0]
|
|
if check.ID != "runtime.go" || check.Label != "Go runtime" || check.Status != "fail" {
|
|
t.Fatalf("doctor --json runtime check = %#v, want failing runtime.go check", check)
|
|
}
|
|
if !strings.Contains(check.Message, "Native zero binary is missing next to the npm wrapper") {
|
|
t.Fatalf("doctor --json must name the actual cause, got %#v", check)
|
|
}
|
|
remedy, _ := check.Details["remedy"].(string)
|
|
if !strings.Contains(remedy, "postinstall.mjs") {
|
|
t.Fatalf("doctor --json remedy should name the postinstall script, got %#v", check.Details)
|
|
}
|
|
}
|
|
|
|
func TestNodeWrapperDoctorHelpShowsUsage(t *testing.T) {
|
|
wrapperPath := copyWrapperFixture(t)
|
|
for _, args := range [][]string{{"doctor", "--help"}, {"doctor", "help"}, {"doctor", "-h"}} {
|
|
stdout, stderr, exitCode := runWrapperFixture(t, wrapperPath, args...)
|
|
if exitCode != wrapperExitSuccess {
|
|
t.Fatalf("%v exit = %d, want %d; stdout=%s stderr=%s", args, exitCode, wrapperExitSuccess, stdout, stderr)
|
|
}
|
|
if strings.TrimSpace(stderr) != "" {
|
|
t.Fatalf("%v help must write to stdout only, got stderr=%q", args, stderr)
|
|
}
|
|
if !strings.Contains(stdout, "Usage:") || !strings.Contains(stdout, "zero doctor [flags]") {
|
|
t.Fatalf("%v help output = %q, want doctor usage text", args, stdout)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNodeWrapperDoctorRejectsUnknownFlag(t *testing.T) {
|
|
wrapperPath := copyWrapperFixture(t)
|
|
stdout, stderr, exitCode := runWrapperFixture(t, wrapperPath, "doctor", "--bogus")
|
|
if exitCode != wrapperExitUsage {
|
|
t.Fatalf("doctor --bogus exit = %d, want %d; stdout=%s stderr=%s", exitCode, wrapperExitUsage, stdout, stderr)
|
|
}
|
|
if strings.TrimSpace(stdout) != "" {
|
|
t.Fatalf("invalid doctor flag should not write stdout, got %q", stdout)
|
|
}
|
|
if stderr != "[zero] unknown doctor flag \"--bogus\"\n" {
|
|
t.Fatalf("invalid doctor flag stderr = %q", stderr)
|
|
}
|
|
}
|
|
|
|
// `doctor --connectivity` (a valid doctor invocation with a trailing flag) must
|
|
// take the same doctor-shaped path: parseDoctorArgs accepts --connectivity and
|
|
// the missing-binary fallback still reports the runtime failure.
|
|
func TestNodeWrapperDoctorWithFlagsStillReportsDoctorFail(t *testing.T) {
|
|
wrapperPath := copyWrapperFixture(t)
|
|
stdout, stderr, exitCode := runWrapperFixture(t, wrapperPath, "doctor", "--connectivity")
|
|
if exitCode != wrapperExitDoctor {
|
|
t.Fatalf("doctor --connectivity exit = %d, want %d; stdout=%s stderr=%s", exitCode, wrapperExitDoctor, stdout, stderr)
|
|
}
|
|
if strings.TrimSpace(stderr) != "" {
|
|
t.Fatalf("doctor --connectivity must write to stdout only, got stderr=%q", stderr)
|
|
}
|
|
if !strings.Contains(stdout, "[fail] runtime.go") {
|
|
t.Fatalf("doctor --connectivity must still emit the failing runtime.go line, got stdout=%q", stdout)
|
|
}
|
|
if strings.Contains(stdout, "[zero] No native binary is available for this install") {
|
|
t.Fatalf("doctor --connectivity must not fall back to the generic bail, got stdout=%q", stdout)
|
|
}
|
|
}
|
|
|
|
func TestNodeWrapperGenericReportsBunRecoveryWhenInstalledByBun(t *testing.T) {
|
|
wrapperPath := copyWrapperFixture(t)
|
|
_, stderr, exitCode := runWrapperFixtureWithEnv(t, wrapperPath, []string{"ZERO_WRAPPER_SIMULATE_BUN=1"}, "--version")
|
|
if exitCode != wrapperExitDoctor {
|
|
t.Fatalf("generic missing-binary exit = %d, want %d; stderr=%s", exitCode, wrapperExitDoctor, stderr)
|
|
}
|
|
assertBunRecoveryMessage(t, stderr)
|
|
if !strings.Contains(stderr, buildFromSourceLead) {
|
|
t.Fatalf("generic bun stderr should include build-from-source guidance, got %q", stderr)
|
|
}
|
|
}
|
|
|
|
func TestNodeWrapperDoctorReportsBunRecoveryWhenInstalledByBun(t *testing.T) {
|
|
wrapperPath := copyWrapperFixture(t)
|
|
stdout, stderr, exitCode := runWrapperFixtureWithEnv(t, wrapperPath, []string{"ZERO_WRAPPER_SIMULATE_BUN=1"}, "doctor")
|
|
if exitCode != wrapperExitDoctor {
|
|
t.Fatalf("doctor bun exit = %d, want %d; stdout=%s stderr=%s", exitCode, wrapperExitDoctor, stdout, stderr)
|
|
}
|
|
if strings.TrimSpace(stderr) != "" {
|
|
t.Fatalf("doctor bun report must go to stdout only, got stderr=%q", stderr)
|
|
}
|
|
if !strings.Contains(stdout, "[fail] runtime.go") {
|
|
t.Fatalf("doctor bun report must still fail runtime.go, got stdout=%q", stdout)
|
|
}
|
|
assertBunRecoveryMessage(t, stdout)
|
|
if !strings.Contains(stdout, buildFromSourceLead) {
|
|
t.Fatalf("doctor bun report should reuse generic build-from-source guidance, got stdout=%q", stdout)
|
|
}
|
|
if strings.Contains(stdout, "If reinstall fails") {
|
|
t.Fatalf("doctor bun report must not use a separate build-from-source string, got stdout=%q", stdout)
|
|
}
|
|
}
|
|
|
|
func TestNodeWrapperDoctorAndGenericShareBunRecoveryCopy(t *testing.T) {
|
|
wrapperPath := copyWrapperFixture(t)
|
|
simulateBun := []string{"ZERO_WRAPPER_SIMULATE_BUN=1"}
|
|
_, genericStderr, _ := runWrapperFixtureWithEnv(t, wrapperPath, simulateBun, "--version")
|
|
doctorStdout, _, _ := runWrapperFixtureWithEnv(t, wrapperPath, simulateBun, "doctor")
|
|
genericBun := extractBunRecoveryBlock(genericStderr)
|
|
doctorBun := extractBunRecoveryBlock(doctorStdout)
|
|
if genericBun != doctorBun {
|
|
t.Fatalf("bun recovery copy drift:\ngeneric=%q\ndoctor=%q", genericBun, doctorBun)
|
|
}
|
|
}
|
|
|
|
func assertBunRecoveryMessage(t *testing.T, output string) {
|
|
t.Helper()
|
|
if !strings.Contains(output, bunRecoveryLead) {
|
|
t.Fatalf("output should include bun recovery lead, got %q", output)
|
|
}
|
|
if !strings.Contains(output, bunPmTrustProject) {
|
|
t.Fatalf("output should include project bun pm trust guidance, got %q", output)
|
|
}
|
|
if !strings.Contains(output, bunPmTrustGlobal) {
|
|
t.Fatalf("output should include global bun pm trust guidance, got %q", output)
|
|
}
|
|
if !strings.Contains(output, bunRecoveryTrustedDeps) {
|
|
t.Fatalf("output should include trustedDependencies fallback guidance, got %q", output)
|
|
}
|
|
if !strings.Contains(output, "to your project package.json and reinstall.") {
|
|
t.Fatalf("output should mention package.json reinstall fallback, got %q", output)
|
|
}
|
|
}
|
|
|
|
func extractBunRecoveryBlock(output string) string {
|
|
start := strings.Index(output, bunRecoveryLead)
|
|
if start < 0 {
|
|
return ""
|
|
}
|
|
end := strings.Index(output[start:], buildFromSourceLead)
|
|
if end < 0 {
|
|
return strings.TrimSpace(output[start:])
|
|
}
|
|
return strings.TrimSpace(output[start : start+end])
|
|
}
|
|
|
|
func TestNodeWrapperLaunchesNativeBinary(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("mock executable fixture uses a POSIX shell script")
|
|
}
|
|
node := requireNode(t)
|
|
wrapperPath := copyWrapperFixture(t)
|
|
root := filepath.Dir(filepath.Dir(wrapperPath))
|
|
nativePath := filepath.Join(root, "zero")
|
|
if err := os.WriteFile(nativePath, []byte("#!/usr/bin/env sh\nprintf 'mock-zero'; for arg in \"$@\"; do printf ' %s' \"$arg\"; done; printf '\\n'\n"), 0o755); err != nil {
|
|
t.Fatalf("WriteFile native fixture: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
|
|
defer cancel()
|
|
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
|
|
output, err := command.CombinedOutput()
|
|
if ctx.Err() != nil {
|
|
t.Fatalf("wrapper timed out launching native binary: %v; output: %s", ctx.Err(), output)
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("wrapper returned error: %v; output: %s", err, output)
|
|
}
|
|
if got := strings.TrimSpace(string(output)); got != "mock-zero --version" {
|
|
t.Fatalf("wrapper output = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestNodeWrapperPassesLocalControlHelperManifest(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("mock executable fixture uses a POSIX shell script")
|
|
}
|
|
node := requireNode(t)
|
|
wrapperPath := copyWrapperFixture(t)
|
|
root := filepath.Dir(filepath.Dir(wrapperPath))
|
|
nativePath := filepath.Join(root, "zero")
|
|
if err := os.WriteFile(nativePath, []byte("#!/usr/bin/env sh\nprintf '%s\\n' \"$ZERO_LOCAL_CONTROL_HELPERS\"\n"), 0o755); err != nil {
|
|
t.Fatalf("WriteFile native fixture: %v", err)
|
|
}
|
|
binDir := filepath.Join(root, "node_modules", ".bin")
|
|
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll node_modules/.bin: %v", err)
|
|
}
|
|
for _, name := range []string{"agent-browser", "tuistory"} {
|
|
if err := os.WriteFile(filepath.Join(binDir, name), []byte("#!/usr/bin/env sh\n"), 0o755); err != nil {
|
|
t.Fatalf("WriteFile helper %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
|
|
defer cancel()
|
|
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
|
|
output, err := command.CombinedOutput()
|
|
if ctx.Err() != nil {
|
|
t.Fatalf("wrapper timed out launching native binary: %v; output: %s", ctx.Err(), output)
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("wrapper returned error: %v; output: %s", err, output)
|
|
}
|
|
var manifest struct {
|
|
Version int `json:"version"`
|
|
Helpers map[string]struct {
|
|
Command string `json:"command"`
|
|
PrefixArgs []string `json:"prefixArgs"`
|
|
PathPrepend []string `json:"pathPrepend"`
|
|
} `json:"helpers"`
|
|
}
|
|
if err := json.Unmarshal([]byte(strings.TrimSpace(string(output))), &manifest); err != nil {
|
|
t.Fatalf("manifest JSON = %q: %v", output, err)
|
|
}
|
|
if manifest.Version != 1 {
|
|
t.Fatalf("manifest version = %d, want 1", manifest.Version)
|
|
}
|
|
for _, name := range []string{"agent-browser", "tuistory"} {
|
|
helper, ok := manifest.Helpers[name]
|
|
if !ok {
|
|
t.Fatalf("manifest missing helper %q: %#v", name, manifest.Helpers)
|
|
}
|
|
wantCommand := canonicalTestPath(t, filepath.Join(binDir, name))
|
|
if helper.Command != wantCommand {
|
|
t.Fatalf("%s command = %q, want %q", name, helper.Command, wantCommand)
|
|
}
|
|
wantBinDir := canonicalTestPath(t, binDir)
|
|
if len(helper.PathPrepend) != 1 || helper.PathPrepend[0] != wantBinDir {
|
|
t.Fatalf("%s pathPrepend = %#v, want [%q]", name, helper.PathPrepend, wantBinDir)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNodeWrapperClearsInheritedLocalControlHelperManifestWhenNoHelpers(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("mock executable fixture uses a POSIX shell script")
|
|
}
|
|
node := requireNode(t)
|
|
wrapperPath := copyWrapperFixture(t)
|
|
root := filepath.Dir(filepath.Dir(wrapperPath))
|
|
nativePath := filepath.Join(root, "zero")
|
|
if err := os.WriteFile(nativePath, []byte("#!/usr/bin/env sh\nif [ -n \"${ZERO_LOCAL_CONTROL_HELPERS+x}\" ]; then printf 'set\\n'; else printf 'unset\\n'; fi\n"), 0o755); err != nil {
|
|
t.Fatalf("WriteFile native fixture: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
|
|
defer cancel()
|
|
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
|
|
command.Env = append(withoutEnvKey(command.Env, "ZERO_LOCAL_CONTROL_HELPERS"), `ZERO_LOCAL_CONTROL_HELPERS={"version":1,"helpers":{"agent-browser":{"command":"stale"}}}`)
|
|
output, err := command.CombinedOutput()
|
|
if ctx.Err() != nil {
|
|
t.Fatalf("wrapper timed out launching native binary: %v; output: %s", ctx.Err(), output)
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("wrapper returned error: %v; output: %s", err, output)
|
|
}
|
|
if got := strings.TrimSpace(string(output)); got != "unset" {
|
|
t.Fatalf("ZERO_LOCAL_CONTROL_HELPERS state = %q, want unset", got)
|
|
}
|
|
}
|
|
|
|
func canonicalTestPath(t *testing.T, path string) string {
|
|
t.Helper()
|
|
realPath, err := filepath.EvalSymlinks(path)
|
|
if err != nil {
|
|
t.Fatalf("EvalSymlinks %s: %v", path, err)
|
|
}
|
|
return realPath
|
|
}
|
|
|
|
func withoutEnvKey(env []string, key string) []string {
|
|
prefix := key + "="
|
|
out := make([]string, 0, len(env))
|
|
for _, item := range env {
|
|
if strings.HasPrefix(item, prefix) {
|
|
continue
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func copyWrapperFixture(t *testing.T) string {
|
|
t.Helper()
|
|
root := repoRoot(t)
|
|
bytes, err := os.ReadFile(filepath.Join(root, "bin", "zero.js"))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile wrapper: %v", err)
|
|
}
|
|
dir := t.TempDir()
|
|
// Create a package.json with "type": "module" so the isolated .js fixture
|
|
// is treated as ESM (matching how it runs when installed from the real package.json).
|
|
// Without this, Node treats .js as CJS on all platforms, causing top-level import
|
|
// to fail with SyntaxError before reaching the missing-binary logic.
|
|
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"type":"module"}`), 0o644); err != nil {
|
|
t.Fatalf("WriteFile package.json fixture: %v", err)
|
|
}
|
|
binDir := filepath.Join(dir, "bin")
|
|
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll bin: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"type":"module"}`), 0o644); err != nil {
|
|
t.Fatalf("WriteFile package fixture: %v", err)
|
|
}
|
|
wrapperPath := filepath.Join(binDir, "zero.js")
|
|
if err := os.WriteFile(wrapperPath, bytes, 0o755); err != nil {
|
|
t.Fatalf("WriteFile wrapper fixture: %v", err)
|
|
}
|
|
return wrapperPath
|
|
}
|
|
|
|
func requireNode(t *testing.T) string {
|
|
t.Helper()
|
|
node, err := exec.LookPath("node")
|
|
if err != nil {
|
|
t.Skip("node is not available")
|
|
}
|
|
return node
|
|
}
|
|
|
|
func nodeWrapperCommand(ctx context.Context, node string, wrapperPath string, args ...string) *exec.Cmd {
|
|
commandArgs := append([]string{wrapperPath}, args...)
|
|
command := exec.CommandContext(ctx, node, commandArgs...)
|
|
// Keep wrapper behavior independent from developer or runner Node settings
|
|
// such as --inspect-brk, which would block this smoke test.
|
|
command.Env = append(os.Environ(), "NODE_OPTIONS=")
|
|
return command
|
|
}
|
|
|
|
func nodeWrapperTimeout() time.Duration {
|
|
if runtime.GOOS == "windows" {
|
|
// Windows CI runners cold-start node slowly under load; 30s intermittently
|
|
// timed out with empty output (a flake, not a wrapper bug — the same run
|
|
// passes in ~2.5s on a warm runner), so give the spawn ample headroom.
|
|
return 90 * time.Second
|
|
}
|
|
return 10 * time.Second
|
|
}
|
|
|
|
func repoRoot(t *testing.T) string {
|
|
t.Helper()
|
|
_, file, _, ok := runtime.Caller(0)
|
|
if !ok {
|
|
t.Fatal("runtime.Caller failed")
|
|
}
|
|
return filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
|
|
}
|