left4me/l4d2web/tests/e2e/test_files_overlay.py
mwiegand aad8356613
test(files): cover 409 askConflict keep-both path
Tries to create `cfg`, which collides with the seeded directory of the
same name. /files/save returns 409 "destination is not a file";
routedSaveClicked routes that through fo.askConflict, which opens the
inline #files-conflict-modal on top of the still-open routed editor.
Clicking keep-both triggers a second POST with the suffixed path
(`cfg (1)`), the routed modal closes, and the new row materialises in
the tree.

This is the F4 path from 8dc14f0 ("wire askConflict into the routed
new-file 409 path"). Before that commit, the routed code branch fell
through to a generic alert(). With this test in place, a missing
call site fails loudly instead of silently.

Pins three invariants:
  * The conflict dialog is INLINE, not routed — it appears without a
    URL change (the decision tree in AGENTS.md "Modals: inline vs
    routed" hinges on this).
  * .files-conflict-path echoes the original colliding path, not the
    computed suffix — the suffix is internal, the user sees the
    collision.
  * withCollisionSuffix("cfg") → "cfg (1)" (no dot after the last
    slash → trailing-suffix branch in uploads.js).

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:40:56 +02:00

177 lines
8 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)