Files
hermes-webui/tests/test_issue3402_workspace_os_import.py
nesquena-hermes c8f8ca3428
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.244 — Release HL (stage-q16) (#3509)
## Release v0.51.244 — Release HL (stage-q16)

UX-approved direction via Telegram (workspace drag-drop polish you requested). All 4 drag-drop flows verified live in-browser.

### Added
| PR | Author | Feature |
|----|--------|---------|
| #3402 / #3424 | @pamnard | **Drop OS files/folders onto a specific workspace folder row or breadcrumb** to upload into that directory (not just the current dir). OS folder drops are traversed (`webkitGetAsEntry`/`readEntries`) preserving nested structure. Uploads via the existing `/api/workspace/upload` (no new backend). |

### Fixed
- **Composer drop-zone jank**: dragging a workspace file (or OS file) over the composer footer rendered a translucent overlay that let the textarea/chips/icons bleed through and collide with the hint text. Now a clean, fully-opaque box with a single centered **context-aware** label — *"Drop to insert workspace reference"* (workspace file → `@path` insert) vs *"Drop files to attach"* (OS file → message attach).
- **Drag-drop handler coexistence (CORE, caught in review)**: #3424's OS-upload binding assigned `el.ondrop` on folder rows, which **overwrote** the drag-to-move handler from #3422 (also `el.ondrop`) — silently breaking move-to-folder (the ws-path drop fell through to the composer as an `@path` insert). Fixed by binding the OS-upload handlers via `addEventListener` so they compose; each handler gates on its own drag type.

### Drag-drop matrix — all verified LIVE in-browser (real drag→drop, asserted on disk)
| Flow | Result |
|------|--------|
| OS image → composer footer | ✓ attaches |
| workspace file → composer footer | ✓ inserts `@path` |
| workspace file → workspace folder | ✓ moves on disk (report.md → docs/) |
| OS file → workspace folder | ✓ uploads into target folder |

### Scope note
#3424's PR branch carried the OLD pre-hardening `_handle_file_move`. Applied **frontend-only** — master's hardened move backend (v0.51.243, TOCTOU/symlink fixes) is untouched (Codex confirmed no `api/routes.py` diff).

### Gate
- Full pytest suite: **7542 passed, 9 skipped, 3 xpassed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): CORE handler-clobber → fixed → **SAFE TO SHIP**

Co-authored-by: pamnard <pamnard@users.noreply.github.com>
2026-06-03 18:19:09 -07:00

84 lines
2.8 KiB
Python

"""Tests for #3402 part B — OS file/folder import into workspace tree targets."""
import json
import shutil
import subprocess
def _src(name: str) -> str:
with open(f"static/{name}") as f:
return f.read()
WORKSPACE_JS = _src("workspace.js")
UI_JS = _src("ui.js")
class TestIssue3402WorkspaceOsImportUi:
def test_folder_rows_bind_os_upload_drop(self):
assert "_bindWorkspaceOsUploadDropTarget(el,item.path)" in UI_JS
def test_breadcrumb_binds_os_upload_drop(self):
assert "_bindWorkspaceOsUploadDropTarget(root,'.')" in UI_JS
assert "_bindWorkspaceOsUploadDropTarget(seg,target)" in UI_JS
def test_os_upload_helpers_exist(self):
assert "function uploadOsDropToWorkspace" in WORKSPACE_JS
assert "function _collectOsDropUploads" in WORKSPACE_JS
assert "webkitGetAsEntry" in WORKSPACE_JS
def test_os_folder_drop_stops_propagation(self):
block = WORKSPACE_JS[
WORKSPACE_JS.index("function _bindWorkspaceOsUploadDropTarget"):
WORKSPACE_JS.index("// Drag-and-drop files onto workspace file tree")
]
assert block.count("e.stopPropagation()") >= 3
def test_tree_drop_skips_folder_rows(self):
assert 'closest(\'.file-item[data-ws-type="dir"]' in WORKSPACE_JS
def test_file_items_expose_ws_type_dataset(self):
assert "el.dataset.wsType=item.type" in UI_JS
def test_os_upload_highlight_css(self):
css = open("static/style.css", encoding="utf-8").read()
assert ".file-item.drag-over-upload" in css
assert ".breadcrumb-seg.drag-over-upload" in css
def test_join_workspace_path_node():
node = shutil.which("node")
if not node:
return
js = r"""
const { joinWorkspacePath, targetDirForRelDir } = (() => {
function joinWorkspacePath(base, rel) {
const b = base || '.';
const r = (rel || '').replace(/^\/+|\/+$/g, '');
if (!r) return b;
return b === '.' ? r : `${b}/${r}`;
}
function targetDirForRelDir(destDir, relDir) {
const dirPart = (relDir || '').replace(/\/+$/, '');
if (!dirPart) return destDir || '.';
return joinWorkspacePath(destDir, dirPart);
}
return { joinWorkspacePath, targetDirForRelDir };
})();
const cases = [
[joinWorkspacePath('.', ''), '.'],
[joinWorkspacePath('docs', ''), 'docs'],
[joinWorkspacePath('.', 'docs/reports'), 'docs/reports'],
[joinWorkspacePath('src', 'lib/utils'), 'src/lib/utils'],
[targetDirForRelDir('projects', ''), 'projects'],
[targetDirForRelDir('projects', 'bundle/'), 'projects/bundle'],
[targetDirForRelDir('.', 'bundle/sub/'), 'bundle/sub'],
];
console.log(JSON.stringify(cases.map(([a,b]) => b)));
"""
out = subprocess.check_output([node, "-e", js], text=True).strip()
assert json.loads(out) == [
".", "docs", "docs/reports", "src/lib/utils",
"projects", "projects/bundle", "bundle/sub",
]