#!/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:
        super().setUp()
        self.restore_dir("/home/admin")

    def enter_files(self) -> None:
        self.login_and_go("/files")
        self.browser.wait_text(".pf-v6-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-v6-c-breadcrumb__link.pf-m-current svg.breadcrumb-hdd-icon")
        else:
            self.browser.wait_text(".pf-v6-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-v6-c-modal-box__title", f"Delete {filetype} {filename}?")
        b.click("button.pf-m-danger")
        if expect_success:
            b.wait_not_present(".pf-v6-c-modal-box")
            b.wait_not_present(f"[data-item='{filename}']")

    def create_directory(self, filename: str, owner: str | None = None, *, expect_success: bool = True) -> None:
        b = self.browser
        b.click("#dropdown-menu")
        b.click("#create-folder")
        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")
        if expect_success:
            b.wait_not_present(".pf-v6-c-modal-box")

    def wait_modal_inline_alert(self, msg: str) -> None:
        b = self.browser
        b.wait_in_text("h4.pf-v6-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-v6-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, content: str | None = None) -> None:
        b = self.browser
        filepath = b.driver.download_dir / filename

        # Big downloads can take a while
        testlib.wait(lambda: filepath.stat().st_size == expected_size, tries=120)

        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".pf-v6-c-menu button:contains('{action}')")

    def open_context_menu(self, selector: str) -> None:
        b = self.browser
        # close if it's already open; useful for assert_pixel layout hooks
        if b.is_present(".contextMenu"):
            b.click("body")
            b.wait_not_present(".contextMenu")

        b.mouse(selector, "click", btn=2)
        b.wait_visible(".contextMenu")

    def open_folder_context_menu(self) -> None:
        # With BiDi on Firefox a click is by default in the "middle" of the
        # element while we want to open the current folder context and a
        # "middle" click can be a file or folder item.
        self.browser.mouse("#files-card-parent", "contextmenu", 1, 1)

    def wait_directory_changed(self, expected_path: str) -> None:
        path = Path(expected_path)
        self.browser.wait_attr("#files-folder-section", "data-dir-loaded", expected_path)
        self.assert_last_breadcrumb('/' if path.name == "" else path.name)

    def chdir(self, path: str, *, expected_path: str | None = None) -> None:
        self.browser.go(f"/files#/?path={path}")
        # The files state always ensures we have a well formulated path ending on a /
        if expected_path is None:
            expected_path = str(Path(path).absolute())
            if not expected_path.endswith('/'):
                expected_path += '/'

        self.wait_directory_changed(expected_path)

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

        self.enter_files()

        b.allow_download()
        # expected heading
        b.wait_visible(".header-toolbar")

        # 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")
        self.wait_directory_changed("/home/admin/empty/")
        b.wait_in_text(".pf-v6-c-empty-state__body", "1 item is hidden")
        b.assert_pixels(".pf-v6-c-page__main", "empty-folder-view")

        # Clicking `show hidden items` shows it
        b.click(".pf-v6-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-v6-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-v6-c-empty-state__title-text", "Directory is empty")
        b.wait_not_in_text(".pf-v6-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")

        # 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-v6-c-empty-state__title-text", "No matching results")

        # clear using empty-state
        b.click(".pf-v6-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-v6-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")

        # 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']")

        # 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.wait_visible("#dropdown-menu")
        self.chdir('/doesnotexists')
        b.wait_in_text(".pf-v6-c-empty-state__body", "No such file or directory")
        b.wait_not_present("#dropdown-menu")

        # Path with multiple slashes is normalized
        self.chdir('////', expected_path='/')

        # 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']")

        dir_cnt = int(m.execute(r'''
            find /home -mindepth 1 -maxdepth 1 -type d \( -name ".*" -prune -o -print \) | wc -l
        ''').strip())
        hidden_cnt = int(m.execute(r'ls -A /home | grep "^\." | wc -l').strip())
        # -1 to ignore first line of output which is not a file
        files_cnt = int(m.execute(r'ls -lA /home | wc -l').strip()) - dir_cnt - hidden_cnt - 1
        b.wait_in_text(".files-footer-info",
                       f"Directory contains {dir_cnt} directories, {files_cnt} files, {hidden_cnt} hidden")

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

        # cwd info is updated when navigating to a new directory
        dir_cnt = int(m.execute(r'''
            find /home/admin -mindepth 1 -maxdepth 1 -type d \( -name ".*" -prune -o -print \) | wc -l
        ''').strip())
        hidden_cnt = int(m.execute(r'ls -A /home/admin | grep "^\." | wc -l').strip())
        files_cnt = int(m.execute(r'ls -lA /home/admin | wc -l').strip()) - dir_cnt - hidden_cnt - 1
        b.wait_in_text(".files-footer-info",
                       f"Directory contains {dir_cnt} directories, {files_cnt} files, {hidden_cnt} hidden")

        # Enabling "show hidden files" changes the counters in files footer
        dir_cnt = int(m.execute("ls -lA /home/admin | grep '^d' | wc -l").strip())
        files_cnt = int(m.execute("ls -lA /home/admin | grep '^-' | wc -l").strip())
        b.select_PF("#sort-menu-toggle", "Show hidden items")
        b.wait_in_text(".files-footer-info",
                       f"Directory contains {dir_cnt} directories, {files_cnt} files")
        b.select_PF("#sort-menu-toggle", "Hide hidden items")

        # double-clicking on a symlink to a directory also takes us into it
        m.execute("ln -s /tmp /home/admin/tmplink")
        b.mouse("[data-item='tmplink']", "dblclick")
        b.wait_not_present("[data-item='tmplink']")
        self.assert_last_breadcrumb("tmplink")

        # create folders and test navigation history buttons
        m.execute("mkdir /home/admin/newdir")
        m.execute("mkdir /home/admin/newdir/newdir2")
        self.chdir("/home/admin")
        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()")
        self.assert_last_breadcrumb("newdir2")
        b.wait_not_present("[data-item='admin']")
        b.eval_js("window.history.back()")
        self.assert_last_breadcrumb("newdir")
        b.wait_visible("[data-item='newdir2']")
        b.eval_js("window.history.back()")
        self.assert_last_breadcrumb("admin")
        b.wait_visible("[data-item='newdir']")
        # navigate forward
        b.eval_js("window.history.forward()")
        b.wait_not_present("[data-item='admin']")
        self.assert_last_breadcrumb("newdir")
        b.eval_js("window.history.forward()")
        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_not_present("[data-item='newdir']")
        self.assert_last_breadcrumb("home")
        b.wait_visible("[data-item='admin']")

        # Change permissions of cwd and see that files-footer-info is updated
        self.chdir("/home/admin/newdir")
        b.wait_in_text("#files-footer-permissions", "rwx r-x r-x")
        m.execute("chmod 700 /home/admin/newdir")
        b.wait_in_text("#files-footer-permissions", "rwx --- ---")
        b.wait_text("#files-footer-owner", "root")

        # Also check popover
        b.click("#files-footer-permissions")
        b.wait_in_text(".pf-v6-c-popover dl div:nth-child(1) > dd", "read, write, and execute")
        b.wait_in_text(".pf-v6-c-popover dl div:nth-child(2) > dd", "none")
        b.wait_in_text(".pf-v6-c-popover dl div:nth-child(3) > dd", "none")
        b.click(".pf-v6-c-popover__close > button")
        b.wait_not_present(".pf-v6-c-popover")

        b.click("#files-footer-owner")
        b.wait_in_text(".pf-v6-c-popover dl div:nth-child(1) > dd", "root")
        b.wait_in_text(".pf-v6-c-popover dl div:nth-child(2) > dd", "root")
        b.click(".pf-v6-c-popover__close > button")
        b.wait_not_present(".pf-v6-c-popover")

        # Change group is shown in popover
        m.execute("chown root:admin /home/admin/newdir")
        b.wait_text("#files-footer-owner", "root:admin")

        b.click("#files-footer-owner")
        b.wait_in_text(".pf-v6-c-popover dl div:nth-child(1) > dd", "root")
        b.wait_in_text(".pf-v6-c-popover dl div:nth-child(2) > dd", "admin")
        b.click(".pf-v6-c-popover__close > button")
        b.wait_not_present(".pf-v6-c-popover")

        # Change last edit time on cwd
        m.execute("touch -d '2 hours ago' /home/admin/newdir")
        b.wait_in_text(".files-footer-mtime", "2 hours ago")
        m.execute("touch /home/admin/newdir")
        b.wait_in_text(".files-footer-mtime", "less than a minute ago")
        # mock edit time for tooltip
        m.execute("touch -d '1995/12/21' /home/admin/newdir")
        b.mouse(".files-footer-mtime", "mouseenter")
        b.wait_in_text(".pf-v6-c-tooltip", "Dec 21, 1995")
        b.mouse(".files-footer-mtime", "mouseleave")

        # Navigating resets the current search filter
        self.chdir('/')
        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']", "")

        self.chdir('/home/admin')

        # Selecting one file shows its info in the footer
        b.click("[data-item='newdir']")
        b.wait_in_text(".files-footer-info", "newdir")
        b.wait_in_text("#files-footer-permissions", "rwx --- ---")
        b.wait_in_text("#files-footer-owner", "root")

        # Select multiple files
        m.execute("touch /home/admin/newfile.txt")
        b.mouse("[data-item='newfile.txt']", "click", ctrlKey=True)
        b.wait_in_text(".files-footer-info", "2 files selected")
        b.mouse("[data-item='tmplink']", "click", ctrlKey=True)
        b.wait_in_text(".files-footer-info", "3 files selected")
        self.chdir('/home')

        # 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)

    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_visible(".header-toolbar")

        # 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_text("th[aria-sort='ascending'].pf-m-selected button", "Name")

        # clicking reverse sort order
        b.click("th.pf-m-selected button")
        b.wait_text("th[aria-sort='descending'].pf-m-selected button", "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_text("th[aria-sort='ascending'].pf-m-selected button", "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_text("th[aria-sort='ascending'].pf-m-selected button", "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_text("th[aria-sort='ascending'].pf-m-selected button", "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_text("th[aria-sort='descending'].pf-m-selected button", "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("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-v6-c-modal-box__title", "Force delete file newfile?")
        b.assert_pixels(".pf-v6-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-v6-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-v6-c-modal-box__close button")
        b.wait_not_present(".pf-v6-c-modal-box")
        b.wait_visible("[data-item='newfile']")
        m.execute("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.focus("#files-card-parent")
        # For strange reasons ctrlKey remains pressed after the b.mouse() above (spotted in firefox)
        b.key("Control")
        b.key("Delete")
        b.wait_in_text("h1.pf-v6-c-modal-box__title", "Delete 2 items?")
        b.assert_pixels(".pf-v6-c-modal-box", "delete-modal")
        b.click("button.pf-m-danger")

        b.wait_not_present(".pf-v6-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-folder")
        b.assert_pixels(".pf-v6-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-v6-c-modal-box__footer button.pf-m-link")  # cancel

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

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

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

        # Creating folder as superuser a non-logged in owned directory has the expected folder permissions
        self.chdir('/root')
        b.wait_text("#files-footer-owner", "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')
        self.chdir('/home/testuser')
        b.wait_text("#files-footer-owner", "testuser")
        self.create_directory('newdir')
        b.wait_visible("[data-item='newdir']")
        self.assert_owner('/home/testuser', 'testuser:testuser')

        self.chdir('/tmp')
        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
        self.chdir('/home/admin')
        b.drop_superuser()
        self.create_directory('admindir')
        b.wait_visible("[data-item='admindir']")
        self.assert_owner('/home/admin/admindir', 'admin:admin')

        # Pressing enter creates a directory
        b.click("#dropdown-menu")
        b.click("#create-folder")
        # Keep focus on the input so the enter key press registers as form submit.
        b.set_input_text("#create-directory-input", "testdir", blur=False)
        b.key("Enter")
        b.wait_not_present(".pf-v6-c-modal-box")
        b.wait_visible("[data-item='testdir']")

    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
            ]
        """)

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

        # Create folder from context menu
        self.open_folder_context_menu()
        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", body_size[1] / 4, body_size[1] / 4)
        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")

        # pixel test for directory context menu; resolution changes, so we need to re-open it for every layout
        b.assert_pixels(".contextMenu", "folder-context-menu",
                        layout_change_hook=lambda: self.open_context_menu("[data-item='newdir']"))

        # Rename folder from context menu
        self.open_context_menu("[data-item='newdir']")
        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']")
        self.open_context_menu("[data-item='newdir1']")
        b.click(".contextMenu button:contains('Edit permissions')")
        b.select_from_dropdown("#edit-permissions-owner", "testuser")
        b.click("button.pf-m-primary")
        b.wait_in_text("[data-item='newdir1'] .item-owner", "testuser")

        if not m.ws_container and self.system_before(329):
            # Open in terminal feature not supported before cockpit 329
            self.open_context_menu("[data-item='newdir1']")
            b.wait_text(".contextMenu button", "Delete")
            b.wait_not_present(".contextMenu button:contains('Open in terminal')")
            self.open_folder_context_menu()
            b.wait_text(".contextMenu button", "Create directory")
            b.wait_not_present(".contextMenu button:contains('Open in terminal')")
        else:
            # Open folder in terminal from context menu
            self.open_context_menu("[data-item='newdir1']")
            b.click(".contextMenu button:contains('Open in terminal')")
            b.enter_page("/system/terminal")
            b.switch_to_top()
            b.wait_js_cond('String(window.location.pathname) === "/system/terminal"')
            b.wait_js_cond('window.location.hash === "#/?path=%2Fhome%2Fadmin%2Fnewdir1"')
            b.go("/files")
            b.enter_page("/files")

            # Open current dir in terminal from context menu
            self.open_folder_context_menu()
            b.click(".contextMenu button:contains('Open in terminal')")
            b.enter_page("/system/terminal")
            b.switch_to_top()
            b.wait_js_cond('String(window.location.pathname) === "/system/terminal"')
            b.wait_js_cond('window.location.hash === "#/?path=%2Fhome%2Fadmin%2F"')
            b.go("/files")
            b.enter_page("/files")

        # Regular files should not have terminal feature
        m.execute("echo 'some content' > /home/admin/newfile")
        self.open_context_menu("[data-item='newfile']")
        b.wait_in_text(".contextMenu", "Delete")
        b.wait_not_present(".contextMenu button:contains('Open in terminal')")

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

        # pixel test for file context menu; resolution changes, so we need to re-open it for every layout
        b.assert_pixels(".contextMenu", "file-context-menu",
                        layout_change_hook=lambda: self.open_context_menu("[data-item='newfile']"))

        # Download file from context menu
        self.open_context_menu("[data-item='newfile']")
        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
        self.open_context_menu("[data-item='newfile']")
        b.click(".contextMenu button:contains('Delete')")
        b.click("button.pf-m-danger")
        b.wait_not_present("[data-item='newfile']")

        # The grid view also supports a contextmenu
        m.execute("touch /home/admin/testfile")
        b.click("button[aria-label='Display as a grid']")
        self.open_context_menu("[data-item='testfile']")
        b.wait_in_text(".contextMenu", "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']")
        self.open_context_menu("[data-item='test.iso']")
        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='漢字']")
        self.open_context_menu("[data-item='漢字']")
        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-v6-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-v6-c-modal-box", "rename-modal")
        b.click(".pf-v6-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-v6-c-modal-box")

        # Renaming protected item should give an error
        m.execute("chattr +i /home/admin/new\\ dir1")
        self.addCleanup(m.execute, "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-v6-c-modal-box__close > button")

        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-v6-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-v6-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-v6-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-v6-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-v6-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-v6-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-v6-c-modal-box__close > button")
        b.wait_not_present(".pf-v6-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-v6-c-modal-box")
        b.wait_visible("[data-item='renamed-foo.txt']")

        # Rename file using keyboard shortcut
        b.click("[data-item='newfile']")
        b.key("F2")
        b.wait_in_text(".pf-v6-c-modal-box__title-text", "Rename newfile")
        b.set_input_text("#rename-item-input", "teddybear.txt")
        b.click(".pf-v6-c-button.pf-m-primary")
        b.wait_visible("[data-item='teddybear.txt']")

        # Rename modal does not open when multiple files are selected
        b.mouse("[data-item='dest']", "click", ctrlKey=True)
        b.mouse("[data-item='new dir1']", "click", ctrlKey=True)
        b.key("F2")
        b.wait_not_present(".pf-v6-c-modal-box__title-text")

    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(".files-footer-info", "Directory contains 0 directories, 1 files, 1 hidden")

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

        # 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
        has_selinux = not any(img in m.image for img in ["arch", "debian", "ubuntu", "suse"])

        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)

        def open_permissions_dialog(filename: str) -> None:
            self.open_context_menu(f"[data-item='{filename}']")
            b.click(".contextMenu button:contains('Edit permissions')")
            b.wait_in_text(".pf-v6-c-modal-box__title-text", filename)

        def check_perms_match(filename: str, basedir: str) -> None:
            perm = self.stat("%A", os.path.join(basedir, filename))
            # format ls output with spaces to match string in UI
            spaced_perm = f"{perm[1:4]} {perm[4:7]} {perm[7:10]}"
            b.wait_in_text(f"[data-item='{filename}'] .item-perms pre", spaced_perm)

        self.enter_files()

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

        # Check sidebar info
        m.execute("touch /home/admin/newfile")
        b.click("[data-item='newfile']")
        self.assertEqual(self.stat("%A", "/home/admin/newfile"), "-rw-r--r--")
        check_perms_match('newfile', '/home/admin')

        # Test changing owner/group
        m.execute("useradd testuser")
        open_permissions_dialog('newfile')
        b.assert_pixels(".pf-v6-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(".pf-v6-c-modal-box button.pf-m-primary")
        b.wait_in_text("[data-item='newfile'] .item-owner", "testuser")

        m.execute("usermod -a -G testuser admin")
        open_permissions_dialog('newfile')
        # 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(".pf-v6-c-modal-box button.pf-m-primary")
        b.wait_in_text("[data-item='newfile'] .item-owner", "admin:testuser")

        # Change the group to admin
        open_permissions_dialog('newfile')
        b.select_from_dropdown("#edit-permissions-group", "admin")
        b.wait_in_text("#edit-permissions-group", "admin")
        b.click(".pf-v6-c-modal-box button.pf-m-primary")
        b.wait_in_text("[data-item='newfile'] .item-owner", "admin")

        # Test changing permissions
        open_permissions_dialog('newfile')
        select_access("no-access")
        b.click(".pf-v6-c-modal-box button.pf-m-primary")
        b.wait_not_present(".pf-v6-c-modal-box")
        self.assertEqual(self.stat("%A", "/home/admin/newfile"), "----------")
        check_perms_match('newfile', '/home/admin')

        open_permissions_dialog('newfile')
        select_access("read-write")
        b.click(".pf-v6-c-modal-box button.pf-m-primary")
        b.wait_not_present(".pf-v6-c-modal-box")
        self.assertEqual(self.stat("%A", "/home/admin/newfile"), "-rw-rw-rw-")
        check_perms_match('newfile', '/home/admin')

        # Test changing CWD permissions
        test_dir = "/home/admin/testdir"
        m.execute(['runuser', '-u', 'admin', 'mkdir', test_dir])
        b.mouse("[data-item='testdir']", "dblclick")
        self.wait_directory_changed(expected_path='/home/admin/testdir/')

        # Via contextmenu
        self.open_folder_context_menu()
        b.click(".contextMenu button:contains('Edit permissions')")
        b.wait_in_text(".pf-v6-c-modal-box__title-text", "testdir")
        b.select_from_dropdown("#edit-permissions-owner-access", "read-write")
        b.select_from_dropdown("#edit-permissions-group-access", "read-only")
        b.select_from_dropdown("#edit-permissions-other-access", "read-only")
        b.click(".pf-v6-c-modal-box button.pf-m-primary")
        b.wait_not_present(".pf-v6-c-modal-box")

        self.assertEqual(self.stat("%A", test_dir), "drwxr-xr-x")

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

        self.assertEqual(self.stat("%A", test_dir), "dr-x------")

        # Can set a file as executable
        b.click("li[data-location='/home/admin'] a")
        self.wait_directory_changed("/home/admin/")

        shell_script = "install.sh"
        m.execute(['runuser', '-u', 'admin', 'touch', f'/home/admin/{shell_script}'])
        m.execute(['chmod', '644', f'/home/admin/{shell_script}'])

        open_permissions_dialog(shell_script)
        b.wait_val("#edit-permissions-owner-access", "read-write")
        b.wait_val("#edit-permissions-group-access", "read-only")
        b.wait_val("#edit-permissions-other-access", "read-only")
        if has_selinux:
            b.wait_val("#selinux-context", "unconfined_u:object_r:user_home_t:s0")
        else:
            b.wait_not_present("#selinux-content")
        self.assertFalse(b.get_checked("#is-executable"))

        b.set_checked("#is-executable", val=True)
        b.click(".pf-v6-c-modal-box button.pf-m-primary")
        b.wait_not_present(".pf-v6-c-modal-box")

        self.assertEqual(self.stat("%A", f"/home/admin/{shell_script}"), "-rwxr-xr-x")
        check_perms_match(shell_script, '/home/admin')

        open_permissions_dialog(shell_script)
        b.wait_in_text(".pf-v6-c-modal-box__title-text", shell_script)

        # Permissions did not change visually only executable is checked now
        b.wait_val("#edit-permissions-owner-access", "read-write")
        b.wait_val("#edit-permissions-group-access", "read-only")
        b.wait_val("#edit-permissions-other-access", "read-only")
        self.assertTrue(b.get_checked("#is-executable"))

        b.set_checked("#is-executable", val=False)
        b.click(".pf-v6-c-modal-box button.pf-m-primary")
        b.wait_not_present(".pf-v6-c-modal-box")
        self.assertEqual(self.stat("%A", f"/home/admin/{shell_script}"), "-rw-r--r--")

        # Not executable for .png
        img_file = "cockpit.png"
        m.execute(['runuser', '-u', 'admin', 'touch', f'/home/admin/{img_file}'])

        open_permissions_dialog(img_file)
        b.wait_not_present("#is-executable")
        b.click("div.pf-v6-c-modal-box__close button")
        b.wait_not_present(".pf-v6-c-modal-box")

        # fifo not executable
        fifo_file = "test.fifo"
        m.execute(['runuser', '-u', 'admin', 'mkfifo', f'/home/admin/{fifo_file}'])

        open_permissions_dialog(fifo_file)
        b.wait_not_present("#is-executable")
        b.click("div.pf-v6-c-modal-box__close button")
        b.wait_not_present(".pf-v6-c-modal-box")

        # `uname` can be marked as executable
        executable_file = "uname"
        m.execute(['runuser', '-u', 'admin', 'touch', f'/home/admin/{executable_file}'])
        m.execute(['chmod', '644', f'/home/admin/{executable_file}'])

        open_permissions_dialog(executable_file)
        b.set_checked("#is-executable", val=True)
        b.click(".pf-v6-c-modal-box button.pf-m-primary")
        b.wait_not_present(".pf-v6-c-modal-box")

        check_perms_match(executable_file, '/home/admin')

        # Symlinks
        symlink = "link-to-uname"
        m.execute(f"runuser -u admin -- ln -s /usr/bin/uname /home/admin/{symlink}")

        # show no file mode permissions
        open_permissions_dialog(symlink)
        b.wait_not_present("#edit-permissions-owner-access")

        # Changing ownership does not affect the symlink target
        b.select_from_dropdown("#edit-permissions-group", "root")
        b.click(".pf-v6-c-modal-box button.pf-m-primary")
        b.wait_not_present(".pf-v6-c-modal-box")

        b.wait_in_text(f"[data-item='{symlink}'] .item-owner", "admin:root")
        self.assertEqual(self.stat("%U:%G", "/usr/bin/uname"), "root:root")

        # Special file access permission cases

        # We normally don't provide "write-only" as an option unless someone
        # has misconfigured their access permissions. Also verify that +x bits
        # are retained.
        m.execute(['chmod', '532', f'/home/admin/{shell_script}'])
        b.click(f"[data-item='{shell_script}']")
        check_perms_match(shell_script, '/home/admin')
        open_permissions_dialog(shell_script)

        b.wait_in_text(".pf-v6-c-modal-box__title-text", shell_script)
        b.wait_val("#edit-permissions-owner-access", "read-only")
        b.wait_val("#edit-permissions-group-access", "write-only")
        b.wait_val("#edit-permissions-other-access", "write-only")
        self.assertFalse(b.get_checked("#is-executable"))

        b.select_from_dropdown("#edit-permissions-group-access", "read-write")
        b.select_from_dropdown("#edit-permissions-other-access", "no-access")
        b.click(".pf-v6-c-modal-box button.pf-m-primary")
        b.wait_not_present(".pf-v6-c-modal-box")
        self.assertEqual(self.stat("%A", f"/home/admin/{shell_script}"), "-r-xrw----")

        self.chdir('/')

        # Shows / as /
        b.click("#dropdown-menu")
        b.click("#edit-permissions")
        b.wait_in_text(".pf-v6-c-modal-box__title-text", "/")
        b.click("button.pf-m-link")
        b.wait_not_present(".pf-v6-c-modal-box")

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

        self.chdir('/home/admin')

        # Enclosed folder permissions
        change_enclosed_dir = "/home/admin/Documents"
        create_test_dirs_sh = f"""
            mkdir {change_enclosed_dir}
            mkdir {change_enclosed_dir}/Finance
            mkdir {change_enclosed_dir}/Work
            chmod 750 {change_enclosed_dir}/Work
            touch {change_enclosed_dir}/Finance/program
            chmod 777 {change_enclosed_dir}/Finance/program
            touch {change_enclosed_dir}/Finance/accounts.txt
            chmod 600 {change_enclosed_dir}/Finance/accounts.txt
            touch {change_enclosed_dir}/Work/myvim
            chmod 750 {change_enclosed_dir}/Work/myvim
            touch {change_enclosed_dir}/Work/notes.txt
            chmod 644 {change_enclosed_dir}/Work/notes.txt
        """
        self.write_file("/usr/local/bin/create-test-dirs.sh", create_test_dirs_sh, perm="755")
        m.execute("runuser -u admin /usr/local/bin/create-test-dirs.sh")

        open_permissions_dialog("Documents")
        b.wait_val("#edit-permissions-owner-access", "read-write")
        b.wait_val("#edit-permissions-group-access", "read-only")
        b.wait_val("#edit-permissions-other-access", "read-only")
        b.click(".pf-v6-c-modal-box button.pf-m-secondary")
        b.wait_not_present(".pf-v6-c-modal-box")

        # Files with any executable bit set, get a +x in all modes when +X is passed.
        self.assertEqual(self.stat("%A,%U,%G",
                                   f"{change_enclosed_dir}/Finance"), "drwxr-xr-x,admin,admin")
        self.assertEqual(self.stat("%A,%U,%G",
                                   f"{change_enclosed_dir}/Finance/program"), "-rwxr-xr-x,admin,admin")
        self.assertEqual(self.stat("%A,%U,%G",
                                   f"{change_enclosed_dir}/Finance/accounts.txt"), "-rw-r--r--,admin,admin")
        self.assertEqual(self.stat("%A,%U,%G",
                                   f"{change_enclosed_dir}/Work"), "drwxr-xr-x,admin,admin")
        self.assertEqual(self.stat("%A,%U,%G",
                                   f"{change_enclosed_dir}/Work/myvim"), "-rwxr-xr-x,admin,admin")
        self.assertEqual(self.stat("%A,%U,%G",
                                   f"{change_enclosed_dir}/Work/notes.txt"), "-rw-r--r--,admin,admin")

        # Via kebab it selects cwd
        b.mouse("[data-item='Documents']", "dblclick")
        self.wait_directory_changed("/home/admin/Documents/")

        b.click("#dropdown-menu")
        b.click("button:contains('Edit permissions')")
        b.wait_in_text(".pf-v6-c-modal-box__title-text", "Documents")

        b.select_from_dropdown("#edit-permissions-group", "root")
        b.select_from_dropdown("#edit-permissions-owner-access", "read-only")
        b.select_from_dropdown("#edit-permissions-group-access", "no-access")
        b.select_from_dropdown("#edit-permissions-other-access", "no-access")
        b.click(".pf-v6-c-modal-box button.pf-m-secondary")
        b.wait_not_present(".pf-v6-c-modal-box")

        self.assertEqual(self.stat("%A,%U,%G",
                                   f"{change_enclosed_dir}/Finance"), "dr-x------,admin,root")
        self.assertEqual(self.stat("%A,%U,%G",
                                   f"{change_enclosed_dir}/Finance/program"), "-r-x------,admin,root")
        self.assertEqual(self.stat("%A,%U,%G",
                                   f"{change_enclosed_dir}/Finance/accounts.txt"), "-r--------,admin,root")
        self.assertEqual(self.stat("%A,%U,%G",
                                   f"{change_enclosed_dir}/Work"), "dr-x------,admin,root")
        self.assertEqual(self.stat("%A,%U,%G",
                                   f"{change_enclosed_dir}/Work/myvim"), "-r-x------,admin,root")
        self.assertEqual(self.stat("%A,%U,%G",
                                   f"{change_enclosed_dir}/Work/notes.txt"), "-r--------,admin,root")

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

        # Edit multiple files
        create_multiple_dirs_sh = "mkdir -p /home/admin/multiple/testdir\n"
        for i in range(20):
            create_multiple_dirs_sh += f"touch /home/admin/multiple/filename{i}\n"

        self.write_file(f"{self.vm_tmpdir}/create-multiple-dirs.sh", create_multiple_dirs_sh, perm="755")
        m.execute(f"runuser -u admin {self.vm_tmpdir}/create-multiple-dirs.sh")
        b.mouse("[data-item='multiple']", "dblclick")
        self.wait_directory_changed("/home/admin/multiple/")

        # Editing two files
        b.click("[data-item='filename1']")
        b.mouse("[data-item='filename2']", "click", ctrlKey=True)
        self.open_context_menu("[data-item='filename1']")
        b.click("button:contains('Edit permissions')")

        b.wait_text(".pf-v6-c-modal-box__title-text", "Permissions for 2 files")
        b.wait_in_text("#edit-permissions-owner", "admin")
        b.wait_in_text("#edit-permissions-group", "admin")
        b.select_from_dropdown("#edit-permissions-owner-access", "read-only")
        b.select_from_dropdown("#edit-permissions-group-access", "no-access")
        b.select_from_dropdown("#edit-permissions-other-access", "no-access")
        b.click(".pf-v6-c-modal-box button.pf-m-primary")
        b.wait_not_present(".pf-v6-c-modal-box")
        self.assertEqual(self.stat("%A", "/home/admin/multiple/filename1"), "-r--------")
        self.assertEqual(self.stat("%A", "/home/admin/multiple/filename2"), "-r--------")

        # No owner/group shown if ownership differs
        def verify_no_owner_group_shown() -> None:
            # HACK: the selected state contains a copy of files state so permissions information is not updated.
            b.mouse("[data-item='filename1']", "click", ctrlKey=True)
            b.mouse("[data-item='filename1']", "click", ctrlKey=True)
            self.open_context_menu("[data-item='filename1']")
            b.click("button:contains('Edit permissions')")

            b.wait_visible("#edit-permissions-owner-access")
            b.wait_not_present("#edit-permissions-owner")
            b.wait_not_present("#edit-permissions-group")
            b.select_from_dropdown("#edit-permissions-owner-access", "read-write")
            b.select_from_dropdown("#edit-permissions-group-access", "read-only")
            b.select_from_dropdown("#edit-permissions-other-access", "read-only")

            b.click(".pf-v6-c-modal-box button.pf-m-primary")
            b.wait_not_present(".pf-v6-c-modal-box")

            self.assertEqual(self.stat("%A", "/home/admin/multiple/filename1"), "-rw-r--r--")
            self.assertEqual(self.stat("%A", "/home/admin/multiple/filename2"), "-rw-r--r--")

        m.execute("chmod 600 /home/admin/multiple/filename1")
        m.execute("chown root: /home/admin/multiple/filename1")
        b.wait_in_text("[data-item='filename1'] .item-owner", "root")
        verify_no_owner_group_shown()

        m.execute("chown admin:root /home/admin/multiple/filename1")
        b.wait_in_text("[data-item='filename1'] .item-owner", "admin:root")
        verify_no_owner_group_shown()

        # No Edit permissions when selecting a folder and files
        b.mouse("[data-item='testdir']", "click", ctrlKey=True)
        self.open_context_menu("[data-item='filename1']")
        b.wait_in_text(".contextMenu", "Delete")
        b.wait_not_present("button:contains('Edit permissions')")

        # Editing > 10 files, select only files with the same owner/group
        # Test that we see an expander and can toggle it.
        b.mouse("[data-item='testdir']", "click", ctrlKey=True)
        b.mouse("[data-item='filename1']", "click", ctrlKey=True)
        for i in range(3, 15):
            b.mouse(f"[data-item='filename{i}']", "click", ctrlKey=True)

        self.open_context_menu("[data-item='filename3']")
        b.click("button:contains('Edit permissions')")

        b.wait_text(".pf-v6-c-modal-box__title-text", "Permissions for 13 files")
        b.wait_in_text(".pf-v6-c-modal-box", "Ownership")
        b.assert_pixels(".pf-v6-c-modal-box", "multiple-files-permissions-modal", chrome_hack_double_shots=True)
        # Expand files
        b.wait_text(".pf-v6-c-expandable-section button", "Show all files")
        b.click(".pf-v6-c-expandable-section button")
        b.wait_text(".pf-v6-c-expandable-section button", "Collapse")
        b.click(".pf-v6-c-modal-box__footer button.pf-m-link")
        b.wait_not_present(".pf-v6-c-modal-box")

        self.chdir('/home/admin')

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

        m.execute("touch /home/admin/adminfile; chown admin: /home/admin/adminfile")
        open_permissions_dialog('adminfile')
        select_access("read-write")
        # A user cannot change ownership
        b.wait_not_in_text(".pf-v6-c-modal-box__body", "Ownership")
        b.click(".pf-v6-c-modal-box button.pf-m-primary")
        self.assertEqual(self.stat("%A", "/home/admin/adminfile"), "-rw-rw-rw-")
        check_perms_match('adminfile', '/home/admin')
        # Does not change ownership
        b.wait_in_text("[data-item='adminfile'] .item-owner", "admin")

        # Cannot change permission of /home
        # HACK: fix with default click(), middle of the element does not work
        b.mouse("li[data-location='/'] a", "click", y=1)
        open_permissions_dialog('home')
        select_access("read-only")
        if has_selinux:
            b.wait_val("#selinux-context", "system_u:object_r:home_root_t:s0")
        b.click(".pf-v6-c-modal-box button.pf-m-primary")
        self.wait_modal_inline_alert("chmod: changing permissions of '/home': Operation not permitted")
        b.click(".pf-v6-c-modal-box button.pf-m-link")
        b.wait_not_present(".pf-v6-c-modal-box")

        self.chdir('/home/admin')

        # Test error conditions in "change enclosing file permissions"
        m.execute("chattr +i /home/admin/Documents/Work/notes.txt")
        self.addCleanup(m.execute, "chattr -i /home/admin/Documents/Work/notes.txt")
        open_permissions_dialog("Documents")
        b.click(".pf-v6-c-modal-box button.pf-m-secondary")
        self.wait_modal_inline_alert("Operation not permitted")
        b.click("button.pf-m-link")
        b.wait_not_present(".pf-v6-c-modal-box")

        # Test permissions in details view
        basedir = "/home/admin"
        for i in range(8):
            m.execute(f"runuser -u admin touch {basedir}/file{i}")

        # 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-v6-c-tooltip dd:nth-of-type(1)", "read and write")
        b.wait_in_text(".pf-v6-c-tooltip dd:nth-of-type(2)", "read and write")
        b.wait_in_text(".pf-v6-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
        """)

        def move_tooltip() -> None:
            b.mouse("[data-item='file4'] .item-perms pre", "mouseleave")
            b.wait_not_present(".pf-v6-c-tooltip")
            b.mouse("[data-item='file4'] .item-perms pre", "mouseenter")
            b.wait_in_text(".pf-v6-c-tooltip dd:nth-of-type(1)", "read and write")
            b.wait_in_text(".pf-v6-c-tooltip dd:nth-of-type(2)", "execute-only")
            b.wait_in_text(".pf-v6-c-tooltip dd:nth-of-type(3)", "write and execute")

        b.assert_pixels(".pf-v6-c-page__main", "permissions-tooltip",
                        mock={".item-date": "Jun 19, 2024, 11:30 AM"},
                        layout_change_hook=move_tooltip)

        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-v6-c-tooltip dd:nth-of-type(1)", "read, write, and execute")
        b.wait_in_text(".pf-v6-c-tooltip dd:nth-of-type(2)", "read, write, and execute")
        b.wait_in_text(".pf-v6-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")
        self.wait_directory_changed("/home/admin/testdir/")
        b.drop_superuser()
        b.wait_in_text(".pf-v6-c-empty-state", "Permission denied")
        b.assert_pixels(".pf-v6-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")
        self.wait_directory_changed("/home/admin/")

        # 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.wait_directory_changed("/home/admin/testdir/")
        b.click("button[aria-label='Display as a list']")
        b.wait_in_text("[data-item='testfile'] .item-owner", "")
        b.wait_in_text("[data-item='testfile'] .item-date", "")

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

        self.enter_files()

        # Check control-clicking
        m.execute("""
        runuser -u admin touch /home/admin/file1 /home/admin/file2
        """)

        # Select all keybind
        b.wait_visible("[data-item='file1']")
        b.wait_visible("[data-item='file2']")
        b.eval_js("window.focus()")
        b.key("a", modifiers=["Control"])
        b.wait_visible("[data-item='file1'].row-selected")
        b.wait_visible("[data-item='file2'].row-selected")

        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.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.mouse("[data-item='file1']", "click", ctrlKey=True)
        b.wait_not_present("[data-item='file1'].row-selected")

        # 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")

        # Check context menu
        b.mouse("[data-item='file2']", "click", ctrlKey=True)
        b.wait_visible("[data-item='file2'].row-selected")
        self.open_context_menu("[data-item='file1']")
        b.wait_in_text(".contextMenu li:nth-child(2) button", "Delete")
        b.click(".contextMenu button:contains('Delete')")
        b.wait_in_text("h1.pf-v6-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-v6-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()

        m.execute("""
        runuser -u admin mkdir -p /home/admin/testdir
        runuser -u admin mkdir -p /home/admin/anotherdir
        """)
        create_files = ""
        for i in range(0, 4):
            create_files += f"touch /home/admin/file{i}; "
        m.execute(create_files)

        # view shortcuts help dialog
        b.click("#dropdown-menu")
        b.click("#shortcuts-help")
        b.wait_in_text(".shortcuts-dialog .pf-v6-c-modal-box__title-text", "Keyboard shortcuts")
        b.assert_pixels(".shortcuts-dialog", "shortcuts-help-menu")
        b.click(".shortcuts-dialog button.pf-m-secondary")
        b.wait_not_present(".shortcuts-dialog")

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

        # Pressing ArrowRight will select first item when nothing is selected
        b.wait_visible(".pf-v6-c-table__tbody tr:nth-child(1)")
        b.key("ArrowRight")
        b.wait_visible(".pf-v6-c-table__tbody tr:nth-child(1).row-selected")

        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")

        # Go up and down in directory hierarchy
        b.click("[data-item='testdir']")
        b.key("ArrowDown", modifiers=["Alt"])
        self.assert_last_breadcrumb("testdir")
        b.key("ArrowUp", modifiers=["Alt"])
        b.wait_visible("[data-item='testdir']")

        # Manually edit path
        b.key("L", modifiers=["Control", "Shift"])
        b.wait_visible("#new-path-input")
        b.input_text("/home/admin/anotherdir")
        b.key("Enter")
        self.assert_last_breadcrumb("anotherdir")
        self.chdir('/home/admin')

        b.key("N", modifiers=["Shift"])
        b.set_input_text("#create-directory-input", "foodir")
        b.click("button.pf-m-primary")
        b.wait_visible("[data-item='foodir']")

        # 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('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.wait_directory_changed("/home/admin/newdir/")
        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.wait_directory_changed("/home/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.wait_directory_changed("/home/admin/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.wait_directory_changed("/home/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")
        self.wait_directory_changed("/home/admin/newdir/")
        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-v6-c-alert__title", "Pasting failed")
        b.wait_in_text(".pf-v6-c-alert__description", "\"newfile\" exists")
        b.click("li[data-location='/home/admin'] a")
        self.wait_directory_changed("/home/admin/")
        b.click(".pf-v6-c-alert__action button")

        # Copy/paste with keybinds
        b.eval_js("window.focus()")
        b.mouse("[data-item='newdir']", "dblclick")
        self.wait_directory_changed("/home/admin/newdir/")
        b.click("[data-item='copyDir']")
        b.wait_visible("[data-item='copyDir'].row-selected")
        b.key("c", modifiers=["Control"])
        m.execute("runuser -u admin mkdir -p /home/admin/kbdCopy")
        self.chdir('/home/admin')
        b.mouse("[data-item='kbdCopy']", "dblclick")
        self.wait_directory_changed("/home/admin/kbdCopy/")
        self.browser.wait_text(".pf-v6-c-empty-state__title-text", "Directory is empty")
        b.key("v", modifiers=["Control"])
        b.wait_visible("[data-item='copyDir']")
        self.chdir('/home/admin')
        m.execute("runuser -u admin echo 'keybindings good' > /home/admin/newdir/newfile")
        b.mouse("[data-item='newdir']", "dblclick")
        self.wait_directory_changed("/home/admin/newdir/")
        b.click("[data-item='loaded']")
        b.mouse("[data-item='newfile']", "click", ctrlKey=True)
        b.wait_visible("[data-item='loaded'].row-selected")
        b.wait_visible("[data-item='newfile'].row-selected")
        b.key("c", modifiers=["Control"])
        self.chdir('/home/admin/kbdCopy')
        b.wait_visible("[data-item='copyDir']")
        m.execute("runuser -u admin rmdir /home/admin/kbdCopy/copyDir")
        b.wait_not_present("[data-item='copyDir']")
        b.key("v", modifiers=["Control"])
        b.wait_visible("[data-item='loaded']")
        b.wait_visible("[data-item='newfile']")
        self.assertEqual(m.execute("head -n 1 /home/admin/kbdCopy/newfile"), "keybindings good\n")

        # File already exists error with keybinds
        self.chdir('/home/admin')
        m.execute("runuser -u admin echo 'changed' > /home/admin/newdir/newfile")
        b.click("[data-item='newfile']")
        b.key("c", modifiers=["Control"])
        self.chdir('/home/admin/kbdCopy')
        b.key("v", modifiers=["Control"])
        b.wait_in_text("h4.pf-v6-c-alert__title", "Pasting failed")
        b.wait_in_text(".pf-v6-c-alert__description", "\"newfile\" exists")
        self.assertEqual(m.execute("head -n 1 /home/admin/kbdCopy/newfile"), "keybindings good\n")
        b.click(".pf-v6-c-alert__action button")

        # Copy & paste as superuser
        # Switch to list so owner is visible
        b.click("button[aria-label='Display as a list']")
        self.chdir('/home/admin')
        m.execute("useradd -m foouser")
        m.write("/home/admin/newfile", "test copy", owner="admin:admin")

        b.click("[data-item='newfile']")
        b.click("#dropdown-menu")
        b.click("#copy-item")

        # Paste file as destination directory owner
        self.chdir('/home/foouser')
        b.click("#dropdown-menu")
        b.click("#paste-item")
        b.wait_visible("#paste-owner-modal")
        b.click("#paste-owner-modal button.pf-m-primary")
        b.wait_in_text("[data-item='newfile'] .item-owner", "foouser")
        self.assertEqual(m.execute("cat /home/foouser/newfile"), "test copy")

        # Copy foouser file into admin directory as admin
        m.write("/home/foouser/foouserfile", "footext", owner="foouser:foouser")
        b.wait_in_text("[data-item='foouserfile'] .item-owner", "foouser")
        b.click("[data-item='foouserfile']")
        b.wait_visible("[data-item='foouserfile'].row-selected")
        b.click("#dropdown-menu")
        b.click("#copy-item")
        self.chdir('/home/admin')
        b.wait_visible("[data-item='copyDir']")
        b.click("#dropdown-menu")
        b.click("#paste-item")
        b.wait_visible("#paste-owner-modal")
        b.click("#paste-owner-modal button.pf-m-primary")
        b.wait_in_text("[data-item='foouserfile'] .item-owner", "admin")
        self.assertEqual(m.execute("cat /home/admin/foouserfile"), "footext")

        # Paste as admin into foouser directory
        m.write("/home/admin/adminfile", "admintext", owner="admin:admin")
        b.wait_in_text("[data-item='adminfile'] .item-owner", "admin")
        b.click("[data-item='adminfile']")
        b.wait_visible("[data-item='adminfile'].row-selected")
        b.click("#dropdown-menu")
        b.click("#copy-item")
        self.chdir('/home/foouser')
        b.click("#dropdown-menu")
        b.click("#paste-item")
        b.wait_visible("#paste-owner-modal")
        b.select_from_dropdown("#paste-owner-select", "admin (original owner)")
        b.click("#paste-owner-modal button.pf-m-primary")
        b.wait_in_text("[data-item='adminfile'] .item-owner", "admin")
        self.assertEqual(m.execute("cat /home/foouser/adminfile"), "admintext")

        # Copying a directory should change the owner recursively
        self.chdir('/home/admin')
        b.click("[data-item='kbdCopy']")
        b.wait_visible("[data-item='kbdCopy'].row-selected")
        b.click("#dropdown-menu")
        b.click("#copy-item")
        self.chdir('/home/foouser')
        b.click("#dropdown-menu")
        b.click("#paste-item")
        b.wait_visible("#paste-owner-modal")
        b.select_from_dropdown("#paste-owner-select", "root")
        b.click("#paste-owner-modal button.pf-m-primary")
        b.wait_in_text("[data-item='kbdCopy'] .item-owner", "root")
        self.chdir('/home/foouser/kbdCopy')
        b.wait_in_text("[data-item='loaded'] .item-owner", "root")
        b.wait_in_text("[data-item='newfile'] .item-owner", "root")

        # Copy file with different user, group
        m.write("/home/admin/adminfoofile", "adminfoo", owner="admin:foouser")
        self.chdir('/home/admin')
        b.click("[data-item='adminfoofile']")
        b.wait_visible("[data-item='adminfoofile'].row-selected")
        b.click("#dropdown-menu")
        b.click("#copy-item")
        self.chdir('/home/foouser')
        b.click("#dropdown-menu")
        b.click("#paste-item")
        b.wait_visible("#paste-owner-modal")
        b.select_from_dropdown("#paste-owner-select", "admin:foouser (original owner)")
        b.click("#paste-owner-modal button.pf-m-primary")
        b.wait_in_text("[data-item='adminfoofile'] .item-owner", "admin:foouser")
        self.assertEqual(m.execute("cat /home/foouser/adminfoofile"), "adminfoo")

        self.chdir('/home/admin')
        b.click("[data-item='newdir']")
        b.wait_visible("[data-item='newdir'].row-selected")
        b.click("#dropdown-menu")
        b.click("#copy-item")
        self.chdir('/home/foouser')
        b.wait_visible("[data-item='kbdCopy']")
        b.click("#dropdown-menu")
        b.click("#paste-item")
        b.wait_visible("#paste-owner-modal")
        b.click("button.pf-m-link")
        b.wait_not_present("#paste-owner-modal")
        b.wait_not_present("[data-item='newdir']")

        # Test where copy fails
        # Add +i attribute on the directory which makes it immutable
        m.execute("""
            runuser -u foouser mkdir /home/foouser/copyfail
            chattr +i /home/foouser/copyfail
        """)
        self.addCleanup(m.execute, "chattr -i /home/foouser/copyfail")
        self.chdir('/home/admin')
        b.click("[data-item='newfile']")
        b.wait_visible("[data-item='newfile'].row-selected")
        b.click("#dropdown-menu")
        b.click("#copy-item")
        self.chdir('/home/foouser/copyfail')
        b.click("#dropdown-menu")
        b.click("#paste-item")
        b.click("#paste-owner-modal button.pf-m-primary")
        b.wait_in_text("h4.pf-v6-c-alert__title", "Pasting failed")
        # check that alert has a sensible error message
        self.assertIn("/home/foouser/copyfail/newfile", b.text(".pf-v6-c-alert__description"))
        self.assertIn("Operation not permitted", b.text(".pf-v6-c-alert__description"))
        b.wait_not_present("[data-item='copyfail']")
        b.click(".pf-v6-c-alert__action button")

        # Multiple ownerships are kept when selecting "keep original owners"
        m.execute("runuser -u foouser mkdir /home/foouser/copydiff")
        self.chdir('/home/admin')
        b.mouse("[data-item='adminfile']", "click")
        b.mouse("[data-item='adminfoofile']", "click", ctrlKey=True)
        b.wait_visible("[data-item='adminfoofile'].row-selected")
        b.click("#dropdown-menu")
        b.click("#copy-item")
        self.chdir('/home/foouser/copydiff')
        b.click("#dropdown-menu")
        b.click("#paste-item")
        b.wait_visible("#paste-owner-modal")
        b.select_from_dropdown("#paste-owner-select", "keep original owners (admin, admin:foouser)")
        b.click("#paste-owner-modal button.pf-m-primary")
        b.wait_in_text("[data-item='adminfile'] .item-owner", "admin")
        b.wait_in_text("[data-item='adminfoofile'] .item-owner", "admin:foouser")
        self.assertEqual(m.execute("cat /home/foouser/copydiff/adminfile"), "admintext")
        self.assertEqual(m.execute("cat /home/foouser/copydiff/adminfoofile"), "adminfoo")

        # Multiple directory trees with multiple ownerships
        # Ownership is not changed when selecting "keep original owners"
        m.execute("""
            mkdir /home/admin/dir1 /home/admin/dir2
            echo 'hello' > /home/admin/dir1/hello.txt
            echo 'goodbye' > /home/admin/dir1/goodbye.txt
            echo 'hi' > /home/admin/dir2/hi.txt
            echo 'bye' > /home/admin/dir2/bye.txt
            chown -R admin:admin /home/admin/dir1
            chown -R foouser:foouser /home/admin/dir2
        """)
        self.chdir('/home/admin')
        b.wait_in_text("[data-item='dir1'] .item-owner", "admin")
        b.wait_in_text("[data-item='dir2'] .item-owner", "foouser")
        b.mouse("[data-item='dir1']", "click")
        b.mouse("[data-item='dir2']", "click", ctrlKey=True)
        b.wait_visible("[data-item='dir2'].row-selected")
        b.click("#dropdown-menu")
        b.click("#copy-item")
        self.chdir('/home/foouser')
        b.click("#dropdown-menu")
        b.click("#paste-item")
        b.wait_visible("#paste-owner-modal")
        b.select_from_dropdown("#paste-owner-select", "keep original owners (admin, foouser)")
        b.click("#paste-owner-modal button.pf-m-primary")
        b.wait_in_text("[data-item='dir1'] .item-owner", "admin")
        b.wait_in_text("[data-item='dir2'] .item-owner", "foouser")
        self.assertEqual(m.execute("cat /home/foouser/dir1/hello.txt"), "hello\n")
        self.assertEqual(m.execute("cat /home/foouser/dir1/goodbye.txt"), "goodbye\n")
        self.assertEqual(m.execute("cat /home/foouser/dir2/hi.txt"), "hi\n")
        self.assertEqual(m.execute("cat /home/foouser/dir2/bye.txt"), "bye\n")
        self.assert_owner("/home/foouser/dir1/hello.txt", "admin:admin")
        self.assert_owner("/home/foouser/dir1/goodbye.txt", "admin:admin")
        self.assert_owner("/home/foouser/dir2/hi.txt", "foouser:foouser")
        self.assert_owner("/home/foouser/dir2/bye.txt", "foouser:foouser")

        # Multiple directories with mixed ownership, chown recursively to same owner after pasting
        m.execute("rm -rf /home/foouser/dir1 /home/foouser/dir2")
        b.wait_not_present("[data-item='dir1']")
        b.wait_not_present("[data-item='dir12]")
        b.click("#dropdown-menu")
        b.click("#paste-item")
        b.wait_visible("#paste-owner-modal")
        b.select_from_dropdown("#paste-owner-select", "foouser")
        b.click("#paste-owner-modal button.pf-m-primary")
        b.wait_in_text("[data-item='dir1'] .item-owner", "foouser")
        b.wait_in_text("[data-item='dir2'] .item-owner", "foouser")
        self.assertEqual(m.execute("cat /home/foouser/dir1/hello.txt"), "hello\n")
        self.assertEqual(m.execute("cat /home/foouser/dir1/goodbye.txt"), "goodbye\n")
        self.assertEqual(m.execute("cat /home/foouser/dir2/hi.txt"), "hi\n")
        self.assertEqual(m.execute("cat /home/foouser/dir2/bye.txt"), "bye\n")
        self.assert_owner("/home/foouser/dir1/hello.txt", "foouser:foouser")
        self.assert_owner("/home/foouser/dir1/goodbye.txt", "foouser:foouser")
        self.assert_owner("/home/foouser/dir2/hi.txt", "foouser:foouser")
        self.assert_owner("/home/foouser/dir2/bye.txt", "foouser:foouser")

        # Symlinks, stay symlinks when copying
        def copy_symlink() -> None:
            self.chdir('/home/admin')
            b.click("[data-item='link-to-uname']")
            b.click("#dropdown-menu")
            b.click("#copy-item")
            b.wait_not_present(".contextMenu")

        # Absolute
        m.execute("runuser -u admin -- ln -s /usr/bin/uname /home/admin/link-to-uname")

        # Non user owned directory
        copy_symlink()
        self.chdir('/home/foouser')
        b.click("#dropdown-menu")
        b.click("#paste-item")
        b.wait_visible("#paste-owner-modal")
        b.click("#paste-owner-modal button.pf-m-primary")
        b.wait_visible("[data-item='link-to-uname'].symlink")

        # Users own directory
        copy_symlink()
        # HACK: can't use dblclick due to the usage of ctrlClick above
        self.chdir('/home/admin/newdir')

        self.open_folder_context_menu()
        b.click(".contextMenu button:contains('Paste')")
        b.wait_visible("[data-item='link-to-uname'].symlink")

        # Non-admin copy to no write access directory shows permission denied error
        self.chdir('/home/admin')
        b.drop_superuser()
        b.click("[data-item='newfile']")
        b.wait_visible("[data-item='newfile'].row-selected")
        b.click("#dropdown-menu")
        b.click("#copy-item")
        self.chdir('/')

        # Permissions tooltip might be active over the dropdown
        b.mouse("body", "mouseleave")
        b.wait_not_present(".pf-v6-c-tooltip")

        b.click("#dropdown-menu")
        b.click("#paste-item")
        b.wait_visible(".pf-v6-c-alert.pf-m-danger")
        self.assertIn("Permission denied", b.text(".pf-v6-c-alert.pf-m-danger .pf-v6-c-alert__title"))
        b.click(".pf-v6-c-alert__action button")
        b.wait_not_present("[data-item='newfile']")

    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())

        def assert_upload_alert(files: list[str], owner_group: str | None = None, *,
                                close: bool = True, pixel_test: bool = False) -> None:
            title = ""
            description = ""
            if len(files) > 1:
                title = "Files uploaded"
                description = f"{len(files)} files"
            else:
                title = "File uploaded"
                description = files[0]

            if owner_group:
                # Default umask should be 022
                description += f"Uploaded as {owner_group}, rw- r-- r--"

            b.wait_in_text(".pf-v6-c-alert__title", title)
            b.wait_text(".pf-v6-c-alert__description", description)

            if pixel_test:
                b.assert_pixels(".pf-v6-c-alert.pf-m-success", "multiple-upload-alert-success")

            if close:
                b.click(".pf-v6-c-alert__action button")
                b.wait_not_present(".pf-v6-c-alert__action")

        with tempfile.TemporaryDirectory() as tmpdir:

            # Test cancelling of upload
            if b.browser == 'chromium':
                # Chromium BiDi driver craps out with uploading large files, so throttle network speed there
                b.cdp_command("Network.emulateNetworkConditions",
                              offline=False, latency=0, downloadThroughput=-1, uploadThroughput=50000)
                big_size = "15MB"
            else:
                # BiDi does not have a network speed API, so we can't throttle on Firefox
                # but that's happy with large files
                big_size = "1500MB"

            big_file = str(Path(tmpdir) / "bigfile.img")
            subprocess.check_call(["truncate", "-s", big_size, big_file])

            m.execute("runuser -u admin mkdir /home/admin/Downloads")
            b.wait_visible("[data-item='Downloads']")
            b.mouse("[data-item='Downloads']", "dblclick")
            self.wait_directory_changed("/home/admin/Downloads/")

            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"],
                            # This pixel test flakes a lot in the
                            # "medium" layout, producing an empty
                            # screenshot. One guess is that this is
                            # caused by changing the browser size when
                            # switching layouts and PF's delayed
                            # reaction to it when placing an already
                            # open popover. So let's only test the
                            # "desktop" and "dark" layouts, which are
                            # the same size. Fingers crossed.
                            skip_layouts=["medium", "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-v6-c-alert__description", "Cancelled upload of bigfile.img")
            b.click(".pf-v6-c-alert__action button")
            b.wait_not_present(".pf-v6-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")
            self.chdir('/mnt/upload')
            b.upload_files("#upload-file-btn + input[type='file']", [big_file])

            b.wait_in_text(".pf-v6-c-alert__description", "No space left on device")
            b.click(".pf-v6-c-alert__action button")
            b.wait_not_present(".pf-v6-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])
            self.chdir(dest_dir)

            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.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))

            assert_upload_alert(files, "admin:admin", pixel_test=True)

            # Verify ownership
            filename = os.path.basename(files[0])
            b.click("button[aria-label='Display as a list']")
            b.wait_in_text(f"[data-item='{filename}'] .item-owner", "admin")
            b.click("button[aria-label='Display as a grid']")

            # 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.upload_files("#upload-file-btn + input[type='file']", [files[0]])
            b.wait_in_text("h1.pf-v6-c-modal-box__title", f"Replace file {filename}?")
            b.assert_pixels(".pf-v6-c-modal-box", "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.upload_files("#upload-file-btn + input[type='file']", [files[0]])
            b.wait_in_text("h1.pf-v6-c-modal-box__title", f"Replace file {filename}?")
            b.click("button.pf-m-warning:contains('Replace')")
            b.wait_not_present(".pf-v6-c-modal-box")
            assert_upload_alert([os.path.basename(files[0])], "admin:admin")

            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.upload_files("#upload-file-btn + input[type='file']", files)

            b.wait_in_text("h1.pf-v6-c-modal-box__title", f"Replace file {filename}?")
            b.wait_in_text(".pf-v6-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-v6-c-modal-box")

            verify_uploaded_files()

            # Keep original for all

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

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

            verify_uploaded_files()

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

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

            assert_upload_alert(files, "admin:admin")
            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.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-v6-c-modal-box__title", f"Replace file {filename}?")
                    b.click("button.pf-m-secondary:contains('Keep original')")

            b.wait_not_present(".pf-v6-c-modal-box")
            b.wait_in_text(".pf-v6-c-alert__title", "File uploaded")
            b.wait_in_text(".pf-v6-c-alert__description", "newfile.txt")
            b.wait_in_text(".pf-v6-c-alert__description", "Uploaded as admin:admin")
            b.click(".pf-v6-c-alert__action button")
            b.wait_not_present(".pf-v6-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.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-v6-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-v6-c-modal-box")
            assert_upload_alert([os.path.basename(files[-1])], "admin:admin")

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

            # As administrator upload in testuser and change ownership to root:root

            m.execute("useradd --create-home testuser")
            self.chdir('/home/testuser')

            testuser_file = str(Path(tmpdir) / "testuser.txt")
            test_content = "testdata"
            with open(testuser_file, "w") as fp:
                fp.write(test_content)

            testuser_filename = os.path.basename(testuser_file)
            b.upload_files("#upload-file-btn + input[type='file']", [testuser_file])
            assert_upload_alert([testuser_filename], "testuser:testuser", close=False)
            self.assert_owner(f'/home/testuser/{testuser_filename}', 'testuser:testuser')
            contents = m.execute(f"cat /home/testuser/{testuser_filename}")
            self.assertEqual(contents, test_content)

            b.click(".pf-v6-c-alert__action-group button")
            b.wait_not_present(".pf-v6-c-alert__action")
            b.wait_in_text(".pf-v6-c-modal-box__title-text", testuser_filename)
            b.wait_val("#edit-permissions-owner", "testuser")
            b.wait_val("#edit-permissions-group", "testuser")
            b.wait_val("#edit-permissions-owner-access", "read-write")
            b.wait_val("#edit-permissions-group-access", "read-only")
            b.wait_val("#edit-permissions-other-access", "read-only")

            b.select_from_dropdown("#edit-permissions-owner", "root")
            b.select_from_dropdown("#edit-permissions-group", "root")
            b.click("button.pf-m-primary")
            b.wait_not_present(".pf-v6-c-modal-box")

            self.assert_owner(f'/home/testuser/{testuser_filename}', 'root:root')

            # Uploading to an immutable dir fails to create a temp file, check that it is cleaned up

            m.execute("chattr +i /home/testuser")
            self.addCleanup(m.execute, "chattr -i /home/testuser")
            b.upload_files("#upload-file-btn + input[type='file']", [files[-1]])
            b.wait_in_text(".pf-v6-c-alert__title", "Failed")
            b.wait_in_text(".pf-v6-c-alert__description", "Not permitted to perform this action")
            b.click(".pf-v6-c-alert__action button")
            b.wait_not_present(".pf-v6-c-alert__action")

            # Non-admin session
            self.chdir(dest_dir)
            b.drop_superuser()
            b.wait_visible(f"[data-item='{filename}']")
            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.upload_files("#upload-file-btn + input[type='file']", [files[-1]])

            b.wait_in_text(".pf-v6-c-alert__title", "File uploaded")
            b.click(".pf-v6-c-alert__action button")
            b.wait_not_present(".pf-v6-c-alert__action")

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

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

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

            # Test drag & drop upload
            b.eval_js('''
                      function dragFileEvent(sel, dragType, filename) {
                          const el = window.ph_find(sel);
                          const dataTransfer = new DataTransfer();
                          dataTransfer.dropEffect = "move";

                          // File is added only for drop event
                          if (dragType === 'drop' && filename) {
                              // Synthesize a file DataTransfer action
                              const content = "Random file content: 4";
                              const file = new File([content], filename, { type: "text/plain" });

                              Object.defineProperty(dataTransfer, 'files', {
                                  value: [file],
                                  writable: false,
                              });
                          }

                          // types are known in all drags event
                          // default to dragging an html element
                          let types = ['text/plain', 'text/html'];
                          if (filename) {
                              types = ['Files'];
                          }
                          Object.defineProperty(dataTransfer, 'types', {
                              value: types,
                              writable: false,
                          });

                          const ev = new DragEvent(dragType, { bubbles: true, dataTransfer: dataTransfer });
                          el.dispatchEvent(ev);
                      }''')

            def drag_file_event(selector: str, dragType: str, filename: str | None = None) -> None:
                b.wait_visible(selector)
                b.call_js_func('dragFileEvent', selector, dragType, filename)

            b.wait_not_present(".drag-drop-upload")
            drag_file_event(".fileview-wrapper", 'dragenter', 'drag-drop-testfile.txt')
            b.wait_visible(".drag-drop-upload")
            b.assert_pixels(".files-card", "drag-drop-upload-dropzone",
                            mock={".item-date": "Jun 19, 2024, 11:30 AM"})
            drag_file_event(".fileview-wrapper", 'drop', 'drag-drop-testfile.txt')
            b.wait_in_text(".pf-v6-c-alert__title", "File uploaded")
            b.click(".pf-v6-c-alert__action button")
            b.wait_visible("[data-item='drag-drop-testfile.txt']")
            b.wait_not_present(".pf-v6-c-alert__action")
            b.wait_not_present(".drag-drop-upload")

            # Upload same file again - warning should show up
            drag_file_event(".fileview-wrapper", 'dragenter', 'drag-drop-testfile.txt')
            b.wait_visible(".drag-drop-upload")
            drag_file_event(".fileview-wrapper", 'dragover', 'drag-drop-testfile.txt')
            drag_file_event(".fileview-wrapper", 'drop', 'drag-drop-testfile.txt')
            b.wait_in_text("h1.pf-v6-c-modal-box__title", "Replace file drag-drop-testfile.txt?")
            b.click(".pf-v6-c-modal-box button.pf-m-link")
            b.wait_not_present(".pf-v6-c-modal-box")

            # Drag & drop upload overlay is not displayed when event has no files
            drag_file_event(".fileview-wrapper", 'dragenter')
            b.wait_not_present(".drag-drop-upload")
            drag_file_event(".fileview-wrapper", 'dragover')
            b.wait_not_present(".drag-drop-upload")
            drag_file_event(".fileview-wrapper", 'dragleave')
            b.wait_not_present(".drag-drop-upload")
            drag_file_event(".fileview-wrapper", 'dragenter')
            b.wait_not_present(".drag-drop-upload")
            drag_file_event(".fileview-wrapper", 'dragover')
            b.wait_not_present(".drag-drop-upload")
            drag_file_event(".fileview-wrapper", 'drop')
            b.wait_not_present(".drag-drop-upload")

            # Drag file around to test if upload icon shows up and hides accordingly
            drag_file_event(".fileview-wrapper", 'dragenter', 'drag-drop-testfile.txt')
            b.wait_visible(".drag-drop-upload")
            drag_file_event(".fileview-wrapper", 'dragleave', 'drag-drop-testfile.txt')
            b.wait_not_present(".drag-drop-upload")

            drag_file_event(".fileview-wrapper", 'dragenter', 'drag-drop-testfile.txt')
            b.wait_visible(".drag-drop-upload")
            # Hover over element which is a child of .fileview-wrapper
            drag_file_event(".fileview-wrapper [data-item='Downloads'] .item-name",
                            'dragenter', 'drag-drop-testfile.txt')
            drag_file_event(".fileview-wrapper", 'dragleave', 'drag-drop-testfile.txt')
            b.wait_visible(".drag-drop-upload")
            drag_file_event(".fileview-wrapper [data-item='project'] .item-name",
                            'dragenter', 'drag-drop-testfile.txt')
            drag_file_event(".fileview-wrapper [data-item='Downloads'] .item-name",
                            'dragleave', 'drag-drop-testfile.txt')
            b.wait_visible(".drag-drop-upload")
            # Drag away from the .fileview-wrapper
            drag_file_event(".fileview-wrapper [data-item='project'] .item-name",
                            'dragleave', 'drag-drop-testfile.txt')
            b.wait_not_present(".drag-drop-upload")
            drag_file_event(".fileview-wrapper", 'dragenter', 'drag-drop-testfile.txt')
            b.wait_visible(".drag-drop-upload")
            drag_file_event(".fileview-wrapper", 'dragleave', 'drag-drop-testfile.txt')
            b.wait_not_present(".drag-drop-upload")

            # Drag & drop is disabled when upload is in progress
            files_cnt = int(m.execute("ls -a /home/admin | wc -l").strip())
            b.upload_files("#upload-file-btn + input[type='file']", [big_file])
            b.wait_visible("#upload-file-btn:disabled")
            b.wait_not_present(".drag-drop-upload")
            drag_file_event(".fileview-wrapper", 'dragenter', 'drag-drop-testfile.txt')
            b.wait_visible(".drag-drop-upload-blocked")
            b.assert_pixels(".files-card", "drag-drop-upload-dropzone-blocked",
                            mock={".item-date": "Jun 19, 2024, 11:30 AM"},
                            ignore=["#upload-progress-btn"])
            drag_file_event(".fileview-wrapper", 'dragleave', 'drag-drop-testfile.txt')
            b.wait_not_present(".drag-drop-upload-blocked")
            # Dropping the file does nothing
            drag_file_event(".fileview-wrapper", 'dragenter', 'drag-drop-testfile.txt')
            b.wait_visible(".drag-drop-upload-blocked")
            drag_file_event(".fileview-wrapper", 'drop', 'drag-drop-file-2.txt')
            b.wait_not_present(".drag-drop-upload")
            b.click("#upload-progress-btn")
            b.wait_in_text(".upload-progress-0", "bigfile.img")
            b.wait_not_present("[data-item='drag-drop-file-2.txt']")
            b.click("#upload-progress-btn")
            b.click(".cancel-button-0")
            b.click(".pf-v6-c-alert__action button")
            b.wait_not_present(".pf-v6-c-alert__action")
            b.wait(lambda: int(m.execute("ls -a /home/admin | wc -l").strip()) == files_cnt)

            # Change directory and upload
            b.mouse("[data-item='project']", "dblclick")
            with b.wait_timeout(30):
                self.wait_directory_changed("/home/admin/project/")
            b.wait_visible("[data-item='4.txt']")
            drag_file_event(".fileview-wrapper", 'dragenter', 'drag-drop-testfile.txt')
            b.wait_visible(".drag-drop-upload")
            drag_file_event(".fileview-wrapper", 'drop', 'drag-drop-testfile.txt')
            b.wait_in_text(".pf-v6-c-alert__title", "File uploaded")
            b.click(".pf-v6-c-alert__action button")
            b.wait_visible("[data-item='drag-drop-testfile.txt']")
            b.wait_not_present(".pf-v6-c-alert__action")
            b.wait_not_present(".drag-drop-upload")

    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"},
                        chrome_hack_double_shots=True)

    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-v6-c-menu")
            if exists:
                b.wait_in_text(".pf-v6-c-menu", bookmark)
            else:
                # Has to wait on file.watch() (inotify) to re-read the bookmarks
                b.wait_not_in_text(".pf-v6-c-menu", bookmark)

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

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

        # Add a bookmark
        self.chdir('/etc')
        b.click("#bookmark-btn")
        b.click(".pf-v6-c-menu .pf-v6-c-menu__list-item button:contains(Add to bookmarks)")
        b.wait_not_present(".pf-v6-c-menu")

        assert_bookmark("/etc", exists=True)

        # Remove bookmark
        b.click("#bookmark-btn")
        b.wait_in_text(".pf-v6-c-menu", "/etc")
        b.click(".pf-v6-c-menu .pf-v6-c-menu__list-item button:contains('Remove from bookmarks')")
        b.wait_not_present(".pf-v6-c-menu")

        assert_bookmark("/etc", exists=False)

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

        self.chdir('/proc')
        b.click("#bookmark-btn")
        b.click(".pf-v6-c-menu .pf-v6-c-menu__list-item button:contains('/etc')")
        self.wait_directory_changed("/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-v6-c-menu .pf-v6-c-menu__list-item button:contains('This is a-test')")
        self.wait_directory_changed("/home/admin/This is a-test/")

        # Removing directory with spaces or aliases
        b.click("#bookmark-btn")
        b.click(".pf-v6-c-menu .pf-v6-c-menu__list-item button:contains('Remove from bookmarks')")
        b.wait_not_present(".pf-v6-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-v6-c-menu .pf-v6-c-menu__list-item button:contains(Add to bookmarks)")

        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-v6-c-menu .pf-v6-c-menu__list-item button:contains('tmp')")
        self.wait_directory_changed("/tmp/")

        b.click("#bookmark-btn")
        b.click(".pf-v6-c-menu .pf-v6-c-menu__list-item button:contains('Remove from bookmarks')")
        b.wait_not_present(".pf-v6-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}'")
        self.chdir('/home/admin')
        b.mouse(f"[data-item='{special_dir}']", "dblclick")
        self.wait_directory_changed(f"/home/admin/{special_dir}/")

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

        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.wait_directory_changed("/home/admin/")

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

        b.click("#bookmark-btn")
        b.click(".pf-v6-c-menu .pf-v6-c-menu__list-item button:contains('Remove from bookmarks')")
        b.wait_not_present(".pf-v6-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-v6-c-menu .pf-v6-c-menu__list-item button:contains('/var')")
        b.wait_not_present(".pf-v6-c-empty-state")
        b.wait_not_present(".pf-v6-c-menu .pf-v6-c-menu__list-item button:contains('lalala')")
        self.wait_directory_changed("/var/")

        # Removing bookmarks file removes all bookmarks
        m.execute(['rm', '-rf', config_file])
        b.click("#bookmark-btn")
        b.wait_not_present(".pf-v6-c-menu .pf-v6-c-menu__list-item button:contains('/etc')")
        b.click("#bookmark-btn")
        b.wait_not_present(".pf-v6-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-v6-c-menu .pf-v6-c-menu__list-item button:contains('non-existent')")
        self.wait_directory_changed("/tmp/non-existent/")
        b.click("#bookmark-btn")
        b.click(".pf-v6-c-menu .pf-v6-c-menu__list-item button:contains('Remove from bookmarks')")
        b.wait_not_present(".pf-v6-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-v6-c-menu .pf-v6-c-menu__list-item button:contains('Add to bookmarks')")
        b.click("#bookmark-btn")
        b.wait_not_present(".pf-v6-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
        """)

        self.chdir('/var')
        b.click("#bookmark-btn")
        b.click(".pf-v6-c-menu .pf-v6-c-menu__list-item button:contains(Add to bookmarks)")
        self.wait_modal_inline_alert("Unable to create bookmark directory")
        b.click(".pf-v6-c-alert__action button")
        b.wait_not_present(".pf-v6-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-v6-c-menu .pf-v6-c-menu__list-item button:contains(Add to bookmarks)")
        self.wait_modal_inline_alert("Unable to save bookmark file")
        b.click(".pf-v6-c-alert__action button")
        b.wait_not_present(".pf-v6-c-alert__action")

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

        self.enter_files()

        def open_editor(file: str) -> None:
            self.open_context_menu(f"[data-item='{file}']")
            b.click(".contextMenu button:contains('Open text file')")

        def validate_content(file: str, content: str) -> None:
            open_editor(file)
            b.wait_val(".file-editor-modal textarea", content)
            b.click(".file-editor-modal button.pf-m-link")
            b.wait_not_present(".file-editor-modal")

            # validate on disk
            self.assertEqual(m.execute(f"cat /home/admin/{file}"), content)

        m.execute("""
            runuser -u admin -- bash -c "echo test > /home/admin/test.txt"
            runuser -u admin -- bash -c "echo planes > /home/admin/notes.txt"
            runuser -u admin -- bash -c "echo emptyme > /home/admin/emptyme.txt"
            runuser -u admin -- truncate -s 2M /home/admin/big.txt
            runuser -u admin -- touch /home/admin/archive.tar.gz
        """)

        # Don't allow opening big text files
        self.open_context_menu("[data-item='big.txt']")
        b.wait_not_in_text(".contextMenu", "Open text file")
        b.click("#files-card-parent tbody")
        b.wait_not_present(".contextMenu")

        # A tarball cannot be edited
        self.open_context_menu("[data-item='archive.tar.gz']")
        b.wait_not_in_text(".contextMenu", "Open text file")
        b.click("#files-card-parent tbody")
        b.wait_not_present(".contextMenu")

        # Opening an test file, editing and closing (discard)
        open_editor("test.txt")
        b.wait_text(".pf-v6-c-modal-box__title", "Edit test.txt")
        b.wait_text(".file-editor-modal textarea", "test\n")

        # Save button is disabled when not editing yet
        b.wait_visible(".pf-v6-c-modal-box__footer button.pf-m-primary:disabled")
        b.set_input_text(".file-editor-modal textarea", "foobar", append=True, value_check=False)
        b.wait_val(".file-editor-modal textarea", "test\nfoobar")

        b.assert_pixels(".file-editor-modal", "editor-modal-changed")
        b.wait_visible(".pf-v6-c-modal-box__footer .pf-m-primary:not(:disabled)")

        b.click(".file-editor-modal button.pf-m-link")
        b.wait_not_present(".file-editor-modal")
        self.assertEqual(m.execute("cat /home/admin/test.txt"), "test\n")

        self.assert_owner('/home/admin/test.txt', 'admin:admin')

        # Opening a test file, remove all text, and save
        open_editor("emptyme.txt")
        b.set_input_text(".file-editor-modal textarea", "", append=False, value_check=False)
        b.wait_val(".file-editor-modal textarea", "")
        b.wait_visible(".file-editor-modal.is-modified")
        b.click(".file-editor-modal button.pf-m-primary")
        # Saving resets modified state so should not be shown
        b.wait_not_present(".file-editor-modal.is-modified")

        b.click(".file-editor-modal button.pf-m-link")
        b.wait_not_present(".file-editor-modal")

        validate_content("emptyme.txt", "")
        self.assert_owner("/home/admin/emptyme.txt", "admin:admin")

        # Opening a test file, add text and save
        open_editor("test.txt")
        b.set_input_text(".file-editor-modal textarea", "foobar", append=True, value_check=False)
        b.wait_val(".file-editor-modal textarea", "test\nfoobar")
        b.wait_visible(".file-editor-modal.is-modified")
        b.click(".file-editor-modal button.pf-m-primary")
        # Saving resets modified state so should not be shown
        b.wait_not_present(".file-editor-modal.is-modified")

        b.click(".file-editor-modal button.pf-m-link")
        b.wait_not_present(".file-editor-modal")

        validate_content("test.txt", "test\nfoobar\n")
        self.assert_owner('/home/admin/test.txt', 'admin:admin')

        # Opening a test file, edit text and someone else changes it while we are editing
        open_editor("test.txt")
        b.set_input_text(".file-editor-modal textarea", "roll\n", append=True, value_check=False)
        b.wait_val(".file-editor-modal textarea", "test\nfoobar\nroll\n")
        b.wait_visible(".pf-v6-c-modal-box__footer .pf-m-primary:not(:disabled)")

        m.execute("runuser -u admin echo 'testing' > /home/admin/test.txt")
        b.wait_in_text(".pf-v6-c-alert__title", "The file has changed on disk")

        # Abort, reload
        b.click(".file-editor-modal button:contains('Reload')")
        b.wait_val(".file-editor-modal textarea", "testing\n")

        # Let someone else edit again and overwrite
        b.set_input_text(".file-editor-modal textarea", "roll\n", append=True, value_check=False)
        b.wait_val(".file-editor-modal textarea", "testing\nroll\n")
        m.execute("echo 'testing' > /home/admin/test.txt")
        b.wait_in_text(".pf-v6-c-alert__title", "The file has changed on disk")

        b.click(".pf-v6-c-modal-box__footer .pf-m-warning")
        b.wait_not_present(".pf-v6-c-alert__title")
        b.click(".file-editor-modal button.pf-m-link")
        b.wait_not_present(".file-editor-modal")

        validate_content("test.txt", "testing\nroll\n")
        self.assert_owner('/home/admin/test.txt', 'admin:admin')

        # Change ownership during editing
        open_editor("test.txt")
        b.wait_val(".file-editor-modal textarea", "testing\nroll\n")
        m.execute("chown root: /home/admin/test.txt")
        b.set_input_text(".file-editor-modal textarea", "reset")
        b.click(".pf-v6-c-modal-box__footer .pf-m-primary")
        b.wait_in_text(".pf-v6-c-alert.pf-m-warning", "The file has changed on disk")

        b.click(".pf-v6-c-modal-box__footer .pf-v6-c-button.pf-m-link")
        b.wait_not_present(".file-editor-modal")

        # Emptying a file does not remove the file
        open_editor("notes.txt")
        b.wait_val(".file-editor-modal textarea", "planes\n")
        b.set_input_text(".file-editor-modal textarea", "")
        b.click(".pf-v6-c-modal-box__footer .pf-m-primary")
        b.wait_visible(".pf-v6-c-modal-box__footer .pf-m-primary:disabled")

        b.click(".pf-v6-c-modal-box__footer .pf-v6-c-button.pf-m-link")
        b.wait_not_present(".file-editor-modal")

        validate_content("notes.txt", "")

        # Remove file during editing
        open_editor("notes.txt")
        b.wait_val(".file-editor-modal textarea", "")
        b.set_input_text(".file-editor-modal textarea", "reset")
        m.execute("rm /home/admin/notes.txt")
        b.click(".pf-v6-c-modal-box__footer .pf-m-primary")
        b.wait_in_text(".pf-v6-c-alert.pf-m-warning", "The file has been removed on disk")

        # Save, file was gone but we write it again
        b.click(".pf-v6-c-modal-box__footer .pf-v6-c-button.pf-m-primary")
        # Saving resets modified state so should not be shown
        b.wait_not_present(".file-editor-modal.is-modified")

        b.click(".pf-v6-c-modal-box__footer .pf-v6-c-button.pf-m-link")
        b.wait_not_present(".file-editor-modal")

        validate_content("notes.txt", "reset\n")

        # Escape closes the dialog
        open_editor("notes.txt")
        b.blur(".file-editor-modal textarea")  # remove focus from textarea
        b.key("Escape")  # Escape for modal
        b.wait_not_present(".file-editor-modal")

        # As unprivileged user
        b.drop_superuser()
        self.wait_directory_changed("/home/admin/")

        # View a file
        m.execute("echo 'this is a config file' > /etc/cockpit-files-test.cfg")
        self.addCleanup(m.execute, "rm /etc/cockpit-files-test.cfg")
        self.chdir('/etc')
        open_editor("cockpit-files-test.cfg")
        b.wait_text(".pf-v6-c-modal-box__title", "View cockpit-files-test.cfgRead-only")
        b.assert_pixels(".file-editor-modal", "editor-modal-read-only")

        b.click(".pf-v6-c-modal-box__footer button.pf-m-secondary")
        b.wait_not_present(".file-editor-modal")

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

        self.enter_files()

        def open_create_file_modal() -> None:
            self.open_folder_context_menu()
            b.click(".contextMenu button:contains('Create file')")
            b.wait_visible(".file-create-modal")
            b.wait_text(".pf-v6-c-modal-box__title-text", "Create file")
            b.wait_visible("button.pf-m-primary:disabled")

        # Error cases
        # cancel does not create a file
        open_create_file_modal()
        b.set_input_text("#file-name", "test.txt")
        b.set_input_text(".file-create-modal textarea", "content")
        b.wait_visible("button.pf-m-primary:not(:disabled)")
        b.click(".pf-v6-c-modal-box__footer button.pf-m-link")  # cancel
        b.wait_not_present(".pf-v6-c-modal-box")
        m.execute("! test -f test.txt")

        # file already exists
        m.execute("runuser -u admin touch /home/admin/test.txt")
        b.wait_visible("[data-item='test.txt']")
        open_create_file_modal()
        b.set_input_text("#file-name", "test.txt")
        b.wait_text(".file-create-modal .pf-v6-c-helper-text__item-text", "File exists")
        b.wait_visible("button.pf-m-primary:disabled")
        b.set_input_text("#file-name", "/test.txt")
        b.wait_text(".file-create-modal .pf-v6-c-helper-text__item-text", "Name cannot include a /")
        b.wait_visible("button.pf-m-primary:disabled")

        # Create a file as admin, we are superuser and /home owned by admin
        b.set_input_text("#file-name", "notes.txt")
        b.set_input_text(".file-create-modal textarea", "content")
        b.wait_val("#create-file-owner", "admin:admin")
        b.assert_pixels(".pf-v6-c-modal-box", "create-file-modal-superuser")
        b.click("button.pf-m-primary")
        b.wait_not_present(".pf-v6-c-modal-box")
        self.assert_owner("/home/admin/notes.txt", "admin:admin")
        self.assertEqual(m.execute("cat /home/admin/notes.txt"), "content")

        # create a file as root.
        open_create_file_modal()
        b.set_input_text("#file-name", "root.txt")
        b.set_input_text(".file-create-modal textarea", "root")
        b.select_from_dropdown("#create-file-owner", "root:root")
        b.click("button.pf-m-primary")
        b.wait_not_present(".pf-v6-c-modal-box")
        self.assert_owner("/home/admin/root.txt", "root:root")
        self.assertEqual(m.execute("cat /home/admin/root.txt"), "root")

        # normal user has no change owner select
        b.drop_superuser()

        open_create_file_modal()
        b.wait_not_present("#create-file-owner")
        b.set_input_text("#file-name", "admin.txt")
        b.set_input_text(".file-create-modal textarea", "admin")
        b.assert_pixels(".pf-v6-c-modal-box", "create-file-modal-admin")
        b.click("button.pf-m-primary")
        b.wait_not_present(".pf-v6-c-modal-box")
        self.assert_owner("/home/admin/admin.txt", "admin:admin")
        self.assertEqual(m.execute("cat /home/admin/admin.txt"), "admin")

        # Escape closes the dialog
        self.open_folder_context_menu()
        b.click(".contextMenu button:contains('Create file')")
        b.blur("#file-name")  # remove focus from input
        b.key("Escape")  # Escape for modal
        b.wait_not_present(".pf-v6-c-modal-box")

        # Escape does not close the the dialog when a filename is entered
        self.open_folder_context_menu()
        b.click(".contextMenu button:contains('Create file')")
        b.set_input_text("#file-name", "admin.txt")
        b.blur("#file-name")  # remove focus from input
        b.key("Escape")  # Escape for modal
        b.wait_val("#file-name", "admin.txt")
        b.click(".pf-v6-c-modal-box__footer button.pf-m-link")  # cancel
        b.wait_not_present(".pf-v6-c-modal-box")

        # Can create empty files
        open_create_file_modal()
        b.wait_not_present("#create-file-owner")
        b.set_input_text("#file-name", "empty-file")
        b.set_input_text(".file-create-modal textarea", "")
        b.click("button.pf-m-primary")
        b.wait_not_present(".pf-v6-c-modal-box")
        self.assert_owner("/home/admin/empty-file", "admin:admin")
        self.assertEqual(m.execute("cat /home/admin/empty-file"), "")

        # Unable to create a file
        self.open_folder_context_menu()
        b.click(".contextMenu button:contains('Create file')")
        b.set_input_text("#file-name", "notallowed.txt")
        b.set_input_text(".file-create-modal textarea", "nope")
        m.execute("chattr +i /home/admin")
        self.addCleanup(m.execute, "chattr -i /home/admin")
        b.click("button.pf-m-primary")
        self.wait_modal_inline_alert("Not permitted to perform this action")
        b.click(".pf-v6-c-modal-box__footer button.pf-m-link")  # cancel
        b.wait_not_present(".pf-v6-c-modal-box")

        # create a file as admin

        # cannot create file in /home as normal user
        b.click("li[data-location='/home'] a")
        self.wait_directory_changed("/home/")
        self.open_folder_context_menu()
        b.click(".contextMenu button:contains('Create file')")
        b.wait_in_text("h4.pf-v6-c-alert__title", "Cannot create file in current directory")

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

        self.enter_files()

        test_filename = "filename.txt"
        m.execute(['runuser', '-u', 'admin', 'touch', "/home/admin/existing.txt", f"/home/admin/{test_filename}"])
        test_directory = 'testdir'
        m.execute(['runuser', '-u', 'admin', 'mkdir', f'/home/admin/{test_directory}'])
        b.wait_visible("[data-item='existing.txt']")
        b.wait_visible(f"[data-item='{test_filename}']")
        b.wait_visible(f"[data-item='{test_directory}']")

        def create_symlink(filename: str) -> None:
            self.open_context_menu(f"[data-item='{filename}']")
            b.click(".contextMenu button:contains('Create link')")
            b.wait_in_text(".pf-v6-c-modal-box__title-text", f"Create link to {filename}")

        def assert_link(link: str, expected: str, owner: str | None = None) -> None:
            self.assertEqual(m.execute(f"readlink '{link}'").strip(),
                             expected)
            if owner is not None:
                self.assertEqual(self.stat("%U:%G", link), owner)

        # Absolute

        # normal filename
        create_symlink(test_filename)
        b.wait_val("#target-name", test_filename)
        b.wait_val("#symlink-input", f"link to {test_filename}")
        b.set_checked("#absolute", val=True)
        b.assert_pixels(".file-symlink-modal", "create-link-dialog")
        b.click(".pf-m-primary")
        b.wait_not_present(".pf-v6-c-modal-box")
        assert_link(f"/home/admin/link to {test_filename}",
                    f"/home/admin/{test_filename}",
                    "admin:admin")

        # absolute path given
        create_symlink(test_filename)
        b.set_input_text("#symlink-input", f"{self.vm_tmpdir}/absolute-absolute")
        b.set_checked("#absolute", val=True)
        b.click(".pf-m-primary")
        b.wait_not_present(".pf-v6-c-modal-box")
        assert_link(f"{self.vm_tmpdir}/absolute-absolute",
                    f"/home/admin/{test_filename}",
                    "root:root")

        # not canonical path
        create_symlink(test_filename)
        b.set_input_text("#symlink-input", f"/..{self.vm_tmpdir}/absolute-relative")
        b.set_checked("#absolute", val=True)
        b.click(".pf-m-primary")
        b.wait_not_present(".pf-v6-c-modal-box")
        assert_link(f"{self.vm_tmpdir}/absolute-relative",
                    f"/home/admin/{test_filename}",
                    "root:root")

        # relative path given
        create_symlink(test_filename)
        b.set_input_text("#symlink-input", "../absolute-relative")
        b.set_checked("#absolute", val=True)
        self.addCleanup(m.execute, "rm /home/absolute-relative")
        b.click(".pf-m-primary")
        b.wait_not_present(".pf-v6-c-modal-box")
        assert_link("/home/absolute-relative",
                    f"/home/admin/{test_filename}",
                    "root:root")

        # Relative
        create_symlink(test_filename)
        b.wait_val("#target-name", test_filename)
        b.set_input_text("#symlink-input", "relative")
        self.assertTrue(b.get_checked("#relative"))
        b.click(".pf-m-primary")
        b.wait_not_present(".pf-v6-c-modal-box")
        assert_link("/home/admin/relative",
                    test_filename,
                    "admin:admin")

        # absolute path given
        create_symlink(test_filename)
        b.set_input_text("#symlink-input", f"{self.vm_tmpdir}/relative-absolute")
        self.assertTrue(b.get_checked("#relative"))
        b.click(".pf-m-primary")
        b.wait_not_present(".pf-v6-c-modal-box")
        assert_link(f"{self.vm_tmpdir}/relative-absolute",
                    f"../../../home/admin/{test_filename}",
                    "root:root")

        # not canonical path
        create_symlink(test_filename)
        b.set_input_text("#symlink-input", f"/../{self.vm_tmpdir}/relative-relative")
        self.assertTrue(b.get_checked("#relative"))
        b.click(".pf-m-primary")
        b.wait_not_present(".pf-v6-c-modal-box")
        assert_link(f"{self.vm_tmpdir}/relative-relative",
                    f"../../../home/admin/{test_filename}",
                    "root:root")

        # relative path
        create_symlink(test_filename)
        b.set_input_text("#symlink-input", "../relative-relative")
        self.assertTrue(b.get_checked("#relative"))
        self.addCleanup(m.execute, "rm /home/relative-relative")
        b.click(".pf-m-primary")
        b.wait_not_present(".pf-v6-c-modal-box")
        assert_link("/home/relative-relative",
                    f"admin/{test_filename}",
                    "root:root")

        # directory
        create_symlink(test_directory)
        b.wait_val("#target-name", test_directory)
        b.wait_val("#symlink-input", f"link to {test_directory}")
        b.set_checked("#absolute", val=True)
        b.click(".pf-m-primary")
        b.wait_not_present(".pf-v6-c-modal-box")
        assert_link(f"/home/admin/link to {test_directory}",
                    f"/home/admin/{test_directory}",
                    "admin:admin")

        # symlinking to /opt/ errors out and does not create /opt/bar
        create_symlink(test_directory)
        b.wait_val("#target-name", test_directory)
        b.set_input_text("#symlink-input", "/opt/")
        b.click(".pf-m-primary")
        self.wait_modal_inline_alert("ln: failed to create symbolic link '/opt/': File exists")
        b.click(".pf-v6-c-modal-box__footer button.pf-m-link")  # cancel
        b.wait_not_present(".pf-v6-c-modal-box")

        # normal user
        b.drop_superuser()

        # absolute
        create_symlink(test_filename)
        b.set_input_text("#symlink-input", "user-absolute")
        b.set_checked("#absolute", val=True)
        b.click(".pf-m-primary")
        b.wait_not_present(".pf-v6-c-modal-box")
        assert_link("/home/admin/user-absolute",
                    f"/home/admin/{test_filename}",
                    "admin:admin")

        # relative
        create_symlink(test_filename)
        self.assertTrue(b.get_checked("#relative"))
        b.set_input_text("#symlink-input", "user-relative")
        b.click(".pf-m-primary")
        b.wait_not_present(".pf-v6-c-modal-box")
        assert_link("/home/admin/user-relative",
                    test_filename,
                    "admin:admin")

        # validation
        create_symlink(test_filename)
        b.set_input_text("#symlink-input", "")
        b.wait_text(".file-symlink-modal .pf-v6-c-helper-text__item-text", "Name cannot be empty")
        b.wait_visible("button.pf-m-primary:disabled")

        b.set_input_text("#symlink-input", "existing.txt")
        b.wait_text(".file-symlink-modal .pf-v6-c-helper-text__item-text", "File exists")

        b.set_input_text("#symlink-input", test_directory)
        b.wait_text(".file-symlink-modal .pf-v6-c-helper-text__item-text",
                    "Directory with the same name exists")
        b.wait_visible("button.pf-m-primary:disabled")

        # no permissions
        b.set_input_text("#symlink-input", "/bar")
        b.click(".pf-m-primary")
        b.wait_in_text(".pf-v6-c-alert__title", "Permission denied")
        b.wait_visible("button.pf-m-primary:disabled")

        # dialogs cwdInfo not updated, still validated on create
        b.set_input_text("#symlink-input", "new")
        m.execute("mkdir /home/admin/new")
        b.wait_visible("[data-item='new']")
        b.click(".file-symlink-modal .pf-m-primary")
        b.wait_text(".file-symlink-modal .pf-v6-c-helper-text__item-text",
                    "Directory with the same name exists")
        b.wait_visible("button.pf-m-primary:disabled")

        # Non-existent target
        b.set_input_text("#symlink-input", "../non-existent/not/really/here")
        b.click(".file-symlink-modal .pf-m-primary")
        b.wait_in_text(".pf-v6-c-alert__title", "No such file or directory")
        b.wait_visible("button.pf-m-primary:disabled")

        b.click(".file-symlink-modal .pf-m-link")
        b.wait_not_present(".pf-v6-c-modal-box")


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