diff --git a/pacquet/crates/package-manager/src/install.rs b/pacquet/crates/package-manager/src/install.rs index 041108822a..7d3198eba1 100644 --- a/pacquet/crates/package-manager/src/install.rs +++ b/pacquet/crates/package-manager/src/install.rs @@ -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 + // . + 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> = 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); } } diff --git a/pacquet/crates/reporter/src/lib.rs b/pacquet/crates/reporter/src/lib.rs index 9781036341..e3d138444e 100644 --- a/pacquet/crates/reporter/src/lib.rs +++ b/pacquet/crates/reporter/src/lib.rs @@ -40,6 +40,15 @@ pub enum LogEvent { /// Upstream: . #[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: . + /// Emit site: . + #[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 diff --git a/pacquet/crates/reporter/src/tests.rs b/pacquet/crates/reporter/src/tests.rs index 223b70b7dc..e8e940084a 100644 --- a/pacquet/crates/reporter/src/tests.rs +++ b/pacquet/crates/reporter/src/tests.rs @@ -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() {