Skip to main content

max / alloy

Move eight verb files' test modules to sibling files audio, display, image, mesh, net, polkit, schema and system each carried a trailing inline test module. Each becomes src/<verb>/tests.rs behind a `#[cfg(test)] mod tests;` declaration, leaving the production file at 905 to 1447 lines instead of 1319 to 2422. No production line changes. Two mechanics the moves had to respect: the include_bytes! paths in display's tests resolve relative to the source file and became ../../testdata/, and the fixture string literals in display and system carry meaningful leading whitespace, so those literal bodies were not dedented. The production files keep their #[cfg(test)] attribute, so they stay in witchbroom's mutation scope.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-03 22:51 UTC
Signed with PGP, not checked
Commit: f118b35fbf690ed0cd11deef5df76335b6cc21db
Parent: f5b883f
16 files changed, +4681 insertions, -3815 deletions
@@ -945,466 +945,4 @@
945 945 }
946 946
947 947 #[cfg(test)]
948 - mod tests {
949 - use super::*;
950 -
951 - // Captured from `pactl -f json list sinks` on PipeWire 1.5.84, with the
952 - // enormous `properties` blob dropped (the parser ignores it) and a second
953 - // sink added to give the list more than one row. Everything the parser
954 - // reads is verbatim.
955 - const SINKS: &str = r#"[
956 - {"index":61,"state":"SUSPENDED","name":"alsa_output.pci-0000_c1_00.6.analog-stereo",
957 - "description":"Family 17h/19h HD Audio Controller Analog Stereo","mute":false,
958 - "volume":{"front-left":{"value":58980,"value_percent":"90%","db":"-2.75 dB"},
959 - "front-right":{"value":58980,"value_percent":"90%","db":"-2.75 dB"}},
960 - "monitor_source":"alsa_output.pci-0000_c1_00.6.analog-stereo.monitor"},
961 - {"index":70,"state":"RUNNING","name":"alsa_output.hdmi-stereo",
962 - "description":"HDMI Stereo","mute":true,
963 - "volume":{"front-left":{"value":65536,"value_percent":"100%","db":"0.00 dB"},
964 - "front-right":{"value":65536,"value_percent":"100%","db":"0.00 dB"}},
965 - "monitor_source":""}
966 - ]"#;
967 -
968 - // Captured from `pactl -f json list sources`. The middle entry is a
969 - // monitor, which is the case the filter exists for.
970 - const SOURCES: &str = r#"[
971 - {"index":60,"state":"SUSPENDED","name":"alsa_input.acp-pdm-mach.stereo-fallback",
972 - "description":"ACP/ACP3X/ACP6x Audio Coprocessor Stereo","mute":false,
973 - "volume":{"front-left":{"value":65536,"value_percent":"100%","db":"0.00 dB"},
974 - "front-right":{"value":65536,"value_percent":"100%","db":"0.00 dB"}},
975 - "monitor_source":""},
976 - {"index":61,"state":"SUSPENDED","name":"alsa_output.pci-0000_c1_00.6.analog-stereo.monitor",
977 - "description":"Monitor of Family 17h/19h HD Audio Controller Analog Stereo","mute":false,
978 - "volume":{"front-left":{"value":65536,"value_percent":"100%","db":"0.00 dB"}},
979 - "monitor_source":"alsa_output.pci-0000_c1_00.6.analog-stereo"},
980 - {"index":62,"state":"SUSPENDED","name":"alsa_input.pci-0000_c1_00.6.analog-stereo",
981 - "description":"Family 17h/19h HD Audio Controller Analog Stereo","mute":false,
982 - "volume":{"front-left":{"value":32768,"value_percent":"50%","db":"-18.06 dB"}},
983 - "monitor_source":""}
984 - ]"#;
985 -
986 - // Captured from `pactl -f json list sink-inputs`, properties trimmed to
987 - // the keys the parser reads, plus a second entry with no
988 - // `application.name` to exercise the name fallback.
989 - const SINK_INPUTS: &str = r#"[
990 - {"index":342,"sink":61,"corked":false,"mute":false,
991 - "volume":{"front-left":{"value":65536,"value_percent":"100%","db":"0.00 dB"}},
992 - "properties":{"application.name":"speech-dispatcher-dummy",
993 - "application.process.binary":"sd_dummy","media.name":"playback"}},
994 - {"index":343,"sink":70,"corked":true,"mute":true,
995 - "volume":{"front-left":{"value":32768,"value_percent":"50%","db":"-18.06 dB"}},
996 - "properties":{"application.process.binary":"mpv","media.name":"Some Song"}}
997 - ]"#;
998 -
999 - #[test]
1000 - fn parses_sinks_with_volume_and_mute() {
1001 - let devices = parse_devices(SINKS, Direction::Output, "alsa_output.hdmi-stereo").unwrap();
1002 - assert_eq!(devices.len(), 2);
1003 - assert_eq!(devices[0].index, 61);
1004 - assert_eq!(devices[0].volume, 90);
1005 - assert!(!devices[0].muted);
1006 - assert!(!devices[0].is_default);
1007 - assert!(devices[1].muted);
1008 - assert!(devices[1].is_default, "the default sink is matched by name");
1009 - }
1010 -
1011 - // A sink's `monitor_source` names the monitor it *has*; a source's names
1012 - // the sink it *is a monitor of*. Filtering sinks on that field would hide
1013 - // every real output, which is the bug this test pins down.
1014 - #[test]
1015 - fn the_monitor_filter_does_not_swallow_sinks() {
1016 - let devices = parse_devices(SINKS, Direction::Output, "").unwrap();
1017 - assert_eq!(
1018 - devices.len(),
1019 - 2,
1020 - "both sinks survive despite a monitor_source"
1021 - );
1022 - }
1023 -
1024 - #[test]
1025 - fn drops_monitor_sources() {
1026 - let devices = parse_devices(SOURCES, Direction::Input, "").unwrap();
1027 - assert_eq!(devices.len(), 2, "the monitor source is filtered out");
1028 - assert!(
1029 - devices
1030 - .iter()
1031 - .all(|d| !d.description.starts_with("Monitor of")),
1032 - "no monitor survived the filter"
1033 - );
1034 - assert_eq!(devices[1].volume, 50);
1035 - }
1036 -
1037 - #[test]
1038 - fn parses_streams_with_their_device_pairing() {
1039 - let streams = parse_streams(SINK_INPUTS, Direction::Output).unwrap();
1040 - assert_eq!(streams.len(), 2);
1041 - assert_eq!(streams[0].index, 342);
1042 - assert_eq!(streams[0].app, "speech-dispatcher-dummy");
1043 - assert_eq!(
1044 - streams[0].device_index, 61,
1045 - "the pairing the connector draws"
1046 - );
1047 - assert!(!streams[0].corked);
1048 - assert_eq!(streams[1].volume, 50);
1049 - assert!(streams[1].corked);
1050 - assert!(streams[1].muted);
1051 - }
1052 -
1053 - // Not every stream sets `application.name`; falling through to the binary
1054 - // beats showing "unknown", and beats `media.name`, which names the audio
1055 - // rather than the app.
1056 - #[test]
1057 - fn stream_name_falls_back_to_the_binary() {
1058 - let streams = parse_streams(SINK_INPUTS, Direction::Output).unwrap();
1059 - assert_eq!(streams[1].app, "mpv");
1060 - }
1061 -
1062 - // A stream mid-setup has no device. It cannot be paired or routed, so it
1063 - // must not occupy a row that invites a keypress that cannot work.
1064 - #[test]
1065 - fn streams_with_no_device_are_dropped() {
1066 - let raw = r#"[{"index":9,"corked":false,"mute":false,"volume":{},"properties":{}}]"#;
1067 - assert!(parse_streams(raw, Direction::Output).unwrap().is_empty());
1068 - }
1069 -
1070 - // Capture streams carry `source`, not `sink`. Reading the wrong field
1071 - // would drop every capture stream as unpaired.
1072 - #[test]
1073 - fn capture_streams_pair_via_the_source_field() {
1074 - let raw = r#"[{"index":5,"source":62,"corked":false,"mute":false,"volume":{},
1075 - "properties":{"application.name":"Recorder"}}]"#;
1076 - let streams = parse_streams(raw, Direction::Input).unwrap();
1077 - assert_eq!(streams.len(), 1);
1078 - assert_eq!(streams[0].device_index, 62);
1079 - }
1080 -
1081 - #[test]
1082 - fn empty_device_list_parses_to_nothing() {
1083 - assert!(
1084 - parse_devices("[]", Direction::Output, "")
1085 - .unwrap()
1086 - .is_empty()
1087 - );
1088 - }
1089 -
1090 - #[test]
1091 - fn malformed_json_is_an_error_not_an_empty_list() {
1092 - assert!(parse_devices("not json", Direction::Output, "").is_err());
1093 - assert!(parse_streams("not json", Direction::Output).is_err());
1094 - }
1095 -
1096 - // Unity is 65536, not 100. Treating the raw value as a percent would show
1097 - // a normal device at "65536%".
1098 - #[test]
1099 - fn volume_is_scaled_from_unity() {
1100 - let full = HashMap::from([(
1101 - "mono".to_string(),
1102 - PaChannel {
1103 - value: VOLUME_UNITY,
1104 - },
1105 - )]);
1106 - assert_eq!(channel_volume(&full), 100);
1107 -
1108 - let half = HashMap::from([(
1109 - "mono".to_string(),
1110 - PaChannel {
1111 - value: VOLUME_UNITY / 2,
1112 - },
1113 - )]);
1114 - assert_eq!(channel_volume(&half), 50);
1115 -
1116 - assert_eq!(
1117 - channel_volume(&HashMap::new()),
1118 - 0,
1119 - "no channels reads as silent"
1120 - );
1121 - }
1122 -
1123 - #[test]
1124 - fn volume_above_unity_clamps_to_100() {
1125 - let boosted = HashMap::from([(
1126 - "mono".to_string(),
1127 - PaChannel {
1128 - value: VOLUME_UNITY * 2,
1129 - },
1130 - )]);
1131 - assert_eq!(channel_volume(&boosted), 100);
1132 - }
1133 -
1134 - // The loudest channel, not the average: one silent channel of a stereo
1135 - // pair must not read as 50%.
1136 - #[test]
1137 - fn volume_reports_the_loudest_channel() {
1138 - let lopsided = HashMap::from([
1139 - (
1140 - "front-left".to_string(),
1141 - PaChannel {
1142 - value: VOLUME_UNITY,
1143 - },
1144 - ),
1145 - ("front-right".to_string(), PaChannel { value: 0 }),
1146 - ]);
1147 - assert_eq!(channel_volume(&lopsided), 100);
1148 - }
1149 -
1150 - #[test]
1151 - fn truncate_marks_clipped_descriptions() {
1152 - assert_eq!(truncate("short", 10), "short");
1153 - assert_eq!(truncate("a very long device name", 10), "a very lo…");
1154 - }
1155 -
1156 - // Slicing a multi-byte description by byte index panics. Device names do
1157 - // carry non-ASCII.
1158 - #[test]
1159 - fn truncate_handles_multibyte_descriptions() {
1160 - assert_eq!(truncate("Björn's Headset Pro", 8), "Björn's…");
1161 - assert_eq!(truncate("Björn", 10), "Björn");
1162 - }
1163 -
1164 - // ---- view behavior ----
1165 -
1166 - fn mock_view() -> (AudioView, CommandLog) {
1167 - let mut log = CommandLog::new();
1168 - let mut view = AudioView {
1169 - backend: Box::new(Mock),
1170 - devices: Vec::new(),
1171 - streams: Vec::new(),
1172 - focus: FocusRing::new(2),
1173 - stream_cursor: Cursor::new(),
1174 - device_cursor: Cursor::new(),
1175 - error: None,
1176 - ticks: 0,
1177 - };
1178 - view.refresh_devices(&mut log);
1179 - view.refresh_streams(&mut log);
1180 - (view, log)
1181 - }
1182 -
1183 - // The pairing the connector draws: stream 0 routes to device index 1,
1184 - // which is position 0 in the device list. Matching on the list position
1185 - // instead of the device index would be right only by coincidence here.
1186 - #[test]
1187 - fn pairing_resolves_a_device_index_to_a_list_position() {
1188 - let (view, _log) = mock_view();
1189 - assert_eq!(view.paired_device_index(), Some(0));
1190 - }
1191 -
1192 - #[test]
1193 - fn pairing_follows_the_selected_stream() {
1194 - let (mut view, _log) = mock_view();
1195 - view.stream_cursor.move_by(1);
1196 - // Stream 1 routes to device index 2, which is position 1.
1197 - assert_eq!(view.paired_device_index(), Some(1));
1198 - }
1199 -
1200 - // A stream routed to a device that is not in the list (filtered, or gone
1201 - // between the two reads) has no drawable pairing.
1202 - #[test]
1203 - fn pairing_is_absent_when_the_device_is_missing() {
1204 - let (mut view, _log) = mock_view();
1205 - view.devices.retain(|d| d.index != 1);
1206 - assert_eq!(view.paired_device_index(), None);
1207 - }
1208 -
1209 - #[test]
1210 - fn tab_moves_focus_between_the_panes() {
1211 - let (mut view, _log) = mock_view();
1212 - assert!(view.focus.is_focused(PANE_STREAMS));
1213 - view.focus.next();
1214 - assert!(view.focus.is_focused(PANE_DEVICES));
1215 - view.focus.next();
1216 - assert!(view.focus.is_focused(PANE_STREAMS), "two panes wrap");
1217 - }
1218 -
1219 - // The action keys follow focus, so `m` mutes the app when the stream pane
1220 - // is focused and the device when it is not.
1221 - #[test]
1222 - fn the_action_target_follows_focus() {
1223 - let (mut view, _log) = mock_view();
1224 - assert!(matches!(view.target(), Some(Target::Stream(_))));
1225 - view.focus.focus(PANE_DEVICES);
1226 - assert!(matches!(view.target(), Some(Target::Device(_))));
1227 - }
1228 -
1229 - // pactl builds its subcommands from these nouns, and the stream case is
1230 - // counterintuitive: a stream playing *out* is a `sink-input`.
1231 - #[test]
1232 - fn targets_use_the_right_pactl_nouns() {
1233 - let (mut view, _log) = mock_view();
1234 - assert_eq!(view.target().unwrap().noun(), "sink-input");
1235 - assert_eq!(view.target().unwrap().id(), "100", "streams go by index");
1236 -
1237 - view.focus.focus(PANE_DEVICES);
1238 - assert_eq!(view.target().unwrap().noun(), "sink");
1239 - assert_eq!(
1240 - view.target().unwrap().id(),
1241 - "alsa_output.analog-stereo",
1242 - "devices go by name"
1243 - );
1244 - }
1245 -
1246 - #[test]
1247 - fn capture_targets_use_the_source_nouns() {
1248 - let (mut view, _log) = mock_view();
1249 - view.focus.focus(PANE_DEVICES);
1250 - // The third mock device is the microphone.
1251 - view.device_cursor.move_by(2);
1252 - assert_eq!(view.target().unwrap().noun(), "source");
1253 - }
1254 -
1255 - // Routing a playback stream to a microphone is not a thing. pactl would
1256 - // refuse, but refusing here says why.
1257 - #[test]
1258 - fn routing_across_directions_is_refused() {
1259 - let (mut view, mut log) = mock_view();
1260 - view.device_cursor.move_by(2); // the input device
1261 - view.route(&mut log);
1262 - let error = view
1263 - .error
1264 - .expect("a cross-direction route reports an error");
1265 - assert!(error.contains("cannot route"), "got: {error}");
1266 - }
1267 -
1268 - #[test]
1269 - fn routing_within_a_direction_is_allowed() {
1270 - let (mut view, mut log) = mock_view();
1271 - view.device_cursor.move_by(1); // the HDMI output
1272 - view.route(&mut log);
1273 - assert!(view.error.is_none(), "same-direction routing is accepted");
1274 - }
1275 -
1276 - // Background polling is console bookkeeping. If it logged, the pane would
1277 - // fill with commands nobody pressed a key for.
1278 - #[test]
1279 - fn ticks_do_not_write_to_the_command_log() {
1280 - let (mut view, mut log) = mock_view();
1281 - let before = log.entries().len();
1282 - for _ in 0..DEVICE_POLL_TICKS * 2 {
1283 - view.tick(&mut log);
1284 - }
1285 - assert_eq!(log.entries().len(), before, "ticks are silent");
1286 - }
1287 -
1288 - // Devices are polled on a slower cadence than streams, so the tick counter
1289 - // has to actually reach the device poll.
1290 - #[test]
1291 - fn devices_are_polled_on_the_slower_cadence() {
1292 - let (mut view, mut log) = mock_view();
1293 - view.devices.clear();
1294 - for _ in 0..DEVICE_POLL_TICKS - 1 {
1295 - view.tick(&mut log);
1296 - }
1297 - assert!(view.devices.is_empty(), "not yet re-read");
1298 - view.tick(&mut log);
1299 - assert!(!view.devices.is_empty(), "re-read on the tenth tick");
1300 - }
1301 -
1302 - // The bug this pins: refreshes run on the background tick, so if a
1303 - // successful refresh cleared `error`, a rejected route would be readable
1304 - // for under a second before a poll wiped it.
1305 - #[test]
1306 - fn a_background_tick_does_not_clear_an_action_error() {
1307 - let (mut view, mut log) = mock_view();
1308 - view.device_cursor.move_by(2); // the input device
1309 - view.route(&mut log);
1310 - assert!(view.error.is_some(), "the route was refused");
1311 -
1312 - for _ in 0..=DEVICE_POLL_TICKS {
1313 - view.tick(&mut log);
1314 - }
1315 - assert!(
1316 - view.error.is_some(),
1317 - "the error survived a full device-poll cycle"
1318 - );
1319 - }
1320 -
1321 - #[test]
1322 - fn a_keypress_clears_a_stale_error() {
1323 - use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1324 -
1325 - let (mut view, mut log) = mock_view();
1326 - view.device_cursor.move_by(2);
1327 - view.route(&mut log);
1328 - assert!(view.error.is_some());
1329 -
1330 - view.handle(
1331 - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
1332 - &mut log,
1333 - );
1334 - assert!(view.error.is_none(), "moving on dismisses the error");
1335 - }
1336 -
1337 - #[test]
1338 - fn acting_with_no_selection_is_inert() {
1339 - let mut log = CommandLog::new();
1340 - let mut view = AudioView {
1341 - backend: Box::new(Mock),
1342 - devices: Vec::new(),
1343 - streams: Vec::new(),
1344 - focus: FocusRing::new(2),
1345 - stream_cursor: Cursor::new(),
1346 - device_cursor: Cursor::new(),
1347 - error: None,
1348 - ticks: 0,
1349 - };
1350 - view.set_volume(&mut log, 5);
1351 - view.toggle_mute(&mut log);
1352 - view.set_default(&mut log);
1353 - view.route(&mut log);
1354 - assert!(view.error.is_none(), "no selection is not an error");
1355 - }
1356 -
1357 - /// Parse whatever this machine's PipeWire actually reports.
1358 - ///
1359 - /// Ignored by default because it needs a running PipeWire and its result
1360 - /// depends on the hardware. Run it (`cargo test -p alloy -- --ignored
1361 - /// --nocapture`) when touching the parser: a fixture only proves the
1362 - /// parser handles the output someone imagined it would get, which is
1363 - /// exactly how the sink/source `monitor_source` inversion got written.
1364 - #[test]
1365 - #[ignore = "requires a running PipeWire"]
1366 - fn parses_this_machines_real_state() {
1367 - let mut log = CommandLog::new();
1368 - let devices = PaCtl.list_devices(&mut log).expect("pactl should answer");
1369 - let streams = PaCtl.list_streams(&mut log).expect("pactl should answer");
1370 -
1371 - assert!(
1372 - !devices.is_empty(),
1373 - "a machine with PipeWire has some device"
1374 - );
1375 - assert!(
1376 - devices.iter().any(|d| d.direction == Direction::Output),
1377 - "at least one output must survive the monitor filter"
1378 - );
1379 - for device in &devices {
1380 - assert!(!device.description.is_empty(), "every row is identifiable");
1381 - assert!(device.volume <= 100, "volume is a clamped percentage");
1382 - assert!(
1383 - !device.description.starts_with("Monitor of"),
1384 - "monitor leaked into the list: {}",
1385 - device.description
1386 - );
1387 - println!(
1388 - "device {:<8} {:<48} {:>3}% mute={} default={}",
1389 - device.direction.label(),
1390 - device.description,
1391 - device.volume,
1392 - device.muted,
1393 - device.is_default
1394 - );
1395 - }
1396 - for stream in &streams {
1397 - // Every listed stream must resolve to a listed device, or the
1398 - // connector has nothing to draw to.
1399 - let paired = devices.iter().find(|d| d.index == stream.device_index);
1400 - println!(
1401 - "stream {:<8} {:<20} -> {:<40} {:>3}%",
1402 - stream.direction.label(),
1403 - stream.app,
1404 - paired.map_or("(unlisted)", |d| d.description.as_str()),
1405 - stream.volume
1406 - );
1407 - assert!(!stream.app.is_empty(), "every stream row is identifiable");
1408 - }
1409 - }
1410 - }
948 + mod tests;
@@ -1388,1035 +1388,4 @@
1388 1388 }
1389 1389
1390 1390 #[cfg(test)]
1391 - mod tests {
1392 - use super::*;
1393 -
1394 - // Captured verbatim from `swaymsg -t get_outputs` on the FW12 Alloy install,
1395 - // 2026-07-29, sway 1.11, with no external display attached. Complete and
1396 - // untrimmed: this is the whole 1499-byte payload, including the fields the
1397 - // parser ignores, because the fields it ignores are where the next surprise
1398 - // lives. `serial` really is the string "Unknown", `refresh` really is
1399 - // millihertz, and `rect` really disagrees with `current_mode`.
1400 - //
1401 - // Still missing, and the reason the multi-output path has tests but no
1402 - // evidence: nobody has attached a second display to an Alloy machine.
1403 - const FW12: &str = r#"
1404 - [
1405 - {
1406 - "id": 3,
1407 - "type": "output",
1408 - "orientation": "none",
1409 - "percent": 1.0,
1410 - "urgent": false,
1411 - "marks": [],
1412 - "layout": "output",
1413 - "border": "none",
1414 - "current_border_width": 0,
1415 - "rect": {
1416 - "x": 0,
1417 - "y": 0,
1418 - "width": 1536,
1419 - "height": 960
1420 - },
1421 - "deco_rect": {
1422 - "x": 0,
1423 - "y": 0,
1424 - "width": 0,
1425 - "height": 0
1426 - },
1427 - "window_rect": {
1428 - "x": 0,
1429 - "y": 0,
1430 - "width": 0,
1431 - "height": 0
1432 - },
1433 - "geometry": {
1434 - "x": 0,
1435 - "y": 0,
1436 - "width": 0,
1437 - "height": 0
1438 - },
1439 - "name": "eDP-1",
1440 - "window": null,
1441 - "nodes": [],
1442 - "floating_nodes": [],
1443 - "focus": [
1444 - 4
1445 - ],
1446 - "fullscreen_mode": 0,
1447 - "sticky": false,
1448 - "floating": null,
1449 - "scratchpad_state": null,
1450 - "primary": false,
1451 - "make": "BOE",
1452 - "model": "NV122WUM-N42",
1453 - "serial": "Unknown",
1454 - "modes": [
1455 - {
1456 - "width": 1920,
1457 - "height": 1200,
1458 - "refresh": 60002,
1459 - "picture_aspect_ratio": "none"
1460 - }
1461 - ],
1462 - "non_desktop": false,
1463 - "active": true,
1464 - "dpms": true,
1465 - "power": true,
1466 - "scale": 1.25,
1467 - "scale_filter": "linear",
1468 - "transform": "normal",
1469 - "adaptive_sync_status": "disabled",
1470 - "current_workspace": "1",
1471 - "current_mode": {
1472 - "width": 1920,
1473 - "height": 1200,
1474 - "refresh": 60002,
1475 - "picture_aspect_ratio": "none"
1476 - },
1477 - "max_render_time": 0,
1478 - "allow_tearing": false,
1479 - "focused": true,
1480 - "subpixel_hinting": "unknown"
1481 - }
1482 - ]
1483 - "#;
1484 -
1485 - fn fw12() -> Output {
1486 - parse(FW12).expect("the real capture parses").remove(0)
1487 - }
1488 -
1489 - #[test]
1490 - fn parses_the_real_capture() {
1491 - let outputs = parse(FW12).unwrap();
1492 - assert_eq!(outputs.len(), 1);
1493 - let panel = &outputs[0];
1494 - assert_eq!(panel.name, "eDP-1");
1495 - assert_eq!(panel.make, "BOE");
1496 - assert_eq!(panel.model, "NV122WUM-N42");
1497 - assert!(panel.active && panel.dpms && panel.focused);
1498 - assert_eq!(panel.transform, "normal");
1499 - }
1500 -
1501 - // sway substitutes the literal string "Unknown" for a field the panel does
1502 - // not report. A parser expecting null or an absent key mis-handles this
1503 - // panel, and the identifier rule below depends on noticing it.
1504 - #[test]
1505 - fn an_unknown_serial_is_a_string_not_a_null() {
1506 - assert_eq!(fw12().serial, "Unknown");
1507 - }
1508 -
1509 - // Millihertz. 60002, not 60 and not 60.0.
1510 - #[test]
1511 - fn refresh_is_millihertz() {
1512 - let mode = fw12().current_mode.expect("the panel has a current mode");
1513 - assert_eq!(mode.refresh, 60002);
1514 - assert_eq!(mode.spelled(), "1920x1200@60.002Hz");
1515 - }
1516 -
1517 - // `rect` is the logical size and `current_mode` the physical one, and at
1518 - // scale 1.25 they disagree by design. A view that showed one of them would
1519 - // make the scale look inert.
1520 - #[test]
1521 - fn the_logical_and_physical_sizes_both_survive() {
1522 - let panel = fw12();
1523 - assert_eq!((panel.rect.width, panel.rect.height), (1536, 960));
1524 - let mode = panel.current_mode.unwrap();
1525 - assert_eq!((mode.width, mode.height), (1920, 1200));
1526 - assert!((panel.scale - 1.25).abs() < f64::EPSILON);
1527 - }
1528 -
1529 - // The panel advertises exactly one mode, which is why there is no mode
1530 - // picker. Pinned so that the day a capture with more than one arrives, the
1531 - // reason for the omission is visible in a diff.
1532 - #[test]
1533 - fn the_panel_advertises_exactly_one_mode() {
1534 - assert_eq!(fw12().modes.len(), 1);
1535 - }
1536 -
1537 - #[test]
1538 - fn malformed_json_is_an_error() {
1539 - assert!(parse("not json").is_err());
1540 - assert!(parse("{}").is_err(), "an object is not a list of outputs");
1541 - }
1542 -
1543 - // sway sends the fields this parser does not read, and it will send more
1544 - // next release. Ignoring them is the point of the derive.
1545 - #[test]
1546 - fn unknown_fields_are_ignored() {
1547 - let raw = r#"[{"name":"HDMI-A-1","something_new":{"nested":true}}]"#;
1548 - assert_eq!(parse(raw).unwrap()[0].name, "HDMI-A-1");
1549 - }
1550 -
1551 - // A field sway stops sending must not read as "asleep" or "scale 0".
1552 - #[test]
1553 - fn absent_fields_take_the_safe_default() {
1554 - let output = parse(r#"[{"name":"DP-1"}]"#).unwrap().remove(0);
1555 - assert!(output.dpms, "an output sway does not describe as asleep");
1556 - assert!((output.scale - 1.0).abs() < f64::EPSILON);
1557 - assert!(output.modes.is_empty());
1558 - assert_eq!(output.current_mode, None);
1559 - }
1560 -
1561 - fn external(name: &str, make: &str, model: &str, serial: &str) -> Output {
1562 - Output {
1563 - name: name.into(),
1564 - make: make.into(),
1565 - model: model.into(),
1566 - serial: serial.into(),
1567 - active: true,
1568 - dpms: true,
1569 - focused: false,
1570 - rect: Rectangle {
1571 - x: 0,
1572 - y: 0,
1573 - width: 2560,
1574 - height: 1440,
1575 - },
1576 - scale: 1.0,
1577 - transform: "normal".into(),
1578 - current_mode: Some(Mode {
1579 - width: 2560,
1580 - height: 1440,
1581 - refresh: 59951,
1582 - }),
1583 - modes: Vec::new(),
1584 - }
1585 - }
1586 -
1587 - // The identifier rule since 2026-08-06: the connector, always, for every
1588 - // output. The triple it replaced was vendor text in a file sway parses and
1589 - // was not unique across identical serial-less monitors; `monitors.rs` holds
1590 - // the reasoning and the replacement.
1591 - #[test]
1592 - fn every_output_is_matched_by_its_connector() {
1593 - let monitor = external("DP-3", "Example Co", "PA279CV", "K8LMQS032990");
1594 - assert_eq!(monitor.identifier(), "DP-3");
1595 - assert_eq!(fw12().identifier(), "eDP-1");
1596 - }
1597 -
1598 - #[test]
1599 - fn every_laptop_panel_connector_reads_as_built_in() {
1600 - for name in ["eDP-1", "eDP-2", "LVDS-1", "DSI-1"] {
1601 - let mut panel = external(name, "BOE", "NV122WUM-N42", UNKNOWN);
1602 - panel.name = name.into();
1603 - assert!(panel.built_in(), "{name}");
1604 - assert_eq!(panel.identifier(), name);
1605 - }
1606 - assert!(!external("DP-3", "Example Co", "PA279CV", "S1").built_in());
1607 - }
1608 -
1609 - /// The injection hazard, closed structurally rather than escaped: none of
1610 - /// these can reach a stanza by any route, because no EDID text is written
1611 - /// at all.
1612 - #[test]
1613 - fn no_edid_text_reaches_a_stanza_however_hostile() {
1614 - for make in [
1615 - "Ex\"Co",
1616 - "Ex\\Co",
1617 - "Ex\noutput * scale 3",
1618 - "Ex\rCo",
1619 - "Ex\tCo",
1620 - ] {
1621 - let output = external("DP-1", make, "PA279CV", "S1");
1622 - assert_eq!(output.identifier(), "DP-1", "{make:?}");
1623 - let line = Directive::new(&output, "scale", "2").line();
1624 - assert_eq!(line, "output DP-1 scale 2", "{make:?}");
1625 - }
1626 - }
1627 -
1628 - /// The portability a triple would buy is not lost, it moved: two monitors
1629 - /// that differ only in punctuation are still two identities, and the
1630 - /// identity is a hash rather than something a config file has to hold.
1631 - #[test]
1632 - fn punctuation_still_distinguishes_two_monitors() {
1633 - let one = external("DP-1", "Example Co.", "PA279CV", "S1");
1634 - let two = external("DP-1", "Example Co", "PA279CV", "S1");
1635 - assert_ne!(one.fingerprint(), two.fingerprint());
1636 - assert!(one.fingerprint().is_some());
1637 - }
1638 -
1639 - /// An output with nothing to identify it is remembered by nothing. There is
1640 - /// no fact about it that would survive a replug, so a table row would be a
1641 - /// row that cannot be right.
1642 - #[test]
1643 - fn an_anonymous_output_has_no_fingerprint() {
1644 - assert_eq!(
1645 - external("HDMI-A-1", UNKNOWN, UNKNOWN, UNKNOWN).fingerprint(),
1646 - None
1647 - );
1648 - assert_eq!(external("HDMI-A-1", "", "", "").fingerprint(), None);
1649 - // Serial alone is not identity: it is the field most often Unknown, and
1650 - // what is left is the two fields that were missing.
1651 - assert_eq!(
1652 - external("HDMI-A-1", UNKNOWN, UNKNOWN, "S1").fingerprint(),
1653 - None
1654 - );
1655 - }
1656 -
1657 - /// The built-in panel cannot move, so it is not in the table.
1658 - #[test]
1659 - fn the_built_in_panel_has_no_fingerprint() {
1660 - assert_eq!(fw12().fingerprint(), None);
1661 - }
1662 -
1663 - /// The tripwire that replaced the quoting function. Connector names pass;
1664 - /// anything carrying the old hazards does not, which is what would fire if
1665 - /// vendor text ever found its way back into a stanza.
1666 - #[test]
1667 - fn a_stanza_identifier_is_one_word() {
1668 - assert!(is_one_word("eDP-1"));
1669 - assert!(is_one_word("HDMI-A-1"));
1670 - assert!(!is_one_word("BOE NV122WUM-N42 Unknown"));
1671 - assert!(!is_one_word("Ex\"Co"));
1672 - assert!(!is_one_word("Ex\\Co"));
1673 - assert!(!is_one_word("Ex\nCo"));
1674 - assert!(!is_one_word(""));
1675 - }
1676 -
1677 - // ---- reconcile ----
1678 -
1679 - fn remembered(connector: &str, scale: f64) -> monitors::Remembered {
1680 - monitors::Remembered {
1681 - connector: connector.to_string(),
1682 - scale,
1683 - transform: String::new(),
1684 - enabled: true,
1685 - last_seen: 1_700_000_000,
1686 - description: "Example Co PA279CV".to_string(),
1687 - }
1688 - }
1689 -
1690 - /// The move that the whole design exists to make: a monitor known at DP-1
1691 - /// turns up on DP-2, and its settings come with it.
1692 - #[test]
1693 - fn a_monitor_on_a_new_port_brings_its_settings() {
1694 - let monitor = external("DP-2", "Example Co", "PA279CV", "S1");
1695 - let mut registry = monitors::Registry::default();
1696 - registry.remember(
1697 - monitor.fingerprint().expect("identifiable"),
1698 - remembered("DP-1", 1.5),
1699 - );
1700 -
1701 - let moved = moves(std::slice::from_ref(&monitor), &registry);
1702 - assert_eq!(moved.len(), 1);
1703 - assert_eq!(moved[0].from, "DP-1");
1704 - assert_eq!(moved[0].to, "DP-2");
1705 -
1706 - let lines: Vec<String> = moved[0].directives().iter().map(Directive::line).collect();
1707 - assert_eq!(lines, vec!["output DP-2 scale 1.5"]);
1708 - }
1709 -
1710 - /// A monitor that has not moved is not a move, and neither is one nobody
1711 - /// has seen before. Both would be work with nothing to fix.
1712 - #[test]
1713 - fn nothing_moves_when_nothing_moved() {
1714 - let monitor = external("DP-1", "Example Co", "PA279CV", "S1");
1715 - let mut registry = monitors::Registry::default();
1716 - registry.remember(
1717 - monitor.fingerprint().expect("identifiable"),
1718 - remembered("DP-1", 1.5),
1719 - );
1720 - assert!(moves(std::slice::from_ref(&monitor), &registry).is_empty());
1721 -
1722 - assert!(
1723 - moves(
1724 - std::slice::from_ref(&monitor),
1725 - &monitors::Registry::default()
1726 - )
1727 - .is_empty(),
1728 - "an unknown monitor is left alone rather than guessed at"
1729 - );
1730 - }
1731 -
1732 - /// An anonymous output cannot be moved, because it cannot be recognised.
1733 - /// It is skipped rather than matched against something.
1734 - #[test]
1735 - fn an_anonymous_output_is_never_moved() {
1736 - let anonymous = external("DP-2", UNKNOWN, UNKNOWN, UNKNOWN);
1737 - let mut registry = monitors::Registry::default();
1738 - registry.remember("whatever".to_string(), remembered("DP-1", 2.0));
1739 - assert!(moves(std::slice::from_ref(&anonymous), &registry).is_empty());
1740 - }
1741 -
1742 - /// Recording is what makes the next run able to notice a move, and it is
1743 - /// also where expiry happens: one pass, so a table cannot be written
1744 - /// without being pruned.
1745 - #[test]
1746 - fn recording_stores_the_settings_and_prunes_the_table() {
1747 - let mut monitor = external("DP-2", "Example Co", "PA279CV", "S1");
1748 - monitor.scale = 1.75;
1749 - let mut registry = monitors::Registry::default();
1750 - registry.remember("ancient".to_string(), remembered("DP-9", 1.0));
1751 -
1752 - let now = 1_700_000_000 + 400 * 24 * 60 * 60;
1753 - remember(std::slice::from_ref(&monitor), &mut registry, now);
1754 -
1755 - let entry = registry
1756 - .get(&monitor.fingerprint().expect("identifiable"))
1757 - .expect("the attached monitor is recorded");
1758 - assert_eq!(entry.connector, "DP-2");
1759 - assert!((entry.scale - 1.75).abs() < f64::EPSILON);
1760 - assert_eq!(entry.last_seen, now);
1761 - assert!(
1762 - registry.get("ancient").is_none(),
1763 - "a row older than the forget window should have gone"
1764 - );
1765 - }
1766 -
1767 - /// A disabled monitor stays disabled when it moves. The `enable false` the
1768 - /// user wrote is a setting like any other, and losing it on a replug would
1769 - /// turn a screen back on that was deliberately off.
1770 - #[test]
1771 - fn a_disabled_monitor_stays_disabled_across_a_move() {
1772 - let monitor = external("DP-2", "Example Co", "PA279CV", "S1");
1773 - let mut entry = remembered("DP-1", 1.0);
1774 - entry.enabled = false;
1775 - entry.transform = "90".to_string();
1776 - let mut registry = monitors::Registry::default();
1777 - registry.remember(monitor.fingerprint().expect("identifiable"), entry);
1778 -
1779 - let moved = moves(std::slice::from_ref(&monitor), &registry);
1780 - let lines: Vec<String> = moved[0].directives().iter().map(Directive::line).collect();
1781 - assert_eq!(
1782 - lines,
1783 - vec![
1784 - "output DP-2 scale 1",
1785 - "output DP-2 transform 90",
1786 - "output DP-2 enable false",
1787 - ]
1788 - );
1789 - }
1790 -
1791 - // The load-bearing property of the whole design: the words that apply the
1792 - // change and the words that persist it are the same words.
1793 - #[test]
1794 - fn the_runtime_command_and_the_config_line_are_the_same_words() {
1795 - let directive = Directive::new(&fw12(), "scale", "1.25");
1796 - assert_eq!(directive.line(), "output eDP-1 scale 1.25");
1797 - assert_eq!(
1798 - directive.invocation().display(),
1799 - "swaymsg output eDP-1 scale 1.25",
1800 - );
1801 - assert_eq!(
1802 - directive.invocation().display(),
1803 - format!("swaymsg {}", directive.line()),
1804 - );
1805 - }
1806 -
1807 - // swaymsg joins its argv with spaces and hands the result to the same
1808 - // parser that reads a config file, and its `join_args` adds no quoting of
1809 - // its own. That used to mean a three-word triple had to carry quotes in the
1810 - // string, in both consumers; with connector names there is nothing to
1811 - // quote, and the property to hold is that neither consumer adds any.
1812 - #[test]
1813 - fn neither_consumer_quotes_a_connector_name() {
1814 - let monitor = external("DP-3", "Example Co", "PA279CV", "K8LMQS032990");
1815 - let directive = Directive::new(&monitor, "scale", "2");
1816 - assert_eq!(directive.line(), "output DP-3 scale 2");
1817 - assert_eq!(
1818 - directive.invocation().display(),
1819 - "swaymsg output DP-3 scale 2",
1820 - "the log pane's single quotes appear only around an argument with \
1821 - whitespace in it, and there is none left",
1822 - );
1823 - }
1824 -
1825 - // `scale 1.250000` is the same instruction spelled to look machine-written,
1826 - // and the shared string is only worth having if a person can paste it.
1827 - #[test]
1828 - fn scales_are_spelled_the_way_a_person_would_type_them() {
1829 - assert_eq!(spell_scale(1.0), "1");
1830 - assert_eq!(spell_scale(1.25), "1.25");
1831 - assert_eq!(spell_scale(1.5), "1.5");
1832 - assert_eq!(spell_scale(2.0), "2");
1833 - }
1834 -
1835 - #[test]
1836 - fn the_scale_key_always_moves() {
1837 - let mut panel = fw12();
1838 - assert!((panel.next_scale() - 1.5).abs() < f64::EPSILON);
1839 - panel.scale = 2.0;
1840 - assert!(
1841 - (panel.next_scale() - 1.0).abs() < f64::EPSILON,
1842 - "the top rung wraps rather than dead-ending",
1843 - );
1844 - // A scale set outside the console lands on the next rung above it.
1845 - panel.scale = 1.1;
1846 - assert!((panel.next_scale() - 1.25).abs() < f64::EPSILON);
1847 - }
1848 -
1849 - // The file is regenerated whole because sway *merges* every stanza that
1850 - // matches an output: a leftover scale from a previous write would keep
1851 - // applying underneath a newer one.
1852 - #[test]
1853 - fn the_file_holds_one_stanza_per_output() {
1854 - let outputs = vec![
1855 - fw12(),
1856 - external("DP-3", "Example Co", "PA279CV", "K8LMQS032990"),
1857 - ];
1858 - let file = config_file(&outputs);
1859 - // Directives only. The header talks about outputs too, and counting the
1860 - // word rather than the instruction is how a test like this passes on a
1861 - // file that has lost a stanza and gained a sentence.
1862 - let directives = file.lines().filter(|line| line.starts_with("output "));
1863 - assert_eq!(directives.count(), 2, "{file}");
1864 - assert!(file.contains("output eDP-1 scale 1.25"), "{file}");
1865 - assert!(file.contains("output DP-3 scale 1"), "{file}");
1866 - assert!(
1867 - !file.contains("Example Co PA279CV K8LMQS032990 scale"),
1868 - "no EDID text belongs in a directive; the comment above it is where \
1869 - a person reads which monitor this is: {file}",
1870 - );
1871 - assert!(
1872 - file.contains("hand edits here are lost"),
1873 - "the header says who owns the file: {file}",
1874 - );
1875 - }
1876 -
1877 - // `transform normal` is sway's default, so writing it says nothing and reads
1878 - // as though the console had an opinion about rotation.
1879 - #[test]
1880 - fn only_a_rotation_that_is_not_the_default_is_written() {
1881 - let mut panel = fw12();
1882 - assert!(!config_file(&[panel.clone()]).contains("transform"));
1883 - panel.transform = "90".into();
1884 - assert!(config_file(&[panel]).contains("output eDP-1 transform 90"));
1885 - }
1886 -
1887 - // An output the user turned off has to stay off across a reboot, or the key
Lines truncated
@@ -1444,485 +1444,4 @@
1444 1444 }
1445 1445
1446 1446 #[cfg(test)]
1447 - mod tests {
1448 - use super::*;
1449 -
1450 - #[test]
1451 - fn the_default_is_the_desktop_with_what_the_image_requires() {
1452 - let choices = Choices::default();
1453 - assert_eq!(choices.profile, Profile::Client);
1454 - assert_eq!(choices.browser, Browser::Firefox);
1455 - // No toolchain. The image requires none to function, and a build
1456 - // host asks for one at mint. See the comment on `Choices::default`.
1457 - assert!(choices.langs.is_empty());
1458 - assert_eq!(choices.artifact, Artifact::Iso);
1459 - // Trimmed by default. What it costs is foreign-architecture emulation,
1460 - // which the house rules forbid using in the first place.
1461 - assert_eq!(choices.trim, Trim::Unused);
1462 - // No database. It is opt-in for the build-host role and every image
1463 - // before the dial existed carried none.
1464 - assert_eq!(choices.db, Db::None);
1465 - }
1466 -
1467 - /// The build args are the whole contract with the Containerfile, so their
1468 - /// names and their order are asserted rather than left to whatever the
1469 - /// struct happens to iterate.
1470 - #[test]
1471 - fn the_build_args_name_what_the_containerfile_reads() {
1472 - let args = Choices::default().build_args();
1473 - let names: Vec<&str> = args.iter().map(|(k, _)| k.as_str()).collect();
1474 - assert_eq!(names, ["PROFILE", "BROWSER", "LANGS", "TRIM", "DB"]);
1475 - assert_eq!(args[0].1, "client");
1476 - // Comma-joined in the enum's declared order, and this is also the
1477 - // literal the Containerfile's own `ARG LANGS` default has to match:
1478 - // the two defaults are one decision written in two files, and a build
1479 - // that bypasses the TUI must get the same stack the TUI would have
1480 - // asked for.
1481 - assert_eq!(args[2].1, "");
1482 - assert_eq!(args[3].1, "unused");
1483 - assert_eq!(args[4].1, "none");
1484 - }
1485 -
1486 - #[test]
1487 - fn identity_args_appear_only_once_they_are_set() {
1488 - let mut choices = Choices::default();
1489 - assert!(
1490 - !choices
1491 - .build_args()
1492 - .iter()
1493 - .any(|(k, _)| k == "ALLOY_HOSTNAME")
1494 - );
1495 - choices.hostname = "bench".to_string();
1496 - assert!(
1497 - choices
1498 - .build_args()
1499 - .iter()
1500 - .any(|(k, _)| k == "ALLOY_HOSTNAME")
1501 - );
1502 - }
1503 -
1504 - #[test]
1505 - fn several_languages_join_into_one_arg() {
1506 - let choices = Choices {
1507 - langs: BTreeSet::from([Lang::Rust, Lang::Go, Lang::Zig]),
1508 - ..Choices::default()
1509 - };
1510 - let args = choices.build_args();
1511 - let langs = &args.iter().find(|(k, _)| k == "LANGS").unwrap().1;
1512 - // BTreeSet order, which is the enum's declared order, so the arg is
1513 - // stable between runs rather than however a HashSet felt that day.
1514 - assert_eq!(langs, "rust,go,zig");
1515 - }
1516 -
1517 - /// The ISO does not come from bootc-image-builder, and passing it a type
1518 - /// would be an error rather than a no-op.
1519 - #[test]
1520 - fn the_iso_and_the_disk_images_use_different_scripts() {
1521 - let mut choices = Choices::default();
1522 - assert_eq!(choices.artifact.script(), "build/build-iso.sh");
1523 - assert!(!choices.invocation().display().contains("--type"));
1524 -
1525 - choices.artifact = Artifact::Raw;
1526 - assert_eq!(choices.artifact.script(), "build/build-image.sh");
1527 - assert!(choices.invocation().display().contains("--type raw"));
1528 - }
1529 -
1530 - #[test]
1531 - fn the_command_is_copy_pasteable() {
1532 - let choices = Choices {
1533 - hostname: "bench".to_string(),
1534 - ..Choices::default()
1535 - };
1536 - let shown = choices.invocation().display();
1537 - assert!(shown.starts_with("build/build-iso.sh"));
1538 - assert!(shown.contains("--build-arg PROFILE=client"));
1539 - assert!(shown.contains("--build-arg ALLOY_HOSTNAME=bench"));
1540 - }
1541 -
1542 - #[test]
1543 - fn a_record_round_trips() {
1544 - let choices = Choices {
1545 - profile: Profile::Server,
1546 - browser: Browser::None,
1547 - langs: BTreeSet::from([Lang::Go]),
1548 - artifact: Artifact::Qcow2,
1549 - trim: Trim::Keep,
1550 - db: Db::Postgres16,
1551 - hostname: "bench".to_string(),
1552 - pubkey: "/home/max/.ssh/id_ed25519.pub".to_string(),
1553 - };
1554 -
1555 - let parsed = Choices::from_toml(&choices.to_toml()).expect("round trip");
1556 - assert_eq!(parsed.profile, Profile::Server);
1557 - assert_eq!(parsed.browser, Browser::None);
1558 - assert_eq!(parsed.langs, BTreeSet::from([Lang::Go]));
1559 - assert_eq!(parsed.artifact, Artifact::Qcow2);
1560 - assert_eq!(parsed.trim, Trim::Keep);
1561 - assert_eq!(parsed.db, Db::Postgres16);
1562 - assert_eq!(parsed.hostname, "bench");
1563 - assert_eq!(parsed.pubkey, "/home/max/.ssh/id_ed25519.pub");
1564 - }
1565 -
1566 - /// A record from a different version of Alloy fails loudly. Falling back
1567 - /// to the default would build something the user did not ask for and
1568 - /// could not explain.
1569 - #[test]
1570 - fn an_unknown_value_is_an_error_rather_than_a_default() {
1571 - let err = Choices::from_toml("browser = \"netscape\"").unwrap_err();
1572 - assert!(format!("{err}").contains("netscape"), "{err}");
1573 -
1574 - let err = Choices::from_toml("langs = [\"cobol\"]").unwrap_err();
1575 - assert!(format!("{err}").contains("cobol"), "{err}");
1576 - }
1577 -
1578 - #[test]
1579 - fn an_empty_record_is_the_default() {
1580 - let parsed = Choices::from_toml("").expect("empty is valid");
1581 - assert_eq!(parsed.profile, Profile::Client);
1582 - }
1583 -
1584 - #[test]
1585 - fn hostnames_are_checked_the_way_dns_checks_them() {
1586 - assert!(valid_hostname("bench"));
1587 - assert!(valid_hostname("build-host-2"));
1588 - assert!(!valid_hostname(""));
1589 - assert!(!valid_hostname("-leading"));
1590 - assert!(!valid_hostname("trailing-"));
1591 - assert!(!valid_hostname("under_score"));
1592 - assert!(!valid_hostname("has space"));
1593 - assert!(!valid_hostname(&"a".repeat(64)));
1594 - assert!(valid_hostname(&"a".repeat(63)));
1595 - }
1596 -
1597 - /// The one check that matters here: a private key must never be mistaken
1598 - /// for a public one, because the artifact is meant to be copyable without
1599 - /// care and baking a private key in would silently make that false.
1600 - #[test]
1601 - fn a_private_key_is_not_mistaken_for_a_public_one() {
1602 - let private = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n";
1603 - assert!(!looks_like_pubkey(private));
1604 - }
1605 -
1606 - #[test]
1607 - fn a_public_key_is_recognized() {
1608 - assert!(looks_like_pubkey(
1609 - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmVLm7Yk2xQ max@fw13\n"
1610 - ));
1611 - assert!(looks_like_pubkey(
1612 - "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY= max@fw13"
1613 - ));
1614 - assert!(!looks_like_pubkey(""));
1615 - assert!(!looks_like_pubkey("hello world"));
1616 - // A key type with no body is not a key.
1617 - assert!(!looks_like_pubkey("ssh-ed25519"));
1618 - assert!(!looks_like_pubkey("ssh-ed25519 short"));
1619 - }
1620 -
1621 - /// A server has no session, so it cannot carry a browser. The blocker
1622 - /// exists for a record loaded off disk; the key handler prevents reaching
1623 - /// the state interactively.
1624 - #[test]
1625 - fn a_server_carrying_a_browser_is_blocked() {
1626 - let choices = Choices {
1627 - profile: Profile::Server,
1628 - browser: Browser::Firefox,
1629 - ..Choices::default()
1630 - };
1631 - let blockers = choices.blockers(Some(Path::new("/nonexistent")));
1632 - assert!(
1633 - blockers.iter().any(|b| b.contains("no graphical session")),
1634 - "{blockers:?}"
1635 - );
1636 - }
1637 -
1638 - #[test]
1639 - fn no_checkout_is_the_first_thing_reported() {
1640 - let blockers = Choices::default().blockers(None);
1641 - assert!(blockers[0].contains("no Alloy checkout"), "{blockers:?}");
1642 - }
1643 -
1644 - /// The write goes through the script's own path, with `--write-only` so it
1645 - /// writes the artifact that already exists rather than rebuilding it.
1646 - /// Re-deriving this is the disk-eating bug the design note warns about.
1647 - #[test]
1648 - fn the_write_delegates_to_the_script_that_owns_the_guards() {
1649 - let shown = ImageView {
1650 - choices: Choices::default(),
1651 - cursor: Cursor::new(),
1652 - repo: None,
1653 - candidates: Vec::new(),
1654 - editing: None,
1655 - sequence: None,
1656 - pending_write: None,
1657 - device: None,
1658 - error: None,
1659 - saved: false,
1660 - }
1661 - .write_command("/dev/sdX")
1662 - .display();
1663 -
1664 - assert!(shown.starts_with("build/build-iso.sh"));
1665 - assert!(shown.contains("--write-only"));
1666 - assert!(shown.contains("--write /dev/sdX"));
1667 - // Nothing resembling a dd, anywhere.
1668 - assert!(!shown.contains("dd "));
1669 - assert!(!shown.contains("of="));
1670 - }
1671 -
1672 - /// The build container cannot see a path on the building machine — the
1673 - /// file is not in the build context — so the key travels as its bytes.
1674 - /// Passing the path would make the Containerfile's own validation reject
1675 - /// it, which is a confusing way to learn a file is missing.
1676 - #[test]
1677 - fn the_key_travels_as_bytes_and_the_record_keeps_the_path() {
1678 - let dir = std::env::temp_dir().join(format!("alloy-image-key-{}", std::process::id()));
1679 - std::fs::create_dir_all(&dir).expect("scratch dir");
1680 - let path = dir.join("id_ed25519.pub");
1681 - std::fs::write(
1682 - &path,
1683 - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmVLm7Yk2xQ max@fw13\n",
1684 - )
1685 - .expect("write key");
1686 -
1687 - let choices = Choices {
1688 - pubkey: path.display().to_string(),
1689 - ..Choices::default()
1690 - };
1691 -
1692 - let key = choices
1693 - .build_args()
1694 - .into_iter()
1695 - .find(|(name, _)| name == "ALLOY_SSH_KEY")
1696 - .expect("the key is passed")
1697 - .1;
1698 - assert!(key.starts_with("ssh-ed25519 AAAA"), "{key}");
1699 - // Trimmed: a trailing newline inside a --build-arg value would land in
1700 - // the authorized_keys file and in the validation `case`.
1701 - assert!(!key.ends_with('\n'));
1702 -
1703 - // The record keeps the path, so a saved build.toml is not a different
1704 - // file on every machine.
1705 - assert!(choices.to_toml().contains("id_ed25519.pub"));
1706 -
1707 - let _ = std::fs::remove_dir_all(&dir);
1708 - }
1709 -
1710 - /// An unreadable key is omitted rather than guessed at, because `blockers`
1711 - /// is the gate and has already refused to start the build.
1712 - #[test]
1713 - fn an_unreadable_key_is_omitted_and_blocked() {
1714 - let choices = Choices {
1715 - pubkey: "/nonexistent/id_ed25519.pub".to_string(),
1716 - ..Choices::default()
1717 - };
1718 - assert!(
1719 - !choices
1720 - .build_args()
1721 - .iter()
1722 - .any(|(name, _)| name == "ALLOY_SSH_KEY")
1723 - );
1724 - assert!(
1725 - choices
1726 - .blockers(None)
1727 - .iter()
1728 - .any(|blocker| blocker.contains("no public key at")),
1729 - );
1730 - }
1731 -
1732 - /// What a built image records, parsed by the same code that reads it back.
1733 - /// The image writes `artifact` nowhere (it does not know which one it was
1734 - /// packed into) and `pubkey` empty (the path meant something on another
1735 - /// machine), so both have to land on their defaults rather than erroring.
1736 - #[test]
1737 - fn the_record_an_image_carries_reads_back() {
1738 - let from_image = "\
1739 - profile = \"server\"
1740 - browser = \"none\"
1741 - langs = [\"rust\", \"go\"]
1742 - hostname = \"bench\"
1743 - pubkey = \"\"
1744 - ";
1745 - let parsed = Choices::from_toml(from_image).expect("an image record parses");
1746 - assert_eq!(parsed.profile, Profile::Server);
1747 - assert_eq!(parsed.browser, Browser::None);
1748 - assert_eq!(parsed.langs, BTreeSet::from([Lang::Rust, Lang::Go]));
1749 - assert_eq!(parsed.hostname, "bench");
1750 - assert!(parsed.pubkey.is_empty());
1751 - // Absent, so the default. Not an error, and not a guess.
1752 - assert_eq!(parsed.artifact, Artifact::Iso);
1753 - }
1754 -
1755 - /// A view with a known candidate list, so the cycling can be exercised
1756 - /// without a `~/.ssh` to stand in front of it.
1757 - fn view_with(candidates: &[&str]) -> ImageView {
1758 - ImageView {
1759 - choices: Choices::default(),
1760 - cursor: Cursor::new(),
1761 - repo: None,
1762 - candidates: candidates.iter().map(|key| (*key).to_string()).collect(),
1763 - editing: None,
1764 - sequence: None,
1765 - pending_write: None,
1766 - device: None,
1767 - error: None,
1768 - saved: false,
1769 - }
1770 - }
1771 -
1772 - /// Empty is a position on the ring, not a state to escape. An image with no
1773 - /// baked key is the ordinary desktop install, so it has to stay reachable
1774 - /// once a key has been cycled onto the row.
1775 - #[test]
1776 - fn cycling_the_pubkey_row_passes_back_through_none() {
1777 - let mut view = view_with(&["/home/max/.ssh/a.pub", "/home/max/.ssh/b.pub"]);
1778 - assert_eq!(view.choices.pubkey, "");
1779 -
1780 - view.cycle_pubkey(true);
1781 - assert_eq!(view.choices.pubkey, "/home/max/.ssh/a.pub");
1782 - view.cycle_pubkey(true);
1783 - assert_eq!(view.choices.pubkey, "/home/max/.ssh/b.pub");
1784 - view.cycle_pubkey(true);
1785 - assert_eq!(view.choices.pubkey, "", "the ring returns to no key");
1786 -
1787 - // And backwards, off none onto the last one.
1788 - view.cycle_pubkey(false);
1789 - assert_eq!(view.choices.pubkey, "/home/max/.ssh/b.pub");
1790 - }
1791 -
1792 - /// A path typed by hand is not one of the candidates, so it reads as the
1793 - /// empty position rather than panicking on a lookup that finds nothing.
1794 - #[test]
1795 - fn a_typed_path_is_not_lost_to_an_index_it_never_had() {
1796 - let mut view = view_with(&["/home/max/.ssh/a.pub"]);
1797 - view.choices.pubkey = "/elsewhere/key.pub".to_string();
1798 - view.cycle_pubkey(true);
1799 - assert_eq!(view.choices.pubkey, "/home/max/.ssh/a.pub");
1800 - }
1801 -
1802 - /// Nothing to cycle says so, rather than silently doing nothing. An inert
1803 - /// key reads as the form being broken.
1804 - #[test]
1805 - fn no_candidates_explains_itself() {
1806 - let mut view = view_with(&[]);
1807 - view.cycle_pubkey(true);
1808 - assert_eq!(view.choices.pubkey, "");
1809 - assert!(
1810 - view.error.as_deref().is_some_and(|e| e.contains("~/.ssh")),
1811 - "{:?}",
1812 - view.error
1813 - );
1814 - }
1815 -
1816 - /// Discovery is filtered by shape, not by suffix. `~/.ssh` collects other
1817 - /// files, and a misnamed private half offered as a candidate is how a
1818 - /// secret reaches an artifact that promises to hold none.
1819 - #[test]
1820 - fn discovery_refuses_anything_that_is_not_a_public_key() {
1821 - let dir = std::env::temp_dir().join(format!("alloy-image-scan-{}", std::process::id()));
1822 - let ssh = dir.join(".ssh");
1823 - std::fs::create_dir_all(&ssh).expect("scratch dir");
1824 -
1825 - std::fs::write(
1826 - ssh.join("id_ed25519.pub"),
1827 - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmVLm7Yk2xQ max@fw13\n",
1828 - )
1829 - .expect("write key");
1830 - // A private key that someone named `.pub`. The suffix is not the check.
1831 - std::fs::write(
1832 - ssh.join("oops.pub"),
1833 - "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n",
1834 - )
1835 - .expect("write private");
1836 - // And the ordinary neighbours, which have no `.pub` at all.
1837 - std::fs::write(ssh.join("known_hosts"), "github.com ssh-ed25519 AAAA\n")
1838 - .expect("write known_hosts");
1839 -
1840 - // SAFETY: single-threaded within this test's own scratch HOME. The
1841 - // discovery reads HOME rather than taking a directory because that is
1842 - // what it does in the program, and testing a different function would
1843 - // test nothing.
1844 - let restore = std::env::var_os("HOME");
1845 - unsafe { std::env::set_var("HOME", &dir) };
1846 - let found = discover_pubkeys();
1847 - match restore {
1848 - Some(home) => unsafe { std::env::set_var("HOME", home) },
1849 - None => unsafe { std::env::remove_var("HOME") },
1850 - }
1851 -
1852 - assert_eq!(found.len(), 1, "{found:?}");
1853 - assert!(found[0].ends_with("id_ed25519.pub"), "{found:?}");
1854 -
1855 - let _ = std::fs::remove_dir_all(&dir);
1856 - }
1857 -
1858 - #[test]
1859 - fn the_record_says_it_is_not_a_lockfile() {
1860 - let toml = Choices::default().to_toml();
1861 - assert!(
1862 - toml.contains("not a lockfile"),
1863 - "the record must not read as one: it pins choices, not resolutions",
1864 - );
1865 - }
1866 -
1867 - /// Against the file the image actually ships, not a fixture, so an
1868 - /// os-release edit that drops or renames `VERSION_ID` fails here rather
1869 - /// than by silently removing the image line from `alloy --version`.
1870 - ///
1871 - /// The committed file carries no `IMAGE_VERSION`: the build stamps it, and
1872 - /// a placeholder here would be a lie on any machine where the stamping
1873 - /// step stopped working. So this is the unstamped shape on purpose, and
1874 - /// the product version is asserted rather than the whole line.
1875 - #[test]
1876 - fn the_shipped_os_release_states_an_image_version() {
1877 - let shipped = concat!(env!("CARGO_MANIFEST_DIR"), "/../../usr/lib/os-release");
1878 - let text = std::fs::read_to_string(shipped).expect("the repo ships usr/lib/os-release");
1879 - let version = version_from(&text).expect("the shipped os-release names an image version");
1880 - assert!(version.starts_with("0."), "{version}");
1881 - assert!(
1882 - !version.contains("build"),
1883 - "the stamp is not committed: {version}"
1884 - );
1885 - assert!(version.contains("Fedora"), "{version}");
1886 - }
1887 -
1888 - /// The line a support conversation reads back: product, build and base,
1889 - /// which move on three different clocks and are three fields for that
1890 - /// reason.
1891 - #[test]
1892 - fn a_stamped_image_composes_the_whole_triple() {
1893 - let stamped = "NAME=\"Alloy\"\nVERSION_ID=\"0.1\"\nIMAGE_VERSION=\"20260816.143012\"\n\
1894 - ALLOY_BASE=\"43\"\nID=alloy\nID_LIKE=fedora\n";
1895 - assert_eq!(
1896 - version_from(stamped).as_deref(),
1897 - Some("0.1 (build 20260816.143012, Fedora 43)")
1898 - );
1899 - }
1900 -
1901 - /// An unstamped build is a real state rather than a broken one: a bare
1902 - /// `podman build` past the wrapper scripts produces one. It reports less
1903 - /// and nothing false, which is what a filled-in "unknown" would not do.
1904 - #[test]
1905 - fn an_unstamped_image_says_less_rather_than_something_false() {
1906 - let unstamped = "VERSION_ID=\"0.1\"\nALLOY_BASE=\"43\"\nID=alloy\n";
1907 - assert_eq!(version_from(unstamped).as_deref(), Some("0.1 (Fedora 43)"));
1908 -
1909 - let bare = "VERSION_ID=\"0.1\"\nID=alloy\n";
1910 - assert_eq!(version_from(bare).as_deref(), Some("0.1"));
1911 - }
1912 -
1913 - /// The reason `ID` is checked. Every Linux host has an os-release, so a
1914 - /// dev box would otherwise report its own distro's version as the image's.
1915 - #[test]
1916 - fn a_foreign_os_release_has_no_image_version() {
1917 - let fedora = "NAME=\"Fedora Linux\"\nID=fedora\nVERSION_ID=43\n";
1918 - assert_eq!(version_from(fedora), None);
1919 - }
1920 -
1921 - /// `ID` is a prefix of `ID_LIKE`, and matching the wrong one would read
1922 - /// every Fedora derivative as Alloy.
1923 - #[test]
1924 - fn id_like_is_not_mistaken_for_id() {
1925 - let derivative = "ID=notalloy\nID_LIKE=alloy\nVERSION_ID=9\n";
1926 - assert_eq!(version_from(derivative), None);
1927 - }
1928 - }
1447 + mod tests;
@@ -951,511 +951,4 @@
951 951 }
952 952
953 953 #[cfg(test)]
954 - mod tests {
955 - use super::*;
956 -
957 - // Shaped from this machine's real `tailscale status --json`, trimmed to
958 - // the fields the parser reads. The awkward parts are real: an online peer
959 - // carrying Go's zero time for LastSeen, a device named "localhost", and
960 - // the Peer map keyed by public key.
961 - const STATUS: &str = r#"{
962 - "Version": "1.90.0",
963 - "BackendState": "Running",
964 - "Health": [],
965 - "MagicDNSSuffix": "example-tailnet.ts.net",
966 - "Self": {
967 - "HostName": "fw13", "OS": "linux",
968 - "TailscaleIPs": ["100.103.89.95", "fd7a:115c:a1e0::af3b:595f"],
969 - "Online": true, "ExitNode": false, "ExitNodeOption": false,
970 - "LastSeen": "0001-01-01T00:00:00Z"
971 - },
972 - "Peer": {
973 - "nodekey:aaa": {
974 - "HostName": "localhost", "OS": "iOS",
975 - "TailscaleIPs": ["100.90.1.2"],
976 - "Online": false, "ExitNode": false, "ExitNodeOption": false,
977 - "LastSeen": "2026-05-21T23:27:30.1Z"
978 - },
979 - "nodekey:bbb": {
980 - "HostName": "astra", "OS": "linux",
981 - "TailscaleIPs": ["100.80.3.4"],
982 - "Online": true, "ExitNode": false, "ExitNodeOption": true,
983 - "LastSeen": "0001-01-01T00:00:00Z"
984 - },
985 - "nodekey:ccc": {
986 - "HostName": "htpy-1", "OS": "linux",
987 - "TailscaleIPs": ["100.70.5.6"],
988 - "Online": true, "ExitNode": false, "ExitNodeOption": false,
989 - "LastSeen": "0001-01-01T00:00:00Z"
990 - }
991 - }
992 - }"#;
993 -
994 - #[test]
995 - fn parses_self_and_peers() {
996 - let status = parse_status(STATUS).unwrap();
997 - assert_eq!(status.backend_state, "Running");
998 - assert!(status.health.is_empty());
999 - assert_eq!(status.peers.len(), 4, "self plus three peers");
1000 - }
1001 -
1002 - // Self first, then online peers by name, then offline. `Peer` is a map, so
1003 - // without an explicit sort the list reshuffles on every refresh with the
1004 - // cursor sitting on whatever lands under it.
1005 - #[test]
1006 - fn peers_are_ordered_self_then_online_then_by_name() {
1007 - let status = parse_status(STATUS).unwrap();
1008 - let names: Vec<&str> = status.peers.iter().map(|p| p.hostname.as_str()).collect();
1009 - assert_eq!(names, ["fw13", "astra", "htpy-1", "localhost"]);
1010 - assert!(status.peers[0].is_self);
1011 - }
1012 -
1013 - // Go's zero time means "currently online", not "last seen in year 1".
1014 - #[test]
1015 - fn go_zero_time_is_not_a_last_seen_date() {
1016 - assert_eq!(last_seen_date("0001-01-01T00:00:00Z"), None);
1017 - assert_eq!(last_seen_date(""), None);
1018 - assert_eq!(
1019 - last_seen_date("2026-05-21T23:27:30.1Z").as_deref(),
1020 - Some("2026-05-21")
1021 - );
1022 -
1023 - let status = parse_status(STATUS).unwrap();
1024 - let astra = &status.peers[1];
1025 - assert!(astra.online);
1026 - assert_eq!(astra.last_seen, None, "an online peer shows no last-seen");
1027 - let phone = &status.peers[3];
1028 - assert_eq!(phone.last_seen.as_deref(), Some("2026-05-21"));
1029 - }
1030 -
1031 - // The state label is what an offline peer's row says. It must not claim a
1032 - // year-1 sighting, and must stay empty for an unremarkable online peer.
1033 - #[test]
1034 - fn state_labels_read_sensibly() {
1035 - let status = parse_status(STATUS).unwrap();
1036 - assert_eq!(status.peers[0].state_label(), "this machine");
1037 - assert_eq!(status.peers[1].state_label(), "offers exit");
1038 - assert_eq!(status.peers[2].state_label(), "", "nothing notable to say");
1039 - assert_eq!(status.peers[3].state_label(), "seen 2026-05-21");
1040 - }
1041 -
1042 - // Peers hold a v4 and a v6; the v4 is the recognizable one. Taking the
1043 - // first entry blindly works only while Tailscale keeps ordering them.
1044 - #[test]
1045 - fn prefers_the_ipv4_address() {
1046 - let status = parse_status(STATUS).unwrap();
1047 - assert_eq!(status.peers[0].ip.as_deref(), Some("100.103.89.95"));
1048 -
1049 - let v6_first = ["fd7a:115c:a1e0::1".to_string(), "100.1.2.3".to_string()];
1050 - assert_eq!(preferred_ip(&v6_first).as_deref(), Some("100.1.2.3"));
1051 - assert_eq!(preferred_ip(&[]), None);
1052 - // v6-only is better shown than blanked.
1053 - let v6_only = ["fd7a:115c:a1e0::1".to_string()];
1054 - assert_eq!(preferred_ip(&v6_only).as_deref(), Some("fd7a:115c:a1e0::1"));
1055 - }
1056 -
1057 - #[test]
1058 - fn a_stopped_backend_is_surfaced() {
1059 - let raw = r#"{"BackendState":"Stopped","Peer":{}}"#;
1060 - let status = parse_status(raw).unwrap();
1061 - assert!(!status.is_running());
1062 - assert!(status.peers.is_empty(), "no Self key means no rows");
1063 - }
1064 -
1065 - // NeedsLogin arrives with no Self and no peers. The screen has to survive
1066 - // it rather than unwrapping something absent.
1067 - #[test]
1068 - fn a_logged_out_tailnet_parses_to_an_empty_list() {
1069 - let raw = r#"{"BackendState":"NeedsLogin","Health":["not logged in"],"Peer":{}}"#;
1070 - let status = parse_status(raw).unwrap();
1071 - assert_eq!(status.peers.len(), 0);
1072 - assert_eq!(status.health, ["not logged in"]);
1073 - }
1074 -
1075 - #[test]
1076 - fn malformed_json_is_an_error() {
1077 - assert!(parse_status("not json").is_err());
1078 - }
1079 -
1080 - #[test]
1081 - fn an_unnamed_peer_still_gets_an_identifiable_row() {
1082 - let raw = r#"{"BackendState":"Running","Peer":{"k":{"OS":"linux","Online":true}}}"#;
1083 - let status = parse_status(raw).unwrap();
1084 - assert_eq!(status.peers[0].hostname, "(unnamed)");
1085 - assert_eq!(status.peers[0].ip, None);
1086 - }
1087 -
1088 - // ---- control plane ----
1089 -
1090 - // An empty ControlURL is how a client that never had one set reports the
1091 - // default, so it must not read as self-hosted.
1092 - #[test]
1093 - fn an_unset_control_url_is_the_hosted_plane() {
1094 - assert_eq!(classify_control_url(""), ControlPlane::Hosted);
1095 - assert_eq!(classify_control_url(" "), ControlPlane::Hosted);
1096 - }
1097 -
1098 - #[test]
1099 - fn the_vendor_control_url_is_recognized() {
1100 - assert_eq!(
1101 - classify_control_url("https://controlplane.tailscale.com"),
1102 - ControlPlane::Hosted
1103 - );
1104 - assert_eq!(
1105 - classify_control_url("https://tailscale.com"),
1106 - ControlPlane::Hosted
1107 - );
1108 - }
1109 -
1110 - #[test]
1111 - fn a_headscale_url_is_reported_by_host() {
1112 - assert_eq!(
1113 - classify_control_url("https://headscale.example.org"),
1114 - ControlPlane::SelfHosted("headscale.example.org".into())
1115 - );
1116 - assert_eq!(
1117 - classify_control_url("https://hs.example.org:8080/some/path"),
1118 - ControlPlane::SelfHosted("hs.example.org".into()),
1119 - "port and path are stripped, leaving the host"
1120 - );
1121 - assert_eq!(
1122 - classify_control_url("http://10.0.0.5:8080"),
1123 - ControlPlane::SelfHosted("10.0.0.5".into())
1124 - );
1125 - }
1126 -
1127 - // Suffix matching is dot-anchored, so a self-hosted server whose name
1128 - // merely contains the vendor's domain is not mistaken for it.
1129 - #[test]
1130 - fn a_lookalike_host_is_not_mistaken_for_the_vendor() {
1131 - assert_eq!(
1132 - classify_control_url("https://headscale.tailscale.com.example.org"),
1133 - ControlPlane::SelfHosted("headscale.tailscale.com.example.org".into())
1134 - );
1135 - assert_eq!(
1136 - classify_control_url("https://nottailscale.com"),
1137 - ControlPlane::SelfHosted("nottailscale.com".into())
1138 - );
1139 - }
1140 -
1141 - // Only a self-hosted plane is worth title space; the other two say nothing
1142 - // rather than "(hosted)" on every screen.
1143 - #[test]
1144 - fn only_a_self_hosted_plane_earns_a_title_suffix() {
1145 - assert_eq!(ControlPlane::Hosted.label(), "");
1146 - assert_eq!(ControlPlane::Unknown.label(), "");
1147 - assert_eq!(
1148 - ControlPlane::SelfHosted("hs.example.org".into()).label(),
1149 - " via hs.example.org"
1150 - );
1151 - }
1152 -
1153 - #[test]
1154 - fn the_title_names_the_backend_and_a_self_hosted_plane() {
1155 - let (mut view, _log) = mock_view();
1156 - assert_eq!(view.title(), "mesh (mock)");
1157 - view.control_plane = ControlPlane::SelfHosted("hs.example.org".into());
1158 - assert_eq!(view.title(), "mesh (mock via hs.example.org)");
1159 - }
1160 -
1161 - // ---- view behavior ----
1162 -
1163 - fn mock_view() -> (MeshView, CommandLog) {
1164 - let (mut view, mut log) = bare_view();
1165 - view.refresh(&mut log);
1166 - (view, log)
1167 - }
1168 -
1169 - /// A view that has not read a status yet.
1170 - fn bare_view() -> (MeshView, CommandLog) {
1171 - (
1172 - MeshView {
1173 - backend: Box::new(Mock),
1174 - status: None,
1175 - control_plane: ControlPlane::Unknown,
1176 - cursor: Cursor::new(),
1177 - error: None,
1178 - ticks: 0,
1179 - server: None,
1180 - },
1181 - CommandLog::new(),
1182 - )
1183 - }
1184 -
1185 - #[test]
1186 - fn routing_through_this_machine_is_refused() {
1187 - let (mut view, mut log) = mock_view();
1188 - view.set_exit_node(&mut log);
1189 - assert!(
1190 - view.error
1191 - .as_deref()
1192 - .is_some_and(|e| e.contains("this machine")),
1193 - "got: {:?}",
1194 - view.error
1195 - );
1196 - }
1197 -
1198 - // Tailscale would reject this too, but naming the peer up front beats a
1199 - // failed command in the log for something knowable in advance.
1200 - #[test]
1201 - fn routing_through_a_peer_that_does_not_offer_is_refused() {
1202 - let (mut view, mut log) = mock_view();
1203 - view.cursor.move_by(2); // the phone, which offers nothing
1204 - view.set_exit_node(&mut log);
1205 - assert!(
1206 - view.error
1207 - .as_deref()
1208 - .is_some_and(|e| e.contains("does not offer")),
1209 - "got: {:?}",
1210 - view.error
1211 - );
1212 - }
1213 -
1214 - #[test]
1215 - fn routing_through_an_offering_peer_is_allowed() {
1216 - let (mut view, mut log) = mock_view();
1217 - view.cursor.move_by(1); // astra, which offers
1218 - view.set_exit_node(&mut log);
1219 - assert!(view.error.is_none(), "got: {:?}", view.error);
1220 - }
1221 -
1222 - #[test]
1223 - fn ticks_are_silent_and_do_not_clear_errors() {
1224 - let (mut view, mut log) = mock_view();
1225 - view.set_exit_node(&mut log); // refused: self
1226 - assert!(view.error.is_some());
1227 -
1228 - let before = log.entries().len();
1229 - for _ in 0..POLL_TICKS * 2 {
1230 - view.tick(&mut log);
1231 - }
1232 - assert_eq!(log.entries().len(), before, "ticks do not log");
1233 - assert!(view.error.is_some(), "ticks do not wipe an action error");
1234 - }
1235 -
1236 - #[test]
1237 - fn acting_with_no_selection_is_inert() {
1238 - let (mut view, mut log) = bare_view();
1239 - view.set_exit_node(&mut log);
1240 - assert!(view.error.is_none(), "no selection is not an error");
1241 - }
1242 -
1243 - // ---- enrollment ----
1244 -
1245 - fn press(view: &mut MeshView, c: char, log: &mut CommandLog) -> Flow {
1246 - view.handle(KeyEvent::from(KeyCode::Char(c)), log)
1247 - }
1248 -
1249 - fn key(view: &mut MeshView, code: KeyCode, log: &mut CommandLog) -> Flow {
1250 - view.handle(KeyEvent::from(code), log)
1251 - }
1252 -
1253 - /// A view sitting on a tailnet it has never signed into.
1254 - fn logged_out_view() -> (MeshView, CommandLog) {
1255 - let (mut view, log) = bare_view();
1256 - view.status = Some(parse_status(r#"{"BackendState":"NeedsLogin","Peer":{}}"#).unwrap());
1257 - (view, log)
1258 - }
1259 -
1260 - #[test]
1261 - fn an_unset_server_means_the_vendor_plane() {
1262 - assert_eq!(validate_login_server(""), Ok(None));
1263 - assert_eq!(validate_login_server(" "), Ok(None));
1264 - }
1265 -
1266 - #[test]
1267 - fn a_server_url_is_trimmed_and_kept() {
1268 - assert_eq!(
1269 - validate_login_server(" https://hs.example.org "),
1270 - Ok(Some("https://hs.example.org".into()))
1271 - );
1272 - // http is allowed: a Headscale on a tailnet-internal address is a real
1273 - // deployment, and refusing it would be a policy this screen has no
1274 - // standing to set.
1275 - assert_eq!(
1276 - validate_login_server("http://10.0.0.5:8080"),
1277 - Ok(Some("http://10.0.0.5:8080".into()))
1278 - );
1279 - }
1280 -
1281 - // The error a bare hostname earns has to say what to type instead. It is
1282 - // the whole reason the check exists.
1283 - #[test]
1284 - fn a_bare_hostname_is_refused_with_the_fix() {
1285 - let error = validate_login_server("hs.example.org").unwrap_err();
1286 - assert!(error.contains("https://hs.example.org"), "got: {error}");
1287 - }
1288 -
1289 - #[test]
1290 - fn enrollment_runs_tailscale_up_under_run0() {
1291 - assert_eq!(Tailscale.enroll(None).display(), "run0 tailscale up");
1292 - assert_eq!(
1293 - Tailscale.enroll(Some("https://hs.example.org")).display(),
1294 - "run0 tailscale up --login-server=https://hs.example.org"
1295 - );
1296 - }
1297 -
1298 - // A running mesh must not offer to sign in, and a status that failed to
1299 - // read must not either — the peer list's error is the thing to show, not an
1300 - // invitation to re-join a mesh the user is already on.
1301 - #[test]
1302 - fn only_a_non_running_backend_gets_the_offer() {
1303 - let (view, _log) = mock_view();
1304 - assert!(view.is_enrolled(), "the mock reports Running");
1305 -
1306 - let (view, _log) = logged_out_view();
1307 - assert!(!view.is_enrolled());
1308 -
1309 - let (view, _log) = bare_view();
1310 - assert!(view.is_enrolled(), "an unread status is not an offer");
1311 - }
1312 -
1313 - // `e` is the exit-node key on one screen and the sign-in key on the other.
1314 - // The two screens are never both on, and this is what says so.
1315 - #[test]
1316 - fn e_signs_in_on_the_offer_and_picks_an_exit_node_on_the_list() {
1317 - let (mut view, mut log) = logged_out_view();
1318 - press(&mut view, 'e', &mut log);
1319 - assert!(view.server.is_some(), "the offer's e opens enrollment");
1320 -
1321 - let (mut view, mut log) = mock_view();
1322 - press(&mut view, 'e', &mut log);
1323 - assert!(
1324 - view.server.is_none(),
1325 - "the list's e does not open enrollment"
1326 - );
1327 - assert!(
1328 - view.error
1329 - .as_deref()
1330 - .is_some_and(|e| e.contains("this machine")),
1331 - "it tried to route instead: {:?}",
1332 - view.error
1333 - );
1334 - }
1335 -
1336 - // A control server that survived a down/up cycle is shown rather than
1337 - // silently reused, so a self-hosted user sees which mesh they are rejoining.
1338 - #[test]
1339 - fn the_field_is_prefilled_from_a_self_hosted_plane() {
1340 - let (mut view, _log) = logged_out_view();
1341 - view.control_plane = ControlPlane::SelfHosted("hs.example.org".into());
1342 - view.open_enrollment();
1343 - assert_eq!(view.server.unwrap().value(), "https://hs.example.org");
1344 -
1345 - let (mut view, _log) = logged_out_view();
1346 - view.open_enrollment();
1347 - assert_eq!(
1348 - view.server.unwrap().value(),
1349 - "",
1350 - "the vendor plane is empty"
1351 - );
1352 - }
1353 -
1354 - // Typing is not a binding. Without this, a server named `https://r.example`
1355 - // would refresh the view and clear the exit node on the way through.
1356 - #[test]
1357 - fn the_overlay_eats_the_keys_the_list_would_claim() {
1358 - let (mut view, mut log) = logged_out_view();
1359 - view.open_enrollment();
1360 - for c in "https://rex.example".chars() {
1361 - press(&mut view, c, &mut log);
1362 - }
1363 - assert_eq!(view.server.as_ref().unwrap().value(), "https://rex.example");
1364 - assert!(
1365 - view.text_entry(),
1366 - "the shell must release its reserved keys"
1367 - );
1368 - }
1369 -
1370 - #[test]
1371 - fn a_bad_server_keeps_the_overlay_open_to_be_corrected() {
1372 - let (mut view, mut log) = logged_out_view();
1373 - view.open_enrollment();
1374 - for c in "hs.example.org".chars() {
1375 - press(&mut view, c, &mut log);
1376 - }
1377 - let flow = key(&mut view, KeyCode::Enter, &mut log);
1378 - assert!(matches!(flow, Flow::Continue), "no suspend on a bad value");
1379 - assert!(view.server.is_some(), "the typed value survives the error");
1380 - assert!(view.error.is_some());
1381 - }
1382 -
1383 - #[test]
1384 - fn a_good_server_suspends_the_console() {
1385 - let (mut view, mut log) = logged_out_view();
1386 - view.open_enrollment();
1387 - let flow = key(&mut view, KeyCode::Enter, &mut log);
1388 - assert!(matches!(flow, Flow::Suspend(_)));
1389 - assert!(view.server.is_none(), "the overlay closes on the way out");
1390 - // The pane carries the command before the handover, not after: the
1391 - // console is about to tear down and there is no after to fill in.
1392 - assert!(
1393 - log.entries().iter().any(|e| e.command.contains("true")),
1394 - "the enrollment command was not logged"
1395 - );
1396 - }
1397 -
1398 - #[test]
1399 - fn esc_closes_the_overlay_before_it_closes_the_view() {
1400 - let (mut view, mut log) = logged_out_view();
1401 - view.open_enrollment();
1402 - key(&mut view, KeyCode::Esc, &mut log);
1403 - assert!(view.server.is_none());
1404 - assert!(matches!(view.cancel(), Flow::Exit), "then Esc leaves");
1405 - }
1406 -
1407 - #[test]
1408 - fn ticks_do_not_refresh_under_the_overlay() {
1409 - let (mut view, mut log) = logged_out_view();
1410 - view.open_enrollment();
1411 - for _ in 0..POLL_TICKS * 2 {
1412 - view.tick(&mut log);
1413 - }
1414 - assert!(view.server.is_some(), "the overlay survived the poll");
1415 - assert!(!view.is_enrolled(), "and the status behind it is untouched");
1416 - }
1417 -
1418 - /// Parse this machine's real tailnet.
1419 - ///
1420 - /// Ignored by default: needs Tailscale installed and logged in, and what
1421 - /// it finds depends on the tailnet. Run it when touching the parser.
1422 - #[test]
1423 - #[ignore = "requires a logged-in Tailscale"]
1424 - fn parses_this_machines_real_tailnet() {
1425 - let mut log = CommandLog::new();
1426 - let status = Tailscale.status(&mut log).expect("tailscale should answer");
1427 -
1428 - assert!(!status.peers.is_empty(), "a mesh has at least this machine");
1429 - assert!(status.peers[0].is_self, "this machine sorts first");
1430 -
1431 - // The control-plane lookup rides an unstable `debug` interface, so
1432 - // what matters is that it produced *something* rather than silently
1433 - // degrading to Unknown on a working client.
1434 - let control = Tailscale.control_plane();
1435 - println!("control plane: {control:?}");
1436 - assert_ne!(
1437 - control,
1438 - ControlPlane::Unknown,
1439 - "`tailscale debug prefs` no longer yields a ControlURL; the lookup \
1440 - has degraded and the title will silently drop its suffix"
1441 - );
1442 - println!(
1443 - "backend: {} health: {:?}",
1444 - status.backend_state, status.health
1445 - );
1446 - for peer in &status.peers {
1447 - assert!(!peer.hostname.is_empty(), "every row is identifiable");
1448 - assert!(
1449 - peer.last_seen.as_deref() != Some("0001-01-01"),
1450 - "Go zero time leaked into a last-seen date"
Lines truncated
@@ -1265,744 +1265,4 @@
1265 1265 }
1266 1266
1267 1267 #[cfg(test)]
1268 - mod tests {
1269 - use super::*;
1270 -
1271 - #[test]
1272 - fn a_wireless_device_needs_no_explanation() {
1273 - assert_eq!(no_wireless(true, Some(true), true, None), None);
1274 - // Even with every other signal looking wrong: the device is there, so
1275 - // there is nothing to explain and the screen stays quiet.
1276 - assert_eq!(no_wireless(true, Some(false), false, Some("wlan0")), None);
1277 - }
1278 -
1279 - #[test]
1280 - fn a_missing_plugin_outranks_every_other_cause() {
1281 - // The state fw12 was in on 2026-09-03, and the reason the ordering is
1282 - // an ordering: with no plugin there is also no managed device and no
1283 - // radio to read, so the vaguer causes would all match too.
1284 - assert_eq!(
1285 - no_wireless(false, None, false, None),
1286 - Some(NoWireless::NoPlugin)
1287 - );
1288 - assert_eq!(
1289 - no_wireless(false, Some(false), false, Some("wlp1s0")),
1290 - Some(NoWireless::NoPlugin)
1291 - );
1292 - }
1293 -
1294 - #[test]
1295 - fn a_kernel_interface_nm_does_not_show_names_itself() {
1296 - assert_eq!(
1297 - no_wireless(false, Some(true), true, Some("wlp192s0")),
1298 - Some(NoWireless::Unmanaged("wlp192s0".to_string()))
1299 - );
1300 - }
1301 -
1302 - #[test]
1303 - fn a_dark_radio_explains_an_absent_device_only_when_nothing_else_does() {
1304 - assert_eq!(
1305 - no_wireless(false, Some(false), true, None),
1306 - Some(NoWireless::RadioOff)
1307 - );
1308 - }
1309 -
1310 - #[test]
1311 - fn no_hardware_is_the_answer_when_nothing_is_wrong() {
1312 - assert_eq!(
1313 - no_wireless(false, Some(true), true, None),
1314 - Some(NoWireless::NoHardware)
1315 - );
1316 - // A backend that cannot report the radio (the mock) is not evidence of
1317 - // a fault either.
1318 - assert_eq!(
1319 - no_wireless(false, None, true, None),
1320 - Some(NoWireless::NoHardware)
1321 - );
1322 - }
1323 -
1324 - #[test]
1325 - fn every_message_says_what_to_do_next() {
1326 - // A diagnosis with no next step is a complaint. NoHardware is the one
1327 - // exception and is exempt: there is nothing to do about a machine that
1328 - // has no radio.
1329 - for reason in [
1330 - NoWireless::NoPlugin,
1331 - NoWireless::Unmanaged("wlan0".to_string()),
1332 - NoWireless::RadioOff,
1333 - ] {
1334 - let message = reason.message();
1335 - assert!(
1336 - message.contains("Layer") || message.contains("Check") || message.contains("press"),
1337 - "{message}"
1338 - );
1339 - }
1340 - }
1341 -
1342 - // Captured verbatim from `nmcli -t -f GENERAL.DEVICE,GENERAL.TYPE,
1343 - // GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS,IP6.ADDRESS device show` on
1344 - // a NetworkManager 1.5x box, hostname and SSID aside. Kept real rather
1345 - // than tidied: the awkward parts below (nested parens in the state, an
1346 - // empty trailing connection, a bare `::1`) are all things nmcli actually
1347 - // emits, and a hand-written fixture is where a parser goes to pass tests
1348 - // it would fail in production.
1349 - const SAMPLE: &str = "\
1350 - GENERAL.DEVICE:wlp192s0
1351 - GENERAL.TYPE:wifi
1352 - GENERAL.STATE:100 (connected)
1353 - GENERAL.CONNECTION:Example Network
1354 - IP4.ADDRESS[1]:192.168.0.16/24
1355 - IP6.ADDRESS[1]:fe80::59a3:bc22:d95f:c06b/64
1356 -
1357 - GENERAL.DEVICE:tailscale0
1358 - GENERAL.TYPE:tun
1359 - GENERAL.STATE:100 (connected (externally))
1360 - GENERAL.CONNECTION:tailscale0
1361 - IP4.ADDRESS[1]:100.103.89.95/32
1362 - IP6.ADDRESS[1]:fd7a:115c:a1e0::af3b:595f/128
1363 - IP6.ADDRESS[2]:fe80::ccae:60fc:a1c5:3b13/64
1364 -
1365 - GENERAL.DEVICE:lo
1366 - GENERAL.TYPE:loopback
1367 - GENERAL.STATE:100 (connected (externally))
1368 - GENERAL.CONNECTION:lo
1369 - IP4.ADDRESS[1]:127.0.0.1/8
1370 - IP6.ADDRESS[1]:::1/128
1371 -
1372 - GENERAL.DEVICE:p2p-dev-wlp192s0
1373 - GENERAL.TYPE:wifi-p2p
1374 - GENERAL.STATE:30 (disconnected)
1375 - GENERAL.CONNECTION:
1376 - ";
1377 -
1378 - #[test]
1379 - fn parses_every_device_block() {
1380 - let ifaces = parse_device_show(SAMPLE);
1381 - assert_eq!(ifaces.len(), 4);
1382 - assert_eq!(ifaces[0].name, "wlp192s0");
1383 - assert_eq!(ifaces[0].kind, Kind::Wireless);
1384 - assert_eq!(ifaces[0].state, State::Connected);
1385 - assert_eq!(ifaces[0].connection.as_deref(), Some("Example Network"));
1386 - assert_eq!(
1387 - ifaces[3].name, "p2p-dev-wlp192s0",
1388 - "the last block is not dropped for want of a trailing blank line"
1389 - );
1390 - }
1391 -
1392 - // IPv6 values carry colons and arrive unescaped, so the split has to be on
1393 - // the *first* colon only. Splitting on every colon shows `fe80` as the
1394 - // address; `::1/128` is the case that breaks a naive rsplit as well.
1395 - #[test]
1396 - fn ipv6_addresses_survive_the_key_value_split() {
1397 - let ifaces = parse_device_show(SAMPLE);
1398 - assert_eq!(
1399 - ifaces[0].addresses,
1400 - vec!["192.168.0.16/24", "fe80::59a3:bc22:d95f:c06b/64"]
1401 - );
1402 - assert_eq!(
1403 - ifaces[1].addresses,
1404 - vec![
1405 - "100.103.89.95/32",
1406 - "fd7a:115c:a1e0::af3b:595f/128",
1407 - "fe80::ccae:60fc:a1c5:3b13/64",
1408 - ],
1409 - "every indexed address is collected, not just the first"
1410 - );
1411 - assert_eq!(ifaces[2].addresses[1], "::1/128");
1412 - }
1413 -
1414 - // NM leaves the connection field empty for a device with no active
1415 - // connection. Empty must read as absent, not as a connection named "".
1416 - #[test]
1417 - fn treats_an_empty_connection_as_absent() {
1418 - let ifaces = parse_device_show(SAMPLE);
1419 - assert_eq!(ifaces[3].connection, None);
1420 - assert!(ifaces[3].addresses.is_empty());
1421 - }
1422 -
1423 - // `--` is NM's other placeholder for "none", used where a field is
1424 - // tabulated rather than left blank.
1425 - #[test]
1426 - fn treats_double_dash_connection_as_absent() {
1427 - let raw = "GENERAL.DEVICE:enp2s0\nGENERAL.TYPE:ethernet\nGENERAL.CONNECTION:--\n";
1428 - assert_eq!(parse_device_show(raw)[0].connection, None);
1429 - }
1430 -
1431 - // The state field nests parentheses: "100 (connected (externally))". Only
1432 - // the leading numeric code is stable across locales, so that is what is
1433 - // parsed; anything reading the text would misclassify this as unmanaged.
1434 - #[test]
1435 - fn parses_state_from_the_numeric_code_not_the_text() {
1436 - let ifaces = parse_device_show(SAMPLE);
1437 - assert_eq!(ifaces[1].state, State::Connected);
1438 - assert_eq!(ifaces[3].state, State::Disconnected);
1439 - }
1440 -
1441 - #[test]
1442 - fn empty_output_yields_no_interfaces() {
1443 - assert!(parse_device_show("").is_empty());
1444 - }
1445 -
1446 - // NM's device-type vocabulary is open-ended; an unknown type must still
1447 - // list rather than vanish.
1448 - #[test]
1449 - fn unknown_device_types_are_listed_as_other() {
1450 - let raw = "GENERAL.DEVICE:wg0\nGENERAL.TYPE:wireguard\nGENERAL.STATE:100 (connected)\n";
1451 - let ifaces = parse_device_show(raw);
1452 - assert_eq!(ifaces.len(), 1);
1453 - assert_eq!(ifaces[0].kind, Kind::Other);
1454 - assert_eq!(ifaces[0].state, State::Connected);
1455 - }
1456 -
1457 - fn mock_view() -> (NetView, CommandLog) {
1458 - let mut log = CommandLog::new();
1459 - let mut view = NetView {
1460 - backend: Box::new(Mock),
1461 - interfaces: Vec::new(),
1462 - cursor: Cursor::new(),
1463 - error: None,
1464 - wifi: None,
1465 - mode: Mode::Devices,
1466 - pending: None,
1467 - no_wireless: None,
1468 - };
1469 - view.refresh(&mut log);
1470 - (view, log)
1471 - }
1472 -
1473 - // Cursor's own tests cover the clamping; this checks the wiring, that
1474 - // refresh actually tells the cursor the new length. Without that call the
1475 - // cursor keeps pointing at a row that no longer exists.
1476 - #[test]
1477 - fn refresh_resizes_the_cursor_when_the_list_shrinks() {
1478 - let (mut view, mut log) = mock_view();
1479 - view.cursor.move_by(2);
1480 - assert_eq!(view.cursor.selected(), Some(2));
1481 -
1482 - view.backend = Box::new(EmptyBackend);
1483 - view.refresh(&mut log);
1484 - assert_eq!(
1485 - view.cursor.selected(),
1486 - None,
1487 - "no selection in an empty list"
1488 - );
1489 - }
1490 -
1491 - // A failed refresh must leave the last good list on screen rather than
1492 - // blanking it, and surface the error in the status area.
1493 - #[test]
1494 - fn a_failed_refresh_keeps_the_previous_interfaces() {
1495 - let (mut view, mut log) = mock_view();
1496 - assert_eq!(view.interfaces.len(), 3);
1497 -
1498 - view.backend = Box::new(FailingBackend);
1499 - view.refresh(&mut log);
1500 - assert_eq!(view.interfaces.len(), 3, "the stale list is still shown");
1501 - assert!(view.error.is_some(), "the failure is surfaced");
1502 - }
1503 -
1504 - fn iface(name: &str, kind: Kind, state: State) -> Interface {
1505 - Interface {
1506 - name: name.into(),
1507 - kind,
1508 - state,
1509 - connection: None,
1510 - addresses: Vec::new(),
1511 - }
1512 - }
1513 -
1514 - // The argv the log pane shows and the user can paste. `device connect` and
1515 - // not `connection up`: the user picked a device, and which profile it uses
1516 - // is a different screen and a different polkit action.
1517 - #[test]
1518 - fn connect_and_disconnect_name_the_device() {
1519 - let wifi = iface("wlp1s0", Kind::Wireless, State::Disconnected);
1520 - assert_eq!(
1521 - NmCli.connect(&wifi).unwrap().display(),
1522 - "nmcli device connect wlp1s0",
1523 - );
1524 - assert_eq!(
1525 - NmCli.disconnect(&wifi).unwrap().display(),
1526 - "nmcli device disconnect wlp1s0",
1527 - );
1528 - }
1529 -
1530 - #[test]
1531 - fn the_radio_switch_names_the_direction() {
1532 - assert_eq!(
1533 - NmCli.set_wifi(true).unwrap().display(),
1534 - "nmcli radio wifi on"
1535 - );
1536 - assert_eq!(
1537 - NmCli.set_wifi(false).unwrap().display(),
1538 - "nmcli radio wifi off",
1539 - );
1540 - }
1541 -
1542 - // Loopback is never brought up or down and an unmanaged device is one NM
1543 - // has been told to leave alone. Both are still listed; neither takes the
1544 - // key.
1545 - #[test]
1546 - fn loopback_and_unmanaged_devices_take_no_action() {
1547 - let lo = iface("lo", Kind::Loopback, State::Unmanaged);
1548 - assert!(NmCli.connect(&lo).is_none());
1549 - assert!(NmCli.disconnect(&lo).is_none());
1550 -
1551 - let bridge = iface("br0", Kind::Other, State::Unmanaged);
1552 - assert!(NmCli.connect(&bridge).is_none());
1553 -
1554 - let wired = iface("enp2s0", Kind::Wired, State::Disconnected);
1555 - assert!(NmCli.connect(&wired).is_some(), "an ordinary device does");
1556 - }
1557 -
1558 - // `missing` is what nmcli says when there is no wifi hardware. Reading it
1559 - // as "off" would offer a toggle for a radio that is not there.
1560 - #[test]
1561 - fn the_radio_reads_only_the_two_answers_it_understands() {
1562 - assert_eq!(parse_radio("enabled\n"), Some(true));
1563 - assert_eq!(parse_radio("disabled\n"), Some(false));
1564 - assert_eq!(parse_radio("missing\n"), None);
1565 - assert_eq!(parse_radio(""), None);
1566 - }
1567 -
1568 - // One key, whichever way the device is pointing, because the row already
1569 - // says which state it is in.
1570 - #[test]
1571 - fn the_key_picks_the_action_the_row_is_not_already_in() {
1572 - let connected = iface("wlp1s0", Kind::Wireless, State::Connected);
1573 - let down = iface("wlp1s0", Kind::Wireless, State::Disconnected);
1574 - assert!(NmCli.disconnect(&connected).is_some());
1575 - assert!(NmCli.connect(&down).is_some());
1576 - }
1577 -
1578 - // A backend that only reads offers no keys, and the footer must not
1579 - // advertise one that does nothing.
1580 - #[test]
1581 - fn a_read_only_backend_offers_no_action_keys() {
1582 - let (view, _log) = mock_view();
1583 - let labels: Vec<&str> = view.hints().iter().map(|hint| hint.label).collect();
1584 - assert!(labels.contains(&"select"), "{labels:?}");
1585 - assert!(labels.contains(&"refresh"), "{labels:?}");
1586 - assert!(!labels.contains(&"connect/disconnect"), "{labels:?}");
1587 - assert!(!labels.contains(&"wifi radio"), "{labels:?}");
1588 - }
1589 -
1590 - // Pressing the key on a device nothing can act on must say so. "Nothing
1591 - // happened" is the one outcome a console must never produce.
1592 - #[test]
1593 - fn acting_on_a_device_with_no_action_reports_why() {
1594 - let (mut view, mut log) = mock_view();
1595 - // Row 2 of the mock is `lo`, unmanaged loopback.
1596 - view.cursor.move_by(2);
1597 - view.toggle(&mut log);
1598 - let message = view.error.as_ref().expect("something was said");
1599 - assert!(message.contains("lo"), "{message}");
1600 - }
1601 -
1602 - #[test]
1603 - fn switching_a_radio_that_is_not_there_reports_why() {
1604 - let (mut view, mut log) = mock_view();
1605 - assert_eq!(view.wifi, None);
1606 - view.toggle_wifi(&mut log);
1607 - assert!(
1608 - view.error
1609 - .as_ref()
1610 - .is_some_and(|m| m.contains("no wifi radio")),
1611 - "{:?}",
1612 - view.error,
1613 - );
1614 - }
1615 -
1616 - // The radio being off is the explanation for every wireless device sitting
1617 - // at `unavailable`, so it is worth the footer line even when nothing failed.
1618 - #[test]
1619 - fn a_radio_that_is_off_is_reported_without_an_error() {
1620 - let (mut view, _log) = mock_view();
1621 - view.wifi = Some(false);
1622 - let (severity, message) = view.status().expect("the footer says so");
1623 - assert_eq!(severity, Severity::Warn);
1624 - assert!(message.contains("wifi radio off"), "{message}");
1625 -
1626 - view.wifi = Some(true);
1627 - assert!(view.status().is_none(), "a radio that is on says nothing");
1628 - }
1629 -
1630 - // Constructed rather than captured, unlike SAMPLE above, and the difference
1631 - // is worth stating: no access point was in range of the machine this was
1632 - // written on. Every shape in it is nmcli's documented terse-tabular
1633 - // behaviour — `*` for in-use, an empty SSID for a hidden network, an empty
1634 - // security field for an open one, and `\:` for a colon inside a value — and
1635 - // the escaping is the half a hand-written fixture is most likely to get
1636 - // wrong, so it is what the tests below are mostly about.
1637 - const WIFI_LIST: &str = "\
1638 - *:Example Network:82:WPA2
1639 - :Cafe\\: Free Wifi:64:WPA2
1640 - :Example Network:41:WPA2
1641 - ::37:WPA2
1642 - :Airport WiFi:22:
1643 - ";
1644 -
1645 - #[test]
1646 - fn a_colon_inside_an_ssid_survives_the_split() {
1647 - let networks = parse_wifi_list(WIFI_LIST);
1648 - assert!(
1649 - networks.iter().any(|n| n.ssid == "Cafe: Free Wifi"),
1650 - "{networks:?}",
1651 - );
1652 - }
1653 -
1654 - #[test]
1655 - fn terse_escapes_are_undone_and_nothing_else_is() {
1656 - assert_eq!(split_terse("a:b"), vec!["a", "b"]);
1657 - assert_eq!(split_terse("a\\:b:c"), vec!["a:b", "c"]);
1658 - assert_eq!(split_terse("a\\\\:b"), vec!["a\\", "b"]);
1659 - assert_eq!(
1660 - split_terse("::"),
1661 - vec!["", "", ""],
1662 - "empty fields are fields",
1663 - );
1664 - }
1665 -
1666 - // Three access points carrying one SSID is one row: NM connects to a name,
1667 - // so offering the name three times offers the same choice three times.
1668 - #[test]
1669 - fn one_row_per_ssid_at_the_strongest_signal() {
1670 - let networks = parse_wifi_list(WIFI_LIST);
1671 - let example: Vec<&Network> = networks
1672 - .iter()
1673 - .filter(|n| n.ssid == "Example Network")
1674 - .collect();
1675 - assert_eq!(example.len(), 1, "{networks:?}");
1676 - assert_eq!(example[0].signal, 82);
1677 - assert!(example[0].in_use, "the merge keeps in-use");
1678 - }
1679 -
1680 - // A hidden network has no name to show and `device wifi connect` takes a
1681 - // name, so the row could not be acted on.
1682 - #[test]
1683 - fn hidden_networks_are_dropped() {
1684 - let networks = parse_wifi_list(WIFI_LIST);
1685 - assert!(networks.iter().all(|n| !n.ssid.is_empty()), "{networks:?}");
1686 - assert_eq!(networks.len(), 3);
1687 - }
1688 -
1689 - // An empty security field is an open network, which is the one case where
1690 - // the console must not ask for a passphrase — and the one case where the
1691 - // row is a warning rather than a fact.
1692 - #[test]
1693 - fn an_empty_security_field_reads_as_open() {
1694 - let networks = parse_wifi_list(WIFI_LIST);
1695 - let open = networks
1696 - .iter()
1697 - .find(|n| n.ssid == "Airport WiFi")
1698 - .expect("the open network is listed");
1699 - assert_eq!(open.security, None);
1700 - assert_eq!(networks[0].security.as_deref(), Some("WPA2"));
1701 - }
1702 -
1703 - #[test]
1704 - fn strongest_first() {
1705 - let networks = parse_wifi_list(WIFI_LIST);
1706 - let signals: Vec<u8> = networks.iter().map(|n| n.signal).collect();
1707 - assert_eq!(signals, vec![82, 64, 22]);
1708 - }
1709 -
1710 - // The argv the log pane shows. `--ask` with nothing after the SSID is the
1711 - // whole point: the passphrase is on stdin, so there is no `password <pw>`
1712 - // for `ps` to show to every user on the machine.
1713 - #[test]
1714 - fn joining_never_puts_the_passphrase_in_argv() {
1715 - let secret = Secret::new(b"correct horse battery".to_vec());
1716 - let invocation = NmCli
1717 - .join("Example Network", Some(secret))
1718 - .expect("nmcli can join");
1719 - let shown = invocation.display();
1720 - assert_eq!(
1721 - shown,
1722 - "nmcli --ask device wifi connect 'Example Network' # input withheld",
1723 - );
1724 - assert!(!shown.contains("correct horse"), "{shown}");
1725 - }
1726 -
1727 - #[test]
1728 - fn an_open_network_is_joined_with_no_input_at_all() {
1729 - let invocation = NmCli.join("Airport WiFi", None).expect("nmcli can join");
1730 - assert_eq!(
1731 - invocation.display(),
1732 - "nmcli --ask device wifi connect 'Airport WiFi'",
1733 - "no pipe, so no `input withheld` note either",
1734 - );
1735 - }
1736 -
1737 - /// A backend that can scan and join, for the mode machine.
1738 - ///
1739 - /// Joining always succeeds here. What the failure paths do is a property of
1740 - /// [`Invocation`] and of the error text nmcli produces, neither of which a
1741 - /// fake backend would be testing.
1742 - struct JoinableBackend;
1743 -
1744 - impl Backend for JoinableBackend {
1745 - fn name(&self) -> &'static str {
1746 - "joinable"
1747 - }
1748 - fn list(&self, _log: &mut CommandLog) -> Result<Vec<Interface>> {
1749 - Ok(vec![iface("wlp1s0", Kind::Wireless, State::Disconnected)])
1750 - }
1751 - fn wifi_enabled(&self, _log: &mut CommandLog) -> Option<bool> {
1752 - Some(true)
1753 - }
1754 - fn networks(&self, _log: &mut CommandLog) -> Option<Result<Vec<Network>>> {
1755 - Some(Ok(parse_wifi_list(WIFI_LIST)))
1756 - }
1757 - fn join(&self, ssid: &str, passphrase: Option<Secret>) -> Option<Invocation> {
1758 - // `true` rather than nmcli: the view runs whatever comes back, and a
1759 - // test that shells out to a network manager is a test that fails on
1760 - // the machine it is run on.
1761 - let invocation = Invocation::new("true").arg(ssid);
1762 - Some(match passphrase {
1763 - Some(secret) => invocation.stdin(secret),
1764 - None => invocation,
Lines truncated
@@ -1023,967 +1023,4 @@
1023 1023 }
1024 1024
1025 1025 #[cfg(test)]
1026 - mod tests {
1027 - use std::os::unix::fs::PermissionsExt;
1028 - use std::process::Command;
1029 - use std::time::Duration;
1030 -
1031 - use super::*;
1032 -
1033 - // Captured from a real /proc/self/stat, with the command name replaced by
1034 - // one that is hostile in the two ways a comm field can be: it contains
1035 - // spaces and a closing parenthesis. Both are legal, because comm is the
1036 - // basename of whatever was executed and anyone can name a binary.
1037 - const STAT: &str = "4021 (evil ) prog) S 1 4021 4021 0 -1 4194304 1234 0 0 0 \
1038 - 5 3 0 0 20 0 8 0 211381054 123456 789 18446744073709551615 1 2 3";
1039 -
1040 - #[test]
1041 - fn the_start_time_is_read_past_a_hostile_command_name() {
1042 - assert_eq!(start_time(STAT), Some(211_381_054));
1043 - }
1044 -
1045 - #[test]
1046 - fn a_stat_line_with_no_comm_is_no_answer_rather_than_a_wrong_one() {
1047 - assert_eq!(start_time("4021 nonsense"), None);
1048 - assert_eq!(start_time(""), None);
1049 - }
1050 -
1051 - #[test]
1052 - fn the_helper_protocol_is_read_exactly() {
1053 - assert_eq!(
1054 - Directive::parse("PAM_PROMPT_ECHO_OFF Password: "),
1055 - Some(Directive::Prompt {
1056 - question: "Password:".into(),
1057 - echo: false,
1058 - }),
1059 - );
1060 - assert_eq!(
1061 - Directive::parse("PAM_PROMPT_ECHO_ON One-time code: "),
1062 - Some(Directive::Prompt {
1063 - question: "One-time code:".into(),
1064 - echo: true,
1065 - }),
1066 - );
1067 - assert_eq!(Directive::parse("SUCCESS"), Some(Directive::Success));
1068 - assert_eq!(Directive::parse("FAILURE"), Some(Directive::Failure));
1069 - }
1070 -
1071 - // Everything else is text for the user or a helper newer than this code,
1072 - // and neither ends a conversation that is still going.
1073 - #[test]
1074 - fn unknown_lines_are_ignored_rather_than_fatal() {
1075 - assert_eq!(Directive::parse("PAM_TEXT_INFO Insert your key"), None);
1076 - assert_eq!(Directive::parse("PAM_ERROR_MSG Try again"), None);
1077 - assert_eq!(Directive::parse(""), None);
1078 - }
1079 -
1080 - const PASSWD: &str = "root:x:0:0:root:/root:/bin/bash\n\
1081 - max:x:1000:1000:Max:/home/max:/bin/bash\n\
1082 - polkitd:x:996:993::/:/usr/sbin/nologin\n";
1083 -
1084 - #[test]
1085 - fn a_uid_resolves_to_the_name_beside_it() {
1086 - assert_eq!(username_in(PASSWD, 1000).as_deref(), Some("max"));
1087 - assert_eq!(username_in(PASSWD, 0).as_deref(), Some("root"));
1088 - assert_eq!(username_in(PASSWD, 4242), None);
1089 - }
1090 -
1091 - fn identity(kind: &str, key: &str, value: u32) -> (String, HashMap<String, OwnedValue>) {
1092 - let mut details = HashMap::new();
1093 - details.insert(
1094 - key.to_string(),
1095 - OwnedValue::try_from(Value::from(value)).expect("u32"),
1096 - );
1097 - (kind.to_string(), details)
1098 - }
1099 -
1100 - // The ordering that matters: polkit offers every administrator, and a
1101 - // console that asked for the first one would teach a laptop owner to type
1102 - // the root password into whatever is on screen.
1103 - #[test]
1104 - fn the_users_own_identity_is_preferred_over_root() {
1105 - let uid = self_uid().expect("this process has a uid");
1106 - let identities = vec![
1107 - identity("unix-user", "uid", 0),
1108 - identity("unix-user", "uid", uid),
1109 - ];
1110 - let chosen = choose_identity(&identities);
1111 - assert_eq!(chosen, username_of(uid), "{chosen:?}");
1112 - }
1113 -
1114 - // A group is not something the helper can be asked about, and expanding one
1115 - // would mean picking an administrator for the user.
1116 - #[test]
1117 - fn group_identities_are_not_asked_for() {
1118 - let identities = vec![identity("unix-group", "gid", 10)];
1119 - assert_eq!(choose_identity(&identities), None);
1120 - }
1121 -
1122 - #[test]
1123 - fn an_empty_identity_list_asks_nobody() {
1124 - assert_eq!(choose_identity(&[]), None);
1125 - }
1126 -
1127 - // ---- the helper conversation, against a scripted helper ----
1128 -
1129 - /// Held for as long as a scripted helper exists, so no two of these tests
1130 - /// have a script open for writing while another is executing one.
1131 - ///
1132 - /// Not tidiness and not a fixture: without it these tests fail together
1133 - /// about one run in eight, with `converse` reporting that it could not run
1134 - /// the helper. The cause is `ETXTBSY` and it is a race between tests rather
1135 - /// than inside one. `Command::spawn` forks, and the child holds a copy of
1136 - /// every descriptor the parent had open until it execs; a script another
1137 - /// test is in the middle of writing is therefore open for writing in that
1138 - /// child, and Linux refuses to execute a file any process has open for
1139 - /// writing. The window is microseconds wide and there is nothing to fix in
1140 - /// `converse`, which is doing the ordinary thing.
1141 - ///
1142 - /// It closes half of the race and not all of it: the fork that loses can
1143 - /// come from any thread in the test binary. [`retrying`] covers the rest.
1144 - static SCRIPTS: Mutex<()> = Mutex::new(());
1145 -
1146 - /// Linux refuses to execute a file that some process holds open for
1147 - /// writing, and this is the errno it says so with.
1148 - const ETXTBSY: i32 = 26;
1149 -
1150 - /// Run a scripted-helper conversation, re-attempting while the exec is
1151 - /// refused with `ETXTBSY`.
1152 - ///
1153 - /// The [`SCRIPTS`] lock serialises these tests against each other and
1154 - /// cannot cover this on its own: `Command::spawn` forks, and any thread in
1155 - /// the binary that forks between the script being written and its exec
1156 - /// holds a writable descriptor to that inode. A `cargo test` run that is
1157 - /// also compiling supplies those forks, which is when the failure shows up.
1158 - /// Test-only: production `converse` execs a setuid helper it never wrote,
1159 - /// so it cannot hit this.
1160 - ///
1161 - /// A refused exec runs nothing, so a retry repeats no side effect.
1162 - fn retrying(attempt: impl Fn() -> Result<()>) -> Result<()> {
1163 - for wait in [1u64, 2, 5, 10, 25, 50] {
1164 - match attempt() {
1165 - Err(err) if text_file_busy(&err) => {
1166 - std::thread::sleep(Duration::from_millis(wait));
1167 - }
1168 - outcome => return outcome,
1169 - }
1170 - }
1171 - attempt()
1172 - }
1173 -
1174 - /// Whether anything in the error chain is `ETXTBSY`.
1175 - fn text_file_busy(err: &anyhow::Error) -> bool {
1176 - err.chain().any(|cause| {
1177 - cause
1178 - .downcast_ref::<std::io::Error>()
1179 - .and_then(std::io::Error::raw_os_error)
1180 - == Some(ETXTBSY)
1181 - })
1182 - }
1183 -
1184 - /// A stand-in for `polkit-agent-helper-1`: the same line protocol, written
1185 - /// out as a shell script so a conversation can be tested end to end with no
1186 - /// D-Bus, no polkit, and no setuid binary anywhere near it. This is what
1187 - /// [`converse`] taking `ask` as a closure was for.
1188 - ///
1189 - /// A guard rather than a path, for two reasons. It carries the [`SCRIPTS`]
1190 - /// lock, which is what the exec race above needs. And it removes what it
1191 - /// wrote however the test ends, where a `remove_file` on the last line of
1192 - /// each test leaves the file behind on every failure.
1193 - struct ScriptedHelper {
1194 - path: PathBuf,
1195 - serialised: Option<std::sync::MutexGuard<'static, ()>>,
1196 - }
1197 -
1198 - impl ScriptedHelper {
1199 - /// Named per test as well as locked, so a leftover from an earlier run
1200 - /// is never the file a test is reading.
1201 - fn new(name: &str, script: &str) -> Self {
1202 - let serialised = SCRIPTS.lock().unwrap_or_else(PoisonError::into_inner);
1203 - let path = std::env::temp_dir().join(format!(
1204 - "alloy-polkit-{name}-{}-{:?}",
1205 - std::process::id(),
1206 - std::thread::current().id(),
1207 - ));
1208 - std::fs::write(&path, script).expect("the script is written");
1209 - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))
1210 - .expect("the script is executable");
1211 - Self {
1212 - path,
1213 - serialised: Some(serialised),
1214 - }
1215 - }
1216 -
1217 - fn path(&self) -> &Path {
1218 - &self.path
1219 - }
1220 -
1221 - /// Where a script that records what it was told writes it.
1222 - fn sidecar(&self) -> PathBuf {
1223 - PathBuf::from(format!("{}.seen", self.path.display()))
1224 - }
1225 - }
1226 -
1227 - impl Drop for ScriptedHelper {
1228 - fn drop(&mut self) {
1229 - let _ = std::fs::remove_file(&self.path);
1230 - let _ = std::fs::remove_file(self.sidecar());
1231 - // Explicit and last: the lock is what keeps another test from
1232 - // writing a script while this one's is still on disk to be run.
1233 - drop(self.serialised.take());
1234 - }
1235 - }
1236 -
1237 - fn scripted_helper(name: &str, script: &str) -> ScriptedHelper {
1238 - ScriptedHelper::new(name, script)
1239 - }
1240 -
1241 - /// Reads the cookie, asks once, and says whether the answer was the one it
1242 - /// wanted. `read -r` is the same one-line-at-a-time protocol the real
1243 - /// helper speaks.
1244 - const ASKS_ONCE: &str = "#!/bin/sh\n\
1245 - read -r cookie\n\
1246 - printf 'PAM_PROMPT_ECHO_OFF Password: \\n'\n\
1247 - read -r answer\n\
1248 - if [ \"$answer\" = letmein ]; then printf 'SUCCESS\\n'; else printf 'FAILURE\\n'; fi\n";
1249 -
1250 - /// Answer every prompt with `answer`, recording the questions asked.
1251 - ///
1252 - /// The answer goes back from a thread of its own because the reply channel
1253 - /// is a rendezvous: [`converse`] hands the prompt over and only then waits
1254 - /// on it, so answering inline would be a send nobody has reached the
1255 - /// receive for. On the real path the answering thread is the one drawing
1256 - /// the screen.
1257 - fn answering(
1258 - answer: &'static str,
1259 - asked: &Arc<Mutex<Vec<String>>>,
1260 - ) -> impl Fn(Prompt) -> Result<()> {
1261 - let asked = Arc::clone(asked);
1262 - move |prompt| {
1263 - asked
1264 - .lock()
1265 - .expect("not poisoned")
1266 - .push(prompt.question.clone());
1267 - std::thread::spawn(move || prompt.answer(Secret::new(answer)));
1268 - Ok(())
1269 - }
1270 - }
1271 -
1272 - #[test]
1273 - fn the_typed_answer_reaches_the_helper_as_one_line() {
1274 - let helper = scripted_helper("accepted", ASKS_ONCE);
1275 - let asked = Arc::new(Mutex::new(Vec::new()));
1276 - let ask = answering("letmein", &asked);
1277 - let outcome = retrying(|| {
1278 - converse(
1279 - helper.path(),
1280 - "someone",
1281 - "cookie",
1282 - "an.action",
1283 - "polkit's sentence",
1284 - &Arc::new(AtomicBool::new(false)),
1285 - &ask,
1286 - )
1287 - });
1288 - assert!(outcome.is_ok(), "{outcome:?}");
1289 - assert_eq!(
1290 - asked.lock().expect("not poisoned").as_slice(),
1291 - ["Password:"]
1292 - );
1293 - }
1294 -
1295 - #[test]
1296 - fn a_wrong_answer_is_the_helpers_verdict_and_not_an_error_here() {
1297 - let helper = scripted_helper("refused", ASKS_ONCE);
1298 - let asked = Arc::new(Mutex::new(Vec::new()));
1299 - let ask = answering("guess", &asked);
1300 - let outcome = retrying(|| {
1301 - converse(
1302 - helper.path(),
1303 - "someone",
1304 - "cookie",
1305 - "an.action",
1306 - "polkit's sentence",
1307 - &Arc::new(AtomicBool::new(false)),
1308 - &ask,
1309 - )
1310 - });
1311 - assert_eq!(outcome.unwrap_err().to_string(), "not authorized");
1312 - }
1313 -
1314 - // The invariant [`Prompt::answer`] states, enforced where it is relied on.
1315 - // A newline would end the line early and leave the rest to be read as the
1316 - // next message of a protocol deciding whether to authorize something.
1317 - #[test]
1318 - fn an_answer_carrying_a_newline_is_refused_rather_than_written() {
1319 - let helper = scripted_helper("newline", ASKS_ONCE);
1320 - let asked = Arc::new(Mutex::new(Vec::new()));
1321 - let ask = answering("letmein\nSUCCESS", &asked);
1322 - let outcome = retrying(|| {
1323 - converse(
1324 - helper.path(),
1325 - "someone",
1326 - "cookie",
1327 - "an.action",
1328 - "polkit's sentence",
1329 - &Arc::new(AtomicBool::new(false)),
1330 - &ask,
1331 - )
1332 - });
1333 - assert_eq!(
1334 - outcome.unwrap_err().to_string(),
1335 - "an answer cannot contain a newline",
1336 - );
1337 - }
1338 -
1339 - // polkit cancelled before this conversation got as far as its question, so
1340 - // nothing should be put on the screen at all.
1341 - #[test]
1342 - fn a_withdrawn_conversation_asks_nothing() {
1343 - let helper = scripted_helper("withdrawn", ASKS_ONCE);
1344 - let asked = Arc::new(Mutex::new(Vec::new()));
1345 - let ask = answering("letmein", &asked);
1346 - let outcome = retrying(|| {
1347 - converse(
1348 - helper.path(),
1349 - "someone",
1350 - "cookie",
1351 - "an.action",
1352 - "polkit's sentence",
1353 - &Arc::new(AtomicBool::new(true)),
1354 - &ask,
1355 - )
1356 - });
1357 - assert_eq!(outcome.unwrap_err().to_string(), "withdrawn");
1358 - assert!(
1359 - asked.lock().expect("not poisoned").is_empty(),
1360 - "a cancelled conversation puts no question on the screen",
1361 - );
1362 - }
1363 -
1364 - // A prompt already on the screen learns about the withdrawal through the
1365 - // flag it carries, which is the only route: the screen owns the prompt and
1366 - // the bus thread cannot reach into it.
1367 - #[test]
1368 - fn a_prompt_reports_the_withdrawal_of_the_conversation_it_belongs_to() {
1369 - let helper = scripted_helper("reports", ASKS_ONCE);
1370 - // The path rather than the guard: the guard holds a `MutexGuard`, which
1371 - // is not `Send`, and the file has to outlive the thread either way.
1372 - let path = helper.path().to_path_buf();
1373 - let withdrawn = Arc::new(AtomicBool::new(false));
1374 - let (seen, prompts) = sync_channel(1);
1375 - let watching = Arc::clone(&withdrawn);
1376 - let conversing = std::thread::spawn(move || {
1377 - let ask = move |prompt| seen.send(prompt).map_err(|_| anyhow!("nobody listening"));
1378 - retrying(|| {
1379 - converse(
1380 - &path,
1381 - "someone",
1382 - "cookie",
1383 - "an.action",
1384 - "polkit's sentence",
1385 - &watching,
1386 - &ask,
1387 - )
1388 - })
1389 - });
1390 -
1391 - let prompt = prompts
1392 - .recv_timeout(Duration::from_secs(10))
1393 - .expect("the question reaches the screen");
1394 - assert!(!prompt.withdrawn(), "nothing has been cancelled yet");
1395 - withdrawn.store(true, Ordering::Relaxed);
1396 - assert!(prompt.withdrawn(), "the screen can see the cancellation");
1397 -
1398 - // What the screen then does: take the modal down, which is a dismissal.
1399 - prompt.dismiss();
1400 - assert_eq!(
1401 - conversing
1402 - .join()
1403 - .expect("the conversation thread")
1404 - .unwrap_err()
1405 - .to_string(),
1406 - "dismissed",
1407 - );
1408 - }
1409 -
1410 - // The window between the answer arriving and it being written. The screen
1411 - // takes a withdrawn modal down, but a keypress can beat its next poll, and
1412 - // an answer for a conversation polkit has abandoned must not reach the
1413 - // helper. Nothing else in this file would notice: the top-of-loop check is
1414 - // taken before the question goes up, and there is no next message after an
1415 - // answer is written.
1416 - #[test]
1417 - fn an_answer_arriving_after_a_withdrawal_is_not_written_to_the_helper() {
1418 - // Records what it was told, so "the answer never reached it" is an
1419 - // assertion about the helper rather than about the error text.
1420 - const RECORDS: &str = "#!/bin/sh\n\
1421 - read -r cookie\n\
1422 - printf 'PAM_PROMPT_ECHO_OFF Password: \\n'\n\
1423 - read -r answer\n\
1424 - printf '%s' \"$answer\" > \"$0.seen\"\n\
1425 - printf 'SUCCESS\\n'\n";
1426 -
1427 - let helper = scripted_helper("late", RECORDS);
1428 - let seen = helper.sidecar();
1429 - let withdrawn = Arc::new(AtomicBool::new(false));
1430 -
1431 - let cancelling = Arc::clone(&withdrawn);
1432 - let ask = move |prompt: Prompt| {
1433 - // polkit withdraws while the question is up, and the answer is
1434 - // sent anyway: the keypress and the withdrawal crossed.
1435 - cancelling.store(true, Ordering::Relaxed);
1436 - std::thread::spawn(move || prompt.answer(Secret::new("letmein")));
1437 - Ok(())
1438 - };
1439 - let outcome = retrying(|| {
1440 - converse(
1441 - helper.path(),
1442 - "someone",
1443 - "cookie",
1444 - "an.action",
1445 - "polkit's sentence",
1446 - &withdrawn,
1447 - &ask,
1448 - )
1449 - });
1450 -
1451 - assert_eq!(outcome.unwrap_err().to_string(), "withdrawn");
1452 - assert!(
1453 - !seen.exists(),
1454 - "the password reached a helper whose conversation was over",
1455 - );
1456 - }
1457 -
1458 - // ---- text this code did not write ----
1459 -
1460 - // polkit localizes its messages, so the filter has to leave the marks that
1461 - // spell a language. Guarding the carve-out rather than the removal: the
1462 - // stripping tests below pass whether or not these survive, so without this
1463 - // one a later widening back to the whole `Cf` block goes unnoticed until an
1464 - // RTL locale reads a mangled prompt.
1465 - #[test]
1466 - fn the_marks_that_spell_a_language_survive_the_filter() {
1467 - // ZWNJ, without which the Persian is misspelled.
1468 - let persian = "\u{645}\u{6cc}\u{200c}\u{62e}\u{648}\u{627}\u{647}\u{645}";
1469 - assert_eq!(printable(persian), persian);
1470 - // ZWJ, which the Indic scripts need for the same reason.
1471 - let devanagari = "\u{915}\u{94d}\u{200d}\u{937}";
1472 - assert_eq!(printable(devanagari), devanagari);
1473 - // The directional marks that pin a Latin word inside an Arabic sentence.
1474 - for mark in ['\u{061c}', '\u{200e}', '\u{200f}'] {
1475 - assert_eq!(
1476 - printable(&format!("a{mark}b")),
1477 - format!("a{mark}b"),
1478 - "{mark:?} orders its neighbours and cannot run to end of line",
1479 - );
1480 - }
1481 - // The half that does run to end of line still goes.
1482 - for attack in ['\u{202a}', '\u{202e}', '\u{2066}', '\u{2069}'] {
1483 - assert_eq!(
1484 - printable(&format!("a{attack}b")),
1485 - "ab",
1486 - "{attack:?} opens a state the rest of the string is read in",
1487 - );
1488 - }
1489 - }
1490 -
1491 - #[test]
1492 - fn control_characters_are_stripped_out_of_borrowed_text() {
1493 - assert_eq!(printable("Password:"), "Password:");
1494 - assert_eq!(
1495 - printable("\u{1b}]0;pwned\u{7}Password:"),
1496 - "]0;pwnedPassword:"
1497 - );
1498 - assert_eq!(printable("two\nlines\ttabbed"), "twolinestabbed");
1499 - assert_eq!(printable("\u{7f}\u{9b}"), "", "DEL and the C1 set go too");
1500 - assert_eq!(
1501 - printable("no\u{202e}drawrofkcab"),
1502 - "nodrawrofkcab",
1503 - "a bidi override cannot reorder a sentence about what is authorized",
1504 - );
1505 - assert_eq!(
1506 - printable("ad\u{200b}min"),
1507 - "admin",
1508 - "and a zero-width space cannot hide the difference between two names",
1509 - );
1510 - assert_eq!(printable("\u{feff}\u{2066}\u{e0041}"), "");
1511 - assert_eq!(
1512 - printable("naïve café"),
1513 - "naïve café",
1514 - "ordinary text is untouched"
1515 - );
1516 - }
1517 -
1518 - // The strings polkit and PAM send reach the modal through the prompt, so
1519 - // that is where the stripping has to have happened.
1520 - #[test]
1521 - fn polkits_and_pams_own_strings_reach_the_screen_stripped() {
1522 - const HOSTILE: &str = "#!/bin/sh\n\
Lines truncated
@@ -932,388 +932,4 @@
932 932 }
933 933
934 934 #[cfg(test)]
935 - mod tests {
936 - use super::*;
937 -
938 - /// The DSL's worked example. Parsing it is the test that matters most: it
939 - /// is the file the format was designed against, and it exercises every
940 - /// affordance except `path` and `hex-alpha`.
941 - ///
942 - /// A fixture rather than a shipped schema since the terminal swap took rio
943 - /// out of the image. Kept whole anyway: what it is worth here is the
944 - /// breadth, and trimming it to the parts a current schema uses would leave
945 - /// groups and list-of-tables covered by nothing.
946 - const RIO: &str = include_str!("../testdata/rio.toml.schema");
947 -
948 - fn rio() -> Schema {
949 - Schema::parse(RIO).expect("the rio fixture parses")
950 - }
951 -
952 - fn parse(body: &str) -> Result<Schema> {
953 - Schema::parse(&format!(
954 - "[schema]\n\
955 - target = \"t.toml\"\n\
956 - target_tool = \"t\"\n\
957 - schema_version = \"1\"\n\
958 - {body}",
959 - ))
960 - }
961 -
962 - /// The whole context chain, which is what a schema error says.
963 - ///
964 - /// `Display` on its own gives only the outermost layer — "field `a`" —
965 - /// and the reason is the layer underneath. Whatever renders the fallback
966 - /// diagnostic has to format with `{:#}` for the same reason these tests do.
967 - fn error(result: Result<Schema>) -> String {
968 - format!("{:#}", result.unwrap_err())
969 - }
970 -
971 - #[test]
972 - fn the_shipped_schema_parses() {
973 - let schema = rio();
974 - assert_eq!(schema.header.target_tool, "rio");
975 - assert_eq!(schema.header.target_version.as_deref(), Some(">=0.2"));
976 - assert_eq!(
977 - schema.header.target_path.as_deref(),
978 - Some("$XDG_CONFIG_HOME/rio/config.toml"),
979 - );
980 - assert_eq!(schema.header.unknown_keys, UnknownKeys::Preserve);
981 - assert_eq!(schema.sections.len(), 7);
982 - }
983 -
984 - // 22 `[[field]]` blocks plus the 29 entries the colors group expands to.
985 - // Written out because the count is the assertion: if a group stopped
986 - // expanding, every other test here would still pass.
987 - #[test]
988 - fn groups_expand_into_ordinary_fields() {
989 - let schema = rio();
990 - assert_eq!(schema.fields.len(), 22 + 29);
991 -
992 - let field = schema.field("colors.background").expect("expanded");
993 - assert!(matches!(
994 - field.kind,
995 - FieldKind::Color {
996 - format: ColorFormat::Hex,
997 - ..
998 - }
999 - ));
1000 - // The group carries the format; the entry carries the default and its
1001 - // own description.
1002 - let FieldKind::Color { default, .. } = &field.kind else {
1003 - unreachable!()
1004 - };
1005 - assert_eq!(default.as_deref(), Some("#e4ded6"));
1006 - assert_eq!(field.description.as_deref(), Some("Surface background."));
1007 - }
1008 -
1009 - #[test]
1010 - fn an_entry_without_a_description_falls_back_to_the_groups() {
1011 - let schema = parse(
1012 - "[[group]]\n\
1013 - path = \"colors\"\n\
1014 - type = \"color\"\n\
1015 - description = \"Terminal palette.\"\n\
1016 - entries = [{ key = \"red\", default = \"#ff0000\" }]\n",
1017 - )
1018 - .unwrap();
1019 - assert_eq!(
1020 - schema.field("colors.red").unwrap().description.as_deref(),
1021 - Some("Terminal palette."),
1022 - );
1023 - }
1024 -
1025 - #[test]
1026 - fn enum_values_take_both_the_flat_and_the_structured_form() {
1027 - let schema = rio();
1028 -
1029 - let FieldKind::Enum { values, .. } = &schema.field("renderer.performance").unwrap().kind
1030 - else {
1031 - panic!("renderer.performance is an enum")
1032 - };
1033 - assert_eq!(values[0].value, "High");
1034 - assert_eq!(values[0].label, "High", "a flat value labels itself");
1035 - assert!(values[0].description.is_none());
1036 -
1037 - let FieldKind::Enum { values, .. } = &schema.field("window.decorations").unwrap().kind
1038 - else {
1039 - panic!("window.decorations is an enum")
1040 - };
1041 - let disabled = values.iter().find(|v| v.value == "Disabled").unwrap();
1042 - assert_eq!(disabled.label, "Disabled");
1043 - assert!(disabled.description.as_ref().unwrap().contains("Sway"));
1044 - }
1045 -
1046 - #[test]
1047 - fn a_list_field_carries_its_element_fields() {
1048 - let schema = rio();
1049 - let FieldKind::List { element } = &schema.field("bindings.keys").unwrap().kind else {
1050 - panic!("bindings.keys is a list")
1051 - };
1052 - let paths: Vec<&str> = element.iter().map(|field| field.path.as_str()).collect();
1053 - assert_eq!(paths, ["key", "action", "mode"]);
1054 - assert!(element[0].required, "key is required");
1055 - assert!(!element[2].required, "mode is not");
1056 - }
1057 -
1058 - // Sections are matched on whole segments and longest-first, which is what
1059 - // keeps `colors.cursor` out of the `cursor` section.
1060 - #[test]
1061 - fn a_field_lands_in_the_section_its_path_starts_with() {
1062 - let schema = rio();
1063 - assert_eq!(schema.section_of("colors.cursor").unwrap().path, "colors");
1064 - assert_eq!(schema.section_of("cursor.shape").unwrap().path, "cursor");
1065 - assert_eq!(
1066 - schema.section_of("fonts.bold.weight").unwrap().path,
1067 - "fonts"
1068 - );
1069 - assert!(schema.section_of("orphan.key").is_none());
1070 - }
1071 -
1072 - #[test]
1073 - fn the_longest_matching_section_wins() {
1074 - let schema = parse(
1075 - "[[section]]\n\
1076 - path = \"colors\"\n\
1077 - [[section]]\n\
1078 - path = \"colors.bright\"\n",
1079 - )
1080 - .unwrap();
1081 - assert_eq!(
1082 - schema.section_of("colors.bright.red").unwrap().path,
1083 - "colors.bright",
1084 - );
1085 - assert_eq!(schema.section_of("colors.red").unwrap().path, "colors");
1086 - }
1087 -
1088 - #[test]
1089 - fn a_section_does_not_swallow_a_path_it_merely_prefixes() {
1090 - let schema = parse("[[section]]\npath = \"font\"\n").unwrap();
1091 - assert!(schema.section_of("fonts.size").is_none());
1092 - assert_eq!(schema.section_of("font").unwrap().path, "font");
1093 - }
1094 -
1095 - // The three fallback routes docs/CONSOLE.md names, plus the authoring
1096 - // mistakes a hand-written schema actually makes.
1097 -
1098 - #[test]
1099 - fn a_newer_dsl_version_is_refused_rather_than_guessed_at() {
1100 - let error = error(Schema::parse(
1101 - "[schema]\n\
1102 - target = \"t.toml\"\n\
1103 - target_tool = \"t\"\n\
1104 - schema_version = \"2\"\n",
1105 - ));
1106 - assert!(error.contains("schema-DSL v1"), "{error}");
1107 - }
1108 -
1109 - #[test]
1110 - fn an_unknown_field_type_is_an_error() {
1111 - let error = error(parse("[[field]]\npath = \"a\"\ntype = \"duration\"\n"));
1112 - assert!(error.contains("duration"), "{error}");
1113 - }
1114 -
1115 - #[test]
1116 - fn a_mistyped_constraint_key_does_not_pass_silently() {
1117 - let error = error(parse(
1118 - "[[field]]\npath = \"a\"\ntype = \"int\"\nrnage = [1, 2]\n",
1119 - ));
1120 - assert!(error.contains("rnage"), "{error}");
1121 - }
1122 -
1123 - #[test]
1124 - fn a_constraint_belonging_to_another_type_is_an_error() {
1125 - let error = error(parse(
1126 - "[[field]]\npath = \"a\"\ntype = \"string\"\nrange = [1, 2]\n",
1127 - ));
1128 - assert!(error.contains("range"), "{error}");
1129 - }
1130 -
1131 - #[test]
1132 - fn a_default_outside_its_own_range_is_an_error() {
1133 - let error = error(parse(
1134 - "[[field]]\n\
1135 - path = \"fonts.regular.weight\"\n\
1136 - type = \"int\"\n\
1137 - range = [100, 900]\n\
1138 - default = 1000\n",
1139 - ));
1140 - assert!(error.contains("fonts.regular.weight"), "{error}");
1141 - assert!(error.contains("1000"), "{error}");
1142 - }
1143 -
1144 - #[test]
1145 - fn a_default_outside_the_declared_enum_is_an_error() {
1146 - let error = error(parse(
1147 - "[[field]]\n\
1148 - path = \"cursor.shape\"\n\
1149 - type = \"enum\"\n\
1150 - values = [\"block\", \"beam\"]\n\
1151 - default = \"underline\"\n",
1152 - ));
1153 - assert!(error.contains("underline"), "{error}");
1154 - }
1155 -
1156 - #[test]
1157 - fn a_malformed_color_default_is_an_error() {
1158 - for bad in ["e4ded6", "#e4ded", "#gggggg", "#e4ded6ff"] {
1159 - let error = error(parse(&format!(
1160 - "[[field]]\npath = \"colors.background\"\ntype = \"color\"\ndefault = \"{bad}\"\n",
1161 - )));
1162 - assert!(error.contains("colors.background"), "{bad}: {error}");
1163 - }
1164 - // The same value is fine where the format says eight digits.
1165 - assert!(
1166 - parse(
1167 - "[[field]]\n\
1168 - path = \"a\"\n\
1169 - type = \"color\"\n\
1170 - format = \"hex-alpha\"\n\
1171 - default = \"#e4ded6ff\"\n",
1172 - )
1173 - .is_ok()
1174 - );
1175 - }
1176 -
1177 - #[test]
1178 - fn a_duplicate_path_is_an_error_however_it_was_declared() {
1179 - let error = error(parse(
1180 - "[[field]]\n\
1181 - path = \"colors.red\"\n\
1182 - type = \"color\"\n\
1183 - [[group]]\n\
1184 - path = \"colors\"\n\
1185 - type = \"color\"\n\
1186 - entries = [{ key = \"red\", default = \"#6a2828\" }]\n",
1187 - ));
1188 - assert!(error.contains("colors.red"), "{error}");
1189 - }
1190 -
1191 - #[test]
1192 - fn a_duplicate_enum_value_is_an_error() {
1193 - let error = error(parse(
1194 - "[[field]]\n\
1195 - path = \"a\"\n\
1196 - type = \"enum\"\n\
1197 - values = [\"block\", \"block\"]\n",
1198 - ));
1199 - assert!(error.contains("block"), "{error}");
1200 - }
1201 -
1202 - #[test]
1203 - fn an_inverted_range_is_an_error() {
1204 - let error = error(parse(
1205 - "[[field]]\npath = \"a\"\ntype = \"int\"\nrange = [900, 100]\n",
1206 - ));
1207 - assert!(error.contains("inverted"), "{error}");
1208 - }
1209 -
1210 - #[test]
1211 - fn a_float_range_accepts_whole_numbers() {
1212 - let schema = parse("[[field]]\npath = \"a\"\ntype = \"float\"\nrange = [0, 1]\n").unwrap();
1213 - let FieldKind::Float { range, .. } = &schema.field("a").unwrap().kind else {
1214 - unreachable!()
1215 - };
1216 - assert_eq!(*range, Some((0.0, 1.0)));
1217 - }
1218 -
1219 - #[test]
1220 - fn unknown_keys_defaults_to_preserve_and_rejects_anything_else() {
1221 - let error = error(Schema::parse(
1222 - "[schema]\n\
1223 - target = \"t.toml\"\n\
1224 - target_tool = \"t\"\n\
1225 - schema_version = \"1\"\n\
1226 - unknown_keys = \"discard\"\n",
1227 - ));
1228 - assert!(error.contains("discard"), "{error}");
1229 - }
1230 -
1231 - #[test]
1232 - fn syntax_defaults_to_toml_and_toml_is_the_one_that_forms() {
1233 - let schema = Schema::parse(
1234 - "[schema]\n\
1235 - target = \"t.toml\"\n\
1236 - target_tool = \"t\"\n\
1237 - schema_version = \"1\"\n",
1238 - )
1239 - .unwrap();
1240 - assert_eq!(schema.header.syntax, Syntax::Toml);
1241 - assert!(Syntax::Toml.forms());
1242 - for syntax in [Syntax::Kdl, Syntax::Sway, Syntax::Text] {
1243 - assert!(!syntax.forms(), "{} has no bind behind it", syntax.name());
1244 - }
1245 - }
1246 -
1247 - // A header alone is the whole of a schema-less app's declaration, and it
1248 - // has to parse: it is what puts sway in the Applications list.
1249 - #[test]
1250 - fn a_header_only_schema_declares_an_app_with_no_form() {
1251 - let schema = Schema::parse(
1252 - "[schema]\n\
1253 - target = \"config\"\n\
1254 - target_path = \"$XDG_CONFIG_HOME/sway/config\"\n\
1255 - target_tool = \"sway\"\n\
1256 - schema_version = \"1\"\n\
1257 - syntax = \"sway\"\n",
1258 - )
1259 - .unwrap();
1260 - assert_eq!(schema.header.syntax, Syntax::Sway);
1261 - assert!(schema.fields.is_empty());
1262 - }
1263 -
1264 - // Rows nothing will ever draw, reported rather than ignored: opening the
1265 - // file as text without a word would read as the schema not being found.
1266 - #[test]
1267 - fn fields_under_a_formless_syntax_are_an_error_not_a_silent_drop() {
1268 - let error = error(Schema::parse(
1269 - "[schema]\n\
1270 - target = \"config\"\n\
1271 - target_path = \"~/.config/sway/config\"\n\
1272 - target_tool = \"sway\"\n\
1273 - schema_version = \"1\"\n\
1274 - syntax = \"sway\"\n\
1275 - [[field]]\n\
1276 - path = \"gaps\"\n\
1277 - type = \"int\"\n",
1278 - ));
1279 - assert!(error.contains("never be rendered"), "{error}");
1280 - }
1281 -
1282 - // Without a path it names no file, and its only job is to open one.
1283 - #[test]
1284 - fn a_formless_schema_must_say_where_the_file_is() {
1285 - let error = error(Schema::parse(
1286 - "[schema]\n\
1287 - target = \"config\"\n\
1288 - target_tool = \"sway\"\n\
1289 - schema_version = \"1\"\n\
1290 - syntax = \"sway\"\n",
1291 - ));
1292 - assert!(error.contains("target_path"), "{error}");
1293 - }
1294 -
1295 - #[test]
1296 - fn an_unknown_syntax_names_the_ones_that_exist() {
1297 - let error = error(Schema::parse(
1298 - "[schema]\n\
1299 - target = \"t\"\n\
1300 - target_tool = \"t\"\n\
1301 - schema_version = \"1\"\n\
1302 - syntax = \"yaml\"\n",
1303 - ));
1304 - assert!(error.contains("yaml") && error.contains("toml"), "{error}");
1305 - }
1306 -
1307 - #[test]
1308 - fn lists_do_not_nest() {
1309 - let error = error(parse(
1310 - "[[field]]\n\
1311 - path = \"a\"\n\
1312 - type = \"list\"\n\
1313 - element = { type = \"table\", fields = [\n\
1314 - { path = \"b\", type = \"list\", element = { type = \"table\", fields = [] } },\n\
1315 - ] }\n",
1316 - ));
1317 - assert!(error.contains("nest"), "{error}");
1318 - }
1319 - }
935 + mod tests;
@@ -902,648 +902,4 @@
902 902 }
903 903
904 904 #[cfg(test)]
905 - mod tests {
906 - use super::*;
907 -
908 - // Verbatim from `timedatectl show` on a systemd 257 box.
909 - pub(crate) const SHOW: &str = "\
910 - Timezone=America/Los_Angeles
911 - LocalRTC=no
912 - CanNTP=yes
913 - NTP=yes
914 - NTPSynchronized=yes
915 - TimeUSec=Fri 2026-07-24 12:11:22 PDT
916 - RTCTimeUSec=Fri 2026-07-24 12:11:22 PDT
917 - ";
918 -
919 - // Trimmed from `hostnamectl --json=short`; the kernel and OS keys this does
920 - // not read are left off.
921 - pub(crate) const HOST: &str = r#"{"Hostname":"fw13","StaticHostname":"fw13",
922 - "PrettyHostname":null,"DefaultHostname":"localhost","HostnameSource":"static",
923 - "IconName":"computer-laptop","Chassis":"laptop","Location":null}"#;
924 -
925 - // Verbatim from `localectl status`, including the leading whitespace and
926 - // the `(unset)` this machine really reports for its console keymap.
927 - pub(crate) const LOCALE_STATUS: &str = "\
928 - System Locale: LANG=en_US.UTF-8
929 - VC Keymap: (unset)
930 - X11 Layout: us
931 - X11 Options: lv3:ralt_switch,compose:rctrl
932 - ";
933 -
934 - fn bind() -> SystemBind {
935 - SystemBind::fixture()
936 - }
937 -
938 - fn message(result: Result<impl Sized>) -> String {
939 - match result {
940 - Ok(_) => panic!("expected an error"),
941 - Err(error) => format!("{error:#}"),
942 - }
943 - }
944 -
945 - fn help(bind: &SystemBind, path: &str) -> String {
946 - bind.field(path)
947 - .unwrap()
948 - .description
949 - .clone()
950 - .unwrap_or_default()
951 - }
952 -
953 - #[test]
954 - fn the_rows_read_what_the_machine_reports() {
955 - let bind = bind();
956 - assert_eq!(
957 - bind.read(ZONE),
958 - Some(Value::String("America/Los_Angeles".into())),
959 - );
960 - assert_eq!(bind.read(NTP), Some(Value::Boolean(true)));
961 - assert_eq!(
962 - bind.read(CLOCK),
963 - Some(Value::String("Fri 2026-07-24 12:11:22 PDT".into())),
964 - "TimeUSec is already formatted for a person, despite the name",
965 - );
966 - assert_eq!(bind.read(HOSTNAME), Some(Value::String("fw13".into())));
967 - assert_eq!(bind.read(LOCALE), Some(Value::String("en_US.UTF-8".into())));
968 - assert_eq!(bind.read(KEYMAP), None, "the fixture reports (unset)");
969 - }
970 -
971 - // The pair the locale row got, applied to the row that reports a file
972 - // rather than a vocabulary. Both states are closed; only the note moves.
973 - #[test]
974 - fn the_secrets_row_is_quiet_when_the_identity_is_there() {
975 - let bind = bind();
976 - assert_eq!(
977 - bind.read(AGE),
978 - Some(Value::String("/home/tester/.config/gopass/age".into())),
979 - );
980 - assert_eq!(
981 - help(&bind, AGE),
982 - "Where gopass keeps the identity it decrypts the store with.",
983 - );
984 - assert!(bind.field(AGE).unwrap().readonly);
985 - }
986 -
987 - #[test]
988 - fn the_secrets_row_names_the_path_when_the_identity_is_missing() {
989 - let bind = SystemBind::fixture_with_age(Age {
990 - dir: Some(PathBuf::from("/home/tester/.config/gopass/age")),
991 - present: false,
992 - });
993 - let note = help(&bind, AGE);
994 - assert!(
995 - note.contains("/home/tester/.config/gopass/age"),
996 - "the note has to name the path a key goes at, got {note:?}",
997 - );
998 - assert!(note.contains("will not decrypt"), "and the consequence");
999 - assert!(
1000 - bind.field(AGE).unwrap().readonly,
1001 - "closed in both states: nothing here writes a key",
1002 - );
1003 - }
1004 -
1005 - // Placing an identity from a settings form is exactly what the provisioning
1006 - // decision ruled out, so the row must refuse rather than quietly no-op.
1007 - #[test]
1008 - fn the_secrets_row_takes_no_write() {
1009 - let mut bind = bind();
1010 - assert_eq!(
1011 - message(bind.commit(AGE, Value::String("AGE-SECRET-KEY-1".into()))),
1012 - "`secrets.age` is shown, not set",
1013 - );
1014 - }
1015 -
1016 - // gopass's own precedence, which is what makes the row right on a machine
1017 - // that has moved its store rather than confidently naming a path nothing
1018 - // reads.
1019 - // Four bytes of EFI attributes, then the one byte that is the answer. The
1020 - // short and missing files are the cases that must not read as "off": a
1021 - // machine that could not be asked has not said no.
1022 - #[test]
1023 - fn the_secure_boot_variable_parses_three_ways() {
1024 - assert_eq!(
1025 - parse_secure_boot(Some(&[0x06, 0x00, 0x00, 0x00, 0x01])),
1026 - SecureBoot::Enforcing,
1027 - );
1028 - assert_eq!(
1029 - parse_secure_boot(Some(&[0x06, 0x00, 0x00, 0x00, 0x00])),
1030 - SecureBoot::Off,
1031 - );
1032 - assert_eq!(
1033 - parse_secure_boot(Some(&[0x06, 0x00, 0x00, 0x00])),
1034 - SecureBoot::Unknown,
1035 - "attributes with no data is a read that went wrong, not a no",
1036 - );
1037 - assert_eq!(parse_secure_boot(Some(&[])), SecureBoot::Unknown);
1038 - assert_eq!(
1039 - parse_secure_boot(None),
1040 - SecureBoot::Unknown,
1041 - "no efivarfs, no legacy-BIOS machine reporting itself as off",
1042 - );
1043 - }
1044 -
1045 - /// The fixture with the firmware's answer chosen, for the same reason the
1046 - /// age state is passed in: a test whose result depends on whether the
1047 - /// machine running it happens to have Secure Boot on is not a test.
1048 - fn with_secure_boot(state: SecureBoot) -> SystemBind {
1049 - let mut bind = SystemBind::fixture();
1050 - bind.secure_boot = state;
1051 - bind.annotate();
1052 - bind
1053 - }
1054 -
1055 - #[test]
1056 - fn the_secure_boot_row_reports_the_firmware_and_never_offers_a_setter() {
1057 - for state in [SecureBoot::Enforcing, SecureBoot::Off, SecureBoot::Unknown] {
1058 - let mut bind = with_secure_boot(state);
1059 - assert_eq!(
1060 - bind.read(SECURE_BOOT),
1061 - Some(Value::String(state.label().into())),
1062 - );
1063 - assert!(
1064 - bind.field(SECURE_BOOT).unwrap().readonly,
1065 - "{state:?} is the firmware's, not this screen's",
1066 - );
1067 - assert_eq!(
1068 - message(bind.commit(SECURE_BOOT, Value::String("enforcing".into()))),
1069 - "`security.secure_boot` is shown, not set",
1070 - );
1071 - }
1072 - }
1073 -
1074 - // The row says what being off costs, not that it is off, and "unknown" says
1075 - // so in its own words rather than borrowing the ones for "off".
1076 - #[test]
1077 - fn the_secure_boot_row_names_the_consequence() {
1078 - assert!(
1079 - help(&with_secure_boot(SecureBoot::Off), SECURE_BOOT)
1080 - .contains("opens for anything that boots it"),
1081 - );
1082 - let unknown = help(&with_secure_boot(SecureBoot::Unknown), SECURE_BOOT);
1083 - assert!(unknown.contains("does not report"), "{unknown}");
1084 - assert!(unknown.contains("Not the same as off"), "{unknown}");
1085 - assert!(
1086 - help(&with_secure_boot(SecureBoot::Enforcing), SECURE_BOOT)
1087 - .contains("signs nothing itself"),
1088 - );
1089 - }
1090 -
1091 - #[test]
1092 - fn the_age_path_follows_gopass() {
1093 - let dir = |homedir, xdg, home| age_dir_from(homedir, xdg, home).unwrap();
1094 - assert_eq!(
1095 - dir(None, None, Some("/home/tester")),
1096 - PathBuf::from("/home/tester/.config/gopass/age"),
1097 - );
1098 - assert_eq!(
1099 - dir(None, Some("/elsewhere"), Some("/home/tester")),
1100 - PathBuf::from("/elsewhere/gopass/age"),
1101 - "XDG_CONFIG_HOME moves it, and is already the whole config root",
1102 - );
1103 - assert_eq!(
1104 - dir(Some("/sandbox"), Some("/elsewhere"), Some("/home/tester")),
1105 - PathBuf::from("/sandbox/.config/gopass/age"),
1106 - "GOPASS_HOMEDIR wins over both",
1107 - );
1108 - assert_eq!(
1109 - age_dir_from(None, None, None),
1110 - None,
1111 - "nothing to resolve against",
1112 - );
1113 - assert_eq!(
1114 - dir(Some(""), None, Some("/home/tester")),
1115 - PathBuf::from("/home/tester/.config/gopass/age"),
1116 - "an empty variable is unset, not a path of nothing",
1117 - );
1118 - }
1119 -
1120 - // A file gopass wrote before it had anything to put in it refuses exactly
1121 - // like no file at all, so the row must not call it present.
1122 - #[test]
1123 - fn an_empty_identities_file_is_not_an_identity() {
1124 - let dir = std::env::temp_dir().join("alloy-age-identity-test");
1125 - let _ = std::fs::remove_dir_all(&dir);
1126 - std::fs::create_dir_all(&dir).unwrap();
1127 - assert!(!identity_present(&dir), "no file at all");
1128 -
1129 - std::fs::write(dir.join("identities"), "").unwrap();
1130 - assert!(!identity_present(&dir), "written, empty, decrypts nothing");
1131 -
1132 - std::fs::write(dir.join("identities"), "AGE-SECRET-KEY-1EXAMPLE\n").unwrap();
1133 - assert!(identity_present(&dir));
1134 -
1135 - std::fs::remove_dir_all(&dir).unwrap();
1136 - }
1137 -
1138 - // The writing commands this bind is allowed, and no others. Each is in the
1139 - // polkit grant; if any argv changes, the grant has to change with it.
1140 - #[test]
1141 - fn every_setter_is_a_granted_action() {
1142 - let mut bind = bind();
1143 - let mut argv = |path: &str, value: Value| {
1144 - let effects = bind.commit(path, value).expect("commits");
1145 - let [Effect::Run(invocation)] = effects.as_slice() else {
1146 - panic!("one command, got {effects:?}")
1147 - };
1148 - invocation.display()
1149 - };
1150 -
1151 - assert_eq!(
1152 - argv(ZONE, Value::String("America/Denver".into())),
1153 - "timedatectl set-timezone America/Denver",
1154 - );
1155 - assert_eq!(
1156 - argv(NTP, Value::Boolean(false)),
1157 - "timedatectl set-ntp false"
1158 - );
1159 - assert_eq!(
1160 - argv(LOCALE, Value::String("en_GB.UTF-8".into())),
1161 - "localectl set-locale LANG=en_GB.UTF-8",
1162 - );
1163 - assert_eq!(
1164 - argv(KEYMAP, Value::String("uk".into())),
1165 - "localectl set-keymap uk",
1166 - );
1167 - }
1168 -
1169 - // `--static` is the whole reason the polkit grant can stay at five actions.
1170 - // Without it hostnamectl also sets the pretty name, which needs
1171 - // set-machine-info, and the row would prompt.
1172 - #[test]
1173 - fn the_hostname_setter_is_scoped_to_the_static_name() {
1174 - let mut bind = bind();
1175 - let effects = bind
1176 - .commit(HOSTNAME, Value::String("astra".into()))
1177 - .unwrap();
1178 - let [Effect::Run(invocation)] = effects.as_slice() else {
1179 - panic!("one command")
1180 - };
1181 - assert_eq!(invocation.display(), "hostnamectl --static hostname astra");
1182 - }
1183 -
1184 - // The installer's rule, called rather than restated: both screens write the
1185 - // same file, and a name one takes and the other refuses is a bug in
1186 - // whichever the user reaches second.
1187 - #[test]
1188 - fn a_hostname_is_held_to_the_installers_rule() {
1189 - let mut bind = bind();
1190 - for bad in ["", "has.dots", "-leading", "trailing-", "under_score"] {
1191 - assert!(
1192 - bind.commit(HOSTNAME, Value::String(bad.into())).is_err(),
1193 - "{bad} should be refused",
1194 - );
1195 - }
1196 - assert!(bind.commit(HOSTNAME, Value::String("fw13".into())).is_ok());
1197 - }
1198 -
1199 - #[test]
1200 - fn a_value_outside_the_machines_own_list_is_refused() {
1201 - let mut bind = bind();
1202 - let error = message(bind.commit(ZONE, Value::String("Mars/Olympus".into())));
1203 - assert!(error.contains("Mars/Olympus"), "{error}");
1204 -
1205 - let error = message(bind.commit(LOCALE, Value::String("kl_GL.UTF-8".into())));
1206 - assert!(error.contains("kl_GL"), "{error}");
1207 - }
1208 -
1209 - #[test]
1210 - fn the_clock_cannot_be_written() {
1211 - let mut bind = bind();
1212 - assert!(bind.field(CLOCK).unwrap().readonly);
1213 - let error = message(bind.commit(CLOCK, Value::String("Fri 2026-07-24".into())));
1214 - assert!(error.contains("shown, not set"), "{error}");
1215 - }
1216 -
1217 - #[test]
1218 - fn the_sync_state_is_reported_beside_the_switch() {
1219 - let mut bind = bind();
1220 - assert!(help(&bind, NTP).contains("agreed with a server"));
1221 -
1222 - bind.time = parse_show(&SHOW.replace("NTPSynchronized=yes", "NTPSynchronized=no"));
1223 - bind.annotate();
1224 - assert!(help(&bind, NTP).contains("not synchronized yet"));
1225 - }
1226 -
1227 - // One rule everywhere: a row whose front did not answer, or whose
1228 - // vocabulary is empty, is shown and not settable, and says why.
1229 - #[test]
1230 - fn a_row_whose_front_stayed_quiet_is_shown_and_closed() {
1231 - let mut bind = bind();
1232 -
1233 - bind.time = parse_show(&SHOW.replace("CanNTP=yes", "CanNTP=no"));
1234 - bind.host = Host::default();
1235 - bind.locale = Locale::default();
1236 - bind.annotate();
1237 -
1238 - for (path, expect) in [
1239 - (NTP, "No network time"),
1240 - (HOSTNAME, "hostnamectl did not answer"),
1241 - (LOCALE, "localectl did not answer"),
1242 - (KEYMAP, "localectl did not answer"),
1243 - ] {
1244 - assert!(bind.field(path).unwrap().readonly, "{path} is closed");
1245 - assert!(
1246 - help(&bind, path).contains(expect),
1247 - "{path}: {}",
1248 - help(&bind, path),
1249 - );
1250 - }
1251 - assert!(bind.commit(NTP, Value::Boolean(true)).is_err());
1252 - }
1253 -
1254 - // Observed for real: a systemd built without console keymap support lists
1255 - // none and has no `set-keymap` verb at all. An empty vocabulary closes the
1256 - // row rather than offering an overlay with nothing in it.
1257 - #[test]
1258 - fn a_machine_that_lists_no_keymaps_does_not_offer_the_row() {
1259 - let mut bind = SystemBind {
1260 - sections: sections(),
1261 - fields: rows(
1262 - Vec::new(),
1263 - parse_list("en_US.UTF-8\nen_GB.UTF-8\n"),
1264 - Vec::new(),
1265 - theme_choices(),
1266 - ),
1267 - time: parse_show(SHOW),
1268 - host: parse_host(HOST),
1269 - locale: parse_locale(LOCALE_STATUS),
1270 - age: Age::default(),
1271 - secure_boot: SecureBoot::Enforcing,
1272 - };
1273 - bind.annotate();
1274 -
1275 - assert!(bind.field(KEYMAP).unwrap().readonly);
1276 - assert!(help(&bind, KEYMAP).contains("no virtual console keymaps"));
1277 - assert!(
1278 - !bind.field(LOCALE).unwrap().readonly,
1279 - "locales are still listed"
1280 - );
1281 - }
1282 -
1283 - /// Build a bind whose locale vocabulary is exactly `locales`.
1284 - fn with_locales(locales: &str) -> SystemBind {
1285 - let mut bind = SystemBind {
1286 - sections: sections(),
1287 - fields: rows(
1288 - Vec::new(),
1289 - parse_list(locales),
1290 - parse_list("us\n"),
1291 - theme_choices(),
1292 - ),
1293 - time: parse_show(SHOW),
1294 - host: parse_host(HOST),
1295 - locale: parse_locale(LOCALE_STATUS),
1296 - age: Age::default(),
1297 - secure_boot: SecureBoot::Enforcing,
1298 - };
1299 - bind.annotate();
1300 - bind
1301 - }
1302 -
1303 - // The shipped image's real state: `glibc-minimal-langpack` and no
1304 - // langpacks, so `/usr/lib/locale` holds `C.utf8` alone and `localectl` has
1305 - // one locale to report. The row is shown, closed, and names the locale that
1306 - // is installed rather than reporting a bare absence.
1307 - #[test]
1308 - fn one_installed_locale_closes_the_row_and_says_which() {
1309 - let mut bind = with_locales("C.UTF-8\n");
1310 -
1311 - assert!(bind.field(LOCALE).unwrap().readonly);
1312 - assert!(
1313 - help(&bind, LOCALE).contains("Only C.UTF-8 is installed here"),
1314 - "{}",
1315 - help(&bind, LOCALE),
1316 - );
1317 - assert!(
1318 - bind.commit(LOCALE, Value::String("C.UTF-8".into()))
1319 - .is_err(),
1320 - "a closed row takes no write"
1321 - );
1322 - }
1323 -
1324 - // The half that matters for the next reader: the gate is the count, not a
1325 - // langpack probe, so installing a langpack reopens the row on its own and
1326 - // nothing here needs revisiting.
1327 - #[test]
1328 - fn a_second_langpack_reopens_the_locale_row() {
1329 - let bind = with_locales("C.UTF-8\nen_US.UTF-8\n");
1330 -
1331 - assert!(!bind.field(LOCALE).unwrap().readonly);
1332 - assert!(
1333 - help(&bind, LOCALE).contains("Applies to new sessions"),
1334 - "{}",
1335 - help(&bind, LOCALE),
1336 - );
1337 - }
1338 -
1339 - // A machine that has never had /etc/hostname still shows the name it
1340 - // answers to, and the help line says what setting it would do.
1341 - #[test]
1342 - fn a_transient_hostname_is_shown_with_what_setting_it_would_do() {
1343 - let mut bind = bind();
1344 - bind.host = parse_host(
1345 - &HOST
1346 - .replace("\"StaticHostname\":\"fw13\"", "\"StaticHostname\":null")
1347 - .replace(
1348 - "\"HostnameSource\":\"static\"",
1349 - "\"HostnameSource\":\"transient\"",
1350 - ),
1351 - );
1352 - bind.annotate();
1353 -
1354 - assert_eq!(
1355 - bind.read(HOSTNAME),
1356 - Some(Value::String("fw13".into())),
1357 - "the name it answers to, not an empty cell",
1358 - );
1359 - assert!(
1360 - help(&bind, HOSTNAME).contains("makes it stick"),
1361 - "{}",
1362 - help(&bind, HOSTNAME),
1363 - );
1364 - }
1365 -
1366 - // The convention's payoff on this screen: "follow the terminal" is a choice
1367 - // the row can hold, not an absence the console infers.
1368 - #[test]
1369 - fn the_theme_row_offers_following_the_terminal_first() {
1370 - let choices = theme_choices();
1371 - assert_eq!(choices[0].value, makeover::FOLLOW);
1372 - assert!(choices[0].label.contains("Follow"));
1373 - }
1374 -
1375 - // The row shows what was chosen, not what is rendered. Storing only the
1376 - // rendered id is what makes a console unable to tell a standing "follow"
1377 - // from a pin the next time the terminal flips.
1378 - #[test]
1379 - fn choosing_to_follow_is_written_as_a_selection_not_an_id() {
1380 - let mut bind = bind();
1381 - let effects = bind
1382 - .commit(THEME, Value::String(makeover::FOLLOW.into()))
1383 - .unwrap();
1384 - let [Effect::Config { key, value, .. }, ..] = effects.as_slice() else {
1385 - panic!("a theme is a config set first: {effects:?}")
1386 - };
1387 - assert_eq!(key, "theme");
1388 - assert_eq!(
1389 - value,
1390 - makeover::FOLLOW,
1391 - "follow is stored as itself, not resolved to an id",
1392 - );
1393 - }
1394 -
1395 - // A theme id round-trips as a pin.
1396 - #[test]
1397 - fn choosing_a_theme_sets_its_id() {
1398 - let mut bind = bind();
1399 - let effects = bind
1400 - .commit(THEME, Value::String("akari-night".into()))
1401 - .unwrap();
Lines truncated
@@ -1,0 +1,463 @@
1 + //! Tests for [`super`].
2 +
3 + use super::*;
4 +
5 + // Captured from `pactl -f json list sinks` on PipeWire 1.5.84, with the
6 + // enormous `properties` blob dropped (the parser ignores it) and a second
7 + // sink added to give the list more than one row. Everything the parser
8 + // reads is verbatim.
9 + const SINKS: &str = r#"[
10 + {"index":61,"state":"SUSPENDED","name":"alsa_output.pci-0000_c1_00.6.analog-stereo",
11 + "description":"Family 17h/19h HD Audio Controller Analog Stereo","mute":false,
12 + "volume":{"front-left":{"value":58980,"value_percent":"90%","db":"-2.75 dB"},
13 + "front-right":{"value":58980,"value_percent":"90%","db":"-2.75 dB"}},
14 + "monitor_source":"alsa_output.pci-0000_c1_00.6.analog-stereo.monitor"},
15 + {"index":70,"state":"RUNNING","name":"alsa_output.hdmi-stereo",
16 + "description":"HDMI Stereo","mute":true,
17 + "volume":{"front-left":{"value":65536,"value_percent":"100%","db":"0.00 dB"},
18 + "front-right":{"value":65536,"value_percent":"100%","db":"0.00 dB"}},
19 + "monitor_source":""}
20 + ]"#;
21 +
22 + // Captured from `pactl -f json list sources`. The middle entry is a
23 + // monitor, which is the case the filter exists for.
24 + const SOURCES: &str = r#"[
25 + {"index":60,"state":"SUSPENDED","name":"alsa_input.acp-pdm-mach.stereo-fallback",
26 + "description":"ACP/ACP3X/ACP6x Audio Coprocessor Stereo","mute":false,
27 + "volume":{"front-left":{"value":65536,"value_percent":"100%","db":"0.00 dB"},
28 + "front-right":{"value":65536,"value_percent":"100%","db":"0.00 dB"}},
29 + "monitor_source":""},
30 + {"index":61,"state":"SUSPENDED","name":"alsa_output.pci-0000_c1_00.6.analog-stereo.monitor",
31 + "description":"Monitor of Family 17h/19h HD Audio Controller Analog Stereo","mute":false,
32 + "volume":{"front-left":{"value":65536,"value_percent":"100%","db":"0.00 dB"}},
33 + "monitor_source":"alsa_output.pci-0000_c1_00.6.analog-stereo"},
34 + {"index":62,"state":"SUSPENDED","name":"alsa_input.pci-0000_c1_00.6.analog-stereo",
35 + "description":"Family 17h/19h HD Audio Controller Analog Stereo","mute":false,
36 + "volume":{"front-left":{"value":32768,"value_percent":"50%","db":"-18.06 dB"}},
37 + "monitor_source":""}
38 + ]"#;
39 +
40 + // Captured from `pactl -f json list sink-inputs`, properties trimmed to
41 + // the keys the parser reads, plus a second entry with no
42 + // `application.name` to exercise the name fallback.
43 + const SINK_INPUTS: &str = r#"[
44 + {"index":342,"sink":61,"corked":false,"mute":false,
45 + "volume":{"front-left":{"value":65536,"value_percent":"100%","db":"0.00 dB"}},
46 + "properties":{"application.name":"speech-dispatcher-dummy",
47 + "application.process.binary":"sd_dummy","media.name":"playback"}},
48 + {"index":343,"sink":70,"corked":true,"mute":true,
49 + "volume":{"front-left":{"value":32768,"value_percent":"50%","db":"-18.06 dB"}},
50 + "properties":{"application.process.binary":"mpv","media.name":"Some Song"}}
51 + ]"#;
52 +
53 + #[test]
54 + fn parses_sinks_with_volume_and_mute() {
55 + let devices = parse_devices(SINKS, Direction::Output, "alsa_output.hdmi-stereo").unwrap();
56 + assert_eq!(devices.len(), 2);
57 + assert_eq!(devices[0].index, 61);
58 + assert_eq!(devices[0].volume, 90);
59 + assert!(!devices[0].muted);
60 + assert!(!devices[0].is_default);
61 + assert!(devices[1].muted);
62 + assert!(devices[1].is_default, "the default sink is matched by name");
63 + }
64 +
65 + // A sink's `monitor_source` names the monitor it *has*; a source's names
66 + // the sink it *is a monitor of*. Filtering sinks on that field would hide
67 + // every real output, which is the bug this test pins down.
68 + #[test]
69 + fn the_monitor_filter_does_not_swallow_sinks() {
70 + let devices = parse_devices(SINKS, Direction::Output, "").unwrap();
71 + assert_eq!(
72 + devices.len(),
73 + 2,
74 + "both sinks survive despite a monitor_source"
75 + );
76 + }
77 +
78 + #[test]
79 + fn drops_monitor_sources() {
80 + let devices = parse_devices(SOURCES, Direction::Input, "").unwrap();
81 + assert_eq!(devices.len(), 2, "the monitor source is filtered out");
82 + assert!(
83 + devices
84 + .iter()
85 + .all(|d| !d.description.starts_with("Monitor of")),
86 + "no monitor survived the filter"
87 + );
88 + assert_eq!(devices[1].volume, 50);
89 + }
90 +
91 + #[test]
92 + fn parses_streams_with_their_device_pairing() {
93 + let streams = parse_streams(SINK_INPUTS, Direction::Output).unwrap();
94 + assert_eq!(streams.len(), 2);
95 + assert_eq!(streams[0].index, 342);
96 + assert_eq!(streams[0].app, "speech-dispatcher-dummy");
97 + assert_eq!(
98 + streams[0].device_index, 61,
99 + "the pairing the connector draws"
100 + );
101 + assert!(!streams[0].corked);
102 + assert_eq!(streams[1].volume, 50);
103 + assert!(streams[1].corked);
104 + assert!(streams[1].muted);
105 + }
106 +
107 + // Not every stream sets `application.name`; falling through to the binary
108 + // beats showing "unknown", and beats `media.name`, which names the audio
109 + // rather than the app.
110 + #[test]
111 + fn stream_name_falls_back_to_the_binary() {
112 + let streams = parse_streams(SINK_INPUTS, Direction::Output).unwrap();
113 + assert_eq!(streams[1].app, "mpv");
114 + }
115 +
116 + // A stream mid-setup has no device. It cannot be paired or routed, so it
117 + // must not occupy a row that invites a keypress that cannot work.
118 + #[test]
119 + fn streams_with_no_device_are_dropped() {
120 + let raw = r#"[{"index":9,"corked":false,"mute":false,"volume":{},"properties":{}}]"#;
121 + assert!(parse_streams(raw, Direction::Output).unwrap().is_empty());
122 + }
123 +
124 + // Capture streams carry `source`, not `sink`. Reading the wrong field
125 + // would drop every capture stream as unpaired.
126 + #[test]
127 + fn capture_streams_pair_via_the_source_field() {
128 + let raw = r#"[{"index":5,"source":62,"corked":false,"mute":false,"volume":{},
129 + "properties":{"application.name":"Recorder"}}]"#;
130 + let streams = parse_streams(raw, Direction::Input).unwrap();
131 + assert_eq!(streams.len(), 1);
132 + assert_eq!(streams[0].device_index, 62);
133 + }
134 +
135 + #[test]
136 + fn empty_device_list_parses_to_nothing() {
137 + assert!(
138 + parse_devices("[]", Direction::Output, "")
139 + .unwrap()
140 + .is_empty()
141 + );
142 + }
143 +
144 + #[test]
145 + fn malformed_json_is_an_error_not_an_empty_list() {
146 + assert!(parse_devices("not json", Direction::Output, "").is_err());
147 + assert!(parse_streams("not json", Direction::Output).is_err());
148 + }
149 +
150 + // Unity is 65536, not 100. Treating the raw value as a percent would show
151 + // a normal device at "65536%".
152 + #[test]
153 + fn volume_is_scaled_from_unity() {
154 + let full = HashMap::from([(
155 + "mono".to_string(),
156 + PaChannel {
157 + value: VOLUME_UNITY,
158 + },
159 + )]);
160 + assert_eq!(channel_volume(&full), 100);
161 +
162 + let half = HashMap::from([(
163 + "mono".to_string(),
164 + PaChannel {
165 + value: VOLUME_UNITY / 2,
166 + },
167 + )]);
168 + assert_eq!(channel_volume(&half), 50);
169 +
170 + assert_eq!(
171 + channel_volume(&HashMap::new()),
172 + 0,
173 + "no channels reads as silent"
174 + );
175 + }
176 +
177 + #[test]
178 + fn volume_above_unity_clamps_to_100() {
179 + let boosted = HashMap::from([(
180 + "mono".to_string(),
181 + PaChannel {
182 + value: VOLUME_UNITY * 2,
183 + },
184 + )]);
185 + assert_eq!(channel_volume(&boosted), 100);
186 + }
187 +
188 + // The loudest channel, not the average: one silent channel of a stereo
189 + // pair must not read as 50%.
190 + #[test]
191 + fn volume_reports_the_loudest_channel() {
192 + let lopsided = HashMap::from([
193 + (
194 + "front-left".to_string(),
195 + PaChannel {
196 + value: VOLUME_UNITY,
197 + },
198 + ),
199 + ("front-right".to_string(), PaChannel { value: 0 }),
200 + ]);
201 + assert_eq!(channel_volume(&lopsided), 100);
202 + }
203 +
204 + #[test]
205 + fn truncate_marks_clipped_descriptions() {
206 + assert_eq!(truncate("short", 10), "short");
207 + assert_eq!(truncate("a very long device name", 10), "a very lo…");
208 + }
209 +
210 + // Slicing a multi-byte description by byte index panics. Device names do
211 + // carry non-ASCII.
212 + #[test]
213 + fn truncate_handles_multibyte_descriptions() {
214 + assert_eq!(truncate("Björn's Headset Pro", 8), "Björn's…");
215 + assert_eq!(truncate("Björn", 10), "Björn");
216 + }
217 +
218 + // ---- view behavior ----
219 +
220 + fn mock_view() -> (AudioView, CommandLog) {
221 + let mut log = CommandLog::new();
222 + let mut view = AudioView {
223 + backend: Box::new(Mock),
224 + devices: Vec::new(),
225 + streams: Vec::new(),
226 + focus: FocusRing::new(2),
227 + stream_cursor: Cursor::new(),
228 + device_cursor: Cursor::new(),
229 + error: None,
230 + ticks: 0,
231 + };
232 + view.refresh_devices(&mut log);
233 + view.refresh_streams(&mut log);
234 + (view, log)
235 + }
236 +
237 + // The pairing the connector draws: stream 0 routes to device index 1,
238 + // which is position 0 in the device list. Matching on the list position
239 + // instead of the device index would be right only by coincidence here.
240 + #[test]
241 + fn pairing_resolves_a_device_index_to_a_list_position() {
242 + let (view, _log) = mock_view();
243 + assert_eq!(view.paired_device_index(), Some(0));
244 + }
245 +
246 + #[test]
247 + fn pairing_follows_the_selected_stream() {
248 + let (mut view, _log) = mock_view();
249 + view.stream_cursor.move_by(1);
250 + // Stream 1 routes to device index 2, which is position 1.
251 + assert_eq!(view.paired_device_index(), Some(1));
252 + }
253 +
254 + // A stream routed to a device that is not in the list (filtered, or gone
255 + // between the two reads) has no drawable pairing.
256 + #[test]
257 + fn pairing_is_absent_when_the_device_is_missing() {
258 + let (mut view, _log) = mock_view();
259 + view.devices.retain(|d| d.index != 1);
260 + assert_eq!(view.paired_device_index(), None);
261 + }
262 +
263 + #[test]
264 + fn tab_moves_focus_between_the_panes() {
265 + let (mut view, _log) = mock_view();
266 + assert!(view.focus.is_focused(PANE_STREAMS));
267 + view.focus.next();
268 + assert!(view.focus.is_focused(PANE_DEVICES));
269 + view.focus.next();
270 + assert!(view.focus.is_focused(PANE_STREAMS), "two panes wrap");
271 + }
272 +
273 + // The action keys follow focus, so `m` mutes the app when the stream pane
274 + // is focused and the device when it is not.
275 + #[test]
276 + fn the_action_target_follows_focus() {
277 + let (mut view, _log) = mock_view();
278 + assert!(matches!(view.target(), Some(Target::Stream(_))));
279 + view.focus.focus(PANE_DEVICES);
280 + assert!(matches!(view.target(), Some(Target::Device(_))));
281 + }
282 +
283 + // pactl builds its subcommands from these nouns, and the stream case is
284 + // counterintuitive: a stream playing *out* is a `sink-input`.
285 + #[test]
286 + fn targets_use_the_right_pactl_nouns() {
287 + let (mut view, _log) = mock_view();
288 + assert_eq!(view.target().unwrap().noun(), "sink-input");
289 + assert_eq!(view.target().unwrap().id(), "100", "streams go by index");
290 +
291 + view.focus.focus(PANE_DEVICES);
292 + assert_eq!(view.target().unwrap().noun(), "sink");
293 + assert_eq!(
294 + view.target().unwrap().id(),
295 + "alsa_output.analog-stereo",
296 + "devices go by name"
297 + );
298 + }
299 +
300 + #[test]
301 + fn capture_targets_use_the_source_nouns() {
302 + let (mut view, _log) = mock_view();
303 + view.focus.focus(PANE_DEVICES);
304 + // The third mock device is the microphone.
305 + view.device_cursor.move_by(2);
306 + assert_eq!(view.target().unwrap().noun(), "source");
307 + }
308 +
309 + // Routing a playback stream to a microphone is not a thing. pactl would
310 + // refuse, but refusing here says why.
311 + #[test]
312 + fn routing_across_directions_is_refused() {
313 + let (mut view, mut log) = mock_view();
314 + view.device_cursor.move_by(2); // the input device
315 + view.route(&mut log);
316 + let error = view
317 + .error
318 + .expect("a cross-direction route reports an error");
319 + assert!(error.contains("cannot route"), "got: {error}");
320 + }
321 +
322 + #[test]
323 + fn routing_within_a_direction_is_allowed() {
324 + let (mut view, mut log) = mock_view();
325 + view.device_cursor.move_by(1); // the HDMI output
326 + view.route(&mut log);
327 + assert!(view.error.is_none(), "same-direction routing is accepted");
328 + }
329 +
330 + // Background polling is console bookkeeping. If it logged, the pane would
331 + // fill with commands nobody pressed a key for.
332 + #[test]
333 + fn ticks_do_not_write_to_the_command_log() {
334 + let (mut view, mut log) = mock_view();
335 + let before = log.entries().len();
336 + for _ in 0..DEVICE_POLL_TICKS * 2 {
337 + view.tick(&mut log);
338 + }
339 + assert_eq!(log.entries().len(), before, "ticks are silent");
340 + }
341 +
342 + // Devices are polled on a slower cadence than streams, so the tick counter
343 + // has to actually reach the device poll.
344 + #[test]
345 + fn devices_are_polled_on_the_slower_cadence() {
346 + let (mut view, mut log) = mock_view();
347 + view.devices.clear();
348 + for _ in 0..DEVICE_POLL_TICKS - 1 {
349 + view.tick(&mut log);
350 + }
351 + assert!(view.devices.is_empty(), "not yet re-read");
352 + view.tick(&mut log);
353 + assert!(!view.devices.is_empty(), "re-read on the tenth tick");
354 + }
355 +
356 + // The bug this pins: refreshes run on the background tick, so if a
357 + // successful refresh cleared `error`, a rejected route would be readable
358 + // for under a second before a poll wiped it.
359 + #[test]
360 + fn a_background_tick_does_not_clear_an_action_error() {
361 + let (mut view, mut log) = mock_view();
362 + view.device_cursor.move_by(2); // the input device
363 + view.route(&mut log);
364 + assert!(view.error.is_some(), "the route was refused");
365 +
366 + for _ in 0..=DEVICE_POLL_TICKS {
367 + view.tick(&mut log);
368 + }
369 + assert!(
370 + view.error.is_some(),
371 + "the error survived a full device-poll cycle"
372 + );
373 + }
374 +
375 + #[test]
376 + fn a_keypress_clears_a_stale_error() {
377 + use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
378 +
379 + let (mut view, mut log) = mock_view();
380 + view.device_cursor.move_by(2);
381 + view.route(&mut log);
382 + assert!(view.error.is_some());
383 +
384 + view.handle(
385 + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
386 + &mut log,
387 + );
388 + assert!(view.error.is_none(), "moving on dismisses the error");
389 + }
390 +
391 + #[test]
392 + fn acting_with_no_selection_is_inert() {
393 + let mut log = CommandLog::new();
394 + let mut view = AudioView {
395 + backend: Box::new(Mock),
396 + devices: Vec::new(),
397 + streams: Vec::new(),
398 + focus: FocusRing::new(2),
399 + stream_cursor: Cursor::new(),
400 + device_cursor: Cursor::new(),
401 + error: None,
402 + ticks: 0,
403 + };
404 + view.set_volume(&mut log, 5);
405 + view.toggle_mute(&mut log);
406 + view.set_default(&mut log);
407 + view.route(&mut log);
408 + assert!(view.error.is_none(), "no selection is not an error");
409 + }
410 +
411 + /// Parse whatever this machine's PipeWire actually reports.
412 + ///
413 + /// Ignored by default because it needs a running PipeWire and its result
414 + /// depends on the hardware. Run it (`cargo test -p alloy -- --ignored
415 + /// --nocapture`) when touching the parser: a fixture only proves the
416 + /// parser handles the output someone imagined it would get, which is
417 + /// exactly how the sink/source `monitor_source` inversion got written.
418 + #[test]
419 + #[ignore = "requires a running PipeWire"]
420 + fn parses_this_machines_real_state() {
421 + let mut log = CommandLog::new();
422 + let devices = PaCtl.list_devices(&mut log).expect("pactl should answer");
423 + let streams = PaCtl.list_streams(&mut log).expect("pactl should answer");
424 +
425 + assert!(
426 + !devices.is_empty(),
427 + "a machine with PipeWire has some device"
428 + );
429 + assert!(
430 + devices.iter().any(|d| d.direction == Direction::Output),
431 + "at least one output must survive the monitor filter"
432 + );
433 + for device in &devices {
434 + assert!(!device.description.is_empty(), "every row is identifiable");
435 + assert!(device.volume <= 100, "volume is a clamped percentage");
436 + assert!(
437 + !device.description.starts_with("Monitor of"),
438 + "monitor leaked into the list: {}",
439 + device.description
440 + );
441 + println!(
442 + "device {:<8} {:<48} {:>3}% mute={} default={}",
443 + device.direction.label(),
444 + device.description,
445 + device.volume,
446 + device.muted,
447 + device.is_default
448 + );
449 + }
450 + for stream in &streams {
451 + // Every listed stream must resolve to a listed device, or the
452 + // connector has nothing to draw to.
453 + let paired = devices.iter().find(|d| d.index == stream.device_index);
454 + println!(
455 + "stream {:<8} {:<20} -> {:<40} {:>3}%",
456 + stream.direction.label(),
457 + stream.app,
458 + paired.map_or("(unlisted)", |d| d.description.as_str()),
459 + stream.volume
460 + );
461 + assert!(!stream.app.is_empty(), "every stream row is identifiable");
462 + }
463 + }
@@ -1,0 +1,1032 @@
1 + //! Tests for [`super`].
2 +
3 + use super::*;
4 +
5 + // Captured verbatim from `swaymsg -t get_outputs` on the FW12 Alloy install,
6 + // 2026-07-29, sway 1.11, with no external display attached. Complete and
7 + // untrimmed: this is the whole 1499-byte payload, including the fields the
8 + // parser ignores, because the fields it ignores are where the next surprise
9 + // lives. `serial` really is the string "Unknown", `refresh` really is
10 + // millihertz, and `rect` really disagrees with `current_mode`.
11 + //
12 + // Still missing, and the reason the multi-output path has tests but no
13 + // evidence: nobody has attached a second display to an Alloy machine.
14 + const FW12: &str = r#"
15 + [
16 + {
17 + "id": 3,
18 + "type": "output",
19 + "orientation": "none",
20 + "percent": 1.0,
21 + "urgent": false,
22 + "marks": [],
23 + "layout": "output",
24 + "border": "none",
25 + "current_border_width": 0,
26 + "rect": {
27 + "x": 0,
28 + "y": 0,
29 + "width": 1536,
30 + "height": 960
31 + },
32 + "deco_rect": {
33 + "x": 0,
34 + "y": 0,
35 + "width": 0,
36 + "height": 0
37 + },
38 + "window_rect": {
39 + "x": 0,
40 + "y": 0,
41 + "width": 0,
42 + "height": 0
43 + },
44 + "geometry": {
45 + "x": 0,
46 + "y": 0,
47 + "width": 0,
48 + "height": 0
49 + },
50 + "name": "eDP-1",
51 + "window": null,
52 + "nodes": [],
53 + "floating_nodes": [],
54 + "focus": [
55 + 4
56 + ],
57 + "fullscreen_mode": 0,
58 + "sticky": false,
59 + "floating": null,
60 + "scratchpad_state": null,
61 + "primary": false,
62 + "make": "BOE",
63 + "model": "NV122WUM-N42",
64 + "serial": "Unknown",
65 + "modes": [
66 + {
67 + "width": 1920,
68 + "height": 1200,
69 + "refresh": 60002,
70 + "picture_aspect_ratio": "none"
71 + }
72 + ],
73 + "non_desktop": false,
74 + "active": true,
75 + "dpms": true,
76 + "power": true,
77 + "scale": 1.25,
78 + "scale_filter": "linear",
79 + "transform": "normal",
80 + "adaptive_sync_status": "disabled",
81 + "current_workspace": "1",
82 + "current_mode": {
83 + "width": 1920,
84 + "height": 1200,
85 + "refresh": 60002,
86 + "picture_aspect_ratio": "none"
87 + },
88 + "max_render_time": 0,
89 + "allow_tearing": false,
90 + "focused": true,
91 + "subpixel_hinting": "unknown"
92 + }
93 + ]
94 + "#;
95 +
96 + fn fw12() -> Output {
97 + parse(FW12).expect("the real capture parses").remove(0)
98 + }
99 +
100 + #[test]
101 + fn parses_the_real_capture() {
102 + let outputs = parse(FW12).unwrap();
103 + assert_eq!(outputs.len(), 1);
104 + let panel = &outputs[0];
105 + assert_eq!(panel.name, "eDP-1");
106 + assert_eq!(panel.make, "BOE");
107 + assert_eq!(panel.model, "NV122WUM-N42");
108 + assert!(panel.active && panel.dpms && panel.focused);
109 + assert_eq!(panel.transform, "normal");
110 + }
111 +
112 + // sway substitutes the literal string "Unknown" for a field the panel does
113 + // not report. A parser expecting null or an absent key mis-handles this
114 + // panel, and the identifier rule below depends on noticing it.
115 + #[test]
116 + fn an_unknown_serial_is_a_string_not_a_null() {
117 + assert_eq!(fw12().serial, "Unknown");
118 + }
119 +
120 + // Millihertz. 60002, not 60 and not 60.0.
121 + #[test]
122 + fn refresh_is_millihertz() {
123 + let mode = fw12().current_mode.expect("the panel has a current mode");
124 + assert_eq!(mode.refresh, 60002);
125 + assert_eq!(mode.spelled(), "1920x1200@60.002Hz");
126 + }
127 +
128 + // `rect` is the logical size and `current_mode` the physical one, and at
129 + // scale 1.25 they disagree by design. A view that showed one of them would
130 + // make the scale look inert.
131 + #[test]
132 + fn the_logical_and_physical_sizes_both_survive() {
133 + let panel = fw12();
134 + assert_eq!((panel.rect.width, panel.rect.height), (1536, 960));
135 + let mode = panel.current_mode.unwrap();
136 + assert_eq!((mode.width, mode.height), (1920, 1200));
137 + assert!((panel.scale - 1.25).abs() < f64::EPSILON);
138 + }
139 +
140 + // The panel advertises exactly one mode, which is why there is no mode
141 + // picker. Pinned so that the day a capture with more than one arrives, the
142 + // reason for the omission is visible in a diff.
143 + #[test]
144 + fn the_panel_advertises_exactly_one_mode() {
145 + assert_eq!(fw12().modes.len(), 1);
146 + }
147 +
148 + #[test]
149 + fn malformed_json_is_an_error() {
150 + assert!(parse("not json").is_err());
151 + assert!(parse("{}").is_err(), "an object is not a list of outputs");
152 + }
153 +
154 + // sway sends the fields this parser does not read, and it will send more
155 + // next release. Ignoring them is the point of the derive.
156 + #[test]
157 + fn unknown_fields_are_ignored() {
158 + let raw = r#"[{"name":"HDMI-A-1","something_new":{"nested":true}}]"#;
159 + assert_eq!(parse(raw).unwrap()[0].name, "HDMI-A-1");
160 + }
161 +
162 + // A field sway stops sending must not read as "asleep" or "scale 0".
163 + #[test]
164 + fn absent_fields_take_the_safe_default() {
165 + let output = parse(r#"[{"name":"DP-1"}]"#).unwrap().remove(0);
166 + assert!(output.dpms, "an output sway does not describe as asleep");
167 + assert!((output.scale - 1.0).abs() < f64::EPSILON);
168 + assert!(output.modes.is_empty());
169 + assert_eq!(output.current_mode, None);
170 + }
171 +
172 + fn external(name: &str, make: &str, model: &str, serial: &str) -> Output {
173 + Output {
174 + name: name.into(),
175 + make: make.into(),
176 + model: model.into(),
177 + serial: serial.into(),
178 + active: true,
179 + dpms: true,
180 + focused: false,
181 + rect: Rectangle {
182 + x: 0,
183 + y: 0,
184 + width: 2560,
185 + height: 1440,
186 + },
187 + scale: 1.0,
188 + transform: "normal".into(),
189 + current_mode: Some(Mode {
190 + width: 2560,
191 + height: 1440,
192 + refresh: 59951,
193 + }),
194 + modes: Vec::new(),
195 + }
196 + }
197 +
198 + // The identifier rule since 2026-08-06: the connector, always, for every
199 + // output. The triple it replaced was vendor text in a file sway parses and
200 + // was not unique across identical serial-less monitors; `monitors.rs` holds
201 + // the reasoning and the replacement.
202 + #[test]
203 + fn every_output_is_matched_by_its_connector() {
204 + let monitor = external("DP-3", "Example Co", "PA279CV", "K8LMQS032990");
205 + assert_eq!(monitor.identifier(), "DP-3");
206 + assert_eq!(fw12().identifier(), "eDP-1");
207 + }
208 +
209 + #[test]
210 + fn every_laptop_panel_connector_reads_as_built_in() {
211 + for name in ["eDP-1", "eDP-2", "LVDS-1", "DSI-1"] {
212 + let mut panel = external(name, "BOE", "NV122WUM-N42", UNKNOWN);
213 + panel.name = name.into();
214 + assert!(panel.built_in(), "{name}");
215 + assert_eq!(panel.identifier(), name);
216 + }
217 + assert!(!external("DP-3", "Example Co", "PA279CV", "S1").built_in());
218 + }
219 +
220 + /// The injection hazard, closed structurally rather than escaped: none of
221 + /// these can reach a stanza by any route, because no EDID text is written
222 + /// at all.
223 + #[test]
224 + fn no_edid_text_reaches_a_stanza_however_hostile() {
225 + for make in [
226 + "Ex\"Co",
227 + "Ex\\Co",
228 + "Ex\noutput * scale 3",
229 + "Ex\rCo",
230 + "Ex\tCo",
231 + ] {
232 + let output = external("DP-1", make, "PA279CV", "S1");
233 + assert_eq!(output.identifier(), "DP-1", "{make:?}");
234 + let line = Directive::new(&output, "scale", "2").line();
235 + assert_eq!(line, "output DP-1 scale 2", "{make:?}");
236 + }
237 + }
238 +
239 + /// The portability a triple would buy is not lost, it moved: two monitors
240 + /// that differ only in punctuation are still two identities, and the
241 + /// identity is a hash rather than something a config file has to hold.
242 + #[test]
243 + fn punctuation_still_distinguishes_two_monitors() {
244 + let one = external("DP-1", "Example Co.", "PA279CV", "S1");
245 + let two = external("DP-1", "Example Co", "PA279CV", "S1");
246 + assert_ne!(one.fingerprint(), two.fingerprint());
247 + assert!(one.fingerprint().is_some());
248 + }
249 +
250 + /// An output with nothing to identify it is remembered by nothing. There is
251 + /// no fact about it that would survive a replug, so a table row would be a
252 + /// row that cannot be right.
253 + #[test]
254 + fn an_anonymous_output_has_no_fingerprint() {
255 + assert_eq!(
256 + external("HDMI-A-1", UNKNOWN, UNKNOWN, UNKNOWN).fingerprint(),
257 + None
258 + );
259 + assert_eq!(external("HDMI-A-1", "", "", "").fingerprint(), None);
260 + // Serial alone is not identity: it is the field most often Unknown, and
261 + // what is left is the two fields that were missing.
262 + assert_eq!(
263 + external("HDMI-A-1", UNKNOWN, UNKNOWN, "S1").fingerprint(),
264 + None
265 + );
266 + }
267 +
268 + /// The built-in panel cannot move, so it is not in the table.
269 + #[test]
270 + fn the_built_in_panel_has_no_fingerprint() {
271 + assert_eq!(fw12().fingerprint(), None);
272 + }
273 +
274 + /// The tripwire that replaced the quoting function. Connector names pass;
275 + /// anything carrying the old hazards does not, which is what would fire if
276 + /// vendor text ever found its way back into a stanza.
277 + #[test]
278 + fn a_stanza_identifier_is_one_word() {
279 + assert!(is_one_word("eDP-1"));
280 + assert!(is_one_word("HDMI-A-1"));
281 + assert!(!is_one_word("BOE NV122WUM-N42 Unknown"));
282 + assert!(!is_one_word("Ex\"Co"));
283 + assert!(!is_one_word("Ex\\Co"));
284 + assert!(!is_one_word("Ex\nCo"));
285 + assert!(!is_one_word(""));
286 + }
287 +
288 + // ---- reconcile ----
289 +
290 + fn remembered(connector: &str, scale: f64) -> monitors::Remembered {
291 + monitors::Remembered {
292 + connector: connector.to_string(),
293 + scale,
294 + transform: String::new(),
295 + enabled: true,
296 + last_seen: 1_700_000_000,
297 + description: "Example Co PA279CV".to_string(),
298 + }
299 + }
300 +
301 + /// The move that the whole design exists to make: a monitor known at DP-1
302 + /// turns up on DP-2, and its settings come with it.
303 + #[test]
304 + fn a_monitor_on_a_new_port_brings_its_settings() {
305 + let monitor = external("DP-2", "Example Co", "PA279CV", "S1");
306 + let mut registry = monitors::Registry::default();
307 + registry.remember(
308 + monitor.fingerprint().expect("identifiable"),
309 + remembered("DP-1", 1.5),
310 + );
311 +
312 + let moved = moves(std::slice::from_ref(&monitor), &registry);
313 + assert_eq!(moved.len(), 1);
314 + assert_eq!(moved[0].from, "DP-1");
315 + assert_eq!(moved[0].to, "DP-2");
316 +
317 + let lines: Vec<String> = moved[0].directives().iter().map(Directive::line).collect();
318 + assert_eq!(lines, vec!["output DP-2 scale 1.5"]);
319 + }
320 +
321 + /// A monitor that has not moved is not a move, and neither is one nobody
322 + /// has seen before. Both would be work with nothing to fix.
323 + #[test]
324 + fn nothing_moves_when_nothing_moved() {
325 + let monitor = external("DP-1", "Example Co", "PA279CV", "S1");
326 + let mut registry = monitors::Registry::default();
327 + registry.remember(
328 + monitor.fingerprint().expect("identifiable"),
329 + remembered("DP-1", 1.5),
330 + );
331 + assert!(moves(std::slice::from_ref(&monitor), &registry).is_empty());
332 +
333 + assert!(
334 + moves(
335 + std::slice::from_ref(&monitor),
336 + &monitors::Registry::default()
337 + )
338 + .is_empty(),
339 + "an unknown monitor is left alone rather than guessed at"
340 + );
341 + }
342 +
343 + /// An anonymous output cannot be moved, because it cannot be recognised.
344 + /// It is skipped rather than matched against something.
345 + #[test]
346 + fn an_anonymous_output_is_never_moved() {
347 + let anonymous = external("DP-2", UNKNOWN, UNKNOWN, UNKNOWN);
348 + let mut registry = monitors::Registry::default();
349 + registry.remember("whatever".to_string(), remembered("DP-1", 2.0));
350 + assert!(moves(std::slice::from_ref(&anonymous), &registry).is_empty());
351 + }
352 +
353 + /// Recording is what makes the next run able to notice a move, and it is
354 + /// also where expiry happens: one pass, so a table cannot be written
355 + /// without being pruned.
356 + #[test]
357 + fn recording_stores_the_settings_and_prunes_the_table() {
358 + let mut monitor = external("DP-2", "Example Co", "PA279CV", "S1");
359 + monitor.scale = 1.75;
360 + let mut registry = monitors::Registry::default();
361 + registry.remember("ancient".to_string(), remembered("DP-9", 1.0));
362 +
363 + let now = 1_700_000_000 + 400 * 24 * 60 * 60;
364 + remember(std::slice::from_ref(&monitor), &mut registry, now);
365 +
366 + let entry = registry
367 + .get(&monitor.fingerprint().expect("identifiable"))
368 + .expect("the attached monitor is recorded");
369 + assert_eq!(entry.connector, "DP-2");
370 + assert!((entry.scale - 1.75).abs() < f64::EPSILON);
371 + assert_eq!(entry.last_seen, now);
372 + assert!(
373 + registry.get("ancient").is_none(),
374 + "a row older than the forget window should have gone"
375 + );
376 + }
377 +
378 + /// A disabled monitor stays disabled when it moves. The `enable false` the
379 + /// user wrote is a setting like any other, and losing it on a replug would
380 + /// turn a screen back on that was deliberately off.
381 + #[test]
382 + fn a_disabled_monitor_stays_disabled_across_a_move() {
383 + let monitor = external("DP-2", "Example Co", "PA279CV", "S1");
384 + let mut entry = remembered("DP-1", 1.0);
385 + entry.enabled = false;
386 + entry.transform = "90".to_string();
387 + let mut registry = monitors::Registry::default();
388 + registry.remember(monitor.fingerprint().expect("identifiable"), entry);
389 +
390 + let moved = moves(std::slice::from_ref(&monitor), &registry);
391 + let lines: Vec<String> = moved[0].directives().iter().map(Directive::line).collect();
392 + assert_eq!(
393 + lines,
394 + vec![
395 + "output DP-2 scale 1",
396 + "output DP-2 transform 90",
397 + "output DP-2 enable false",
398 + ]
399 + );
400 + }
401 +
402 + // The load-bearing property of the whole design: the words that apply the
403 + // change and the words that persist it are the same words.
404 + #[test]
405 + fn the_runtime_command_and_the_config_line_are_the_same_words() {
406 + let directive = Directive::new(&fw12(), "scale", "1.25");
407 + assert_eq!(directive.line(), "output eDP-1 scale 1.25");
408 + assert_eq!(
409 + directive.invocation().display(),
410 + "swaymsg output eDP-1 scale 1.25",
411 + );
412 + assert_eq!(
413 + directive.invocation().display(),
414 + format!("swaymsg {}", directive.line()),
415 + );
416 + }
417 +
418 + // swaymsg joins its argv with spaces and hands the result to the same
419 + // parser that reads a config file, and its `join_args` adds no quoting of
420 + // its own. That used to mean a three-word triple had to carry quotes in the
421 + // string, in both consumers; with connector names there is nothing to
422 + // quote, and the property to hold is that neither consumer adds any.
423 + #[test]
424 + fn neither_consumer_quotes_a_connector_name() {
425 + let monitor = external("DP-3", "Example Co", "PA279CV", "K8LMQS032990");
426 + let directive = Directive::new(&monitor, "scale", "2");
427 + assert_eq!(directive.line(), "output DP-3 scale 2");
428 + assert_eq!(
429 + directive.invocation().display(),
430 + "swaymsg output DP-3 scale 2",
431 + "the log pane's single quotes appear only around an argument with \
432 + whitespace in it, and there is none left",
433 + );
434 + }
435 +
436 + // `scale 1.250000` is the same instruction spelled to look machine-written,
437 + // and the shared string is only worth having if a person can paste it.
438 + #[test]
439 + fn scales_are_spelled_the_way_a_person_would_type_them() {
440 + assert_eq!(spell_scale(1.0), "1");
441 + assert_eq!(spell_scale(1.25), "1.25");
442 + assert_eq!(spell_scale(1.5), "1.5");
443 + assert_eq!(spell_scale(2.0), "2");
444 + }
445 +
446 + #[test]
447 + fn the_scale_key_always_moves() {
448 + let mut panel = fw12();
449 + assert!((panel.next_scale() - 1.5).abs() < f64::EPSILON);
450 + panel.scale = 2.0;
451 + assert!(
452 + (panel.next_scale() - 1.0).abs() < f64::EPSILON,
453 + "the top rung wraps rather than dead-ending",
454 + );
455 + // A scale set outside the console lands on the next rung above it.
456 + panel.scale = 1.1;
457 + assert!((panel.next_scale() - 1.25).abs() < f64::EPSILON);
458 + }
459 +
460 + // The file is regenerated whole because sway *merges* every stanza that
461 + // matches an output: a leftover scale from a previous write would keep
462 + // applying underneath a newer one.
463 + #[test]
464 + fn the_file_holds_one_stanza_per_output() {
465 + let outputs = vec![
466 + fw12(),
467 + external("DP-3", "Example Co", "PA279CV", "K8LMQS032990"),
468 + ];
469 + let file = config_file(&outputs);
470 + // Directives only. The header talks about outputs too, and counting the
471 + // word rather than the instruction is how a test like this passes on a
472 + // file that has lost a stanza and gained a sentence.
473 + let directives = file.lines().filter(|line| line.starts_with("output "));
474 + assert_eq!(directives.count(), 2, "{file}");
475 + assert!(file.contains("output eDP-1 scale 1.25"), "{file}");
476 + assert!(file.contains("output DP-3 scale 1"), "{file}");
477 + assert!(
478 + !file.contains("Example Co PA279CV K8LMQS032990 scale"),
479 + "no EDID text belongs in a directive; the comment above it is where \
480 + a person reads which monitor this is: {file}",
481 + );
482 + assert!(
483 + file.contains("hand edits here are lost"),
484 + "the header says who owns the file: {file}",
485 + );
486 + }
487 +
488 + // `transform normal` is sway's default, so writing it says nothing and reads
489 + // as though the console had an opinion about rotation.
490 + #[test]
491 + fn only_a_rotation_that_is_not_the_default_is_written() {
492 + let mut panel = fw12();
493 + assert!(!config_file(&[panel.clone()]).contains("transform"));
494 + panel.transform = "90".into();
495 + assert!(config_file(&[panel]).contains("output eDP-1 transform 90"));
496 + }
497 +
498 + // An output the user turned off has to stay off across a reboot, or the key
499 + // did not do what it said.
500 + #[test]
Lines truncated
@@ -1,0 +1,482 @@
1 + //! Tests for [`super`].
2 +
3 + use super::*;
4 +
5 + #[test]
6 + fn the_default_is_the_desktop_with_what_the_image_requires() {
7 + let choices = Choices::default();
8 + assert_eq!(choices.profile, Profile::Client);
9 + assert_eq!(choices.browser, Browser::Firefox);
10 + // No toolchain. The image requires none to function, and a build
11 + // host asks for one at mint. See the comment on `Choices::default`.
12 + assert!(choices.langs.is_empty());
13 + assert_eq!(choices.artifact, Artifact::Iso);
14 + // Trimmed by default. What it costs is foreign-architecture emulation,
15 + // which the house rules forbid using in the first place.
16 + assert_eq!(choices.trim, Trim::Unused);
17 + // No database. It is opt-in for the build-host role and every image
18 + // before the dial existed carried none.
19 + assert_eq!(choices.db, Db::None);
20 + }
21 +
22 + /// The build args are the whole contract with the Containerfile, so their
23 + /// names and their order are asserted rather than left to whatever the
24 + /// struct happens to iterate.
25 + #[test]
26 + fn the_build_args_name_what_the_containerfile_reads() {
27 + let args = Choices::default().build_args();
28 + let names: Vec<&str> = args.iter().map(|(k, _)| k.as_str()).collect();
29 + assert_eq!(names, ["PROFILE", "BROWSER", "LANGS", "TRIM", "DB"]);
30 + assert_eq!(args[0].1, "client");
31 + // Comma-joined in the enum's declared order, and this is also the
32 + // literal the Containerfile's own `ARG LANGS` default has to match:
33 + // the two defaults are one decision written in two files, and a build
34 + // that bypasses the TUI must get the same stack the TUI would have
35 + // asked for.
36 + assert_eq!(args[2].1, "");
37 + assert_eq!(args[3].1, "unused");
38 + assert_eq!(args[4].1, "none");
39 + }
40 +
41 + #[test]
42 + fn identity_args_appear_only_once_they_are_set() {
43 + let mut choices = Choices::default();
44 + assert!(
45 + !choices
46 + .build_args()
47 + .iter()
48 + .any(|(k, _)| k == "ALLOY_HOSTNAME")
49 + );
50 + choices.hostname = "bench".to_string();
51 + assert!(
52 + choices
53 + .build_args()
54 + .iter()
55 + .any(|(k, _)| k == "ALLOY_HOSTNAME")
56 + );
57 + }
58 +
59 + #[test]
60 + fn several_languages_join_into_one_arg() {
61 + let choices = Choices {
62 + langs: BTreeSet::from([Lang::Rust, Lang::Go, Lang::Zig]),
63 + ..Choices::default()
64 + };
65 + let args = choices.build_args();
66 + let langs = &args.iter().find(|(k, _)| k == "LANGS").unwrap().1;
67 + // BTreeSet order, which is the enum's declared order, so the arg is
68 + // stable between runs rather than however a HashSet felt that day.
69 + assert_eq!(langs, "rust,go,zig");
70 + }
71 +
72 + /// The ISO does not come from bootc-image-builder, and passing it a type
73 + /// would be an error rather than a no-op.
74 + #[test]
75 + fn the_iso_and_the_disk_images_use_different_scripts() {
76 + let mut choices = Choices::default();
77 + assert_eq!(choices.artifact.script(), "build/build-iso.sh");
78 + assert!(!choices.invocation().display().contains("--type"));
79 +
80 + choices.artifact = Artifact::Raw;
81 + assert_eq!(choices.artifact.script(), "build/build-image.sh");
82 + assert!(choices.invocation().display().contains("--type raw"));
83 + }
84 +
85 + #[test]
86 + fn the_command_is_copy_pasteable() {
87 + let choices = Choices {
88 + hostname: "bench".to_string(),
89 + ..Choices::default()
90 + };
91 + let shown = choices.invocation().display();
92 + assert!(shown.starts_with("build/build-iso.sh"));
93 + assert!(shown.contains("--build-arg PROFILE=client"));
94 + assert!(shown.contains("--build-arg ALLOY_HOSTNAME=bench"));
95 + }
96 +
97 + #[test]
98 + fn a_record_round_trips() {
99 + let choices = Choices {
100 + profile: Profile::Server,
101 + browser: Browser::None,
102 + langs: BTreeSet::from([Lang::Go]),
103 + artifact: Artifact::Qcow2,
104 + trim: Trim::Keep,
105 + db: Db::Postgres16,
106 + hostname: "bench".to_string(),
107 + pubkey: "/home/max/.ssh/id_ed25519.pub".to_string(),
108 + };
109 +
110 + let parsed = Choices::from_toml(&choices.to_toml()).expect("round trip");
111 + assert_eq!(parsed.profile, Profile::Server);
112 + assert_eq!(parsed.browser, Browser::None);
113 + assert_eq!(parsed.langs, BTreeSet::from([Lang::Go]));
114 + assert_eq!(parsed.artifact, Artifact::Qcow2);
115 + assert_eq!(parsed.trim, Trim::Keep);
116 + assert_eq!(parsed.db, Db::Postgres16);
117 + assert_eq!(parsed.hostname, "bench");
118 + assert_eq!(parsed.pubkey, "/home/max/.ssh/id_ed25519.pub");
119 + }
120 +
121 + /// A record from a different version of Alloy fails loudly. Falling back
122 + /// to the default would build something the user did not ask for and
123 + /// could not explain.
124 + #[test]
125 + fn an_unknown_value_is_an_error_rather_than_a_default() {
126 + let err = Choices::from_toml("browser = \"netscape\"").unwrap_err();
127 + assert!(format!("{err}").contains("netscape"), "{err}");
128 +
129 + let err = Choices::from_toml("langs = [\"cobol\"]").unwrap_err();
130 + assert!(format!("{err}").contains("cobol"), "{err}");
131 + }
132 +
133 + #[test]
134 + fn an_empty_record_is_the_default() {
135 + let parsed = Choices::from_toml("").expect("empty is valid");
136 + assert_eq!(parsed.profile, Profile::Client);
137 + }
138 +
139 + #[test]
140 + fn hostnames_are_checked_the_way_dns_checks_them() {
141 + assert!(valid_hostname("bench"));
142 + assert!(valid_hostname("build-host-2"));
143 + assert!(!valid_hostname(""));
144 + assert!(!valid_hostname("-leading"));
145 + assert!(!valid_hostname("trailing-"));
146 + assert!(!valid_hostname("under_score"));
147 + assert!(!valid_hostname("has space"));
148 + assert!(!valid_hostname(&"a".repeat(64)));
149 + assert!(valid_hostname(&"a".repeat(63)));
150 + }
151 +
152 + /// The one check that matters here: a private key must never be mistaken
153 + /// for a public one, because the artifact is meant to be copyable without
154 + /// care and baking a private key in would silently make that false.
155 + #[test]
156 + fn a_private_key_is_not_mistaken_for_a_public_one() {
157 + let private = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n";
158 + assert!(!looks_like_pubkey(private));
159 + }
160 +
161 + #[test]
162 + fn a_public_key_is_recognized() {
163 + assert!(looks_like_pubkey(
164 + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmVLm7Yk2xQ max@fw13\n"
165 + ));
166 + assert!(looks_like_pubkey(
167 + "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY= max@fw13"
168 + ));
169 + assert!(!looks_like_pubkey(""));
170 + assert!(!looks_like_pubkey("hello world"));
171 + // A key type with no body is not a key.
172 + assert!(!looks_like_pubkey("ssh-ed25519"));
173 + assert!(!looks_like_pubkey("ssh-ed25519 short"));
174 + }
175 +
176 + /// A server has no session, so it cannot carry a browser. The blocker
177 + /// exists for a record loaded off disk; the key handler prevents reaching
178 + /// the state interactively.
179 + #[test]
180 + fn a_server_carrying_a_browser_is_blocked() {
181 + let choices = Choices {
182 + profile: Profile::Server,
183 + browser: Browser::Firefox,
184 + ..Choices::default()
185 + };
186 + let blockers = choices.blockers(Some(Path::new("/nonexistent")));
187 + assert!(
188 + blockers.iter().any(|b| b.contains("no graphical session")),
189 + "{blockers:?}"
190 + );
191 + }
192 +
193 + #[test]
194 + fn no_checkout_is_the_first_thing_reported() {
195 + let blockers = Choices::default().blockers(None);
196 + assert!(blockers[0].contains("no Alloy checkout"), "{blockers:?}");
197 + }
198 +
199 + /// The write goes through the script's own path, with `--write-only` so it
200 + /// writes the artifact that already exists rather than rebuilding it.
201 + /// Re-deriving this is the disk-eating bug the design note warns about.
202 + #[test]
203 + fn the_write_delegates_to_the_script_that_owns_the_guards() {
204 + let shown = ImageView {
205 + choices: Choices::default(),
206 + cursor: Cursor::new(),
207 + repo: None,
208 + candidates: Vec::new(),
209 + editing: None,
210 + sequence: None,
211 + pending_write: None,
212 + device: None,
213 + error: None,
214 + saved: false,
215 + }
216 + .write_command("/dev/sdX")
217 + .display();
218 +
219 + assert!(shown.starts_with("build/build-iso.sh"));
220 + assert!(shown.contains("--write-only"));
221 + assert!(shown.contains("--write /dev/sdX"));
222 + // Nothing resembling a dd, anywhere.
223 + assert!(!shown.contains("dd "));
224 + assert!(!shown.contains("of="));
225 + }
226 +
227 + /// The build container cannot see a path on the building machine — the
228 + /// file is not in the build context — so the key travels as its bytes.
229 + /// Passing the path would make the Containerfile's own validation reject
230 + /// it, which is a confusing way to learn a file is missing.
231 + #[test]
232 + fn the_key_travels_as_bytes_and_the_record_keeps_the_path() {
233 + let dir = std::env::temp_dir().join(format!("alloy-image-key-{}", std::process::id()));
234 + std::fs::create_dir_all(&dir).expect("scratch dir");
235 + let path = dir.join("id_ed25519.pub");
236 + std::fs::write(
237 + &path,
238 + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmVLm7Yk2xQ max@fw13\n",
239 + )
240 + .expect("write key");
241 +
242 + let choices = Choices {
243 + pubkey: path.display().to_string(),
244 + ..Choices::default()
245 + };
246 +
247 + let key = choices
248 + .build_args()
249 + .into_iter()
250 + .find(|(name, _)| name == "ALLOY_SSH_KEY")
251 + .expect("the key is passed")
252 + .1;
253 + assert!(key.starts_with("ssh-ed25519 AAAA"), "{key}");
254 + // Trimmed: a trailing newline inside a --build-arg value would land in
255 + // the authorized_keys file and in the validation `case`.
256 + assert!(!key.ends_with('\n'));
257 +
258 + // The record keeps the path, so a saved build.toml is not a different
259 + // file on every machine.
260 + assert!(choices.to_toml().contains("id_ed25519.pub"));
261 +
262 + let _ = std::fs::remove_dir_all(&dir);
263 + }
264 +
265 + /// An unreadable key is omitted rather than guessed at, because `blockers`
266 + /// is the gate and has already refused to start the build.
267 + #[test]
268 + fn an_unreadable_key_is_omitted_and_blocked() {
269 + let choices = Choices {
270 + pubkey: "/nonexistent/id_ed25519.pub".to_string(),
271 + ..Choices::default()
272 + };
273 + assert!(
274 + !choices
275 + .build_args()
276 + .iter()
277 + .any(|(name, _)| name == "ALLOY_SSH_KEY")
278 + );
279 + assert!(
280 + choices
281 + .blockers(None)
282 + .iter()
283 + .any(|blocker| blocker.contains("no public key at")),
284 + );
285 + }
286 +
287 + /// What a built image records, parsed by the same code that reads it back.
288 + /// The image writes `artifact` nowhere (it does not know which one it was
289 + /// packed into) and `pubkey` empty (the path meant something on another
290 + /// machine), so both have to land on their defaults rather than erroring.
291 + #[test]
292 + fn the_record_an_image_carries_reads_back() {
293 + let from_image = "\
294 + profile = \"server\"
295 + browser = \"none\"
296 + langs = [\"rust\", \"go\"]
297 + hostname = \"bench\"
298 + pubkey = \"\"
299 + ";
300 + let parsed = Choices::from_toml(from_image).expect("an image record parses");
301 + assert_eq!(parsed.profile, Profile::Server);
302 + assert_eq!(parsed.browser, Browser::None);
303 + assert_eq!(parsed.langs, BTreeSet::from([Lang::Rust, Lang::Go]));
304 + assert_eq!(parsed.hostname, "bench");
305 + assert!(parsed.pubkey.is_empty());
306 + // Absent, so the default. Not an error, and not a guess.
307 + assert_eq!(parsed.artifact, Artifact::Iso);
308 + }
309 +
310 + /// A view with a known candidate list, so the cycling can be exercised
311 + /// without a `~/.ssh` to stand in front of it.
312 + fn view_with(candidates: &[&str]) -> ImageView {
313 + ImageView {
314 + choices: Choices::default(),
315 + cursor: Cursor::new(),
316 + repo: None,
317 + candidates: candidates.iter().map(|key| (*key).to_string()).collect(),
318 + editing: None,
319 + sequence: None,
320 + pending_write: None,
321 + device: None,
322 + error: None,
323 + saved: false,
324 + }
325 + }
326 +
327 + /// Empty is a position on the ring, not a state to escape. An image with no
328 + /// baked key is the ordinary desktop install, so it has to stay reachable
329 + /// once a key has been cycled onto the row.
330 + #[test]
331 + fn cycling_the_pubkey_row_passes_back_through_none() {
332 + let mut view = view_with(&["/home/max/.ssh/a.pub", "/home/max/.ssh/b.pub"]);
333 + assert_eq!(view.choices.pubkey, "");
334 +
335 + view.cycle_pubkey(true);
336 + assert_eq!(view.choices.pubkey, "/home/max/.ssh/a.pub");
337 + view.cycle_pubkey(true);
338 + assert_eq!(view.choices.pubkey, "/home/max/.ssh/b.pub");
339 + view.cycle_pubkey(true);
340 + assert_eq!(view.choices.pubkey, "", "the ring returns to no key");
341 +
342 + // And backwards, off none onto the last one.
343 + view.cycle_pubkey(false);
344 + assert_eq!(view.choices.pubkey, "/home/max/.ssh/b.pub");
345 + }
346 +
347 + /// A path typed by hand is not one of the candidates, so it reads as the
348 + /// empty position rather than panicking on a lookup that finds nothing.
349 + #[test]
350 + fn a_typed_path_is_not_lost_to_an_index_it_never_had() {
351 + let mut view = view_with(&["/home/max/.ssh/a.pub"]);
352 + view.choices.pubkey = "/elsewhere/key.pub".to_string();
353 + view.cycle_pubkey(true);
354 + assert_eq!(view.choices.pubkey, "/home/max/.ssh/a.pub");
355 + }
356 +
357 + /// Nothing to cycle says so, rather than silently doing nothing. An inert
358 + /// key reads as the form being broken.
359 + #[test]
360 + fn no_candidates_explains_itself() {
361 + let mut view = view_with(&[]);
362 + view.cycle_pubkey(true);
363 + assert_eq!(view.choices.pubkey, "");
364 + assert!(
365 + view.error.as_deref().is_some_and(|e| e.contains("~/.ssh")),
366 + "{:?}",
367 + view.error
368 + );
369 + }
370 +
371 + /// Discovery is filtered by shape, not by suffix. `~/.ssh` collects other
372 + /// files, and a misnamed private half offered as a candidate is how a
373 + /// secret reaches an artifact that promises to hold none.
374 + #[test]
375 + fn discovery_refuses_anything_that_is_not_a_public_key() {
376 + let dir = std::env::temp_dir().join(format!("alloy-image-scan-{}", std::process::id()));
377 + let ssh = dir.join(".ssh");
378 + std::fs::create_dir_all(&ssh).expect("scratch dir");
379 +
380 + std::fs::write(
381 + ssh.join("id_ed25519.pub"),
382 + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmVLm7Yk2xQ max@fw13\n",
383 + )
384 + .expect("write key");
385 + // A private key that someone named `.pub`. The suffix is not the check.
386 + std::fs::write(
387 + ssh.join("oops.pub"),
388 + "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n",
389 + )
390 + .expect("write private");
391 + // And the ordinary neighbours, which have no `.pub` at all.
392 + std::fs::write(ssh.join("known_hosts"), "github.com ssh-ed25519 AAAA\n")
393 + .expect("write known_hosts");
394 +
395 + // SAFETY: single-threaded within this test's own scratch HOME. The
396 + // discovery reads HOME rather than taking a directory because that is
397 + // what it does in the program, and testing a different function would
398 + // test nothing.
399 + let restore = std::env::var_os("HOME");
400 + unsafe { std::env::set_var("HOME", &dir) };
401 + let found = discover_pubkeys();
402 + match restore {
403 + Some(home) => unsafe { std::env::set_var("HOME", home) },
404 + None => unsafe { std::env::remove_var("HOME") },
405 + }
406 +
407 + assert_eq!(found.len(), 1, "{found:?}");
408 + assert!(found[0].ends_with("id_ed25519.pub"), "{found:?}");
409 +
410 + let _ = std::fs::remove_dir_all(&dir);
411 + }
412 +
413 + #[test]
414 + fn the_record_says_it_is_not_a_lockfile() {
415 + let toml = Choices::default().to_toml();
416 + assert!(
417 + toml.contains("not a lockfile"),
418 + "the record must not read as one: it pins choices, not resolutions",
419 + );
420 + }
421 +
422 + /// Against the file the image actually ships, not a fixture, so an
423 + /// os-release edit that drops or renames `VERSION_ID` fails here rather
424 + /// than by silently removing the image line from `alloy --version`.
425 + ///
426 + /// The committed file carries no `IMAGE_VERSION`: the build stamps it, and
427 + /// a placeholder here would be a lie on any machine where the stamping
428 + /// step stopped working. So this is the unstamped shape on purpose, and
429 + /// the product version is asserted rather than the whole line.
430 + #[test]
431 + fn the_shipped_os_release_states_an_image_version() {
432 + let shipped = concat!(env!("CARGO_MANIFEST_DIR"), "/../../usr/lib/os-release");
433 + let text = std::fs::read_to_string(shipped).expect("the repo ships usr/lib/os-release");
434 + let version = version_from(&text).expect("the shipped os-release names an image version");
435 + assert!(version.starts_with("0."), "{version}");
436 + assert!(
437 + !version.contains("build"),
438 + "the stamp is not committed: {version}"
439 + );
440 + assert!(version.contains("Fedora"), "{version}");
441 + }
442 +
443 + /// The line a support conversation reads back: product, build and base,
444 + /// which move on three different clocks and are three fields for that
445 + /// reason.
446 + #[test]
447 + fn a_stamped_image_composes_the_whole_triple() {
448 + let stamped = "NAME=\"Alloy\"\nVERSION_ID=\"0.1\"\nIMAGE_VERSION=\"20260816.143012\"\n\
449 + ALLOY_BASE=\"43\"\nID=alloy\nID_LIKE=fedora\n";
450 + assert_eq!(
451 + version_from(stamped).as_deref(),
452 + Some("0.1 (build 20260816.143012, Fedora 43)")
453 + );
454 + }
455 +
456 + /// An unstamped build is a real state rather than a broken one: a bare
457 + /// `podman build` past the wrapper scripts produces one. It reports less
458 + /// and nothing false, which is what a filled-in "unknown" would not do.
459 + #[test]
460 + fn an_unstamped_image_says_less_rather_than_something_false() {
461 + let unstamped = "VERSION_ID=\"0.1\"\nALLOY_BASE=\"43\"\nID=alloy\n";
462 + assert_eq!(version_from(unstamped).as_deref(), Some("0.1 (Fedora 43)"));
463 +
464 + let bare = "VERSION_ID=\"0.1\"\nID=alloy\n";
465 + assert_eq!(version_from(bare).as_deref(), Some("0.1"));
466 + }
467 +
468 + /// The reason `ID` is checked. Every Linux host has an os-release, so a
469 + /// dev box would otherwise report its own distro's version as the image's.
470 + #[test]
471 + fn a_foreign_os_release_has_no_image_version() {
472 + let fedora = "NAME=\"Fedora Linux\"\nID=fedora\nVERSION_ID=43\n";
473 + assert_eq!(version_from(fedora), None);
474 + }
475 +
476 + /// `ID` is a prefix of `ID_LIKE`, and matching the wrong one would read
477 + /// every Fedora derivative as Alloy.
478 + #[test]
479 + fn id_like_is_not_mistaken_for_id() {
480 + let derivative = "ID=notalloy\nID_LIKE=alloy\nVERSION_ID=9\n";
481 + assert_eq!(version_from(derivative), None);
482 + }
@@ -1,0 +1,508 @@
1 + //! Tests for [`super`].
2 +
3 + use super::*;
4 +
5 + // Shaped from this machine's real `tailscale status --json`, trimmed to
6 + // the fields the parser reads. The awkward parts are real: an online peer
7 + // carrying Go's zero time for LastSeen, a device named "localhost", and
8 + // the Peer map keyed by public key.
9 + const STATUS: &str = r#"{
10 + "Version": "1.90.0",
11 + "BackendState": "Running",
12 + "Health": [],
13 + "MagicDNSSuffix": "example-tailnet.ts.net",
14 + "Self": {
15 + "HostName": "fw13", "OS": "linux",
16 + "TailscaleIPs": ["100.103.89.95", "fd7a:115c:a1e0::af3b:595f"],
17 + "Online": true, "ExitNode": false, "ExitNodeOption": false,
18 + "LastSeen": "0001-01-01T00:00:00Z"
19 + },
20 + "Peer": {
21 + "nodekey:aaa": {
22 + "HostName": "localhost", "OS": "iOS",
23 + "TailscaleIPs": ["100.90.1.2"],
24 + "Online": false, "ExitNode": false, "ExitNodeOption": false,
25 + "LastSeen": "2026-05-21T23:27:30.1Z"
26 + },
27 + "nodekey:bbb": {
28 + "HostName": "astra", "OS": "linux",
29 + "TailscaleIPs": ["100.80.3.4"],
30 + "Online": true, "ExitNode": false, "ExitNodeOption": true,
31 + "LastSeen": "0001-01-01T00:00:00Z"
32 + },
33 + "nodekey:ccc": {
34 + "HostName": "htpy-1", "OS": "linux",
35 + "TailscaleIPs": ["100.70.5.6"],
36 + "Online": true, "ExitNode": false, "ExitNodeOption": false,
37 + "LastSeen": "0001-01-01T00:00:00Z"
38 + }
39 + }
40 + }"#;
41 +
42 + #[test]
43 + fn parses_self_and_peers() {
44 + let status = parse_status(STATUS).unwrap();
45 + assert_eq!(status.backend_state, "Running");
46 + assert!(status.health.is_empty());
47 + assert_eq!(status.peers.len(), 4, "self plus three peers");
48 + }
49 +
50 + // Self first, then online peers by name, then offline. `Peer` is a map, so
51 + // without an explicit sort the list reshuffles on every refresh with the
52 + // cursor sitting on whatever lands under it.
53 + #[test]
54 + fn peers_are_ordered_self_then_online_then_by_name() {
55 + let status = parse_status(STATUS).unwrap();
56 + let names: Vec<&str> = status.peers.iter().map(|p| p.hostname.as_str()).collect();
57 + assert_eq!(names, ["fw13", "astra", "htpy-1", "localhost"]);
58 + assert!(status.peers[0].is_self);
59 + }
60 +
61 + // Go's zero time means "currently online", not "last seen in year 1".
62 + #[test]
63 + fn go_zero_time_is_not_a_last_seen_date() {
64 + assert_eq!(last_seen_date("0001-01-01T00:00:00Z"), None);
65 + assert_eq!(last_seen_date(""), None);
66 + assert_eq!(
67 + last_seen_date("2026-05-21T23:27:30.1Z").as_deref(),
68 + Some("2026-05-21")
69 + );
70 +
71 + let status = parse_status(STATUS).unwrap();
72 + let astra = &status.peers[1];
73 + assert!(astra.online);
74 + assert_eq!(astra.last_seen, None, "an online peer shows no last-seen");
75 + let phone = &status.peers[3];
76 + assert_eq!(phone.last_seen.as_deref(), Some("2026-05-21"));
77 + }
78 +
79 + // The state label is what an offline peer's row says. It must not claim a
80 + // year-1 sighting, and must stay empty for an unremarkable online peer.
81 + #[test]
82 + fn state_labels_read_sensibly() {
83 + let status = parse_status(STATUS).unwrap();
84 + assert_eq!(status.peers[0].state_label(), "this machine");
85 + assert_eq!(status.peers[1].state_label(), "offers exit");
86 + assert_eq!(status.peers[2].state_label(), "", "nothing notable to say");
87 + assert_eq!(status.peers[3].state_label(), "seen 2026-05-21");
88 + }
89 +
90 + // Peers hold a v4 and a v6; the v4 is the recognizable one. Taking the
91 + // first entry blindly works only while Tailscale keeps ordering them.
92 + #[test]
93 + fn prefers_the_ipv4_address() {
94 + let status = parse_status(STATUS).unwrap();
95 + assert_eq!(status.peers[0].ip.as_deref(), Some("100.103.89.95"));
96 +
97 + let v6_first = ["fd7a:115c:a1e0::1".to_string(), "100.1.2.3".to_string()];
98 + assert_eq!(preferred_ip(&v6_first).as_deref(), Some("100.1.2.3"));
99 + assert_eq!(preferred_ip(&[]), None);
100 + // v6-only is better shown than blanked.
101 + let v6_only = ["fd7a:115c:a1e0::1".to_string()];
102 + assert_eq!(preferred_ip(&v6_only).as_deref(), Some("fd7a:115c:a1e0::1"));
103 + }
104 +
105 + #[test]
106 + fn a_stopped_backend_is_surfaced() {
107 + let raw = r#"{"BackendState":"Stopped","Peer":{}}"#;
108 + let status = parse_status(raw).unwrap();
109 + assert!(!status.is_running());
110 + assert!(status.peers.is_empty(), "no Self key means no rows");
111 + }
112 +
113 + // NeedsLogin arrives with no Self and no peers. The screen has to survive
114 + // it rather than unwrapping something absent.
115 + #[test]
116 + fn a_logged_out_tailnet_parses_to_an_empty_list() {
117 + let raw = r#"{"BackendState":"NeedsLogin","Health":["not logged in"],"Peer":{}}"#;
118 + let status = parse_status(raw).unwrap();
119 + assert_eq!(status.peers.len(), 0);
120 + assert_eq!(status.health, ["not logged in"]);
121 + }
122 +
123 + #[test]
124 + fn malformed_json_is_an_error() {
125 + assert!(parse_status("not json").is_err());
126 + }
127 +
128 + #[test]
129 + fn an_unnamed_peer_still_gets_an_identifiable_row() {
130 + let raw = r#"{"BackendState":"Running","Peer":{"k":{"OS":"linux","Online":true}}}"#;
131 + let status = parse_status(raw).unwrap();
132 + assert_eq!(status.peers[0].hostname, "(unnamed)");
133 + assert_eq!(status.peers[0].ip, None);
134 + }
135 +
136 + // ---- control plane ----
137 +
138 + // An empty ControlURL is how a client that never had one set reports the
139 + // default, so it must not read as self-hosted.
140 + #[test]
141 + fn an_unset_control_url_is_the_hosted_plane() {
142 + assert_eq!(classify_control_url(""), ControlPlane::Hosted);
143 + assert_eq!(classify_control_url(" "), ControlPlane::Hosted);
144 + }
145 +
146 + #[test]
147 + fn the_vendor_control_url_is_recognized() {
148 + assert_eq!(
149 + classify_control_url("https://controlplane.tailscale.com"),
150 + ControlPlane::Hosted
151 + );
152 + assert_eq!(
153 + classify_control_url("https://tailscale.com"),
154 + ControlPlane::Hosted
155 + );
156 + }
157 +
158 + #[test]
159 + fn a_headscale_url_is_reported_by_host() {
160 + assert_eq!(
161 + classify_control_url("https://headscale.example.org"),
162 + ControlPlane::SelfHosted("headscale.example.org".into())
163 + );
164 + assert_eq!(
165 + classify_control_url("https://hs.example.org:8080/some/path"),
166 + ControlPlane::SelfHosted("hs.example.org".into()),
167 + "port and path are stripped, leaving the host"
168 + );
169 + assert_eq!(
170 + classify_control_url("http://10.0.0.5:8080"),
171 + ControlPlane::SelfHosted("10.0.0.5".into())
172 + );
173 + }
174 +
175 + // Suffix matching is dot-anchored, so a self-hosted server whose name
176 + // merely contains the vendor's domain is not mistaken for it.
177 + #[test]
178 + fn a_lookalike_host_is_not_mistaken_for_the_vendor() {
179 + assert_eq!(
180 + classify_control_url("https://headscale.tailscale.com.example.org"),
181 + ControlPlane::SelfHosted("headscale.tailscale.com.example.org".into())
182 + );
183 + assert_eq!(
184 + classify_control_url("https://nottailscale.com"),
185 + ControlPlane::SelfHosted("nottailscale.com".into())
186 + );
187 + }
188 +
189 + // Only a self-hosted plane is worth title space; the other two say nothing
190 + // rather than "(hosted)" on every screen.
191 + #[test]
192 + fn only_a_self_hosted_plane_earns_a_title_suffix() {
193 + assert_eq!(ControlPlane::Hosted.label(), "");
194 + assert_eq!(ControlPlane::Unknown.label(), "");
195 + assert_eq!(
196 + ControlPlane::SelfHosted("hs.example.org".into()).label(),
197 + " via hs.example.org"
198 + );
199 + }
200 +
201 + #[test]
202 + fn the_title_names_the_backend_and_a_self_hosted_plane() {
203 + let (mut view, _log) = mock_view();
204 + assert_eq!(view.title(), "mesh (mock)");
205 + view.control_plane = ControlPlane::SelfHosted("hs.example.org".into());
206 + assert_eq!(view.title(), "mesh (mock via hs.example.org)");
207 + }
208 +
209 + // ---- view behavior ----
210 +
211 + fn mock_view() -> (MeshView, CommandLog) {
212 + let (mut view, mut log) = bare_view();
213 + view.refresh(&mut log);
214 + (view, log)
215 + }
216 +
217 + /// A view that has not read a status yet.
218 + fn bare_view() -> (MeshView, CommandLog) {
219 + (
220 + MeshView {
221 + backend: Box::new(Mock),
222 + status: None,
223 + control_plane: ControlPlane::Unknown,
224 + cursor: Cursor::new(),
225 + error: None,
226 + ticks: 0,
227 + server: None,
228 + },
229 + CommandLog::new(),
230 + )
231 + }
232 +
233 + #[test]
234 + fn routing_through_this_machine_is_refused() {
235 + let (mut view, mut log) = mock_view();
236 + view.set_exit_node(&mut log);
237 + assert!(
238 + view.error
239 + .as_deref()
240 + .is_some_and(|e| e.contains("this machine")),
241 + "got: {:?}",
242 + view.error
243 + );
244 + }
245 +
246 + // Tailscale would reject this too, but naming the peer up front beats a
247 + // failed command in the log for something knowable in advance.
248 + #[test]
249 + fn routing_through_a_peer_that_does_not_offer_is_refused() {
250 + let (mut view, mut log) = mock_view();
251 + view.cursor.move_by(2); // the phone, which offers nothing
252 + view.set_exit_node(&mut log);
253 + assert!(
254 + view.error
255 + .as_deref()
256 + .is_some_and(|e| e.contains("does not offer")),
257 + "got: {:?}",
258 + view.error
259 + );
260 + }
261 +
262 + #[test]
263 + fn routing_through_an_offering_peer_is_allowed() {
264 + let (mut view, mut log) = mock_view();
265 + view.cursor.move_by(1); // astra, which offers
266 + view.set_exit_node(&mut log);
267 + assert!(view.error.is_none(), "got: {:?}", view.error);
268 + }
269 +
270 + #[test]
271 + fn ticks_are_silent_and_do_not_clear_errors() {
272 + let (mut view, mut log) = mock_view();
273 + view.set_exit_node(&mut log); // refused: self
274 + assert!(view.error.is_some());
275 +
276 + let before = log.entries().len();
277 + for _ in 0..POLL_TICKS * 2 {
278 + view.tick(&mut log);
279 + }
280 + assert_eq!(log.entries().len(), before, "ticks do not log");
281 + assert!(view.error.is_some(), "ticks do not wipe an action error");
282 + }
283 +
284 + #[test]
285 + fn acting_with_no_selection_is_inert() {
286 + let (mut view, mut log) = bare_view();
287 + view.set_exit_node(&mut log);
288 + assert!(view.error.is_none(), "no selection is not an error");
289 + }
290 +
291 + // ---- enrollment ----
292 +
293 + fn press(view: &mut MeshView, c: char, log: &mut CommandLog) -> Flow {
294 + view.handle(KeyEvent::from(KeyCode::Char(c)), log)
295 + }
296 +
297 + fn key(view: &mut MeshView, code: KeyCode, log: &mut CommandLog) -> Flow {
298 + view.handle(KeyEvent::from(code), log)
299 + }
300 +
301 + /// A view sitting on a tailnet it has never signed into.
302 + fn logged_out_view() -> (MeshView, CommandLog) {
303 + let (mut view, log) = bare_view();
304 + view.status = Some(parse_status(r#"{"BackendState":"NeedsLogin","Peer":{}}"#).unwrap());
305 + (view, log)
306 + }
307 +
308 + #[test]
309 + fn an_unset_server_means_the_vendor_plane() {
310 + assert_eq!(validate_login_server(""), Ok(None));
311 + assert_eq!(validate_login_server(" "), Ok(None));
312 + }
313 +
314 + #[test]
315 + fn a_server_url_is_trimmed_and_kept() {
316 + assert_eq!(
317 + validate_login_server(" https://hs.example.org "),
318 + Ok(Some("https://hs.example.org".into()))
319 + );
320 + // http is allowed: a Headscale on a tailnet-internal address is a real
321 + // deployment, and refusing it would be a policy this screen has no
322 + // standing to set.
323 + assert_eq!(
324 + validate_login_server("http://10.0.0.5:8080"),
325 + Ok(Some("http://10.0.0.5:8080".into()))
326 + );
327 + }
328 +
329 + // The error a bare hostname earns has to say what to type instead. It is
330 + // the whole reason the check exists.
331 + #[test]
332 + fn a_bare_hostname_is_refused_with_the_fix() {
333 + let error = validate_login_server("hs.example.org").unwrap_err();
334 + assert!(error.contains("https://hs.example.org"), "got: {error}");
335 + }
336 +
337 + #[test]
338 + fn enrollment_runs_tailscale_up_under_run0() {
339 + assert_eq!(Tailscale.enroll(None).display(), "run0 tailscale up");
340 + assert_eq!(
341 + Tailscale.enroll(Some("https://hs.example.org")).display(),
342 + "run0 tailscale up --login-server=https://hs.example.org"
343 + );
344 + }
345 +
346 + // A running mesh must not offer to sign in, and a status that failed to
347 + // read must not either — the peer list's error is the thing to show, not an
348 + // invitation to re-join a mesh the user is already on.
349 + #[test]
350 + fn only_a_non_running_backend_gets_the_offer() {
351 + let (view, _log) = mock_view();
352 + assert!(view.is_enrolled(), "the mock reports Running");
353 +
354 + let (view, _log) = logged_out_view();
355 + assert!(!view.is_enrolled());
356 +
357 + let (view, _log) = bare_view();
358 + assert!(view.is_enrolled(), "an unread status is not an offer");
359 + }
360 +
361 + // `e` is the exit-node key on one screen and the sign-in key on the other.
362 + // The two screens are never both on, and this is what says so.
363 + #[test]
364 + fn e_signs_in_on_the_offer_and_picks_an_exit_node_on_the_list() {
365 + let (mut view, mut log) = logged_out_view();
366 + press(&mut view, 'e', &mut log);
367 + assert!(view.server.is_some(), "the offer's e opens enrollment");
368 +
369 + let (mut view, mut log) = mock_view();
370 + press(&mut view, 'e', &mut log);
371 + assert!(
372 + view.server.is_none(),
373 + "the list's e does not open enrollment"
374 + );
375 + assert!(
376 + view.error
377 + .as_deref()
378 + .is_some_and(|e| e.contains("this machine")),
379 + "it tried to route instead: {:?}",
380 + view.error
381 + );
382 + }
383 +
384 + // A control server that survived a down/up cycle is shown rather than
385 + // silently reused, so a self-hosted user sees which mesh they are rejoining.
386 + #[test]
387 + fn the_field_is_prefilled_from_a_self_hosted_plane() {
388 + let (mut view, _log) = logged_out_view();
389 + view.control_plane = ControlPlane::SelfHosted("hs.example.org".into());
390 + view.open_enrollment();
391 + assert_eq!(view.server.unwrap().value(), "https://hs.example.org");
392 +
393 + let (mut view, _log) = logged_out_view();
394 + view.open_enrollment();
395 + assert_eq!(
396 + view.server.unwrap().value(),
397 + "",
398 + "the vendor plane is empty"
399 + );
400 + }
401 +
402 + // Typing is not a binding. Without this, a server named `https://r.example`
403 + // would refresh the view and clear the exit node on the way through.
404 + #[test]
405 + fn the_overlay_eats_the_keys_the_list_would_claim() {
406 + let (mut view, mut log) = logged_out_view();
407 + view.open_enrollment();
408 + for c in "https://rex.example".chars() {
409 + press(&mut view, c, &mut log);
410 + }
411 + assert_eq!(view.server.as_ref().unwrap().value(), "https://rex.example");
412 + assert!(
413 + view.text_entry(),
414 + "the shell must release its reserved keys"
415 + );
416 + }
417 +
418 + #[test]
419 + fn a_bad_server_keeps_the_overlay_open_to_be_corrected() {
420 + let (mut view, mut log) = logged_out_view();
421 + view.open_enrollment();
422 + for c in "hs.example.org".chars() {
423 + press(&mut view, c, &mut log);
424 + }
425 + let flow = key(&mut view, KeyCode::Enter, &mut log);
426 + assert!(matches!(flow, Flow::Continue), "no suspend on a bad value");
427 + assert!(view.server.is_some(), "the typed value survives the error");
428 + assert!(view.error.is_some());
429 + }
430 +
431 + #[test]
432 + fn a_good_server_suspends_the_console() {
433 + let (mut view, mut log) = logged_out_view();
434 + view.open_enrollment();
435 + let flow = key(&mut view, KeyCode::Enter, &mut log);
436 + assert!(matches!(flow, Flow::Suspend(_)));
437 + assert!(view.server.is_none(), "the overlay closes on the way out");
438 + // The pane carries the command before the handover, not after: the
439 + // console is about to tear down and there is no after to fill in.
440 + assert!(
441 + log.entries().iter().any(|e| e.command.contains("true")),
442 + "the enrollment command was not logged"
443 + );
444 + }
445 +
446 + #[test]
447 + fn esc_closes_the_overlay_before_it_closes_the_view() {
448 + let (mut view, mut log) = logged_out_view();
449 + view.open_enrollment();
450 + key(&mut view, KeyCode::Esc, &mut log);
451 + assert!(view.server.is_none());
452 + assert!(matches!(view.cancel(), Flow::Exit), "then Esc leaves");
453 + }
454 +
455 + #[test]
456 + fn ticks_do_not_refresh_under_the_overlay() {
457 + let (mut view, mut log) = logged_out_view();
458 + view.open_enrollment();
459 + for _ in 0..POLL_TICKS * 2 {
460 + view.tick(&mut log);
461 + }
462 + assert!(view.server.is_some(), "the overlay survived the poll");
463 + assert!(!view.is_enrolled(), "and the status behind it is untouched");
464 + }
465 +
466 + /// Parse this machine's real tailnet.
467 + ///
468 + /// Ignored by default: needs Tailscale installed and logged in, and what
469 + /// it finds depends on the tailnet. Run it when touching the parser.
470 + #[test]
471 + #[ignore = "requires a logged-in Tailscale"]
472 + fn parses_this_machines_real_tailnet() {
473 + let mut log = CommandLog::new();
474 + let status = Tailscale.status(&mut log).expect("tailscale should answer");
475 +
476 + assert!(!status.peers.is_empty(), "a mesh has at least this machine");
477 + assert!(status.peers[0].is_self, "this machine sorts first");
478 +
479 + // The control-plane lookup rides an unstable `debug` interface, so
480 + // what matters is that it produced *something* rather than silently
481 + // degrading to Unknown on a working client.
482 + let control = Tailscale.control_plane();
483 + println!("control plane: {control:?}");
484 + assert_ne!(
485 + control,
486 + ControlPlane::Unknown,
487 + "`tailscale debug prefs` no longer yields a ControlURL; the lookup \
488 + has degraded and the title will silently drop its suffix"
489 + );
490 + println!(
491 + "backend: {} health: {:?}",
492 + status.backend_state, status.health
493 + );
494 + for peer in &status.peers {
495 + assert!(!peer.hostname.is_empty(), "every row is identifiable");
496 + assert!(
497 + peer.last_seen.as_deref() != Some("0001-01-01"),
498 + "Go zero time leaked into a last-seen date"
499 + );
500 + println!(
Lines truncated