From 31595711e4db458709f7329f5dd19468f85a194f Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 15 Sep 2026 12:04:13 +0800 Subject: [PATCH 1/2] ci: stabilize install tests and artifact uploads --- .../upload-artifact-with-retry/action.yml | 37 +++++ .github/scripts/__tests__/retry-install.mjs | 126 ++++++++++++++++++ .github/scripts/retry-install.sh | 51 +++++++ .github/workflows/ci.yml | 104 ++++++++++----- 4 files changed, 287 insertions(+), 31 deletions(-) create mode 100644 .github/actions/upload-artifact-with-retry/action.yml create mode 100644 .github/scripts/__tests__/retry-install.mjs create mode 100644 .github/scripts/retry-install.sh diff --git a/.github/actions/upload-artifact-with-retry/action.yml b/.github/actions/upload-artifact-with-retry/action.yml new file mode 100644 index 0000000000..03d884e7c7 --- /dev/null +++ b/.github/actions/upload-artifact-with-retry/action.yml @@ -0,0 +1,37 @@ +name: Upload artifact with retry +description: Retry an artifact upload once after a transient service failure +inputs: + name: + description: Artifact name + required: true + path: + description: Files to upload + required: true + retention-days: + description: Artifact retention in days + default: '7' +runs: + using: composite + steps: + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + id: upload + continue-on-error: true + with: + name: ${{ inputs.name }} + path: ${{ inputs.path }} + retention-days: ${{ inputs.retention-days }} + if-no-files-found: error + - name: Wait before retrying + if: steps.upload.outcome == 'failure' + shell: bash + run: sleep 5 + # FinalizeArtifact can fail with an intermediary 403 after the upload + # completes. A fresh upload recovers without rebuilding the test archives. + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: steps.upload.outcome == 'failure' + with: + name: ${{ inputs.name }} + path: ${{ inputs.path }} + retention-days: ${{ inputs.retention-days }} + if-no-files-found: error + overwrite: true diff --git a/.github/scripts/__tests__/retry-install.mjs b/.github/scripts/__tests__/retry-install.mjs new file mode 100644 index 0000000000..a6d56a62fb --- /dev/null +++ b/.github/scripts/__tests__/retry-install.mjs @@ -0,0 +1,126 @@ +// Run with node --test; no workspace dependencies are needed. +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const script = fileURLToPath(new URL('../retry-install.sh', import.meta.url)); +const assertion = + 'Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\\win\\async.c, line 76'; + +function run(t, attempts, runnerOS = 'Linux') { + const directory = mkdtempSync(join(tmpdir(), 'ci install retry ')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const child = join(directory, 'install.cjs'); + writeFileSync( + child, + `const fs = require('node:fs'); +const attempts = ${JSON.stringify(attempts)}; +const counter = ${JSON.stringify(join(directory, 'counter'))}; +const index = fs.existsSync(counter) ? Number(fs.readFileSync(counter)) : 0; +fs.writeFileSync(counter, String(index + 1)); +fs.writeFileSync(process.env.SFW_JSON_REPORT_PATH, JSON.stringify({ attempt: index + 1 })); +const attempt = attempts[Math.min(index, attempts.length - 1)]; +console.log(process.argv[2]); +console.error(attempt.output); +process.exit(attempt.status); +`, + ); + const logPrefix = join(directory, 'logs', 'install'); + const result = spawnSync( + 'bash', + [script, logPrefix, process.execPath, child] + .map((path) => path.replaceAll('\\', '/')) + .concat('argument with spaces'), + { + encoding: 'utf8', + env: { ...process.env, RUNNER_OS: runnerOS, CI_INSTALL_RETRY_DELAY_SECONDS: '0' }, + }, + ); + assert.ifError(result.error); + const logs = readdirSync(join(directory, 'logs')) + .filter((name) => name.endsWith('.log')) + .toSorted() + .map((name) => readFileSync(join(directory, 'logs', name), 'utf8')); + for (let attempt = 1; attempt <= logs.length; attempt++) { + assert.deepEqual(JSON.parse(readFileSync(`${logPrefix}.${attempt}.log.json`, 'utf8')), { + attempt, + }); + } + for (const log of logs) { + assert.match(log, /argument with spaces/); + } + return { ...result, logs, attempts: Number(readFileSync(join(directory, 'counter'))) }; +} + +for (const output of [ + assertion, + 'Socket Firewall encountered an unexpected error: TypeError fetch failed\nCause: Error read ECONNRESET', + 'HTTPError: Response code 504 (Gateway Time-out)', +]) { + test(`retries and preserves logs: ${output.split('\n')[0]}`, (t) => { + const result = run( + t, + [ + { status: output === assertion ? 127 : 1, output }, + { status: 0, output: 'installed' }, + ], + 'Windows', + ); + assert.equal(result.status, 0); + assert.equal(result.attempts, 2); + assert.ok(result.logs[0].includes(output)); + assert.match(result.logs[1], /installed/); + assert.match(result.stdout, /::warning::/); + }); +} + +test('fails after three transport failures with the original exit code', (t) => { + const result = run(t, [{ status: 137, output: 'Error read ECONNRESET' }]); + assert.equal(result.status, 137); + assert.equal(result.attempts, 3); +}); + +for (const [status, output] of [ + [1, "npm error Cannot read properties of null (reading 'edgesOut')"], + [1, 'HTTP status client error (409 Conflict)\nError read ECONNRESET'], + [1, 'HTTPError: Response code 403 (Forbidden)\nHTTPError: Response code 504'], + [1, 'Package blocked by policy\nError read ECONNRESET'], + [1, 'ERR_PNPM_MINIMUM_RELEASE_AGE\nError read ECONNRESET'], + [127, 'sfw: command not found'], + [130, 'Error read ECONNRESET'], + [143, 'Error read ECONNRESET'], + [0, 'WARN ECONNRESET (recovered)'], +]) { + test(`does not retry exit ${status}: ${output.split('\n')[0]}`, (t) => { + const result = run(t, [{ status, output }]); + assert.equal(result.status, status); + assert.equal(result.attempts, 1); + }); +} + +test('does not retry the Windows assertion on another platform', (t) => { + const result = run(t, [{ status: 127, output: assertion }]); + assert.equal(result.status, 127); + assert.equal(result.attempts, 1); +}); + +test('does not accept a Socket Firewall internal error with exit zero', (t) => { + const result = run(t, [ + { status: 0, output: 'Socket Firewall encountered an unexpected error: broken proxy' }, + ]); + assert.equal(result.status, 1); + assert.equal(result.attempts, 1); +}); + +test('recovers from a Socket Firewall transport error reported with exit zero', (t) => { + const result = run(t, [ + { status: 0, output: 'Socket Firewall encountered an unexpected error: Error read ECONNRESET' }, + { status: 0, output: 'installed' }, + ]); + assert.equal(result.status, 0); + assert.equal(result.attempts, 2); +}); diff --git a/.github/scripts/retry-install.sh b/.github/scripts/retry-install.sh new file mode 100644 index 0000000000..bec9f3238b --- /dev/null +++ b/.github/scripts/retry-install.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Retry only known external failures. Keep every attempt for CI diagnostics. +# Usage: retry-install.sh [args...] +set -euo pipefail + +log_prefix=$1 +shift +mkdir -p "$(dirname "$log_prefix")" + +for attempt in 1 2 3; do + log_file="$log_prefix.$attempt.log" + set +e + SFW_JSON_REPORT_PATH="$log_file.json" "$@" 2>&1 | tee "$log_file" + statuses=("${PIPESTATUS[@]}") + set -e + status=${statuses[0]} + if [ "${statuses[1]}" -ne 0 ]; then + exit "${statuses[1]}" + fi + if [ "$status" -eq 0 ]; then + # Some sfw internal-error paths return zero after terminating the child. + if grep -Fq 'Socket Firewall encountered an unexpected error:' "$log_file"; then + status=1 + else + exit 0 + fi + fi + + # Cancellation, package-policy decisions and HTTP client errors must remain + # failures, even if another request in the same install had a transient error. + if [ "$status" -eq 130 ] || [ "$status" -eq 143 ] || + grep -Eiq 'HTTP[^[:cntrl:]]*\b4[0-9]{2}\b|status (code|client error)[^[:cntrl:]]*\b4[0-9]{2}\b|ERR_PNPM_(TRUST|MINIMUM_RELEASE_AGE)|malicious|policy (violation|rejection)|blocked by' "$log_file"; then + exit "$status" + fi + + retry=false + if [ "${RUNNER_OS:-}" = Windows ] && [ "$status" -eq 127 ] && + grep -Fq 'Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)' "$log_file"; then + # sfw 1.15.1 can abort during Windows process teardown after a successful + # install. Repeat the complete command; never turn the assertion into success. + retry=true + elif grep -Eq 'ECONNRESET|ETIMEDOUT|EAI_AGAIN|HTTPError: Response code 50[234]|HTTP[^[:cntrl:]]*\b50[234]\b' "$log_file"; then + retry=true + fi + if [ "$retry" = false ] || [ "$attempt" -eq 3 ]; then + exit "$status" + fi + + echo "::warning::Transient install failure (exit $status); retrying after attempt $attempt/3. Log: $log_file" + sleep "${CI_INSTALL_RETRY_DELAY_SECONDS:-5}" +done diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b4331e5f4..a0b6da8628 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -201,7 +201,7 @@ jobs: cargo nextest archive $(for d in crates/*/; do n=$(basename $d); [ "$n" = "vp_cli_snapshots" ] || [ "$n" = "vp_trampoline" ] || echo -n "-p $n "; done) -p vite-plus-cli \ --target x86_64-pc-windows-msvc --archive-file windows-tests.tar.zst - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - uses: ./.github/actions/upload-artifact-with-retry with: name: windows-test-archive path: windows-tests.tar.zst @@ -220,7 +220,7 @@ jobs: cargo nextest archive -p vp_cli_snapshots \ --target x86_64-pc-windows-msvc --archive-file windows-snapshot-tests.tar.zst - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - uses: ./.github/actions/upload-artifact-with-retry with: name: windows-snapshot-test-archive path: windows-snapshot-tests.tar.zst @@ -393,6 +393,9 @@ jobs: - uses: oxc-project/setup-node@f46a72f95efdc55273fcd042d61c84e723b2892c # v1.4.1 + - name: Test CI install retries + run: node --test .github/scripts/__tests__/retry-install.mjs + - name: Install docs dependencies run: pnpm -C docs install --frozen-lockfile @@ -1009,6 +1012,14 @@ jobs: RUST_BACKTRACE: '1' VP_SNAP_SHARD: ${{ matrix.shard }}/3 + - name: Upload failed snapshots + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cli-snapshots-${{ matrix.target }}-${{ matrix.shard }}-${{ github.run_attempt }} + path: crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/*/snapshots/*.md.new + retention-days: 7 + # Runs the PTY snapshot suite (crates/vp_cli_snapshots) on Windows with # BOTH vp flavors, without a Rust toolchain on the runner: the test binary # and vpt arrive cross-compiled in the nextest archive from @@ -1127,6 +1138,14 @@ jobs: # Keep Windows env parity with the `test` recipe in justfile. __COMPAT_LAYER: RunAsInvoker + - name: Upload failed snapshots + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cli-snapshots-windows-${{ matrix.shard }}-${{ github.run_attempt }} + path: crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/*/snapshots/*.md.new + retention-days: 7 + cli-e2e-test-musl: name: CLI E2E test (Linux x64 musl) needs: @@ -1299,6 +1318,7 @@ jobs: - name: Run local CLI `vp install` run: | export PATH=$PWD/node_modules/.bin:$PATH + retry_install="$GITHUB_WORKSPACE/.github/scripts/retry-install.sh" vp -h # Test vp install on various repositories with different package managers # Entry format: "owner/repo:dir" or "owner/repo:dir:ref" (tag or branch to clone) @@ -1322,21 +1342,36 @@ jobs: echo "Testing vp install on $repo${ref:+ ($ref)}…" # remove the directory if it exists if [ -d "$RUNNER_TEMP/$dir_name" ]; then - rm -rf "$RUNNER_TEMP/$dir_name" + rm -rf "${RUNNER_TEMP:?}/${dir_name:?}" fi clone_args=(--depth 1) if [ -n "$ref" ]; then clone_args+=(--branch "$ref") fi - git clone "${clone_args[@]}" "https://github.com/$repo.git" "$RUNNER_TEMP/$dir_name" + if [ "$repo" = vitejs/vite ]; then + # Use the same pinned Vite revision as the build, with a clean worktree. + git clone --no-hardlinks "$GITHUB_WORKSPACE/vite" "$RUNNER_TEMP/$dir_name" + else + git clone "${clone_args[@]}" "https://github.com/$repo.git" "$RUNNER_TEMP/$dir_name" + fi cd "$RUNNER_TEMP/$dir_name" - vp install --no-frozen-lockfile - # run again to show install cache increase by time - time vp install + git rev-parse HEAD + bash "$retry_install" "$RUNNER_TEMP/install-logs/$dir_name-cold" vp install --no-frozen-lockfile + # Exercise the warm install separately so each failure has its own logs. + time bash "$retry_install" "$RUNNER_TEMP/install-logs/$dir_name-warm" vp install echo "✓ Successfully installed dependencies for $repo" echo "" done + - name: Upload install logs + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: install-logs-${{ github.run_attempt }} + path: ${{ runner.temp }}/install-logs + retention-days: 7 + install-e2e-test-sfw: name: Local CLI `vp install` E2E test (Socket Firewall Free) needs: @@ -1355,12 +1390,8 @@ jobs: target: x86_64-unknown-linux-gnu sfw_asset: sfw-free-linux-x86_64 vp_bin: vp - # Only Linux needs the TLS-bypass: the ubuntu-latest runner - # doesn't preinstall Node 22.18 into vp's cache, so vp's HttpClient - # (rustls) fetches `nodejs.org/.../SHASUMS256.txt` through sfw and - # hits the upstream EKU bug. macOS/Windows runners ship Node in - # vp's cache already, so vp never calls its HttpClient through sfw - # in this test — leave the verification on there. + # Retain the Linux-only workaround for SFW's CA interoperability + # issue (SocketDev/sfw-free#30, #43). Other platforms verify TLS. vp_insecure_tls: '1' - os: namespace-profile-mac-default target: aarch64-apple-darwin @@ -1422,6 +1453,8 @@ jobs: fi - name: Download sfw + env: + SFW_VERSION: v1.15.1 run: | set -euo pipefail mkdir -p "$RUNNER_TEMP/sfw-bin" @@ -1430,7 +1463,7 @@ jobs: # than with "exec format error" on an empty binary. curl --fail --location --remove-on-error --retry 3 --retry-delay 2 \ --output "$RUNNER_TEMP/sfw-bin/sfw${{ runner.os == 'Windows' && '.exe' || '' }}" \ - "https://github.com/SocketDev/sfw-free/releases/latest/download/${{ matrix.sfw_asset }}" + "https://github.com/SocketDev/sfw-free/releases/download/$SFW_VERSION/${{ matrix.sfw_asset }}" if [[ "${{ runner.os }}" != "Windows" ]]; then chmod +x "$RUNNER_TEMP/sfw-bin/sfw" fi @@ -1440,28 +1473,37 @@ jobs: run: sfw --version - name: Run `sfw vp install` against a real repo - # TODO(SocketDev/sfw-free#30, SocketDev/sfw-free#43): drop `vp_insecure_tls` - # from the Linux matrix entry once sfw ships the EKU fix. Verified - # against sfw v1.11.0 (releases/latest as of 2026-05-28): on Linux, - # vp's HTTPS request to nodejs.org through sfw still fails with - # "invalid peer certificate: UnknownIssuer" because sfw's CA carries a - # present-but-empty Extended Key Usage extension that rustls rejects. - # macOS/Windows runners cache Node 22.18 in vp's directory, so vp - # doesn't call its HttpClient through sfw — leaving TLS verification - # enabled there gives us coverage that the bypass *isn't* used on - # those platforms. + timeout-minutes: 15 + # TODO(SocketDev/sfw-free#30, #43): remove the existing Linux CA + # workaround once upstream resolves it. The global-package probe + # exercises vp's HTTP client on all platforms; macOS/Windows keep + # certificate verification enabled. env: VP_INSECURE_TLS: ${{ matrix.vp_insecure_tls }} + SFW_VERBOSE: 'true' run: | set -euo pipefail - # Force the registry-fetch path: install a pinned pnpm globally so - # vp downloads it (and therefore traverses sfw) rather than reusing - # whatever's preinstalled on the runner. - sfw "${{ matrix.vp_bin }}" i -g pnpm@9.15.0 - # Then exercise `vp install` inside a real repo, also through sfw. - git clone --depth 1 https://github.com/vitejs/vite.git "$RUNNER_TEMP/vite" + retry_install="$GITHUB_WORKSPACE/.github/scripts/retry-install.sh" + # Package managers are bundled: installing pnpm globally is now a + # no-op. An ordinary package exercises the download and global shim. + bash "$retry_install" "$RUNNER_TEMP/install-logs/sfw-global" sfw "${{ matrix.vp_bin }}" i -g semver@7.7.2 + test "$(semver 1.2.3)" = '1.2.3' + "${{ matrix.vp_bin }}" uninstall -g semver + # Use a clean clone of the revision already pinned by build-upstream. + git clone --no-hardlinks "$GITHUB_WORKSPACE/vite" "$RUNNER_TEMP/vite" cd "$RUNNER_TEMP/vite" - sfw "${{ matrix.vp_bin }}" install --no-frozen-lockfile + git rev-parse HEAD + # Bound request fan-out through the proxy. Keep supply-chain checks enabled. + bash "$retry_install" "$RUNNER_TEMP/install-logs/sfw-workspace" sfw "${{ matrix.vp_bin }}" install --no-frozen-lockfile -- --network-concurrency=4 + + - name: Upload Socket Firewall install logs + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sfw-install-logs-${{ matrix.target }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/install-logs + retention-days: 7 done: runs-on: ubuntu-latest From 25c67d26cb9d3ed439b7ae4e077d2c146f0da40b Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 15 Sep 2026 14:15:17 +0800 Subject: [PATCH 2/2] test: stabilize pnpm removal snapshots --- .../snapshots/command_remove_pnpm10.global.md | 4 +- .../snapshots/command_remove_pnpm10.local.md | 4 +- .../snapshots/command_remove_pnpm11.md | 4 +- .../command_update_pnpm10/snapshots.toml | 3 ++ .../snapshots/command_update_pnpm10.global.md | 24 +++++++++++- .../snapshots/command_update_pnpm10.local.md | 24 +++++++++++- .../snapshots/command_update_pnpm11.md | 2 +- .../tests/cli_snapshots/redact.rs | 24 ++++++++++++ crates/vp_cli_snapshots/tests/redact_unit.rs | 39 +++++++++++++++++++ 9 files changed, 117 insertions(+), 11 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_remove_pnpm10/snapshots/command_remove_pnpm10.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_remove_pnpm10/snapshots/command_remove_pnpm10.global.md index a6089a0c92..5a3d4c5072 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_remove_pnpm10/snapshots/command_remove_pnpm10.global.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_remove_pnpm10/snapshots/command_remove_pnpm10.global.md @@ -116,10 +116,10 @@ Packages: -2 -- dependencies: -- testnpm2 1.0.1 +- testnpm2 devDependencies: -- test-vite-plus-install 1.0.0 +- test-vite-plus-install Done in using pnpm ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_remove_pnpm10/snapshots/command_remove_pnpm10.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_remove_pnpm10/snapshots/command_remove_pnpm10.local.md index ca09982902..2283f86949 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_remove_pnpm10/snapshots/command_remove_pnpm10.local.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_remove_pnpm10/snapshots/command_remove_pnpm10.local.md @@ -112,10 +112,10 @@ Packages: -2 -- dependencies: -- testnpm2 1.0.1 +- testnpm2 devDependencies: -- test-vite-plus-install 1.0.0 +- test-vite-plus-install Done in using pnpm ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_remove_pnpm11/snapshots/command_remove_pnpm11.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_remove_pnpm11/snapshots/command_remove_pnpm11.md index aaf526bf84..be515b4d3f 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_remove_pnpm11/snapshots/command_remove_pnpm11.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_remove_pnpm11/snapshots/command_remove_pnpm11.md @@ -116,10 +116,10 @@ Packages: -2 -- dependencies: -- testnpm2 1.0.1 +- testnpm2 devDependencies: -- test-vite-plus-install 1.0.0 +- test-vite-plus-install Done in using pnpm ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_pnpm10/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_pnpm10/snapshots.toml index 048deab906..5bebe2b035 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_pnpm10/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_pnpm10/snapshots.toml @@ -14,7 +14,10 @@ steps = [ { argv = ["vpt", "print-file", "package.json"], continue-on-failure = true }, { argv = ["vp", "rm", "testnpm2"], comment = "should remove package from dependencies for the next test", snapshot = false, continue-on-failure = true }, { argv = ["vp", "add", "testnpm2@1.0.0", "-O"], comment = "should skip optional dependencies" }, + ["vpt", "stat-file", "node_modules/testnpm2/package.json", "node_modules/test-vite-plus-package-optional/package.json", "--assert", "file"], ["vp", "update", "--no-optional", "--latest"], + ["vpt", "stat-file", "node_modules/testnpm2", "node_modules/test-vite-plus-package-optional", "--assert", "missing"], + ["vpt", "stat-file", "node_modules/test-vite-plus-package/package.json", "--assert", "file"], { argv = ["vpt", "print-file", "package.json"], continue-on-failure = true }, { argv = ["vp", "update"], comment = "should update all packages and change the package.json" }, ["vp", "update", "--recursive"], diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_pnpm10/snapshots/command_update_pnpm10.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_pnpm10/snapshots/command_update_pnpm10.global.md index fda81d8fb5..b2eacffca4 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_pnpm10/snapshots/command_update_pnpm10.global.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_pnpm10/snapshots/command_update_pnpm10.global.md @@ -182,6 +182,13 @@ optionalDependencies: Done in using pnpm ``` +## `vpt stat-file node_modules/testnpm2/package.json node_modules/test-vite-plus-package-optional/package.json --assert file` + +``` +node_modules/testnpm2/package.json: file +node_modules/test-vite-plus-package-optional/package.json: file +``` + ## `vp update --no-optional --latest` ``` @@ -189,12 +196,25 @@ Packages: -2 -- optionalDependencies: -- test-vite-plus-package-optional 1.0.0 -- testnpm2 1.0.0 +- test-vite-plus-package-optional +- testnpm2 Done in using pnpm ``` +## `vpt stat-file node_modules/testnpm2 node_modules/test-vite-plus-package-optional --assert missing` + +``` +node_modules/testnpm2: missing +node_modules/test-vite-plus-package-optional: missing +``` + +## `vpt stat-file node_modules/test-vite-plus-package/package.json --assert file` + +``` +node_modules/test-vite-plus-package/package.json: file +``` + ## `vpt print-file package.json` ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_pnpm10/snapshots/command_update_pnpm10.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_pnpm10/snapshots/command_update_pnpm10.local.md index 3d37055500..b07856fd93 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_pnpm10/snapshots/command_update_pnpm10.local.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_pnpm10/snapshots/command_update_pnpm10.local.md @@ -178,6 +178,13 @@ optionalDependencies: Done in using pnpm ``` +## `vpt stat-file node_modules/testnpm2/package.json node_modules/test-vite-plus-package-optional/package.json --assert file` + +``` +node_modules/testnpm2/package.json: file +node_modules/test-vite-plus-package-optional/package.json: file +``` + ## `vp update --no-optional --latest` ``` @@ -185,12 +192,25 @@ Packages: -2 -- optionalDependencies: -- test-vite-plus-package-optional 1.0.0 -- testnpm2 1.0.0 +- test-vite-plus-package-optional +- testnpm2 Done in using pnpm ``` +## `vpt stat-file node_modules/testnpm2 node_modules/test-vite-plus-package-optional --assert missing` + +``` +node_modules/testnpm2: missing +node_modules/test-vite-plus-package-optional: missing +``` + +## `vpt stat-file node_modules/test-vite-plus-package/package.json --assert file` + +``` +node_modules/test-vite-plus-package/package.json: file +``` + ## `vpt print-file package.json` ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_pnpm11/snapshots/command_update_pnpm11.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_pnpm11/snapshots/command_update_pnpm11.md index 7eed3730e5..af2231ad83 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_pnpm11/snapshots/command_update_pnpm11.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_pnpm11/snapshots/command_update_pnpm11.md @@ -189,7 +189,7 @@ Done in using pnpm - optionalDependencies: -- testnpm2 1.0.0 +- testnpm2 testnpm2 1.0.1 Done in using pnpm diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs index 1301e2dc33..85ed0d0dde 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs @@ -207,6 +207,23 @@ static PNPM_STORE_INFO_RE: LazyLock = LazyLock::new(|| { ) .unwrap() }); +// pnpm reads a removed package's manifest concurrently with unlinking its +// node_modules entry, so the removal summary can omit the version. Normalize +// only removal rows inside pnpm dependency summaries; keep added versions and +// package.json contents assertable. +// https://github.com/pnpm/pnpm/blob/v10.18.0/pkg-manager/modules-cleaner/src/removeDirectDependency.ts#L23-L40 +static PNPM_DEPENDENCY_SUMMARY_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new( + r"(?m)^((?:dependencies|devDependencies|optionalDependencies):\n)((?:[^\n]+\n?)+)", + ) + .unwrap() +}); +static PNPM_REMOVED_VERSION_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new( + r"(?m)^(- (?:@[^/\s]+/)?[^/\s]+) \d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$", + ) + .unwrap() +}); // Stack frames under file:// URLs carry line:column offsets of the bundled // chunk that produced them, which shift with every build of the bundle (and // the chunk hash in the frame path shifts with content); the error message @@ -579,6 +596,13 @@ pub fn redact_output( output = SPINNER_FRAME_RE.replace_all(&output, "\u{283F}").into_owned(); output = PNPM_PROGRESS_RE.replace_all(&output, "").into_owned(); output = PNPM_STORE_INFO_RE.replace_all(&output, "").into_owned(); + if output.contains("using pnpm ") { + output = PNPM_DEPENDENCY_SUMMARY_RE + .replace_all(&output, |caps: ®ex::Captures| { + format!("{}{}", &caps[1], PNPM_REMOVED_VERSION_RE.replace_all(&caps[2], "${1}")) + }) + .into_owned(); + } // Pin racy blank-line layout last, after every rule above that strips // whole lines (banner box, stack frames, progress rows) has run, so the diff --git a/crates/vp_cli_snapshots/tests/redact_unit.rs b/crates/vp_cli_snapshots/tests/redact_unit.rs index 5c264e6756..445a3c97c1 100644 --- a/crates/vp_cli_snapshots/tests/redact_unit.rs +++ b/crates/vp_cli_snapshots/tests/redact_unit.rs @@ -11,6 +11,45 @@ mod redact; use redact::{redact_output, redact_version_probe_output}; +#[test] +fn normalizes_pnpm_removal_versions_for_both_manifest_read_outcomes() { + let with_versions = concat!( + "dependencies:\n- prod-package 1.2.3\n+ prod-package 2.0.0\n\n", + "devDependencies:\n- @scope/dev-package 1.0.0-beta.1+build.2\n\n", + "optionalDependencies:\n- test-vite-plus-package-optional 1.0.0\n- testnpm2 1.0.0\n\n", + "Done in 1s using pnpm v10.18.0\n", + ); + let without_versions = concat!( + "dependencies:\n- prod-package\n+ prod-package 2.0.0\n\n", + "devDependencies:\n- @scope/dev-package\n\n", + "optionalDependencies:\n- test-vite-plus-package-optional\n- testnpm2\n\n", + "Done in 1s using pnpm v10.18.0\n", + ); + // The existing progress rule also strips the leading plus from added rows. + let expected = without_versions + .replace("1s", "") + .replace("v10.18.0", "") + .replace("\n+", "\n"); + assert_eq!(redact_output(with_versions.to_owned(), &[], true), expected); + assert_eq!(redact_output(without_versions.to_owned(), &[], true), expected); +} + +#[test] +fn preserves_versions_outside_pnpm_removal_summaries() { + let output = concat!( + "- unrelated 1.0.0\n\n", + "dependencies:\n+ added-package 2.0.0\n\n", + "Done in 1s using pnpm v10.18.0\n\n", + "{\"optionalDependencies\": {\"testnpm2\": \"1.0.1\"}}\n", + ); + assert_eq!( + redact_output(output.to_owned(), &[], true), + output.replace("1s", "").replace("v10.18.0", "").replace("\n+", "\n"), + ); + let other_manager = "optionalDependencies:\n- testnpm2 1.0.0\n"; + assert_eq!(redact_output(other_manager.to_owned(), &[], true), other_manager); +} + #[test] fn masks_bare_version_block_only_for_version_probe_steps() { // `npm --version` / `npx --version` print a bare semver alone in the