# 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 `