diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index 4bb3768..52200db 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index ec09f9a..3ba8b38 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -40,6 +40,25 @@ class UiSmokeGoldenTest(unittest.TestCase): return source[start : index + 1] self.fail(f"Expected closing brace for function {name}") + def _extract_async_js_function(self, source: str, name: str) -> str: + marker = f"async function {name}(" + start = source.find(marker) + if start < 0: + self.fail(f"Expected async function {name} in app.js") + brace_start = source.find("{", start) + if brace_start < 0: + self.fail(f"Expected opening brace for async function {name}") + depth = 0 + for index in range(brace_start, len(source)): + char = source[index] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return source[start : index + 1] + self.fail(f"Expected closing brace for async function {name}") + def _run_app_js_behavior_check(self, app_js: str) -> None: functions = "\n\n".join( [ @@ -592,6 +611,104 @@ class UiSmokeGoldenTest(unittest.TestCase): ) self.assertEqual(result.returncode, 0, msg=result.stderr or result.stdout) + def _run_operation_start_behavior_check(self, app_js: str) -> None: + functions = "\n\n".join( + [ + self._extract_js_function(app_js, "paneState"), + self._extract_js_function(app_js, "otherPane"), + self._extract_js_function(app_js, "defaultDestination"), + self._extract_async_js_function(app_js, "startCopySelected"), + self._extract_async_js_function(app_js, "executeMoveSelection"), + ] + ) + script = textwrap.dedent( + f""" + const assert = (condition, message) => {{ + if (!condition) {{ + throw new Error(message); + }} + }}; + + let state = {{ + activePane: "left", + panes: {{ + left: {{ + currentPath: "storage1/source", + selectedItems: [ + {{ path: "storage1/source/a.txt", kind: "file", name: "a.txt" }}, + {{ path: "storage1/source/b.txt", kind: "file", name: "b.txt" }}, + ], + }}, + right: {{ + currentPath: "storage1/dest", + selectedItems: [], + }}, + }}, + selectedTaskId: null, + }}; + + const apiCalls = []; + const statusMessages = []; + const errorCalls = []; + const refreshCalls = []; + const loadCalls = []; + const clearedSelection = []; + + async function apiRequest(method, url, body) {{ + apiCalls.push({{ method, url, body }}); + return {{ task_id: "task-123", status: "queued" }}; + }} + function setError() {{}} + function setActionError(action, err) {{ errorCalls.push({{ action, message: err.message }}); }} + function setStatus(message) {{ statusMessages.push(message); }} + async function refreshTasksSnapshot() {{ refreshCalls.push(true); }} + async function loadBrowsePane(pane) {{ loadCalls.push(pane); }} + function setSelectedItem(pane, value) {{ clearedSelection.push({{ pane, value }}); }} + + {functions} + + (async () => {{ + await startCopySelected(); + assert(apiCalls.length === 1, "Multi-select copy should issue one request"); + assert(apiCalls[0].url === "/api/files/copy", "Copy should use copy endpoint"); + assert(Array.isArray(apiCalls[0].body.sources), "Copy should send batch sources"); + assert(apiCalls[0].body.sources.length === 2, "Copy batch should include all selected items"); + assert(apiCalls[0].body.destination_base === "storage1/dest", "Copy batch should target destination base"); + assert(state.selectedTaskId === "task-123", "Copy should store the created task id"); + assert(refreshCalls.length === 1, "Copy should refresh task snapshot once"); + assert(statusMessages.includes("Copy: operation started"), "Copy should report operation start"); + + apiCalls.length = 0; + refreshCalls.length = 0; + statusMessages.length = 0; + state.selectedTaskId = null; + + await executeMoveSelection("storage1/dest"); + assert(apiCalls.length === 1, "Multi-select move should issue one request"); + assert(apiCalls[0].url === "/api/files/move", "Move should use move endpoint"); + assert(Array.isArray(apiCalls[0].body.sources), "Move should send batch sources"); + assert(apiCalls[0].body.sources.length === 2, "Move batch should include all selected items"); + assert(apiCalls[0].body.destination_base === "storage1/dest", "Move batch should target destination base"); + assert(state.selectedTaskId === "task-123", "Move should store the created task id"); + assert(refreshCalls.length === 1, "Move should refresh task snapshot once"); + assert(statusMessages.includes("Move: operation started"), "Move should report operation start"); + assert(clearedSelection.length === 1 && clearedSelection[0].pane === "left", "Move batch should clear source selection once"); + assert(errorCalls.length === 0, "Batch operation start should not emit action errors"); + }})().catch((error) => {{ + console.error(error); + process.exit(1); + }}); + """ + ) + result = subprocess.run( + ["node", "-e", script], + cwd="/workspace/webmanager-mvp", + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, msg=result.stderr or result.stdout) + def test_ui_mount_and_index_contains_expected_panels(self) -> None: mount = self._ui_mount() self.assertIsInstance(mount.app, StaticFiles) @@ -1014,6 +1131,10 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('startCopySelected();', app_js) self.assertIn('openF6Flow();', app_js) self.assertIn('deleteSelected();', app_js) + self.assertIn('sources: selectedItems.map((item) => item.path),', app_js) + self.assertIn('destination_base: baseDestination,', app_js) + self.assertIn('setStatus("Copy: operation started");', app_js) + self.assertIn('setStatus("Move: operation started");', app_js) self.assertIn('const confirmed = await openConfirmModal({', app_js) self.assertIn('title: selectedItems.length === 1 ? "Delete item?" : "Delete selected items?"', app_js) self.assertIn('title: "Discard unsaved changes?"', app_js) @@ -1108,6 +1229,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('input.setAttribute("webkitdirectory", "")', app_js) self.assertIn('await apiRequest("POST", "/api/files/mkdir", {', app_js) self.assertIn('await uploadFileRequest(targetPath, entry.file, overwrite);', app_js) + self._run_operation_start_behavior_check(app_js) self.assertIn('Folder upload: preparing', app_js) self.assertIn('Folder upload: ${uploadState.successfulCount} uploaded, ${uploadState.skippedCount} skipped', app_js) self.assertIn('async function handleUploadSelection(event)', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index b6bd5fc..94df0d8 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -2874,28 +2874,30 @@ async function startCopySelected() { } const baseDestination = paneState(destinationPane).currentPath; setError("actions-error", ""); - let successes = 0; - let failures = 0; - let firstError = null; - for (const item of selectedItems) { - const destination = defaultDestination(item.path, baseDestination); - try { - const result = await apiRequest("POST", "/api/files/copy", { + try { + let result; + if (selectedItems.length > 1) { + result = await apiRequest("POST", "/api/files/copy", { + sources: selectedItems.map((item) => item.path), + destination_base: baseDestination, + }); + setStatus("Copy: operation started"); + } else { + const item = selectedItems[0]; + const destination = defaultDestination(item.path, baseDestination); + result = await apiRequest("POST", "/api/files/copy", { source: item.path, destination, }); - state.selectedTaskId = result.task_id; - await refreshTasksSnapshot(); - successes += 1; - } catch (err) { - failures += 1; - if (!firstError) { - firstError = `${item.path}: ${err.message}`; - } + setStatus("Copy: started"); } + state.selectedTaskId = result.task_id; + await refreshTasksSnapshot(); + } catch (err) { + setActionError("Copy", err); + return; } await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); - showActionSummary("Copy", successes, failures, firstError); } async function startMoveSelected() { @@ -2908,10 +2910,9 @@ async function executeMoveSelection(baseDestination) { if (selectedItems.length === 0) { return; } - const allFiles = selectedItems.every((item) => item.kind === "file"); setError("actions-error", ""); - if (!allFiles) { + if (selectedItems.length > 1) { const result = await apiRequest("POST", "/api/files/move", { sources: selectedItems.map((item) => item.path), destination_base: baseDestination, @@ -2920,36 +2921,25 @@ async function executeMoveSelection(baseDestination) { await refreshTasksSnapshot(); setSelectedItem(sourcePane, null); await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); - setStatus("Move: batch started"); + setStatus("Move: operation started"); return; } - let successes = 0; - let failures = 0; - let firstError = null; - for (const item of selectedItems) { - const destination = defaultDestination(item.path, baseDestination); - try { - if (item.kind !== "file") { - throw new Error("Only files are supported for move"); - } - const result = await apiRequest("POST", "/api/files/move", { - source: item.path, - destination, - }); - state.selectedTaskId = result.task_id; - await refreshTasksSnapshot(); - successes += 1; - } catch (err) { - failures += 1; - if (!firstError) { - firstError = `${item.path}: ${err.message}`; - } - } + const item = selectedItems[0]; + const destination = defaultDestination(item.path, baseDestination); + if (item.kind !== "file") { + setActionError("Move", new Error("Only files are supported for single-item move")); + return; } + const result = await apiRequest("POST", "/api/files/move", { + source: item.path, + destination, + }); + state.selectedTaskId = result.task_id; + await refreshTasksSnapshot(); setSelectedItem(sourcePane, null); await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); - showActionSummary("Move", successes, failures, firstError); + setStatus("Move: started"); } async function addBookmark() {