max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
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), ®istry); | |
| 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), ®istry).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), ®istry).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), ®istry); | |
| 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), ®istry); | |
| 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), ®istry).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), ®istry).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), ®istry); | |
| 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