Compare commits

...

5 commits

Author SHA1 Message Date
mwiegand
d21cd72f8d
test(files): cover server-detail hover-download
New test module (test_server_detail.py) — the server-detail page is
NOT a files-overlay (files_overlay=False in the template), it just
reuses _overlay_file_tree.html in read-only mode. Tests live
separately to make the semantic split visible.

The test navigates to /servers/<id>, hovers the server.cfg row to
defeat the CSS :hover gate on .files-row-actions
(opacity:0/pointer-events:none → 1/auto), clicks the ⬇ download
link, and asserts both the suggested filename and the byte content
of the downloaded file.

The :hover gate is load-bearing: without locator.hover() first,
pointer-events:none blocks the click. A regression that ships
actions always-visible would change the user-flow ergonomics and
needs to update this test deliberately.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md
(Tier 3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:22:17 +02:00
mwiegand
b43bb9e0fa
test(files): add server-detail e2e fixture
Adds server_with_files fixture: seeds alice + a Blueprint + a Server
row pinned to port 27015, then pre-creates
LEFT4ME_ROOT/runtime/<server_id>/merged/ with a single seed file.
The /servers/<id> page lists files from that merged directory (the
kernel-overlayfs view of a running server), which never exists on
dev/test boxes — without the pre-mkdir + seed, the page renders
the empty-state branch instead of file rows.

Server.port is a global UNIQUE constraint on the model, but every
e2e test gets its own SQLite DB, so a fixed L4D2-default port is
fine.

Sets the stage for the Tier 3 hover-download test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:20:20 +02:00
mwiegand
e89dd25cdd
test(files): cover internal drag row to folder move
Drags server.cfg from the overlay root onto the cfg/ folder row,
asserts both that the file actually moved on disk AND that the tree
reflects the move after the debounced HTMX refresh of both parents.

Exercises the internal-drag path in uploads.js:332-366: the
custom-MIME setData/getData contract, the POST /files/move call,
and the dual scheduleRefresh(parentOf(src)) + scheduleRefresh(dst)
on success. Playwright's locator.drag_to() synthesizes setData
correctly — it relies on dataTransfer.getData(), NOT
webkitGetAsEntry which Playwright cannot fake.

Pitfalls handled inline: scoped the source/target locators to
li.file-tree-row-{file,dir} because action buttons inside the row
duplicate data-target-path + data-row-kind and would trip strict
mode. Used to_have_count instead of to_be_visible because cfg's
children div is collapsed (hidden) by default — the moved row is
in the DOM but not visually rendered until cfg is expanded.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md
(Tier 2 case C).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:18:57 +02:00
mwiegand
a6be29c6d2
test(files): cover Escape closes editor with no stale state
Opens server.cfg, drives the CM6 controller to a dirty buffer,
presses Escape to close without saving, then reopens the same file
and asserts the editor shows the original disk content — not the
discarded buffer.

Pins two invariants: native <dialog>'s cancel→close path stays
intact (no JS shortcut around Escape), and the reopened editor
fetches a FRESH fragment via htmx.ajax with CM6 re-mounting on
the new textarea. A regression that cached buffer state to "feel
snappy" would fail this test loudly.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md
(Tier 2 case B).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:17:21 +02:00
mwiegand
b222fdc918
test(files): cover share-URL deep link reopens editor
Navigates directly to /overlays/<id>?modal=<urlencoded edit path>
and asserts the routed editor auto-opens on the right file.

This is the central guarantee of the URL-addressable modals spec —
copy the URL, share it, and the recipient lands on the same modal
state. A regression in modals.js's DOMContentLoaded bootstrap (the
URLSearchParams.get("modal") → fetchAndShowRouted chain) would
silently dead-link every shared URL; this test fails loudly instead.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md
(Tier 2, highest-value case).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:16:51 +02:00
3 changed files with 240 additions and 1 deletions

View file

@ -14,7 +14,7 @@ from werkzeug.serving import make_server
from l4d2web.app import create_app from l4d2web.app import create_app
from l4d2web.auth import hash_password from l4d2web.auth import hash_password
from l4d2web.db import init_db, session_scope from l4d2web.db import init_db, session_scope
from l4d2web.models import Blueprint, Overlay, User from l4d2web.models import Blueprint, Overlay, Server, User
def _free_port() -> int: def _free_port() -> int:
@ -164,3 +164,65 @@ def files_overlay_server(tmp_path, monkeypatch):
} }
finally: finally:
shutdown() shutdown()
@pytest.fixture(scope="function")
def server_with_files(tmp_path, monkeypatch):
"""live_server + a Server owned by alice with a populated runtime
merged directory. Used by the server-detail e2e tests that exercise
file rows + download.
The /servers/<id> page lists files from
LEFT4ME_ROOT/runtime/<server_id>/merged/ (the kernel-overlayfs view
of a running server). On a dev/test box no overlayfs is mounted, so
the fixture pre-creates that directory and seeds one plain file
enough for the file-tree partial to render rows.
Yields {base_url, user_id, blueprint_id, server_id, merged_root}.
"""
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
app = _boot_app(tmp_path, monkeypatch)
with session_scope() as session:
user = User(
username="alice",
password_digest=hash_password("secret"),
admin=False,
)
session.add(user)
session.flush()
bp = Blueprint(
user_id=user.id, name="bp", arguments="[]", config="[]"
)
session.add(bp)
session.flush()
# Server.port has a global UNIQUE constraint, but this is a
# fresh per-test SQLite DB so any value works — 27015 is the
# L4D2 default, semantically obvious.
server = Server(
user_id=user.id,
blueprint_id=bp.id,
name="srv",
port=27015,
)
session.add(server)
session.flush()
user_id = user.id
blueprint_id = bp.id
server_id = server.id
merged_root = tmp_path / "runtime" / str(server_id) / "merged"
merged_root.mkdir(parents=True)
(merged_root / "server.cfg").write_text('hostname "from-merged-runtime"\n')
base_url, shutdown = _serve(app)
try:
yield {
"base_url": base_url,
"user_id": user_id,
"blueprint_id": blueprint_id,
"server_id": server_id,
"merged_root": merged_root,
}
finally:
shutdown()

View file

@ -14,6 +14,8 @@ docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.
""" """
from __future__ import annotations from __future__ import annotations
import urllib.parse
import pytest import pytest
from playwright.sync_api import Page, expect from playwright.sync_api import Page, expect
@ -383,3 +385,118 @@ def test_filename_rename_on_save(page: Page, files_overlay_server) -> None:
expect( expect(
page.locator('.file-tree-row-file[data-target-path="server.cfg"]') page.locator('.file-tree-row-file[data-target-path="server.cfg"]')
).to_have_count(0) ).to_have_count(0)
def test_share_url_deep_link_reopens_editor(page: Page, files_overlay_server) -> None:
"""Navigate directly to /overlays/<id>?modal=<urlencoded edit path>
and assert the routed editor auto-opens for the right file. This
is the central guarantee of the URL-addressable modals spec copy
the current URL, paste it in a new tab (or share it), and the
modal reopens. Without this test, a regression in modals.js's
DOMContentLoaded bootstrap (the `URLSearchParams.get("modal")
fetchAndShowRouted` chain) would silently make every shared link
dead.
The `?modal=` param holds the UNENCODED routed path; URLSearchParams
decodes it for us. We pass the URL-encoded version through quote()
so the surrounding URL stays valid.
"""
base = files_overlay_server["base_url"]
overlay_id = files_overlay_server["overlay_id"]
login(page, base)
modal_path = f"/overlays/{overlay_id}/files/edit?path=server.cfg"
page.goto(
f"{base}/overlays/{overlay_id}?modal={urllib.parse.quote(modal_path)}"
)
_wait_for_routed_editor(page)
assert page.locator('textarea[data-rel-path="server.cfg"]').count() == 1
def test_modal_close_on_escape_preserves_no_state(page: Page, files_overlay_server) -> None:
"""Open server.cfg, drive the CM6 controller to a dirty buffer,
press Escape to close the modal without saving, then reopen the
same file and assert the editor shows the original disk content
not the dirty buffer the user just discarded.
Pins two invariants:
* modals.js's `cancel` → preventDefault → close() path on
`<dialog id="modal-container">` actually closes the modal on
Escape (no JS shortcuts away from the native behavior).
* The reopened editor fetches a fresh fragment via htmx.ajax;
the CM6 controller re-mounts on the FRESH textarea (seeded
from disk content), not a stale cache of the prior session.
A regression that survived the close event e.g., a future commit
that cached buffer state in a module-scope variable to "feel
snappy" — would surface here as the dirty content reappearing.
"""
base = files_overlay_server["base_url"]
overlay_id = files_overlay_server["overlay_id"]
overlay_root = files_overlay_server["overlay_root"]
original_content = (overlay_root / "server.cfg").read_text()
_open_overlay(page, base, overlay_id)
# --- First open: type something but don't save ----------------------
page.click('button.file-tree-name-button[data-target-path="server.cfg"]')
_wait_for_routed_editor(page)
dirty = "DIRTY UNSAVED BUFFER " + "x" * 40
page.evaluate("(t) => window.__filesEditor.setContent(t)", dirty)
page.keyboard.press("Escape")
expect(page.locator("#modal-container")).to_be_hidden(timeout=5000)
# Disk must still hold the original — never saved.
assert (overlay_root / "server.cfg").read_text() == original_content
# --- Reopen and confirm CM6 starts fresh from disk ------------------
page.click('button.file-tree-name-button[data-target-path="server.cfg"]')
_wait_for_routed_editor(page)
reopened = page.evaluate("() => window.__filesEditor.getValue()")
assert reopened == original_content
def test_drag_row_to_folder_moves_file(page: Page, files_overlay_server) -> None:
"""Drag the server.cfg row from the overlay root onto the cfg/
folder row. Asserts the file actually moved on disk AND the tree
reflects the move after the debounced HTMX refresh.
Exercises the internal-drag path in uploads.js:332-366 the
`dataTransfer.setData("application/x-files-overlay", path)`
`getData` round-trip POST /files/move schedule refresh of
both parents. Playwright's `locator.drag_to()` synthesizes the
`setData/getData` contract that this path relies on (it does NOT
require `webkitGetAsEntry`, which Playwright cannot fake).
Tree DOM gotcha: the cfg folder is collapsed by default, so its
children div is `hidden`. `scheduleRefresh("cfg")` still swaps
HTML into the hidden div the new row exists in the DOM, just
not visible to a `to_be_visible` assertion. Using `to_have_count`
instead of `to_be_visible` accommodates this.
"""
base = files_overlay_server["base_url"]
overlay_id = files_overlay_server["overlay_id"]
overlay_root = files_overlay_server["overlay_root"]
original_content = (overlay_root / "server.cfg").read_text()
_open_overlay(page, base, overlay_id)
# Scope to the row <li>, not any inner button — delete-action
# buttons also carry data-target-path + data-row-kind, so a bare
# attribute selector resolves to multiple elements and trips
# strict mode.
source = page.locator('li.file-tree-row-file[data-target-path="server.cfg"]')
target = page.locator('li.file-tree-row-dir[data-target-path="cfg"]')
source.drag_to(target)
# Wait for both refreshes to land: old row gone from root, new row
# present (though hidden) inside cfg's children div.
expect(
page.locator('.file-tree-row-file[data-target-path="server.cfg"]')
).to_have_count(0, timeout=5000)
expect(
page.locator('.file-tree-row-file[data-target-path="cfg/server.cfg"]')
).to_have_count(1, timeout=5000)
# Disk is the load-bearing assertion — proves /files/move actually
# moved bytes, not just shuffled DOM rows.
assert not (overlay_root / "server.cfg").exists()
assert (overlay_root / "cfg" / "server.cfg").read_text() == original_content

View file

@ -0,0 +1,60 @@
"""End-to-end Playwright tests for the server detail page.
Distinct from test_files_overlay.py because the server-detail page
is NOT a files-overlay (`files_overlay=False` in the template). It
reuses the same `_overlay_file_tree.html` partial but renders rows
in read-only mode no edit button on filenames, no delete action,
only the download anchor in the per-row action strip.
Tier 3 from 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 test_hover_download_initiates_file_download(page: Page, server_with_files) -> None:
"""Navigate to /servers/<id>, hover a file row to defeat the
`opacity: 0; pointer-events: none` CSS gate on `.files-row-actions`,
click the download link, and assert the browser receives a file
download with the expected filename + bytes.
Pins the contract that the read-only file tree on server-detail
pages can download files served from the runtime/<id>/merged/
directory via the dedicated /servers/<id>/files/download
endpoint (separate from the files-overlay download path).
The CSS hover gate is non-decorative: without `locator.hover()`
first, `pointer-events: none` makes the link unclickable. A
regression that ships row actions always-visible (or always-hidden)
would change the user-flow ergonomics; if the gate semantics ever
move, this test needs an update.
"""
base = server_with_files["base_url"]
server_id = server_with_files["server_id"]
merged_root = server_with_files["merged_root"]
expected_bytes = (merged_root / "server.cfg").read_bytes()
login(page, base)
page.goto(f"{base}/servers/{server_id}")
# On server detail, files_overlay=False in the template, so the
# row <li> has no data-target-path. Match by row class + visible
# filename text instead.
row = page.locator("li.file-tree-row-file", has_text="server.cfg")
expect(row).to_be_visible(timeout=5000)
row.hover()
download_link = row.locator('a.files-row-action[title="Download"]')
with page.expect_download() as dl_info:
download_link.click()
download = dl_info.value
assert download.suggested_filename == "server.cfg"
# Playwright auto-saves to a temp dir we can read back from.
assert download.path().read_bytes() == expected_bytes