From 9618109f0fb976137a75a0b15d2c5f4e1804a407 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Sat, 16 May 2026 18:04:56 +0200 Subject: [PATCH] plan(textarea-editor): 12-task TDD implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vendors Prism + CodeJar, builds the editor widget incrementally (mount/sync → highlighting → autocomplete → files-editor integration), scaffolds Playwright + writes the e2e editor test. Form-contract Python tests guard each call-site wiring step. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-16-textarea-code-editor.md | 1502 +++++++++++++++++ 1 file changed, 1502 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-16-textarea-code-editor.md diff --git a/docs/superpowers/plans/2026-05-16-textarea-code-editor.md b/docs/superpowers/plans/2026-05-16-textarea-code-editor.md new file mode 100644 index 0000000..967cf18 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-textarea-code-editor.md @@ -0,0 +1,1502 @@ +# Textarea Code Editor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Upgrade the blueprint config, overlay script, and files-editor textareas with a reusable vanilla-JS code editor that does syntax highlighting and identifier autocomplete. + +**Architecture:** One widget (`editor.js`) mounts on any ` +``` + +to: + +```jinja + +``` + +At the very end of `{% block content %}` (after the existing `` line near line 94), add the partial include: + +```jinja +{% include '_editor_assets.html' %} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cd l4d2web && uv run pytest tests/test_blueprints.py::test_blueprint_detail_renders_editor_assets tests/test_blueprints.py::test_blueprint_config_form_post_still_round_trips -v +``` + +Expected: both PASS. + +- [ ] **Step 5: Manual smoke (Chrome MCP or local browser)** + +Start the dev server, log in, navigate to `/blueprints/`. Expected: +- The config textarea is visually replaced by the editor (looks similar to a textarea — no shifted layout). +- Existing content (`exec foo.cfg`, cvar lines) is preserved. +- Typing renders highlighted tokens (comments in muted color, keywords like `exec` in keyword color, numbers in number color). +- Submitting the form persists the edited content (refresh confirms). + +- [ ] **Step 6: Commit** + +```bash +git add l4d2web/l4d2web/templates/blueprint_detail.html l4d2web/tests/test_blueprints.py +git commit -m "feat(blueprint): mount srccfg editor on the config textarea + +The textarea is preserved as the form field; the editor renders a +contenteditable sibling and mirrors content back on every input. Form +POST contract is untouched (covered by new round-trip test)." +``` + +--- + +## Task 7: Wire bash editor on overlay script form + +**Files:** +- Modify: `l4d2web/l4d2web/templates/overlay_detail.html` (line 25, end of `content` block) +- Modify: `l4d2web/tests/test_script_overlay_routes.py` + +- [ ] **Step 1: Write the failing form-contract test** + +Append to `l4d2web/tests/test_script_overlay_routes.py`. The file already defines an `app` fixture, an `alice_id` fixture, a `_client_for(app, user_id)` helper, and a `_create_script_overlay(app, user_id, *, name)` helper. Reuse them: + +```python +def test_script_overlay_detail_renders_bash_editor(app, alice_id) -> None: + overlay_id = _create_script_overlay(app, alice_id, name="bash-editor-test") + client = _client_for(app, alice_id) + + response = client.get(f"/overlays/{overlay_id}") + assert response.status_code == 200 + body = response.get_data(as_text=True) + assert 'data-editor-language="bash"' in body + assert "static/js/editor.js" in body + assert 'nonce="' in body +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +cd l4d2web && uv run pytest tests/test_script_overlay_routes.py::test_script_overlay_detail_renders_bash_editor -v +``` + +Expected: FAIL — `data-editor-language="bash"` is not in the rendered HTML. + +- [ ] **Step 3: Edit overlay_detail.html (line 25)** + +Change: + +```jinja + +``` + +to: + +```jinja + +``` + +At the very end of `{% block content %}` (after the existing `` line near line 274, but outside the `{% if files_can_edit %}` block so it loads for script overlays too), add the partial include: + +```jinja +{% include '_editor_assets.html' %} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +cd l4d2web && uv run pytest tests/test_script_overlay_routes.py::test_script_overlay_detail_renders_bash_editor -v +``` + +Expected: PASS. + +- [ ] **Step 5: Manual smoke** + +Navigate to a script-type overlay. Confirm bash highlighting: keywords like `for`, `if`, `done` should be highlighted; `$VAR` references colored; `#` comments muted. + +- [ ] **Step 6: Commit** + +```bash +git add l4d2web/l4d2web/templates/overlay_detail.html l4d2web/tests/test_script_overlay_routes.py +git commit -m "feat(overlay): mount bash editor on script overlay form + +data-editor-language=bash opts the textarea in; the editor uses +Prism's stock bash grammar (no project-owned bash code)." +``` + +--- + +## Task 8: Seed srccfg vocabulary + +**Files:** +- Create: `l4d2web/l4d2web/static/data/srccfg-vocab.json` + +A minimal but useful seed list. The full L4D2 cvar list has ~3000 entries; this seed covers the highest-traffic ones a user is likely to type. Augment later as needed. + +- [ ] **Step 1: Write the seed vocab** + +Create `l4d2web/l4d2web/static/data/srccfg-vocab.json`: + +```json +{ + "_comment": "Curated L4D2 cvars + commands for editor autocomplete. Regenerate by running `cvarlist` and `cmdlist` against a freshly-started L4D2 dedicated server with the project's common SourceMod plugins loaded, then hand-trimming engine internals nobody touches. Descriptions come from the trailing help text where present.", + "cvars": [ + {"name": "sv_cheats", "desc": "Allow cheat cvars (0/1) — disables VAC"}, + {"name": "sv_pure", "desc": "Pure-server enforcement (0=off, 1=loose, 2=strict)"}, + {"name": "sv_consistency", "desc": "Force consistency on every client file (0/1)"}, + {"name": "sv_alltalk", "desc": "Cross-team voice chat (0/1)"}, + {"name": "sv_lan", "desc": "LAN-only server (0=internet, 1=LAN)"}, + {"name": "sv_voiceenable", "desc": "Enable voice chat (0/1)"}, + {"name": "sv_password", "desc": "Server join password (empty for open)"}, + {"name": "sv_logflush", "desc": "Flush log file after every line (0/1)"}, + {"name": "sv_minrate", "desc": "Minimum client bandwidth (bytes/sec)"}, + {"name": "sv_maxrate", "desc": "Maximum client bandwidth (bytes/sec)"}, + {"name": "sv_mincmdrate", "desc": "Minimum client command rate"}, + {"name": "sv_maxcmdrate", "desc": "Maximum client command rate"}, + {"name": "sv_minupdaterate", "desc": "Minimum server update rate"}, + {"name": "sv_maxupdaterate", "desc": "Maximum server update rate"}, + {"name": "sv_region", "desc": "Server browser region code"}, + {"name": "sv_steamgroup", "desc": "Steam group ID for restricted servers"}, + {"name": "sv_tags", "desc": "Comma-separated tags for the server browser"}, + {"name": "hostname", "desc": "Server name shown in the browser"}, + {"name": "rcon_password", "desc": "Remote-console admin password"}, + {"name": "mp_gamemode", "desc": "Game mode (coop, versus, survival, scavenge, realism)"}, + {"name": "mp_roundtime", "desc": "Round time limit (minutes)"}, + {"name": "z_difficulty", "desc": "AI director difficulty (Easy/Normal/Hard/Impossible)"}, + {"name": "director_no_specials", "desc": "Disable special-infected spawning (0/1)"}, + {"name": "director_no_bosses", "desc": "Disable tank/witch spawning (0/1)"}, + {"name": "director_panic_forever", "desc": "Endless horde panic event (0/1)"}, + {"name": "nb_update_frequency", "desc": "Infected bot AI tick frequency"}, + {"name": "fps_max", "desc": "Frame rate cap (0=uncapped)"}, + {"name": "tickrate", "desc": "Server tickrate (engine-dependent ceiling)"}, + {"name": "net_splitpacket_maxrate", "desc": "Maximum split-packet bandwidth"}, + {"name": "decalfrequency", "desc": "Anti-spam delay between sprays (seconds)"} + ], + "commands": [ + {"name": "exec", "desc": "Execute a .cfg file"}, + {"name": "alias", "desc": "Define a console-command alias"}, + {"name": "bind", "desc": "Bind a key to a command"}, + {"name": "unbind", "desc": "Remove a key binding"}, + {"name": "toggle", "desc": "Flip a 0/1 cvar"}, + {"name": "sm_cvar", "desc": "SourceMod: set a cvar bypassing sv_cheats restrictions"}, + {"name": "echo", "desc": "Print to console"}, + {"name": "say", "desc": "Send a chat message as the server"} + ] +} +``` + +- [ ] **Step 2: Validate JSON parses** + +```bash +python3 -m json.tool l4d2web/l4d2web/static/data/srccfg-vocab.json > /dev/null && echo OK +``` + +Expected: `OK`. + +- [ ] **Step 3: Commit** + +```bash +git add l4d2web/l4d2web/static/data/srccfg-vocab.json +git commit -m "data(editor): seed L4D2 cvar/command vocabulary + +Hand-curated set of high-traffic cvars and commands sourced from the +existing l4d2-server-cvar-reference.md and common SourceMod usage. +Regeneration procedure documented in the file header." +``` + +--- + +## Task 9: Autocomplete popup in editor.js + +**Files:** +- Modify: `l4d2web/l4d2web/static/js/editor.js` + +Add: vocab lazy-loader, caret-position popup, keyboard navigation, accept/dismiss. + +- [ ] **Step 1: Add the vocab loader** + +Insert near the top of `editor.js`, after the `LANG_BY_EXT` constant: + +```js +const VOCAB_URLS = { + srccfg: "/static/data/srccfg-vocab.json", +}; + +const vocabCache = {}; + +async function loadVocab(lang) { + if (vocabCache[lang]) return vocabCache[lang]; + const url = VOCAB_URLS[lang]; + if (!url) return []; + try { + const r = await fetch(url); + if (!r.ok) return []; + const data = await r.json(); + const merged = [] + .concat(data.cvars || []) + .concat(data.commands || []); + vocabCache[lang] = merged; + return merged; + } catch (err) { + console.warn("[editor] vocab load failed for " + lang, err); + vocabCache[lang] = []; + return []; + } +} +``` + +- [ ] **Step 2: Add the popup helpers** + +Insert after `loadVocab`: + +```js +const WORD_BEFORE_CARET = /[A-Za-z0-9_]{2,}$/; + +function getCaretContext(codeEl) { + const sel = window.getSelection(); + if (!sel || !sel.rangeCount) return null; + const range = sel.getRangeAt(0).cloneRange(); + if (!codeEl.contains(range.endContainer)) return null; + // Build the text from start of the editor up to the caret. + const pre = range.cloneRange(); + pre.selectNodeContents(codeEl); + pre.setEnd(range.endContainer, range.endOffset); + const textBefore = pre.toString(); + const m = WORD_BEFORE_CARET.exec(textBefore); + if (!m) return null; + return { + fragment: m[0], + rect: range.getBoundingClientRect(), + }; +} + +function filterVocab(vocab, fragment) { + const lower = fragment.toLowerCase(); + const prefix = []; + const substr = []; + for (const entry of vocab) { + const name = entry.name.toLowerCase(); + if (name.startsWith(lower)) prefix.push(entry); + else if (name.includes(lower)) substr.push(entry); + if (prefix.length + substr.length >= 50) break; + } + return prefix.concat(substr).slice(0, 50); +} + +function renderPopup(popup, items, activeIndex) { + popup.innerHTML = ""; + const visible = items.slice(0, 8); + visible.forEach((entry, i) => { + const li = document.createElement("li"); + li.className = "editor-popup-item" + (i === activeIndex ? " is-active" : ""); + li.dataset.index = String(i); + const name = document.createElement("span"); + name.className = "name"; + name.textContent = entry.name; + li.appendChild(name); + if (entry.desc) { + const desc = document.createElement("span"); + desc.className = "desc"; + desc.textContent = "— " + entry.desc; + li.appendChild(desc); + } + popup.appendChild(li); + }); +} + +function positionPopup(popup, rect) { + popup.style.left = (window.scrollX + rect.left) + "px"; + popup.style.top = (window.scrollY + rect.bottom + 2) + "px"; +} +``` + +- [ ] **Step 3: Wire the popup into the editor instance** + +Inside `mount(textarea)`, after the existing `jar.onUpdate(...)` block, add: + +```js +let popup = null; +let popupItems = []; +let popupActive = 0; + +function ensurePopup() { + if (popup) return popup; + popup = document.createElement("ul"); + popup.className = "editor-popup"; + popup.style.display = "none"; + document.body.appendChild(popup); + popup.addEventListener("mousedown", function (e) { + e.preventDefault(); // keep caret in editor + const li = e.target.closest(".editor-popup-item"); + if (!li) return; + acceptCompletion(popupItems[parseInt(li.dataset.index, 10)]); + }); + return popup; +} + +function hidePopup() { + if (popup) popup.style.display = "none"; + popupItems = []; +} + +function acceptCompletion(entry) { + if (!entry) return; + const ctx = getCaretContext(code); + if (!ctx) { + hidePopup(); + return; + } + // Replace the trailing word fragment with the chosen identifier. + const sel = window.getSelection(); + const range = sel.getRangeAt(0); + range.setStart(range.endContainer, range.endOffset - ctx.fragment.length); + range.deleteContents(); + range.insertNode(document.createTextNode(entry.name)); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + // Force CodeJar to re-highlight + emit onUpdate. + jar.updateCode(jar.toString()); + hidePopup(); +} + +async function refreshPopup() { + if (instance.language === "plain") { + hidePopup(); + return; + } + const ctx = getCaretContext(code); + if (!ctx) { + hidePopup(); + return; + } + const vocab = await loadVocab(instance.language); + if (!vocab.length) { + hidePopup(); + return; + } + const filtered = filterVocab(vocab, ctx.fragment); + if (!filtered.length) { + hidePopup(); + return; + } + popupItems = filtered; + popupActive = 0; + ensurePopup(); + renderPopup(popup, popupItems, popupActive); + positionPopup(popup, ctx.rect); + popup.style.display = ""; +} + +code.addEventListener("input", refreshPopup); +code.addEventListener("blur", function () { + // Defer hide so a popup click can still register. + setTimeout(hidePopup, 100); +}); + +code.addEventListener("keydown", function (e) { + if (!popup || popup.style.display === "none" || !popupItems.length) return; + if (e.key === "ArrowDown") { + popupActive = (popupActive + 1) % Math.min(popupItems.length, 8); + renderPopup(popup, popupItems, popupActive); + e.preventDefault(); + } else if (e.key === "ArrowUp") { + popupActive = + (popupActive - 1 + Math.min(popupItems.length, 8)) % + Math.min(popupItems.length, 8); + renderPopup(popup, popupItems, popupActive); + e.preventDefault(); + } else if (e.key === "Tab" || e.key === "Enter") { + acceptCompletion(popupItems[popupActive]); + e.preventDefault(); + } else if (e.key === "Escape") { + hidePopup(); + e.preventDefault(); + } +}); +``` + +- [ ] **Step 4: Manual smoke** + +Open a blueprint detail page. Type `sv_che`. Expected: +- Popup appears below the caret listing `sv_cheats` (highlighted first). +- ↓ moves the highlight, ↑ moves it back. +- Tab inserts `sv_cheats` replacing `sv_che`. +- Esc dismisses without inserting. +- Clicking on a popup item inserts that item. + +Try edge cases: typing `xyzzy` (no match) hides the popup; pressing Esc, then continuing to type re-opens it; switching languages via the files-editor dropdown disables srccfg autocomplete when set to bash (because there's no bash vocab URL). + +- [ ] **Step 5: Commit** + +```bash +git add l4d2web/l4d2web/static/js/editor.js +git commit -m "feat(editor): add identifier autocomplete popup + +Vocab loaded lazily from /static/data/-vocab.json on first +mount, cached in memory. Popup appears when the word fragment before +the caret has ≥2 word characters and matches the vocabulary. Prefix +matches rank ahead of substring matches; popup shows up to 8 with +scroll. ↑/↓ navigate, Tab/Enter accept, Esc dismisses." +``` + +--- + +## Task 10: Files-editor modal integration + +**Files:** +- Modify: `l4d2web/l4d2web/templates/overlay_detail.html` (~line 178) +- Modify: `l4d2web/l4d2web/static/js/files-overlay.js` (~lines 345, 385) + +The files-editor modal opens for a different file each time, so the widget needs `setValue(content)` and `setLanguage(name)` calls when the modal opens. The dropdown lets the user override the auto-detected language. + +- [ ] **Step 1: Edit overlay_detail.html (around line 178)** + +Locate the existing block: + +```jinja + +``` + +Replace with: + +```jinja + + +``` + +The `_editor_assets.html` include added in Task 7 already covers this template, so no additional script tags are needed. + +- [ ] **Step 2: Bridge files-overlay.js to call setValue/setLanguage** + +In `l4d2web/l4d2web/static/js/files-overlay.js`, locate the two places that assign to `editorEls.contentBox.value`: + +- Around line 345 (`openEditorTextNew`): `editorEls.contentBox.value = "";` +- Around line 385 (`openEditorForFile`): `editorEls.contentBox.value = r.body.content;` + +Replace each with a helper that goes through the editor when one is mounted: + +Add this helper near the top of `files-overlay.js` (after the existing `editorDialog = document.getElementById(...)` line): + +```js +function setEditorContent(text) { + const editor = editorEls.contentBox._codeEditor; + if (editor) { + editor.setValue(text); + editor.setLanguage("auto"); // re-derive from filename + } else { + editorEls.contentBox.value = text; + } +} +``` + +Then update the two call sites: + +```js +// In openEditorTextNew (was: editorEls.contentBox.value = "";) +setEditorContent(""); + +// In openEditorForFile (was: editorEls.contentBox.value = r.body.content;) +setEditorContent(r.body.content); +``` + +The transient "Loading…" assignment around line 378 (`editorEls.contentBox.value = "Loading…";`) can also be swapped to `setEditorContent("Loading…");` for consistency. + +- [ ] **Step 3: Wire the language dropdown** + +Add right after the `setEditorContent` helper: + +```js +const languageSelect = document.querySelector(".files-editor-language"); +if (languageSelect) { + languageSelect.addEventListener("change", function () { + const editor = editorEls.contentBox._codeEditor; + if (editor) editor.setLanguage(languageSelect.value); + }); +} +``` + +Also, when the filename input changes and the dropdown is still on `auto`, re-derive. Inside the existing filename-input handler (search for `editorEls.filename.addEventListener("input"`), append a call to `setEditorContent(jar.getValue())` — wait, that's not right; we just want to re-trigger the auto-detection. Use: + +```js +// Append inside the existing filename input handler: +const _editor = editorEls.contentBox._codeEditor; +if (_editor && languageSelect && languageSelect.value === "auto") { + _editor.setLanguage("auto"); +} +``` + +- [ ] **Step 4: Manual smoke** + +Open a files-type overlay. Click `+ new file` on the root row, name it `test.cfg`, paste some `sv_cheats 1`-style content. Expected: +- Editor mounts inside the modal. +- Language dropdown shows "Auto (from filename)". +- Content highlighted as srccfg (cvar tokens colored). +- Type `sv_che` → autocomplete popup with `sv_cheats`. +- Switch dropdown to "Bash (.sh)" → re-highlights with bash grammar (no autocomplete because no bash vocab). +- Switch dropdown to "Auto" → re-highlights as srccfg. +- Click Save → file is saved (verify by reopening it). +- Open the file again → editor loads content with srccfg highlighting (auto-detected from the .cfg extension). + +- [ ] **Step 5: Commit** + +```bash +git add l4d2web/l4d2web/templates/overlay_detail.html l4d2web/l4d2web/static/js/files-overlay.js +git commit -m "feat(files-editor): mount auto-language editor + dropdown override + +The modal textarea opts in with data-editor-language=auto; the editor +derives the language from the filename extension on each modal open. +A dropdown lets the user override (srccfg / bash / plain). The +existing fetch-based /files/save path is unchanged — files-overlay.js +keeps reading textarea.value, which the editor mirrors." +``` + +--- + +## Task 11: Playwright scaffolding + +**Files:** +- Modify: `l4d2web/pyproject.toml` +- Create: `l4d2web/tests/e2e/__init__.py` +- Create: `l4d2web/tests/e2e/conftest.py` +- Modify: `AGENTS.md` (repo root) + +- [ ] **Step 1: Inspect the existing dev-deps shape** + +```bash +grep -A 20 "\[project.optional-dependencies\]\|\[dependency-groups\]\|\[tool.uv\]" l4d2web/pyproject.toml +``` + +Note which group dev deps live under (`dev`, `test`, etc.). + +- [ ] **Step 2: Add playwright to dev deps** + +Edit `l4d2web/pyproject.toml`. In the dev-deps group identified in Step 1, append: + +```toml +"playwright>=1.49.0", +"pytest-playwright>=0.6.0", +``` + +- [ ] **Step 3: Install the dep + chromium binary** + +```bash +cd l4d2web && uv sync +cd l4d2web && uv run playwright install chromium +``` + +Expected: `playwright install chromium` downloads the browser binary (a few hundred MB) and prints a "chromium installed" line. + +- [ ] **Step 4: Configure pytest to register the e2e marker** + +In `l4d2web/pyproject.toml`, find the `[tool.pytest.ini_options]` block (or create one). Ensure it contains: + +```toml +[tool.pytest.ini_options] +markers = [ + "e2e: end-to-end browser tests (slow, require chromium)", +] +``` + +If markers already exist, append the `e2e` entry to the list. + +- [ ] **Step 5: Create the e2e test directory + conftest** + +```bash +touch l4d2web/tests/e2e/__init__.py +``` + +Create `l4d2web/tests/e2e/conftest.py`: + +```python +"""Pytest fixtures for end-to-end browser tests. + +Boots the Flask app in a background thread on an ephemeral port and +yields the base URL. The app uses a temp SQLite DB so e2e runs don't +contaminate the dev database. +""" + +import socket +import threading +from datetime import UTC, datetime + +import pytest +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, User + + +def _free_port() -> int: + s = socket.socket() + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + s.close() + return port + + +@pytest.fixture(scope="function") +def live_server(tmp_path, monkeypatch): + db_path = tmp_path / "e2e.db" + db_url = f"sqlite:///{db_path}" + monkeypatch.setenv("DATABASE_URL", db_url) + app = create_app({"TESTING": False, "DATABASE_URL": db_url, "SECRET_KEY": "e2e"}) + init_db() + + 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() + blueprint_id = bp.id + user_id = user.id + + port = _free_port() + server = make_server("127.0.0.1", port, app) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield { + "base_url": f"http://127.0.0.1:{port}", + "user_id": user_id, + "blueprint_id": blueprint_id, + } + finally: + server.shutdown() + thread.join(timeout=2) +``` + +- [ ] **Step 6: Document playwright install in AGENTS.md** + +Add a short subsection to the repo-root `AGENTS.md`, under whatever the existing "Local setup" section is named (grep for it; if there's no obvious section, append at end): + +```markdown +### End-to-end tests + +The Playwright-based browser tests under `l4d2web/tests/e2e/` need a +chromium binary, fetched on first setup: + +```bash +cd l4d2web && uv run playwright install chromium +``` + +Run with `cd l4d2web && uv run pytest -m e2e`. Excluded from the +default fast suite via the `e2e` marker. +``` + +- [ ] **Step 7: Smoke-test the fixture (no real test yet)** + +Create `l4d2web/tests/e2e/test_smoke.py`: + +```python +import pytest + + +@pytest.mark.e2e +def test_live_server_boots(live_server) -> None: + import urllib.request + + resp = urllib.request.urlopen(live_server["base_url"] + "/login") + assert resp.status == 200 +``` + +Run: + +```bash +cd l4d2web && uv run pytest -m e2e -v +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add l4d2web/pyproject.toml l4d2web/tests/e2e/ AGENTS.md +git commit -m "test(e2e): scaffold Playwright + live-server fixture + +Adds playwright + pytest-playwright dev deps, an e2e marker, and a +fixture that boots the Flask app on an ephemeral port with a temp +SQLite DB. Smoke test confirms the live server is reachable." +``` + +--- + +## Task 12: Playwright editor test (red → green) + +**Files:** +- Create: `l4d2web/tests/e2e/test_editor.py` +- Modify (delete): `l4d2web/tests/e2e/test_smoke.py` (optional — keep if you like the heartbeat coverage) + +- [ ] **Step 1: Write the failing editor test** + +Create `l4d2web/tests/e2e/test_editor.py`: + +```python +"""End-to-end test for the textarea code editor. + +Logs in as the seed user, navigates to the blueprint detail page, types +`sv_che` into the editor, asserts the autocomplete popup appears with +`sv_cheats` highlighted, accepts via Tab, and asserts the underlying +textarea now contains `sv_cheats`. +""" + +import pytest +from playwright.sync_api import expect, sync_playwright + + +@pytest.mark.e2e +def test_editor_autocomplete_inserts_cvar(live_server) -> None: + base = live_server["base_url"] + blueprint_id = live_server["blueprint_id"] + + with sync_playwright() as p: + browser = p.chromium.launch() + ctx = browser.new_context() + page = ctx.new_page() + + # Log in. + page.goto(f"{base}/login") + page.fill('input[name="username"]', "alice") + page.fill('input[name="password"]', "secret") + page.click('button[type="submit"]') + expect(page).to_have_url(f"{base}/dashboard", timeout=5000) + + # Navigate to the seeded blueprint. + page.goto(f"{base}/blueprints/{blueprint_id}") + + # Editor mounts on DOMContentLoaded; the contenteditable replaces + # the textarea visually. Wait for it. + editor = page.locator(".editor-code").first + expect(editor).to_be_visible(timeout=5000) + + # Focus the editor and type a cvar prefix. + editor.click() + page.keyboard.type("sv_che") + + # The popup should appear and contain sv_cheats. + popup = page.locator(".editor-popup") + expect(popup).to_be_visible(timeout=2000) + expect(popup).to_contain_text("sv_cheats") + + # Accept via Tab. + page.keyboard.press("Tab") + + # The hidden textarea (form field) must now contain the cvar. + textarea_value = page.evaluate( + "() => document.querySelector('textarea[name=config]').value" + ) + assert "sv_cheats" in textarea_value + + browser.close() +``` + +- [ ] **Step 2: Run the test to verify it fails (or passes)** + +```bash +cd l4d2web && uv run pytest l4d2web/tests/e2e/test_editor.py -v +``` + +If all prior tasks landed correctly, this should PASS. If it fails, debug: +- Run with `--headed` (add `p.chromium.launch(headless=False, slow_mo=300)`) to watch the browser. +- Check the console for JS errors via Playwright's `page.on('console', print)`. + +- [ ] **Step 3: Run the full default suite to confirm no regressions** + +```bash +cd l4d2web && uv run pytest -v +``` + +Expected: all existing tests pass (the new form-contract tests from Tasks 6/7 are included). + +```bash +cd l4d2web && uv run pytest -m e2e -v +``` + +Expected: e2e suite passes. + +- [ ] **Step 4: Final commit** + +```bash +git add l4d2web/tests/e2e/test_editor.py +git commit -m "test(e2e): editor autocomplete end-to-end + +Logs in, navigates to a blueprint, types sv_che, asserts the popup +appears with sv_cheats, accepts via Tab, and asserts the form +textarea's value contains the inserted cvar." +``` + +--- + +## Self-Review (run after writing the plan; do not commit a separate review file) + +Spec coverage: + +- [x] Blueprint config textarea — Task 6 +- [x] Overlay script (bash) textarea — Task 7 +- [x] Files-editor modal textarea — Task 10 +- [x] Auto language detection from filename — Task 10 +- [x] Language dropdown in files-editor — Task 10 +- [x] `srccfg-grammar.js` — Task 2 +- [x] CodeJar + Prism vendored — Task 1 +- [x] `_editor_assets.html` partial — Task 5 +- [x] `editor.css` dedicated stylesheet — Task 3 +- [x] `srccfg-vocab.json` curated vocab — Task 8 +- [x] Lazy vocab loading + caching — Task 9 (`loadVocab` + `vocabCache`) +- [x] Autocomplete trigger + filter + keyboard + mouse — Task 9 +- [x] Form-contract tests (GET + POST round-trip) — Task 6 (blueprint), Task 7 (overlay) +- [x] Playwright scaffold + e2e test — Tasks 11 + 12 +- [x] No backend code changes — confirmed: `blueprint_routes.py`, `app.py`, `/files/save` route untouched + +Closed items in spec (line numbers, multi-cursor, etc.) — out of scope by design. + +Open issue noted during planning: + +- **CodeJar caret preservation across `setLanguage`** — Task 4 Step 2 explicitly accepts caret-loss on language switch (rare action, no UX regression). If a future task needs caret-preserved language switching, expect to introduce a thin wrapper that re-mounts CodeJar while recording the previous selection offset. +- **Bash autocomplete vocab** — none in v1, by spec. The popup simply won't appear when language is `bash`. `VOCAB_URLS` is structured to make adding a `bash` entry trivial later. + +--- + +## Reference snippets and where to look them up + +- **Form contract POST shape** — `l4d2web/l4d2web/routes/blueprint_routes.py:117-142` reads `request.form.get("config")` and splits on newlines. Do not change this in any task; the editor preserves the textarea's `name="config"` and pipes value back on input. +- **CSP nonce accessor** — `l4d2web/l4d2web/app.py:84-86` exposes `g.csp_nonce` via `before_request`. All editor `