Compare commits

..

No commits in common. "d21cd72f8d938b8069aa2ae4532db9ce05189c44" and "2fcf9c37783bbd9c5c1ba8df08735ec7ba8f3bfd" have entirely different histories.

3 changed files with 1 additions and 240 deletions

View file

@ -14,7 +14,7 @@ from werkzeug.serving import make_server
from l4d2web.app import create_app
from l4d2web.auth import hash_password
from l4d2web.db import init_db, session_scope
from l4d2web.models import Blueprint, Overlay, Server, User
from l4d2web.models import Blueprint, Overlay, User
def _free_port() -> int:
@ -164,65 +164,3 @@ def files_overlay_server(tmp_path, monkeypatch):
}
finally:
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,8 +14,6 @@ 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
@ -385,118 +383,3 @@ def test_filename_rename_on_save(page: Page, files_overlay_server) -> None:
expect(
page.locator('.file-tree-row-file[data-target-path="server.cfg"]')
).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

@ -1,60 +0,0 @@
"""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