#!/usr/bin/python3 -cimport os, sys; os.execv(os.path.dirname(sys.argv[1]) + "/common/pywrap", sys.argv)

# This file is part of Cockpit.
#
# Copyright (C) 2021 Red Hat, Inc.
#
# Cockpit is free software; you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation; either version 2.1 of the License, or
# (at your option) any later version.
#
# Cockpit 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
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with Cockpit; If not, see <http://www.gnu.org/licenses/>.

import os
import time
import xml.etree.ElementTree as ET

import machineslib
import testlib


@testlib.nondestructive
class TestMachinesConsoles(machineslib.VirtualMachinesCase):

    def waitDownloadFile(self, filename: str, expected_size: int | None = None, content: str | None = None) -> None:
        filepath = self.browser.driver.download_dir / filename
        testlib.wait(filepath.exists)

        if expected_size is not None:
            testlib.wait(lambda: filepath.stat().st_size == expected_size)

        if content is not None:
            self.assertEqual(filepath.read_text(), content)

        os.unlink(filepath)

    def waitViewerDownload(self, kind, host, port=5900):
        self.browser.allow_download()
        self.browser.click('.vm-console-footer button:contains("Launch viewer")')
        content = f"""[virt-viewer]
type={kind}
host={host}
port={port}
delete-this-file=1
fullscreen=0
"""
        self.waitDownloadFile("console.vv", content=content, expected_size=len(content))

    @testlib.skipImage('SPICE not supported on RHEL', "rhel-*", "centos-*")
    def testExternalConsole(self):
        b = self.browser

        self.createVm("subVmTest1", graphics="spice")

        self.login_and_go("/machines")
        self.waitPageInit()
        self.waitVmRow("subVmTest1")

        b.wait_in_text("#vm-subVmTest1-system-state", "Running")  # running or paused
        self.goToVmPage("subVmTest1")

        # VNC is not defined for this VM, so we get the empty SPICE state
        b.wait_in_text(".consoles-card", "This machine has a SPICE graphical console")
        b.click(".vm-console-footer .pf-v6-c-button.pf-m-link")
        b.wait_in_text(".ct-remote-viewer-popover", f"spice://{b.address}:5900")

        # Pixel testing the popover is slightly tricky. We need to
        # avoid its round corners by only looking at the body, and it
        # moves around asynchronously when changing layout.

        def reset_popover():
            b.click(".vm-console-footer .pf-v6-c-button.pf-m-link")
            b.wait_not_present(".ct-remote-viewer-popover")
            b.click(".vm-console-footer .pf-v6-c-button.pf-m-link")
            b.wait_visible(".ct-remote-viewer-popover")

        b.assert_pixels("#popover-remote-viewer-info-body", "popover",
                        layout_change_hook=reset_popover)

        b.click(".vm-console-footer .pf-v6-c-button.pf-m-link")
        b.wait_not_present(".ct-remote-viewer-popover")

        self.waitViewerDownload("spice", b.address)

    def testInlineConsole(self, urlroot=""):
        b = self.browser

        args = self.createVm("subVmTest1", "vnc")

        if urlroot != "":
            self.machine.write("/etc/cockpit/cockpit.conf", f"[WebService]\nUrlRoot={urlroot}")

        self.login_and_go("/machines", urlroot=urlroot)
        self.waitPageInit()
        self.waitVmRow("subVmTest1")

        b.wait_in_text("#vm-subVmTest1-system-state", "Running")  # running or paused
        self.goToVmPage("subVmTest1")

        # since VNC is defined for this VM, the view for "In-Browser Viewer" is rendered by default
        b.wait_visible(".vm-console-vnc canvas")

        # make sure the log file is full - then empty it and reboot the VM - the log file should fill up again
        self.waitGuestBooted(args['logfile'])

        self.machine.execute(f"echo '' > {args['logfile']}")
        b.click("#vnc-actions")
        b.click("#ctrl-alt-Delete")
        self.waitLogFile(args['logfile'], "reboot: Restarting system")

    def testInlineConsoleWithUrlRoot(self, urlroot=""):
        self.testInlineConsole(urlroot="/webcon")

    def testSerialConsole(self):
        b = self.browser
        m = self.machine
        name = "vmWithSerialConsole"

        # Restrict number of remembered console card states to
        # two. This way we can test the automatic closing of channels
        # when more than two machines are visited.

        override = "/etc/cockpit/machines.override.json"
        if m.image == "ubuntu-2204":
            override = "/usr/share/cockpit/machines/override.json"
        self.write_file(override,
                        """{ "config": { "MaxConsoleCardStates": 2 } }\n""")

        self.createVm(name, graphics='vnc', ptyconsole=True)

        self.login_and_go("/machines")
        self.waitPageInit()
        self.waitVmRow(name)

        self.goToVmPage(name)
        b.wait_in_text(f"#vm-{name}-system-state", "Running")

        b.click(".consoles-card .pf-v6-c-toggle-group button:contains(Serial)")

        # In case the OS already finished booting, press Enter into the console to re-trigger the login prompt
        # Sometimes, pressing Enter one time doesn't take effect, so loop to press Enter to make sure
        # the console has accepted it.
        for _ in range(0, 60):
            b.focus(f"#{name}-terminal .xterm-accessibility-tree")
            b.key("Enter")
            if "Welcome to Alpine Linux" in b.text(f"#{name}-terminal .xterm-accessibility-tree"):
                break
            time.sleep(1)

        def wait_for_alpine_greeting():
            testlib.wait(lambda: "Welcome to Alpine Linux" in b.text(f"#{name}-terminal .xterm-accessibility-tree"))

        # Make sure the content of console is expected

        wait_for_alpine_greeting()

        # Make sure the console keeps their content when navigating
        # around

        b.click(".consoles-card button:contains(Expand)")
        b.wait_visible(".consoles-page-expanded")
        wait_for_alpine_greeting()

        b.click(".consoles-card button:contains(Compress)")
        b.wait_visible(".consoles-card")
        wait_for_alpine_greeting()

        b.go("/machines#/")
        self.waitPageInit()
        self.waitVmRow(name)
        self.goToVmPage(name)
        b.wait_visible(f"#{name}-terminal")
        wait_for_alpine_greeting()

        # Test re-connecting

        b.click(".consoles-card button:contains(Disconnect)")
        b.wait_in_text(".consoles-card", "Disconnected")

        b.click('.consoles-card button:contains("Connect")')
        b.wait_in_text(f"#{name}-terminal .xterm-accessibility-tree > div:nth-child(1)",
                       f"Connected to domain '{name}'")

        def channel_is_open_predicate(tag):
            pattern = f"virsh -c [q]emu:///system console vmWithSerialConsole {tag}"
            return lambda: m.execute(f"(ps aux | grep '{pattern}') || true") != ""

        # Add a second serial console
        m.execute("""
            virsh destroy vmWithSerialConsole;
            virt-xml --add-device vmWithSerialConsole --console pty,target_type=virtio;
            virsh start vmWithSerialConsole""")
        b.click('.consoles-card .pf-v6-c-toggle-group button:contains("Serial (serial0)")')
        b.wait(channel_is_open_predicate("serial0"))
        b.click('.consoles-card .pf-v6-c-toggle-group button:contains("Serial (console1)")')
        b.wait(channel_is_open_predicate("console1"))

        # Add multiple serial consoles
        # Remove all console firstly
        m.execute("virsh destroy vmWithSerialConsole")
        m.execute("virt-xml --remove-device vmWithSerialConsole --console all")
        # Add console1 ~ console5
        m.execute("""
                  for i in {1..5}; do
                    virt-xml vmWithSerialConsole --add-device --console pty,target.type=virtio;
                  done
                  virsh start vmWithSerialConsole
                  """)

        for i in range(0, 6):
            tag = "serial" if i == 0 else "console"
            b.click(f'.consoles-card .pf-v6-c-toggle-group button:contains("Serial ({tag}{i})")')
            b.wait(channel_is_open_predicate(f"{tag}{i}"))

        def count_console_channels():
            return int(m.execute("ps aux | grep 'virsh -c [q]emu:///system console vmWithSerialConsole' | wc -l"))

        # Now we should have 6 channels open
        b.wait(lambda: count_console_channels() == 6)

        # Create two more machines navigate to them. Because we have
        # set MaxConsoleCardStates to 2 at the start of the test, this
        # will close all channels to the first machine.

        def create_and_visit_machine(name):
            self.createVm(name, graphics='vnc', ptyconsole=True, running=False)
            b.go("/machines#/")
            self.waitPageInit()
            self.waitVmRow(name)
            self.goToVmPage(name)

        create_and_visit_machine(name + "2")

        # Channels for the first machine should still be open, but no
        # new ones should have been opened.
        b.wait(lambda: count_console_channels() == 6)

        create_and_visit_machine(name + "3")

        # Now no channels should be open anymore.
        b.wait(lambda: count_console_channels() == 0)

        # disconnecting the serial console closes the pty channel
        self.allow_journal_messages("connection unexpectedly closed by peer",
                                    ".*Connection reset by peer")
        self.allow_browser_errors("Disconnection timed out.",
                                  "Failed when connecting: Connection closed")
        self.allow_journal_messages(".* couldn't shutdown fd: Transport endpoint is not connected")
        self.allow_journal_messages("127.0.0.1:5900: couldn't read: Connection refused")

    def testBasic(self):
        b = self.browser
        name = "subVmTest1"

        self.createVm(name, graphics="vnc", ptyconsole=True)

        self.login_and_go("/machines")
        self.waitPageInit()

        self.waitVmRow(name)
        self.goToVmPage(name)
        b.wait_in_text(f"#vm-{name}-system-state", "Running")

        # test switching console from serial to graphical
        b.wait_visible(".consoles-card")
        b.wait_visible(".vm-console-vnc canvas")

        b.click(".consoles-card .pf-v6-c-toggle-group button:contains(Serial)")
        b.wait_not_present(".vm-console-vnc canvas")
        b.wait_visible(f"#{name}-terminal")

        # Go back to Vnc console
        b.click(".consoles-card .pf-v6-c-toggle-group button:contains(Graphical)")
        b.wait_not_present(f"#{name}-terminal")
        b.wait_visible(".vm-console-vnc canvas")

        # Test message is present if VM is not running
        self.performAction(name, "forceOff", checkExpectedState=False)

        b.wait_in_text(".consoles-card", "Start the virtual machine")

        self.allow_journal_messages("connection unexpectedly closed by peer")
        self.allow_browser_errors("Disconnection timed out.",
                                  "Failed when connecting: Connection closed")

    @testlib.skipBeiboot("multi-host config not supported in beiboot scenario")
    def testMultiHostExternalConsole(self):
        b = self.browser

        my_ip = "172.27.0.15"
        name = "subVmTest1"

        self.setup_ssh_auth()
        self.machine.execute(f"ssh-keyscan {my_ip} > /etc/ssh/ssh_known_hosts")
        self.enable_multihost(self.machine)
        self.machine.write("/etc/cockpit/cockpit.conf", "[Session]\nWarnBeforeConnecting=false\n", append=True)

        self.createVm(name, graphics="vnc")

        self.machine.start_cockpit()

        # Direct login via SSH

        b.open(f"/={my_ip}/machines")
        b.set_val('#login-user-input', "admin")
        b.set_val('#login-password-input', "foobar")
        b.click("#login-button")

        self.waitPageInit()
        b.become_superuser()
        b.enter_page("/machines")
        self.waitVmRow(name)

        self.goToVmPage(name)
        b.wait_in_text(f"#vm-{name}-system-state", "Running")

        b.click(".consoles-card .pf-v6-c-toggle-group button:contains(Graphical)")
        b.click(".vm-console-footer .pf-v6-c-button.pf-m-link")
        b.wait_in_text(".ct-remote-viewer-popover", f"vnc://{my_ip}:5900")
        b.click(".vm-console-footer .pf-v6-c-button.pf-m-link")
        b.wait_not_present(".ct-remote-viewer-popover")

        self.waitViewerDownload("vnc", my_ip)

        # Login from Shell via SSH

        b.logout()
        b.login_and_go("/system")

        host = f"admin@{my_ip}"

        b.add_machine(host, password=None, known_host=True, expect_warning=False)

        b.go(f"/@{host}/machines")
        b.enter_page("/machines", host=host)
        self.waitPageInit()
        b.become_superuser()
        b.enter_page("/machines", host=host)
        self.waitVmRow(name)

        self.goToVmPage(name)
        b.wait_in_text(f"#vm-{name}-system-state", "Running")

        b.click(".consoles-card .pf-v6-c-toggle-group button:contains(Graphical)")
        b.click(".vm-console-footer .pf-v6-c-button.pf-m-link")
        b.wait_in_text(".ct-remote-viewer-popover", f"vnc://{my_ip}:5900")
        b.click(".vm-console-footer .pf-v6-c-button.pf-m-link")
        b.wait_not_present(".ct-remote-viewer-popover")

        self.waitViewerDownload("vnc", my_ip)

    def testAddEditVNC(self):
        b = self.browser

        # Create a machine without any consoles

        name = "subVmTest1"
        self.createVm(name)

        self.login_and_go("/machines")
        self.waitPageInit()
        self.waitVmRow(name)
        self.goToVmPage(name)

        def assert_state(text):
            b.wait_in_text(f"#vm-{name}-consoles .pf-v6-c-empty-state", text)

        def assert_not_state(text):
            b.wait_not_in_text(f"#vm-{name}-consoles .pf-v6-c-empty-state", text)

        # "Console" card shows empty state

        assert_state("Graphical console support not enabled")
        b.assert_pixels(".consoles-card", "no-vnc")

        b.click(".consoles-card .pf-v6-c-empty-state button:contains(Add VNC)")

        assert_state("Restart this virtual machine to access its graphical console")
        b.wait_visible(f"#vm-{name}-needs-shutdown")
        b.assert_pixels(".consoles-card", "needs-shutdown")

        root = ET.fromstring(self.machine.execute(f"virsh dumpxml --inactive --security-info {name}"))
        graphics = root.find('devices').findall('graphics')
        self.assertEqual(len(graphics), 1)
        self.assertEqual(graphics[0].get('port'), "-1")
        self.assertEqual(graphics[0].get('passwd'), None)

        b.click(".vm-console-footer .pf-v6-c-button.pf-m-link")
        b.click(".ct-remote-viewer-popover button:contains('Edit VNC settings')")
        b.wait_not_present(".ct-remote-viewer-popover")
        b.wait_visible("#vnc-edit-dialog")
        b.assert_pixels("#vnc-edit-dialog", "add")
        b.set_input_text("#vnc-edit-port", "5000")
        b.wait_visible("#vnc-edit-dialog .pf-m-error:contains('Port must be 5900 or larger.')")
        b.set_input_text("#vnc-edit-port", "Hamburg")
        b.wait_visible("#vnc-edit-dialog .pf-m-error:contains('Port must be a number.')")
        b.set_input_text("#vnc-edit-port", "100000000000")  # for testing failed libvirt calls
        b.set_input_text("#vnc-edit-password", "foobarfoobar")
        b.wait_attr("#vnc-edit-password", "type", "password")
        b.click("#vnc-edit-dialog .pf-v6-c-input-group button")
        b.wait_attr("#vnc-edit-password", "type", "text")
        b.wait_visible("#vnc-edit-dialog .pf-m-error:contains('Password must be at most 8 characters.')")
        b.set_input_text("#vnc-edit-password", "foobar")
        b.click("#vnc-edit-save")
        b.wait_in_text("#vnc-edit-dialog", "VNC settings could not be saved")
        # Exact error messages vary, but they all contain the wrong number
        b.wait_in_text("#vnc-edit-dialog", "100000000000")
        b.set_input_text("#vnc-edit-port", "5901")
        b.click("#vnc-edit-save")
        b.wait_not_present("#vnc-edit-dialog")

        root = ET.fromstring(self.machine.execute(f"virsh dumpxml --inactive --security-info {name}"))
        graphics = root.find('devices').findall('graphics')
        self.assertEqual(len(graphics), 1)
        self.assertEqual(graphics[0].get('port'), "5901")
        self.assertEqual(graphics[0].get('passwd'), "foobar")

        # Shut down machine

        self.performAction("subVmTest1", "forceOff")
        assert_state("Start the virtual machine to access the console")
        b.assert_pixels(".consoles-card", "shutoff")

        # Remove VNC from the outside and add it back while the machine is off

        self.machine.execute(f"virt-xml --remove-device --graphics vnc {name}")

        assert_state("Graphical console support not enabled")

        b.click(".consoles-card .pf-v6-c-empty-state button:contains(Add VNC)")
        assert_not_state("Graphical console support not enabled")
        assert_state("Start the virtual machine to access the console")

    def testAddSerial(self):
        b = self.browser
        m = self.machine

        # Create a machine without any serial consoles

        name = "subVmTest1"
        self.createVm(name, running=False, ptyconsole=True)
        m.execute(f"virt-xml --remove-device {name} --serial all")

        self.login_and_go("/machines")
        self.waitPageInit()
        self.waitVmRow(name)
        self.goToVmPage(name)

        def assert_state(text):
            b.wait_in_text(f"#vm-{name}-consoles .pf-v6-c-empty-state", text)

        # Switch to Serial console

        b.click('.consoles-card .pf-v6-c-toggle-group button:contains("Serial")')

        # "Console" card shows empty state

        assert_state("Serial console support not enabled")
        b.assert_pixels(".consoles-card", "no-serial")

        b.click(".consoles-card .pf-v6-c-empty-state button:contains(Add serial console)")

        assert_state("Start the virtual machine to access the console")

        self.performAction(name, "run")
        testlib.wait(lambda: ("Welcome to Alpine Linux" in
                              b.text(f"#{name}-terminal .xterm-accessibility-tree")),
                     delay=5)

        # Shutdown, remove, start, and add it while VM is running
        self.performAction(name, "forceOff")
        assert_state("Start the virtual machine to access the console")
        m.execute(f"virt-xml --remove-device {name} --serial all")
        self.performAction(name, "run")
        b.wait_in_text(f"#vm-{name}-system-state", "Running")

        b.click('.consoles-card .pf-v6-c-toggle-group button:contains("Serial")')
        assert_state("Serial console support not enabled")
        b.click(".consoles-card .pf-v6-c-empty-state button:contains(Add serial console)")

        assert_state("Restart this virtual machine to access its serial console")
        b.wait_visible(f"#vm-{name}-needs-shutdown")
        b.assert_pixels(".consoles-card", "needs-shutdown")

        self.performAction("subVmTest1", "forceOff")
        assert_state("Start the virtual machine to access the console")
        self.performAction(name, "run")
        testlib.wait(lambda: ("Welcome to Alpine Linux" in
                              b.text(f"#{name}-terminal .xterm-accessibility-tree")),
                     delay=5)

    def testExpandedConsole(self):
        b = self.browser

        # Create a machine without any serial consoles

        name = "subVmTest1"
        self.createVm(name, graphics="vnc", ptyconsole=True)

        self.login_and_go("/machines")
        self.waitPageInit()
        self.waitVmRow(name)
        self.goToVmPage(name)

        b.click(".consoles-card button:contains(Expand)")
        b.wait_visible(".consoles-page-expanded")
        b.assert_pixels(".consoles-card", "expanded", ignore=[".vm-console-vnc"], chrome_hack_double_shots=True)

        # Disconnect VNC, switch to Serial

        b.click(".consoles-card button:contains(Disconnect)")
        b.wait_in_text(".consoles-card", "Disconnected")
        b.click('.consoles-card .pf-v6-c-toggle-group button:contains("Serial")')
        b.wait_visible(".consoles-card .vm-terminal")

        # Compress, Serial should still be selected and VNC should stay
        # disconnected

        b.click(".consoles-card button:contains(Compress)")
        b.wait_visible("#vm-details")
        b.wait_visible(".consoles-card .vm-terminal")
        b.click('.consoles-card .pf-v6-c-toggle-group button:contains("Graphical")')
        b.wait_in_text(".consoles-card", "Disconnected")

        # Connect VNC
        b.click(".consoles-card button:contains(Connect)")
        b.wait_visible(".vm-console-vnc canvas")

    @testlib.skipImage('SPICE not supported on RHEL', "rhel-*", "centos-*")
    def testSpice(self):
        b = self.browser

        # Create a machine with a spice console, and no vnc.

        name = "subVmTest1"
        self.createVm(name, graphics="spice")

        self.login_and_go("/machines")
        self.waitPageInit()
        self.waitVmRow(name)
        self.goToVmPage(name)

        def assert_state(text):
            b.wait_in_text(f"#vm-{name}-consoles .pf-v6-c-empty-state", text)

        assert_state("This machine has a SPICE graphical console that can not be shown here.")

        b.click(".consoles-card .pf-v6-c-empty-state button:contains(Replace with VNC)")
        b.wait_text(".pf-v6-c-modal-box__title-text", f"Replace SPICE devices in VM {name}")
        b.click("#replace-spice-dialog-confirm")
        b.wait_not_present(".pf-v6-c-modal-box")

        assert_state("Restart this virtual machine to access its graphical consol")

        self.performAction(name, "forceOff")
        assert_state("Start the virtual machine to access the console")

        self.performAction(name, "run")
        b.wait_visible(".vm-console-vnc canvas")

    @testlib.skipImage('No virtio video', "arch", "opensuse-*", "rhel-8-*")
    def testScaleResize(self):
        b = self.browser
        m = self.machine

        name = "subVmTest1"
        self.createVm(name, graphics="vnc")

        self.login_and_go("/machines")
        self.waitPageInit()
        self.waitVmRow(name)
        self.goToVmPage(name)

        # The "width" and "height" HTML attributes of the NoVNC canvas
        # always reflect the size of the remote framebuffer.  If we
        # request a remote resize and it is rejected, these attributes
        # will not change.  Thus, we don't actually have to log into
        # the guest and do stuff like looking at
        # /sys/class/drm/<foo>/modes to see how big the actual
        # framebuffer is.

        # Local scaling is done by NoVNC by setting the width and
        # height style properties of the canvas element.  The browser
        # will then scale the content of the canvas to make it as
        # large as requested by its style.

        def wait_widths(func):
            def pred():
                remote = int(b.attr(".vm-console-vnc canvas", "width"))
                local = b.eval_js('document.querySelector(".vm-console-vnc canvas").offsetWidth')
                ui = b.eval_js('document.querySelector(".vm-console-vnc").offsetWidth')
                print("Widths: remote", remote, "local", local, "ui", ui)
                return remote > 0 and local > 0 and ui > 0 and func(remote, local, ui)
            testlib.wait(pred)

        b.click(".consoles-card button:contains(Expand)")
        b.wait_visible(".consoles-page-expanded")

        # We want a small browser so that the remote framebuffer is
        # initially wider than the console UI.

        b.set_layout("medium")

        # Initially we are in "No scaling or resizing" mode. The
        # remote width is determined by the guest OS somehow, the
        # local width is the same as remote and the UI is smaller than
        # that.

        initial_remote = 1280
        if m.image in ["ubuntu-2204"]:
            initial_remote = 1024

        b.wait_text("#vm-console-vnc-scaling", "No scaling or resizing")
        wait_widths(lambda remote, local, ui: remote == initial_remote and local == remote and local > ui)

        # When switching to "Local scaling", the remote width stays
        # unchanged, but the local width is now smaller than the ui.

        b.select_PF("#vm-console-vnc-scaling", "Local scaling")
        wait_widths(lambda remote, local, ui: remote == initial_remote and local <= ui)

        # When switching to "Remote resizing", the remote and local
        # width become equal to the UI.

        b.select_PF("#vm-console-vnc-scaling", "Remote resizing")
        wait_widths(lambda remote, local, ui: remote == ui and local == ui)

        # When collapsing and expanding again, nothing should have
        # changed.

        b.click(".consoles-card button:contains(Compress)")
        b.wait_visible("#vm-details")
        b.wait_not_present("#vm-console-vnc-scaling")
        b.click(".consoles-card button:contains(Expand)")
        b.wait_visible(".consoles-page-expanded")

        b.wait_text("#vm-console-vnc-scaling", "Remote resizing")
        wait_widths(lambda remote, local, ui: remote == ui and local == ui)

    def testDetached(self):
        b = self.browser
        m = self.machine

        name = "subVmTest1"
        self.createVm(name, graphics="vnc")

        self.login_and_go("/machines")
        self.waitPageInit()
        self.waitVmRow(name)
        self.goToVmPage(name)

        b2 = self.new_browser()
        cookie = b.cookie("cockpit")

        initial_remote = 1280
        if m.image in ["ubuntu-2204", "rhel-8-10"]:
            initial_remote = 1024

        with b.wait_timeout(60):
            b.wait_attr(".vm-console-vnc canvas", "width", str(initial_remote))

        href = b.attr(".consoles-card a:contains(Detach)", "href")
        b.click(".consoles-card a:contains(Detach)")

        # HACK - We can't control the new window that was opened by
        # the click on "Detach" because of limitations in our
        # testlib. So we open a second one explicitly with a new
        # Browser instance. But there is a debug message that will
        # tell us what the click on "Detach" has done.

        def is_detach_message(msg):
            return (msg.startswith("> debug: Detaching VNC: ") and
                    href in msg and
                    f"width={initial_remote}" in msg)

        testlib.wait(lambda: any(is_detach_message(msg) for msg in b.get_js_log()))

        b2.open("/cockpit/@localhost/machines/index.html" + href, cookie=cookie)
        with b2.wait_timeout(60):
            b2.wait_attr(".vm-console-vnc canvas", "width", str(initial_remote))

        # Logging out should close both of the detached windows, the
        # one in "b" from the click on "Detach", and the explicitly
        # opened one in "b2".  We detect this by looking for another
        # debug message.

        b.logout()
        testlib.wait(lambda: "> debug: Closing detached VNC" in b.get_js_log())
        testlib.wait(lambda: "> debug: Closing detached VNC" in b2.get_js_log())

        # cleanup expects self.browser to be logged in.
        self.login_and_go("/machines")

    def testGlobalVNCPassword(self):
        b = self.browser
        m = self.machine

        # Configure a global password

        self.write_file("/etc/libvirt/qemu.conf", '\nvnc_password = "barfoo"\n', append=True)
        m.execute(f"systemctl restart {self.getLibvirtServiceName()}")

        name = "subVmTest1"
        self.createVm(name, graphics="vnc")

        self.login_and_go("/machines")
        self.waitPageInit()
        self.waitVmRow(name)
        self.goToVmPage(name)

        # VNC viewer should be connected and should have a password
        b.wait_visible(".vm-console-vnc canvas")
        self.assertIn("password=on", m.execute(f"grep ^-vnc /var/log/libvirt/qemu/{name}.log"))


if __name__ == '__main__':
    testlib.test_main()
