feat(reporter): emit pnpm:summary at install end (#355)

* feat(reporter): emit pnpm:summary at install end

Adds the `Summary` variant to `LogEvent` and emits it from
`Install::run` immediately after `pnpm:stage importing_done`. Mirrors
pnpm's emit at
https://github.com/pnpm/pnpm/blob/086c5e91e8/installing/deps-installer/src/install/index.ts#L1663.

The payload carries only `prefix`; the reporter combines this with the
accumulated `pnpm:root` events (still TODO under #347) to render the
final "+N -M" diff block.

Test: extends the install integration test (renamed to
`install_emits_pnpm_event_sequence` to match the growing scope) to
assert the four-event success sequence (`Context`, `ImportingStarted`,
`ImportingDone`, `Summary`) and spot-checks summary's `prefix`.
Verified the test catches the regression by temporarily commenting
out the emit and confirming it failed with a Summary-shaped gap.

Refs #347. Engine: #345. Previous channel: #354.

* docs(reporter): clarify SummaryLog payload comment

Per review feedback on #355: the original wording said the `pnpm:summary`
payload "is just `prefix`", which omitted the `level` field that's
also part of the serialized record. `level` is the bunyan-envelope
severity carried by every channel, not channel-specific to summary;
say so explicitly so a reader doesn't try to "fix" the struct based on
the comment.

Same fix applied to the wire-shape test's doc comment.
This commit is contained in:
Zoltan Kochan
2026-05-01 23:19:47 +02:00
committed by GitHub
parent 33f0731acd
commit f4e18be8db
3 changed files with 79 additions and 13 deletions

View File

@@ -10,7 +10,7 @@ use pacquet_lockfile::Lockfile;
use pacquet_network::ThrottledClient;
use pacquet_npmrc::Npmrc;
use pacquet_package_manifest::{DependencyGroup, PackageManifest};
use pacquet_reporter::{ContextLog, LogEvent, LogLevel, Reporter, Stage, StageLog};
use pacquet_reporter::{ContextLog, LogEvent, LogLevel, Reporter, Stage, StageLog, SummaryLog};
use pacquet_tarball::MemCache;
/// This subroutine does everything `pacquet install` is supposed to do.
@@ -160,10 +160,16 @@ where
R::emit(&LogEvent::Stage(StageLog {
level: LogLevel::Debug,
prefix,
prefix: prefix.clone(),
stage: Stage::ImportingDone,
}));
// `pnpm:summary` closes the install and lets the reporter render
// the accumulated `pnpm:root` events as a "+N -M" block. Must
// come after `importing_done`, matching pnpm's ordering at
// <https://github.com/pnpm/pnpm/blob/086c5e91e8/installing/deps-installer/src/install/index.ts#L1663>.
R::emit(&LogEvent::Summary(SummaryLog { level: LogLevel::Debug, prefix }));
Ok(())
}
}
@@ -175,7 +181,9 @@ mod tests {
use pacquet_npmrc::Npmrc;
use pacquet_package_manifest::{DependencyGroup, PackageManifest};
use pacquet_registry_mock::AutoMockInstance;
use pacquet_reporter::{ContextLog, LogEvent, Reporter, SilentReporter, Stage, StageLog};
use pacquet_reporter::{
ContextLog, LogEvent, Reporter, SilentReporter, Stage, StageLog, SummaryLog,
};
use pacquet_testing_utils::fs::{get_all_folders, is_symlink_or_junction};
use std::sync::Mutex;
use tempfile::tempdir;
@@ -566,14 +574,14 @@ mod tests {
drop(dir);
}
/// [`Install::run`] emits `pnpm:context` followed by the
/// `pnpm:stage` `importing_started` / `importing_done` pair that
/// brackets the install. On the success path all three fire in
/// order; on an early-error path such as
/// [`Install::run`] emits `pnpm:context`, then `pnpm:stage`
/// `importing_started`, then on the success path `importing_done`
/// followed by `pnpm:summary`. On an early-error path such as
/// [`InstallError::NoLockfile`] only the leading events fire. This
/// matches pnpm: context is emitted once alongside the install
/// header, and the stage pairing drives the JS reporter's progress
/// UI.
/// header, the stage pairing drives the JS reporter's progress
/// UI, and summary closes the run so the reporter can render its
/// "+N -M" block.
///
/// `pnpm:context` carries `currentLockfileExists`, `storeDir`,
/// `virtualStoreDir`. `currentLockfileExists` is hard-coded
@@ -581,7 +589,7 @@ mod tests {
/// `node_modules/.pnpm/lock.yaml`), matching the TODO in
/// [`Install::run`].
#[tokio::test]
async fn install_emits_context_and_stage_events() {
async fn install_emits_pnpm_event_sequence() {
static EVENTS: Mutex<Vec<LogEvent>> = Mutex::new(Vec::new());
struct RecordingReporter;
@@ -635,8 +643,8 @@ mod tests {
let captured = EVENTS.lock().unwrap();
// Event ordering matches pnpm: context first, then the stage
// bracketing pair.
// Event ordering matches pnpm: context, then the stage
// bracketing pair, then summary closing the run.
assert!(
matches!(
captured.as_slice(),
@@ -644,6 +652,7 @@ mod tests {
LogEvent::Context(_),
LogEvent::Stage(StageLog { stage: Stage::ImportingStarted, .. }),
LogEvent::Stage(StageLog { stage: Stage::ImportingDone, .. }),
LogEvent::Summary(_),
]
),
"unexpected event sequence: {captured:?}",
@@ -665,6 +674,17 @@ mod tests {
assert_eq!(emitted_store_dir, &store_dir.display().to_string());
assert_eq!(emitted_virtual_store_dir, &virtual_store_dir.to_string_lossy().into_owned());
// Summary's `prefix` must equal the manifest-parent value
// `Install::run` derives, since pnpm's reporter keys its
// accumulated root-events by prefix to render the diff.
let LogEvent::Summary(SummaryLog { prefix: summary_prefix, .. }) = &captured[3] else {
unreachable!("fourth event is summary, asserted above");
};
assert_eq!(
summary_prefix,
&manifest.path().parent().unwrap().to_string_lossy().into_owned(),
);
drop(dir);
}
}

View File

@@ -40,6 +40,15 @@ pub enum LogEvent {
/// Upstream: <https://github.com/pnpm/pnpm/blob/3b12eb27de/core/core-loggers/src/stageLogger.ts>.
#[serde(rename = "pnpm:stage")]
Stage(StageLog),
/// End-of-install marker (`pnpm:summary`). pnpm's reporter combines
/// this with the accumulated `pnpm:root` events to render the final
/// "+N -M" block.
///
/// Upstream: <https://github.com/pnpm/pnpm/blob/086c5e91e8/core/core-loggers/src/summaryLogger.ts>.
/// Emit site: <https://github.com/pnpm/pnpm/blob/086c5e91e8/installing/deps-installer/src/install/index.ts#L1663>.
#[serde(rename = "pnpm:summary")]
Summary(SummaryLog),
}
/// `pnpm:context` payload.
@@ -77,6 +86,18 @@ pub enum Stage {
ImportingDone,
}
/// `pnpm:summary` payload. `prefix` identifies the importer; pnpm's
/// reporter uses it to look up the matching `pnpm:root` history and
/// render its "+N -M" diff. `level` is the [bunyan]-envelope severity,
/// common to every channel.
///
/// [bunyan]: https://github.com/trentm/node-bunyan
#[derive(Debug, Clone, Serialize)]
pub struct SummaryLog {
pub level: LogLevel,
pub prefix: String,
}
/// Severity level on the [bunyan]-shaped envelope.
///
/// pnpm's logger uses the [bole] library, which writes one of these strings

View File

@@ -6,7 +6,7 @@ use serde_json::Value;
use crate::{
ContextLog, Envelope, GetHostName, LogEvent, LogLevel, RealApi, Reporter, SilentReporter,
Stage, StageLog,
Stage, StageLog, SummaryLog,
};
/// Context log serializes with the camelCase field names
@@ -64,6 +64,31 @@ fn stage_event_matches_pnpm_wire_shape() {
assert_eq!(json["pid"], 4242);
}
/// Summary log serializes with the channel name flattened into the
/// envelope alongside `prefix` and the [bunyan]-envelope `level`.
/// `prefix` is what pnpm's reporter uses to find the matching
/// `pnpm:root` history and render its "+N -M" block.
///
/// [bunyan]: https://github.com/trentm/node-bunyan
#[test]
fn summary_event_matches_pnpm_wire_shape() {
let event = LogEvent::Summary(SummaryLog {
level: LogLevel::Debug,
prefix: "/some/project".to_string(),
});
let envelope = Envelope { time: 1_700_000_000_000, hostname: "host", pid: 4242, event: &event };
let json: Value = envelope
.pipe_ref(serde_json::to_string)
.expect("serialize envelope")
.pipe_as_ref(serde_json::from_str)
.expect("parse JSON");
assert_eq!(json["name"], "pnpm:summary");
assert_eq!(json["level"], "debug");
assert_eq!(json["prefix"], "/some/project");
}
/// Phase markers serialize as the snake_case strings pnpm uses.
#[test]
fn stage_phases_serialize_in_pnpm_form() {