| 1 | from pathlib import Path |
| 2 | import sys |
| 3 | |
| 4 | import pytest |
| 5 | |
| 6 | |
| 7 | PROJECT_ROOT = Path(__file__).resolve().parents[1] |
| 8 | if str(PROJECT_ROOT) not in sys.path: |
| 9 | sys.path.insert(0, str(PROJECT_ROOT)) |
| 10 | |
| 11 | |
| 12 | from helpers.file_browser import FileBrowser |
| 13 | |
| 14 | |
| 15 | def read(*parts: str) -> str: |
| 16 | return PROJECT_ROOT.joinpath(*parts).read_text(encoding="utf-8") |
| 17 | |
| 18 | |
| 19 | def test_file_browser_remember_last_directory_defaults_enabled() -> None: |
| 20 | settings_source = read("helpers", "settings.py") |
| 21 | |
| 22 | assert "file_browser_remember_last_directory: bool" in settings_source |
| 23 | assert "file_browser_remember_last_directory=get_default_value(" in settings_source |
| 24 | assert '"file_browser_remember_last_directory",\n True,' in settings_source |
| 25 | |
| 26 | |
| 27 | def test_file_browser_editable_path_bar_and_remembered_directory_contract() -> None: |
| 28 | html = read("webui", "components", "modals", "file-browser", "file-browser.html") |
| 29 | store = read("webui", "components", "modals", "file-browser", "file-browser-store.js") |
| 30 | workdir_settings = read("webui", "components", "settings", "agent", "workdir.html") |
| 31 | |
| 32 | assert 'class="path-navigator"' in html |
| 33 | assert 'class="nav-button back-button"' in html |
| 34 | assert 'class="text-button back-button"' not in html |
| 35 | assert ".nav-button:focus-visible" in html |
| 36 | assert ".nav-button .material-symbols-outlined" in html |
| 37 | assert 'class="nav-button-label">Up</span>' in html |
| 38 | assert "flex-direction: column;" in html |
| 39 | assert ".nav-button-label" in html |
| 40 | assert 'x-model="$store.fileBrowser.pathInput"' in html |
| 41 | assert '@submit.prevent="$store.fileBrowser.submitPath()"' in html |
| 42 | assert "Go to directory" in html |
| 43 | assert "$store.fileBrowser.pathError" in html |
| 44 | |
| 45 | assert "FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY" in store |
| 46 | assert 'callJsonApi("settings_get", null)' in store |
| 47 | assert "file_browser_remember_last_directory" in store |
| 48 | assert "getRememberedDirectory()" in store |
| 49 | assert "rememberCurrentDirectory(this.browser.currentPath)" in store |
| 50 | assert "clearRememberedDirectory()" in store |
| 51 | assert "scheduleMountedDefaultLoad()" in store |
| 52 | assert 'this.browser.currentPath = "";' in store |
| 53 | assert 'this.browser.parentPath = "";' in store |
| 54 | assert 'const requestedPath = this.normalizeOpeningPath(path) || "$WORK_DIR";' in store |
| 55 | assert "`/get_work_dir_files?path=${encodeURIComponent(requestedPath)}`" in store |
| 56 | assert 'result.current_path || (requestedPath === "$WORK_DIR" ? "/a0" : requestedPath)' in store |
| 57 | |
| 58 | explicit_path_index = store.index("const explicitPath = this.normalizeOpeningPath") |
| 59 | remembered_path_index = store.index("const rememberedPath = !explicitPath") |
| 60 | assert explicit_path_index < remembered_path_index |
| 61 | |
| 62 | assert "Remember last file browser location" in workdir_settings |
| 63 | assert "$store.settings.settings.file_browser_remember_last_directory" in workdir_settings |
| 64 | |
| 65 | |
| 66 | def test_file_browser_compact_controls_and_narrow_layout_contract() -> None: |
| 67 | html = read("webui", "components", "modals", "file-browser", "file-browser.html") |
| 68 | dox = read("webui", "components", "modals", "file-browser", "AGENTS.md") |
| 69 | |
| 70 | assert 'aria-label="New file"' in html |
| 71 | assert 'title="New file"' in html |
| 72 | assert 'aria-label="New folder"' in html |
| 73 | assert 'title="New folder"' in html |
| 74 | assert ">New File<" not in html |
| 75 | assert ">New Folder<" not in html |
| 76 | assert ".btn-new-item" in html |
| 77 | assert "width: 2.8rem;" in html |
| 78 | assert "height: 2.8rem;" in html |
| 79 | assert ".path-navigator {\n align-items: center;\n flex-direction: row;" in html |
| 80 | assert ".file-browser-toolbar {\n align-items: center;\n flex-direction: row;" in html |
| 81 | assert ".file-search-shell {\n flex: 1 1 auto;\n min-width: 0;\n width: auto;" in html |
| 82 | assert ".path-navigator .nav-button-label {\n display: none;" in html |
| 83 | |
| 84 | assert "container: file-browser / inline-size;" in html |
| 85 | assert "@container file-browser (max-width: 620px)" in html |
| 86 | assert "grid-template-columns: 2.25rem minmax(0, 1fr) minmax(4.25rem, max-content) 8rem;" in html |
| 87 | assert ".file-cell-date,\n .file-date {\n display: none;" in html |
| 88 | assert ".file-cell-size,\n .file-size" not in html |
| 89 | |
| 90 | assert "hiding the Modified date column" in dox |
| 91 | assert "New file and New folder controls icon-only" in dox |
| 92 | |
| 93 | |
| 94 | def test_file_browser_editor_picker_modes_have_primary_footer_actions() -> None: |
| 95 | html = read("webui", "components", "modals", "file-browser", "file-browser.html") |
| 96 | store = read("webui", "components", "modals", "file-browser", "file-browser-store.js") |
| 97 | dox = read("webui", "components", "modals", "file-browser", "AGENTS.md") |
| 98 | |
| 99 | assert "PICKER_MODE_TEXT_OPEN" in store |
| 100 | assert "PICKER_MODE_SAVE_AS" in store |
| 101 | assert "openTextPicker" in store |
| 102 | assert "openSaveAsPicker" in store |
| 103 | assert 'new Set(["md", "txt"])' in store |
| 104 | assert "pickerSelectedFiles()" in store |
| 105 | assert "validatePickerFilename" in store |
| 106 | assert "handleFileNameClick(file = {})" in store |
| 107 | assert "fileSurfaceTarget(file) === \"editor\"" in store |
| 108 | assert "isEditorSurface(file = {})" in store |
| 109 | assert "canOpenInActionMenu(file = {})" in store |
| 110 | |
| 111 | assert "file-browser-picker-actions" in html |
| 112 | assert "file-editor-open-action" in html |
| 113 | assert 'aria-label="Open in Editor"' in html |
| 114 | assert "picker-filename-input" in html |
| 115 | assert "Open Selected" in store |
| 116 | assert "Save Here" in store |
| 117 | assert "$store.fileBrowser.confirmPicker()" in html |
| 118 | assert "$store.fileBrowser.pickerSelectionLabel()" in html |
| 119 | assert "$store.fileBrowser.isPickerMode()" in html |
| 120 | assert "$store.fileBrowser.isTextOpenPicker()" in html |
| 121 | assert "picker-confirm-button" in html |
| 122 | |
| 123 | assert "picker modes for Editor Open and Save As" in dox |
| 124 | assert "Markdown or plain text files" in dox |
| 125 | assert "Open in Editor action visible outside the overflow menu" in dox |
| 126 | |
| 127 | editor_button_index = html.index("file-editor-open-action") |
| 128 | dropdown_menu_index = html.index('class="dropdown-menu file-actions-menu"') |
| 129 | assert editor_button_index < dropdown_menu_index |
| 130 | assert 'x-show="$store.fileBrowser.canOpenInActionMenu(file)"' in html |
| 131 | |
| 132 | |
| 133 | def test_file_browser_extract_and_editor_download_actions() -> None: |
| 134 | browser_html = read("webui", "components", "modals", "file-browser", "file-browser.html") |
| 135 | browser_store = read("webui", "components", "modals", "file-browser", "file-browser-store.js") |
| 136 | editor_html = read("plugins", "_editor", "webui", "editor-panel.html") |
| 137 | editor_store = read("plugins", "_editor", "webui", "editor-store.js") |
| 138 | |
| 139 | assert 'x-show="!file.is_dir && $store.fileBrowser.isArchive(file.name)"' in browser_html |
| 140 | assert '$store.fileBrowser.extractArchive(file)' in browser_html |
| 141 | assert "ARCHIVE_SUFFIXES" in browser_store |
| 142 | assert 'fetchApi("/extract_work_dir_archive"' in browser_store |
| 143 | assert "async extractArchive(file = {})" in browser_store |
| 144 | assert "<span>Extract</span>" in browser_html |
| 145 | assert "downloadActiveFile()" in editor_store |
| 146 | assert "$store.editor.downloadActiveFile()" in editor_html |
| 147 | assert "<span>Download</span>" in editor_html |
| 148 | |
| 149 | |
| 150 | def test_file_browser_dropdown_escapes_scroll_container_and_header_is_opaque() -> None: |
| 151 | html = read("webui", "components", "modals", "file-browser", "file-browser.html") |
| 152 | store = read("webui", "components", "modals", "file-browser", "file-browser-store.js") |
| 153 | |
| 154 | assert '<div class="files-list" @scroll="$store.fileBrowser.closeDropdown()">' in html |
| 155 | assert 'overflow: auto;' in html |
| 156 | assert 'x-teleport="body"' in html |
| 157 | assert 'class="dropdown-menu file-actions-menu"' in html |
| 158 | assert ':style="$store.fileBrowser.dropdownStyle"' in html |
| 159 | assert '@click.stop="$store.fileBrowser.toggleDropdown(file.path, $event.currentTarget)"' in html |
| 160 | assert "getDropdownStyle(triggerElement)" in store |
| 161 | assert 'position: "fixed"' in store |
| 162 | assert 'zIndex: "6000"' in store |
| 163 | |
| 164 | assert "var(--secondary-bg)" not in html |
| 165 | assert "var(--border-color)" not in html |
| 166 | assert "var(--text-secondary)" not in html |
| 167 | assert "background: color-mix(in srgb, var(--color-panel) 88%, var(--color-background) 12%);" in html |
| 168 | assert "border-bottom: 1px solid var(--color-border);" in html |
| 169 | |
| 170 | |
| 171 | def test_file_browser_empty_api_path_uses_default_workdir_contract() -> None: |
| 172 | api_source = read("api", "get_work_dir_files.py") |
| 173 | api_dox = read("api", "get_work_dir_files.py.dox.md") |
| 174 | |
| 175 | assert 'current_path = request.args.get("path", "") or "$WORK_DIR"' in api_source |
| 176 | assert 'current_path = "/a0"' in api_source |
| 177 | assert "Empty `path` requests and explicit `$WORK_DIR` requests resolve" in api_dox |
| 178 | |
| 179 | |
| 180 | def test_file_browser_is_registered_as_right_canvas_surface() -> None: |
| 181 | html = read("webui", "components", "modals", "file-browser", "file-browser.html") |
| 182 | store = read("webui", "components", "modals", "file-browser", "file-browser-store.js") |
| 183 | surfaces = read("webui", "js", "surfaces.js") |
| 184 | register = read("extensions", "webui", "right_canvas_register_surfaces", "register-files.js") |
| 185 | panel = read("extensions", "webui", "right-canvas-panels", "files-panel.html") |
| 186 | input_store = read("webui", "components", "chat", "input", "input-store.js") |
| 187 | welcome_store = read("webui", "components", "welcome", "welcome-store.js") |
| 188 | |
| 189 | assert 'id: "files"' in surfaces |
| 190 | assert 'title: "Files"' in surfaces |
| 191 | assert 'modalPath: "modals/file-browser/file-browser.html"' in surfaces |
| 192 | assert 'await store.openSurface(payload.path || payload.filePath || payload.directory || "")' in surfaces |
| 193 | assert 'data-surface-id="files"' in html |
| 194 | assert 'data-surface-modal-path="modals/file-browser/file-browser.html"' in html |
| 195 | assert 'class="surface-modal file-browser-modal modal-no-backdrop"' in html |
| 196 | assert 'class="file-browser-modal-body"' in html |
| 197 | assert 'x-create="$store.fileBrowser.onMount($el, xAttrs($el) || {})"' in html |
| 198 | assert 'x-destroy="$store.fileBrowser.onUnmount(xAttrs($el) || {})"' in html |
| 199 | assert ".modal-inner.file-browser-modal" in html |
| 200 | assert "resize: both" in html |
| 201 | assert "openSurface(path" in store |
| 202 | assert "setupFloatingSurfaceModalChrome" in store |
| 203 | assert 'focusButtonClass: "file-browser-modal-focus-button"' in store |
| 204 | assert "beginSurfaceHandoff()" in store |
| 205 | assert "finishSurfaceHandoff()" in store |
| 206 | assert 'id: "files"' in register |
| 207 | assert "fileBrowserStore.openSurface" in register |
| 208 | assert 'data-surface-id="files"' in panel |
| 209 | assert 'path="modals/file-browser/file-browser.html" mode="canvas"' in panel |
| 210 | assert 'openLatestSurface("files"' in input_store |
| 211 | assert 'import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";' in welcome_store |
| 212 | assert "fileBrowserStore.open()" in welcome_store |
| 213 | assert "chatInputStore.browseFiles" not in welcome_store |
| 214 | |
| 215 | |
| 216 | def test_file_browser_reports_missing_directory(tmp_path: Path) -> None: |
| 217 | missing_directory = tmp_path / "missing" |
| 218 | |
| 219 | result = FileBrowser().get_files(str(missing_directory)) |
| 220 | |
| 221 | assert result["entries"] == [] |
| 222 | assert result["current_path"] == str(missing_directory) |
| 223 | assert result["error"] == "Directory not found" |
| 224 | |
| 225 | |
| 226 | def test_file_browser_moves_selected_items_without_overwriting_or_self_nesting(tmp_path: Path) -> None: |
| 227 | browser = FileBrowser() |
| 228 | browser.base_dir = tmp_path |
| 229 | source_file = tmp_path / "note.md" |
| 230 | source_folder = tmp_path / "skills" |
| 231 | destination = tmp_path / "archive" |
| 232 | source_file.write_text("hello", encoding="utf-8") |
| 233 | source_folder.mkdir() |
| 234 | destination.mkdir() |
| 235 | |
| 236 | moved = browser.move_items(["note.md", "skills"], "archive") |
| 237 | |
| 238 | assert moved == [str(destination / "note.md"), str(destination / "skills")] |
| 239 | assert (destination / "note.md").read_text(encoding="utf-8") == "hello" |
| 240 | assert (destination / "skills").is_dir() |
| 241 | |
| 242 | collision = tmp_path / "collision.md" |
| 243 | collision.write_text("source", encoding="utf-8") |
| 244 | (destination / "collision.md").write_text("keep", encoding="utf-8") |
| 245 | with pytest.raises(FileExistsError, match="already exists"): |
| 246 | browser.move_items(["collision.md"], "archive") |
| 247 | assert collision.read_text(encoding="utf-8") == "source" |
| 248 | assert (destination / "collision.md").read_text(encoding="utf-8") == "keep" |
| 249 | |
| 250 | nested = destination / "skills" / "nested" |
| 251 | nested.mkdir() |
| 252 | with pytest.raises(ValueError, match="cannot be moved into itself"): |
| 253 | browser.move_items(["archive/skills"], "archive/skills/nested") |
| 254 | |
| 255 | |
| 256 | def test_file_browser_drag_and_drop_contract() -> None: |
| 257 | html = read("webui", "components", "modals", "file-browser", "file-browser.html") |
| 258 | store = read("webui", "components", "modals", "file-browser", "file-browser-store.js") |
| 259 | attachments = read("webui", "components", "chat", "attachments", "attachmentsStore.js") |
| 260 | api = read("api", "rename_work_dir_file.py") |
| 261 | |
| 262 | assert ':draggable="!$store.fileBrowser.isPickerMode() && !$store.fileBrowser.isBulkBusy"' in html |
| 263 | assert "$store.fileBrowser.dropItems(file.path, file.name, $event)" in html |
| 264 | assert "$store.fileBrowser.dropItems($store.fileBrowser.browser.parentPath, 'parent folder', $event)" in html |
| 265 | start_drag = store[store.index(" startDrag("):store.index(" isDraggingPath(")] |
| 266 | assert "this.clearSelection()" not in start_drag |
| 267 | assert "file.selected = true" not in start_drag |
| 268 | assert ": [file.path]" in start_drag |
| 269 | assert "decorateEntries(data.data?.entries || [], selectedPaths)" in store |
| 270 | assert "application/x-agent-zero-files" in store |
| 271 | assert 'action: "move"' in store |
| 272 | assert 'fetchApi("/rename_work_dir_file"' in store |
| 273 | assert 'if action == "move":' in api |
| 274 | assert 'isExternalFileDrag(event)' in attachments |
| 275 | assert 'includes("Files")' in attachments |