Subject: [PATCH 7/7] tests: install: Add "gui-installed-os".

From: Ludovic Courtès <[email-masked]>
Date: Sat, 22 Feb 2020 00:20:30 +0100

* gnu/installer/tests.scm: New file.
* gnu/local.mk (INSTALLER_MODULES): Add it.
* gnu/tests/install.scm (run-install): Add #:gui-test.  Add (gnu
installer tests) to the marionette imported modules.  Honor GUI-TEST.
Check whether SCRIPT is true.
(%root-password, %syslog-conf): New variable.
(operating-system-with-console-syslog, gui-test-program)
(guided-installation-test): New procedures.
(%extra-packages, installation-os-for-gui-tests)
(%test-gui-installed-os): New variable.
---
 gnu/installer/tests.scm | 340 ++++++++++++++++++++++++++++++++++++++++
 gnu/local.mk            |   3 +-
 gnu/tests/install.scm   | 200 ++++++++++++++++++++++-
 3 files changed, 535 insertions(+), 8 deletions(-)
 create mode 100644 gnu/installer/tests.scm

diff --git a/gnu/installer/tests.scm b/gnu/installer/tests.scm
new file mode 100644
index 0000000000..6f5393e3ab
--- /dev/null
+++ b/gnu/installer/tests.scm
@@ -0,0 +1,340 @@
+;;; GNU Guix --- Functional package management for GNU
+;;; Copyright © 2020 Ludovic Courtès <[email-masked]>
+;;;
+;;; This file is part of GNU Guix.
+;;;
+;;; GNU Guix is free software; you can redistribute it and/or modify it
+;;; under the terms of the GNU General Public License as published by
+;;; the Free Software Foundation; either version 3 of the License, or (at
+;;; your option) any later version.
+;;;
+;;; GNU Guix is distributed in the hope that it will be useful, but
+;;; WITHOUT ANY WARRANTY; without even the implied warranty of
+;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;;; GNU General Public License for more details.
+;;;
+;;; You should have received a copy of the GNU General Public License
+;;; along with GNU Guix.  If not, see <http://www.gnu.org/licenses/>.
+
+(define-module (gnu installer tests)
+  #:use-module (srfi srfi-1)
+  #:use-module (srfi srfi-34)
+  #:use-module (srfi srfi-35)
+  #:use-module (ice-9 match)
+  #:use-module (ice-9 regex)
+  #:use-module (ice-9 pretty-print)
+  #:export (&pattern-not-matched
+            pattern-not-matched?
+
+            %installer-socket-file
+            open-installer-socket
+
+            converse
+            conversation-log-port
+
+            choose-locale+keyboard
+            enter-host-name+passwords
+            choose-services
+            choose-partitioning
+            conclude-installation
+
+            edit-configuration-file))
+
+;;; Commentary:
+;;;
+;;; This module provides tools to test the guided "graphical" installer in a
+;;; non-interactive fashion.  The core of it is 'converse': it allows you to
+;;; state Expect-style dialogues, which happen over the Unix-domain socket the
+;;; installer listens to.  Higher-level procedures such as
+;;; 'choose-locale+keyboard' are provided to perform specific parts of the
+;;; dialogue.
+;;;
+;;; Code:
+
+(define %installer-socket-file
+  ;; Socket the installer listens to.
+  "/var/guix/installer-socket")
+
+(define* (open-installer-socket #:optional (file %installer-socket-file))
+  "Return a socket connected to the installer."
+  (let ((sock (socket AF_UNIX SOCK_STREAM 0)))
+    (connect sock AF_UNIX file)
+    sock))
+
+(define-condition-type &pattern-not-matched &error
+  pattern-not-matched?
+  (pattern pattern-not-matched-pattern)
+  (sexp    pattern-not-matched-sexp))
+
+(define (pattern-error pattern sexp)
+  (raise (condition
+          (&pattern-not-matched
+           (pattern pattern) (sexp sexp)))))
+
+(define conversation-log-port
+  ;; Port where debugging info is logged
+  (make-parameter (current-error-port)))
+
+(define (converse-debug pattern)
+  (format (conversation-log-port)
+          "conversation expecting pattern ~s~%"
+          pattern))
+
+(define-syntax converse
+  (lambda (s)
+    "Convert over PORT: read sexps from there, match them against each
+PATTERN, and send the corresponding REPLY.  Raise to '&pattern-not-matched'
+when one of the PATTERNs is not matched."
+
+    ;; XXX: Strings that appear in PATTERNs must be in the language the
+    ;; installer is running in.  In the future, we should add support to allow
+    ;; writing English strings in PATTERNs and have the pattern matcher
+    ;; automatically translate them.
+
+    ;; Here we emulate 'pmatch' syntax on top of 'match'.  This is ridiculous
+    ;; but that's because 'pmatch' compares objects with 'eq?', making it
+    ;; pretty useless, and it doesn't support ellipses and such.
+
+    (define (quote-pattern s)
+      ;; Rewrite the pattern S from pmatch style (a ,b) to match style like
+      ;; ('a b).
+      (with-ellipsis :::
+        (syntax-case s (unquote _ ...)
+          ((unquote id) #'id)
+          (_ #'_)
+          (... #'...)
+          (id
+           (identifier? #'id)
+           #''id)
+          ((lst :::) (map quote-pattern #'(lst :::)))
+          (pattern #'pattern))))
+
+    (define (match-pattern s)
+      ;; Match one pattern without a guard.
+      (syntax-case s ()
+        ((port (pattern reply) continuation)
+         (with-syntax ((pattern (quote-pattern #'pattern)))
+           #'(let ((pat 'pattern))
+               (converse-debug pat)
+               (match (read port)
+                 (pattern
+                  (let ((data (call-with-values (lambda () reply)
+                                list)))
+                    (for-each (lambda (obj)
+                                (write obj port)
+                                (newline port))
+                              data)
+                    (force-output port)
+                    (continuation port)))
+                 (sexp
+                  (pattern-error pat sexp))))))))
+
+    (syntax-case s ()
+      ((_ port (pattern reply) rest ...)
+       (match-pattern #'(port (pattern reply)
+                              (lambda (port)
+                                (converse port rest ...)))))
+      ((_ port (pattern guard reply) rest ...)
+       #`(let ((skip? (not guard))
+               (next  (lambda (p)
+                        (converse p rest ...))))
+           (if skip?
+               (next port)
+               #,(match-pattern #'(port (pattern reply) next)))))
+      ((_ port)
+       #t))))
+
+(define* (choose-locale+keyboard port
+                                 #:key
+                                 (language "English")
+                                 (location "Hong Kong")
+                                 (timezone '("Europe" "Zagreb"))
+                                 (keyboard
+                                  '("English (US)"
+                                    "English (intl., with AltGr dead keys)")))
+  "Converse over PORT with the guided installer to choose the specified
+LANGUAGE, LOCATION, TIMEZONE, and KEYBOARD."
+  (converse port
+    ((list-selection (title "Locale language")
+                     (multiple-choices? #f)
+                     (items _))
+     language)
+    ((list-selection (title "Locale location")
+                     (multiple-choices? #f)
+                     (items _))
+     location)
+    ((menu (title "GNU Guix install")
+           (text _)
+           (items (,guided _ ...)))           ;"Guided graphical installation"
+     guided)
+    ((list-selection (title "Timezone")
+                     (multiple-choices? #f)
+                     (items _))
+     (first timezone))
+    ((list-selection (title "Timezone")
+                     (multiple-choices? #f)
+                     (items _))
+     (second timezone))
+    ((list-selection (title "Layout")
+                     (multiple-choices? #f)
+                     (items _))
+     (first keyboard))
+    ((list-selection (title "Variant")
+                     (multiple-choices? #f)
+                     (items _))
+     (second keyboard))))
+
+(define* (enter-host-name+passwords port
+                                    #:key
+                                    (host-name "guix")
+                                    (root-password "foo")
+                                    (users '(("alice" "pass1")
+                                             ("bob" "pass2")
+                                             ("charlie" "pass3"))))
+  "Converse over PORT with the guided installer to choose HOST-NAME,
+ROOT-PASSWORD, and USERS."
+  (converse port
+    ((input (title "Hostname") (text _) (default _))
+     host-name)
+    ((input (title "System administrator password") (text _) (default _))
+     root-password)
+    ((input (title "Password confirmation required") (text _) (default _))
+     root-password)
+    ((add-users)
+     (match users
+       (((names passwords) ...)
+        (map (lambda (name password)
+               `(user (name ,name) (real-name ,(string-titlecase name))
+                      (home-directory ,(string-append "/home/" name))
+                      (password ,password)))
+             names passwords))))))
+
+(define* (choose-services port
+                          #:key
+                          (desktop-environments '("GNOME"))
+                          (choose-network-service?
+                           (lambda (service)
+                             (or (string-contains service "SSH")
+                                 (string-contains service "NSS"))))
+                          (choose-network-management-tool?
+                           (lambda (service)
+                             (string-contains service "DHCP"))))
+  "Converse over PORT to choose networking services."
+  (converse port
+    ((checkbox-list (title "Desktop environment") (text _)
+                    (items _))
+     desktop-environments)
+    ((checkbox-list (title "Network service") (text _)
+                    (items ,services))
+     (filter choose-network-service? services))
+
+    ;; The "Network management" dialog shows up only when no desktop
+    ;; environments have been selected, hence the guard.
+    ((list-selection (title "Network management")
+                     (multiple-choices? #f)
+                     (items ,services))
+     (null? desktop-environments)
+     (find choose-network-management-tool? services))))
+
+(define (edit-configuration-file file)
+  "Edit FILE, an operating system configuration file generated by the
+installer, by adding a marionette service such that the installed OS is
+instrumented for further testing."
+  (define (read-expressions port)
+    (let loop ((result '()))
+      (match (read port)
+        ((? eof-object?)
+         (reverse result))
+        (exp
+         (loop (cons exp result))))))
+
+  (define (edit exp)
+    (match exp
+      (('operating-system _ ...)
+       `(marionette-operating-system ,exp
+                                     #:imported-modules
+                                     '((gnu services herd)
+                                       (guix build utils)
+                                       (guix combinators))))
+      (_
+       exp)))
+
+  (let ((content (call-with-input-file file read-expressions)))
+    (call-with-output-file file
+      (lambda (port)
+        (format port "\
+;; Operating system configuration edited for automated testing.~%~%")
+
+        (pretty-print '(use-modules (gnu tests)) port)
+        (for-each (lambda (exp)
+                    (pretty-print (edit exp) port)
+                    (newline port))
+                  content)))
+
+    #t))
+
+(define* (choose-partitioning port
+                              #:key
+                              (encrypted? #t)
+                              (passphrase "thepassphrase")
+                              (edit-configuration-file
+                               edit-configuration-file))
+  "Converse over PORT to choose the partitioning method.  When ENCRYPTED? is
+true, choose full-disk encryption with PASSPHRASE as the LUKS passphrase.
+This conversation goes past the final dialog box that shows the configuration
+file, actually starting the installation process."
+  (converse port
+    ((list-selection (title "Partitioning method")
+                     (multiple-choices? #f)
+                     (items (,not-encrypted ,encrypted _ ...)))
+     (if encrypted?
+         encrypted
+         not-encrypted))
+    ((list-selection (title "Disk") (multiple-choices? #f)
+                     (items (,disk _ ...)))
+     disk)
+
+    ;; The "Partition table" dialog pops up only if there's not already a
+    ;; partition table.
+    ((list-selection (title "Partition table")
+                     (multiple-choices? #f)
+                     (items _))
+     "gpt")
+    ((list-selection (title "Partition scheme")
+                     (multiple-choices? #f)
+                     (items (,one-partition _ ...)))
+     one-partition)
+    ((list-selection (title "Guided partitioning")
+                     (multiple-choices? #f)
+                     (items (,disk _ ...)))
+     disk)
+    ((input (title "Password required")
+            (text _) (default #f))
+     encrypted?                                   ;only when ENCRYPTED?
+     passphrase)
+    ((input (title "Password confirmation required")
+            (text _) (default #f))
+     encrypted?
+     passphrase)
+    ((confirmation (title "Format disk?") (text _))
+     #t)
+    ((info (title "Preparing partitions") _ ...)
+     (values))                                    ;nothing to return
+    ((file-dialog (title "Configuration file")
+                  (text _)
+                  (file ,configuration-file))
+     (edit-configuration-file configuration-file))))
+
+(define (conclude-installation port)
+  "Conclude the installation by checking over PORT that we get the final
+messages once the 'guix system init' process has completed."
+  (converse port
+    ((pause)                                      ;"Press Enter to continue."
+     #t)
+    ((installation-complete)                      ;congratulations!
+     (values))))
+
+;;; Local Variables:
+;;; eval: (put 'converse 'scheme-indent-function 1)
+;;; eval: (put 'with-ellipsis 'scheme-indent-function 1)
+;;; End:
diff --git a/gnu/local.mk b/gnu/local.mk
index f2289518e5..702dc59b80 100644
--- a/gnu/local.mk
+++ b/gnu/local.mk
@@ -1,5 +1,5 @@
 # GNU Guix --- Functional package management for GNU
-# Copyright © 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019 Ludovic Courtès <[email-masked]>
+# Copyright © 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Ludovic Courtès <[email-masked]>
 # Copyright © 2013, 2014, 2015, 2016, 2017, 2018, 2019 Andreas Enge <[email-masked]>
 # Copyright © 2016 Mathieu Lirzin <[email-masked]>
 # Copyright © 2013, 2014, 2015, 2016, 2017, 2018, 2019 Mark H Weaver <[email-masked]>
@@ -655,6 +655,7 @@ INSTALLER_MODULES =                             \
   %D%/installer/record.scm		        \
   %D%/installer/services.scm			\
   %D%/installer/steps.scm			\
+  %D%/installer/tests.scm			\
   %D%/installer/timezone.scm			\
   %D%/installer/user.scm			\
   %D%/installer/utils.scm			\
diff --git a/gnu/tests/install.scm b/gnu/tests/install.scm
index 335efbd468..8480c95fd6 100644
--- a/gnu/tests/install.scm
+++ b/gnu/tests/install.scm
@@ -26,10 +26,14 @@
   #:use-module (gnu system install)
   #:use-module (gnu system vm)
   #:use-module ((gnu build vm) #:select (qemu-command))
+  #:use-module (gnu packages admin)
   #:use-module (gnu packages bootloaders)
+  #:use-module (gnu packages cryptsetup)
+  #:use-module (gnu packages linux)
   #:use-module (gnu packages ocr)
   #:use-module (gnu packages package-management)
   #:use-module (gnu packages virtualization)
+  #:use-module (gnu services networking)
   #:use-module (guix store)
   #:use-module (guix monads)
   #:use-module (guix packages)
@@ -44,7 +48,9 @@
             %test-raid-root-os
             %test-encrypted-root-os
             %test-btrfs-root-os
-            %test-jfs-root-os))
+            %test-jfs-root-os
+
+            %test-gui-installed-os))
 
 ;;; Commentary:
 ;;;
@@ -179,6 +185,7 @@ reboot\n")
 (define* (run-install target-os target-os-source
                       #:key
                       (script %simple-installation-script)
+                      (gui-test #f)
                       (packages '())
                       (os (marionette-operating-system
                            (operating-system
@@ -191,6 +198,7 @@ reboot\n")
                                        packages))
                              (kernel-arguments '("console=ttyS0")))
                            #:imported-modules '((gnu services herd)
+                                                (gnu installer tests)
                                                 (guix combinators))))
                       (installation-disk-image-file-system-type "ext4")
                       (target-size (* 2200 MiB)))
@@ -256,13 +264,21 @@ packages defined in installation-os."
                                 (start 'term-tty1))
                              marionette)
 
-            (marionette-eval '(call-with-output-file "/etc/target-config.scm"
-                                (lambda (port)
-                                  (write '#$target-os-source port)))
-                             marionette)
+            (when #$(->bool script)
+              (marionette-eval '(call-with-output-file "/etc/target-config.scm"
+                                  (lambda (port)
+                                    (write '#$target-os-source port)))
+                               marionette)
+              (exit (marionette-eval '(zero? (system #$script))
+                                     marionette)))
 
-            (exit (marionette-eval '(zero? (system #$script))
-                                   marionette)))))
+            (when #$(->bool gui-test)
+              (wait-for-unix-socket "/var/guix/installer-socket"
+                                    marionette)
+              (format #t "installer socket ready~%")
+              (force-output)
+              (exit #$(and gui-test
+                           (gui-test #~marionette)))))))
 
     (gexp->derivation "installation" install)))
 
@@ -890,4 +906,174 @@ build (current-guix) and then store a couple of full system images.")
                          (command (qemu-command/writable-image image)))
       (run-basic-test %jfs-root-os command "jfs-root-os")))))
 
+
+;;;
+;;; Installation through the graphical interface.
+;;;
+
+(define %syslog-conf
+  ;; Syslog configuration that dumps to /dev/console, so we can see the
+  ;; installer's messages during the test.
+  (computed-file "syslog.conf"
+                 #~(begin
+                     (copy-file #$%default-syslog.conf #$output)
+                     (chmod #$output #o644)
+                     (let ((port (open-file #$output "a")))
+                       (display "\n*.info /dev/console\n" port)
+                       #t))))
+
+(define (operating-system-with-console-syslog os)
+  "Return OS with a syslog service that writes to /dev/console."
+  (operating-system
+    (inherit os)
+    (services (modify-services (operating-system-user-services os)
+                (syslog-service-type config
+                                     =>
+                                     (syslog-configuration
+                                      (inherit config)
+                                      (config-file %syslog-conf)))))))
+
+(define %root-password "foo")
+
+(define* (gui-test-program marionette #:key (encrypted? #f))
+  #~(let ()
+      (define (screenshot file)
+        (marionette-control (string-append "screendump " file)
+                            #$marionette))
+
+      (setvbuf (current-output-port) 'none)
+      (setvbuf (current-error-port) 'none)
+
+      (marionette-eval '(use-modules (gnu installer tests))
+                       #$marionette)
+
+      ;; Arrange so that 'converse' prints debugging output to the console.
+      (marionette-eval '(let ((console (open-output-file "/dev/console")))
+                          (setvbuf console 'none)
+                          (conversation-log-port console))
+                       #$marionette)
+
+      ;; Tell the installer to not wait for the Connman "online" status.
+      (marionette-eval '(call-with-output-file "/tmp/installer-assume-online"
+                          (const #t))
+                       #$marionette)
+
+      ;; Run 'guix system init' with '--no-grafts', to cope with the lack of
+      ;; network access.
+      (marionette-eval '(call-with-output-file
+                            "/tmp/installer-system-init-options"
+                          (lambda (port)
+                            (write '("--no-grafts" "--no-substitutes")
+                                   port)))
+                       #$marionette)
+
+      (marionette-eval '(define installer-socket
+                          (open-installer-socket))
+                       #$marionette)
+      (screenshot "installer-start.ppm")
+
+      (marionette-eval '(choose-locale+keyboard installer-socket)
+                       #$marionette)
+      (screenshot "installer-locale.ppm")
+
+      ;; Choose the host name that the "basic" test expects.
+      (marionette-eval '(enter-host-name+passwords installer-socket
+                                                   #:host-name "liberigilo"
+                                                   #:root-password
+                                                   #$%root-password
+                                                   #:users
+                                                   '(("alice" "pass1")
+                                                     ("bob" "pass2")))
+                       #$marionette)
+      (screenshot "installer-services.ppm")
+
+      (marionette-eval '(choose-services installer-socket
+                                         #:desktop-environments '()
+                                         #:choose-network-service?
+                                         (const #f))
+                       #$marionette)
+      (screenshot "installer-partitioning.ppm")
+
+      (marionette-eval '(choose-partitioning installer-socket
+                                             #:encrypted? #$encrypted?
+                                             #:passphrase #$%luks-passphrase)
+                       #$marionette)
+      (screenshot "installer-run.ppm")
+
+      (marionette-eval '(conclude-installation installer-socket)
+                       #$marionette)
+
+      (sync)
+      #t))
+
+(define %extra-packages
+  ;; Packages needed when installing with an encrypted root.
+  (list isc-dhcp
+        lvm2-static cryptsetup-static e2fsck/static
+        loadkeys-static))
+
+(define installation-os-for-gui-tests
+  ;; Operating system that contains all of %EXTRA-PACKAGES, needed for the
+  ;; target OS, as well as syslog output redirected to the console so we can
+  ;; see what the installer is up to.
+  (marionette-operating-system
+   (operating-system
+     (inherit (operating-system-with-console-syslog
+               (operating-system-add-packages
+                (operating-system-with-current-guix
+                 installation-os)
+                %extra-packages)))
+     (kernel-arguments '("console=ttyS0")))
+   #:imported-modules '((gnu services herd)
+                        (gnu installer tests)
+                        (guix combinators))))
+
+(define* (guided-installation-test name #:key encrypted?)
+  (define os
+    (operating-system
+      (inherit %minimal-os)
+      (users (append (list (user-account
+                            (name "alice")
+                            (comment "Bob's sister")
+                            (group "users")
+                            (supplementary-groups
+                             '("wheel" "audio" "video")))
+                           (user-account
+                            (name "bob")
+                            (comment "Alice's brother")
+                            (group "users")
+                            (supplementary-groups
+                             '("wheel" "audio" "video"))))
+                     %base-user-accounts))
+      (swap-devices '("/dev/vdb2"))
+      (services (cons (service dhcp-client-service-type)
+                      (operating-system-user-services %minimal-os)))))
+
+  (system-test
+   (name name)
+   (description
+    "Install an OS using the graphical installer and test it.")
+   (value
+    (mlet* %store-monad ((image   (run-install os '(this is unused)
+                                               #:script #f
+                                               #:os installation-os-for-gui-tests
+                                               #:gui-test
+                                               (lambda (marionette)
+                                                 (gui-test-program
+                                                  marionette
+                                                  #:encrypted? encrypted?))))
+                         (command (qemu-command/writable-image image)))
+      (run-basic-test os command name
+                      #:initialization (and encrypted? enter-luks-passphrase)
+                      #:root-password %root-password)))))
+
+(define %test-gui-installed-os
+  (guided-installation-test "gui-installed-os"
+                            #:encrypted? #f))
+
+;; (define %test-gui-installed-os
+;;   ;; FIXME: Fails due to <https://bugs.gnu.org/39712>.
+;;   (guided-installation-test "gui-installed-os-encrypted"
+;;                             #:encrypted? #t))
+
 ;;; install.scm ends here
-- 
2.25.1