Opens icon.png, attaches new bytes via Playwright's file chooser (intercepting the click → hidden-input.click() → OS picker chain), clicks Replace, and asserts the new bytes land on disk under the unchanged filename. Covers the multipart /files/replace endpoint and the click → setRoutedReplacement → save-enabled UI wiring. Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
279 lines
12 KiB
Python
279 lines
12 KiB
Python
"""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/<id>, 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 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/<id>/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 <input> 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 <textarea data-rel-path> — only the
|
|
# binary panel carries the rel-path. Catches a regression where
|
|
# the server accidentally rendered the text branch for a binary
|
|
# file (which would mean an editable textarea full of PNG bytes).
|
|
assert page.locator("textarea[data-rel-path]").count() == 0
|
|
|
|
|
|
def test_binary_replace_via_browse_writes_new_bytes(page: Page, files_overlay_server) -> None:
|
|
"""Open icon.png, click "browse" to attach a new file buffer via
|
|
Playwright's file chooser, click Replace, and assert disk reflects
|
|
the new bytes. Exercises:
|
|
* .files-editor-replace-browse triggers the hidden file input's
|
|
click() (line ~140 in files-overlay/editor.js).
|
|
* `change` event on the input flows into setRoutedReplacement,
|
|
which enables the save button + paints the queued-state UI.
|
|
* routedReplaceClicked posts FormData (multipart) to
|
|
/files/replace with the original rel-path — NOT a rename —
|
|
and the server streams the upload into place.
|
|
"""
|
|
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="icon.png"]')
|
|
expect(page.locator('.files-editor-binary[data-rel-path="icon.png"]')).to_be_visible(timeout=5000)
|
|
|
|
save_btn = page.locator(".files-editor-save")
|
|
expect(save_btn).to_be_disabled()
|
|
|
|
# The browse button calls fileInput.click() in the JS handler —
|
|
# Playwright's expect_file_chooser intercepts the resulting OS
|
|
# picker so we can hand back a buffer instead of an OS path.
|
|
new_bytes = b"\x89PNG\r\n\x1a\nE2E_REPLACED" + b"\x00" * 20
|
|
with page.expect_file_chooser() as fc_info:
|
|
page.click(".files-editor-replace-browse")
|
|
fc_info.value.set_files({
|
|
"name": "new-icon.png",
|
|
"mimeType": "image/png",
|
|
"buffer": new_bytes,
|
|
})
|
|
|
|
# Queueing a file flips the save button enabled.
|
|
expect(save_btn).to_be_enabled(timeout=2000)
|
|
save_btn.click()
|
|
|
|
expect(page.locator("#modal-container")).to_be_hidden(timeout=5000)
|
|
# The filename on disk stays the same (no rename) — only the bytes
|
|
# changed.
|
|
assert (overlay_root / "icon.png").read_bytes() == new_bytes
|