#!/usr/bin/python3 -cimport os, sys; os.execv(os.path.dirname(sys.argv[1]) + "/common/pywrap", sys.argv)
# Run this with --help to see available options for tracing and debugging
# See https://github.com/cockpit-project/cockpit/blob/main/test/common/testlib.py
# "class Browser" and "class MachineCase" for the available API.

import datetime
import os
import re
import shlex
import subprocess
import tempfile
from pathlib import Path

# import Cockpit's machinery for test VMs and its browser test API
import testlib


# Nondestructive tests all run in the same running VM. This allows them to run
# in Packit, Fedora, and RHEL dist-git gating They must not permanently change
# any file or configuration on the system in a way that influences other tests.
@testlib.nondestructive
class TestFiles(testlib.MachineCase):
    @classmethod
    def setUpClass(_cls) -> None:
        # Run browser in UTC as the displayed time is in the browser's timezone
        os.environ['TZ'] = 'UTC'

    def setUp(self) -> None:  # type: ignore[override]
        super().setUp()
        self.restore_dir("/home/admin")

    def enter_files(self) -> None:
        self.login_and_go("/files")
        self.browser.wait_text(".pf-v5-c-empty-state__title-text", "Directory is empty")

    def stat(self, fmt: str, path: str) -> str:
        return self.machine.execute(['stat', f'--format={fmt}', path]).strip()

    def assert_stat(self, fmt: str, path: str, expected: str) -> None:
        self.assertEqual(self.stat(fmt, path), expected)

    def assert_owner(self, path: str, owner: str) -> None:
        self.assert_stat('%U:%G', path, owner)

    def assert_last_breadcrumb(self, directory: str) -> None:
        if directory == '/':
            self.browser.wait_visible("a.pf-v5-c-breadcrumb__link.pf-m-current svg.breadcrumb-hdd-icon")
        else:
            self.browser.wait_text(".pf-v5-c-page__main-breadcrumb a.pf-m-current", directory)

    def delete_item(self, filetype: str, filename: str, *, expect_success: bool = True) -> None:
        b = self.browser
        b.click(f"[data-item='{filename}']")
        b.click("#dropdown-menu")
        b.click("#delete-item")
        b.wait_in_text("h1.pf-v5-c-modal-box__title", f"Delete {filetype} {filename}?")
        b.click("button.pf-m-danger")
        if expect_success:
            b.wait_not_present(".pf-v5-c-modal-box")
            b.wait_not_present(f"[data-item='{filename}']")

    def create_directory(self, filename: str, owner: str | None = None) -> None:
        b = self.browser
        b.click("#dropdown-menu")
        b.click("#create-item")
        b.set_input_text("#create-directory-input", f"{filename}")
        if owner:
            b.select_from_dropdown("#create-directory-owner", owner)
        b.click("button.pf-m-primary")

    def wait_modal_inline_alert(self, msg: str) -> None:
        b = self.browser
        b.wait_in_text("h4.pf-v5-c-alert__title", msg)

    def rename_item(self, itemname: str, newname: str) -> None:
        b = self.browser
        b.click(f"[data-item='{itemname}']")
        b.click("#dropdown-menu")
        b.click("#rename-item")
        b.wait_in_text("h1.pf-v5-c-modal-box__title", "Rename")
        b.set_input_text("#rename-item-input", f"{newname}")
        b.click("button.pf-m-primary")

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

        # Big downloads can take a while
        testlib.wait(filepath.exists, tries=120)
        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)

    def file_action_modal(self, filename: str, action: str) -> None:
        b = self.browser
        b.click(f"[data-item='{filename}']")
        b.click("#dropdown-menu")
        b.click(f"#dropdown-menu + .pf-v5-c-menu button:contains('{action}')")

    def testBasic(self) -> None:
        b = self.browser
        m = self.machine

        self.enter_files()

        b.allow_download()
        # expected heading
        b.wait_text("#files-card-header", "Directories & files")
        files_cnt = m.execute("ls -A /home/admin | wc -l").strip()
        hidden_files_cnt = m.execute(r'ls -A /home/admin | grep "^\." | wc -l').strip()
        b.wait_text("#sidebar-card-header", f"admin{files_cnt} items ({hidden_files_cnt} hidden)")

        # empty directory with one hidden file
        m.execute("runuser -u admin mkdir /home/admin/empty")
        m.execute("runuser -u admin touch /home/admin/empty/.hiddenfile")
        b.mouse("[data-item='empty']", "dblclick")
        b.wait_text(".pf-v5-c-empty-state__title-text", "Directory is empty")
        b.wait_in_text(".pf-v5-c-empty-state__body", "1 item is hidden")
        b.assert_pixels(".pf-v5-c-page__main", "empty-folder-view")

        # Clicking `show hidden items` shows it
        b.click(".pf-v5-c-empty-state button:contains('Show hidden items')")
        b.wait_visible("[data-item='.hiddenfile']")

        # Reset global setting
        b.select_PF("#sort-menu-toggle", "Hide hidden items")
        b.wait_not_present("[data-item='.hiddenfile']")
        b.wait_text(".pf-v5-c-empty-state__title-text", "Directory is empty")

        # removing the empty file shows empty directory again
        m.execute("rm /home/admin/empty/.hiddenfile")
        b.wait_text(".pf-v5-c-empty-state__title-text", "Directory is empty")
        b.wait_not_in_text(".pf-v5-c-empty-state", "1 item is hidden")

        b.click("li[data-location='/home/admin'] a")  # go back to home dir
        m.execute("rm -r /home/admin/empty")
        b.wait_not_present("[data-item='empty']")

        # new files are auto-detected
        m.execute("touch --date @1641038400 /home/admin/newfile")
        b.wait_visible("[data-item='newfile']")

        # new directories are auto-detected
        m.execute("mkdir /home/admin/newdir; touch --date @1641038400 /home/admin/newfile /home/admin/newdir")
        b.wait_visible("[data-item='newdir']")

        # hidden files are not displayed
        m.execute("touch /home/admin/.hiddenfile /home/admin/not-hidden")
        b.wait_visible("[data-item='not-hidden']")
        b.wait_not_present("[data-item='.hiddenfile']")

        # Symlink to `.` and `..` work and get shown as directories
        m.execute("ln -sf . /home/admin/dot")
        b.wait_visible("[data-item='dot'].symlink.folder")
        m.execute("ln -sf .. /home/admin/dotdot")
        b.wait_visible("[data-item='dotdot'].symlink.folder")

        b.assert_pixels("#files-card-parent", "folder-view")

        # file sidebar information
        b.click("[data-item='newfile']")
        b.wait_text("#sidebar-card-header", "newfileempty")
        b.wait_text("#description-list-owner dd", "root")
        b.wait_text("#description-list-group dd", "root")
        b.wait_text("#description-list-size dd", "0 B")
        b.wait_text("#description-list-last-modified dd", "Jan 1, 2022, 12:00 PM")

        # saving a file updates sidebar info
        # FIXME: Size does not update
        m.execute("head -c 7 /dev/zero > /home/admin/newfile")
        b.wait_text("#description-list-size dd", "7 B")
        b.wait_not_in_text("#description-list-last-modified ", "Jan 1, 2022, 12:00 PM")
        m.execute("touch --date @1641038400 /home/admin/newfile")
        b.wait_in_text("#description-list-last-modified ", "Jan 1, 2022, 12:00 PM")

        # clicking empty space resets sidebar
        # Firefox/BiDi clicks in the middle by default, which catches/selects a file;
        # so explicitly click in the corner
        b.mouse("#folder-view tbody", "click", x=1, y=1)
        b.wait_in_text("#sidebar-card-header", "admin")

        # folder information doesn't contain size
        b.click("[data-item='newdir']")
        b.wait_text("#sidebar-card-header", "newdirdirectory")
        b.wait_text("#description-list-owner dd", "root")
        b.wait_text("#description-list-group dd", "root")
        b.wait_not_present("#description-list-size")
        b.wait_text("#description-list-last-modified dd", "Jan 1, 2022, 12:00 PM")

        # filtering works
        self.browser.wait_js_cond("ph_count('#folder-view tbody tr') > 1")
        b.set_input_text("input[placeholder='Filter directory']", "newfile")
        self.browser.wait_js_cond("ph_count('#folder-view tbody tr') == 1")

        # no results when filtering
        b.set_input_text("input[placeholder='Filter directory']", "absolutelynothing")
        self.browser.wait_js_cond("ph_count('#folder-view tbody tr') == 0")
        b.wait_text(".pf-v5-c-empty-state__title-text", "No matching results")

        # clear using empty-state
        b.click(".pf-v5-c-empty-state button:contains('Clear filter')")
        b.wait_text("input[placeholder='Filter directory']", "")
        self.browser.wait_js_cond("ph_count('#folder-view tbody tr') != 0")

        # clear using input button
        b.set_input_text("input[placeholder='Filter directory']", "absolutelynothing")
        self.browser.wait_js_cond("ph_count('#folder-view tbody tr') == 0")
        b.wait_text(".pf-v5-c-empty-state__title-text", "No matching results")

        b.click("input[aria-label='Search input']")
        b.wait_text("input[placeholder='Filter directory']", "")

        # filtering persists when changing view
        b.click("button[aria-label='Display as a list']")
        b.set_input_text("input[placeholder='Filter directory']", "newfile")
        self.browser.wait_js_cond("ph_count('#folder-view tbody tr') == 1")
        b.set_input_text("input[placeholder='Filter directory']", "")
        self.browser.wait_js_cond("ph_count('#folder-view tbody tr') > 1")

        # Big file downloads fine, runs in this test as running two downloads
        # in the same test causes issues with Chromium. This takes ~ 40 seconds.
        m.execute("truncate -s 1500M /home/admin/test.iso")
        b.wait_visible("[data-item='test.iso']")
        b.mouse("[data-item='test.iso']", "contextmenu")
        b.click(".contextMenu button:contains('Download')")
        self.waitDownloadFile("test.iso", 1500 * 1024 * 1024)

        # Selected view is saved in localStorage
        b.logout()
        self.login_and_go("/files")
        b.wait_visible("button[aria-label='Display as a grid']")

        # deleted files and directories are auto-detected
        m.execute("rmdir /home/admin/newdir")
        m.execute("rm /home/admin/newfile")
        b.wait_not_present("[data-item='newdir']")
        b.wait_not_present("[data-item='newfile']")

        # current directory sidebar item count is updated
        files_cnt = m.execute("ls -A /home/admin | wc -l").strip()
        hidden_files_cnt = m.execute(r'ls -A /home/admin | grep "^\." | wc -l').strip()
        b.wait_text("#sidebar-card-header", f"admin{files_cnt} items ({hidden_files_cnt} hidden)")

        # sidebar is reset when files are removed
        b.wait_in_text("#sidebar-card-header", "admin")

        # List root directory
        # Click "/" on the breadcrumb
        b.click("li[data-location='/'] a")  # go back to home dir
        b.wait_visible("[data-item='home']")

        # Enter /dev to make sure we can show special files properly
        b.mouse("[data-item='dev']", "dblclick")
        b.wait_visible("[data-item='urandom']")

        # Non-existing directory
        b.go("/files#/?path=/doesnotexists")
        b.wait_in_text(".pf-v5-c-empty-state__body", "No such file or directory")
        b.wait_visible("#dropdown-menu:disabled")

        # Path with multiple slashes is normalized
        b.go("/files#/?path=/////")
        b.wait_text("li[data-location='/']", "")

        # Path without a forward slash does get one
        b.go("/files#/?path=etc")
        b.wait_text("li[data-location='/etc']", "etc")

    def testNavigation(self) -> None:
        b = self.browser
        m = self.machine

        self.enter_files()

        b.wait_text("li[data-location='/home']", "home")
        b.wait_text("li[data-location='/home/admin']", "admin")

        # clicking on the home button should take us to the home directory
        b.click("li[data-location='/home'] a")
        b.wait_visible("li[data-location='/home'] a.pf-m-current")
        b.wait_text("li[data-location='/home']", "home")
        b.wait_visible("[data-item='admin']")

        # show folder info in sidebar
        b.click("[data-item='admin']")
        b.wait_in_text("#sidebar-card-header", "admin")

        # double-clicking on a directory should take us into it
        b.mouse("[data-item='admin']", "dblclick")
        b.wait_not_present("[data-item='admin']")
        self.assert_last_breadcrumb("admin")

        # navigating into a directory resets the sidebar
        b.wait_in_text("#sidebar-card-header", "admin")

        # double-clicking on a symlink to a directory also takes us into it
        m.execute("ln -s /tmp /home/admin/tmplink")
        b.click("[data-item='tmplink']")
        b.wait_in_text("#sidebar-card-header", "symbolic link to /tmp")
        b.mouse("[data-item='tmplink']", "dblclick")
        b.wait_not_present("[data-item='tmplink']")
        self.assert_last_breadcrumb("tmplink")
        b.go("/files#/?path=/home/admin")
        b.wait_not_present(".pf-v5-c-empty-state")

        # create folders and test navigation history buttons
        m.execute("mkdir /home/admin/newdir")
        m.execute("mkdir /home/admin/newdir/newdir2")
        b.mouse("[data-item='newdir']", "dblclick")
        b.wait_not_present("[data-item='admin']")
        b.wait_visible("[data-item='newdir2']")
        b.mouse("[data-item='newdir2']", "dblclick")
        b.wait_not_present("[data-item='newdir']")
        b.click("li[data-location='/home'] a")
        self.assert_last_breadcrumb("home")
        b.wait_visible("[data-item='admin']")
        # navigate back
        b.eval_js("window.history.back()")
        b.wait_in_text("#sidebar-card-header", "newdir2")
        b.wait_not_present("[data-item='admin']")
        b.eval_js("window.history.back()")
        b.wait_in_text("#sidebar-card-header", "newdir")
        b.wait_visible("[data-item='newdir2']")
        b.eval_js("window.history.back()")
        b.wait_in_text("#sidebar-card-header", "admin")
        b.wait_visible("[data-item='newdir']")
        # navigate forward
        b.eval_js("window.history.forward()")
        b.wait_in_text("#sidebar-card-header", "newdir")
        b.wait_not_present("[data-item='admin']")
        self.assert_last_breadcrumb("newdir")
        b.eval_js("window.history.forward()")
        b.wait_in_text("#sidebar-card-header", "newdir2")
        b.wait_not_present("[data-item='newdir']")
        self.assert_last_breadcrumb("newdir2")
        b.eval_js("window.history.forward()")
        # Switching navigation resets selected state
        b.wait_visible("[data-item='admin']")
        b.wait_not_present("[data-item='admin'].row-selected")
        b.wait_in_text("#sidebar-card-header", "home")
        b.wait_not_present("[data-item='newdir']")
        self.assert_last_breadcrumb("home")
        b.wait_visible("[data-item='admin']")

        # Navigation via editing the path
        path_input = "#new-path-input"
        edit_button = ".breadcrumb-button-edit"
        apply_button = ".breadcrumb-button-edit-apply"
        cancel_button = ".breadcrumb-button-edit-cancel"

        # Cancel

        # Via escape
        b.click(edit_button)
        b.wait_val(path_input, "/home/")
        b.set_input_text(path_input, "/home/admin")
        b.wait_visible(path_input)
        b.focus(path_input)
        b.key("Escape")
        b.wait_not_present(path_input)

        # Via cancel button
        b.click(edit_button)
        # Cancelled edit should not save the path
        b.wait_val(path_input, "/home/")
        b.click(cancel_button)
        b.wait_not_present(path_input)

        # Change path

        # Via Enter key
        b.click(edit_button)
        b.set_input_text(path_input, "/opt")
        b.focus(path_input)
        b.key("Enter")
        self.assert_last_breadcrumb("opt")

        # Via apply button
        b.click(edit_button)
        b.set_input_text(path_input, "/var")
        b.click(apply_button)
        self.assert_last_breadcrumb("var")

        # Editing and cancelling does not remember input
        b.click(edit_button)
        b.set_input_text(path_input, "/path/to/nowhere")
        b.click(cancel_button)
        b.wait_not_present(path_input)
        b.click(edit_button)
        b.wait_visible(path_input)
        b.wait_val(path_input, "/var/")
        b.click(cancel_button)

        # Editing / shows / in the input
        b.click(edit_button)
        b.set_input_text(path_input, "/")
        b.click(apply_button)
        self.assert_last_breadcrumb("/")
        b.click(edit_button)
        b.wait_val(path_input, "/")
        b.click(cancel_button)

        # Navigating resets the current search filter
        b.set_input_text("input[placeholder='Filter directory']", "sys")
        self.browser.wait_js_cond("ph_count('#folder-view tbody tr') == 1")

        b.mouse("[data-item='sys']", "dblclick")
        self.assert_last_breadcrumb("sys")
        b.wait_val("input[placeholder='Filter directory']", "")

        b.set_input_text("input[placeholder='Filter directory']", "no-matches-at-all")
        self.browser.wait_js_cond("ph_count('#folder-view tbody tr') == 0")
        self.assert_last_breadcrumb("sys")

        b.click("li[data-location='/'] a")
        self.assert_last_breadcrumb("/")
        b.wait_val("input[placeholder='Filter directory']", "")

    def testSorting(self) -> None:
        b = self.browser
        m = self.machine

        self.enter_files()

        # set a bogus sort value in localStorage to make sure we handle it gracefully
        b.eval_js("""window.localStorage.setItem("files:sort", 'bzzt')""")
        b.reload()
        self.enter_files()

        # Expected heading
        b.wait_text("#files-card-header", "Directories & files")

        # Create test files and folders
        m.execute("touch -d '3 hours ago' /home/admin/aaa")
        b.wait_visible("[data-item='aaa']")
        m.execute("touch -d '4 hours ago' /home/admin/BBB")
        b.wait_visible("[data-item='BBB']")
        m.execute("touch -d '2 hours ago' /home/admin/ccc")
        b.wait_visible("[data-item='ccc']")

        # Pixel test the menu
        b.click("#sort-menu-toggle")
        b.assert_pixels("#sort-menu", "sort-menu")
        b.click("#sort-menu-toggle")
        b.wait_not_present("#sort-menu")

        # Default sort is A-Z (also used for invalid value found in localStorage)
        # Alphabet sorts should be case insensitive
        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-name", "aaa")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-name", "BBB")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-name", "ccc")

        # Sort by reverse alphabet
        b.select_PF("#sort-menu-toggle", "Z-A")
        # Alphabet sorts should be case insensitive
        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-name", "ccc")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-name", "BBB")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-name", "aaa")

        # Sort by last modified
        b.select_PF("#sort-menu-toggle", "Last modified")
        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-name", "ccc")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-name", "aaa")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-name", "BBB")

        # Update content of files
        m.execute('echo "update" > /home/admin/aaa')

        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-name", "aaa")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-name", "ccc")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-name", "BBB")

        # Sort by first modified
        b.select_PF("#sort-menu-toggle", "First modified")
        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-name", "BBB")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-name", "ccc")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-name", "aaa")

        # Sort option should be saved in localStorage
        b.select_PF("#sort-menu-toggle", "Z-A")
        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-name", "ccc")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-name", "BBB")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-name", "aaa")
        b.reload()
        b.enter_page("/files")
        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-name", "ccc")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-name", "BBB")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-name", "aaa")

        # Sort on size
        m.execute("""
            echo 'lol' > /home/admin/aaa
            sleep 0.01   # make sure these get different timestamps on the filesystem
            truncate -s 10M /home/admin/BBB
        """)
        b.select_PF("#sort-menu-toggle", "Largest size")
        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-name", "BBB")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-name", "aaa")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-name", "ccc")

        b.select_PF("#sort-menu-toggle", "Smallest size")
        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-name", "ccc")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-name", "aaa")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-name", "BBB")

        m.execute("""
            ln -s /tmp /home/admin/ddd
            sleep 0.01   # make sure these get different timestamps on the filesystem
            mkdir /home/admin/eee
            sleep 0.01   # make sure these get different timestamps on the filesystem
            mkdir /home/admin/Eee
        """)
        b.wait_visible("[data-item='ddd']")
        b.wait_visible("[data-item='eee']")
        b.wait_visible("[data-item='Eee']")

        # Directories are sorted first
        b.select_PF("#sort-menu-toggle", "A-Z")
        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-name", "ddd")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-name", "eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-name", "Eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(4) .item-name", "aaa")
        b.wait_text("#folder-view tbody tr:nth-of-type(5) .item-name", "BBB")
        b.wait_text("#folder-view tbody tr:nth-of-type(6) .item-name", "ccc")

        # Sort headers also work in the list view
        b.click("button[aria-label='Display as a list']")
        b.assert_pixels("#files-card-parent", "list-view", mock={".item-date": "Jun 19, 2024, 11:30 AM"})
        b.wait_visible("th[aria-sort='ascending'].pf-m-selected button:contains(Name)")

        # clicking reverse sort order
        b.click("th.pf-m-selected button")
        b.wait_visible("th[aria-sort='descending'].pf-m-selected button:contains(Name)")
        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-name", "Eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-name", "eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-name", "ddd")
        b.wait_text("#folder-view tbody tr:nth-of-type(4) .item-name", "ccc")
        b.wait_text("#folder-view tbody tr:nth-of-type(5) .item-name", "BBB")
        b.wait_text("#folder-view tbody tr:nth-of-type(6) .item-name", "aaa")

        b.click("th button:contains(Modified)")
        b.wait_visible("th[aria-sort='ascending'].pf-m-selected button:contains(Modified)")
        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-name", "ddd")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-name", "eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-name", "Eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(4) .item-name", "ccc")
        b.wait_text("#folder-view tbody tr:nth-of-type(5) .item-name", "aaa")
        b.wait_text("#folder-view tbody tr:nth-of-type(6) .item-name", "BBB")

        b.click("th button:contains(Size)")
        b.wait_visible("th[aria-sort='ascending'].pf-m-selected button:contains(Size)")
        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-name", "ddd")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-name", "eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-name", "Eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(4) .item-name", "BBB")
        b.wait_text("#folder-view tbody tr:nth-of-type(5) .item-name", "aaa")
        b.wait_text("#folder-view tbody tr:nth-of-type(6) .item-name", "ccc")

        # Test sorting by permissions
        basedir = "/home/admin"
        m.execute(f"""
        chmod 0754 {basedir}/eee
        chmod 0755 {basedir}/Eee
        chmod 0500 {basedir}/BBB
        chmod 0477 {basedir}/aaa
        chmod 0511 {basedir}/ccc
        """)

        b.click("th button:contains(Permissions)")
        b.wait_visible("th[aria-sort='ascending'].pf-m-selected button:contains(Permissions)")
        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-name", "ddd")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-name", "Eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-name", "eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(4) .item-name", "ccc")
        b.wait_text("#folder-view tbody tr:nth-of-type(5) .item-name", "BBB")
        b.wait_text("#folder-view tbody tr:nth-of-type(6) .item-name", "aaa")

        # Directories are sorted first
        m.execute(f"""
        chmod 0755 {basedir}/eee
        chmod 0123 {basedir}/Eee
        chmod 0777 {basedir}/BBB
        chmod 0640 {basedir}/aaa
        chmod 0600 {basedir}/ccc
        """)

        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-name", "ddd")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-name", "eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-name", "Eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(4) .item-name", "BBB")
        b.wait_text("#folder-view tbody tr:nth-of-type(5) .item-name", "aaa")
        b.wait_text("#folder-view tbody tr:nth-of-type(6) .item-name", "ccc")

        # Clicking again inverts the list
        # Directories are still sorted first
        b.click("th button:contains(Permissions)")
        b.wait_visible("th[aria-sort='descending'].pf-m-selected button:contains(Permissions)")
        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-name", "Eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-name", "eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-name", "ddd")
        b.wait_text("#folder-view tbody tr:nth-of-type(4) .item-name", "ccc")
        b.wait_text("#folder-view tbody tr:nth-of-type(5) .item-name", "aaa")
        b.wait_text("#folder-view tbody tr:nth-of-type(6) .item-name", "BBB")

        # Special bits are not used for sorting
        m.execute(f"""
        chmod 6755 {basedir}/eee
        chmod 0755 {basedir}/Eee
        chmod 1700 {basedir}/BBB
        chmod 3700 {basedir}/aaa
        chmod 0701 {basedir}/ccc
        """)

        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-name", "Eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-name", "eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-name", "ddd")
        b.wait_text("#folder-view tbody tr:nth-of-type(4) .item-name", "aaa")
        b.wait_text("#folder-view tbody tr:nth-of-type(5) .item-name", "BBB")
        b.wait_text("#folder-view tbody tr:nth-of-type(6) .item-name", "ccc")

        # create test users
        m.execute("""
        useradd barusr
        useradd cmanusr
        useradd foousr
        useradd qusr
        useradd rebelusr
        """)

        # Sorting by file ownership
        # Files are primarily sorted by user
        m.execute(f"""
        chown admin:root {basedir}/aaa
        chown foousr:root {basedir}/BBB
        chown barusr:root {basedir}/ccc
        chown qusr:foousr {basedir}/eee
        chown rebelusr:cmanusr {basedir}/Eee
        """)
        b.click("th button:contains(Owner)")

        # Verify order
        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-name", "eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-name", "Eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-name", "ddd")
        b.wait_text("#folder-view tbody tr:nth-of-type(4) .item-name", "aaa")
        b.wait_text("#folder-view tbody tr:nth-of-type(5) .item-name", "ccc")
        b.wait_text("#folder-view tbody tr:nth-of-type(6) .item-name", "BBB")

        # Verify owner is displayed correctly
        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-owner", "qusr:foousr")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-owner", "rebelusr:cmanusr")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-owner", "root")
        b.wait_text("#folder-view tbody tr:nth-of-type(4) .item-owner", "admin:root")
        b.wait_text("#folder-view tbody tr:nth-of-type(5) .item-owner", "barusr:root")
        b.wait_text("#folder-view tbody tr:nth-of-type(6) .item-owner", "foousr:root")

        # Reverse sorting
        b.click("th button:contains(Owner)")
        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-name", "ddd")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-name", "Eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-name", "eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(4) .item-name", "BBB")
        b.wait_text("#folder-view tbody tr:nth-of-type(5) .item-name", "ccc")
        b.wait_text("#folder-view tbody tr:nth-of-type(6) .item-name", "aaa")

        # Files are sorted by group when user is the same
        m.execute(f"""
        chown admin:root {basedir}/aaa
        chown admin:foousr {basedir}/BBB
        chown admin:rebelusr {basedir}/ccc
        chown --no-dereference admin:rebelusr {basedir}/ddd
        chown admin:foousr {basedir}/eee
        chown admin:admin {basedir}/Eee
        """)
        b.click("th button:contains(Owner)")

        # Verify order
        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-name", "Eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-name", "eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-name", "ddd")
        b.wait_text("#folder-view tbody tr:nth-of-type(4) .item-name", "BBB")
        b.wait_text("#folder-view tbody tr:nth-of-type(5) .item-name", "ccc")
        b.wait_text("#folder-view tbody tr:nth-of-type(6) .item-name", "aaa")

        # Verify owner is displayed correctly
        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-owner", "admin")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-owner", "admin:foousr")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-owner", "admin:rebelusr")
        b.wait_text("#folder-view tbody tr:nth-of-type(4) .item-owner", "admin:foousr")
        b.wait_text("#folder-view tbody tr:nth-of-type(5) .item-owner", "admin:rebelusr")
        b.wait_text("#folder-view tbody tr:nth-of-type(6) .item-owner", "admin:root")

        # Reverse sorting
        b.click("th button:contains(Owner)")
        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-name", "ddd")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-name", "eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-name", "Eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(4) .item-name", "aaa")
        b.wait_text("#folder-view tbody tr:nth-of-type(5) .item-name", "ccc")
        b.wait_text("#folder-view tbody tr:nth-of-type(6) .item-name", "BBB")

        # Sorting falls back to file name sort
        m.execute(f"""
        chown admin:admin {basedir}/aaa
        chown admin:admin {basedir}/BBB
        chown admin:admin {basedir}/ccc
        chown admin:admin {basedir}/eee
        chown admin:admin {basedir}/Eee
        """)
        b.click("th button:contains(Owner)")

        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-name", "eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-name", "Eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-name", "ddd")
        b.wait_text("#folder-view tbody tr:nth-of-type(4) .item-name", "aaa")
        b.wait_text("#folder-view tbody tr:nth-of-type(5) .item-name", "BBB")
        b.wait_text("#folder-view tbody tr:nth-of-type(6) .item-name", "ccc")

        # Sorting when UID is a number
        # Numbers are sorted after known names
        m.execute(f"""
        chown foousr:admin {basedir}/aaa
        chown foousr:568 {basedir}/BBB
        chown foousr:admin {basedir}/ccc
        chown foousr:admin {basedir}/eee
        chown 569:admin {basedir}/Eee
        chown --no-dereference 568:admin {basedir}/ddd
        """)

        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-name", "eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-name", "ddd")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-name", "Eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(4) .item-name", "aaa")
        b.wait_text("#folder-view tbody tr:nth-of-type(5) .item-name", "ccc")
        b.wait_text("#folder-view tbody tr:nth-of-type(6) .item-name", "BBB")

        # Verify owner is displayed correctly
        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-owner", "foousr:admin")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-owner", "568:admin")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-owner", "569:admin")
        b.wait_text("#folder-view tbody tr:nth-of-type(4) .item-owner", "foousr:admin")
        b.wait_text("#folder-view tbody tr:nth-of-type(5) .item-owner", "foousr:admin")
        b.wait_text("#folder-view tbody tr:nth-of-type(6) .item-owner", "foousr:568")

        # Reverse
        b.click("th button:contains(Owner)")
        b.wait_text("#folder-view tbody tr:nth-of-type(1) .item-name", "Eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(2) .item-name", "ddd")
        b.wait_text("#folder-view tbody tr:nth-of-type(3) .item-name", "eee")
        b.wait_text("#folder-view tbody tr:nth-of-type(4) .item-name", "BBB")
        b.wait_text("#folder-view tbody tr:nth-of-type(5) .item-name", "ccc")
        b.wait_text("#folder-view tbody tr:nth-of-type(6) .item-name", "aaa")

    def testDelete(self) -> None:
        b = self.browser
        m = self.machine

        self.enter_files()

        self.allow_journal_messages("rm: cannot remove '/home/admin/newdir/newfile': Permission denied",
                                    "rm: cannot remove '/home/admin/newfile': Operation not permitted")

        # Delete file
        m.execute("touch /home/admin/newfile")
        b.wait_visible("[data-item='newfile']")
        self.delete_item("file", "newfile")

        # Delete file with space in the file name
        m.execute(r"touch /home/admin/new\ file")
        b.wait_visible("[data-item='new file']")
        self.delete_item("file", "new file")

        # Delete empty directory
        m.execute("mkdir /home/admin/newdir")
        b.wait_visible("[data-item='newdir']")
        self.delete_item("directory", "newdir")

        # Delete full directory
        m.execute("mkdir /home/admin/newdir")
        m.execute("touch /home/admin/newdir/newfile")
        b.wait_visible("[data-item='newdir']")
        self.delete_item("directory", "newdir")

        # Delete symlink
        m.execute("""
        touch /home/admin/target
        ln -s /home/admin/target /home/admin/link
        """)
        self.delete_item("link", "link")

        # Deleting protected file should give an error
        m.execute("touch /home/admin/newfile")
        m.execute("sudo chattr +i /home/admin/newfile")
        b.wait_visible("[data-item='newfile']")
        self.delete_item("file", "newfile", expect_success=False)
        b.wait_in_text("h1.pf-v5-c-modal-box__title", "Force delete file newfile?")
        b.assert_pixels(".pf-v5-c-modal-box", "delete-modal-error")
        self.wait_modal_inline_alert("rm: cannot remove '/home/admin/newfile': Operation not permitted")
        b.click("button.pf-m-danger")
        b.wait_in_text("h1.pf-v5-c-modal-box__title", "Force delete file newfile?")
        self.wait_modal_inline_alert("rm: cannot remove '/home/admin/newfile': Operation not permitted")
        b.click("div.pf-v5-c-modal-box__close button")
        b.wait_not_present(".pf-v5-c-modal-box")
        b.wait_visible("[data-item='newfile']")
        m.execute("sudo chattr -i /home/admin/newfile")
        self.delete_item("file", "newfile")

        # Delete using keyboard shortcut
        m.execute("touch /home/admin/delete1 /home/admin/delete2")
        b.click("[data-item='delete1']")
        b.mouse("[data-item='delete2']", "click", ctrlKey=True)
        b.wait_visible("[data-item='delete1'].row-selected")
        b.wait_visible("[data-item='delete2'].row-selected")
        b.key("Delete")
        b.wait_in_text("h1.pf-v5-c-modal-box__title", "Delete 2 items?")
        b.assert_pixels(".pf-v5-c-modal-box", "delete-modal")
        b.click("button.pf-m-danger")

        b.wait_not_present(".pf-v5-c-modal-box")
        b.wait_not_present("[data-item='delete1']")
        b.wait_not_present("[data-item='delete2']")

    def testCreateDirectory(self) -> None:
        b = self.browser
        m = self.machine

        self.enter_files()

        # Create folder
        self.create_directory("newdir")
        b.wait_visible("[data-item='newdir']")
        self.assert_owner('/home/admin/newdir', 'admin:admin')

        # validation
        b.click("#dropdown-menu")
        b.click("#create-item")
        b.assert_pixels(".pf-v5-c-modal-box", "create-modal")
        b.set_input_text("#create-directory-input", "test")
        b.set_input_text("#create-directory-input", "")
        b.wait_visible("button.pf-m-primary:disabled")
        b.wait_in_text("#create-directory-input-helper", "Directory name cannot be empty.")

        b.set_input_text("#create-directory-input", "a" * 256)
        b.wait_visible("button.pf-m-primary:disabled")
        b.wait_in_text("#create-directory-input-helper", "Directory name too long.")

        b.set_input_text("#create-directory-input", "foo/bar")
        b.wait_visible("button.pf-m-primary:disabled")
        b.wait_in_text("#create-directory-input-helper", "Directory name cannot include a /.")

        b.set_input_text("#create-directory-input", "test")
        b.wait_visible("button.pf-m-primary:not(:disabled)")
        b.click(".pf-v5-c-modal-box__footer button.pf-m-link")  # cancel

        # Creating folder with duplicate name should return an error
        self.create_directory("newdir")
        self.wait_modal_inline_alert("mkdir: cannot create directory ‘/home/admin/newdir’: File exists")
        b.click("div.pf-v5-c-modal-box__close button.pf-v5-c-button")

        # Creating folder with empty name should return an error
        self.create_directory("")
        self.wait_modal_inline_alert("mkdir: cannot create directory ‘/home/admin/’: File exists")
        b.click("div.pf-v5-c-modal-box__close button.pf-v5-c-button")

        # Creating folder inside protected folder should return an error
        m.execute("sudo chattr +i /home/admin/newdir")
        b.mouse("[data-item='newdir']", "dblclick")
        b.wait_not_present("[data-item='newdir']")
        self.create_directory("test")
        alert_text = "mkdir: cannot create directory ‘/home/admin/newdir/test’: Operation not permitted"
        self.wait_modal_inline_alert(alert_text)
        b.click("div.pf-v5-c-modal-box__close button.pf-v5-c-button")
        m.execute("sudo chattr -i /home/admin/newdir")

        # Creating folder as superuser a non-logged in owned directory has the expected folder permissions
        b.go("/files#/?path=/root")
        self.assert_last_breadcrumb("root")
        self.addCleanup(m.execute, ['rm', '-rf', '/root/newdir'])
        self.create_directory("newdir")
        b.wait_visible("[data-item='newdir']")
        self.assert_owner('/root/newdir', 'root:root')

        m.execute('useradd -m testuser')
        b.go('/files#/?path=/home/testuser')
        b.wait_not_present('.pf-v5-c-empty-state')
        self.create_directory('newdir')
        b.wait_visible("[data-item='newdir']")
        self.assert_owner('/home/testuser', 'testuser:testuser')

        b.go('/files#/?path=/tmp')
        b.wait_not_present('.pf-v5-c-empty-state')
        self.addCleanup(m.execute, ['rm', '-rf', '/tmp/newdir'])
        self.create_directory('newdir')
        b.wait_visible("[data-item='newdir']")
        self.assert_owner('/tmp/newdir', 'root:root')

        # Create as superuser but owned by admin:admin
        self.addCleanup(m.execute, ['rm', '-rf', '/tmp/admindir'])
        self.create_directory('admindir', 'admin:admin')
        b.wait_visible("[data-item='admindir']")
        self.assert_owner('/tmp/admindir', 'admin:admin')

        # Creating folder as user without administrator privileges
        b.drop_superuser()
        b.go('/files#/?path=/home/admin')
        b.wait_not_present('.pf-v5-c-empty-state')
        self.create_directory('admindir')
        b.wait_visible("[data-item='admindir']")
        self.assert_owner('/home/admin/admindir', 'admin:admin')

    def testContextMenu(self) -> None:
        b = self.browser
        m = self.machine

        self.enter_files()
        b.allow_download()

        # We should be able to click anywhere in this div.
        body_size = b.eval_js("""
            [
              document.getElementById('files-card-parent').offsetWidth,
              document.getElementById('files-card-parent').offsetHeight
            ]
        """)

        # Create folder from context menu, click in the middle to assert we can click everywhere.
        b.mouse("#files-card-parent", "contextmenu", body_size[0] / 2, body_size[0] / 2)
        b.click(".contextMenu button:contains('Create directory')")
        b.set_input_text("#create-directory-input", "newdir")
        b.click("button.pf-m-primary")
        b.wait_visible("[data-item='newdir']")

        # Opening context menu from empty space deselects item
        b.click("[data-item='newdir']")
        b.mouse("#files-card-parent tbody", "contextmenu")
        b.assert_pixels(".contextMenu", "overview-context-menu")
        b.click(".contextMenu button:contains('Create directory')")
        b.set_input_text("#create-directory-input", "newdir2")
        b.click("button.pf-m-primary")
        b.wait_visible("[data-item='newdir2']")
        m.execute("rmdir /home/admin/newdir2")

        # Rename folder from context menu
        b.mouse("[data-item='newdir']", "contextmenu")
        b.assert_pixels(".contextMenu", "folder-context-menu")
        b.click(".contextMenu button:contains('Rename')")
        b.set_input_text("#rename-item-input", "newdir1")
        b.click("button.pf-m-primary")
        b.wait_visible("[data-item='newdir1']")

        # Edit permissions from context menu
        m.execute("useradd testuser")
        b.click("[data-item='newdir1']")
        b.mouse("[data-item='newdir1']", "contextmenu")
        b.click(".contextMenu button:contains('Edit permissions')")
        b.select_from_dropdown("#edit-permissions-owner", "testuser")
        b.click("button.pf-m-primary")
        b.wait_text("#description-list-owner dd", "testuser")

        # Delete folder from context menu
        b.mouse("[data-item='newdir1']", "contextmenu")
        b.click(".contextMenu button:contains('Delete')")
        b.click("button.pf-m-danger")
        b.wait_not_present("[data-item='newdir1']")

        m.execute("echo 'some content' > /home/admin/newfile")
        b.mouse("[data-item='newfile']", "contextmenu")
        b.assert_pixels(".contextMenu", "file-context-menu")
        b.click(".contextMenu button:contains('Download')")
        size = int(self.stat('%s', '/home/admin/newfile'))
        self.waitDownloadFile("newfile", size, "some content\n")

        # Delete button text should match item type: directory/file
        b.mouse("[data-item='newfile']", "contextmenu")
        b.click(".contextMenu button:contains('Delete')")
        b.click("button.pf-m-danger")
        b.wait_not_present("[data-item='newfile']")

        # The list view also supports a contextmenu
        m.execute("touch /home/admin/testfile")
        b.click("button[aria-label='Display as a list']")
        b.mouse("[data-item='testfile']", "contextmenu")
        b.wait_visible(".contextMenu button:contains('Delete')")

    def testDownload(self) -> None:
        b = self.browser
        m = self.machine

        self.enter_files()
        b.allow_download()

        # Big file downloads fine
        m.execute("truncate -s 1500M /home/admin/test.iso")
        b.wait_visible("[data-item='test.iso']")
        b.mouse("[data-item='test.iso']", "contextmenu")
        b.click(".contextMenu button:contains('Download')")
        self.waitDownloadFile("test.iso", 1500 * 1024 * 1024)

        # non-latin1 file is fine
        m.execute("echo 'non-latin filename' > /home/admin/漢字")
        b.wait_visible("[data-item='漢字']")
        b.mouse("[data-item='漢字']", "contextmenu")
        b.click(".contextMenu button:contains('Download')")
        size = int(self.stat('%s', '/home/admin/漢字'))
        self.waitDownloadFile("漢字", size, "non-latin filename\n")
        m.execute("rm /home/admin/漢字")

    def testRename(self) -> None:
        b = self.browser
        m = self.machine

        self.enter_files()

        # validation
        m.execute("touch /home/admin/newfile")
        b.click("[data-item='newfile']")
        b.click("#dropdown-menu")
        b.click("#rename-item")
        b.set_input_text("#rename-item-input", "test")
        b.set_input_text("#rename-item-input", "")
        b.wait_visible("button.pf-m-primary:disabled")
        b.wait_in_text("#rename-item-input-helper", "Name cannot be empty.")
        b.assert_pixels(".pf-v5-c-modal-box", "rename-modal-error")

        b.set_input_text("#rename-item-input", "a" * 256)
        b.wait_visible("button.pf-m-primary:disabled")
        b.wait_in_text("#rename-item-input-helper", "Name too long.")

        b.set_input_text("#rename-item-input", "foo/bar")
        b.wait_visible("button.pf-m-primary:disabled")
        b.wait_in_text("#rename-item-input-helper", "Name cannot include a /.")

        b.set_input_text("#rename-item-input", "test")
        b.wait_visible("button.pf-m-primary:not(:disabled)")
        b.assert_pixels(".pf-v5-c-modal-box", "rename-modal")
        b.click(".pf-v5-c-modal-box__footer button.pf-m-link")  # cancel

        # Rename file
        self.rename_item("newfile", "newfile1")
        b.wait_visible("[data-item='newfile1']")
        m.execute("rm /home/admin/newfile1")

        # Rename directory
        m.execute("mkdir /home/admin/newdir")
        self.rename_item("newdir", "newdir1")
        b.wait_visible("[data-item='newdir1']")

        # Rename with space
        self.rename_item("newdir1", "new dir1")
        b.wait_visible("[data-item='new dir1']")

        # Rename to an existing directory should not move the file into the directory
        m.execute("""
        touch /home/admin/newfile
        mkdir /home/admin/dest
        """)
        b.wait_visible("[data-item='newfile']")
        b.wait_visible("[data-item='dest']")
        b.click("[data-item='newfile']")
        b.click("#dropdown-menu")
        b.click("#rename-item")
        b.set_input_text("#rename-item-input", "dest")
        b.wait_in_text("#rename-item-input-helper", "Directory with the same name exists")
        b.click("button.pf-m-link:contains('Cancel')")
        b.wait_not_present(".pf-v5-c-modal-box")

        # Renaming protected item should give an error
        m.execute("sudo chattr +i /home/admin/new\\ dir1")
        self.rename_item("new dir1", "testdir")
        alert_text = "mv: cannot move '/home/admin/new dir1' to '/home/admin/testdir': Operation not permitted"
        self.wait_modal_inline_alert(alert_text)
        b.click("div.pf-v5-c-modal-box__close > button")
        m.execute("sudo chattr -i /home/admin/new\\ dir1")

        basedir = '/home/admin'
        # Force overwrite rename on normal file
        m.execute(f"""
        echo 'foo text' > {basedir}/foo.txt
        echo 'bar text' > {basedir}/bar.txt
        mkdir {basedir}/foodir
        mkdir {basedir}/bardir
        """)
        b.wait_visible("[data-item='foo.txt']")
        b.wait_visible("[data-item='bar.txt']")
        b.wait_visible("[data-item='foodir']")
        b.wait_visible("[data-item='bardir']")

        # Overwrite regular file
        self.file_action_modal('foo.txt', 'Rename')
        b.set_input_text("#rename-item-input", "bar.txt")
        b.wait_in_text("#rename-item-input-helper", "File exists")
        b.wait_visible("button.pf-m-primary:disabled")
        b.click("button.pf-m-danger")
        b.wait_not_present("[data-item='foo.txt']")
        b.wait_visible("[data-item='bar.txt']")
        contents = m.execute(f"cat {basedir}/bar.txt").strip()
        self.assertEqual(contents, 'foo text')

        # Don't allow force overwrite on directory
        self.file_action_modal('foodir', 'Rename')
        b.set_input_text("#rename-item-input", "bardir")
        b.wait_in_text("#rename-item-input-helper", "Directory with the same name exists")
        b.wait_visible("button.pf-m-primary:disabled")
        b.wait_not_present("button.pf-m-danger")
        b.click("div.pf-v5-c-modal-box__close > button")

        # Trying to overwrite normal file to directory name shows error
        self.file_action_modal('bar.txt', 'Rename')
        b.set_input_text("#rename-item-input", "bardir")
        b.wait_in_text("#rename-item-input-helper", "Directory with the same name exists")
        b.wait_visible("button.pf-m-primary:disabled")
        b.wait_not_present("button.pf-m-danger")
        b.click("div.pf-v5-c-modal-box__close > button")

        # Overwriting symlinks is not supported
        m.execute(f"""
        echo 'foo text' > {basedir}/foo.txt
        echo 'bar text' > {basedir}/bar.txt
        ln -s {basedir}/foo.txt {basedir}/foolink.txt
        ln -s {basedir}/bar.txt {basedir}/barlink.txt
        """)
        self.file_action_modal('foolink.txt', 'Rename')
        b.set_input_text("#rename-item-input", "barlink.txt")
        b.wait_in_text("#rename-item-input-helper", "File exists")
        b.wait_visible("button.pf-m-primary:disabled")
        b.wait_not_present("button.pf-m-danger")
        b.click("div.pf-v5-c-modal-box__close > button")

        # Trying to overwrite symlink to directory name shows error
        m.execute(f"""
        ln -s {basedir}/foodir {basedir}/foodirlink
        """)
        self.file_action_modal('foodirlink', 'Rename')
        b.set_input_text("#rename-item-input", "bardir")
        b.wait_in_text("#rename-item-input-helper", "Directory with the same name exists")
        b.wait_visible("button.pf-m-primary:disabled")
        b.wait_not_present("button.pf-m-danger")
        b.click("div.pf-v5-c-modal-box__close > button")

        # Do not overwrite symlinks to directory with another file
        self.file_action_modal('bardir', 'Rename')
        b.set_input_text("#rename-item-input", "foodirlink")
        b.wait_in_text("#rename-item-input-helper", "File exists")
        b.wait_visible("button.pf-m-primary:disabled")
        b.wait_not_present("button.pf-m-danger")
        b.click("div.pf-v5-c-modal-box__close > button")
        self.file_action_modal('foo.txt', 'Rename')
        b.set_input_text("#rename-item-input", "foodirlink")
        b.wait_in_text("#rename-item-input-helper", "File exists")
        b.wait_visible("button.pf-m-primary:disabled")
        b.wait_not_present("button.pf-m-danger")
        b.click("div.pf-v5-c-modal-box__close > button")

        # Rename back to the original name
        self.file_action_modal('foo.txt', 'Rename')
        b.set_input_text("#rename-item-input", "bar.txt")
        b.wait_in_text("#rename-item-input-helper", "File exists")
        b.wait_visible("button.pf-m-primary:disabled")
        b.set_input_text("#rename-item-input", "foo.txt")
        b.wait_in_text("#rename-item-input-helper", "Filename is the same as original name")
        b.click("div.pf-v5-c-modal-box__close > button")
        b.wait_not_present(".pf-v5-c-modal-box")

        # Enter allows renaming.
        self.file_action_modal('foo.txt', 'Rename')
        b.wait_val("#rename-item-input", 'foo.txt')
        b.key('Enter')
        b.wait_in_text("#rename-item-input-helper", "Filename is the same as original name")
        b.set_input_text("#rename-item-input", "renamed-foo.txt", blur=False)
        b.key('Enter')
        b.wait_not_present(".pf-v5-c-modal-box")
        b.wait_visible("[data-item='renamed-foo.txt']")

    def testHiddenItems(self) -> None:
        b = self.browser
        m = self.machine

        self.enter_files()

        # Check hidden item count
        m.execute("mkdir /home/admin/newdir")
        m.execute("touch /home/admin/newdir/f1 /home/admin/newdir/.f2")
        b.mouse("[data-item='newdir']", "dblclick")
        b.wait_visible("[data-item='f1']")
        b.wait_not_present("[data-item='.f2']")
        b.wait_in_text("#sidebar-card-header", "2 items (1 hidden)")

        b.select_PF("#sort-menu-toggle", "Show hidden items")
        b.wait_visible("[data-item='f1']")
        b.wait_visible("[data-item='.f2']")
        b.wait_in_text("#sidebar-card-header", "2 items")

        # Selected option is saved in localStorage
        b.reload()
        b.enter_page("/files")
        b.wait_visible("[data-item='f1']")
        b.wait_visible("[data-item='.f2']")

        b.select_PF("#sort-menu-toggle", "Hide hidden items")
        b.wait_visible("[data-item='f1']")
        b.wait_not_present("[data-item='.f2']")

    def testPermissions(self) -> None:
        b = self.browser
        m = self.machine

        def wait_permissions(permission: str) -> None:
            b.wait_text("#description-list-owner-permissions dd", permission)
            b.wait_text("#description-list-group-permissions dd", permission)
            b.wait_text("#description-list-other-permissions dd", permission)

        def select_access(access: str) -> None:
            b.select_from_dropdown("#edit-permissions-owner-access", access)
            b.select_from_dropdown("#edit-permissions-group-access", access)
            b.select_from_dropdown("#edit-permissions-other-access", access)

        self.enter_files()

        # Check sidebar info
        m.execute("touch /home/admin/newfile")
        b.click("[data-item='newfile']")
        self.assertEqual(m.execute("ls -l /home/admin/newfile")[:10], "-rw-r--r--")
        b.wait_text("#description-list-owner-permissions dd", "Read and write")
        b.wait_text("#description-list-group-permissions dd", "Read-only")
        b.wait_text("#description-list-other-permissions dd", "Read-only")

        # Test changing owner/group
        m.execute("useradd testuser")
        b.click("button:contains('Edit permissions')")
        b.assert_pixels(".pf-v5-c-modal-box", "permissions-modal")
        # Changing owner should change group if user is not in the group
        b.select_from_dropdown("#edit-permissions-owner", "testuser")
        b.wait_in_text("#edit-permissions-group", "testuser")
        b.click("button.pf-m-primary")
        b.wait_text("#description-list-owner dd", "testuser")
        b.wait_text("#description-list-group dd", "testuser")

        m.execute("usermod -a -G testuser admin")
        b.click("button:contains('Edit permissions')")
        # Changing owner shouldn't change group if user is in the group
        b.select_from_dropdown("#edit-permissions-owner", "admin")
        b.wait_in_text("#edit-permissions-group", "testuser")
        b.click("button.pf-m-primary")
        b.wait_text("#description-list-owner dd", "admin")
        b.wait_text("#description-list-group dd", "testuser")

        # Change the group to admin
        b.click("button:contains('Edit permissions')")
        b.select_from_dropdown("#edit-permissions-group", "admin")
        b.wait_in_text("#edit-permissions-group", "admin")
        b.click("button.pf-m-primary")
        b.wait_text("#description-list-owner dd", "admin")
        b.wait_text("#description-list-group dd", "admin")

        # Test changing permissions
        b.click("button:contains('Edit permissions')")
        select_access("0")
        b.click("button.pf-m-primary")
        self.assertEqual(m.execute("ls -l /home/admin/newfile")[:10], "----------")
        wait_permissions("None")

        b.click("button:contains('Edit permissions')")
        select_access("7")
        b.click("button.pf-m-primary")
        self.assertEqual(m.execute("ls -l /home/admin/newfile")[:10], "-rwxrwxrwx")
        wait_permissions("Read, write, and execute")

        # Test changing CWD permissions
        test_dir = "/home/admin/testdir"
        m.execute(['runuser', '-u', 'admin', 'mkdir', test_dir])
        b.wait_visible("[data-item='testdir']")
        b.mouse("[data-item='testdir']", "dblclick")
        b.wait_not_present(".pf-v5-c-empty-state")

        # Via contextmenu
        b.mouse("#files-card-parent", "contextmenu")
        b.click(".contextMenu button:contains('Edit permissions')")
        b.wait_in_text(".pf-v5-c-modal-box__title-text", "testdir")
        b.select_from_dropdown("#edit-permissions-owner-access", "6")
        b.select_from_dropdown("#edit-permissions-group-access", "4")
        b.select_from_dropdown("#edit-permissions-other-access", "4")
        b.click("button.pf-m-primary")
        b.wait_not_present(".pf-v5-c-modal-box")

        self.assertEqual(m.execute(f"ls -ld {test_dir}")[:10], "drw-r--r--")

        # Via kebab
        b.click("#dropdown-menu")
        b.click("button:contains('Edit permissions')")
        b.wait_in_text(".pf-v5-c-modal-box__title-text", "testdir")
        b.select_from_dropdown("#edit-permissions-owner-access", "7")
        b.select_from_dropdown("#edit-permissions-group-access", "5")
        b.select_from_dropdown("#edit-permissions-other-access", "5")
        b.click("button.pf-m-primary")
        b.wait_not_present(".pf-v5-c-modal-box")

        self.assertEqual(m.execute(f"ls -ld {test_dir}")[:10], "drwxr-xr-x")

        b.go("/files#/?path=/")
        b.wait_not_present(".pf-v5-c-empty-state")

        # Shows root user owns "bin"
        b.click("[data-item='bin']")
        b.click("#dropdown-menu")
        b.click("#edit-permissions")
        b.wait_in_text(".pf-v5-c-modal-box__title-text", "bin")
        b.wait_in_text("#edit-permissions-owner", "root")
        b.wait_in_text("#edit-permissions-group", "root")

        # As normal user you cannot change user/group permissions
        b.drop_superuser()

        b.go("/files#/?path=/home/admin")
        b.wait_not_present(".pf-v5-c-empty-state")

        m.execute("touch /home/admin/adminfile; chown admin: /home/admin/adminfile")
        b.click("[data-item='adminfile']")
        b.click("button:contains('Edit permissions')")
        select_access("7")
        # A user cannot change ownership
        b.wait_not_in_text(".pf-v5-c-modal-box__body", "Ownership")
        b.click("button.pf-m-primary")
        self.assertEqual(m.execute("ls -l /home/admin/adminfile")[:10], "-rwxrwxrwx")
        wait_permissions("Read, write, and execute")
        # Does not change ownership
        b.wait_text("#description-list-owner dd", "admin")
        b.wait_text("#description-list-group dd", "admin")

        # Cannot change permission of /home
        b.click("li[data-location='/'] a")
        b.click("[data-item='home']")
        b.click("button:contains('Edit permissions')")
        select_access("7")
        b.click("button.pf-m-primary")
        self.wait_modal_inline_alert("chmod: changing permissions of '/home': Operation not permitted")
        b.click("button.pf-m-link")
        b.wait_not_present(".pf-v5-c-modal-box")

        # Test permissions in details view
        b.go("/files#/?path=/home/admin")
        b.wait_not_present(".pf-v5-c-empty-state")
        b.click("button[aria-label='Display as a list']")
        basedir = "/home/admin"
        for i in range(8):
            m.execute(f"runuser -u admin touch {basedir}/file{i}")

        def check_perms_match(file: str, basedir: str) -> None:
            ls = m.execute(f"ls -l {basedir}/{file}")[:10]
            ui = b.text(f"[data-item='{file}'] .item-perms pre").replace(" ", "")
            self.assertEqual(ls[1:], ui)

        # Simple permissions
        m.execute(f"""
        chmod 000 {basedir}/file0
        chmod 111 {basedir}/file1
        chmod 222 {basedir}/file2
        chmod 333 {basedir}/file3
        chmod 444 {basedir}/file4
        chmod 555 {basedir}/file5
        chmod 666 {basedir}/file6
        chmod 777 {basedir}/file7
        """)

        b.mouse("[data-item='file6'] .item-perms pre", "mouseenter")
        b.wait_in_text(".pf-v5-c-tooltip dd:nth-of-type(1)", "read and write")
        b.wait_in_text(".pf-v5-c-tooltip dd:nth-of-type(2)", "read and write")
        b.wait_in_text(".pf-v5-c-tooltip dd:nth-of-type(3)", "read and write")
        b.mouse("[data-item='file6'] .item-perms pre", "mouseleave")

        for i in range(8):
            check_perms_match(f"file{i}", basedir)

        # Test different permissions for owner/group/others
        m.execute(f"""
        chmod 411 {basedir}/file0
        chmod 546 {basedir}/file1
        chmod 337 {basedir}/file2
        chmod 755 {basedir}/file3
        chmod 613 {basedir}/file4
        chmod 711 {basedir}/file5
        chmod 531 {basedir}/file6
        chmod 740 {basedir}/file7
        """)

        b.mouse("[data-item='file4'] .item-perms pre", "mouseenter")
        b.wait_in_text(".pf-v5-c-tooltip dd:nth-of-type(1)", "read and write")
        b.wait_in_text(".pf-v5-c-tooltip dd:nth-of-type(2)", "execute-only")
        b.wait_in_text(".pf-v5-c-tooltip dd:nth-of-type(3)", "write and execute")
        b.assert_pixels(".pf-v5-c-page__main", "permissions-tooltip", mock={".item-date": "Jun 19, 2024, 11:30 AM"})
        b.mouse("[data-item='file4'] .item-perms pre", "mouseleave")

        for i in range(8):
            check_perms_match(f"file{i}", basedir)

        # Test permissions with setuid, setgid, sticky
        m.execute(f"""
        chmod 1000 {basedir}/file0
        chmod 2111 {basedir}/file1
        chmod 3222 {basedir}/file2
        chmod 4333 {basedir}/file3
        chmod 5444 {basedir}/file4
        chmod 6555 {basedir}/file5
        chmod 7666 {basedir}/file6
        chmod 3777 {basedir}/file7
        """)

        b.mouse("[data-item='file7'] .item-perms pre", "mouseenter")
        b.wait_in_text(".pf-v5-c-tooltip dd:nth-of-type(1)", "read, write, and execute")
        b.wait_in_text(".pf-v5-c-tooltip dd:nth-of-type(2)", "read, write, and execute")
        b.wait_in_text(".pf-v5-c-tooltip dd:nth-of-type(3)", "read, write, and execute")
        b.mouse("[data-item='file7'] .item-perms pre", "mouseleave")

        for i in range(8):
            check_perms_match(f"file{i}", basedir)

    def testErrors(self) -> None:
        b = self.browser
        m = self.machine

        self.enter_files()

        # Make a directory that's not readable to the admin user
        m.execute("mkdir /home/admin/testdir && chmod 400 /home/admin/testdir")
        b.mouse("[data-item='testdir']", "dblclick")
        b.wait_not_present(".pf-v5-c-empty-state")
        b.drop_superuser()
        b.wait_in_text(".pf-v5-c-empty-state", "Permission denied")
        b.assert_pixels(".pf-v5-c-page__main", "error-folder-view")

        # clicking on the home button should take us to the home directory
        b.click("li[data-location='/home/admin'] a")

        # Now set a+r.  We will be able to enter the directory now, and see the
        # files present, but not read any information about them (not +x).
        m.execute('touch /home/admin/testdir/testfile && chmod a+r /home/admin/testdir')
        b.mouse("[data-item='testdir']", "dblclick")
        self.assert_last_breadcrumb("testdir")
        b.click("[data-item='testfile']")
        b.wait_in_text("#sidebar-card-header", "testfile")
        b.wait_text("#description-list-owner dd", "unknown")
        b.wait_text("#description-list-group dd", "unknown")
        b.wait_text("#description-list-last-modified dd", "unknown")

    def testMultiSelect(self) -> None:
        b = self.browser
        m = self.machine

        self.enter_files()

        # Check control-clicking
        m.execute("touch /home/admin/file1 && touch /home/admin/file2")
        b.click("[data-item='file1']")
        b.mouse("[data-item='file2']", "click", ctrlKey=True)
        b.wait_visible("[data-item='file1'].row-selected")
        b.wait_visible("[data-item='file2'].row-selected")
        b.assert_pixels("#files-card-parent", "multi-select-folder-view")
        b.wait_text("#sidebar-card-header", "admin2 items selected")

        b.mouse("[data-item='file2']", "click", ctrlKey=True)
        b.wait_visible("[data-item='file1'].row-selected")
        b.wait_not_present("[data-item='file2'].row-selected")
        b.wait_text("#sidebar-card-header", "file1empty")

        b.mouse("[data-item='file1']", "click", ctrlKey=True)
        b.wait_not_present("[data-item='file1'].row-selected")
        b.wait_in_text("#sidebar-card-header", "admin")

        # Control-clicking when nothing is selected should select item normally
        b.mouse("[data-item='file1']", "click", ctrlKey=True)
        b.wait_visible("[data-item='file1'].row-selected")
        b.wait_text("#sidebar-card-header", "file1empty")

        # Check context menu
        b.mouse("[data-item='file2']", "click", ctrlKey=True)
        b.wait_visible("[data-item='file2'].row-selected")
        b.wait_text("#sidebar-card-header", "admin2 items selected")
        b.mouse("[data-item='file1']", "contextmenu")
        b.wait_in_text(".contextMenu li:nth-child(2) button", "Delete")
        b.click(".contextMenu button:contains('Delete')")
        b.wait_in_text("h1.pf-v5-c-modal-box__title", "Delete 2 items?")
        b.click("button.pf-m-danger")
        b.wait_not_present("[data-item='file1']")
        b.wait_not_present("[data-item='file2']")

        # Check sidebar menu
        m.execute("touch /home/admin/file1 && touch /home/admin/file2")
        b.click("[data-item='file1']")
        b.mouse("[data-item='file2']", "click", ctrlKey=True)
        b.click("#dropdown-menu")
        b.click("#delete-item")
        b.wait_in_text("h1.pf-v5-c-modal-box__title", "Delete 2 items?")
        b.click("button.pf-m-danger")
        b.wait_not_present("[data-item='file1']")
        b.wait_not_present("[data-item='file2']")

    def testKeyboardNav(self) -> None:
        b = self.browser
        m = self.machine

        self.enter_files()

        create_files = ""
        for i in range(0, 4):
            create_files += f"touch /home/admin/file{i}; "
        m.execute(create_files)

        # Focus the iframe for global keybindings in Files.
        b.eval_js("window.focus()")

        b.click("[data-item='file0']")
        b.wait_visible("[data-item='file0'].row-selected")

        b.key("ArrowRight")
        b.wait_visible("[data-item='file1'].row-selected")
        b.key("ArrowLeft")
        b.wait_visible("[data-item='file0'].row-selected")

        # Up / Down depends on the layout, this is tested on mobile where the
        # width is two or three columns.
        b.set_layout("mobile")
        b.click("[data-item='file0']")
        b.wait_visible("[data-item='file0'].row-selected")

        b.key("ArrowDown")
        b.wait_visible("[data-item='file2'].row-selected")

        b.key("ArrowUp")
        b.wait_visible("[data-item='file0'].row-selected")

        # Test with very long hostnames
        original_hostname = m.execute('hostnamectl hostname').strip()
        self.addCleanup(m.execute, ['hostnamectl', 'set-hostname', original_hostname])
        # length of testing farm hostname
        m.execute('hostnamectl set-hostname aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeee.testing-farm')
        b.key("ArrowDown")
        b.wait_visible("[data-item='file2'].row-selected")

        m.execute("mkdir /home/admin/foo")
        b.click("[data-item='foo']")

        b.wait_visible("[data-item='foo'].row-selected")
        b.key("Enter")
        self.assert_last_breadcrumb("foo")

    def testCopyPaste(self) -> None:
        b = self.browser
        m = self.machine

        self.enter_files()

        # Copy/paste file
        m.execute("runuser -u admin mkdir /home/admin/newdir")
        m.write('/home/admin/newfile', 'test_text\n', owner='admin:admin')
        b.click("[data-item='newfile']")
        b.click("#dropdown-menu")
        b.click("#copy-item")
        b.mouse("[data-item='newdir']", "dblclick")
        self.assert_last_breadcrumb("newdir")
        b.wait_in_text(".pf-v5-c-empty-state", "Directory is empty")
        b.click("#dropdown-menu")
        b.click("#paste-item")
        b.wait_visible("[data-item='newfile']")
        self.assertEqual(m.execute("head -n 1 /home/admin/newdir/newfile"), "test_text\n")
        b.click("li[data-location='/home/admin'] a")
        self.assert_last_breadcrumb("admin")
        # original file still exists
        b.wait_visible("[data-item='newfile']")

        # Copy/paste directory
        m.execute("runuser -u admin mkdir /home/admin/copyDir")
        m.execute("runuser -u admin mkdir /home/admin/newdir/loaded")
        b.click("[data-item='copyDir']")
        b.click("#dropdown-menu")
        b.click("#copy-item")
        b.mouse("[data-item='newdir']", "dblclick")
        self.assert_last_breadcrumb("newdir")
        b.wait_visible("[data-item='loaded']")
        b.click("#dropdown-menu")
        b.click("#paste-item")
        b.wait_visible("[data-item='copyDir']")
        b.click("li[data-location='/home/admin'] a")
        self.assert_last_breadcrumb("admin")
        b.wait_visible("[data-item='copyDir']")

        # File already exists error
        m.write('/home/admin/newfile', 'changed', owner='admin:admin')
        b.click("[data-item='newfile']")
        b.click("#dropdown-menu")
        b.click("#copy-item")
        b.mouse("[data-item='newdir']", "dblclick")
        b.click("#dropdown-menu")
        b.click("#paste-item")
        b.wait_visible("[data-item='newfile']")
        self.assertEqual(m.execute("head -n 1 /home/admin/newdir/newfile"), "test_text\n")
        b.wait_in_text("h4.pf-v5-c-alert__title", "Pasting failed")
        b.wait_in_text(".pf-v5-c-alert__description", "\"newfile\" exists")
        b.click("li[data-location='/home/admin'] a")
        self.assert_last_breadcrumb("admin")

    @testlib.skipBrowser(".upload_files() doesn't work on Firefox", "firefox")
    def testUpload(self) -> None:
        b = self.browser
        m = self.machine

        self.enter_files()

        def get_piechart_progress(sel: str) -> int:
            b.wait_visible(sel)
            style = b.attr(sel, "style")
            m = re.search(r"--progress: (\d+).\d+%;", style)
            assert m is not None
            return int(m.group(1))

        def dir_file_count(directory: str) -> int:
            return int(m.execute(f"find {directory} -mindepth 1 -maxdepth 1 | wc -l").strip())

        with tempfile.TemporaryDirectory() as tmpdir:
            # Test cancelling of upload
            big_file = str(Path(tmpdir) / "bigfile.img")
            subprocess.check_call(["truncate", "-s", "1500MB", big_file])

            m.execute("runuser -u admin mkdir /home/admin/Downloads")
            b.wait_visible("[data-item='Downloads']")
            b.mouse("[data-item='Downloads']", "dblclick")
            b.wait_not_present(".pf-v5-c-empty-state")

            b.click("#upload-file-btn")
            b.upload_files("#upload-file-btn + input[type='file']", [big_file])
            b.wait_visible("#upload-file-btn:disabled")

            # Wait for some progress and cancel
            b.click("#upload-progress-btn")
            b.wait(lambda: get_piechart_progress("#upload-progress-btn") >= 2)
            b.wait_in_text(".upload-progress-0", "bigfile.img")
            b.assert_pixels(".upload-popover", "upload-popover",
                            ignore=[".upload-progress-0"],
                            skip_layouts=["mobile", "rtl"])
            b.wait(lambda: b.get_pf_progress_value(".upload-progress-0") >= 2)

            b.click(".cancel-button-0")

            b.wait_not_present("#upload-progress-btn")
            b.wait_visible("#upload-file-btn")
            b.wait_in_text(".pf-v5-c-alert__description", "Cancelled upload of bigfile.img")
            b.click(".pf-v5-c-alert__action button")
            b.wait_not_present(".pf-v5-c-alert__action")
            self.assertEqual(dir_file_count("/home/admin/Downloads"), 0)

            # Early ENOSPC error (before we would block on 'ack')
            m.execute("mkdir -p /mnt/upload; mount -t tmpfs -o size=1M none /mnt/upload;")
            # TODO: cockpit-bridge keeps a handle on /mnt/upload, pkill is a hack
            self.addCleanup(m.execute, """
                pkill cockpit-bridge || true;
                while mountpoint -q /mnt/upload && ! umount /mnt/upload; do sleep 0.2; done;
                rmdir /mnt/upload;
            """)

            b.go("/files#/?path=/mnt/upload")
            self.assert_last_breadcrumb("upload")
            b.wait_text(".pf-v5-c-empty-state__title-text", "Directory is empty")
            b.click("#upload-file-btn")
            b.upload_files("#upload-file-btn + input[type='file']", [big_file])

            b.wait_in_text(".pf-v5-c-alert__description", "No space left on device")
            b.click(".pf-v5-c-alert__action button")
            b.wait_not_present(".pf-v5-c-alert__action")
            self.assertEqual(dir_file_count("/home/admin/Downloads"), 0)

            # Multi upload
            dest_dir = "/home/admin/project"
            m.execute(['runuser', '-u', 'admin', 'mkdir', dest_dir])
            b.go(f"/files#/?path={dest_dir}")
            self.assert_last_breadcrumb("project")
            b.wait_text(".pf-v5-c-empty-state__title-text", "Directory is empty")

            files = [str(Path(tmpdir) / f"{i}.txt") for i in range(0, 5)]
            test_data = "this is a test"
            for file in files:
                with open(file, "w") as fp:
                    fp.write(test_data)

            def verify_uploaded_files(*, changed: bool = False) -> None:
                for f in files:
                    contents = m.execute(f"cat {dest_dir}/{os.path.basename(f)}")
                    if changed:
                        self.assertNotEqual(contents, test_data)
                    else:
                        self.assertEqual(contents, test_data)

            b.click("#upload-file-btn")
            b.upload_files("#upload-file-btn + input[type='file']", [str(Path(tmpdir) / file) for file in files])
            with b.wait_timeout(30):
                b.wait(lambda: int(m.execute(f"ls {dest_dir} | wc -l").strip()) == len(files))

            b.wait_in_text(".pf-v5-c-alert__description", "Successfully uploaded file")
            b.click(".pf-v5-c-alert__action button")
            b.wait_not_present(".pf-v5-c-alert__action")

            # Verify ownership
            filename = os.path.basename(files[0])
            b.click(f"[data-item='{filename}']")
            b.wait_text("#sidebar-card-header h2", filename)
            b.wait_text("#description-list-owner dd", "admin")
            b.wait_text("#description-list-group dd", "admin")

            # Conflict handling
            # change the content of the files locally so we can detect
            # overwrites and make it noticeably bigger so we can assert that in
            # the dialog details
            for f in files:
                with open(f, "w") as fp:
                    fp.write("new content" * 20)

            # Cancel does not overwrite
            b.click("#upload-file-btn")
            b.upload_files("#upload-file-btn + input[type='file']", [files[0]])
            b.wait_in_text("h1.pf-v5-c-modal-box__title", f"Replace file {filename}?")
            b.assert_pixels(".pf-v5-c-modal-box.pf-m-warning", "upload-replace-dialog",
                            mock={".new-file-date": "Jun 19, 2024, 11:30 AM",
                                  ".original-file-date": "Jun 19, 2024, 11:30 AM"})
            b.click("button.pf-m-link:contains('Cancel')")
            # content did not change as we cancelled
            self.assertEqual(m.execute(f"cat {dest_dir}/{filename}"), "this is a test")

            # Overwrite
            b.click("#upload-file-btn")
            b.upload_files("#upload-file-btn + input[type='file']", [files[0]])
            b.wait_in_text("h1.pf-v5-c-modal-box__title", f"Replace file {filename}?")
            b.click("button.pf-m-warning:contains('Replace')")
            b.wait_not_present(".pf-v5-c-modal-box")

            b.wait_in_text(".pf-v5-c-alert__description", "Successfully uploaded file")
            b.click(".pf-v5-c-alert__action button")
            b.wait_not_present(".pf-v5-c-alert__action")

            self.assertEqual(m.execute(f"cat {dest_dir}/{filename}"),
                            subprocess.check_output(["cat", files[0]]).decode())
            # reset test file
            m.execute(f"echo -n this is a test > {dest_dir}/{filename}")

            # Multiple files

            # Cancel all
            b.click("#upload-file-btn")
            b.upload_files("#upload-file-btn + input[type='file']", files)

            b.wait_in_text("h1.pf-v5-c-modal-box__title", f"Replace file {filename}?")
            b.wait_in_text(".pf-v5-c-check__label", "Apply this action to all conflicting files")
            # Cancelling calls all
            b.click("button.pf-m-link:contains('Cancel')")
            b.wait_not_present(".pf-v5-c-modal-box")

            verify_uploaded_files()

            # Keep original for all

            b.click("#upload-file-btn")
            b.upload_files("#upload-file-btn + input[type='file']", files)

            b.wait_in_text("h1.pf-v5-c-modal-box__title", f"Replace file {filename}?")
            b.wait_in_text(".pf-v5-c-check__label", "Apply this action to all conflicting files")
            b.set_checked("#replace-all", True)
            b.click("button.pf-m-secondary:contains('Keep original')")
            b.wait_not_present(".pf-v5-c-modal-box")

            verify_uploaded_files()

            # Replace all
            b.click("#upload-file-btn")
            b.upload_files("#upload-file-btn + input[type='file']", files)

            b.wait_in_text("h1.pf-v5-c-modal-box__title", f"Replace file {filename}?")
            b.wait_in_text(".pf-v5-c-check__label", "Apply this action to all conflicting files")
            b.set_checked("#replace-all", True)
            b.click("button.pf-m-warning:contains('Replace')")
            b.wait_not_present(".pf-v5-c-modal-box")

            b.wait_in_text(".pf-v5-c-alert__description", "Successfully uploaded file")
            b.click(".pf-v5-c-alert__action button")
            b.wait_not_present(".pf-v5-c-alert__action")

            verify_uploaded_files(changed=True)

            # Upload one file new file which is uploaded automatically
            newfile = str(Path(tmpdir) / "newfile.txt")
            with open(newfile, "w") as fp:
                fp.write("bazinga")

            files.append(newfile)

            b.click("#upload-file-btn")
            b.upload_files("#upload-file-btn + input[type='file']", files)

            # We only get asked about existing files
            for file in files:
                filename = os.path.basename(file)
                if file != files[-1]:
                    b.wait_in_text("h1.pf-v5-c-modal-box__title", f"Replace file {filename}?")
                    b.click("button.pf-m-secondary:contains('Keep original')")

            b.wait_not_present(".pf-v5-c-modal-box")
            b.wait_in_text(".pf-v5-c-alert__description", "Successfully uploaded file")
            b.click(".pf-v5-c-alert__action button")
            b.wait_not_present(".pf-v5-c-alert__action")

            b.wait_visible(f"[data-item='{os.path.basename(newfile)}']")

            # Replace a the last file

            with open(newfile, "w") as fp:
                fp.write("new content")

            b.click("#upload-file-btn")
            b.upload_files("#upload-file-btn + input[type='file']", files)

            # We only get asked about existing files
            for file in files:
                filename = os.path.basename(file)
                b.wait_in_text("h1.pf-v5-c-modal-box__title", f"Replace file {filename}?")
                if file == files[-1]:
                    b.click("button.pf-m-warning:contains('Replace')")
                else:
                    b.click("button.pf-m-secondary:contains('Keep original')")

            b.wait_not_present(".pf-v5-c-modal-box")
            b.wait_in_text(".pf-v5-c-alert__description", "Successfully uploaded file")
            b.click(".pf-v5-c-alert__action button")
            b.wait_not_present(".pf-v5-c-alert__action")

            self.assertEqual(m.execute(f"cat {dest_dir}/{filename}"), "new content")

            # Non-admin session
            b.drop_superuser()
            m.execute(f"rm {dest_dir}/{filename}; touch {dest_dir}/update.txt")
            b.wait_not_present(f"[data-item='{filename}']")
            # Wait for the update to appear so uploading doesn't flake
            b.wait_visible("[data-item='update.txt']")

            b.click("#upload-file-btn")
            b.upload_files("#upload-file-btn + input[type='file']", [files[-1]])

            b.wait_in_text(".pf-v5-c-alert__description", "Successfully uploaded file")
            b.click(".pf-v5-c-alert__action button")
            b.wait_not_present(".pf-v5-c-alert__action")

            b.wait_visible(f"[data-item='{filename}']")

            # Permission error
            b.go("/files#/?path=/")
            b.wait_visible("[data-item='bin']")

            b.click("#upload-file-btn")
            b.upload_files("#upload-file-btn + input[type='file']", [files[0]])
            b.wait_in_text(".pf-v5-c-alert__description", "UploadError: Not permitted to perform this action")
            b.click(".pf-v5-c-alert__action button")
            b.wait_not_present(".pf-v5-c-alert__action")

    def testFileTypes(self) -> None:
        m = self.machine
        b = self.browser

        exts = {
            '': 'file',
            '.tar.gz': 'archive-file',
            '.ogg': 'audio-file',
            '.py': 'code-file',
            '.png': 'image-file',
            '.txt': 'text-file',
            '.mkv': 'video-file',
        }

        files = m.execute(fr"""
            cd ~admin

            # for each different file category, as per extension recognition...
            for ext in {shlex.join(exts)}; do
                # ... create one of each of the 7 fundamental types, minus symlinks
                mkdir "dir$ext"
                truncate -s1234 "file$ext"
                mkfifo "fifo$ext"
                python3 -c "import socket; socket.socket(socket.AF_UNIX).bind('sock$ext')"
                mknod "chrdev$ext" c 0 0
                mknod "blkdev$ext" b 0 0

                # different types of broken symlink
                ln -sf "loop$ext" "loop$ext"
                ln -sf /bzzt "broken$ext"
            done

            # create some symlinks to those things
            for source in *; do
                ln -sf "$source" sym-"$source"
                ln -sf "sym-$source" sym-sym-"$source"
            done

            ls /home/admin  # get the result
        """).split()

        # Make sure we created what we expected:
        #   - len(exts) extension types; times
        #   - 6 fundamental types plus 2 broken symlinks; times
        #   - 3 levels of additional symlinking ('file', 'sym-file', 'sym-sym-file')
        self.assertEqual(len(files), len(exts) * (6 + 2) * 3)

        self.login_and_go("/files")
        b.click("button[aria-label='Display as a list']")

        # For each file we created, assert various things about how we expect
        # it to be displayed.
        for name in files:
            selector = f"[data-item='{name}']"
            classes = b.attr(selector, 'class').split()
            self.assertEqual(b.text(f'{selector} .item-name'), name)
            size = b.text(f'{selector} [data-label="size"]')
            date = b.text(f'{selector} [data-label="date"]')

            if 'dir' in name:
                # directories are shown with folder icon, regardless of extension
                self.assertIn('folder', classes)
            elif 'file' in name:
                _, dot, ext = name.partition('.')
                self.assertIn(exts[dot + ext], classes)
            else:
                # specials are shown with file icon (for now), regardless of extension
                # that includes broken symlinks ('loop*', 'broken*')
                self.assertIn('file', classes)

            # check if the symlink icon is present/missing, as appropriate
            if name.startswith(('sym', 'loop', 'broken')):
                self.assertIn('symlink', classes)
            else:
                self.assertNotIn('symlink', classes)

            # check the size field — it should only be present directly on
            # files and not on symlinks to files (like 'sym-file.txt', etc) and
            # definitely not shown for any other file type
            if name.startswith('file'):
                self.assertEqual(size, '1.23 kB')
            else:
                self.assertEqual(size, '')

            # we should always have a reasonable date — make sure it parses
            datetime.datetime.strptime(date + ' +0000', '%b %d, %Y, %I:%M %p %z')

        # Make sure nothing else was present
        b.wait_js_cond(f"ph_count('#folder-view tbody tr') == {len(files)}")
        b.assert_pixels("#files-card-parent", "icon-list-view", mock={".item-date": "Jun 19, 2024, 11:30 AM"})

    def testBookmark(self) -> None:
        b = self.browser
        m = self.machine
        config_file = "/home/admin/.config/gtk-3.0/bookmarks"

        def read_config() -> str:
            return m.execute(f"cat {config_file}")

        self.enter_files()

        def assert_bookmark(bookmark: str, *, exists: bool = True) -> None:
            b.click("#bookmark-btn")
            b.wait_visible(".pf-v5-c-menu")
            if exists:
                b.wait_visible(f".pf-v5-c-menu .pf-v5-c-menu__list-item:contains('{bookmark}') button")
            else:
                # Has to wait on file.watch() (inotify) to re-read the bookmarks
                b.wait_not_in_text(".pf-v5-c-menu", bookmark)

            b.click("#bookmark-btn")
            b.wait_not_present(".pf-v5-c-menu .pf-v5-c-menu__list-item")

        b.click("#bookmark-btn")
        # There is only one menu item
        b.wait_in_text(".pf-v5-c-menu .pf-v5-c-menu__list-item", "Home")
        # Home directory cannot be added or removed as bookmark
        b.wait_not_present(".pf-v5-c-menu .pf-v5-c-menu__list-item:contains(Add bookmark)")
        b.wait_not_present(".pf-v5-c-menu .pf-v5-c-menu__list-item:contains(Remove current directory)")
        b.click("#bookmark-btn")
        b.wait_not_present(".pf-v5-c-menu .pf-v5-c-menu__list-item")

        # Add a bookmark
        b.go("/files#/?path=/etc")
        self.assert_last_breadcrumb("etc")
        b.wait_visible("[data-item='passwd']")

        b.click("#bookmark-btn")
        b.click(".pf-v5-c-menu .pf-v5-c-menu__list-item:contains(Add bookmark) button")
        b.wait_not_present(".pf-v5-c-menu")

        assert_bookmark("/etc", exists=True)

        # Remove bookmark
        b.click("#bookmark-btn")
        b.wait_visible(".pf-v5-c-menu .pf-v5-c-menu__list-item:contains('/etc') button")
        b.click(".pf-v5-c-menu .pf-v5-c-menu__list-item:contains('Remove current directory') button")
        b.wait_not_present(".pf-v5-c-menu")

        assert_bookmark("/etc", exists=False)

        # Go to bookmark
        b.click("#bookmark-btn")
        b.wait_not_present(".pf-v5-c-menu .pf-v5-c-menu__list-item:contains('/etc') button")
        b.click(".pf-v5-c-menu .pf-v5-c-menu__list-item:contains(Add bookmark) button")
        b.wait_not_present(".pf-v5-c-menu")

        b.go("/files#/?path=/proc")
        b.wait_not_present(".pf-v5-c-empty-state")

        b.click("#bookmark-btn")
        b.click(".pf-v5-c-menu .pf-v5-c-menu__list-item:contains('/etc') button")
        b.wait_not_present(".pf-v5-c-empty-state")
        self.assert_last_breadcrumb("etc")

        # Bookmarks from nautilus are supported
        m.execute("runuser -u admin mkdir '/home/admin/This is a-test'")
        m.write(config_file,
                "file:///home/admin/This%20is%20a%2dtest\n"
                "file:///tmp Temporary Directory\n",
                append=True, owner='admin:admin')
        b.click("#bookmark-btn")
        b.click(".pf-v5-c-menu .pf-v5-c-menu__list-item:contains('This is a-test') button")
        self.assert_last_breadcrumb("This is a-test")

        # Removing directory with spaces or aliases
        b.click("#bookmark-btn")
        b.click(".pf-v5-c-menu .pf-v5-c-menu__list-item:contains('Remove current directory') button")
        b.wait_not_present(".pf-v5-c-menu")

        assert_bookmark("This is a-test", exists=False)

        # Bookmarking a directory with spaces encodes it correctly
        b.click("#bookmark-btn")
        b.click(".pf-v5-c-menu .pf-v5-c-menu__list-item:contains(Add bookmark) button")

        assert_bookmark("This is a-test", exists=True)
        self.assertIn("file:///home/admin/This%20is%20a-test/\n", read_config())

        # Removing /tmp bookmark keeps bookmark with spaces
        b.click("#bookmark-btn")
        b.click(".pf-v5-c-menu .pf-v5-c-menu__list-item:contains('tmp') button")
        self.assert_last_breadcrumb("tmp")

        b.click("#bookmark-btn")
        b.click(".pf-v5-c-menu .pf-v5-c-menu__list-item:contains('Remove current directory') button")
        b.wait_not_present(".pf-v5-c-menu")

        assert_bookmark("/tmp", exists=False)
        self.assertEqual(read_config(), "file:///etc/\nfile:///home/admin/This%20is%20a-test/\n")

        # Add a directory with special characters
        special_dir = "super#special*1 2><.,ß"
        m.execute(f"runuser -u admin mkdir '/home/admin/{special_dir}'")
        b.go("/files#/?path=/home/admin")
        b.wait_visible(f"[data-item='{special_dir}'")
        b.mouse(f"[data-item='{special_dir}']", "dblclick")
        self.assert_last_breadcrumb(special_dir)

        b.click("#bookmark-btn")
        b.click(".pf-v5-c-menu .pf-v5-c-menu__list-item:contains(Add bookmark) button")

        assert_bookmark(special_dir, exists=True)
        self.assertEqual("file:///etc/\nfile:///home/admin/This%20is%20a-test/\nfile:///home/admin/super%23special*1%202%3E%3C.%2C%C3%9F/\n",
                         read_config())

        b.click("li[data-location='/home/admin'] a")
        self.assert_last_breadcrumb("admin")

        b.click("#bookmark-btn")
        b.click(".pf-v5-c-menu .pf-v5-c-menu__list-item:contains('super#special') button")
        self.assert_last_breadcrumb(special_dir)

        b.click("#bookmark-btn")
        b.click(".pf-v5-c-menu .pf-v5-c-menu__list-item:contains('Remove current directory') button")
        b.wait_not_present(".pf-v5-c-menu")

        assert_bookmark(special_dir, exists=False)
        self.assertEqual(read_config(), "file:///etc/\nfile:///home/admin/This%20is%20a-test/\n")

        # Modifications outside of Cockpit are shown
        m.write(config_file,
                "nfs://lalala\n"
                "file:///var\n",
                append=True, owner='admin:admin')

        b.click("#bookmark-btn")
        b.click(".pf-v5-c-menu .pf-v5-c-menu__list-item:contains('/var') button")
        b.wait_not_present(".pf-v5-c-empty-state")
        b.wait_not_present(".pf-v5-c-menu .pf-v5-c-menu__list-item:contains('lalala') button")
        self.assert_last_breadcrumb("var")

        # Removing bookmarks file removes all bookmarks
        m.execute(['rm', '-rf', config_file])
        b.click("#bookmark-btn")
        b.wait_not_present(".pf-v5-c-menu .pf-v5-c-menu__list-item:contains('/etc') button")
        b.click("#bookmark-btn")
        b.wait_not_present(".pf-v5-c-menu")

        # Remove a bookmark when the directory is removed
        m.write(config_file,
                "file:///tmp/non-existent\n",
                owner='admin:admin')

        b.click("#bookmark-btn")
        b.click(".pf-v5-c-menu .pf-v5-c-menu__list-item:contains('non-existent') button")
        self.assert_last_breadcrumb("non-existent")
        b.click("#bookmark-btn")
        b.click(".pf-v5-c-menu .pf-v5-c-menu__list-item:contains('Remove current directory') button")
        b.wait_not_present(".pf-v5-c-menu")
        # Removing the last bookmark, empties the file
        m.execute(f"until [ $(stat -c '%s' {config_file}) -eq 0 ]; do sleep 1; done")

        b.click("#bookmark-btn")
        b.wait_not_present(".pf-v5-c-menu .pf-v5-c-menu__list-item:contains('Add bookmark') button")
        b.click("#bookmark-btn")
        b.wait_not_present(".pf-v5-c-menu")

        # Error conditions

        # When we can't create the directory
        m.execute("""
            rm -fr /home/admin/.config/gtk-3.0
            chattr +i /home/admin/.config
        """)

        b.go("/files#/?path=/var")
        self.assert_last_breadcrumb("var")

        b.click("#bookmark-btn")
        b.click(".pf-v5-c-menu .pf-v5-c-menu__list-item:contains(Add bookmark) button")
        self.wait_modal_inline_alert("Unable to create bookmark directory")
        b.click(".pf-v5-c-alert__action button")
        b.wait_not_present(".pf-v5-c-alert__action")
        m.execute("chattr -i /home/admin/.config")

        # When directory is not writable for us
        m.execute("mkdir /home/admin/.config/gtk-3.0")
        b.click("#bookmark-btn")
        b.click(".pf-v5-c-menu .pf-v5-c-menu__list-item:contains(Add bookmark) button")
        self.wait_modal_inline_alert("Unable to save bookmark file")
        b.click(".pf-v5-c-alert__action button")
        b.wait_not_present(".pf-v5-c-alert__action")


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