left4me/docs/superpowers/plans/2026-05-16-textarea-code-editor.md
mwiegand 9618109f0f
plan(textarea-editor): 12-task TDD implementation plan
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) <noreply@anthropic.com>
2026-05-16 18:04:56 +02:00

1502 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `<textarea data-editor-language>`. The textarea stays in the DOM as the value carrier; a sibling `contenteditable` mirrors content back on every input. CodeJar handles the editor shell, Prism handles highlighting, a small custom popup handles autocomplete. No bundler, no build step — everything self-hosted under `/static/`.
**Tech Stack:** Vanilla JS (ES2020), Prism.js 1.x (vendored), CodeJar 4.x (vendored), Jinja, Flask, pytest, Playwright.
**Reference spec:** `docs/superpowers/specs/2026-05-16-textarea-code-editor-design.md`
---
## File Structure
| File | Action | Responsibility |
|---|---|---|
| `l4d2web/l4d2web/static/vendor/prism.js` | Create (vendor) | Prism core + `clike` + `bash` (custom build from prismjs.com/download) |
| `l4d2web/l4d2web/static/vendor/prism.css` | Create (vendor) | Prism default theme |
| `l4d2web/l4d2web/static/vendor/codejar.js` | Create (vendor) | CodeJar editor shell (UMD release) |
| `l4d2web/l4d2web/static/vendor/README.md` | Create | Pinned URLs + versions + SHA256 for each vendored asset |
| `l4d2web/l4d2web/static/js/srccfg-grammar.js` | Create | `Prism.languages.srccfg` token regexes |
| `l4d2web/l4d2web/static/js/editor.js` | Create | Widget: mount, sync, autocomplete popup, language switch, auto-detection |
| `l4d2web/l4d2web/static/css/editor.css` | Create | `.editor-shell`, `.editor-code`, `.editor-popup` styles |
| `l4d2web/l4d2web/static/data/srccfg-vocab.json` | Create | Curated cvars/commands for autocomplete |
| `l4d2web/l4d2web/templates/_editor_assets.html` | Create | Jinja partial: link/script tags with nonces |
| `l4d2web/l4d2web/templates/blueprint_detail.html` | Modify (~line 52, end of `content` block) | Add `data-editor-language="srccfg"`; include partial |
| `l4d2web/l4d2web/templates/overlay_detail.html` | Modify (~lines 25, 178, end of `content` block) | Bash editor for script form; auto editor + dropdown for files-editor; include partial |
| `l4d2web/l4d2web/static/js/files-overlay.js` | Modify (~lines 345, 385) | Replace direct `editorEls.contentBox.value = …` with `editor.setValue(…)` + `editor.setLanguage(…)` |
| `l4d2web/pyproject.toml` | Modify | Add `playwright` to dev deps |
| `l4d2web/tests/e2e/__init__.py` | Create | Marker file |
| `l4d2web/tests/e2e/conftest.py` | Create | Pytest fixture booting Flask app on an ephemeral port |
| `l4d2web/tests/e2e/test_editor.py` | Create | Playwright test: type `sv_che`, accept popup, assert textarea value |
| `l4d2web/tests/test_blueprints.py` | Modify | Add two form-contract tests for the upgraded textarea |
| `l4d2web/tests/test_overlay_creation.py` | Modify (or wherever overlay-script form is tested) | Add one form-contract test for bash editor presence |
| `AGENTS.md` (repo root) | Modify | One-line note: run `playwright install chromium` to set up e2e |
**Untouched by design:**
- `l4d2web/l4d2web/routes/blueprint_routes.py` — form contract unchanged
- `l4d2web/l4d2web/app.py` — CSP already permits nonce'd scripts
- The files-overlay save path (`/files/save`) — JSON shape unchanged
---
## Task 1: Vendor Prism + CodeJar with provenance
**Files:**
- Create: `l4d2web/l4d2web/static/vendor/prism.js`
- Create: `l4d2web/l4d2web/static/vendor/prism.css`
- Create: `l4d2web/l4d2web/static/vendor/codejar.js`
- Create: `l4d2web/l4d2web/static/vendor/README.md`
- [ ] **Step 1: Build the Prism custom bundle**
Open `https://prismjs.com/download.html#themes=prism&languages=clike+bash` in a browser. Set theme = "Default". Tick languages: `clike` (Bash depends on it), `bash`. Leave all plugins unchecked. Click "Download JS" and "Download CSS". Save them as `l4d2web/l4d2web/static/vendor/prism.js` and `l4d2web/l4d2web/static/vendor/prism.css`.
Verify the version string:
```bash
head -1 l4d2web/l4d2web/static/vendor/prism.js | grep -oE 'v[0-9.]+'
```
Expected: a version like `v1.29.0`. Note it for the README.
- [ ] **Step 2: Download CodeJar**
```bash
mkdir -p l4d2web/l4d2web/static/vendor
curl -fsSL -o l4d2web/l4d2web/static/vendor/codejar.js \
https://raw.githubusercontent.com/antonmedv/codejar/v4.0.0/codejar.js
```
Inspect the first few lines to confirm it's a CodeJar source file with the expected `export function CodeJar(...)` signature. Then convert it to a script that exposes `CodeJar` on `window` (the UMD form CodeJar ships is ESM; we need a `<script>`-loadable form):
```bash
# Append a small global-export shim
cat >> l4d2web/l4d2web/static/vendor/codejar.js <<'EOF'
// Browser global shim: surface CodeJar on window so non-module
// <script> tags can call it. The export above is preserved for
// consumers that import the file as an ES module.
window.CodeJar = CodeJar;
EOF
```
If the downloaded file uses ES `export` syntax that browsers refuse outside of `type="module"`, replace `export function CodeJar` with `function CodeJar` before the shim. Verify by opening the file and reading the first 5 lines.
- [ ] **Step 3: Record SHA256s and source URLs in README.md**
Create `l4d2web/l4d2web/static/vendor/README.md`:
```markdown
# Vendored static assets
All third-party JS/CSS shipped under `/static/vendor/` is committed
verbatim from the upstream releases below. The strict CSP
(`default-src 'self'`) means we cannot load these from CDNs.
| File | Upstream | Version | SHA256 |
|---|---|---|---|
| `prism.js` | https://prismjs.com/download.html (theme=prism, languages=clike,bash) | v1.29.0 | `<sha>` |
| `prism.css` | https://prismjs.com/download.html (theme=prism) | v1.29.0 | `<sha>` |
| `codejar.js` | https://github.com/antonmedv/codejar (release v4.0.0) + browser-global shim | v4.0.0 | `<sha>` |
## Regenerating
- **Prism:** Use the same configurator URL, tick the same languages,
re-download both files, drop in place.
- **CodeJar:** `curl` the raw release file as in this plan's Task 1
Step 2, then re-append the `window.CodeJar = CodeJar` shim.
Bump the version + SHA columns in this table after any update.
```
Then fill in the SHA256s:
```bash
cd l4d2web/l4d2web/static/vendor
for f in prism.js prism.css codejar.js; do
echo "$f: $(shasum -a 256 "$f" | cut -d' ' -f1)"
done
```
Paste each SHA into the table.
- [ ] **Step 4: Verify the asset files load**
```bash
ls -la l4d2web/l4d2web/static/vendor/
```
Expected output includes `codejar.js`, `prism.css`, `prism.js`, `README.md`. File sizes: prism.js around 2030 KB; prism.css around 13 KB; codejar.js around 35 KB.
- [ ] **Step 5: Commit**
```bash
git add l4d2web/l4d2web/static/vendor/
git commit -m "vendor(editor): pin Prism v1.29.0 + CodeJar v4.0.0
Self-host the editor dependencies under /static/vendor/ since the strict
CSP forbids CDN loading. README records source URLs, versions, and
SHA256s for each file."
```
---
## Task 2: srccfg Prism grammar
**Files:**
- Create: `l4d2web/l4d2web/static/js/srccfg-grammar.js`
The grammar defines five token classes for Source-engine `.cfg` syntax: comment, string, number, keyword (`exec`, `alias`, `bind`), and a generic identifier. Prism applies CSS classes like `token comment`, `token string`, etc. — Task 5 styles them.
- [ ] **Step 1: Write the grammar**
Create `l4d2web/l4d2web/static/js/srccfg-grammar.js`:
```js
// Prism grammar for Source-engine config files (server.cfg-style).
// Tokens: comment, string, number, keyword, identifier. Purely visual —
// no semantic validation of cvars or values.
(function (Prism) {
if (!Prism) return;
Prism.languages.srccfg = {
comment: /\/\/.*/,
string: {
pattern: /"(?:\\.|[^"\\])*"/,
greedy: true,
},
keyword: /\b(?:exec|alias|bind|unbind|toggle)\b/,
number: /\b-?\d+(?:\.\d+)?\b/,
identifier: /\b[a-zA-Z_][a-zA-Z0-9_]*\b/,
operator: /[+\-;]/,
};
})(typeof window !== "undefined" ? window.Prism : undefined);
```
- [ ] **Step 2: Manual verification (deferred until Task 6 is wired)**
This file is verified end-to-end once Task 6 renders a blueprint detail page with the editor mounted. For now, sanity-check it parses by opening any HTML page that loads prism.js + this file (or test in Node):
```bash
node -e "
const window = {};
const fs = require('fs');
eval(fs.readFileSync('l4d2web/l4d2web/static/vendor/prism.js', 'utf8'));
eval(fs.readFileSync('l4d2web/l4d2web/static/js/srccfg-grammar.js', 'utf8'));
console.log(Object.keys(window.Prism.languages.srccfg));
"
```
Expected output: `[ 'comment', 'string', 'keyword', 'number', 'identifier', 'operator' ]`.
- [ ] **Step 3: Commit**
```bash
git add l4d2web/l4d2web/static/js/srccfg-grammar.js
git commit -m "feat(editor): add Prism grammar for Source-engine .cfg syntax
Five token classes (comment, string, keyword, number, identifier) plus
operators. Purely visual highlighting; no semantic validation of cvar
names or values."
```
---
## Task 3: Editor stylesheet
**Files:**
- Create: `l4d2web/l4d2web/static/css/editor.css`
The widget needs to visually replace the textarea without layout shift. The contenteditable inherits the textarea's monospace look from `tokens.css`. Popup is absolutely positioned near the caret.
- [ ] **Step 1: Inspect existing textarea styling**
```bash
grep -n "textarea" l4d2web/l4d2web/static/css/components.css | head -5
grep -n "monospace\|--font-mono\|--color-" l4d2web/l4d2web/static/css/tokens.css | head -10
```
Note the font-family, color, border, and background tokens that textareas use today. Use those same tokens in editor.css so the upgraded surface matches.
- [ ] **Step 2: Write editor.css**
Create `l4d2web/l4d2web/static/css/editor.css`:
```css
/* Code editor widget — paired with editor.js. Mounted as a sibling of
any <textarea data-editor-language>. The textarea is hidden but stays
in the DOM for form submission and JS .value reads. */
.editor-shell {
position: relative;
width: 100%;
}
.editor-code {
display: block;
width: 100%;
min-height: 6em;
padding: 0.5em 0.75em;
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
font-size: 0.95em;
line-height: 1.45;
color: var(--color-fg, #e6e6e6);
background: var(--color-bg-input, #1b1b1b);
border: 1px solid var(--color-border, #333);
border-radius: var(--radius-input, 4px);
white-space: pre-wrap;
word-break: break-word;
overflow: auto;
outline: none;
caret-color: var(--color-caret, #fff);
}
.editor-code:focus {
border-color: var(--color-focus, #6ab0ff);
}
/* Prism token colors — override defaults to match the site palette. */
.editor-code .token.comment { color: var(--color-muted, #888); font-style: italic; }
.editor-code .token.string { color: var(--color-string, #c2e886); }
.editor-code .token.keyword { color: var(--color-keyword, #ff7b72); font-weight: 600; }
.editor-code .token.number { color: var(--color-number, #f0b86e); }
.editor-code .token.operator { color: var(--color-muted, #888); }
.editor-code .token.identifier { color: inherit; }
/* Autocomplete popup. */
.editor-popup {
position: absolute;
z-index: 1000;
max-height: 14em;
overflow-y: auto;
min-width: 14em;
margin: 0;
padding: 0.25em 0;
list-style: none;
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
font-size: 0.9em;
background: var(--color-bg-popover, #222);
border: 1px solid var(--color-border, #333);
border-radius: var(--radius-input, 4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.editor-popup-item {
padding: 0.25em 0.75em;
cursor: pointer;
white-space: nowrap;
}
.editor-popup-item.is-active {
background: var(--color-bg-popover-active, #2d4f7c);
}
.editor-popup-item .name { color: var(--color-fg, #e6e6e6); }
.editor-popup-item .desc { color: var(--color-muted, #888); margin-left: 0.5em; }
/* Files-editor language dropdown. */
.editor-language-select {
margin-left: 0.5em;
}
```
If `tokens.css` doesn't define some of the variables referenced (e.g. `--color-bg-input`), the `var(name, fallback)` form falls back gracefully. After Step 1 you'll know which token names are real; replace any fallback-only ones with values you've grepped.
- [ ] **Step 3: Commit**
```bash
git add l4d2web/l4d2web/static/css/editor.css
git commit -m "style(editor): add stylesheet for editor shell + Prism tokens + popup
Defines .editor-shell, .editor-code, .editor-popup. Reuses tokens.css
variables where present so the editor matches the site palette."
```
---
## Task 4: Editor widget core (mount + textarea sync, no autocomplete yet)
**Files:**
- Create: `l4d2web/l4d2web/static/js/editor.js`
The widget mounts on every `<textarea data-editor-language>`, hides the textarea, creates a sibling contenteditable, mounts CodeJar with Prism highlighting, and pipes content back to the textarea on every input. Autocomplete is added in Task 8; this task lands a working "highlight as you type" experience.
- [ ] **Step 1: Write the widget skeleton**
Create `l4d2web/l4d2web/static/js/editor.js`:
```js
// Code editor widget. Mounts on any <textarea data-editor-language>.
// The textarea stays in the DOM (display:none) and the widget mirrors
// content back into it on every input — form submission and JS code
// that reads textarea.value (e.g. files-overlay.js) keep working.
//
// Public per-instance API (attached to the textarea as ._codeEditor):
// - setValue(text)
// - setLanguage(name) // "srccfg" | "bash" | "plain" | "auto"
// - getValue()
// - destroy()
(function () {
"use strict";
const LANG_BY_EXT = {
cfg: "srccfg",
sh: "bash",
bash: "bash",
};
function resolveAutoLanguage(filename) {
if (!filename) return "plain";
const m = /\.([a-zA-Z0-9]+)$/.exec(filename);
if (!m) return "plain";
return LANG_BY_EXT[m[1].toLowerCase()] || "plain";
}
function highlightFor(lang) {
return function (editorEl) {
if (lang === "plain" || !window.Prism || !window.Prism.languages[lang]) {
// Plain mode or grammar missing: leave textContent alone.
editorEl.innerHTML = editorEl.textContent
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;");
return;
}
window.Prism.highlightElement(editorEl);
};
}
function mount(textarea) {
if (textarea._codeEditor) return textarea._codeEditor;
const requested = textarea.dataset.editorLanguage || "plain";
let language =
requested === "auto"
? resolveAutoLanguage(findFilenameInput(textarea)?.value)
: requested;
// Build the visible editor.
const shell = document.createElement("div");
shell.className = "editor-shell";
const code = document.createElement("code");
code.className = "editor-code language-" + language;
code.setAttribute("contenteditable", "true");
code.setAttribute("spellcheck", "false");
code.textContent = textarea.value;
shell.appendChild(code);
textarea.parentNode.insertBefore(shell, textarea);
textarea.style.display = "none";
// CodeJar mounts on the contenteditable and re-runs the highlighter
// on each input while preserving caret position.
const jar = window.CodeJar(code, highlightFor(language), { tab: " " });
jar.onUpdate(function (value) {
// Mirror back to the underlying textarea so form POST and any
// .value readers see the current content.
textarea.value = value;
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
const instance = {
textarea,
shell,
code,
jar,
language,
setValue: function (text) {
jar.updateCode(text);
textarea.value = text;
},
getValue: function () {
return jar.toString();
},
setLanguage: function (name) {
const next =
name === "auto"
? resolveAutoLanguage(findFilenameInput(textarea)?.value)
: name;
if (next === instance.language) return;
instance.language = next;
code.className = "editor-code language-" + next;
jar.updateOptions({});
// Replace the highlighter by recreating it via updateCode (CodeJar
// doesn't expose setHighlight directly).
// Trick: stash the new highlighter on the closure and rerun.
code.__highlighter = highlightFor(next);
code.__highlighter(code);
},
destroy: function () {
jar.destroy();
shell.remove();
textarea.style.display = "";
delete textarea._codeEditor;
},
};
textarea._codeEditor = instance;
return instance;
}
// For "auto" language: look for a filename input near the textarea
// (the files-editor modal). Returns the <input> or null.
function findFilenameInput(textarea) {
const modal = textarea.closest("dialog, .modal, body");
if (!modal) return null;
return modal.querySelector(".files-editor-filename");
}
function mountAll(root) {
const scope = root || document;
scope.querySelectorAll("textarea[data-editor-language]").forEach(mount);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
mountAll(document);
});
} else {
mountAll(document);
}
// Re-export for callers that need to mount editors created later (e.g.
// the files-editor modal which is in the static DOM but only used after
// user interaction — the initial mount is still correct, but exposing
// this hook lets future code mount dynamically-inserted editors).
window.l4d2Editor = { mount, mountAll };
})();
```
- [ ] **Step 2: Patch the `setLanguage` hot-path**
CodeJar's `updateOptions` API doesn't replace the highlighter function — the constructor captured it by closure. Reading the source: CodeJar stores the highlight callback as `highlight` in its options, then calls it on every input. We need a workaround:
Replace the `setLanguage` body with a tear-down-and-remount strategy that preserves caret if possible (for our use case — switching from srccfg to bash in the files-editor — losing caret is acceptable since the user just clicked a dropdown):
```js
setLanguage: function (name) {
const next =
name === "auto"
? resolveAutoLanguage(findFilenameInput(textarea)?.value)
: name;
if (next === instance.language) return;
const currentText = jar.toString();
jar.destroy();
instance.language = next;
code.className = "editor-code language-" + next;
code.textContent = currentText;
instance.jar = window.CodeJar(code, highlightFor(next), { tab: " " });
instance.jar.onUpdate(function (value) {
textarea.value = value;
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
},
```
- [ ] **Step 3: Commit (no test yet — manual smoke runs after Task 6)**
```bash
git add l4d2web/l4d2web/static/js/editor.js
git commit -m "feat(editor): widget core — mount, sync, language switch
Mounts on <textarea data-editor-language>, hides the textarea, renders
content in a contenteditable sibling with Prism highlighting via
CodeJar. Mirrors content back to textarea.value on every input so form
POST and existing JS readers keep working unchanged. Exposes
setValue/setLanguage/getValue on textarea._codeEditor for callers."
```
---
## Task 5: Editor assets Jinja partial
**Files:**
- Create: `l4d2web/l4d2web/templates/_editor_assets.html`
Three pages need the same five script/link tags. Centralise into a partial.
- [ ] **Step 1: Write the partial**
Create `l4d2web/l4d2web/templates/_editor_assets.html`:
```jinja
{# Editor asset bundle — include on any page that mounts a
<textarea data-editor-language>. Order matters: prism + codejar load
first, then the srccfg grammar registers itself on window.Prism, then
editor.js scans the DOM and mounts. #}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/prism.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/editor.css') }}">
<script src="{{ url_for('static', filename='vendor/prism.js') }}" defer nonce="{{ g.csp_nonce }}"></script>
<script src="{{ url_for('static', filename='vendor/codejar.js') }}" defer nonce="{{ g.csp_nonce }}"></script>
<script src="{{ url_for('static', filename='js/srccfg-grammar.js') }}" defer nonce="{{ g.csp_nonce }}"></script>
<script src="{{ url_for('static', filename='js/editor.js') }}" defer nonce="{{ g.csp_nonce }}"></script>
```
- [ ] **Step 2: Commit**
```bash
git add l4d2web/l4d2web/templates/_editor_assets.html
git commit -m "feat(editor): add Jinja partial for editor asset includes
Five script/link tags consolidated so call-site templates only need a
single {% include '_editor_assets.html' %} to enable the widget."
```
---
## Task 6: Wire blueprint config to use the editor
**Files:**
- Modify: `l4d2web/l4d2web/templates/blueprint_detail.html` (line 52, end of `content` block)
- Modify: `l4d2web/tests/test_blueprints.py`
- [ ] **Step 1: Write the failing form-contract test**
Add to `l4d2web/tests/test_blueprints.py` (after the existing form-update test around line 254):
```python
def test_blueprint_detail_renders_editor_assets(user_client) -> None:
with session_scope() as session:
user = session.scalar(select(User).where(User.username == "alice"))
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
session.add(blueprint)
session.flush()
blueprint_id = blueprint.id
response = user_client.get(f"/blueprints/{blueprint_id}")
assert response.status_code == 200
body = response.get_data(as_text=True)
# Editor opts the textarea in via a data-attribute.
assert 'data-editor-language="srccfg"' in body
# All editor assets are referenced.
assert "static/vendor/prism.js" in body
assert "static/vendor/codejar.js" in body
assert "static/js/srccfg-grammar.js" in body
assert "static/js/editor.js" in body
assert "static/css/editor.css" in body
# Scripts are nonce'd (CSP regression guard).
assert 'nonce="' in body
def test_blueprint_config_form_post_still_round_trips(user_client) -> None:
# The editor is a visual layer; the form POST contract must be
# unaffected. This guards against accidentally renaming `config` or
# dropping it from form serialization.
with session_scope() as session:
user = session.scalar(select(User).where(User.username == "alice"))
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
session.add(blueprint)
session.flush()
blueprint_id = blueprint.id
update = user_client.post(
f"/blueprints/{blueprint_id}",
data={
"name": "bp",
"arguments": "",
"config": "sv_cheats 1\nmp_gamemode coop",
"overlay_ids": [],
},
headers={"X-CSRF-Token": "test-token"},
)
assert update.status_code == 302
with session_scope() as session:
bp = session.get(Blueprint, blueprint_id)
assert json.loads(bp.config) == ["sv_cheats 1", "mp_gamemode coop"]
```
- [ ] **Step 2: Run the tests to verify they fail**
```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 fail. The first because `data-editor-language="srccfg"` isn't in the HTML yet. The second may pass already (form contract isn't broken) — if so, leave it as a regression guard for later tasks.
- [ ] **Step 3: Edit blueprint_detail.html**
At line 52, change:
```jinja
<textarea name="config" rows="8" spellcheck="false">{{ config_lines | join('\n') }}</textarea>
```
to:
```jinja
<textarea name="config" rows="8" spellcheck="false" data-editor-language="srccfg">{{ config_lines | join('\n') }}</textarea>
```
At the very end of `{% block content %}` (after the existing `<script src="...blueprint-overlay-picker.js" defer></script>` 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/<some_id>`. 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
<textarea name="script" rows="20" spellcheck="false">{{ overlay.script or "" }}</textarea>
```
to:
```jinja
<textarea name="script" rows="20" spellcheck="false" data-editor-language="bash">{{ overlay.script or "" }}</textarea>
```
At the very end of `{% block content %}` (after the existing `<script src="...files-overlay.js" defer></script>` 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/<lang>-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
<label class="files-editor-field">
<span class="files-field-label">Content</span>
<textarea class="files-editor-content" rows="14" spellcheck="false"></textarea>
</label>
```
Replace with:
```jinja
<label class="files-editor-field">
<span class="files-field-label">Content</span>
<textarea class="files-editor-content" rows="14" spellcheck="false" data-editor-language="auto"></textarea>
</label>
<label class="files-editor-field files-editor-language-field">
<span class="files-field-label">Language</span>
<select class="files-editor-language editor-language-select">
<option value="auto" selected>Auto (from filename)</option>
<option value="srccfg">Source config (.cfg)</option>
<option value="bash">Bash (.sh)</option>
<option value="plain">Plain text</option>
</select>
</label>
```
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 `<script>` tags use it; no inline scripts anywhere.
- **Existing progressive-enhancement pattern** — `l4d2web/l4d2web/static/js/blueprint-overlay-picker.js:20-26,63` manipulates hidden inputs directly. The editor follows the same shape: no submit interception, just DOM mirroring.
- **Files-editor save path** — `l4d2web/l4d2web/static/js/files-overlay.js:450-557`. JSON-fetch POST of `{path, content}` where `content` is `editorEls.contentBox.value`. The editor's `onUpdate` callback keeps `.value` in sync; the save call site needs no changes (only the `value =` *assignments* at lines 345 + 385 do).