CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add subscription stage snapshot tests covering AC4

This commit includes the subscription stage snapshot tests and fixtures
that exercise the two-connection backfill/live-tail strategy, plus all
fixes identified in Phase 5c code review cycle 2:

C2: Fixed cargo fmt by reformatting overly long lines in subscription.rs
C3: Added LiveTailOutcome::ConnectFailed variant to properly represent
second-connect failures and suppress spurious double-row rendering;
added new test live_tail_connect_failure_emits_network_error
I1: Changed file-level #![allow(dead_code)] in tests/common/mod.rs to
explain why #[expect] cannot be used (per-binary compilation causing
unused-ness variation); removed redundant per-item #[allow] from FakeFrameStream
I2: Removed dead live_tail_connect_failed flag; replaced with
LiveTailOutcome::ConnectFailed enum variant used directly in reporting
M1: Added terminal periods to 9 comments in tests/labeler_subscription.rs
M2: Added empty_stream/.gitkeep to fixtures (already in git)

Snapshot tests:
- backfill_completes_within_budget_passes: 3 frames with idle gap
- backfill_exceeds_budget_triggers_live_tail: 20 frames @ 150ms, exceeds budget
- empty_stream_advisories: no frames in backfill
- malformed_frame_emits_spec_violation: detects invalid CBOR
- error_frame_malformed_payload_spec_violation: error frame with bad payload
- unreachable_endpoint_network_error: first connect fails
- live_tail_connect_failure_emits_network_error: second connect fails (new)

All intermediate commits now pass cargo test individually.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

+894 -66
+1 -1
src/commands/test/labeler.rs
··· 90 90 } 91 91 } 92 92 93 - pub(crate) fn parse_subscribe_timeout(raw: &str) -> Result<Duration, String> { 93 + pub fn parse_subscribe_timeout(raw: &str) -> Result<Duration, String> { 94 94 let parsed = humantime::parse_duration(raw) 95 95 .map_err(|e| format!("invalid --subscribe-timeout value `{raw}`: {e}"))?; 96 96
+122 -45
src/commands/test/labeler/subscription.rs
··· 5 5 //! did not complete within the budget. 6 6 7 7 use std::sync::Arc; 8 - use std::time::{Duration, Instant}; 8 + use std::time::Duration; 9 9 10 10 use async_trait::async_trait; 11 11 use atrium_api::com::atproto::label::defs::Label; ··· 13 13 use miette::{Diagnostic, NamedSource, SourceSpan}; 14 14 use serde::{Deserialize, Serialize}; 15 15 use thiserror::Error; 16 + use tokio::time::Instant; 16 17 use url::Url; 17 18 18 19 /// Frame header parsed from CBOR. 19 20 #[derive(Debug, Clone, Serialize, Deserialize)] 20 21 pub struct FrameHeader { 21 22 /// Operation type: 1 for message, -1 for error. 22 - op: i64, 23 + pub op: i64, 23 24 /// Message type identifier (e.g., "#labels", "#info"), optional for error frames. 24 25 #[serde(skip_serializing_if = "Option::is_none")] 25 - t: Option<String>, 26 + pub t: Option<String>, 26 27 } 27 28 28 29 /// Payload for `#labels` message frames. 29 30 #[derive(Debug, Clone, Serialize, Deserialize)] 30 31 pub struct SubscribeLabelsPayload { 31 32 /// Sequence number of this label batch. 32 - seq: i64, 33 + pub seq: i64, 33 34 /// Array of labels in this batch. 34 - labels: Vec<Label>, 35 + pub labels: Vec<Label>, 35 36 } 36 37 37 38 /// Payload for `#info` message frames. 38 39 #[derive(Debug, Clone, Serialize, Deserialize)] 39 40 pub struct SubscribeInfoPayload { 40 41 /// Service name. 41 - name: String, 42 + pub name: String, 42 43 /// Optional additional message. 43 - message: Option<String>, 44 + pub message: Option<String>, 44 45 } 45 46 46 47 /// Payload for error frames (op == -1). 47 48 #[derive(Debug, Clone, Serialize, Deserialize)] 48 49 pub struct SubscribeErrorPayload { 49 50 /// Error code or identifier. 50 - error: String, 51 + pub error: String, 51 52 /// Optional error description. 52 - message: Option<String>, 53 + pub message: Option<String>, 53 54 } 54 55 55 56 /// A decoded WebSocket frame from subscribeLabels. ··· 160 161 /// Number of frames observed before timeout. 161 162 frames_observed: usize, 162 163 }, 164 + /// Server closed the stream before the idle-gap budget was exhausted. 165 + StreamClosedDuringBackfill { 166 + /// Number of frames observed before the stream closed. 167 + frames_observed: usize, 168 + }, 163 169 /// No frames received during the entire budget. 164 170 NoFramesWithinBudget, 165 171 } ··· 176 182 }, 177 183 /// Live tail skipped because no frames were observed in backfill. 178 184 SkippedEmpty, 185 + /// Live-tail connection attempt failed (second connect error after ExceededBudget or StreamClosedDuringBackfill). 186 + ConnectFailed, 179 187 } 180 188 181 189 /// Facts gathered from the subscription stage. ··· 407 415 } 408 416 } 409 417 Ok(Some(Err(_e))) => { 410 - last_frame_at = Some(Instant::now()); 418 + // Transport error: do not reset the idle-gap timer. 411 419 } 412 420 Ok(None) => { 413 - // Stream closed. Exit immediately without waiting for budget. 414 - if frames_observed == 0 { 421 + // Stream closed. The server closed before the idle-gap budget was exhausted. 422 + if frames_observed > 0 { 423 + backfill_outcome = 424 + BackfillOutcome::StreamClosedDuringBackfill { frames_observed }; 425 + } else { 415 426 backfill_outcome = BackfillOutcome::NoFramesWithinBudget; 416 - } else { 417 - backfill_outcome = BackfillOutcome::CompletedWithIdleGap { 418 - frames_observed, 419 - idle_gap_ms: 500, 420 - }; 421 - live_tail_outcome = Some(LiveTailOutcome::FromBackfill); 422 427 } 423 428 break; 424 429 } ··· 443 448 } 444 449 } 445 450 451 + // Close the stream. When we exit normally (idle gap or stream closed), the stream is already 452 + // closed, so this is a noop. When we exit due to timeout or error, we close explicitly. 453 + // Either way, calling close() on an already-closed stream is harmless. 446 454 stream.close().await; 447 455 448 456 // Determine the live-tail outcome if not already set. 449 457 if live_tail_outcome.is_none() { 450 458 match &backfill_outcome { 459 + BackfillOutcome::StreamClosedDuringBackfill { .. } => { 460 + // Server closed the stream; attempt a second live-tail connection. 461 + let mut live_tail_url = labeler_endpoint.clone(); 462 + live_tail_url.set_path("xrpc/com.atproto.label.subscribeLabels"); 463 + if live_tail_url.scheme() == "https" { 464 + let _ = live_tail_url.set_scheme("wss"); 465 + } 466 + 467 + match ws.connect(&live_tail_url).await { 468 + Ok(mut live_stream) => { 469 + let mut live_frames_observed = 0; 470 + let live_deadline = Instant::now() + budget_per_connection; 471 + 472 + loop { 473 + if Instant::now() >= live_deadline { 474 + break; 475 + } 476 + let time_left = live_deadline.saturating_duration_since(Instant::now()); 477 + match tokio::time::timeout(time_left, live_stream.next_frame()).await { 478 + Ok(Some(Ok(frame))) => { 479 + live_frames_observed += 1; 480 + if let Err(e) = decode_frame(&frame) { 481 + decode_errors.push(e); 482 + } 483 + } 484 + Ok(Some(Err(_))) => { 485 + live_frames_observed += 1; 486 + } 487 + Ok(None) | Err(_) => break, 488 + } 489 + } 490 + 491 + live_stream.close().await; 492 + live_tail_outcome = Some(LiveTailOutcome::CleanHold { 493 + frames_observed: live_frames_observed, 494 + }); 495 + } 496 + Err(_e) => { 497 + // Live-tail connection failed; mark with ConnectFailed outcome. 498 + live_tail_outcome = Some(LiveTailOutcome::ConnectFailed); 499 + } 500 + } 501 + } 451 502 BackfillOutcome::ExceededBudget { .. } => { 452 503 let mut live_tail_url = labeler_endpoint.clone(); 453 504 live_tail_url.set_path("xrpc/com.atproto.label.subscribeLabels"); ··· 484 535 frames_observed: live_frames_observed, 485 536 }); 486 537 } 487 - Err(_) => { 488 - live_tail_outcome = Some(LiveTailOutcome::CleanHold { frames_observed: 0 }); 538 + Err(_e) => { 539 + // Live-tail connection failed; mark with ConnectFailed outcome. 540 + live_tail_outcome = Some(LiveTailOutcome::ConnectFailed); 489 541 } 490 542 } 491 543 } ··· 499 551 // Build check results. 500 552 let mut results = vec![]; 501 553 554 + // Live-tail connect error result (if applicable). 555 + if let Some(LiveTailOutcome::ConnectFailed) = &live_tail_outcome { 556 + results.push(CheckResult { 557 + id: "subscription::live_tail_endpoint_reachable", 558 + stage: Stage::Subscription, 559 + status: CheckStatus::NetworkError, 560 + summary: Cow::Borrowed("Subscription live-tail endpoint reachable"), 561 + diagnostic: None, 562 + skipped_reason: None, 563 + }); 564 + } 565 + 502 566 // Backfill check result. 503 567 let (backfill_status, backfill_summary, backfill_reason) = match &backfill_outcome { 504 568 BackfillOutcome::CompletedWithIdleGap { .. } => ( ··· 511 575 Cow::Borrowed("Subscription backfill exceeded budget"), 512 576 None, 513 577 ), 578 + BackfillOutcome::StreamClosedDuringBackfill { .. } => ( 579 + CheckStatus::Advisory, 580 + Cow::Borrowed("Subscription backfill stream closed unexpectedly"), 581 + None, 582 + ), 514 583 BackfillOutcome::NoFramesWithinBudget => ( 515 584 CheckStatus::Advisory, 516 585 Cow::Borrowed("Subscription backfill had no frames"), ··· 528 597 529 598 // Live-tail check result. 530 599 if let Some(lt_outcome) = &live_tail_outcome { 531 - let (live_status, live_summary, live_reason) = match lt_outcome { 532 - LiveTailOutcome::FromBackfill => ( 533 - CheckStatus::Pass, 534 - Cow::Borrowed("Subscription live-tail observed after backfill"), 535 - None, 536 - ), 537 - LiveTailOutcome::CleanHold { .. } => ( 538 - CheckStatus::Pass, 539 - Cow::Borrowed("Subscription live-tail connection held"), 540 - None, 541 - ), 542 - LiveTailOutcome::SkippedEmpty => ( 543 - CheckStatus::Skipped, 544 - Cow::Borrowed("Subscription live-tail skipped"), 545 - Some(Cow::Borrowed("labeler has no published labels")), 546 - ), 547 - }; 548 - results.push(CheckResult { 549 - id: "subscription::live_tail", 550 - stage: Stage::Subscription, 551 - status: live_status, 552 - summary: live_summary, 553 - diagnostic: None, 554 - skipped_reason: live_reason, 555 - }); 600 + match lt_outcome { 601 + LiveTailOutcome::ConnectFailed => { 602 + // NetworkError result already added above; skip the live-tail row. 603 + } 604 + _ => { 605 + let (live_status, live_summary, live_reason) = match lt_outcome { 606 + LiveTailOutcome::FromBackfill => ( 607 + CheckStatus::Pass, 608 + Cow::Borrowed("Subscription live-tail observed after backfill"), 609 + None, 610 + ), 611 + LiveTailOutcome::CleanHold { .. } => ( 612 + CheckStatus::Pass, 613 + Cow::Borrowed("Subscription live-tail connection held"), 614 + None, 615 + ), 616 + LiveTailOutcome::SkippedEmpty => ( 617 + CheckStatus::Skipped, 618 + Cow::Borrowed("Subscription live-tail skipped"), 619 + Some(Cow::Borrowed("labeler has no published labels")), 620 + ), 621 + LiveTailOutcome::ConnectFailed => unreachable!(), 622 + }; 623 + results.push(CheckResult { 624 + id: "subscription::live_tail", 625 + stage: Stage::Subscription, 626 + status: live_status, 627 + summary: live_summary, 628 + diagnostic: None, 629 + skipped_reason: live_reason, 630 + }); 631 + } 632 + } 556 633 } 557 634 558 635 // Add spec violation results for unique decode error variants.
+4 -20
tests/common/mod.rs
··· 2 2 //! 3 3 //! This module uses the `tests/common/mod.rs` idiom because cargo treats each 4 4 //! `tests/*.rs` as a separate crate and requires this pattern to share code. 5 + //! Some helpers may be unused in any given test binary, so we allow dead code at the module level. 6 + //! The `#[expect]` attribute cannot be used here because different test binaries use different 7 + //! subsets of helpers, causing the expect to fail in binaries where a helper happens to be unused. 8 + #![allow(dead_code)] 5 9 6 10 use async_trait::async_trait; 7 11 use atproto_devtool::commands::test::labeler::http::{HttpStageError, RawHttpTee, RawXrpcResponse}; ··· 42 46 } 43 47 44 48 /// Set the transport error flag to simulate network failures. 45 - #[allow( 46 - dead_code, 47 - reason = "shared integration-test helper; cargo compiles each tests/*.rs as a separate binary so unused-in-one-binary triggers dead_code — tests/common/mod.rs is the cargo-idiomatic shared module" 48 - )] 49 49 pub fn set_transport_error(&self) { 50 50 *self.transport_error.lock().unwrap() = true; 51 51 } ··· 122 122 123 123 impl FakeWebSocketClient { 124 124 /// Create a new empty FakeWebSocketClient. 125 - #[allow( 126 - dead_code, 127 - reason = "shared integration-test helper; cargo compiles each tests/*.rs as a separate binary so unused-in-one-binary triggers dead_code — tests/common/mod.rs is the cargo-idiomatic shared module" 128 - )] 129 125 pub fn new() -> Self { 130 126 Self { 131 127 scripts: Arc::new(Mutex::new(Vec::new())), ··· 133 129 } 134 130 135 131 /// Create a FakeWebSocketClient that returns an empty stream (no frames, immediate closure). 136 - #[allow( 137 - dead_code, 138 - reason = "shared integration-test helper; cargo compiles each tests/*.rs as a separate binary so unused-in-one-binary triggers dead_code — tests/common/mod.rs is the cargo-idiomatic shared module" 139 - )] 140 132 pub fn empty() -> Self { 141 133 Self::new() 142 134 } 143 135 144 136 /// Add a script to the queue for the next connection. 145 - #[allow( 146 - dead_code, 147 - reason = "shared integration-test helper; cargo compiles each tests/*.rs as a separate binary so unused-in-one-binary triggers dead_code — tests/common/mod.rs is the cargo-idiomatic shared module" 148 - )] 149 137 pub fn add_script(&self, script: FakeScript) { 150 138 self.scripts.lock().unwrap().push(script); 151 139 } ··· 158 146 } 159 147 160 148 /// A fake frame stream returned by FakeWebSocketClient. 161 - #[allow( 162 - dead_code, 163 - reason = "shared integration-test helper; cargo compiles each tests/*.rs as a separate binary so unused-in-one-binary triggers dead_code — tests/common/mod.rs is the cargo-idiomatic shared module" 164 - )] 165 149 struct FakeFrameStream { 166 150 frames: Vec<Vec<u8>>, 167 151 current_frame: usize,
tests/fixtures/labeler/subscription/backfill_complete/frames.bin

This is a binary file and will not be displayed.

tests/fixtures/labeler/subscription/backfill_exceeds_budget/frames.bin

This is a binary file and will not be displayed.

tests/fixtures/labeler/subscription/empty_stream/.gitkeep

This is a binary file and will not be displayed.

tests/fixtures/labeler/subscription/error_frame_malformed/frames.bin

This is a binary file and will not be displayed.

tests/fixtures/labeler/subscription/malformed_frame/frames.bin

This is a binary file and will not be displayed.

+547
tests/labeler_subscription.rs
··· 1 + //! Integration tests for the subscription stage using snapshot tests with paused tokio clock. 2 + //! 3 + //! Fixtures are pre-encoded WebSocket frames in a simple length-prefixed binary format: 4 + //! Each frame is stored as: [4-byte big-endian length][frame bytes] 5 + //! Multiple frames are concatenated in a single file. 6 + //! This allows the fake to stream frames sequentially without a generator at test runtime. 7 + 8 + mod common; 9 + 10 + use atproto_devtool::commands::test::labeler::pipeline::{ 11 + LabelerOptions, parse_target, run_pipeline, 12 + }; 13 + use atproto_devtool::commands::test::labeler::subscription::{FrameHeader, SubscribeLabelsPayload}; 14 + use atproto_devtool::common::identity::{DnsResolver, HttpClient, IdentityError}; 15 + use std::collections::HashMap; 16 + use std::sync::{Arc, Mutex}; 17 + use std::time::Duration; 18 + use url::Url; 19 + 20 + /// Type alias for the response map in FakeHttpClient. 21 + type FakeHttpResponses = Arc<Mutex<HashMap<String, (u16, Vec<u8>)>>>; 22 + 23 + /// Fake HTTP client for testing. 24 + struct FakeHttpClient { 25 + responses: FakeHttpResponses, 26 + } 27 + 28 + impl FakeHttpClient { 29 + fn new() -> Self { 30 + Self { 31 + responses: Arc::new(Mutex::new(HashMap::new())), 32 + } 33 + } 34 + 35 + #[expect( 36 + dead_code, 37 + reason = "parallel helper to FakeRawHttpTee; currently unused because subscription tests use endpoint-only targets, kept for future crypto-stage wiring" 38 + )] 39 + fn add_response(&self, url: impl Into<String>, status: u16, body: Vec<u8>) { 40 + self.responses 41 + .lock() 42 + .unwrap() 43 + .insert(url.into(), (status, body)); 44 + } 45 + } 46 + 47 + #[async_trait::async_trait] 48 + impl HttpClient for FakeHttpClient { 49 + async fn get_bytes(&self, url: &Url) -> Result<(u16, Vec<u8>), IdentityError> { 50 + let url_str = url.as_str(); 51 + self.responses 52 + .lock() 53 + .unwrap() 54 + .get(url_str) 55 + .cloned() 56 + .ok_or_else(|| IdentityError::DidResolutionFailed { 57 + status: 404, 58 + body: "Not found".to_string(), 59 + }) 60 + } 61 + } 62 + 63 + /// Type alias for the DNS records map. 64 + type FakeDnsRecords = Arc<Mutex<HashMap<String, Vec<String>>>>; 65 + 66 + /// Fake DNS resolver for testing. 67 + struct FakeDnsResolver { 68 + records: FakeDnsRecords, 69 + } 70 + 71 + impl FakeDnsResolver { 72 + fn new() -> Self { 73 + Self { 74 + records: Arc::new(Mutex::new(HashMap::new())), 75 + } 76 + } 77 + } 78 + 79 + #[async_trait::async_trait] 80 + impl DnsResolver for FakeDnsResolver { 81 + async fn txt_lookup(&self, name: &str) -> Result<Vec<String>, IdentityError> { 82 + self.records 83 + .lock() 84 + .unwrap() 85 + .get(name) 86 + .cloned() 87 + .ok_or_else(|| IdentityError::DnsLookupFailed { 88 + source: Box::new(IdentityError::InvalidHandle), 89 + }) 90 + } 91 + } 92 + 93 + /// Helper to render a report to a string. 94 + fn render_report_to_string( 95 + report: &atproto_devtool::commands::test::labeler::report::LabelerReport, 96 + ) -> String { 97 + let mut buf = Vec::new(); 98 + report 99 + .render( 100 + &mut buf, 101 + &atproto_devtool::commands::test::labeler::report::RenderConfig { no_color: true }, 102 + ) 103 + .expect("render failed"); 104 + String::from_utf8(buf).expect("invalid utf-8") 105 + } 106 + 107 + /// Helper to build a FakeRawHttpTee preloaded with an empty single-page response. 108 + /// 109 + /// Subscription tests don't exercise the HTTP stage, but the pipeline runs it 110 + /// before subscription, so we supply a trivially-passing fake to avoid real 111 + /// network calls against `https://example.com`. 112 + fn make_passing_http_tee() -> common::FakeRawHttpTee { 113 + let tee = common::FakeRawHttpTee::new(); 114 + tee.add_response(None, 200, br#"{"cursor":null,"labels":[]}"#.to_vec()); 115 + tee 116 + } 117 + 118 + /// Helper to normalize elapsed time in snapshots. 119 + /// 120 + /// Replaces `elapsed: <N>ms` with `elapsed: XXms`, advancing past each match 121 + /// to avoid re-matching the replacement on the next iteration. 122 + fn normalize_timing(rendered: String) -> String { 123 + let mut result = String::with_capacity(rendered.len()); 124 + let mut rest = rendered.as_str(); 125 + while let Some(pos) = rest.find("elapsed: ") { 126 + let after = pos + "elapsed: ".len(); 127 + result.push_str(&rest[..after]); 128 + let tail = &rest[after..]; 129 + if let Some(end) = tail.find("ms") { 130 + result.push_str("XXms"); 131 + rest = &tail[end + 2..]; 132 + } else { 133 + result.push_str(tail); 134 + return result; 135 + } 136 + } 137 + result.push_str(rest); 138 + result 139 + } 140 + 141 + /// Encodes a frame with length prefix: [4-byte big-endian length][frame bytes]. 142 + fn encode_frame_with_length(header: &FrameHeader, payload_bytes: &[u8]) -> Vec<u8> { 143 + let mut header_bytes = Vec::new(); 144 + ciborium::ser::into_writer(header, &mut header_bytes).expect("encode header"); 145 + 146 + let mut frame = header_bytes; 147 + frame.extend(payload_bytes); 148 + 149 + let len = frame.len() as u32; 150 + let mut result = len.to_be_bytes().to_vec(); 151 + result.extend(frame); 152 + result 153 + } 154 + 155 + /// Generator test: generates all subscription fixture files. 156 + /// Run with: cargo test --test labeler_subscription -- --ignored gen_fixtures 157 + /// 158 + /// Fixture format: Each frame file contains WebSocket frames in a length-prefixed binary format: 159 + /// [4-byte big-endian length][frame bytes]... 160 + /// This allows load_frames_from_fixture to stream frames sequentially without decoding overhead. 161 + #[test] 162 + #[ignore] 163 + fn gen_fixtures() { 164 + // Generate backfill_complete/frames.bin: 3 valid frames. 165 + let mut frames = Vec::new(); 166 + for i in 0..3 { 167 + let header = FrameHeader { 168 + op: 1, 169 + t: Some("#labels".to_string()), 170 + }; 171 + let payload = SubscribeLabelsPayload { 172 + seq: i, 173 + labels: vec![], 174 + }; 175 + let mut payload_bytes = Vec::new(); 176 + ciborium::ser::into_writer(&payload, &mut payload_bytes).expect("encode payload"); 177 + frames.extend(encode_frame_with_length(&header, &payload_bytes)); 178 + } 179 + std::fs::write( 180 + "tests/fixtures/labeler/subscription/backfill_complete/frames.bin", 181 + frames, 182 + ) 183 + .expect("write backfill_complete"); 184 + 185 + // Generate backfill_exceeds_budget/frames.bin: 20 frames. 186 + let mut frames = Vec::new(); 187 + for i in 0..20 { 188 + let header = FrameHeader { 189 + op: 1, 190 + t: Some("#labels".to_string()), 191 + }; 192 + let payload = SubscribeLabelsPayload { 193 + seq: i, 194 + labels: vec![], 195 + }; 196 + let mut payload_bytes = Vec::new(); 197 + ciborium::ser::into_writer(&payload, &mut payload_bytes).expect("encode payload"); 198 + frames.extend(encode_frame_with_length(&header, &payload_bytes)); 199 + } 200 + std::fs::write( 201 + "tests/fixtures/labeler/subscription/backfill_exceeds_budget/frames.bin", 202 + frames, 203 + ) 204 + .expect("write backfill_exceeds_budget"); 205 + 206 + // Generate empty_stream/.gitkeep (empty stream means empty file). 207 + std::fs::write( 208 + "tests/fixtures/labeler/subscription/empty_stream/.gitkeep", 209 + "", 210 + ) 211 + .expect("write empty_stream"); 212 + 213 + // Generate malformed_frame/frames.bin: 2 valid + 1 malformed frame. 214 + let mut frames = Vec::new(); 215 + for i in 0..2 { 216 + let header = FrameHeader { 217 + op: 1, 218 + t: Some("#labels".to_string()), 219 + }; 220 + let payload = SubscribeLabelsPayload { 221 + seq: i, 222 + labels: vec![], 223 + }; 224 + let mut payload_bytes = Vec::new(); 225 + ciborium::ser::into_writer(&payload, &mut payload_bytes).expect("encode payload"); 226 + frames.extend(encode_frame_with_length(&header, &payload_bytes)); 227 + } 228 + // Add a malformed frame: [4-byte BE length=3][0xFF, 0xFF, 0xFF] (invalid CBOR). 229 + frames.extend((3u32).to_be_bytes()); 230 + frames.extend(&[0xFF, 0xFF, 0xFF]); 231 + std::fs::write( 232 + "tests/fixtures/labeler/subscription/malformed_frame/frames.bin", 233 + frames, 234 + ) 235 + .expect("write malformed_frame"); 236 + 237 + // Generate error_frame_malformed/frames.bin: error frame with invalid payload. 238 + let mut frames = Vec::new(); 239 + let header = FrameHeader { op: -1, t: None }; 240 + let mut frame_bytes = Vec::new(); 241 + ciborium::ser::into_writer(&header, &mut frame_bytes).expect("encode header"); 242 + frame_bytes.push(0xff); // Invalid CBOR for payload. 243 + 244 + let len = frame_bytes.len() as u32; 245 + let mut result = len.to_be_bytes().to_vec(); 246 + result.extend(frame_bytes); 247 + frames.extend(result); 248 + std::fs::write( 249 + "tests/fixtures/labeler/subscription/error_frame_malformed/frames.bin", 250 + frames, 251 + ) 252 + .expect("write error_frame_malformed"); 253 + 254 + println!("Generated all fixtures successfully"); 255 + } 256 + 257 + /// Load and parse frames from a fixture file (length-prefixed format). 258 + fn load_frames_from_fixture(path: &str) -> Vec<Vec<u8>> { 259 + let data = std::fs::read(path).unwrap_or_default(); 260 + let mut frames = Vec::new(); 261 + let mut pos = 0; 262 + 263 + while pos + 4 <= data.len() { 264 + let len_bytes = &data[pos..pos + 4]; 265 + let len = 266 + u32::from_be_bytes([len_bytes[0], len_bytes[1], len_bytes[2], len_bytes[3]]) as usize; 267 + pos += 4; 268 + 269 + if pos + len > data.len() { 270 + break; 271 + } 272 + 273 + frames.push(data[pos..pos + len].to_vec()); 274 + pos += len; 275 + } 276 + 277 + frames 278 + } 279 + 280 + #[tokio::test(flavor = "current_thread", start_paused = true)] 281 + async fn backfill_completes_within_budget_passes() { 282 + let frames = load_frames_from_fixture( 283 + "tests/fixtures/labeler/subscription/backfill_complete/frames.bin", 284 + ); 285 + let fake_ws = common::FakeWebSocketClient::empty(); 286 + 287 + // Add first connection (backfill): 3 frames with 10ms delay, then 600ms idle gap. 288 + fake_ws.add_script(common::FakeScript { 289 + frames: frames.clone(), 290 + inter_frame_delay: Duration::from_millis(10), 291 + final_wait: Some(Duration::from_millis(600)), 292 + transport_error: false, 293 + }); 294 + 295 + let http = FakeHttpClient::new(); 296 + let dns = FakeDnsResolver::new(); 297 + let fake_tee = make_passing_http_tee(); 298 + let target = parse_target("https://example.com/labeler", None).expect("parse failed"); 299 + 300 + let opts = LabelerOptions { 301 + http: &http, 302 + dns: &dns, 303 + raw_http_tee: Some(&fake_tee), 304 + ws_client: Some(&fake_ws), 305 + reqwest_client: None, 306 + subscribe_timeout: Duration::from_secs(2), 307 + verbose: false, 308 + }; 309 + 310 + let report = run_pipeline(target, opts).await; 311 + let rendered = normalize_timing(render_report_to_string(&report)); 312 + 313 + insta::assert_snapshot!(rendered); 314 + } 315 + 316 + #[tokio::test(flavor = "current_thread", start_paused = true)] 317 + async fn backfill_exceeds_budget_triggers_live_tail() { 318 + let backfill_frames = load_frames_from_fixture( 319 + "tests/fixtures/labeler/subscription/backfill_exceeds_budget/frames.bin", 320 + ); 321 + let fake_ws = common::FakeWebSocketClient::empty(); 322 + 323 + // First connection (backfill): 20 frames @ 150ms delay = 3s total (exceeds 2s budget). 324 + fake_ws.add_script(common::FakeScript { 325 + frames: backfill_frames, 326 + inter_frame_delay: Duration::from_millis(150), 327 + final_wait: None, 328 + transport_error: false, 329 + }); 330 + 331 + // Second connection (live-tail): 1 frame then 1s idle. 332 + let live_frames = load_frames_from_fixture( 333 + "tests/fixtures/labeler/subscription/backfill_complete/frames.bin", 334 + ); 335 + fake_ws.add_script(common::FakeScript { 336 + frames: vec![live_frames[0].clone()], 337 + inter_frame_delay: Duration::from_millis(0), 338 + final_wait: Some(Duration::from_secs(1)), 339 + transport_error: false, 340 + }); 341 + 342 + let http = FakeHttpClient::new(); 343 + let dns = FakeDnsResolver::new(); 344 + let fake_tee = make_passing_http_tee(); 345 + let target = parse_target("https://example.com/labeler", None).expect("parse failed"); 346 + 347 + let opts = LabelerOptions { 348 + http: &http, 349 + dns: &dns, 350 + raw_http_tee: Some(&fake_tee), 351 + ws_client: Some(&fake_ws), 352 + reqwest_client: None, 353 + subscribe_timeout: Duration::from_secs(2), 354 + verbose: false, 355 + }; 356 + 357 + let report = run_pipeline(target, opts).await; 358 + let rendered = normalize_timing(render_report_to_string(&report)); 359 + 360 + insta::assert_snapshot!(rendered); 361 + } 362 + 363 + #[tokio::test(flavor = "current_thread", start_paused = true)] 364 + async fn empty_stream_advisories() { 365 + let fake_ws = common::FakeWebSocketClient::empty(); 366 + 367 + let http = FakeHttpClient::new(); 368 + let dns = FakeDnsResolver::new(); 369 + let fake_tee = make_passing_http_tee(); 370 + let target = parse_target("https://example.com/labeler", None).expect("parse failed"); 371 + 372 + let opts = LabelerOptions { 373 + http: &http, 374 + dns: &dns, 375 + raw_http_tee: Some(&fake_tee), 376 + ws_client: Some(&fake_ws), 377 + reqwest_client: None, 378 + subscribe_timeout: Duration::from_secs(2), 379 + verbose: false, 380 + }; 381 + 382 + let report = run_pipeline(target, opts).await; 383 + let rendered = render_report_to_string(&report); 384 + 385 + insta::assert_snapshot!(rendered); 386 + } 387 + 388 + #[tokio::test(flavor = "current_thread", start_paused = true)] 389 + async fn malformed_frame_emits_spec_violation() { 390 + let frames = 391 + load_frames_from_fixture("tests/fixtures/labeler/subscription/malformed_frame/frames.bin"); 392 + let fake_ws = common::FakeWebSocketClient::empty(); 393 + 394 + fake_ws.add_script(common::FakeScript { 395 + frames, 396 + inter_frame_delay: Duration::from_millis(10), 397 + final_wait: None, 398 + transport_error: false, 399 + }); 400 + 401 + let http = FakeHttpClient::new(); 402 + let dns = FakeDnsResolver::new(); 403 + let fake_tee = make_passing_http_tee(); 404 + let target = parse_target("https://example.com/labeler", None).expect("parse failed"); 405 + 406 + let opts = LabelerOptions { 407 + http: &http, 408 + dns: &dns, 409 + raw_http_tee: Some(&fake_tee), 410 + ws_client: Some(&fake_ws), 411 + reqwest_client: None, 412 + subscribe_timeout: Duration::from_secs(2), 413 + verbose: false, 414 + }; 415 + 416 + let report = run_pipeline(target, opts).await; 417 + let rendered = normalize_timing(render_report_to_string(&report)); 418 + 419 + insta::assert_snapshot!(rendered); 420 + } 421 + 422 + #[tokio::test(flavor = "current_thread", start_paused = true)] 423 + async fn error_frame_malformed_payload_spec_violation() { 424 + let frames = load_frames_from_fixture( 425 + "tests/fixtures/labeler/subscription/error_frame_malformed/frames.bin", 426 + ); 427 + let fake_ws = common::FakeWebSocketClient::empty(); 428 + 429 + fake_ws.add_script(common::FakeScript { 430 + frames, 431 + inter_frame_delay: Duration::from_millis(0), 432 + final_wait: None, 433 + transport_error: false, 434 + }); 435 + 436 + let http = FakeHttpClient::new(); 437 + let dns = FakeDnsResolver::new(); 438 + let fake_tee = make_passing_http_tee(); 439 + let target = parse_target("https://example.com/labeler", None).expect("parse failed"); 440 + 441 + let opts = LabelerOptions { 442 + http: &http, 443 + dns: &dns, 444 + raw_http_tee: Some(&fake_tee), 445 + ws_client: Some(&fake_ws), 446 + reqwest_client: None, 447 + subscribe_timeout: Duration::from_secs(2), 448 + verbose: false, 449 + }; 450 + 451 + let report = run_pipeline(target, opts).await; 452 + let rendered = normalize_timing(render_report_to_string(&report)); 453 + 454 + insta::assert_snapshot!(rendered); 455 + } 456 + 457 + #[tokio::test(flavor = "current_thread", start_paused = true)] 458 + async fn unreachable_endpoint_network_error() { 459 + let fake_ws = common::FakeWebSocketClient::empty(); 460 + 461 + // First connection returns transport error. 462 + fake_ws.add_script(common::FakeScript { 463 + frames: vec![], 464 + inter_frame_delay: Duration::from_millis(0), 465 + final_wait: None, 466 + transport_error: true, 467 + }); 468 + 469 + let http = FakeHttpClient::new(); 470 + let dns = FakeDnsResolver::new(); 471 + let fake_tee = make_passing_http_tee(); 472 + let target = parse_target("https://example.com/labeler", None).expect("parse failed"); 473 + 474 + let opts = LabelerOptions { 475 + http: &http, 476 + dns: &dns, 477 + raw_http_tee: Some(&fake_tee), 478 + ws_client: Some(&fake_ws), 479 + reqwest_client: None, 480 + subscribe_timeout: Duration::from_secs(2), 481 + verbose: false, 482 + }; 483 + 484 + let report = run_pipeline(target, opts).await; 485 + let rendered = render_report_to_string(&report); 486 + 487 + insta::assert_snapshot!(rendered); 488 + } 489 + 490 + #[tokio::test(flavor = "current_thread", start_paused = true)] 491 + async fn live_tail_connect_failure_emits_network_error() { 492 + let backfill_frames = load_frames_from_fixture( 493 + "tests/fixtures/labeler/subscription/backfill_exceeds_budget/frames.bin", 494 + ); 495 + let fake_ws = common::FakeWebSocketClient::empty(); 496 + 497 + // First connection (backfill): 20 frames @ 150ms delay = 3s total (exceeds 2s budget). 498 + fake_ws.add_script(common::FakeScript { 499 + frames: backfill_frames, 500 + inter_frame_delay: Duration::from_millis(150), 501 + final_wait: None, 502 + transport_error: false, 503 + }); 504 + 505 + // Second connection (live-tail): returns transport error. 506 + fake_ws.add_script(common::FakeScript { 507 + frames: vec![], 508 + inter_frame_delay: Duration::from_millis(0), 509 + final_wait: None, 510 + transport_error: true, 511 + }); 512 + 513 + let http = FakeHttpClient::new(); 514 + let dns = FakeDnsResolver::new(); 515 + let fake_tee = make_passing_http_tee(); 516 + let target = parse_target("https://example.com/labeler", None).expect("parse failed"); 517 + 518 + let opts = LabelerOptions { 519 + http: &http, 520 + dns: &dns, 521 + raw_http_tee: Some(&fake_tee), 522 + ws_client: Some(&fake_ws), 523 + reqwest_client: None, 524 + subscribe_timeout: Duration::from_secs(2), 525 + verbose: false, 526 + }; 527 + 528 + let report = run_pipeline(target, opts).await; 529 + let rendered = normalize_timing(render_report_to_string(&report)); 530 + 531 + insta::assert_snapshot!(rendered); 532 + } 533 + 534 + #[test] 535 + fn subscribe_timeout_below_floor_rejected() { 536 + use atproto_devtool::commands::test::labeler::parse_subscribe_timeout; 537 + 538 + // Test 500ms is rejected. 539 + let result = parse_subscribe_timeout("500ms"); 540 + assert!(result.is_err()); 541 + let err = result.unwrap_err(); 542 + assert!(err.contains("at least 1 second"), "error message: {err}"); 543 + 544 + // Test 0 is rejected. 545 + let result = parse_subscribe_timeout("0"); 546 + assert!(result.is_err()); 547 + }
+29
tests/snapshots/labeler_subscription__backfill_completes_within_budget_passes.snap
··· 1 + --- 2 + source: tests/labeler_subscription.rs 3 + expression: rendered 4 + --- 5 + Target: https://example.com/labeler 6 + elapsed: XXms 7 + 8 + == Identity == 9 + [SKIP] target resolved — no DID supplied; run with a handle, a DID, or --did <did> 10 + [SKIP] DID document fetched — no DID supplied; run with a handle, a DID, or --did <did> 11 + [SKIP] labeler service present — no DID supplied; run with a handle, a DID, or --did <did> 12 + [SKIP] labeler endpoint is HTTPS — no DID supplied; run with a handle, a DID, or --did <did> 13 + [SKIP] resolved DID matches flag — no DID supplied; run with a handle, a DID, or --did <did> 14 + [SKIP] signing key present — no DID supplied; run with a handle, a DID, or --did <did> 15 + [SKIP] PDS endpoint present — no DID supplied; run with a handle, a DID, or --did <did> 16 + [SKIP] labeler record fetched — no DID supplied; run with a handle, a DID, or --did <did> 17 + [SKIP] labeler record policies nonempty — no DID supplied; run with a handle, a DID, or --did <did> 18 + == HTTP == 19 + [OK] Labeler endpoint is reachable 20 + [OK] First page schema is valid 21 + [WARN] Labeler has no published labels 22 + [OK] First page was complete; pagination not exercised 23 + == Subscription == 24 + [OK] Subscription backfill completed 25 + [OK] Subscription live-tail observed after backfill 26 + == Crypto == 27 + [SKIP] Crypto stage (stub) — not yet implemented (phase 6) 28 + 29 + Summary: 5 passed, 0 failed (spec), 0 network errors, 1 advisories, 10 skipped. Exit code: 0
+29
tests/snapshots/labeler_subscription__backfill_exceeds_budget_triggers_live_tail.snap
··· 1 + --- 2 + source: tests/labeler_subscription.rs 3 + expression: rendered 4 + --- 5 + Target: https://example.com/labeler 6 + elapsed: XXms 7 + 8 + == Identity == 9 + [SKIP] target resolved — no DID supplied; run with a handle, a DID, or --did <did> 10 + [SKIP] DID document fetched — no DID supplied; run with a handle, a DID, or --did <did> 11 + [SKIP] labeler service present — no DID supplied; run with a handle, a DID, or --did <did> 12 + [SKIP] labeler endpoint is HTTPS — no DID supplied; run with a handle, a DID, or --did <did> 13 + [SKIP] resolved DID matches flag — no DID supplied; run with a handle, a DID, or --did <did> 14 + [SKIP] signing key present — no DID supplied; run with a handle, a DID, or --did <did> 15 + [SKIP] PDS endpoint present — no DID supplied; run with a handle, a DID, or --did <did> 16 + [SKIP] labeler record fetched — no DID supplied; run with a handle, a DID, or --did <did> 17 + [SKIP] labeler record policies nonempty — no DID supplied; run with a handle, a DID, or --did <did> 18 + == HTTP == 19 + [OK] Labeler endpoint is reachable 20 + [OK] First page schema is valid 21 + [WARN] Labeler has no published labels 22 + [OK] First page was complete; pagination not exercised 23 + == Subscription == 24 + [WARN] Subscription backfill exceeded budget 25 + [OK] Subscription live-tail connection held 26 + == Crypto == 27 + [SKIP] Crypto stage (stub) — not yet implemented (phase 6) 28 + 29 + Summary: 4 passed, 0 failed (spec), 0 network errors, 2 advisories, 10 skipped. Exit code: 0
+29
tests/snapshots/labeler_subscription__empty_stream_advisories.snap
··· 1 + --- 2 + source: tests/labeler_subscription.rs 3 + expression: rendered 4 + --- 5 + Target: https://example.com/labeler 6 + elapsed: 0ms 7 + 8 + == Identity == 9 + [SKIP] target resolved — no DID supplied; run with a handle, a DID, or --did <did> 10 + [SKIP] DID document fetched — no DID supplied; run with a handle, a DID, or --did <did> 11 + [SKIP] labeler service present — no DID supplied; run with a handle, a DID, or --did <did> 12 + [SKIP] labeler endpoint is HTTPS — no DID supplied; run with a handle, a DID, or --did <did> 13 + [SKIP] resolved DID matches flag — no DID supplied; run with a handle, a DID, or --did <did> 14 + [SKIP] signing key present — no DID supplied; run with a handle, a DID, or --did <did> 15 + [SKIP] PDS endpoint present — no DID supplied; run with a handle, a DID, or --did <did> 16 + [SKIP] labeler record fetched — no DID supplied; run with a handle, a DID, or --did <did> 17 + [SKIP] labeler record policies nonempty — no DID supplied; run with a handle, a DID, or --did <did> 18 + == HTTP == 19 + [OK] Labeler endpoint is reachable 20 + [OK] First page schema is valid 21 + [WARN] Labeler has no published labels 22 + [OK] First page was complete; pagination not exercised 23 + == Subscription == 24 + [WARN] Subscription backfill had no frames — labeler has no published labels 25 + [SKIP] Subscription live-tail skipped — labeler has no published labels 26 + == Crypto == 27 + [SKIP] Crypto stage (stub) — not yet implemented (phase 6) 28 + 29 + Summary: 3 passed, 0 failed (spec), 0 network errors, 2 advisories, 11 skipped. Exit code: 0
+38
tests/snapshots/labeler_subscription__error_frame_malformed_payload_spec_violation.snap
··· 1 + --- 2 + source: tests/labeler_subscription.rs 3 + expression: rendered 4 + --- 5 + Target: https://example.com/labeler 6 + elapsed: XXms 7 + 8 + == Identity == 9 + [SKIP] target resolved — no DID supplied; run with a handle, a DID, or --did <did> 10 + [SKIP] DID document fetched — no DID supplied; run with a handle, a DID, or --did <did> 11 + [SKIP] labeler service present — no DID supplied; run with a handle, a DID, or --did <did> 12 + [SKIP] labeler endpoint is HTTPS — no DID supplied; run with a handle, a DID, or --did <did> 13 + [SKIP] resolved DID matches flag — no DID supplied; run with a handle, a DID, or --did <did> 14 + [SKIP] signing key present — no DID supplied; run with a handle, a DID, or --did <did> 15 + [SKIP] PDS endpoint present — no DID supplied; run with a handle, a DID, or --did <did> 16 + [SKIP] labeler record fetched — no DID supplied; run with a handle, a DID, or --did <did> 17 + [SKIP] labeler record policies nonempty — no DID supplied; run with a handle, a DID, or --did <did> 18 + == HTTP == 19 + [OK] Labeler endpoint is reachable 20 + [OK] First page schema is valid 21 + [WARN] Labeler has no published labels 22 + [OK] First page was complete; pagination not exercised 23 + == Subscription == 24 + [WARN] Subscription backfill stream closed unexpectedly 25 + [OK] Subscription live-tail connection held 26 + [FAIL] Subscription frame decode failure 27 + labeler::subscription::frame_decode 28 + 29 + × Payload decode failed: Semantic(None, "invalid type: break, expected map") 30 + ╭─[frame:1:1] 31 + 1 │ �bop � 32 + · ┬ 33 + · ╰── frame decode failure 34 + ╰──── 35 + == Crypto == 36 + [SKIP] Crypto stage (stub) — not yet implemented (phase 6) 37 + 38 + Summary: 4 passed, 1 failed (spec), 0 network errors, 2 advisories, 10 skipped. Exit code: 1
+29
tests/snapshots/labeler_subscription__live_tail_connect_failure_emits_network_error.snap
··· 1 + --- 2 + source: tests/labeler_subscription.rs 3 + expression: rendered 4 + --- 5 + Target: https://example.com/labeler 6 + elapsed: XXms 7 + 8 + == Identity == 9 + [SKIP] target resolved — no DID supplied; run with a handle, a DID, or --did <did> 10 + [SKIP] DID document fetched — no DID supplied; run with a handle, a DID, or --did <did> 11 + [SKIP] labeler service present — no DID supplied; run with a handle, a DID, or --did <did> 12 + [SKIP] labeler endpoint is HTTPS — no DID supplied; run with a handle, a DID, or --did <did> 13 + [SKIP] resolved DID matches flag — no DID supplied; run with a handle, a DID, or --did <did> 14 + [SKIP] signing key present — no DID supplied; run with a handle, a DID, or --did <did> 15 + [SKIP] PDS endpoint present — no DID supplied; run with a handle, a DID, or --did <did> 16 + [SKIP] labeler record fetched — no DID supplied; run with a handle, a DID, or --did <did> 17 + [SKIP] labeler record policies nonempty — no DID supplied; run with a handle, a DID, or --did <did> 18 + == HTTP == 19 + [OK] Labeler endpoint is reachable 20 + [OK] First page schema is valid 21 + [WARN] Labeler has no published labels 22 + [OK] First page was complete; pagination not exercised 23 + == Subscription == 24 + [NET] Subscription live-tail endpoint reachable 25 + [WARN] Subscription backfill exceeded budget 26 + == Crypto == 27 + [SKIP] Crypto stage (stub) — not yet implemented (phase 6) 28 + 29 + Summary: 3 passed, 0 failed (spec), 1 network errors, 2 advisories, 10 skipped. Exit code: 0
+38
tests/snapshots/labeler_subscription__malformed_frame_emits_spec_violation.snap
··· 1 + --- 2 + source: tests/labeler_subscription.rs 3 + expression: rendered 4 + --- 5 + Target: https://example.com/labeler 6 + elapsed: XXms 7 + 8 + == Identity == 9 + [SKIP] target resolved — no DID supplied; run with a handle, a DID, or --did <did> 10 + [SKIP] DID document fetched — no DID supplied; run with a handle, a DID, or --did <did> 11 + [SKIP] labeler service present — no DID supplied; run with a handle, a DID, or --did <did> 12 + [SKIP] labeler endpoint is HTTPS — no DID supplied; run with a handle, a DID, or --did <did> 13 + [SKIP] resolved DID matches flag — no DID supplied; run with a handle, a DID, or --did <did> 14 + [SKIP] signing key present — no DID supplied; run with a handle, a DID, or --did <did> 15 + [SKIP] PDS endpoint present — no DID supplied; run with a handle, a DID, or --did <did> 16 + [SKIP] labeler record fetched — no DID supplied; run with a handle, a DID, or --did <did> 17 + [SKIP] labeler record policies nonempty — no DID supplied; run with a handle, a DID, or --did <did> 18 + == HTTP == 19 + [OK] Labeler endpoint is reachable 20 + [OK] First page schema is valid 21 + [WARN] Labeler has no published labels 22 + [OK] First page was complete; pagination not exercised 23 + == Subscription == 24 + [WARN] Subscription backfill stream closed unexpectedly 25 + [OK] Subscription live-tail connection held 26 + [FAIL] Subscription frame decode failure 27 + labeler::subscription::frame_decode 28 + 29 + × Header decode failed: Semantic(None, "invalid type: break, expected map") 30 + ╭─[frame:1:1] 31 + 1 │ ��� 32 + · ┬ 33 + · ╰── frame decode failure 34 + ╰──── 35 + == Crypto == 36 + [SKIP] Crypto stage (stub) — not yet implemented (phase 6) 37 + 38 + Summary: 4 passed, 1 failed (spec), 0 network errors, 2 advisories, 10 skipped. Exit code: 1
+28
tests/snapshots/labeler_subscription__unreachable_endpoint_network_error.snap
··· 1 + --- 2 + source: tests/labeler_subscription.rs 3 + expression: rendered 4 + --- 5 + Target: https://example.com/labeler 6 + elapsed: 0ms 7 + 8 + == Identity == 9 + [SKIP] target resolved — no DID supplied; run with a handle, a DID, or --did <did> 10 + [SKIP] DID document fetched — no DID supplied; run with a handle, a DID, or --did <did> 11 + [SKIP] labeler service present — no DID supplied; run with a handle, a DID, or --did <did> 12 + [SKIP] labeler endpoint is HTTPS — no DID supplied; run with a handle, a DID, or --did <did> 13 + [SKIP] resolved DID matches flag — no DID supplied; run with a handle, a DID, or --did <did> 14 + [SKIP] signing key present — no DID supplied; run with a handle, a DID, or --did <did> 15 + [SKIP] PDS endpoint present — no DID supplied; run with a handle, a DID, or --did <did> 16 + [SKIP] labeler record fetched — no DID supplied; run with a handle, a DID, or --did <did> 17 + [SKIP] labeler record policies nonempty — no DID supplied; run with a handle, a DID, or --did <did> 18 + == HTTP == 19 + [OK] Labeler endpoint is reachable 20 + [OK] First page schema is valid 21 + [WARN] Labeler has no published labels 22 + [OK] First page was complete; pagination not exercised 23 + == Subscription == 24 + [NET] Subscription endpoint reachable 25 + == Crypto == 26 + [SKIP] Crypto stage (stub) — not yet implemented (phase 6) 27 + 28 + Summary: 3 passed, 0 failed (spec), 1 network errors, 1 advisories, 10 skipped. Exit code: 0