Compare commits

..

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

8 changed files with 75 additions and 609 deletions

View file

@ -61,7 +61,7 @@ textarea {
} }
textarea { textarea {
font-family: var(--font-mono); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.5; line-height: 1.5;
resize: vertical; resize: vertical;
@ -214,7 +214,7 @@ dialog.modal::backdrop {
margin: 0; margin: 0;
display: grid; display: grid;
gap: var(--space-xs); gap: var(--space-xs);
font-family: var(--font-mono); font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.875rem; font-size: 0.875rem;
} }
@ -348,7 +348,7 @@ dialog.modal::backdrop {
.overlay-picker-handle { .overlay-picker-handle {
color: var(--color-muted); color: var(--color-muted);
font-family: var(--font-mono); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: -0.1em; letter-spacing: -0.1em;
} }
@ -516,7 +516,7 @@ button.danger-outline:hover {
border: var(--line); border: var(--line);
border-bottom: none; border-bottom: none;
border-radius: var(--radius-s) var(--radius-s) 0 0; border-radius: var(--radius-s) var(--radius-s) 0 0;
font-family: var(--font-mono); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.5; line-height: 1.5;
white-space: pre; white-space: pre;
@ -579,7 +579,7 @@ button.danger-outline:hover {
.files-row-root > .files-row-root-label { .files-row-root > .files-row-root-label {
color: var(--color-muted); color: var(--color-muted);
font-family: var(--font-mono); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
} }
.files-root-children { .files-root-children {
@ -672,7 +672,7 @@ div.modal.modal-wide {
.files-editor-field input, .files-editor-field input,
.files-editor-field textarea { .files-editor-field textarea {
font-family: var(--font-mono); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.875rem; font-size: 0.875rem;
} }
@ -684,7 +684,7 @@ div.modal.modal-wide {
} }
.files-editor-path { .files-editor-path {
font-family: var(--font-mono); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.95rem; font-size: 0.95rem;
margin: 0; margin: 0;
} }
@ -702,7 +702,7 @@ div.modal.modal-wide {
} }
.files-editor-rename-hint code { .files-editor-rename-hint code {
font-family: var(--font-mono); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
} }
.files-editor-binary { .files-editor-binary {
@ -779,7 +779,7 @@ div.modal.modal-wide {
} }
.files-conflict-path { .files-conflict-path {
font-family: var(--font-mono); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
} }
/* Floating uploads panel — bottom-right of the page. */ /* Floating uploads panel — bottom-right of the page. */
@ -829,7 +829,7 @@ div.modal.modal-wide {
} }
.files-uploads-row-name { .files-uploads-row-name {
font-family: var(--font-mono); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -942,7 +942,7 @@ div.modal.modal-wide {
color: var(--color-log-text); color: var(--color-log-text);
border-radius: var(--radius-s); border-radius: var(--radius-s);
padding: var(--space-m); padding: var(--space-m);
font-family: var(--font-mono); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.4; line-height: 1.4;
margin-bottom: var(--space-m); margin-bottom: var(--space-m);
@ -988,7 +988,7 @@ div.modal.modal-wide {
} }
.console-prompt-glyph { .console-prompt-glyph {
font-family: var(--font-mono); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-weight: 600; font-weight: 600;
color: var(--color-primary); color: var(--color-primary);
flex-shrink: 0; flex-shrink: 0;
@ -1002,7 +1002,7 @@ div.modal.modal-wide {
.console-spinner { .console-spinner {
display: none; display: none;
color: var(--color-muted); color: var(--color-muted);
font-family: var(--font-mono); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
flex-shrink: 0; flex-shrink: 0;
} }

View file

@ -12,7 +12,7 @@
color: var(--cm-fg, #e0e0e0); color: var(--cm-fg, #e0e0e0);
border: 1px solid var(--color-border, #444); border: 1px solid var(--color-border, #444);
border-radius: 4px; border-radius: 4px;
font-family: var(--font-mono); font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px; font-size: 13px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
} }

View file

@ -6,7 +6,7 @@
color: var(--color-log-text); color: var(--color-log-text);
border-radius: var(--radius-s); border-radius: var(--radius-s);
padding: var(--space-m); padding: var(--space-m);
font-family: var(--font-mono); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.4; line-height: 1.4;
white-space: pre-wrap; white-space: pre-wrap;

View file

@ -26,8 +26,6 @@
--radius-s: var(--radius-base); --radius-s: var(--radius-base);
--radius-m: calc(var(--radius-base) * 2); --radius-m: calc(var(--radius-base) * 2);
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--line: 1px solid var(--color-border); --line: 1px solid var(--color-border);
--line-soft: 1px solid color-mix(in srgb, var(--color-border) 50%, transparent); --line-soft: 1px solid color-mix(in srgb, var(--color-border) 50%, transparent);

View file

@ -6,28 +6,15 @@
// //
// First-token only: the dropdown is hidden as soon as the cursor // First-token only: the dropdown is hidden as soon as the cursor
// is past the first space in the input. // is past the first space in the input.
//
// Listeners are delegated to the form (not the input) so the input
// element can be swapped via HTMX without breaking autocomplete.
// Document- and window-level handlers are module-scoped and operate
// on the currently-open dropdown via the activeBinding pointer.
const VOCAB_URL = "/static/data/srccfg-vocab.json"; const VOCAB_URL = "/static/data/srccfg-vocab.json";
const MAX_RENDERED = 8; const MAX_RENDERED = 8;
// Module-scoped state.
let vocabPromise = null; let vocabPromise = null;
let vocab = null;
// activeBinding assumes a single dropdown open at a time. Today the
// project has one console form per page; multi-form support would
// need per-form open-state tracking (e.g. a Set) here.
let activeBinding = null;
function loadVocab() { function loadVocab() {
if (vocabPromise) return vocabPromise; if (vocabPromise) return vocabPromise;
vocabPromise = fetch(VOCAB_URL, { credentials: "same-origin" }) vocabPromise = fetch(VOCAB_URL, { credentials: "same-origin" })
.then(r => r.ok ? r.json() : Promise.reject(new Error("vocab fetch failed: " + r.status))) .then(r => r.ok ? r.json() : Promise.reject(new Error("vocab fetch failed: " + r.status)))
.then(v => { vocab = v; return v; })
.catch(err => { console.warn("[console-autocomplete] vocab load failed", err); return null; }); .catch(err => { console.warn("[console-autocomplete] vocab load failed", err); return null; });
return vocabPromise; return vocabPromise;
} }
@ -43,45 +30,18 @@ function firstTokenSlice(value, caret) {
return { token: value.slice(0, spaceIdx), from: 0, to: spaceIdx }; return { token: value.slice(0, spaceIdx), from: 0, to: spaceIdx };
} }
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
// Module-scoped document/window listeners installed once.
// Operate on whichever binding has its dropdown currently open.
document.addEventListener("mousedown", (event) => {
if (!activeBinding) return;
const dropdown = activeBinding.getDropdown();
if (!dropdown || dropdown.style.display === "none") return;
const row = event.target.closest(".console-autocomplete-row");
if (!row || !dropdown.contains(row)) return;
event.preventDefault();
activeBinding.acceptRow(row);
});
window.addEventListener("resize", () => {
if (activeBinding) activeBinding.position();
});
window.addEventListener("scroll", () => {
if (activeBinding) activeBinding.position();
}, true);
function bindConsoleAutocomplete(form) { function bindConsoleAutocomplete(form) {
if (form.dataset.consoleAutocompleteBound === "true") return; if (form.dataset.consoleAutocompleteBound === "true") return;
form.dataset.consoleAutocompleteBound = "true"; form.dataset.consoleAutocompleteBound = "true";
// Per-binding state. const input = form.querySelector("input[name='command']");
let dropdown = null; if (!input) return;
let items = [];
let highlightIdx = 0;
let focusHandledOnce = false;
function getInput() { // --- Dropdown DOM (created lazily on first show) ---
return form.querySelector("input[name='command']"); let dropdown = null;
} let items = []; // current ranked items
let highlightIdx = 0; // index of currently-highlighted row
let vocab = null;
function ensureDropdown() { function ensureDropdown() {
if (dropdown) return dropdown; if (dropdown) return dropdown;
@ -94,8 +54,7 @@ function bindConsoleAutocomplete(form) {
} }
function position() { function position() {
const input = getInput(); if (!dropdown) return;
if (!input || !dropdown) return;
const rect = input.getBoundingClientRect(); const rect = input.getBoundingClientRect();
dropdown.style.left = `${rect.left + window.scrollX}px`; dropdown.style.left = `${rect.left + window.scrollX}px`;
dropdown.style.top = `${rect.bottom + window.scrollY}px`; dropdown.style.top = `${rect.bottom + window.scrollY}px`;
@ -103,14 +62,10 @@ function bindConsoleAutocomplete(form) {
} }
function close() { function close() {
if (!dropdown) { if (!dropdown) return;
if (activeBinding === binding) activeBinding = null;
return;
}
dropdown.style.display = "none"; dropdown.style.display = "none";
items = []; items = [];
highlightIdx = 0; highlightIdx = 0;
if (activeBinding === binding) activeBinding = null;
} }
function render() { function render() {
@ -124,41 +79,34 @@ function bindConsoleAutocomplete(form) {
}).join(""); }).join("");
dropdown.innerHTML = rows; dropdown.innerHTML = rows;
dropdown.style.display = "block"; dropdown.style.display = "block";
activeBinding = binding;
position(); position();
} }
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
function acceptHighlighted() { function acceptHighlighted() {
if (items.length === 0) return; if (items.length === 0) return;
const input = getInput();
if (!input) return;
const chosen = items[highlightIdx]; const chosen = items[highlightIdx];
const slice = firstTokenSlice(input.value, input.selectionStart || 0); const slice = firstTokenSlice(input.value, input.selectionStart || 0);
if (!slice) return; if (!slice) return;
// If the first token is already exactly the chosen name, accepting it
// would be a no-op; close the dropdown so Tab feels responsive.
if (slice.token === chosen.name) { close(); return; } if (slice.token === chosen.name) { close(); return; }
const before = input.value.slice(0, slice.from); const before = input.value.slice(0, slice.from);
const after = input.value.slice(slice.to); const after = input.value.slice(slice.to);
input.value = before + chosen.name + after; input.value = before + chosen.name + after;
// Place caret at end of inserted name
const caret = before.length + chosen.name.length; const caret = before.length + chosen.name.length;
input.setSelectionRange(caret, caret); input.setSelectionRange(caret, caret);
recompute(); recompute();
} }
function acceptRow(rowEl) {
highlightIdx = parseInt(rowEl.dataset.idx, 10) || 0;
acceptHighlighted();
const input = getInput();
if (input) input.focus();
}
function recompute() { function recompute() {
if (!vocab) return; if (!vocab) return;
if (typeof window.__rankVocab !== "function") {
console.warn("[console-autocomplete] window.__rankVocab unavailable — vocab-rank.bundle.js failed to load?");
return;
}
const input = getInput();
if (!input) return;
const slice = firstTokenSlice(input.value, input.selectionStart || 0); const slice = firstTokenSlice(input.value, input.selectionStart || 0);
if (!slice || !slice.token) { close(); return; } if (!slice || !slice.token) { close(); return; }
items = window.__rankVocab(slice.token, vocab); items = window.__rankVocab(slice.token, vocab);
@ -167,41 +115,22 @@ function bindConsoleAutocomplete(form) {
render(); render();
} }
// Binding object exposed to module-scope listeners. // --- Lazy vocab fetch on first focus ---
// getDropdown() returns the current dropdown reference, which is input.addEventListener("focus", async () => {
// assigned lazily by ensureDropdown(); the getter is needed because
// the dropdown variable's binding changes after construction.
const binding = {
getDropdown: () => dropdown,
position,
close,
acceptRow,
};
// Form-level event delegation. event.target.matches gates each
// handler to the command input, so the input element can be
// swapped via HTMX without rebinding.
form.addEventListener("focusin", async (event) => {
if (!event.target.matches('input[name="command"]')) return;
if (focusHandledOnce) return;
focusHandledOnce = true;
if (!vocab) { if (!vocab) {
await loadVocab(); vocab = await loadVocab();
// If the user typed during the fetch, rank now so the dropdown // If the user already typed during the fetch, rank now so the
// doesn't appear to lag a keystroke behind on cold load. // dropdown doesn't appear to lag a keystroke behind on cold load.
if (vocab && document.activeElement === event.target) recompute(); if (vocab && document.activeElement === input) recompute();
} }
}); }, { once: true });
form.addEventListener("input", (event) => { input.addEventListener("input", () => {
if (!event.target.matches('input[name="command"]')) return; if (!vocab) return; // fetch may not have resolved yet; next input will recompute
if (!vocab) return; // fetch may not have resolved yet
recompute(); recompute();
}); });
form.addEventListener("keydown", (event) => { input.addEventListener("keydown", (event) => {
if (!event.target.matches('input[name="command"]')) return;
if (event.key === "Tab" && !event.shiftKey) { if (event.key === "Tab" && !event.shiftKey) {
if (items.length > 0) { if (items.length > 0) {
event.preventDefault(); event.preventDefault();
@ -223,14 +152,28 @@ function bindConsoleAutocomplete(form) {
// ArrowUp/ArrowDown/Enter intentionally NOT handled here. // ArrowUp/ArrowDown/Enter intentionally NOT handled here.
}); });
form.addEventListener("focusout", (event) => { input.addEventListener("blur", () => {
if (!event.target.matches('input[name="command"]')) return;
// Delay close so a click on a dropdown row can fire first. // Delay close so a click on a dropdown row can fire first.
setTimeout(close, 100); setTimeout(close, 100);
}); });
// Mouse click on a row → accept that row.
document.addEventListener("mousedown", (event) => {
if (!dropdown || dropdown.style.display === "none") return;
const row = event.target.closest(".console-autocomplete-row");
if (!row || !dropdown.contains(row)) return;
event.preventDefault();
highlightIdx = parseInt(row.dataset.idx, 10) || 0;
acceptHighlighted();
input.focus();
});
// HTMX form submission clears the input; close on submit. // HTMX form submission clears the input; close on submit.
form.addEventListener("htmx:beforeRequest", close); form.addEventListener("htmx:beforeRequest", close);
// Reposition on resize/scroll while dropdown is open.
window.addEventListener("resize", () => { if (dropdown && dropdown.style.display !== "none") position(); });
window.addEventListener("scroll", () => { if (dropdown && dropdown.style.display !== "none") position(); }, true);
} }
function bindAll(root) { function bindAll(root) {

View file

@ -16,7 +16,6 @@ function score(query, label) {
return -1; return -1;
} }
// vocab shape: { cvars: [{name, desc?}, …], commands: [{name, desc?}, …] }
export function rankVocab(query, vocab, { limit = 50 } = {}) { export function rankVocab(query, vocab, { limit = 50 } = {}) {
if (!query) return []; if (!query) return [];
const q = query.toLowerCase(); const q = query.toLowerCase();

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, User
def _free_port() -> int: def _free_port() -> int:
@ -25,19 +25,17 @@ def _free_port() -> int:
return port return port
def _boot_app(tmp_path, monkeypatch): @pytest.fixture(scope="function")
"""Set the env vars + create_app + init_db combo shared by every e2e def live_server(tmp_path, monkeypatch):
fixture. Returns the Flask app.
Sets DATABASE_URL temp sqlite; SESSION_COOKIE_SECURE=0 so the
browser keeps the session cookie over http://127.0.0.1 (app.py:57
would otherwise mark it Secure). Caller-specific env vars (e.g.
LEFT4ME_ROOT for files-overlay tests) must be set BEFORE calling
this, since create_app reads them during app construction.
"""
db_path = tmp_path / "e2e.db" db_path = tmp_path / "e2e.db"
db_url = f"sqlite:///{db_path}" db_url = f"sqlite:///{db_path}"
monkeypatch.setenv("DATABASE_URL", db_url) monkeypatch.setenv("DATABASE_URL", db_url)
# app.py:57 sets SESSION_COOKIE_SECURE = not TESTING, which would
# mark the session cookie Secure. The browser then drops it over
# http://127.0.0.1 in e2e tests and the login flow silently fails
# with a redirect back to /login. Force it off explicitly via the
# env-var override (app.py:53-55) rather than flipping TESTING,
# which would skip the SECRET_KEY guard and other production paths.
monkeypatch.setenv("SESSION_COOKIE_SECURE", "0") monkeypatch.setenv("SESSION_COOKIE_SECURE", "0")
app = create_app({"TESTING": False, "DATABASE_URL": db_url, "SECRET_KEY": "e2e"}) app = create_app({"TESTING": False, "DATABASE_URL": db_url, "SECRET_KEY": "e2e"})
# create_app() already calls init_db() inside an app context, which # create_app() already calls init_db() inside an app context, which
@ -47,37 +45,6 @@ def _boot_app(tmp_path, monkeypatch):
# call creates the tables on that env-derived engine so the seed # call creates the tables on that env-derived engine so the seed
# inserts have somewhere to land. # inserts have somewhere to land.
init_db() init_db()
return app
def _serve(app):
"""Start the app on a background thread, return (base_url, shutdown).
Caller is responsible for calling shutdown() in a finally block.
"""
port = _free_port()
server = make_server("127.0.0.1", port, app)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
def shutdown():
server.shutdown()
thread.join(timeout=2)
return f"http://127.0.0.1:{port}", shutdown
def login(page, base_url: str, username: str = "alice", password: str = "secret") -> None:
"""Form-POST login helper. Reused by every e2e test."""
page.goto(f"{base_url}/login")
page.fill('input[name="username"]', username)
page.fill('input[name="password"]', password)
page.click('button[type="submit"]')
@pytest.fixture(scope="function")
def live_server(tmp_path, monkeypatch):
app = _boot_app(tmp_path, monkeypatch)
with session_scope() as session: with session_scope() as session:
user = User( user = User(
@ -95,72 +62,16 @@ def live_server(tmp_path, monkeypatch):
blueprint_id = bp.id blueprint_id = bp.id
user_id = user.id user_id = user.id
base_url, shutdown = _serve(app) port = _free_port()
server = make_server("127.0.0.1", port, app)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try: try:
yield { yield {
"base_url": base_url, "base_url": f"http://127.0.0.1:{port}",
"user_id": user_id, "user_id": user_id,
"blueprint_id": blueprint_id, "blueprint_id": blueprint_id,
} }
finally: finally:
shutdown() server.shutdown()
thread.join(timeout=2)
@pytest.fixture(scope="function")
def files_overlay_server(tmp_path, monkeypatch):
"""live_server + a files-type Overlay owned by alice, seeded with:
<overlay_root>/server.cfg (text, editable)
<overlay_root>/icon.png (binary, replaceable)
<overlay_root>/cfg/admins.txt (nested-folder fixture)
LEFT4ME_ROOT is monkey-patched to tmp_path BEFORE create_app() so
overlay path resolution (l4d2host.paths.overlay_path) lands under
the temp directory instead of /var/lib/left4me without this every
files-overlay route 404s on macOS (see AGENTS.md "symptom-to-cause").
"""
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()
# Same insert-then-update-path pattern dev-server.py uses: we
# need overlay.id to write into overlay.path, but the column is
# NOT NULL so it gets a placeholder until we know the id.
overlay = Overlay(
name="cfgs",
path="_pending",
type="files",
user_id=user.id,
)
session.add(overlay)
session.flush()
overlay.path = str(overlay.id)
user_id = user.id
overlay_id = overlay.id
overlay_root = tmp_path / "overlays" / str(overlay_id)
overlay_root.mkdir(parents=True)
(overlay_root / "server.cfg").write_text('hostname "left4me"\n')
# 8-byte PNG signature + 60 null bytes — large enough for the
# editor's binary-mode detection (which checks the magic header).
(overlay_root / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 60)
(overlay_root / "cfg").mkdir()
(overlay_root / "cfg" / "admins.txt").write_text("STEAM_1:0:1\n")
base_url, shutdown = _serve(app)
try:
yield {
"base_url": base_url,
"user_id": user_id,
"overlay_id": overlay_id,
"overlay_root": overlay_root,
}
finally:
shutdown()

View file

@ -1,385 +0,0 @@
"""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)
def test_open_binary_file_renders_replace_ui(page: Page, files_overlay_server) -> None:
"""Open icon.png and assert the routed editor renders the binary
branch of overlay_file_editor.html replace-zone present, save
button labelled "Replace" and disabled until a file is queued or
the filename is edited.
The same `/files/edit?path=...` route serves both text and binary
modes; the server picks the template branch based on is_editable +
a magic-byte check. This test pins the binary contract: the user
cannot save by accident on a fresh open, and the queue UI is wired
correctly.
Pins:
* .files-editor-binary[data-rel-path="icon.png"] exists (the
data-rel-path stable hook for save logic).
* Save button has visible text "Replace" + the `disabled`
attribute is set on open.
* .files-editor-replace-zone + .files-editor-replace-browse +
the hidden file <input> exist.
* Download link points back at /files/download?path=icon.png.
"""
base = files_overlay_server["base_url"]
overlay_id = files_overlay_server["overlay_id"]
_open_overlay(page, base, overlay_id)
page.click('button.file-tree-name-button[data-target-path="icon.png"]')
fragment = page.locator("#files-editor-fragment")
expect(fragment).to_be_visible(timeout=5000)
binary_panel = page.locator('.files-editor-binary[data-rel-path="icon.png"]')
expect(binary_panel).to_be_visible(timeout=5000)
save_btn = page.locator(".files-editor-save")
expect(save_btn).to_have_text("Replace")
expect(save_btn).to_be_disabled()
expect(page.locator(".files-editor-replace-zone")).to_be_visible()
expect(page.locator(".files-editor-replace-browse")).to_be_visible()
# The file input is type=file hidden — Playwright's "visible"
# assertions treat hidden inputs as not visible, so check
# attached + the type instead.
expect(page.locator(".files-editor-replace-input")).to_have_attribute(
"type", "file"
)
# Download link points back at the overlay's /files/download.
download = page.locator("a.files-editor-download")
expect(download).to_have_attribute(
"href", f"/overlays/{overlay_id}/files/download?path=icon.png"
)
# In binary mode there's no <textarea data-rel-path> — only the
# binary panel carries the rel-path. Catches a regression where
# the server accidentally rendered the text branch for a binary
# file (which would mean an editable textarea full of PNG bytes).
assert page.locator("textarea[data-rel-path]").count() == 0
def test_binary_replace_via_browse_writes_new_bytes(page: Page, files_overlay_server) -> None:
"""Open icon.png, click "browse" to attach a new file buffer via
Playwright's file chooser, click Replace, and assert disk reflects
the new bytes. Exercises:
* .files-editor-replace-browse triggers the hidden file input's
click() (line ~140 in files-overlay/editor.js).
* `change` event on the input flows into setRoutedReplacement,
which enables the save button + paints the queued-state UI.
* routedReplaceClicked posts FormData (multipart) to
/files/replace with the original rel-path NOT a rename
and the server streams the upload into place.
"""
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="icon.png"]')
expect(page.locator('.files-editor-binary[data-rel-path="icon.png"]')).to_be_visible(timeout=5000)
save_btn = page.locator(".files-editor-save")
expect(save_btn).to_be_disabled()
# The browse button calls fileInput.click() in the JS handler —
# Playwright's expect_file_chooser intercepts the resulting OS
# picker so we can hand back a buffer instead of an OS path.
new_bytes = b"\x89PNG\r\n\x1a\nE2E_REPLACED" + b"\x00" * 20
with page.expect_file_chooser() as fc_info:
page.click(".files-editor-replace-browse")
fc_info.value.set_files({
"name": "new-icon.png",
"mimeType": "image/png",
"buffer": new_bytes,
})
# Queueing a file flips the save button enabled.
expect(save_btn).to_be_enabled(timeout=2000)
save_btn.click()
expect(page.locator("#modal-container")).to_be_hidden(timeout=5000)
# The filename on disk stays the same (no rename) — only the bytes
# changed.
assert (overlay_root / "icon.png").read_bytes() == new_bytes
def test_new_folder_then_delete(page: Page, files_overlay_server) -> None:
"""Create a folder via the inline new-folder dialog (Enter-to-submit
keydown path), then delete it via the inline delete-confirm dialog.
Each step asserts disk + tree state, so a regression in either the
mkdir or delete route or in the modal close + refresh wiring
fails loudly.
The new-folder dialog has TWO submit paths (click on
.files-new-folder-create + Enter keydown on the name input); this
test pins the keydown path, which is direct-bound rather than
delegated, because it's the one most likely to break under future
refactors.
"""
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)
# --- Create folder via Enter-to-submit -----------------------------
page.click('button[data-action="new-folder"][data-target-path=""]')
new_folder_modal = page.locator("#files-new-folder-modal")
expect(new_folder_modal).to_be_visible(timeout=5000)
folder_name = "sourcemod"
page.fill(".files-new-folder-name", folder_name)
page.locator(".files-new-folder-name").press("Enter")
expect(new_folder_modal).to_be_hidden(timeout=5000)
assert (overlay_root / folder_name).is_dir()
folder_row = page.locator(
f'.file-tree-row-dir[data-target-path="{folder_name}"]'
)
expect(folder_row).to_be_visible(timeout=5000)
# --- Delete folder via inline confirm ------------------------------
# `force=True`: the row-action buttons are positioned inside a
# `<li draggable="true">`, and Playwright's hit-test treats the
# draggable ancestor as intercepting pointer events even though
# real browsers dispatch the click correctly. Forcing skips the
# hit-test and dispatches on the target — matches user behavior.
page.locator(
f'button[data-action="delete"]'
f'[data-target-path="{folder_name}"]'
f'[data-row-kind="dir"]'
).click(force=True)
delete_modal = page.locator("#files-delete-modal")
expect(delete_modal).to_be_visible(timeout=5000)
# The dialog should name the row being deleted — the user must see
# what they're confirming.
expect(delete_modal.locator(".files-delete-name")).to_have_text(folder_name)
page.click(".files-delete-confirm")
expect(delete_modal).to_be_hidden(timeout=5000)
assert not (overlay_root / folder_name).exists()
# The tree refresh removes the row.
expect(folder_row).to_have_count(0, timeout=5000)
def test_filename_rename_on_save(page: Page, files_overlay_server) -> None:
"""Open server.cfg, change the filename input to server-renamed.cfg,
and click Save. The single POST /files/save call performs an atomic
rename + write; the test asserts the disk reflects both the new
name and the original content, AND that the tree row swaps over.
This pins the rename-on-save branch in routedSaveClicked (the
non-is_new code path that diffs originalLeaf vs editedFilename
and emits `new_path`). A regression that dropped the rename
payload would silently leave the original file in place the
"old name gone" + "new name present" assertion pair catches that.
We do NOT call __filesEditor.setContent here: the rename should
preserve the original textarea-seeded content. If the save handler
accidentally writes an empty body, the content-equality assertion
fails.
"""
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)
page.click('button.file-tree-name-button[data-target-path="server.cfg"]')
_wait_for_routed_editor(page)
new_name = "server-renamed.cfg"
page.fill("input[data-editor-filename]", new_name)
page.click(".files-editor-save")
expect(page.locator("#modal-container")).to_be_hidden(timeout=5000)
# Disk: old name gone, new name present with the original content.
assert not (overlay_root / "server.cfg").exists()
assert (overlay_root / new_name).read_text() == original_content
# Tree refresh runs at parentOf(new_path) = "" (root) — both rows
# come from that same listing, so we wait for the new one and
# assert the old one is gone in the same refresh.
expect(
page.locator(f'.file-tree-row-file[data-target-path="{new_name}"]')
).to_be_visible(timeout=5000)
expect(
page.locator('.file-tree-row-file[data-target-path="server.cfg"]')
).to_have_count(0)