| 16 |
16 |
|
use ratatui::Frame;
|
| 17 |
17 |
|
use ratatui::crossterm::event::{KeyCode, KeyEvent};
|
| 18 |
18 |
|
use ratatui::layout::Rect;
|
|
19 |
+ |
use ratatui::style::{Modifier, Style};
|
| 19 |
20 |
|
use ratatui::text::{Line, Span};
|
|
21 |
+ |
use ratatui::widgets::Paragraph;
|
| 20 |
22 |
|
use serde::Deserialize;
|
| 21 |
23 |
|
|
| 22 |
24 |
|
use alloy_tui::Cursor;
|
| 23 |
25 |
|
|
| 24 |
26 |
|
use crate::cli::{CommandLog, Invocation};
|
|
27 |
+ |
use crate::field::TextField;
|
| 25 |
28 |
|
use crate::shell::{Flow, View, block_title};
|
| 26 |
29 |
|
use crate::wizard::Steps;
|
| 27 |
30 |
|
|
| 28 |
|
- |
/// How many questions the wizard asks.
|
|
31 |
+ |
/// The questions, in the order they are asked.
|
| 29 |
32 |
|
///
|
| 30 |
|
- |
/// One while only the disk step exists. Each later step raises this as it
|
| 31 |
|
- |
/// lands, which is what keeps [`Steps::is_last`] honest about where Enter runs
|
| 32 |
|
- |
/// the install rather than advancing into a screen that is not written yet.
|
| 33 |
|
- |
const STEP_COUNT: usize = 1;
|
|
33 |
+ |
/// The disk comes first because it is the one that can be wrong in a way
|
|
34 |
+ |
/// nothing later recovers from, and because a user who cannot see their disk in
|
|
35 |
+ |
/// the list should find that out before typing anything.
|
|
36 |
+ |
const STEPS: [Step; 2] = [Step::Disk, Step::Hostname];
|
|
37 |
+ |
|
|
38 |
+ |
/// Which question the wizard is on.
|
|
39 |
+ |
///
|
|
40 |
+ |
/// An enum rather than a bare index so `render` and `handle` match on what is
|
|
41 |
+ |
/// being asked instead of on a number, and so adding a step is a compiler error
|
|
42 |
+ |
/// everywhere it needs to be handled.
|
|
43 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
44 |
+ |
enum Step {
|
|
45 |
+ |
Disk,
|
|
46 |
+ |
Hostname,
|
|
47 |
+ |
}
|
|
48 |
+ |
|
|
49 |
+ |
impl Step {
|
|
50 |
+ |
const fn label(self) -> &'static str {
|
|
51 |
+ |
match self {
|
|
52 |
+ |
Self::Disk => "select a disk",
|
|
53 |
+ |
Self::Hostname => "name this machine",
|
|
54 |
+ |
}
|
|
55 |
+ |
}
|
|
56 |
+ |
}
|
|
57 |
+ |
|
|
58 |
+ |
/// The hostname an install gets if the user does not change it.
|
|
59 |
+ |
///
|
|
60 |
+ |
/// Matches `etc/hostname` in the image, so the installer's default and the
|
|
61 |
+ |
/// baked-in one cannot drift apart.
|
|
62 |
+ |
const DEFAULT_HOSTNAME: &str = "alloy";
|
|
63 |
+ |
|
|
64 |
+ |
/// Longest single hostname label, per RFC 1123.
|
|
65 |
+ |
const HOSTNAME_MAX: usize = 63;
|
|
66 |
+ |
|
|
67 |
+ |
/// Check a hostname, returning why it is unacceptable.
|
|
68 |
+ |
///
|
|
69 |
+ |
/// RFC 1123 rules for a single label, which is what `/etc/hostname` holds:
|
|
70 |
+ |
/// letters, digits and hyphens, not starting or ending with a hyphen, at most
|
|
71 |
+ |
/// 63 characters. Dots are refused rather than accepted as an FQDN, because
|
|
72 |
+ |
/// `/etc/hostname` holding a dotted name makes `hostname -s` and `hostname -f`
|
|
73 |
+ |
/// disagree, and the installer should not be the thing that sets that up.
|
|
74 |
+ |
///
|
|
75 |
+ |
/// Returns the message shown to the user, so each case says what to do rather
|
|
76 |
+ |
/// than that something is wrong.
|
|
77 |
+ |
fn validate_hostname(name: &str) -> Result<(), String> {
|
|
78 |
+ |
if name.is_empty() {
|
|
79 |
+ |
return Err("a hostname is required".into());
|
|
80 |
+ |
}
|
|
81 |
+ |
if name.chars().count() > HOSTNAME_MAX {
|
|
82 |
+ |
return Err(format!("a hostname is at most {HOSTNAME_MAX} characters"));
|
|
83 |
+ |
}
|
|
84 |
+ |
if name.contains('.') {
|
|
85 |
+ |
return Err("a hostname cannot contain dots; use the short name".into());
|
|
86 |
+ |
}
|
|
87 |
+ |
if name.starts_with('-') || name.ends_with('-') {
|
|
88 |
+ |
return Err("a hostname cannot start or end with a hyphen".into());
|
|
89 |
+ |
}
|
|
90 |
+ |
let allowed = |c: &char| c.is_ascii_alphanumeric() || *c == '-';
|
|
91 |
+ |
if let Some(bad) = name.chars().find(|c| !allowed(c)) {
|
|
92 |
+ |
return Err(format!("'{bad}' is not allowed here; use a-z, 0-9 or -"));
|
|
93 |
+ |
}
|
|
94 |
+ |
Ok(())
|
|
95 |
+ |
}
|
| 34 |
96 |
|
|
| 35 |
97 |
|
// ---- what the installer has been told ----
|
| 36 |
98 |
|
|
| 43 |
105 |
|
pub struct Answers {
|
| 44 |
106 |
|
/// Device path of the install target, e.g. `/dev/nvme0n1`.
|
| 45 |
107 |
|
pub disk: Option<String>,
|
|
108 |
+ |
/// Written to `/etc/hostname` on the installed system.
|
|
109 |
+ |
pub hostname: Option<String>,
|
| 46 |
110 |
|
}
|
| 47 |
111 |
|
|
| 48 |
112 |
|
// ---- disks ----
|
| 337 |
401 |
|
backend: Box<dyn Backend>,
|
| 338 |
402 |
|
disks: Vec<Disk>,
|
| 339 |
403 |
|
cursor: Cursor,
|
|
404 |
+ |
hostname: TextField,
|
| 340 |
405 |
|
answers: Answers,
|
| 341 |
406 |
|
error: Option<String>,
|
| 342 |
407 |
|
}
|
| 343 |
408 |
|
|
| 344 |
409 |
|
impl InstallView {
|
| 345 |
410 |
|
pub fn new(log: &mut CommandLog) -> Self {
|
|
411 |
+ |
let mut hostname = TextField::new();
|
|
412 |
+ |
// Seeded rather than blank: the default is what most installs want, and
|
|
413 |
+ |
// a field arriving pre-filled says what shape of answer is expected.
|
|
414 |
+ |
hostname.set(DEFAULT_HOSTNAME);
|
|
415 |
+ |
|
| 346 |
416 |
|
let mut view = Self {
|
| 347 |
|
- |
steps: Steps::new(STEP_COUNT),
|
|
417 |
+ |
steps: Steps::new(STEPS.len()),
|
| 348 |
418 |
|
backend: detect(),
|
| 349 |
419 |
|
disks: Vec::new(),
|
| 350 |
420 |
|
cursor: Cursor::new(),
|
|
421 |
+ |
hostname,
|
| 351 |
422 |
|
answers: Answers::default(),
|
| 352 |
423 |
|
error: None,
|
| 353 |
424 |
|
};
|
| 355 |
426 |
|
view
|
| 356 |
427 |
|
}
|
| 357 |
428 |
|
|
|
429 |
+ |
/// Which question is on screen.
|
|
430 |
+ |
fn step(&self) -> Step {
|
|
431 |
+ |
STEPS[self.steps.current()]
|
|
432 |
+ |
}
|
|
433 |
+ |
|
| 358 |
434 |
|
fn refresh(&mut self, log: &mut CommandLog) {
|
| 359 |
435 |
|
match self.backend.list(log) {
|
| 360 |
436 |
|
Ok(disks) => {
|
| 394 |
470 |
|
Flow::Continue
|
| 395 |
471 |
|
}
|
| 396 |
472 |
|
|
|
473 |
+ |
/// Take the hostname as the answer, if it is one.
|
|
474 |
+ |
///
|
|
475 |
+ |
/// The diagnostic is inline and on Enter rather than on every keystroke:
|
|
476 |
+ |
/// a half-typed name is invalid at almost every intermediate state, and a
|
|
477 |
+ |
/// field that reddens while you are still typing into it teaches nothing.
|
|
478 |
+ |
fn name_machine(&mut self) -> Flow {
|
|
479 |
+ |
match validate_hostname(self.hostname.value()) {
|
|
480 |
+ |
Ok(()) => {
|
|
481 |
+ |
self.answers.hostname = Some(self.hostname.value().to_string());
|
|
482 |
+ |
self.error = None;
|
|
483 |
+ |
self.steps.advance();
|
|
484 |
+ |
}
|
|
485 |
+ |
Err(message) => self.error = Some(message),
|
|
486 |
+ |
}
|
|
487 |
+ |
Flow::Continue
|
|
488 |
+ |
}
|
|
489 |
+ |
|
|
490 |
+ |
/// Keys for the hostname field.
|
|
491 |
+ |
///
|
|
492 |
+ |
/// Every printable character is taken literally, which is only correct
|
|
493 |
+ |
/// because [`View::text_entry`] tells the shell to stop claiming `q` while
|
|
494 |
+ |
/// this step is on screen.
|
|
495 |
+ |
fn edit_hostname(&mut self, key: KeyEvent) -> Flow {
|
|
496 |
+ |
match key.code {
|
|
497 |
+ |
KeyCode::Char(c) => self.hostname.insert(c),
|
|
498 |
+ |
KeyCode::Backspace => self.hostname.backspace(),
|
|
499 |
+ |
KeyCode::Delete => self.hostname.delete(),
|
|
500 |
+ |
KeyCode::Left => self.hostname.left(),
|
|
501 |
+ |
KeyCode::Right => self.hostname.right(),
|
|
502 |
+ |
KeyCode::Home => self.hostname.home(),
|
|
503 |
+ |
KeyCode::End => self.hostname.end(),
|
|
504 |
+ |
KeyCode::Enter => return self.name_machine(),
|
|
505 |
+ |
_ => {}
|
|
506 |
+ |
}
|
|
507 |
+ |
Flow::Continue
|
|
508 |
+ |
}
|
|
509 |
+ |
|
|
510 |
+ |
/// The hostname pane: a prompt, the field with its caret, and what the
|
|
511 |
+ |
/// name is for.
|
|
512 |
+ |
fn render_hostname(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
|
513 |
+ |
let (before, under, after) = self.hostname.split();
|
|
514 |
+ |
let caret = under.unwrap_or(' ');
|
|
515 |
+ |
|
|
516 |
+ |
let lines = vec![
|
|
517 |
+ |
Line::from(text::muted(theme, "This machine's name on the network.")),
|
|
518 |
+ |
Line::default(),
|
|
519 |
+ |
Line::from(vec![
|
|
520 |
+ |
text::secondary(theme, "hostname "),
|
|
521 |
+ |
text::primary(theme, before.to_string()),
|
|
522 |
+ |
Span::styled(
|
|
523 |
+ |
caret.to_string(),
|
|
524 |
+ |
Style::default().add_modifier(Modifier::REVERSED),
|
|
525 |
+ |
),
|
|
526 |
+ |
text::primary(theme, after.to_string()),
|
|
527 |
+ |
]),
|
|
528 |
+ |
];
|
|
529 |
+ |
|
|
530 |
+ |
frame.render_widget(Paragraph::new(lines), area);
|
|
531 |
+ |
}
|
|
532 |
+ |
|
| 397 |
533 |
|
fn row<'a>(&self, theme: &Theme, disk: &'a Disk) -> Line<'a> {
|
| 398 |
534 |
|
let (status, severity) = match disk.blocker() {
|
| 399 |
535 |
|
Some(blocked) => (blocked.label(), Severity::Warn),
|
| 415 |
551 |
|
impl View for InstallView {
|
| 416 |
552 |
|
fn title(&self) -> String {
|
| 417 |
553 |
|
format!(
|
| 418 |
|
- |
"install ({}) — step {} of {}: select a disk",
|
|
554 |
+ |
"install ({}) — step {} of {}: {}",
|
| 419 |
555 |
|
self.backend.name(),
|
| 420 |
556 |
|
self.steps.current() + 1,
|
| 421 |
|
- |
self.steps.len()
|
|
557 |
+ |
self.steps.len(),
|
|
558 |
+ |
self.step().label(),
|
| 422 |
559 |
|
)
|
| 423 |
560 |
|
}
|
| 424 |
561 |
|
|
| 425 |
562 |
|
fn hints(&self) -> Vec<Hint> {
|
| 426 |
|
- |
vec![
|
| 427 |
|
- |
hint("j/k", "select"),
|
| 428 |
|
- |
hint("enter", "choose"),
|
| 429 |
|
- |
hint("r", "refresh"),
|
| 430 |
|
- |
]
|
|
563 |
+ |
// Esc is listed from the second step on, where it means "back". On the
|
|
564 |
+ |
// first it closes the installer, which is the shell's own `q`.
|
|
565 |
+ |
let mut hints = match self.step() {
|
|
566 |
+ |
Step::Disk => vec![
|
|
567 |
+ |
hint("j/k", "select"),
|
|
568 |
+ |
hint("enter", "choose"),
|
|
569 |
+ |
hint("r", "refresh"),
|
|
570 |
+ |
],
|
|
571 |
+ |
Step::Hostname => vec![hint("enter", "confirm")],
|
|
572 |
+ |
};
|
|
573 |
+ |
if !self.steps.is_first() {
|
|
574 |
+ |
hints.push(hint("esc", "back"));
|
|
575 |
+ |
}
|
|
576 |
+ |
hints
|
| 431 |
577 |
|
}
|
| 432 |
578 |
|
|
| 433 |
579 |
|
fn status(&self) -> Option<(Severity, String)> {
|
| 448 |
594 |
|
let inner = block.inner(area);
|
| 449 |
595 |
|
frame.render_widget(block, area);
|
| 450 |
596 |
|
|
|
597 |
+ |
if self.step() == Step::Hostname {
|
|
598 |
+ |
self.render_hostname(frame, inner, theme);
|
|
599 |
+ |
return;
|
|
600 |
+ |
}
|
|
601 |
+ |
|
| 451 |
602 |
|
if self.disks.is_empty() {
|
| 452 |
603 |
|
frame.render_widget(Line::from(text::muted(theme, "no disks found")), inner);
|
| 453 |
604 |
|
return;
|
| 464 |
615 |
|
);
|
| 465 |
616 |
|
}
|
| 466 |
617 |
|
|
|
618 |
+ |
/// The hostname step types, so the shell must stop reading `q` as quit
|
|
619 |
+ |
/// while it is on screen.
|
|
620 |
+ |
fn text_entry(&self) -> bool {
|
|
621 |
+ |
self.step() == Step::Hostname
|
|
622 |
+ |
}
|
|
623 |
+ |
|
| 467 |
624 |
|
fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow {
|
|
625 |
+ |
if self.step() == Step::Hostname {
|
|
626 |
+ |
return self.edit_hostname(key);
|
|
627 |
+ |
}
|
|
628 |
+ |
|
| 468 |
629 |
|
match key.code {
|
| 469 |
630 |
|
KeyCode::Char('j') | KeyCode::Down => self.cursor.next(),
|
| 470 |
631 |
|
KeyCode::Char('k') | KeyCode::Up => self.cursor.prev(),
|
| 651 |
812 |
|
/// through [`InstallView::new`] so the tests do not depend on whatever
|
| 652 |
813 |
|
/// disks the machine running them happens to have.
|
| 653 |
814 |
|
fn view() -> (InstallView, CommandLog) {
|
|
815 |
+ |
let mut hostname = TextField::new();
|
|
816 |
+ |
hostname.set(DEFAULT_HOSTNAME);
|
| 654 |
817 |
|
let mut view = InstallView {
|
| 655 |
|
- |
steps: Steps::new(STEP_COUNT),
|
|
818 |
+ |
steps: Steps::new(STEPS.len()),
|
| 656 |
819 |
|
backend: Box::new(Mock),
|
| 657 |
820 |
|
disks: disks(),
|
| 658 |
821 |
|
cursor: Cursor::new(),
|
|
822 |
+ |
hostname,
|
| 659 |
823 |
|
answers: Answers::default(),
|
| 660 |
824 |
|
error: None,
|
| 661 |
825 |
|
};
|
| 721 |
885 |
|
assert!(matches!(view.cancel(), Flow::Exit));
|
| 722 |
886 |
|
}
|
| 723 |
887 |
|
|
| 724 |
|
- |
// Until a second step lands there is nothing to advance into, so a chosen
|
| 725 |
|
- |
// disk leaves the wizard where it is rather than stepping off the end.
|
| 726 |
888 |
|
#[test]
|
| 727 |
|
- |
fn choosing_does_not_advance_past_the_last_step() {
|
|
889 |
+ |
fn choosing_a_disk_advances_to_the_hostname_step() {
|
| 728 |
890 |
|
let (mut view, mut log) = view();
|
| 729 |
891 |
|
select(&mut view, "sda");
|
| 730 |
892 |
|
|
| 731 |
893 |
|
view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
|
| 732 |
894 |
|
|
| 733 |
|
- |
assert!(view.steps.is_last());
|
| 734 |
|
- |
assert_eq!(view.steps.current(), 0);
|
|
895 |
+ |
assert_eq!(view.step(), Step::Hostname);
|
|
896 |
+ |
}
|
|
897 |
+ |
|
|
898 |
+ |
// A refused disk must leave the user on the disk step. Advancing off a
|
|
899 |
+ |
// rejected answer is how a wizard ends up installing to nothing.
|
|
900 |
+ |
#[test]
|
|
901 |
+ |
fn a_refused_disk_does_not_advance() {
|
|
902 |
+ |
let (mut view, mut log) = view();
|
|
903 |
+ |
select(&mut view, "nvme0n1");
|
|
904 |
+ |
|
|
905 |
+ |
view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
|
|
906 |
+ |
|
|
907 |
+ |
assert_eq!(view.step(), Step::Disk);
|
|
908 |
+ |
}
|
|
909 |
+ |
|
|
910 |
+ |
// ---- the hostname step ----
|
|
911 |
+ |
|
|
912 |
+ |
fn at_hostname() -> (InstallView, CommandLog) {
|
|
913 |
+ |
let (mut view, mut log) = view();
|
|
914 |
+ |
select(&mut view, "sda");
|
|
915 |
+ |
view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
|
|
916 |
+ |
assert_eq!(view.step(), Step::Hostname, "fixture stalled on the disk");
|
|
917 |
+ |
(view, log)
|
|
918 |
+ |
}
|
|
919 |
+ |
|
|
920 |
+ |
fn type_into(view: &mut InstallView, text: &str, log: &mut CommandLog) {
|
|
921 |
+ |
for c in text.chars() {
|
|
922 |
+ |
view.handle(KeyEvent::from(KeyCode::Char(c)), log);
|
|
923 |
+ |
}
|
|
924 |
+ |
}
|
|
925 |
+ |
|
|
926 |
+ |
#[test]
|
|
927 |
+ |
fn the_hostname_field_starts_on_the_image_default() {
|
|
928 |
+ |
let (view, _log) = at_hostname();
|
|
929 |
+ |
assert_eq!(view.hostname.value(), DEFAULT_HOSTNAME);
|
|
930 |
+ |
}
|
|
931 |
+ |
|
|
932 |
+ |
#[test]
|
|
933 |
+ |
fn a_valid_hostname_is_recorded() {
|
|
934 |
+ |
let (mut view, mut log) = at_hostname();
|
|
935 |
+ |
for _ in 0..DEFAULT_HOSTNAME.len() {
|
|
936 |
+ |
view.handle(KeyEvent::from(KeyCode::Backspace), &mut log);
|
|
937 |
+ |
}
|
|
938 |
+ |
type_into(&mut view, "workshop", &mut log);
|
|
939 |
+ |
view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
|
|
940 |
+ |
|
|
941 |
+ |
assert_eq!(view.answers.hostname.as_deref(), Some("workshop"));
|
|
942 |
+ |
assert!(view.error.is_none());
|
|
943 |
+ |
}
|
|
944 |
+ |
|
|
945 |
+ |
// The whole reason `View::text_entry` exists. `q` is the console's quit key
|
|
946 |
+ |
// on every other screen, and a user typing it into a hostname must keep the
|
|
947 |
+ |
// installer they are halfway through.
|
|
948 |
+ |
#[test]
|
|
949 |
+ |
fn q_is_a_letter_in_the_hostname_field() {
|
|
950 |
+ |
let (mut view, mut log) = at_hostname();
|
|
951 |
+ |
assert!(view.text_entry(), "the shell was not told to release q");
|
|
952 |
+ |
|
|
953 |
+ |
type_into(&mut view, "q", &mut log);
|
|
954 |
+ |
|
|
955 |
+ |
assert_eq!(view.hostname.value(), "alloyq");
|
|
956 |
+ |
}
|
|
957 |
+ |
|
|
958 |
+ |
// ...and the disk step, which has no field, must not release it.
|
|
959 |
+ |
#[test]
|
|
960 |
+ |
fn the_disk_step_leaves_q_to_the_shell() {
|
|
961 |
+ |
let (view, _log) = view();
|
|
962 |
+ |
assert!(!view.text_entry());
|
|
963 |
+ |
}
|
|
964 |
+ |
|
|
965 |
+ |
#[test]
|
|
966 |
+ |
fn esc_from_the_hostname_step_returns_to_the_disk() {
|
|
967 |
+ |
let (mut view, _log) = at_hostname();
|
|
968 |
+ |
|
|
969 |
+ |
assert!(matches!(view.cancel(), Flow::Continue));
|
|
970 |
+ |
|
|
971 |
+ |
assert_eq!(view.step(), Step::Disk);
|
|
972 |
+ |
}
|
|
973 |
+ |
|
|
974 |
+ |
#[test]
|
|
975 |
+ |
fn a_rejected_hostname_stays_on_the_step_and_says_why() {
|
|
976 |
+ |
let (mut view, mut log) = at_hostname();
|
|
977 |
+ |
type_into(&mut view, "_", &mut log);
|
|
978 |
+ |
view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
|
|
979 |
+ |
|
|
980 |
+ |
assert_eq!(view.step(), Step::Hostname);
|
|
981 |
+ |
assert_eq!(view.answers.hostname, None);
|
|
982 |
+ |
let error = view.error.expect("the rejection was silent");
|
|
983 |
+ |
assert!(error.contains('_'), "{error}");
|
|
984 |
+ |
}
|
|
985 |
+ |
|
|
986 |
+ |
#[test]
|
|
987 |
+ |
fn hostnames_are_checked_against_rfc_1123() {
|
|
988 |
+ |
assert!(validate_hostname("alloy").is_ok());
|
|
989 |
+ |
assert!(validate_hostname("fw13-2").is_ok());
|
|
990 |
+ |
assert!(validate_hostname("a").is_ok());
|
|
991 |
+ |
|
|
992 |
+ |
assert!(validate_hostname("").is_err(), "empty");
|
|
993 |
+ |
assert!(validate_hostname("-alloy").is_err(), "leading hyphen");
|
|
994 |
+ |
assert!(validate_hostname("alloy-").is_err(), "trailing hyphen");
|
|
995 |
+ |
assert!(validate_hostname("al loy").is_err(), "space");
|
|
996 |
+ |
assert!(validate_hostname("al_loy").is_err(), "underscore");
|
|
997 |
+ |
assert!(validate_hostname(&"a".repeat(64)).is_err(), "64 characters");
|
|
998 |
+ |
assert!(validate_hostname(&"a".repeat(63)).is_ok(), "63 allowed");
|
|
999 |
+ |
}
|
|
1000 |
+ |
|
|
1001 |
+ |
// Refused rather than accepted: /etc/hostname holding a dotted name makes
|
|
1002 |
+ |
// `hostname -s` and `hostname -f` disagree, and the installer should not be
|
|
1003 |
+ |
// the thing that sets that up.
|
|
1004 |
+ |
#[test]
|
|
1005 |
+ |
fn a_dotted_hostname_is_refused_with_advice() {
|
|
1006 |
+ |
let error = validate_hostname("alloy.local").unwrap_err();
|
|
1007 |
+ |
assert!(error.contains("short name"), "{error}");
|
| 735 |
1008 |
|
}
|
| 736 |
1009 |
|
|
| 737 |
1010 |
|
/// Parse whatever this machine's real lsblk reports.
|
| 779 |
1052 |
|
let (view, _log) = view();
|
| 780 |
1053 |
|
let title = view.title();
|
| 781 |
1054 |
|
assert!(title.contains("mock"), "{title}");
|
| 782 |
|
- |
assert!(title.contains("step 1 of 1"), "{title}");
|
|
1055 |
+ |
assert!(title.contains("step 1 of 2"), "{title}");
|
| 783 |
1056 |
|
}
|
| 784 |
1057 |
|
}
|