55 indoc:: formatdoc,
66 predicates:: prelude:: PredicateBooleanExt ,
77 predicates:: str:: contains,
8+ std:: io:: { BufRead as _, BufReader } ,
9+ std:: process:: Stdio ,
810 std:: time:: Duration ,
911} ;
1012
@@ -74,7 +76,8 @@ async fn canister_logs_single_fetch() {
7476 . assert ( )
7577 . success ( ) ;
7678
77- // Fetch logs
79+ // Fetch logs: the default emits the human-readable "[idx. timestamp]: content" lines,
80+ // not JSON. (Regression: this orientation was once swapped with `--json`.)
7881 ctx. icp ( )
7982 . current_dir ( & project_dir)
8083 . args ( [
@@ -86,10 +89,51 @@ async fn canister_logs_single_fetch() {
8689 ] )
8790 . assert ( )
8891 . success ( )
89- . stdout ( contains ( "Test message 1" ) )
90- . stdout ( contains ( "Test message 2" ) ) ;
92+ . stdout ( contains ( "]: Test message 1" ) )
93+ . stdout ( contains ( "]: Test message 2" ) )
94+ . stdout ( contains ( "log_records" ) . not ( ) ) ;
95+
96+ // With --json: parseable JSON in the JsonListRecord shape, terminated by a newline.
97+ let json_stdout = ctx
98+ . icp ( )
99+ . current_dir ( & project_dir)
100+ . args ( [
101+ "canister" ,
102+ "logs" ,
103+ "logger" ,
104+ "--json" ,
105+ "--environment" ,
106+ "random-environment" ,
107+ ] )
108+ . assert ( )
109+ . success ( )
110+ . get_output ( )
111+ . stdout
112+ . clone ( ) ;
113+ let json_stdout = String :: from_utf8 ( json_stdout) . expect ( "stdout is not valid UTF-8" ) ;
114+ assert ! (
115+ json_stdout. ends_with( '\n' ) ,
116+ "--json output should end with a newline, got {json_stdout:?}"
117+ ) ;
118+ let parsed: serde_json:: Value =
119+ serde_json:: from_str ( json_stdout. trim ( ) ) . expect ( "--json output should be valid JSON" ) ;
120+ let records = parsed[ "log_records" ]
121+ . as_array ( )
122+ . expect ( "--json output should contain a log_records array" ) ;
123+ for message in [ "Test message 1" , "Test message 2" ] {
124+ let record = records
125+ . iter ( )
126+ . find ( |r| r[ "content" ] . as_str ( ) == Some ( message) )
127+ . unwrap_or_else ( || panic ! ( "--json output should contain {message:?}" ) ) ;
128+ assert ! ( record[ "timestamp" ] . is_u64( ) ) ;
129+ assert ! ( record[ "index" ] . is_u64( ) ) ;
130+ }
91131}
92132
133+ /// Every `--follow` behaviour, in one network start and one `moc` deploy: each phase would
134+ /// otherwise pay for its own, which dominates the wall clock. Covers (1) polling for logs
135+ /// that do not exist yet, (2) `--json` streaming newline-delimited records as they arrive,
136+ /// and (3) a consumer that stops reading ending the command quietly.
93137#[ cfg( unix) ] // moc
94138#[ tokio:: test]
95139async fn canister_logs_follow_mode ( ) {
@@ -126,27 +170,23 @@ async fn canister_logs_follow_mode() {
126170 . assert ( )
127171 . success ( ) ;
128172
129- // Trigger repeated logging (will log 5 times over 5 seconds)
130- ctx. icp ( )
131- . current_dir ( & project_dir)
132- . args ( [
133- "canister" ,
134- "call" ,
135- "--environment" ,
136- "random-environment" ,
137- "logger" ,
138- "log_repeated" ,
139- "(\" Repeated\" )" ,
140- ] )
141- . assert ( )
142- . success ( ) ;
143-
144- // Start following logs with a timeout of 7 seconds (enough to see several logs)
145- // The logs are not present yet, so if e.g. "5 Repeated" is present in stdout after the timeout, then we correctly polled for new logs
146- ctx. icp ( )
147- . current_dir ( & project_dir)
148- . timeout ( Duration :: from_secs ( 7 ) )
149- . args ( [
173+ let call = |method : & str , message : & str | {
174+ ctx. icp ( )
175+ . current_dir ( & project_dir)
176+ . args ( [
177+ "canister" ,
178+ "call" ,
179+ "--environment" ,
180+ "random-environment" ,
181+ "logger" ,
182+ method,
183+ & format ! ( "(\" {message}\" )" ) ,
184+ ] )
185+ . assert ( )
186+ . success ( ) ;
187+ } ;
188+ let follow_args = |json : bool | {
189+ let mut args = vec ! [
150190 "canister" ,
151191 "logs" ,
152192 "logger" ,
@@ -155,14 +195,103 @@ async fn canister_logs_follow_mode() {
155195 "1" ,
156196 "--environment" ,
157197 "random-environment" ,
158- ] )
198+ ] ;
199+ if json {
200+ args. push ( "--json" ) ;
201+ }
202+ args
203+ } ;
204+
205+ // Phase 1: the human-readable output polls for new logs. `log_repeated` returns
206+ // immediately and logs 5 times over 5 seconds from a recurring timer, so none of these
207+ // records exist when the follow starts — seeing "5 Repeated" within the 7s window means
208+ // we really polled rather than replaying the 1-hour lookback.
209+ call ( "log_repeated" , "Repeated" ) ;
210+ ctx. icp ( )
211+ . current_dir ( & project_dir)
212+ . timeout ( Duration :: from_secs ( 7 ) )
213+ . args ( follow_args ( false ) )
159214 . assert ( )
160215 . failure ( ) // Will timeout/be interrupted
161216 . stdout ( contains ( "1 Repeated" ) )
162217 . stdout ( contains ( "2 Repeated" ) )
163218 . stdout ( contains ( "3 Repeated" ) )
164219 . stdout ( contains ( "4 Repeated" ) )
165220 . stdout ( contains ( "5 Repeated" ) ) ;
221+
222+ // Phase 2: `--json` streams newline-delimited records. Same fresh-logs setup, and
223+ // because the timeout SIGKILLs the process rather than letting it exit, nothing buffered
224+ // in stdout is flushed on the way out: any output we observe must have been flushed as
225+ // the records arrived. Parsing each line on its own also pins the delimiting, since
226+ // `serde_json::from_str` rejects the trailing content of a concatenated `{...}{...}`.
227+ call ( "log_repeated" , "Streamed" ) ;
228+ let stdout = ctx
229+ . icp ( )
230+ . current_dir ( & project_dir)
231+ . timeout ( Duration :: from_secs ( 7 ) )
232+ . args ( follow_args ( true ) )
233+ . assert ( )
234+ . failure ( ) // Will timeout/be interrupted
235+ . get_output ( )
236+ . stdout
237+ . clone ( ) ;
238+ let stdout = String :: from_utf8 ( stdout) . expect ( "stdout is not valid UTF-8" ) ;
239+ let mut contents = Vec :: new ( ) ;
240+ for line in stdout. lines ( ) {
241+ let record: serde_json:: Value = serde_json:: from_str ( line)
242+ . unwrap_or_else ( |e| panic ! ( "--follow --json line {line:?} is not valid JSON: {e}" ) ) ;
243+ assert ! ( record[ "timestamp" ] . is_u64( ) ) ;
244+ assert ! ( record[ "index" ] . is_u64( ) ) ;
245+ contents. push (
246+ record[ "content" ]
247+ . as_str ( )
248+ . expect ( "--follow --json record should contain a content string" )
249+ . to_string ( ) ,
250+ ) ;
251+ }
252+ for i in 1 ..=5 {
253+ let message = format ! ( "{i} Streamed" ) ;
254+ assert ! (
255+ contents. iter( ) . any( |c| c. contains( & message) ) ,
256+ "--follow --json output should contain {message:?}, got {contents:?}"
257+ ) ;
258+ }
259+
260+ // Phase 3: a consumer that stops reading — `icp canister logs --follow | head -1` — must
261+ // not make the command report a broken pipe. `--json` writes far more often now that it
262+ // streams, so it reaches the closed pipe first, but both modes share the same write.
263+ for json in [ true , false ] {
264+ let mut child = ctx
265+ . icp_std ( )
266+ . current_dir ( & project_dir)
267+ . args ( follow_args ( json) )
268+ . stdout ( Stdio :: piped ( ) )
269+ . spawn ( )
270+ . expect ( "failed to spawn icp canister logs --follow" ) ;
271+
272+ // Read one record, then close the read end of the pipe, exactly as `head -1` does.
273+ let mut reader = BufReader :: new ( child. stdout . take ( ) . expect ( "stdout is piped" ) ) ;
274+ let mut line = String :: new ( ) ;
275+ reader
276+ . read_line ( & mut line)
277+ . expect ( "failed to read the first record" ) ;
278+ assert ! (
279+ !line. is_empty( ) ,
280+ "expected a first record before the pipe closed (json: {json})"
281+ ) ;
282+ drop ( reader) ;
283+
284+ // Produce another record so the next poll must write to the now-closed pipe. The
285+ // earlier phases left plenty for the first poll to emit, but this makes the write
286+ // independent of how much the lookback happens to return.
287+ call ( "log" , & format ! ( "After close {json}" ) ) ;
288+
289+ let status = child. wait ( ) . expect ( "failed to wait for icp canister logs" ) ;
290+ assert ! (
291+ status. success( ) ,
292+ "a closed stdout pipe should end `--follow` quietly (json: {json}), got {status}"
293+ ) ;
294+ }
166295}
167296
168297#[ cfg( unix) ] // moc
0 commit comments