Compare commits
No commits in common. "d21cd72f8d938b8069aa2ae4532db9ce05189c44" and "2fcf9c37783bbd9c5c1ba8df08735ec7ba8f3bfd" have entirely different histories.
d21cd72f8d
...
2fcf9c3778
3 changed files with 1 additions and 240 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, Server, User
|
from l4d2web.models import Blueprint, Overlay, User
|
||||||
|
|
||||||
|
|
||||||
def _free_port() -> int:
|
def _free_port() -> int:
|
||||||
|
|
@ -164,65 +164,3 @@ 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,8 +14,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -385,118 +383,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
Loading…
Reference in a new issue