From 2d5ceca1dd653e60858260d21ea16d949ba00a49 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 30 Sep 2026 17:16:47 +0200 Subject: [PATCH 1/9] Support multiple package managers in apps validation --- .../cli/apps-validate-package-managers.md | 1 + acceptance/apps/validate/app/package.json | 1 + acceptance/apps/validate/bin/pnpm | 7 + acceptance/apps/validate/bin/pnpm.cmd | 7 + .../apps/validate/conflicting/package.json | 1 + .../apps/validate/conflicting/pnpm-lock.yaml | 1 + .../apps/validate/conflicting/yarn.lock | 1 + acceptance/apps/validate/databricks.yml | 2 + acceptance/apps/validate/invalid/package.json | 1 + acceptance/apps/validate/out.test.toml | 2 + acceptance/apps/validate/output.txt | 80 ++++++++++ acceptance/apps/validate/package.json | 6 + acceptance/apps/validate/pnpm-lock.yaml | 1 + acceptance/apps/validate/script | 22 +++ acceptance/apps/validate/test.toml | 7 + cmd/apps/validate.go | 13 +- libs/apps/validation/nodejs.go | 51 ++++-- libs/apps/validation/nodejs_test.go | 150 ++++++++++++++++++ libs/apps/validation/package_manager.go | 67 ++++++++ libs/apps/validation/package_manager_test.go | 74 +++++++++ libs/apps/validation/testdata/package-manager | 7 + .../validation/testdata/package-manager.cmd | 7 + 22 files changed, 497 insertions(+), 12 deletions(-) create mode 100644 .nextchanges/cli/apps-validate-package-managers.md create mode 100644 acceptance/apps/validate/app/package.json create mode 100755 acceptance/apps/validate/bin/pnpm create mode 100644 acceptance/apps/validate/bin/pnpm.cmd create mode 100644 acceptance/apps/validate/conflicting/package.json create mode 100644 acceptance/apps/validate/conflicting/pnpm-lock.yaml create mode 100644 acceptance/apps/validate/conflicting/yarn.lock create mode 100644 acceptance/apps/validate/databricks.yml create mode 100644 acceptance/apps/validate/invalid/package.json create mode 100644 acceptance/apps/validate/out.test.toml create mode 100644 acceptance/apps/validate/output.txt create mode 100644 acceptance/apps/validate/package.json create mode 100644 acceptance/apps/validate/pnpm-lock.yaml create mode 100644 acceptance/apps/validate/script create mode 100644 acceptance/apps/validate/test.toml create mode 100644 libs/apps/validation/nodejs_test.go create mode 100644 libs/apps/validation/package_manager.go create mode 100644 libs/apps/validation/package_manager_test.go create mode 100644 libs/apps/validation/testdata/package-manager create mode 100644 libs/apps/validation/testdata/package-manager.cmd diff --git a/.nextchanges/cli/apps-validate-package-managers.md b/.nextchanges/cli/apps-validate-package-managers.md new file mode 100644 index 00000000000..2d5c3fb71ad --- /dev/null +++ b/.nextchanges/cli/apps-validate-package-managers.md @@ -0,0 +1 @@ +* Support npm, pnpm, Yarn, and Bun lockfile detection for `apps validate` and project validation during `apps deploy`. diff --git a/acceptance/apps/validate/app/package.json b/acceptance/apps/validate/app/package.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/acceptance/apps/validate/app/package.json @@ -0,0 +1 @@ +{} diff --git a/acceptance/apps/validate/bin/pnpm b/acceptance/apps/validate/bin/pnpm new file mode 100755 index 00000000000..98fc6af1c62 --- /dev/null +++ b/acceptance/apps/validate/bin/pnpm @@ -0,0 +1,7 @@ +#!/bin/sh +printf 'pnpm %s\n' "$*" >> commands.log +if [ "$*" = "${VALIDATION_TEST_FAIL:-}" ]; then + echo "validation stdout" + echo "validation stderr" >&2 + exit 17 +fi diff --git a/acceptance/apps/validate/bin/pnpm.cmd b/acceptance/apps/validate/bin/pnpm.cmd new file mode 100644 index 00000000000..5f95695f2ea --- /dev/null +++ b/acceptance/apps/validate/bin/pnpm.cmd @@ -0,0 +1,7 @@ +@echo off +echo pnpm %*>>commands.log +if "%*"=="%VALIDATION_TEST_FAIL%" ( + echo validation stdout + echo validation stderr 1>&2 + exit /b 17 +) diff --git a/acceptance/apps/validate/conflicting/package.json b/acceptance/apps/validate/conflicting/package.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/acceptance/apps/validate/conflicting/package.json @@ -0,0 +1 @@ +{} diff --git a/acceptance/apps/validate/conflicting/pnpm-lock.yaml b/acceptance/apps/validate/conflicting/pnpm-lock.yaml new file mode 100644 index 00000000000..b0a073a9c13 --- /dev/null +++ b/acceptance/apps/validate/conflicting/pnpm-lock.yaml @@ -0,0 +1 @@ +lockfileVersion: '9.0' diff --git a/acceptance/apps/validate/conflicting/yarn.lock b/acceptance/apps/validate/conflicting/yarn.lock new file mode 100644 index 00000000000..ef452491215 --- /dev/null +++ b/acceptance/apps/validate/conflicting/yarn.lock @@ -0,0 +1 @@ +# yarn lockfile v1 diff --git a/acceptance/apps/validate/databricks.yml b/acceptance/apps/validate/databricks.yml new file mode 100644 index 00000000000..3b058245ebe --- /dev/null +++ b/acceptance/apps/validate/databricks.yml @@ -0,0 +1,2 @@ +bundle: + name: validate-app diff --git a/acceptance/apps/validate/invalid/package.json b/acceptance/apps/validate/invalid/package.json new file mode 100644 index 00000000000..98232c64fce --- /dev/null +++ b/acceptance/apps/validate/invalid/package.json @@ -0,0 +1 @@ +{ diff --git a/acceptance/apps/validate/out.test.toml b/acceptance/apps/validate/out.test.toml new file mode 100644 index 00000000000..0938e678987 --- /dev/null +++ b/acceptance/apps/validate/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/apps/validate/output.txt b/acceptance/apps/validate/output.txt new file mode 100644 index 00000000000..3e57d3819b4 --- /dev/null +++ b/acceptance/apps/validate/output.txt @@ -0,0 +1,80 @@ + +=== Validate the directory selected by --path +>>> withdir app [CLI] apps validate --path .. +Validating project using pnpm... +✅ Installing dependencies ([DURATION]) +⏭️ Skipped Generating types (script "typegen" not found) +⏭️ Skipped Running AST-grep lint (script "lint:ast-grep" not found) +⏭️ Skipped Type checking (script "typecheck" not found) +✅ Building ([DURATION]) +✅ Running tests ([DURATION]) +✅ All validation checks passed ([DURATION]) + +=== Skip tests +>>> [CLI] apps validate --skip-tests +Validating project using pnpm... +✅ Installing dependencies ([DURATION]) +⏭️ Skipped Generating types (script "typegen" not found) +⏭️ Skipped Running AST-grep lint (script "lint:ast-grep" not found) +⏭️ Skipped Type checking (script "typecheck" not found) +✅ Building ([DURATION]) +⏭️ Skipped Running tests +✅ All validation checks passed ([DURATION]) + +=== Report a failed script +>>> VALIDATION_TEST_FAIL=run test musterr [CLI] apps validate +Validating project using pnpm... +✅ Installing dependencies ([DURATION]) +⏭️ Skipped Generating types (script "typegen" not found) +⏭️ Skipped Running AST-grep lint (script "lint:ast-grep" not found) +⏭️ Skipped Type checking (script "typecheck" not found) +✅ Building ([DURATION]) +Error: tests failed (duration: [DURATION]) +❌ Running tests failed ([DURATION]) +validation failed (exit code 17) +Stdout: +validation stdout + +Stderr: +validation stderr + +Error: validation failed + +=== Deploy validates with the same package manager before uploading +>>> VALIDATION_TEST_FAIL=run build musterr [CLI] apps deploy +Validating project using pnpm... +✅ Installing dependencies ([DURATION]) +⏭️ Skipped Generating types (script "typegen" not found) +⏭️ Skipped Running AST-grep lint (script "lint:ast-grep" not found) +⏭️ Skipped Type checking (script "typecheck" not found) +Error: build failed (duration: [DURATION]) +❌ Building failed ([DURATION]) +validation failed (exit code 17) +Stdout: +validation stdout + +Stderr: +validation stderr + +Error: validation failed - fix errors before deploying + +=== Commands executed in the project directory +>>> cat commands.log +pnpm install +pnpm run build +pnpm run test +pnpm install +pnpm run build +pnpm install +pnpm run build +pnpm run test +pnpm install +pnpm run build + +=== Reject conflicting lockfiles +>>> musterr [CLI] apps validate --path conflicting +Error: validation error: conflicting package manager lockfiles: pnpm-lock.yaml, yarn.lock; keep lockfiles for only one package manager + +=== Reject malformed package.json +>>> musterr [CLI] apps validate --path invalid +Error: validation error: failed to parse package.json: unexpected end of JSON input diff --git a/acceptance/apps/validate/package.json b/acceptance/apps/validate/package.json new file mode 100644 index 00000000000..373a51f977f --- /dev/null +++ b/acceptance/apps/validate/package.json @@ -0,0 +1,6 @@ +{ + "scripts": { + "build": "echo build", + "test": "echo test" + } +} diff --git a/acceptance/apps/validate/pnpm-lock.yaml b/acceptance/apps/validate/pnpm-lock.yaml new file mode 100644 index 00000000000..b0a073a9c13 --- /dev/null +++ b/acceptance/apps/validate/pnpm-lock.yaml @@ -0,0 +1 @@ +lockfileVersion: '9.0' diff --git a/acceptance/apps/validate/script b/acceptance/apps/validate/script new file mode 100644 index 00000000000..00b8de207ed --- /dev/null +++ b/acceptance/apps/validate/script @@ -0,0 +1,22 @@ +export PATH="$PWD/bin:$PATH" + +title "Validate the directory selected by --path" +trace withdir app $CLI apps validate --path .. + +title "Skip tests" +trace $CLI apps validate --skip-tests + +title "Report a failed script" +trace VALIDATION_TEST_FAIL="run test" musterr $CLI apps validate + +title "Deploy validates with the same package manager before uploading" +trace VALIDATION_TEST_FAIL="run build" musterr $CLI apps deploy + +title "Commands executed in the project directory" +trace cat commands.log + +title "Reject conflicting lockfiles" +trace musterr $CLI apps validate --path conflicting + +title "Reject malformed package.json" +trace musterr $CLI apps validate --path invalid diff --git a/acceptance/apps/validate/test.toml b/acceptance/apps/validate/test.toml new file mode 100644 index 00000000000..aef8c31494f --- /dev/null +++ b/acceptance/apps/validate/test.toml @@ -0,0 +1,7 @@ +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +Env.VALIDATION_TEST_FAIL = "" +Ignore = ["commands.log"] + +[[Repls]] +Old = '\d+\.\d+s' +New = '[DURATION]' diff --git a/cmd/apps/validate.go b/cmd/apps/validate.go index 34340192798..81933f77929 100644 --- a/cmd/apps/validate.go +++ b/cmd/apps/validate.go @@ -17,7 +17,18 @@ func newValidateCmd() *cobra.Command { Long: `Validate a Databricks App project by running build, typecheck, and lint checks. This command detects the project type and runs the appropriate validation: -- Node.js projects (package.json): runs npm install, build, typecheck, lint, and tests +- Node.js projects (package.json): installs dependencies and runs typegen, + lint:ast-grep, typecheck, build, and test scripts when present + +The package manager is detected from lockfiles in the project directory: +- package-lock.json or npm-shrinkwrap.json: npm +- pnpm-lock.yaml: pnpm +- yarn.lock: Yarn +- bun.lock or bun.lockb: Bun + +Projects without a lockfile use npm. Lockfiles for different package managers +must be resolved before validation. The selected package manager must be on PATH. +Dependencies are installed when node_modules is absent. Examples: # Validate the current directory diff --git a/libs/apps/validation/nodejs.go b/libs/apps/validation/nodejs.go index 3bd25bd5612..329bab07c97 100644 --- a/libs/apps/validation/nodejs.go +++ b/libs/apps/validation/nodejs.go @@ -2,8 +2,10 @@ package validation import ( "context" + "encoding/json" "fmt" "os" + osexec "os/exec" "path/filepath" "time" @@ -17,7 +19,7 @@ type ValidationNodeJs struct{} type validationStep struct { name string - command string + script string errorPrefix string displayName string skipIf func(workDir string, opts ValidateOptions) bool // Optional: skip step if this returns true @@ -27,44 +29,62 @@ func (v *ValidationNodeJs) Validate(ctx context.Context, workDir string, opts Va log.Infof(ctx, "Starting Node.js validation: build + typecheck") startTime := time.Now() - cmdio.LogString(ctx, "Validating project...") + packageJSON, err := os.ReadFile(filepath.Join(workDir, "package.json")) + if err != nil { + return nil, fmt.Errorf("failed to read package.json: %w", err) + } + var project struct { + Scripts map[string]string `json:"scripts"` + } + if err := json.Unmarshal(packageJSON, &project); err != nil { + return nil, fmt.Errorf("failed to parse package.json: %w", err) + } + + manager, err := DetectPackageManager(workDir) + if err != nil { + return nil, err + } + if _, err := osexec.LookPath(manager); err != nil { + return nil, fmt.Errorf("cannot run %s; install it and ensure it is on PATH: %w", manager, err) + } + + cmdio.LogString(ctx, "Validating project using "+manager+"...") // TODO: these steps could be changed to npx appkit [command] instead if we can determine its an appkit project. steps := []validationStep{ { name: "install", - command: "npm install", errorPrefix: "Failed to install dependencies", displayName: "Installing dependencies", skipIf: func(workDir string, _ ValidateOptions) bool { return hasNodeModules(workDir) }, }, { name: "generate", - command: "npm run typegen --if-present", - errorPrefix: "Failed to run npm typegen", + script: "typegen", + errorPrefix: "Failed to generate types", displayName: "Generating types", }, { name: "ast-grep-lint", - command: "npm run lint:ast-grep --if-present", + script: "lint:ast-grep", errorPrefix: "AST-grep lint found violations", displayName: "Running AST-grep lint", }, { name: "typecheck", - command: "npm run typecheck --if-present", + script: "typecheck", errorPrefix: "Failed to run client typecheck", displayName: "Type checking", }, { name: "build", - command: "npm run build --if-present", - errorPrefix: "Failed to run npm build", + script: "build", + errorPrefix: "Failed to build", displayName: "Building", }, { name: "tests", - command: "npm run test --if-present", + script: "test", errorPrefix: "Failed to run tests", displayName: "Running tests", skipIf: func(_ string, opts ValidateOptions) bool { return opts.SkipTests }, @@ -78,6 +98,15 @@ func (v *ValidationNodeJs) Validate(ctx context.Context, workDir string, opts Va cmdio.LogString(ctx, "⏭️ Skipped "+step.displayName) continue } + if _, ok := project.Scripts[step.script]; step.script != "" && !ok { + cmdio.LogString(ctx, fmt.Sprintf("⏭️ Skipped %s (script %q not found)", step.displayName, step.script)) + continue + } + + command := manager + " install" + if step.script != "" { + command = manager + " run " + step.script + } log.Debugf(ctx, "running %s...", step.name) @@ -88,7 +117,7 @@ func (v *ValidationNodeJs) Validate(ctx context.Context, workDir string, opts Va spinner := cmdio.NewSpinner(ctx) spinner.Update(step.displayName + "...") - stepErr = runValidationCommand(ctx, workDir, step.command) + stepErr = runValidationCommand(ctx, workDir, command) spinner.Close() stepDuration := time.Since(stepStart) diff --git a/libs/apps/validation/nodejs_test.go b/libs/apps/validation/nodejs_test.go new file mode 100644 index 00000000000..a55aa5df3bd --- /dev/null +++ b/libs/apps/validation/nodejs_test.go @@ -0,0 +1,150 @@ +package validation_test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/databricks/cli/internal/testutil" + "github.com/databricks/cli/libs/apps/validation" + "github.com/databricks/cli/libs/cmdio" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var validationScripts = []string{"typegen", "lint:ast-grep", "typecheck", "build", "test"} + +// stubPackageManagers records commands without installing packages or requiring Node.js. +func stubPackageManagers(t *testing.T) { + t.Helper() + binDir := t.TempDir() + for _, manager := range []string{"npm", "pnpm", "yarn", "bun"} { + for _, suffix := range []string{"", ".cmd"} { + stub := testutil.ReadFile(t, "testdata/package-manager"+suffix) + require.NoError(t, os.WriteFile(filepath.Join(binDir, manager+suffix), []byte(stub), 0o755)) + } + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("VALIDATION_TEST_FAIL", "") +} + +// writeValidationProject creates a manifest with the requested scripts. +func writeValidationProject(t *testing.T, dir string, scripts []string) { + t.Helper() + scriptMap := make(map[string]string, len(scripts)) + for _, script := range scripts { + scriptMap[script] = "echo " + script + } + data, err := json.Marshal(map[string]any{"scripts": scriptMap}) + require.NoError(t, err) + testutil.WriteFile(t, filepath.Join(dir, "package.json"), string(data)) +} + +func TestNodeJsValidatePackageManagers(t *testing.T) { + stubPackageManagers(t) + tests := []struct { + manager string + lockfile string + }{ + {manager: "npm"}, + {manager: "npm", lockfile: "package-lock.json"}, + {manager: "pnpm", lockfile: "pnpm-lock.yaml"}, + {manager: "yarn", lockfile: "yarn.lock"}, + {manager: "bun", lockfile: "bun.lock"}, + } + for _, tt := range tests { + t.Run(tt.manager+"/"+tt.lockfile, func(t *testing.T) { + dir := t.TempDir() + writeValidationProject(t, dir, validationScripts) + if tt.lockfile != "" { + testutil.Touch(t, dir, tt.lockfile) + } + validator := validation.ValidationNodeJs{} + result, err := validator.Validate(cmdio.MockDiscard(t.Context()), dir, validation.ValidateOptions{}) + require.NoError(t, err) + require.True(t, result.Success, "%+v", result.Details) + wantCommands := []string{tt.manager + " install"} + for _, script := range validationScripts { + wantCommands = append(wantCommands, tt.manager+" run "+script) + } + commands := testutil.ReadFile(t, filepath.Join(dir, "commands.log")) + assert.Equal(t, strings.Join(wantCommands, "\n")+"\n", strings.ReplaceAll(commands, "\r\n", "\n")) + }) + } +} + +func TestNodeJsValidateSkips(t *testing.T) { + stubPackageManagers(t) + tests := []struct { + name string + scripts []string + nodeModules bool + skipTests bool + want string + }{ + {name: "missing scripts", want: "pnpm install\n"}, + {name: "optional scripts", scripts: []string{"build"}, want: "pnpm install\npnpm run build\n"}, + {name: "installed dependencies", scripts: []string{"build"}, nodeModules: true, want: "pnpm run build\n"}, + {name: "skip tests", scripts: []string{"build", "test"}, skipTests: true, want: "pnpm install\npnpm run build\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + writeValidationProject(t, dir, tt.scripts) + testutil.Touch(t, dir, "pnpm-lock.yaml") + if tt.nodeModules { + require.NoError(t, os.Mkdir(filepath.Join(dir, "node_modules"), 0o755)) + } + validator := validation.ValidationNodeJs{} + result, err := validator.Validate(cmdio.MockDiscard(t.Context()), dir, validation.ValidateOptions{SkipTests: tt.skipTests}) + require.NoError(t, err) + require.True(t, result.Success, "%+v", result.Details) + commands := testutil.ReadFile(t, filepath.Join(dir, "commands.log")) + assert.Equal(t, tt.want, strings.ReplaceAll(commands, "\r\n", "\n")) + }) + } +} + +func TestNodeJsValidateCommandFailure(t *testing.T) { + stubPackageManagers(t) + for _, command := range []string{"install", "run typecheck", "run test"} { + t.Run(command, func(t *testing.T) { + dir := t.TempDir() + writeValidationProject(t, dir, validationScripts) + testutil.Touch(t, dir, "yarn.lock") + t.Setenv("VALIDATION_TEST_FAIL", command) + validator := validation.ValidationNodeJs{} + result, err := validator.Validate(cmdio.MockDiscard(t.Context()), dir, validation.ValidateOptions{}) + require.NoError(t, err) + require.False(t, result.Success) + require.NotNil(t, result.Details) + assert.Equal(t, 17, result.Details.ExitCode) + assert.Contains(t, result.Details.Stdout, "validation stdout") + assert.Contains(t, result.Details.Stderr, "validation stderr") + commands := testutil.ReadFile(t, filepath.Join(dir, "commands.log")) + assert.True(t, strings.HasSuffix(strings.ReplaceAll(commands, "\r\n", "\n"), "yarn "+command+"\n"), commands) + }) + } +} + +func TestNodeJsValidateInvalidPackageJSON(t *testing.T) { + dir := t.TempDir() + testutil.WriteFile(t, filepath.Join(dir, "package.json"), "{") + validator := validation.ValidationNodeJs{} + _, err := validator.Validate(t.Context(), dir, validation.ValidateOptions{}) + require.ErrorContains(t, err, "failed to parse package.json") + assert.ErrorAs(t, err, new(*json.SyntaxError)) +} + +func TestNodeJsValidateMissingPackageManager(t *testing.T) { + dir := t.TempDir() + writeValidationProject(t, dir, validationScripts) + testutil.Touch(t, dir, "pnpm-lock.yaml") + t.Setenv("PATH", t.TempDir()) + validator := validation.ValidationNodeJs{} + _, err := validator.Validate(t.Context(), dir, validation.ValidateOptions{}) + require.ErrorContains(t, err, "cannot run pnpm; install it and ensure it is on PATH") + assert.NoFileExists(t, filepath.Join(dir, "commands.log")) +} diff --git a/libs/apps/validation/package_manager.go b/libs/apps/validation/package_manager.go new file mode 100644 index 00000000000..996e894795d --- /dev/null +++ b/libs/apps/validation/package_manager.go @@ -0,0 +1,67 @@ +package validation + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + packageManagerNpm = "npm" + packageManagerPnpm = "pnpm" + packageManagerYarn = "yarn" + packageManagerBun = "bun" +) + +// DetectPackageManager selects a package manager from lockfiles in workDir. +// Projects without lockfiles use npm for compatibility with existing validation. +func DetectPackageManager(workDir string) (string, error) { + lockfiles := []struct { + name string + manager string + }{ + // https://docs.npmjs.com/cli/configuring-npm/package-lock-json + // https://docs.npmjs.com/cli/configuring-npm/npm-shrinkwrap-json + {"package-lock.json", packageManagerNpm}, + {"npm-shrinkwrap.json", packageManagerNpm}, + // https://pnpm.io/git + {"pnpm-lock.yaml", packageManagerPnpm}, + // https://classic.yarnpkg.com/lang/en/docs/yarn-lock/ + {"yarn.lock", packageManagerYarn}, + // https://bun.sh/docs/install/lockfile + {"bun.lock", packageManagerBun}, + {"bun.lockb", packageManagerBun}, + } + + var manager string + var found []string + var conflict bool + for _, lockfile := range lockfiles { + info, err := os.Stat(filepath.Join(workDir, lockfile.name)) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return "", fmt.Errorf("failed to inspect %s: %w", lockfile.name, err) + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("lockfile %s must be a regular file", lockfile.name) + } + + found = append(found, lockfile.name) + if manager != "" && manager != lockfile.manager { + conflict = true + } + manager = lockfile.manager + } + + if conflict { + return "", fmt.Errorf("conflicting package manager lockfiles: %s; keep lockfiles for only one package manager", strings.Join(found, ", ")) + } + if manager == "" { + return packageManagerNpm, nil + } + return manager, nil +} diff --git a/libs/apps/validation/package_manager_test.go b/libs/apps/validation/package_manager_test.go new file mode 100644 index 00000000000..fa9b32cbec5 --- /dev/null +++ b/libs/apps/validation/package_manager_test.go @@ -0,0 +1,74 @@ +package validation_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/databricks/cli/internal/testutil" + "github.com/databricks/cli/libs/apps/validation" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDetectPackageManager(t *testing.T) { + tests := []struct { + name string + lockfiles []string + want string + wantError string + }{ + {name: "no lockfile", want: "npm"}, + {name: "npm", lockfiles: []string{"package-lock.json"}, want: "npm"}, + {name: "npm shrinkwrap", lockfiles: []string{"npm-shrinkwrap.json"}, want: "npm"}, + {name: "pnpm", lockfiles: []string{"pnpm-lock.yaml"}, want: "pnpm"}, + {name: "yarn", lockfiles: []string{"yarn.lock"}, want: "yarn"}, + {name: "bun", lockfiles: []string{"bun.lock"}, want: "bun"}, + {name: "bun binary", lockfiles: []string{"bun.lockb"}, want: "bun"}, + {name: "npm aliases", lockfiles: []string{"package-lock.json", "npm-shrinkwrap.json"}, want: "npm"}, + {name: "bun aliases", lockfiles: []string{"bun.lock", "bun.lockb"}, want: "bun"}, + { + name: "conflicting managers", + lockfiles: []string{"yarn.lock", "package-lock.json"}, + wantError: "conflicting package manager lockfiles: package-lock.json, yarn.lock; keep lockfiles for only one package manager", + }, + { + name: "conflict includes all lockfiles", + lockfiles: []string{"bun.lockb", "bun.lock", "pnpm-lock.yaml", "package-lock.json", "npm-shrinkwrap.json"}, + wantError: "conflicting package manager lockfiles: package-lock.json, npm-shrinkwrap.json, pnpm-lock.yaml, bun.lock, bun.lockb; keep lockfiles for only one package manager", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + for _, lockfile := range tt.lockfiles { + testutil.Touch(t, dir, lockfile) + } + manager, err := validation.DetectPackageManager(dir) + if tt.wantError != "" { + require.EqualError(t, err, tt.wantError) + assert.Empty(t, manager) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, manager) + }) + } +} + +func TestDetectPackageManagerInvalidLockfile(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "pnpm-lock.yaml"), 0o755)) + _, err := validation.DetectPackageManager(dir) + require.EqualError(t, err, "lockfile pnpm-lock.yaml must be a regular file") +} + +func TestDetectPackageManagerProjectDirectory(t *testing.T) { + dir := t.TempDir() + testutil.Touch(t, dir, "yarn.lock") + projectDir := filepath.Join(dir, "app") + testutil.Touch(t, projectDir, "package-lock.json") + manager, err := validation.DetectPackageManager(projectDir) + require.NoError(t, err) + assert.Equal(t, "npm", manager) +} diff --git a/libs/apps/validation/testdata/package-manager b/libs/apps/validation/testdata/package-manager new file mode 100644 index 00000000000..d452c009e29 --- /dev/null +++ b/libs/apps/validation/testdata/package-manager @@ -0,0 +1,7 @@ +#!/bin/sh +printf '%s %s\n' "${0##*/}" "$*" >> commands.log +if [ "$*" = "${VALIDATION_TEST_FAIL:-}" ]; then + echo "validation stdout" + echo "validation stderr" >&2 + exit 17 +fi diff --git a/libs/apps/validation/testdata/package-manager.cmd b/libs/apps/validation/testdata/package-manager.cmd new file mode 100644 index 00000000000..1e7de72f5dd --- /dev/null +++ b/libs/apps/validation/testdata/package-manager.cmd @@ -0,0 +1,7 @@ +@echo off +echo %~n0 %*>>commands.log +if "%*"=="%VALIDATION_TEST_FAIL%" ( + echo validation stdout + echo validation stderr 1>&2 + exit /b 17 +) From be8d4c1e49d202a5c89d07b0a8eb54179945a46a Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 30 Sep 2026 17:18:13 +0200 Subject: [PATCH 2/9] Link apps validation changelog to PR 6892 --- .nextchanges/cli/apps-validate-package-managers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.nextchanges/cli/apps-validate-package-managers.md b/.nextchanges/cli/apps-validate-package-managers.md index 2d5c3fb71ad..4ecb306d909 100644 --- a/.nextchanges/cli/apps-validate-package-managers.md +++ b/.nextchanges/cli/apps-validate-package-managers.md @@ -1 +1 @@ -* Support npm, pnpm, Yarn, and Bun lockfile detection for `apps validate` and project validation during `apps deploy`. +* Support npm, pnpm, Yarn, and Bun lockfile detection for `apps validate` and project validation during `apps deploy`. ([#6892](https://github.com/databricks/cli/pull/6892)) From 0b0c70becf1f30d94c6e2772235595613036c254 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 30 Sep 2026 17:31:13 +0200 Subject: [PATCH 3/9] Limit apps validation to npm and pnpm --- .../cli/apps-validate-package-managers.md | 2 +- .../validate/conflicting/package-lock.json | 1 + acceptance/apps/validate/output.txt | 6 +++- acceptance/apps/validate/script | 3 ++ .../apps/validate/unsupported/package.json | 1 + .../{conflicting => unsupported}/yarn.lock | 0 cmd/apps/validate.go | 5 ++- libs/apps/validation/nodejs_test.go | 8 ++--- libs/apps/validation/package_manager.go | 3 ++ libs/apps/validation/package_manager_test.go | 34 ++++++++++++++----- 10 files changed, 44 insertions(+), 19 deletions(-) create mode 100644 acceptance/apps/validate/conflicting/package-lock.json create mode 100644 acceptance/apps/validate/unsupported/package.json rename acceptance/apps/validate/{conflicting => unsupported}/yarn.lock (100%) diff --git a/.nextchanges/cli/apps-validate-package-managers.md b/.nextchanges/cli/apps-validate-package-managers.md index 4ecb306d909..84b9c22936b 100644 --- a/.nextchanges/cli/apps-validate-package-managers.md +++ b/.nextchanges/cli/apps-validate-package-managers.md @@ -1 +1 @@ -* Support npm, pnpm, Yarn, and Bun lockfile detection for `apps validate` and project validation during `apps deploy`. ([#6892](https://github.com/databricks/cli/pull/6892)) +* Support npm and pnpm lockfile detection for `apps validate` and project validation during `apps deploy`. ([#6892](https://github.com/databricks/cli/pull/6892)) diff --git a/acceptance/apps/validate/conflicting/package-lock.json b/acceptance/apps/validate/conflicting/package-lock.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/acceptance/apps/validate/conflicting/package-lock.json @@ -0,0 +1 @@ +{} diff --git a/acceptance/apps/validate/output.txt b/acceptance/apps/validate/output.txt index 3e57d3819b4..efbc1e058d6 100644 --- a/acceptance/apps/validate/output.txt +++ b/acceptance/apps/validate/output.txt @@ -73,7 +73,11 @@ pnpm run build === Reject conflicting lockfiles >>> musterr [CLI] apps validate --path conflicting -Error: validation error: conflicting package manager lockfiles: pnpm-lock.yaml, yarn.lock; keep lockfiles for only one package manager +Error: validation error: conflicting package manager lockfiles: package-lock.json, pnpm-lock.yaml; keep lockfiles for only one package manager + +=== Reject unsupported package managers +>>> musterr [CLI] apps validate --path unsupported +Error: validation error: yarn is not supported for apps validation (found yarn.lock); use npm or pnpm === Reject malformed package.json >>> musterr [CLI] apps validate --path invalid diff --git a/acceptance/apps/validate/script b/acceptance/apps/validate/script index 00b8de207ed..06a4700013a 100644 --- a/acceptance/apps/validate/script +++ b/acceptance/apps/validate/script @@ -18,5 +18,8 @@ trace cat commands.log title "Reject conflicting lockfiles" trace musterr $CLI apps validate --path conflicting +title "Reject unsupported package managers" +trace musterr $CLI apps validate --path unsupported + title "Reject malformed package.json" trace musterr $CLI apps validate --path invalid diff --git a/acceptance/apps/validate/unsupported/package.json b/acceptance/apps/validate/unsupported/package.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/acceptance/apps/validate/unsupported/package.json @@ -0,0 +1 @@ +{} diff --git a/acceptance/apps/validate/conflicting/yarn.lock b/acceptance/apps/validate/unsupported/yarn.lock similarity index 100% rename from acceptance/apps/validate/conflicting/yarn.lock rename to acceptance/apps/validate/unsupported/yarn.lock diff --git a/cmd/apps/validate.go b/cmd/apps/validate.go index 81933f77929..f4b4fcb584e 100644 --- a/cmd/apps/validate.go +++ b/cmd/apps/validate.go @@ -23,10 +23,9 @@ This command detects the project type and runs the appropriate validation: The package manager is detected from lockfiles in the project directory: - package-lock.json or npm-shrinkwrap.json: npm - pnpm-lock.yaml: pnpm -- yarn.lock: Yarn -- bun.lock or bun.lockb: Bun -Projects without a lockfile use npm. Lockfiles for different package managers +Only npm and pnpm are supported. Yarn and Bun lockfiles are rejected. +Projects without a lockfile use npm. Lockfiles for both npm and pnpm must be resolved before validation. The selected package manager must be on PATH. Dependencies are installed when node_modules is absent. diff --git a/libs/apps/validation/nodejs_test.go b/libs/apps/validation/nodejs_test.go index a55aa5df3bd..a47a3108191 100644 --- a/libs/apps/validation/nodejs_test.go +++ b/libs/apps/validation/nodejs_test.go @@ -20,7 +20,7 @@ var validationScripts = []string{"typegen", "lint:ast-grep", "typecheck", "build func stubPackageManagers(t *testing.T) { t.Helper() binDir := t.TempDir() - for _, manager := range []string{"npm", "pnpm", "yarn", "bun"} { + for _, manager := range []string{"npm", "pnpm"} { for _, suffix := range []string{"", ".cmd"} { stub := testutil.ReadFile(t, "testdata/package-manager"+suffix) require.NoError(t, os.WriteFile(filepath.Join(binDir, manager+suffix), []byte(stub), 0o755)) @@ -51,8 +51,6 @@ func TestNodeJsValidatePackageManagers(t *testing.T) { {manager: "npm"}, {manager: "npm", lockfile: "package-lock.json"}, {manager: "pnpm", lockfile: "pnpm-lock.yaml"}, - {manager: "yarn", lockfile: "yarn.lock"}, - {manager: "bun", lockfile: "bun.lock"}, } for _, tt := range tests { t.Run(tt.manager+"/"+tt.lockfile, func(t *testing.T) { @@ -113,7 +111,7 @@ func TestNodeJsValidateCommandFailure(t *testing.T) { t.Run(command, func(t *testing.T) { dir := t.TempDir() writeValidationProject(t, dir, validationScripts) - testutil.Touch(t, dir, "yarn.lock") + testutil.Touch(t, dir, "pnpm-lock.yaml") t.Setenv("VALIDATION_TEST_FAIL", command) validator := validation.ValidationNodeJs{} result, err := validator.Validate(cmdio.MockDiscard(t.Context()), dir, validation.ValidateOptions{}) @@ -124,7 +122,7 @@ func TestNodeJsValidateCommandFailure(t *testing.T) { assert.Contains(t, result.Details.Stdout, "validation stdout") assert.Contains(t, result.Details.Stderr, "validation stderr") commands := testutil.ReadFile(t, filepath.Join(dir, "commands.log")) - assert.True(t, strings.HasSuffix(strings.ReplaceAll(commands, "\r\n", "\n"), "yarn "+command+"\n"), commands) + assert.True(t, strings.HasSuffix(strings.ReplaceAll(commands, "\r\n", "\n"), "pnpm "+command+"\n"), commands) }) } } diff --git a/libs/apps/validation/package_manager.go b/libs/apps/validation/package_manager.go index 996e894795d..c071a6994c2 100644 --- a/libs/apps/validation/package_manager.go +++ b/libs/apps/validation/package_manager.go @@ -49,6 +49,9 @@ func DetectPackageManager(workDir string) (string, error) { if !info.Mode().IsRegular() { return "", fmt.Errorf("lockfile %s must be a regular file", lockfile.name) } + if lockfile.manager != packageManagerNpm && lockfile.manager != packageManagerPnpm { + return "", fmt.Errorf("%s is not supported for apps validation (found %s); use npm or pnpm", lockfile.manager, lockfile.name) + } found = append(found, lockfile.name) if manager != "" && manager != lockfile.manager { diff --git a/libs/apps/validation/package_manager_test.go b/libs/apps/validation/package_manager_test.go index fa9b32cbec5..2689c70fce9 100644 --- a/libs/apps/validation/package_manager_test.go +++ b/libs/apps/validation/package_manager_test.go @@ -22,20 +22,36 @@ func TestDetectPackageManager(t *testing.T) { {name: "npm", lockfiles: []string{"package-lock.json"}, want: "npm"}, {name: "npm shrinkwrap", lockfiles: []string{"npm-shrinkwrap.json"}, want: "npm"}, {name: "pnpm", lockfiles: []string{"pnpm-lock.yaml"}, want: "pnpm"}, - {name: "yarn", lockfiles: []string{"yarn.lock"}, want: "yarn"}, - {name: "bun", lockfiles: []string{"bun.lock"}, want: "bun"}, - {name: "bun binary", lockfiles: []string{"bun.lockb"}, want: "bun"}, {name: "npm aliases", lockfiles: []string{"package-lock.json", "npm-shrinkwrap.json"}, want: "npm"}, - {name: "bun aliases", lockfiles: []string{"bun.lock", "bun.lockb"}, want: "bun"}, + { + name: "unsupported yarn", + lockfiles: []string{"yarn.lock"}, + wantError: "yarn is not supported for apps validation (found yarn.lock); use npm or pnpm", + }, + { + name: "unsupported bun", + lockfiles: []string{"bun.lock"}, + wantError: "bun is not supported for apps validation (found bun.lock); use npm or pnpm", + }, + { + name: "unsupported bun binary", + lockfiles: []string{"bun.lockb"}, + wantError: "bun is not supported for apps validation (found bun.lockb); use npm or pnpm", + }, + { + name: "unsupported manager alongside npm", + lockfiles: []string{"package-lock.json", "yarn.lock"}, + wantError: "yarn is not supported for apps validation (found yarn.lock); use npm or pnpm", + }, { name: "conflicting managers", - lockfiles: []string{"yarn.lock", "package-lock.json"}, - wantError: "conflicting package manager lockfiles: package-lock.json, yarn.lock; keep lockfiles for only one package manager", + lockfiles: []string{"pnpm-lock.yaml", "package-lock.json"}, + wantError: "conflicting package manager lockfiles: package-lock.json, pnpm-lock.yaml; keep lockfiles for only one package manager", }, { name: "conflict includes all lockfiles", - lockfiles: []string{"bun.lockb", "bun.lock", "pnpm-lock.yaml", "package-lock.json", "npm-shrinkwrap.json"}, - wantError: "conflicting package manager lockfiles: package-lock.json, npm-shrinkwrap.json, pnpm-lock.yaml, bun.lock, bun.lockb; keep lockfiles for only one package manager", + lockfiles: []string{"pnpm-lock.yaml", "package-lock.json", "npm-shrinkwrap.json"}, + wantError: "conflicting package manager lockfiles: package-lock.json, npm-shrinkwrap.json, pnpm-lock.yaml; keep lockfiles for only one package manager", }, } for _, tt := range tests { @@ -65,7 +81,7 @@ func TestDetectPackageManagerInvalidLockfile(t *testing.T) { func TestDetectPackageManagerProjectDirectory(t *testing.T) { dir := t.TempDir() - testutil.Touch(t, dir, "yarn.lock") + testutil.Touch(t, dir, "pnpm-lock.yaml") projectDir := filepath.Join(dir, "app") testutil.Touch(t, projectDir, "package-lock.json") manager, err := validation.DetectPackageManager(projectDir) From a0d84f1e1d7f81987142310f074581044102f82f Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 30 Sep 2026 20:21:28 +0200 Subject: [PATCH 4/9] Reload package.json per step and strip UTF-8 BOM in apps validation Validation steps can rewrite package.json (e.g. type generation adding scripts), so reload the manifest before each script lookup instead of reading it once. Strip a leading UTF-8 BOM before decoding to match npm's parser. Co-authored-by: Isaac --- libs/apps/validation/nodejs.go | 42 +++++-- libs/apps/validation/nodejs_test.go | 112 +++++++++++++++++- libs/apps/validation/testdata/package-manager | 3 + .../validation/testdata/package-manager.cmd | 3 + 4 files changed, 147 insertions(+), 13 deletions(-) diff --git a/libs/apps/validation/nodejs.go b/libs/apps/validation/nodejs.go index 329bab07c97..806a3b72c8a 100644 --- a/libs/apps/validation/nodejs.go +++ b/libs/apps/validation/nodejs.go @@ -1,6 +1,7 @@ package validation import ( + "bytes" "context" "encoding/json" "fmt" @@ -29,15 +30,9 @@ func (v *ValidationNodeJs) Validate(ctx context.Context, workDir string, opts Va log.Infof(ctx, "Starting Node.js validation: build + typecheck") startTime := time.Now() - packageJSON, err := os.ReadFile(filepath.Join(workDir, "package.json")) - if err != nil { - return nil, fmt.Errorf("failed to read package.json: %w", err) - } - var project struct { - Scripts map[string]string `json:"scripts"` - } - if err := json.Unmarshal(packageJSON, &project); err != nil { - return nil, fmt.Errorf("failed to parse package.json: %w", err) + // Reject an invalid manifest before running commands that may modify it. + if _, err := readPackageScripts(workDir); err != nil { + return nil, err } manager, err := DetectPackageManager(workDir) @@ -98,9 +93,15 @@ func (v *ValidationNodeJs) Validate(ctx context.Context, workDir string, opts Va cmdio.LogString(ctx, "⏭️ Skipped "+step.displayName) continue } - if _, ok := project.Scripts[step.script]; step.script != "" && !ok { - cmdio.LogString(ctx, fmt.Sprintf("⏭️ Skipped %s (script %q not found)", step.displayName, step.script)) - continue + if step.script != "" { + scripts, err := readPackageScripts(workDir) + if err != nil { + return nil, err + } + if _, ok := scripts[step.script]; !ok { + cmdio.LogString(ctx, fmt.Sprintf("⏭️ Skipped %s (script %q not found)", step.displayName, step.script)) + continue + } } command := manager + " install" @@ -145,6 +146,23 @@ func (v *ValidationNodeJs) Validate(ctx context.Context, workDir string, opts Va }, nil } +// readPackageScripts reloads the manifest because earlier validation steps can change it. +func readPackageScripts(workDir string) (map[string]string, error) { + packageJSON, err := os.ReadFile(filepath.Join(workDir, "package.json")) + if err != nil { + return nil, fmt.Errorf("failed to read package.json: %w", err) + } + // npm strips a UTF-8 BOM before decoding: https://github.com/npm/json-parse-even-better-errors. + packageJSON = bytes.TrimPrefix(packageJSON, []byte("\xef\xbb\xbf")) + var project struct { + Scripts map[string]string `json:"scripts"` + } + if err := json.Unmarshal(packageJSON, &project); err != nil { + return nil, fmt.Errorf("failed to parse package.json: %w", err) + } + return project.Scripts, nil +} + // hasNodeModules returns true if node_modules directory exists in the workDir. func hasNodeModules(workDir string) bool { nodeModules := filepath.Join(workDir, "node_modules") diff --git a/libs/apps/validation/nodejs_test.go b/libs/apps/validation/nodejs_test.go index a47a3108191..f78a36299a0 100644 --- a/libs/apps/validation/nodejs_test.go +++ b/libs/apps/validation/nodejs_test.go @@ -28,6 +28,8 @@ func stubPackageManagers(t *testing.T) { } t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("VALIDATION_TEST_FAIL", "") + t.Setenv("VALIDATION_TEST_UPDATE", "") + t.Setenv("VALIDATION_TEST_PACKAGE_JSON", "") } // writeValidationProject creates a manifest with the requested scripts. @@ -127,13 +129,121 @@ func TestNodeJsValidateCommandFailure(t *testing.T) { } } +func TestNodeJsValidateUpdatedScripts(t *testing.T) { + stubPackageManagers(t) + tests := []struct { + name string + command string + scripts []string + updatedScripts []string + wantCommands string + wantSuccess bool + }{ + { + name: "install adds build", + command: "install", + updatedScripts: []string{"build"}, + wantCommands: "npm install\nnpm run build\n", + }, + { + name: "typegen adds build", + command: "run typegen", + scripts: []string{"typegen"}, + updatedScripts: []string{"typegen", "build"}, + wantCommands: "npm install\nnpm run typegen\nnpm run build\n", + }, + { + name: "install removes build", + command: "install", + scripts: []string{"build"}, + wantCommands: "npm install\n", + wantSuccess: true, + }, + { + name: "typegen removes build", + command: "run typegen", + scripts: []string{"typegen", "build"}, + updatedScripts: []string{"typegen"}, + wantCommands: "npm install\nnpm run typegen\n", + wantSuccess: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + writeValidationProject(t, dir, tt.scripts) + updatedDir := t.TempDir() + writeValidationProject(t, updatedDir, tt.updatedScripts) + t.Setenv("VALIDATION_TEST_UPDATE", tt.command) + t.Setenv("VALIDATION_TEST_PACKAGE_JSON", filepath.Join(updatedDir, "package.json")) + t.Setenv("VALIDATION_TEST_FAIL", "run build") + validator := validation.ValidationNodeJs{} + result, err := validator.Validate(cmdio.MockDiscard(t.Context()), dir, validation.ValidateOptions{}) + require.NoError(t, err) + assert.Equal(t, tt.wantSuccess, result.Success) + if !tt.wantSuccess { + require.NotNil(t, result.Details) + assert.Equal(t, "Failed to build", result.Message) + assert.Equal(t, 17, result.Details.ExitCode) + } + commands := testutil.ReadFile(t, filepath.Join(dir, "commands.log")) + assert.Equal(t, tt.wantCommands, strings.ReplaceAll(commands, "\r\n", "\n")) + }) + } +} + +func TestNodeJsValidatePackageJSONBOM(t *testing.T) { + stubPackageManagers(t) + dir := t.TempDir() + writeValidationProject(t, dir, []string{"build"}) + path := filepath.Join(dir, "package.json") + testutil.WriteFile(t, path, "\xef\xbb\xbf"+testutil.ReadFile(t, path)) + validator := validation.ValidationNodeJs{} + result, err := validator.Validate(cmdio.MockDiscard(t.Context()), dir, validation.ValidateOptions{}) + require.NoError(t, err) + require.True(t, result.Success, "%+v", result.Details) + commands := testutil.ReadFile(t, filepath.Join(dir, "commands.log")) + assert.Equal(t, "npm install\nnpm run build\n", strings.ReplaceAll(commands, "\r\n", "\n")) +} + func TestNodeJsValidateInvalidPackageJSON(t *testing.T) { + stubPackageManagers(t) dir := t.TempDir() testutil.WriteFile(t, filepath.Join(dir, "package.json"), "{") validator := validation.ValidationNodeJs{} - _, err := validator.Validate(t.Context(), dir, validation.ValidateOptions{}) + result, err := validator.Validate(t.Context(), dir, validation.ValidateOptions{}) + require.ErrorContains(t, err, "failed to parse package.json") + assert.ErrorAs(t, err, new(*json.SyntaxError)) + assert.Nil(t, result) + assert.NoFileExists(t, filepath.Join(dir, "commands.log")) +} + +func TestNodeJsValidateMissingPackageJSON(t *testing.T) { + stubPackageManagers(t) + dir := t.TempDir() + validator := validation.ValidationNodeJs{} + result, err := validator.Validate(t.Context(), dir, validation.ValidateOptions{}) + require.ErrorContains(t, err, "failed to read package.json") + assert.ErrorIs(t, err, os.ErrNotExist) + assert.Nil(t, result) + assert.NoFileExists(t, filepath.Join(dir, "commands.log")) +} + +func TestNodeJsValidateUpdatedInvalidPackageJSON(t *testing.T) { + stubPackageManagers(t) + dir := t.TempDir() + writeValidationProject(t, dir, validationScripts) + updatedPath := filepath.Join(t.TempDir(), "package.json") + testutil.WriteFile(t, updatedPath, "{") + t.Setenv("VALIDATION_TEST_UPDATE", "install") + t.Setenv("VALIDATION_TEST_PACKAGE_JSON", updatedPath) + validator := validation.ValidationNodeJs{} + result, err := validator.Validate(cmdio.MockDiscard(t.Context()), dir, validation.ValidateOptions{}) require.ErrorContains(t, err, "failed to parse package.json") assert.ErrorAs(t, err, new(*json.SyntaxError)) + assert.Nil(t, result) + commands := testutil.ReadFile(t, filepath.Join(dir, "commands.log")) + assert.Equal(t, "npm install\n", strings.ReplaceAll(commands, "\r\n", "\n")) } func TestNodeJsValidateMissingPackageManager(t *testing.T) { diff --git a/libs/apps/validation/testdata/package-manager b/libs/apps/validation/testdata/package-manager index d452c009e29..a2946b5256c 100644 --- a/libs/apps/validation/testdata/package-manager +++ b/libs/apps/validation/testdata/package-manager @@ -1,5 +1,8 @@ #!/bin/sh printf '%s %s\n' "${0##*/}" "$*" >> commands.log +if [ "$*" = "${VALIDATION_TEST_UPDATE:-}" ]; then + cp "$VALIDATION_TEST_PACKAGE_JSON" package.json || exit 1 +fi if [ "$*" = "${VALIDATION_TEST_FAIL:-}" ]; then echo "validation stdout" echo "validation stderr" >&2 diff --git a/libs/apps/validation/testdata/package-manager.cmd b/libs/apps/validation/testdata/package-manager.cmd index 1e7de72f5dd..f66f30e03f5 100644 --- a/libs/apps/validation/testdata/package-manager.cmd +++ b/libs/apps/validation/testdata/package-manager.cmd @@ -1,5 +1,8 @@ @echo off echo %~n0 %*>>commands.log +if "%*"=="%VALIDATION_TEST_UPDATE%" ( + copy /y "%VALIDATION_TEST_PACKAGE_JSON%" package.json >nul || exit /b 1 +) if "%*"=="%VALIDATION_TEST_FAIL%" ( echo validation stdout echo validation stderr 1>&2 From 31195fc7899e3146d0aa356fdf7ea942ef0f85fe Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 1 Oct 2026 14:14:48 +0200 Subject: [PATCH 5/9] Delegate script presence to --if-present and soften lockfile detection Apps validation now runs each script step as ` run --if-present