Files
zero/internal/npmwrapper/buildpackages_test.go
Kevin Codex 5e1405d0b7 feat(npm): ship the native binary as platform optionalDependencies (#626)
* feat(npm): ship the native binary as platform optionalDependencies

Replace the postinstall downloader with Codex-style platform packages:
suffixed versions of @gitlawb/zero (X.Y.Z-<platform>-<arch>) carrying the
binary, sandbox helpers, and the vendored agent-browser/tuistory tree,
referenced from the wrapper via npm: aliases in optionalDependencies.

The published wrapper has no lifecycle scripts and no dependencies, so
npm/Bun/pnpm installs are silent: no EBADENGINE from agent-browser's
node>=24 engines pin, no allow-scripts warnings, no network outside the
npm registry. The old downloader remains as a checksum-verified first-run
fallback for --omit=optional installs and unsupported platforms.

scripts/npm/build-platform-packages.mjs assembles the payloads from the
release archives at publish time: it materializes npm's symlink .bin
shims into regular sh shims (npm pack silently drops symlinks) and prunes
agent-browser's other-platform binaries (~65 MB per package). The publish
job uploads platform versions first under --tag platform, asserts the
latest dist-tag survived (platform versions are semver prereleases of the
wrapper version), then publishes the wrapper.

See docs/NPM_PACKAGING.md for the architecture and publishing rules.

* fix(npm): address PR review — LICENSE in payloads, dist-tag lookup guard, accurate fallback and platform docs

- Ship the repo LICENSE in both the wrapper and platform payloads (npm only
  includes a license file physically present in the packed directory).
- Guard the platform-publish dist-tag lookup with || true so a transient
  npm view failure (or a bootstrap publish with no latest tag) cannot abort
  the job between the platform and wrapper publishes.
- Gate the wrapper's fallback downloader on the release matrix so platforms
  with no asset (windows-arm64 and unknown combos) skip the spawn instead of
  attempting it every run; keep deliberate retry-on-failure for supported
  platforms and say so honestly in the notice and docs.
- Correct Windows support wording everywhere: x64 only, ARM runs under
  emulation (README, README_ZH, INSTALL.md incl. the release-target list).
2026-07-11 23:25:41 +08:00

261 lines
10 KiB
Go

package npmwrapper
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// TestBuildPlatformPackagesAssemblesPublishPayloads runs the release-time
// assembly script against a fixture linux-x64 archive shaped like the output
// of `zero-release package` and checks the npm publish payloads it emits:
// the platform payload (suffixed version, os/cpu gate, pack-safe helper
// shims, pruned agent-browser binaries) and the wrapper (no scripts, no
// dependencies, full optionalDependencies alias matrix).
func TestBuildPlatformPackagesAssemblesPublishPayloads(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fixture uses POSIX symlinks and shell scripts")
}
node := requireNode(t)
tar := requireCommand(t, "tar")
version := packageVersion(t)
root := t.TempDir()
staging := filepath.Join(root, "staging")
// Payload the platform package must keep.
writeFixtureFile(t, filepath.Join(staging, "zero"), "#!/usr/bin/env sh\necho fixture-zero\n", 0o755)
writeFixtureFile(t, filepath.Join(staging, "zero-linux-sandbox"), "#!/usr/bin/env sh\n", 0o755)
writeFixtureFile(t, filepath.Join(staging, "zero-seccomp"), "#!/usr/bin/env sh\n", 0o755)
// Wrapper-owned files the platform package must exclude.
writeFixtureFile(t, filepath.Join(staging, "package.json"), `{"name":"@gitlawb/zero"}`, 0o644)
writeFixtureFile(t, filepath.Join(staging, "README.md"), "readme\n", 0o644)
writeFixtureFile(t, filepath.Join(staging, "VERSION"), version+"\n", 0o644)
writeFixtureFile(t, filepath.Join(staging, "bin", "zero.js"), "#!/usr/bin/env node\n", 0o755)
// Vendored helpers tree with npm's symlink .bin shims and agent-browser's
// multi-platform binary set.
agentBrowserDir := filepath.Join(staging, "helpers", "node_modules", "agent-browser")
writeFixtureFile(t, filepath.Join(agentBrowserDir, "package.json"), `{"name":"agent-browser"}`, 0o644)
writeFixtureFile(t, filepath.Join(agentBrowserDir, "bin", "agent-browser.js"), "#!/usr/bin/env node\nconsole.log('fixture agent-browser')\n", 0o644)
for _, name := range []string{
"agent-browser-linux-x64",
"agent-browser-linux-musl-x64",
"agent-browser-linux-arm64",
"agent-browser-darwin-arm64",
"agent-browser-darwin-x64",
"agent-browser-win32-x64.exe",
} {
writeFixtureFile(t, filepath.Join(agentBrowserDir, "bin", name), "native\n", 0o755)
}
tuistoryDir := filepath.Join(staging, "helpers", "node_modules", "tuistory")
writeFixtureFile(t, filepath.Join(tuistoryDir, "dist", "cli.js"), "#!/usr/bin/env node\n", 0o644)
binDir := filepath.Join(staging, "helpers", "node_modules", ".bin")
mustMkdirAllFixture(t, binDir)
if err := os.Symlink("../agent-browser/bin/agent-browser.js", filepath.Join(binDir, "agent-browser")); err != nil {
t.Fatalf("Symlink agent-browser shim: %v", err)
}
if err := os.Symlink("../tuistory/dist/cli.js", filepath.Join(binDir, "tuistory")); err != nil {
t.Fatalf("Symlink tuistory shim: %v", err)
}
// Archive with the payload at the archive root, like zero-release's
// createArchive, plus the sha256 sidecar the script verifies against.
assetName := fmt.Sprintf("zero-v%s-linux-x64.tar.gz", version)
archivePath := filepath.Join(root, assetName)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if output, err := exec.CommandContext(ctx, tar, "-C", staging, "-czf", archivePath, ".").CombinedOutput(); err != nil {
t.Fatalf("tar failed: %v\n%s", err, output)
}
archiveBytes, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("ReadFile archive: %v", err)
}
digest := sha256.Sum256(archiveBytes)
writeFixtureFile(t, archivePath+".sha256", fmt.Sprintf("%x %s\n", digest, assetName), 0o644)
outDir := filepath.Join(root, "out")
script := filepath.Join(repoRoot(t), "scripts", "npm", "build-platform-packages.mjs")
runCtx, runCancel := context.WithTimeout(context.Background(), 60*time.Second)
defer runCancel()
command := exec.CommandContext(runCtx, node, script, "--artifacts-dir", root, "--out-dir", outDir, "--only", "linux-x64")
command.Env = append(os.Environ(), "NODE_OPTIONS=")
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("build-platform-packages failed: %v\n%s", err, output)
}
platformDir := filepath.Join(outDir, "platforms", "zero-linux-x64")
var platformPkg struct {
Name string `json:"name"`
Version string `json:"version"`
OS []string `json:"os"`
CPU []string `json:"cpu"`
Scripts map[string]string
}
unmarshalJSONFile(t, filepath.Join(platformDir, "package.json"), &platformPkg)
if platformPkg.Name != "@gitlawb/zero" {
t.Fatalf("platform package name = %q, want @gitlawb/zero (same-name suffixed versions share one trusted publisher)", platformPkg.Name)
}
if want := version + "-linux-x64"; platformPkg.Version != want {
t.Fatalf("platform package version = %q, want %q", platformPkg.Version, want)
}
if len(platformPkg.OS) != 1 || platformPkg.OS[0] != "linux" || len(platformPkg.CPU) != 1 || platformPkg.CPU[0] != "x64" {
t.Fatalf("platform package os/cpu = %v/%v, want [linux]/[x64]", platformPkg.OS, platformPkg.CPU)
}
if len(platformPkg.Scripts) != 0 {
t.Fatalf("platform package has lifecycle scripts: %v", platformPkg.Scripts)
}
info, err := os.Stat(filepath.Join(platformDir, "zero"))
if err != nil {
t.Fatalf("platform package binary missing: %v", err)
}
if info.Mode()&0o111 == 0 {
t.Fatalf("platform package binary is not executable: %v", info.Mode())
}
for _, excluded := range []string{"README.md", "VERSION", "bin"} {
if _, err := os.Stat(filepath.Join(platformDir, excluded)); !os.IsNotExist(err) {
t.Fatalf("platform package still contains wrapper-owned %q", excluded)
}
}
// npm only ships a license file it finds in the packed directory, so the
// assembly must place the repo LICENSE into every payload.
if _, err := os.Stat(filepath.Join(platformDir, "LICENSE")); err != nil {
t.Fatalf("platform package missing LICENSE: %v", err)
}
// npm pack drops symlinks, so the shim must now be a regular executable file.
shimPath := filepath.Join(platformDir, "helpers", "node_modules", ".bin", "agent-browser")
shimInfo, err := os.Lstat(shimPath)
if err != nil {
t.Fatalf("agent-browser shim missing: %v", err)
}
if shimInfo.Mode()&os.ModeSymlink != 0 {
t.Fatalf("agent-browser shim is still a symlink; npm pack would drop it")
}
if shimInfo.Mode()&0o111 == 0 {
t.Fatalf("agent-browser shim is not executable: %v", shimInfo.Mode())
}
shim, err := os.ReadFile(shimPath)
if err != nil {
t.Fatalf("ReadFile shim: %v", err)
}
if !strings.Contains(string(shim), "../agent-browser/bin/agent-browser.js") {
t.Fatalf("shim does not exec the vendored helper: %q", string(shim))
}
if _, err := os.Stat(filepath.Join(platformDir, "helpers", "node_modules", ".bin", "tuistory")); err != nil {
t.Fatalf("tuistory shim missing: %v", err)
}
// Pruning: only the linux-x64 (glibc + musl) agent-browser binaries survive.
prunedBinDir := filepath.Join(platformDir, "helpers", "node_modules", "agent-browser", "bin")
entries, err := os.ReadDir(prunedBinDir)
if err != nil {
t.Fatalf("ReadDir pruned bin: %v", err)
}
got := []string{}
for _, entry := range entries {
got = append(got, entry.Name())
}
want := map[string]bool{
"agent-browser.js": true,
"agent-browser-linux-x64": true,
"agent-browser-linux-musl-x64": true,
}
if len(got) != len(want) {
t.Fatalf("pruned agent-browser bin = %v, want exactly %v", got, want)
}
for _, name := range got {
if !want[name] {
t.Fatalf("pruning left unexpected %q (full set: %v)", name, got)
}
}
wrapperDir := filepath.Join(outDir, "wrapper")
var wrapperPkg struct {
Name string `json:"name"`
Version string `json:"version"`
Scripts map[string]string `json:"scripts"`
Dependencies map[string]string `json:"dependencies"`
Optional map[string]string `json:"optionalDependencies"`
}
unmarshalJSONFile(t, filepath.Join(wrapperDir, "package.json"), &wrapperPkg)
if wrapperPkg.Version != version {
t.Fatalf("wrapper version = %q, want %q", wrapperPkg.Version, version)
}
if len(wrapperPkg.Scripts) != 0 {
t.Fatalf("published wrapper has lifecycle scripts: %v", wrapperPkg.Scripts)
}
if len(wrapperPkg.Dependencies) != 0 {
t.Fatalf("published wrapper has dependencies: %v (helpers are vendored in the platform payloads)", wrapperPkg.Dependencies)
}
wantAliases := map[string]string{
"@gitlawb/zero-darwin-arm64": "npm:@gitlawb/zero@" + version + "-darwin-arm64",
"@gitlawb/zero-darwin-x64": "npm:@gitlawb/zero@" + version + "-darwin-x64",
"@gitlawb/zero-linux-arm64": "npm:@gitlawb/zero@" + version + "-linux-arm64",
"@gitlawb/zero-linux-x64": "npm:@gitlawb/zero@" + version + "-linux-x64",
"@gitlawb/zero-win32-x64": "npm:@gitlawb/zero@" + version + "-win32-x64",
}
if len(wrapperPkg.Optional) != len(wantAliases) {
t.Fatalf("wrapper optionalDependencies = %v, want %v", wrapperPkg.Optional, wantAliases)
}
for alias, spec := range wantAliases {
if wrapperPkg.Optional[alias] != spec {
t.Fatalf("wrapper optionalDependencies[%q] = %q, want %q", alias, wrapperPkg.Optional[alias], spec)
}
}
for _, file := range []string{"bin/zero.js", "scripts/postinstall.mjs", "README.md", "LICENSE"} {
if _, err := os.Stat(filepath.Join(wrapperDir, filepath.FromSlash(file))); err != nil {
t.Fatalf("wrapper payload missing %s: %v", file, err)
}
}
}
func unmarshalJSONFile(t *testing.T, path string, target any) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile %s: %v", path, err)
}
if err := json.Unmarshal(data, target); err != nil {
t.Fatalf("Unmarshal %s: %v", path, err)
}
}
func writeFixtureFile(t *testing.T, path string, content string, mode os.FileMode) {
t.Helper()
mustMkdirAllFixture(t, filepath.Dir(path))
if err := os.WriteFile(path, []byte(content), mode); err != nil {
t.Fatalf("WriteFile %s: %v", path, err)
}
if mode&0o111 != 0 {
if err := os.Chmod(path, mode); err != nil {
t.Fatalf("Chmod %s: %v", path, err)
}
}
}
func mustMkdirAllFixture(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("MkdirAll %s: %v", path, err)
}
}
func requireCommand(t *testing.T, name string) string {
t.Helper()
path, err := exec.LookPath(name)
if err != nil {
t.Skipf("%s is not available", name)
}
return path
}