Addresses two Minor follow-ups from the Task 4 code review: - findFilenameInput previously included `body` in its closest() selector, meaning any "auto" textarea outside a modal would walk all the way up and pick up the files-editor modal's filename input from elsewhere in the document. Drop `body` so out-of-modal "auto" usage degrades cleanly to "plain". - setValue now dispatches an `input` event on the textarea after writing, matching the onUpdate mirror. Task 10 wires the files-editor modal to call setValue when loading file content — without this fix, textarea-listening code (e.g. unsaved-changes indicators) wouldn't see programmatic loads. Now setValue and user typing produce the same observable side effects. Plan source block updated to match. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
56 KiB
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 unchangedl4d2web/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: Assemble the Prism bundle via curl
Prism ships per-component files at cdn.jsdelivr.net/npm/prismjs@1.29.0/. We concatenate three components — core (required), clike (bash depends on it), bash — into a single self-contained prism.js. Theme CSS is grabbed separately.
mkdir -p l4d2web/l4d2web/static/vendor
VER=1.29.0
BASE=https://cdn.jsdelivr.net/npm/prismjs@${VER}
# Concatenate core + clike + bash into one file, in the required load order.
{
echo "/* Prism v${VER} — core + clike + bash, assembled from ${BASE}/components/ */"
curl -fsSL "${BASE}/components/prism-core.min.js"
echo # newline separator between components
curl -fsSL "${BASE}/components/prism-clike.min.js"
echo
curl -fsSL "${BASE}/components/prism-bash.min.js"
} > l4d2web/l4d2web/static/vendor/prism.js
# Theme CSS (the default "prism" theme).
curl -fsSL -o l4d2web/l4d2web/static/vendor/prism.css \
"${BASE}/themes/prism.min.css"
Verify:
ls -la l4d2web/l4d2web/static/vendor/prism.{js,css}
# Prism's minified bundle renames `Prism` → `e`, so grep for the
# bash-specific shebang regex that ships in the bash grammar instead.
grep -cE 'shebang|languages\.bash' l4d2web/l4d2web/static/vendor/prism.js
Expected: prism.js around 15–25 KB; prism.css around 1–3 KB. Grep count ≥1 (confirming the bash grammar got included — both shebang token and the language attachment are present in the minified bundle).
- Step 2: Download CodeJar from unpkg
CodeJar publishes a browser-ready bundle to npm. unpkg/jsdelivr serve it directly:
VER=4.0.0
curl -fsSL -o l4d2web/l4d2web/static/vendor/codejar.js \
"https://cdn.jsdelivr.net/npm/codejar@${VER}/dist/codejar.js"
Inspect the first 5 lines to confirm it's a valid JS module:
head -5 l4d2web/l4d2web/static/vendor/codejar.js
If the file uses ES module export syntax (export function CodeJar or export { CodeJar }), strip the export keyword and append a global shim so <script> tags can load it directly:
# Strip ESM exports, then expose CodeJar on window.
sed -i.bak \
-e 's/^export function /function /' \
-e 's/^export { CodeJar }.*$//' \
-e 's/^export { CodeJar as default }.*$//' \
l4d2web/l4d2web/static/vendor/codejar.js
rm -f l4d2web/l4d2web/static/vendor/codejar.js.bak
cat >> l4d2web/l4d2web/static/vendor/codejar.js <<'EOF'
// Browser global shim: surface CodeJar on window so non-module
// <script> tags can call it.
window.CodeJar = CodeJar;
EOF
If the CDN serves a UMD/IIFE bundle that already exposes window.CodeJar, the sed lines are no-ops and the final window.CodeJar = CodeJar will fail. In that case, open the file with head -20 to confirm CodeJar is already on window, and either remove the shim or wrap it in a typeof CodeJar !== 'undefined' check. If unsure, BLOCK and report what the file actually contains so the controller can advise.
- Step 3: Record SHA256s and source URLs in README.md
Create l4d2web/l4d2web/static/vendor/README.md:
# 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` | jsdelivr concat: prism-core.min.js + prism-clike.min.js + prism-bash.min.js | v1.29.0 | `<sha>` |
| `prism.css` | https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css | v1.29.0 | `<sha>` |
| `codejar.js` | https://cdn.jsdelivr.net/npm/codejar@4.0.0/codejar.js + ESM-strip + browser-global shim | v4.0.0 | `<sha>` |
## Regenerating
- **Prism:** Re-run the three-component concat in Task 1 Step 1 of
`docs/superpowers/plans/2026-05-16-textarea-code-editor.md` with
an updated `VER`, then re-download the theme CSS.
- **CodeJar:** Re-download from jsdelivr per the same plan's Task 1
Step 2, strip ESM exports, re-append the `window.CodeJar` shim.
Bump the version + SHA columns in this table after any update.
Then fill in the SHA256s:
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
ls -la l4d2web/l4d2web/static/vendor/
Expected output includes codejar.js, prism.css, prism.js, README.md. File sizes: prism.js around 20–30 KB; prism.css around 1–3 KB; codejar.js around 3–5 KB.
- Step 5: Commit
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:
// 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):
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
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
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:
/* 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. */
/* Token swaps from spec to match tokens.css:
--color-fg → --color-text (text color token)
--color-bg-input → --color-surface (textarea uses --color-surface)
--radius-input → --radius-s (closest available radius token)
--color-bg-popover → --color-surface (surface layer for overlays)
*/
.editor-shell {
position: relative;
width: 100%;
}
.editor-code {
display: block;
width: 100%;
min-height: 6em;
padding: var(--space-s) var(--space-m);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.875rem;
line-height: 1.5;
color: var(--color-text, #e6e6e6);
background: var(--color-surface, #1b1b1b);
border: 1px solid var(--color-border, #333);
border-radius: var(--radius-s, 4px);
white-space: pre-wrap;
word-break: break-word;
overflow: auto;
outline: none;
}
.editor-code:focus {
border-color: var(--color-focus, #6ab0ff);
}
/* Prism token colors — override defaults to match the site palette.
--color-string / --color-keyword / --color-number / --color-bg-popover-active
are added to tokens.css in both :root and the dark-mode block, so the
editor inherits the site's light/dark theming without per-token fallbacks. */
.editor-code .token.comment { color: var(--color-muted, #888); font-style: italic; }
.editor-code .token.string { color: var(--color-string); }
.editor-code .token.keyword { color: var(--color-keyword); font-weight: 600; }
.editor-code .token.number { color: var(--color-number); }
.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-surface, #222);
border: 1px solid var(--color-border, #333);
border-radius: var(--radius-s, 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);
}
.editor-popup-item .name { color: var(--color-text, #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;
}
The token-swap comment at the top records the four tokens.css reconciliations applied during the original audit pass. The five-line block following the Prism-token comment block also requires the matching token additions to tokens.css (both :root and @media (prefers-color-scheme: dark)): see the immediately following sub-step.
Step 2b: Add the syntax-highlighting tokens to tokens.css
/* Inside :root (light mode) */
--color-string: #0a3069; /* dark blue */
--color-keyword: #cf222e; /* red */
--color-number: #0550ae; /* medium blue */
--color-bg-popover-active: #e5e7eb; /* light gray — visible on white surface */
/* Inside @media (prefers-color-scheme: dark) */
--color-string: #a5d6ff; /* light blue */
--color-keyword: #ff7b72; /* salmon */
--color-number: #79c0ff; /* light blue, different hue from string */
--color-bg-popover-active: #374151; /* lifted gray on dark surface */
These are GitHub-style code-syntax colors tuned to ≥4.5:1 contrast on each theme's surface. Confirm placement matches the file's existing block structure (alphabetical-within-block in the current file).
- Step 3: Commit
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 9; this task lands a working "highlight as you type" experience.
- Step 1: Write
editor.js(consolidated final version)
Create l4d2web/l4d2web/static/js/editor.js:
Key design decisions baked into the final file (both the initial skeleton and the setLanguage fix merged into one):
findFilenameInputis hoisted abovemountso the initiallanguageresolution can reference it without a forward-reference hazard.attachOnUpdate(jarInstance)is a small helper called at construction time AND insidesetLanguage's remount, avoiding a duplicated callback body.- All method bodies in
instanceuseinstance.jar(not the capturedjarvariable) sosetValue/getValue/destroyalways operate on the current jar after asetLanguageswap. setLanguageuses tear-down-and-remount (notupdateOptions/code.__highlighter) because CodeJar captures its highlight callback by closure at construction time and provides no API to swap it on a live instance.
// 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, "&")
.replace(/</g, "<");
return;
}
window.Prism.highlightElement(editorEl);
};
}
// For "auto" language: look for a filename input inside the nearest
// enclosing dialog or .modal. Returns the <input> or null.
// Intentionally does NOT walk up to <body>: an "auto" textarea
// outside a modal scope degrades to "plain" rather than picking up
// some unrelated .files-editor-filename elsewhere in the document.
function findFilenameInput(textarea) {
const scope = textarea.closest("dialog, .modal");
if (!scope) return null;
return scope.querySelector(".files-editor-filename");
}
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: " " });
function attachOnUpdate(jarInstance) {
jarInstance.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 }));
});
}
attachOnUpdate(jar);
const instance = {
textarea,
shell,
code,
jar,
language,
setValue: function (text) {
// updateCode does not invoke the onUpdate mirror, so fire the
// same textarea sync + input event here for consistency. Any
// listener watching the textarea sees external setValue calls
// (e.g. files-overlay loading a file into the modal) the same
// way it sees user typing.
instance.jar.updateCode(text);
textarea.value = text;
textarea.dispatchEvent(new Event("input", { bubbles: true }));
},
getValue: function () {
return instance.jar.toString();
},
setLanguage: function (name) {
const next =
name === "auto"
? resolveAutoLanguage(findFilenameInput(textarea)?.value)
: name;
if (next === instance.language) return;
// CodeJar captures its highlight callback by closure at
// construction time — there is no API to swap it on a live
// instance. Tear down and remount with the new highlighter.
// Caret position is lost on switch; acceptable since this is
// triggered by the user clicking the language dropdown.
const currentText = instance.jar.toString();
instance.jar.destroy();
instance.language = next;
code.className = "editor-code language-" + next;
code.textContent = currentText;
instance.jar = window.CodeJar(code, highlightFor(next), { tab: " " });
attachOnUpdate(instance.jar);
},
destroy: function () {
instance.jar.destroy();
shell.remove();
textarea.style.display = "";
delete textarea._codeEditor;
},
};
textarea._codeEditor = instance;
return instance;
}
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
// (the files-editor modal exists in the static DOM but is only
// shown after user interaction — initial mount is correct, but
// exposing this hook lets future code mount dynamically-inserted
// editors if needed).
window.l4d2Editor = { mount, mountAll };
})();
- Manual verification note: No smoke test in this task — first live render happens in Task 6 when the blueprint config textarea gets
data-editor-language="srccfg"and the asset partial is included.
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:
{# 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
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 ofcontentblock) -
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):
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
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:
<textarea name="config" rows="8" spellcheck="false">{{ config_lines | join('\n') }}</textarea>
to:
<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:
{% include '_editor_assets.html' %}
- Step 4: Run the tests to verify they pass
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
execin keyword color, numbers in number color). -
Submitting the form persists the edited content (refresh confirms).
-
Step 6: Commit
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 ofcontentblock) -
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:
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
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:
<textarea name="script" rows="20" spellcheck="false">{{ overlay.script or "" }}</textarea>
to:
<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:
{% include '_editor_assets.html' %}
- Step 4: Run the test to verify it passes
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
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:
{
"_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
python3 -m json.tool l4d2web/l4d2web/static/data/srccfg-vocab.json > /dev/null && echo OK
Expected: OK.
- Step 3: Commit
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:
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:
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:
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_cheatsreplacingsv_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
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:
<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:
<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):
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:
// 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:
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:
// 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 withsv_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
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
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:
"playwright>=1.49.0",
"pytest-playwright>=0.6.0",
- Step 3: Install the dep + chromium binary
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:
[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
touch l4d2web/tests/e2e/__init__.py
Create l4d2web/tests/e2e/conftest.py:
"""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):
### 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:
cd l4d2web && uv run pytest -m e2e -v
Expected: PASS.
- Step 8: Commit
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:
"""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)
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(addp.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
cd l4d2web && uv run pytest -v
Expected: all existing tests pass (the new form-contract tests from Tasks 6/7 are included).
cd l4d2web && uv run pytest -m e2e -v
Expected: e2e suite passes.
- Step 4: Final commit
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:
- Blueprint config textarea — Task 6
- Overlay script (bash) textarea — Task 7
- Files-editor modal textarea — Task 10
- Auto language detection from filename — Task 10
- Language dropdown in files-editor — Task 10
srccfg-grammar.js— Task 2- CodeJar + Prism vendored — Task 1
_editor_assets.htmlpartial — Task 5editor.cssdedicated stylesheet — Task 3srccfg-vocab.jsoncurated vocab — Task 8- Lazy vocab loading + caching — Task 9 (
loadVocab+vocabCache) - Autocomplete trigger + filter + keyboard + mouse — Task 9
- Form-contract tests (GET + POST round-trip) — Task 6 (blueprint), Task 7 (overlay)
- Playwright scaffold + e2e test — Tasks 11 + 12
- No backend code changes — confirmed:
blueprint_routes.py,app.py,/files/saveroute 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_URLSis structured to make adding abashentry trivial later.
Reference snippets and where to look them up
- Form contract POST shape —
l4d2web/l4d2web/routes/blueprint_routes.py:117-142readsrequest.form.get("config")and splits on newlines. Do not change this in any task; the editor preserves the textarea'sname="config"and pipes value back on input. - CSP nonce accessor —
l4d2web/l4d2web/app.py:84-86exposesg.csp_nonceviabefore_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,63manipulates 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}wherecontentiseditorEls.contentBox.value. The editor'sonUpdatecallback keeps.valuein sync; the save call site needs no changes (only thevalue =assignments at lines 345 + 385 do).