"""End-to-end Playwright tests for the files-overlay file manager. The files_overlay_server fixture (conftest.py) seeds user "alice"/"secret", a files-type Overlay owned by alice, and an overlay root pre-populated with one editable text file, one binary file, and one nested folder. Each test logs in, opens /overlays/, drives the UI through real clicks + HTMX swaps, and asserts on DOM + filesystem state. Running locally: `uv run pytest -m e2e l4d2web/tests/e2e/test_files_overlay.py`. Requires `uv run playwright install chromium` once. Pattern model: test_editor.py (CM6 controller flow) + the handoff doc at docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md. """ from __future__ import annotations import urllib.parse import pytest from playwright.sync_api import Page, expect from .conftest import login pytestmark = pytest.mark.e2e def _open_overlay(page: Page, base_url: str, overlay_id: int) -> None: login(page, base_url) page.goto(f"{base_url}/overlays/{overlay_id}") def _wait_for_routed_editor(page: Page) -> None: """Wait until the routed modal's CM6 surface is mounted. The textarea itself is `display:none` (editor.css pre-hides + CM6's mount sets `style.display = "none"`), so we cannot assert visibility on it directly. The `.cm-content` element appearing under `#files-editor-fragment` is the canonical "editor is ready, the `window.__filesEditor` controller is wired" signal. """ expect(page.locator("#files-editor-fragment")).to_be_visible(timeout=5000) expect(page.locator("#files-editor-fragment .cm-content")).to_be_visible(timeout=5000) def test_edit_text_file_save_round_trip(page: Page, files_overlay_server) -> None: """Open server.cfg via the file-row name button, drive the CM6 controller to a new doc, save, and assert the disk reflects it. Exercises the full edit path: row click → openRouted → htmx swap → CM6 mount → save POST → modal close. Failure modes guarded against: * /overlays//files/edit returns the wrong fragment (textarea gets the wrong data-rel-path, or none). * editor.js's htmx:afterSwap re-init doesn't fire / fails — then window.__filesEditor stays unset and the save reads the stale textarea.value instead of the user's edits. * routedSaveClicked stops short of closeRouted() — modal stays open and the test's hidden-assertion catches it. * The POST /files/save endpoint doesn't actually write — covered by reading the file back from disk. """ base = files_overlay_server["base_url"] overlay_id = files_overlay_server["overlay_id"] overlay_root = files_overlay_server["overlay_root"] _open_overlay(page, base, overlay_id) page.click('button.file-tree-name-button[data-target-path="server.cfg"]') _wait_for_routed_editor(page) # Sanity: the modal opened for the file we asked for, not some other. assert page.locator('textarea[data-rel-path="server.cfg"]').count() == 1 new_content = 'hostname "edited by e2e"\nmp_gamemode coop\n' page.evaluate("(text) => window.__filesEditor.setContent(text)", new_content) page.click(".files-editor-save") # Modal close is async (await postJson then closeRouted()). expect(page.locator("#modal-container")).to_be_hidden(timeout=5000) assert (overlay_root / "server.cfg").read_text() == new_content def test_create_new_file_routed(page: Page, files_overlay_server) -> None: """Click `+ new file` at the overlay root, fill the routed new-file editor, click Create, and assert the file lands on disk AND the row appears in the tree. The routed new-file modal is the SAME template as the edit modal (overlay_file_editor.html with is_new=True). The differences this test exercises: * textarea has data-rel-path="" — routedSaveClicked branches on the empty value into the "compose fullPath from filename" path, not the rename-on-save path. * Save button reads "Create", not "Save". * scheduleRefresh(parentOf(fullPath)) fires after POST — for a root-level file that's refreshFolder("") which re-fetches the whole root listing; the new row should appear without a page reload. """ base = files_overlay_server["base_url"] overlay_id = files_overlay_server["overlay_id"] overlay_root = files_overlay_server["overlay_root"] _open_overlay(page, base, overlay_id) page.click('button[data-action="new-file"][data-target-path=""]') _wait_for_routed_editor(page) # Confirm the fragment opened in is_new mode (empty rel-path). assert page.locator('textarea[data-rel-path=""]').count() == 1 new_filename = "new-file.cfg" new_content = 'sv_cheats 1\nmp_gamemode versus\n' page.fill('input[data-editor-filename]', new_filename) page.evaluate("(text) => window.__filesEditor.setContent(text)", new_content) page.click(".files-editor-save") expect(page.locator("#modal-container")).to_be_hidden(timeout=5000) assert (overlay_root / new_filename).read_text() == new_content # Tree refresh is debounced 50ms then HTMX-fetched — wait for the # new row to appear rather than asserting synchronously. expect( page.locator(f'.file-tree-row-file[data-target-path="{new_filename}"]') ).to_be_visible(timeout=5000) def test_create_new_file_409_askConflict_keep_both(page: Page, files_overlay_server) -> None: """Try to create a file named `cfg`, which collides with the seeded directory of the same name. /files/save returns 409 because the target exists and is not a file; routedSaveClicked routes that 409 through fo.askConflict, which opens the INLINE #files-conflict-modal on top of the still-open routed editor. Clicking "keep-both" causes a second POST with the suffixed path (cfg → "cfg (1)"), the routed modal closes, and the new row appears in the tree. This is the F4 path from commit 8dc14f0 ("wire askConflict into the routed new-file 409 path"). Without coverage it can regress silently — the legacy create-new flow went through a different code path before the rewrite, and a missing call site would only manifest as a confusing alert() instead of the conflict dialog. Key invariants this asserts: * The conflict dialog is inline, NOT routed — it appears WITHOUT a URL change (we don't navigate, we just wait for the dialog). * .files-conflict-path shows the original colliding path, not the suffixed one. * withCollisionSuffix("cfg") returns "cfg (1)" (single-extension path with no dot falls into the trailing-suffix branch). """ base = files_overlay_server["base_url"] overlay_id = files_overlay_server["overlay_id"] overlay_root = files_overlay_server["overlay_root"] _open_overlay(page, base, overlay_id) page.click('button[data-action="new-file"][data-target-path=""]') _wait_for_routed_editor(page) page.fill('input[data-editor-filename]', "cfg") new_content = "STEAM_1:0:5\n" page.evaluate("(text) => window.__filesEditor.setContent(text)", new_content) page.click(".files-editor-save") # The conflict dialog should pop up because `cfg` is a directory. conflict = page.locator("#files-conflict-modal") expect(conflict).to_be_visible(timeout=5000) # The dialog must echo the ORIGINAL colliding path, not the suffix. expect(conflict.locator(".files-conflict-path")).to_have_text("cfg") page.click('[data-files-conflict-action="keep-both"]') # Both modals should close (conflict dialog first, routed modal # after the second /files/save resolves). expect(page.locator("#modal-container")).to_be_hidden(timeout=5000) expect(conflict).to_be_hidden(timeout=5000) # The seeded `cfg/` directory must still be intact + the new # suffixed file landed alongside it. assert (overlay_root / "cfg").is_dir() assert (overlay_root / "cfg (1)").read_text() == new_content expect( page.locator('.file-tree-row-file[data-target-path="cfg (1)"]') ).to_be_visible(timeout=5000) def test_open_binary_file_renders_replace_ui(page: Page, files_overlay_server) -> None: """Open icon.png and assert the routed editor renders the binary branch of overlay_file_editor.html — replace-zone present, save button labelled "Replace" and disabled until a file is queued or the filename is edited. The same `/files/edit?path=...` route serves both text and binary modes; the server picks the template branch based on is_editable + a magic-byte check. This test pins the binary contract: the user cannot save by accident on a fresh open, and the queue UI is wired correctly. Pins: * .files-editor-binary[data-rel-path="icon.png"] exists (the data-rel-path stable hook for save logic). * Save button has visible text "Replace" + the `disabled` attribute is set on open. * .files-editor-replace-zone + .files-editor-replace-browse + the hidden file exist. * Download link points back at /files/download?path=icon.png. """ base = files_overlay_server["base_url"] overlay_id = files_overlay_server["overlay_id"] _open_overlay(page, base, overlay_id) page.click('button.file-tree-name-button[data-target-path="icon.png"]') fragment = page.locator("#files-editor-fragment") expect(fragment).to_be_visible(timeout=5000) binary_panel = page.locator('.files-editor-binary[data-rel-path="icon.png"]') expect(binary_panel).to_be_visible(timeout=5000) save_btn = page.locator(".files-editor-save") expect(save_btn).to_have_text("Replace") expect(save_btn).to_be_disabled() expect(page.locator(".files-editor-replace-zone")).to_be_visible() expect(page.locator(".files-editor-replace-browse")).to_be_visible() # The file input is type=file hidden — Playwright's "visible" # assertions treat hidden inputs as not visible, so check # attached + the type instead. expect(page.locator(".files-editor-replace-input")).to_have_attribute( "type", "file" ) # Download link points back at the overlay's /files/download. download = page.locator("a.files-editor-download") expect(download).to_have_attribute( "href", f"/overlays/{overlay_id}/files/download?path=icon.png" ) # In binary mode there's no