Files
pnpm/cli/default-reporter/test/reportingLockfileVerification.ts
Mayank Maurya 3f0fb219b6 fix(default-reporter): erase trailing characters on progress line (#12351)
External processes like SSH passphrase prompts can write to the terminal between progress updates. The previous renderer used `ansi-diff`, which only overwrites the characters it knows changed, so leftover characters from the external output stayed visible on the progress line — e.g. `added 0sa':`, where `sa':` is a fragment of `Enter passphrase for key '.../.ssh/id_rsa':`.

Closes https://github.com/pnpm/pnpm/issues/12350

## Summary

The interactive (non-append-only) reporter now redraws the whole frame in place on each update instead of incrementally diffing it:

- return the cursor to the top-left of the previous frame (`ESC[<rows>A` followed by a carriage return, so the redraw starts at column 0 even if an external process left the cursor mid-line),
- erase from there to the end of the display (`ESC[0J`),
- reprint the frame — all in a single atomic write, so there is no flicker.

Because the whole region is erased on every frame, any characters an external process wrote in between are cleared. This matches pacquet's `Output::Frame` rendering (the column-reset hardening was applied to both stacks). The now-unused `ansi-diff` dependency has been removed.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-06-17 08:17:15 +02:00

187 lines
6.3 KiB
TypeScript

import path from 'node:path'
import { stripVTControlCharacters as stripAnsi } from 'node:util'
import { expect, test } from '@jest/globals'
import { toOutput$ } from '@pnpm/cli.default-reporter'
import type { Config, ConfigContext } from '@pnpm/config.reader'
import { lockfileVerificationLogger } from '@pnpm/core-loggers'
import { createStreamParser } from '@pnpm/logger'
import { firstValueFrom, take, toArray } from 'rxjs'
test('prints lockfile verification in-progress and completion messages', async () => {
const cwd = '/repo'
const output$ = toOutput$({
context: {
argv: ['install'],
config: { dir: cwd } as Config & ConfigContext,
},
streamParser: createStreamParser(),
})
// Subscribe before emitting so we capture both the started and the
// done frame in the interactive (non-append-only) reporter.
const frames = firstValueFrom(output$.pipe(take(2), toArray()))
const lockfilePath = path.join(cwd, 'pnpm-lock.yaml')
lockfileVerificationLogger.debug({ status: 'started', entries: 234, lockfilePath })
lockfileVerificationLogger.debug({
status: 'done',
entries: 234,
elapsedMs: 1234,
lockfilePath,
})
const [started, done] = await frames
expect(stripAnsi(started)).toBe('? Verifying lockfile against supply-chain policies (234 entries)...')
expect(stripAnsi(done)).toBe('✓ Lockfile passes supply-chain policies (234 entries in 1.2s)')
})
test('uses singular noun for one entry', async () => {
const output$ = toOutput$({
context: { argv: ['install'] },
streamParser: createStreamParser(),
})
const frames = firstValueFrom(output$.pipe(take(2), toArray()))
lockfileVerificationLogger.debug({ status: 'started', entries: 1 })
lockfileVerificationLogger.debug({
status: 'done',
entries: 1,
elapsedMs: 42,
})
const [started, done] = await frames
expect(stripAnsi(started)).toBe('? Verifying lockfile against supply-chain policies (1 entry)...')
expect(stripAnsi(done)).toBe('✓ Lockfile passes supply-chain policies (1 entry in 42ms)')
})
test('prints relative path when lockfile lives outside the workspace root', async () => {
const cwd = '/repo/packages/app'
const workspaceDir = '/repo'
const output$ = toOutput$({
context: {
argv: ['install'],
config: { dir: cwd, workspaceDir } as Config & ConfigContext,
},
streamParser: createStreamParser(),
})
const frames = firstValueFrom(output$.pipe(take(2), toArray()))
// Lockfile lives in a sibling dir, not at the workspace root.
const lockfilePath = '/repo/locks/pnpm-lock.yaml'
lockfileVerificationLogger.debug({ status: 'started', entries: 5, lockfilePath })
lockfileVerificationLogger.debug({
status: 'done',
entries: 5,
elapsedMs: 200,
lockfilePath,
})
const [started, done] = await frames
expect(stripAnsi(started)).toBe('? Verifying lockfile at ../../locks/pnpm-lock.yaml against supply-chain policies (5 entries)...')
expect(stripAnsi(done)).toBe('✓ Lockfile at ../../locks/pnpm-lock.yaml passes supply-chain policies (5 entries in 200ms)')
})
test('does not print path when running from workspace subdir and lockfile is at workspace root', async () => {
const cwd = '/repo/packages/app'
const workspaceDir = '/repo'
const output$ = toOutput$({
context: {
argv: ['install'],
config: { dir: cwd, workspaceDir } as Config & ConfigContext,
},
streamParser: createStreamParser(),
})
const frames = firstValueFrom(output$.pipe(take(1), toArray()))
const lockfilePath = path.join(workspaceDir, 'pnpm-lock.yaml')
lockfileVerificationLogger.debug({ status: 'started', entries: 10, lockfilePath })
const [started] = await frames
expect(stripAnsi(started)).toBe('? Verifying lockfile against supply-chain policies (10 entries)...')
})
test('suppresses path when workspaceDir has a trailing separator', async () => {
const cwd = '/repo'
// Workspace dir with a trailing slash — strict === against
// path.dirname(lockfilePath) would mismatch; path.relative normalizes.
const workspaceDir = '/repo/'
const output$ = toOutput$({
context: {
argv: ['install'],
config: { dir: cwd, workspaceDir } as Config & ConfigContext,
},
streamParser: createStreamParser(),
})
const frames = firstValueFrom(output$.pipe(take(1), toArray()))
lockfileVerificationLogger.debug({
status: 'started',
entries: 3,
lockfilePath: '/repo/pnpm-lock.yaml',
})
const [started] = await frames
expect(stripAnsi(started)).toBe('? Verifying lockfile against supply-chain policies (3 entries)...')
})
test('prints a previously-verified line when the cached verdict is reused', async () => {
const cwd = '/repo'
const output$ = toOutput$({
context: {
argv: ['install'],
config: { dir: cwd } as Config & ConfigContext,
},
streamParser: createStreamParser(),
})
const frames = firstValueFrom(output$.pipe(take(1), toArray()))
lockfileVerificationLogger.debug({
status: 'cached',
verifiedAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
lockfilePath: path.join(cwd, 'pnpm-lock.yaml'),
})
const [cached] = await frames
expect(stripAnsi(cached)).toBe('✓ Lockfile passes supply-chain policies (verified 2h ago)')
})
test('falls back to a timeless cached message when the record has no timestamp', async () => {
const output$ = toOutput$({
context: { argv: ['install'] },
streamParser: createStreamParser(),
})
const frames = firstValueFrom(output$.pipe(take(1), toArray()))
lockfileVerificationLogger.debug({ status: 'cached' })
const [cached] = await frames
expect(stripAnsi(cached)).toBe('✓ Lockfile passes supply-chain policies (previously verified)')
})
test('emits a brief failure line on failed status', async () => {
const output$ = toOutput$({
context: { argv: ['install'] },
streamParser: createStreamParser(),
})
const frames = firstValueFrom(output$.pipe(take(2), toArray()))
lockfileVerificationLogger.debug({ status: 'started', entries: 12 })
lockfileVerificationLogger.debug({
status: 'failed',
entries: 12,
elapsedMs: 800,
})
const [started, failed] = await frames
expect(stripAnsi(started)).toBe('? Verifying lockfile against supply-chain policies (12 entries)...')
expect(stripAnsi(failed)).toBe('✗ Lockfile failed supply-chain policy check (12 entries in 800ms)')
})