Compare commits
5 commits
2fcf9c3778
...
d21cd72f8d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d21cd72f8d | ||
|
|
b43bb9e0fa | ||
|
|
e89dd25cdd | ||
|
|
a6be29c6d2 | ||
|
|
b222fdc918 |
3 changed files with 240 additions and 1 deletions
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
60
l4d2web/tests/e2e/test_server_detail.py
Normal file
60
l4d2web/tests/e2e/test_server_detail.py
Normal 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
|
||||||
Loading…
Reference in a new issue