docs(server-detail): implementation plan for console/log autoscroll
5 tasks, TDD style: 1. server-side slice console_history_overview = console_history[-20:] 2. generalise scrollConsolesToBottom -> scrollAutoscrollTargets (ancestor walk) 3. tabs.js pins autoscroll targets on tab activation 4. console-modal pin on modal:opened event 5. e2e: pin-to-bottom on tab + on command submit Bug confirmed in live browser before plan was written: after switching to the Console tab on /servers/1, transcript scrollTop=0 with bottomDistance=1873px (top-of-history visible, not newest). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2415885d30
commit
39963db2e3
1 changed files with 540 additions and 0 deletions
|
|
@ -0,0 +1,540 @@
|
||||||
|
# Server detail — console + log autoscroll 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:** Make the Log and Console transcripts on `/servers/<id>` stay pinned to their bottom on initial load, tab activation, and command append; cap the inline Console to the 20 newest entries while the modal keeps all 50.
|
||||||
|
|
||||||
|
**Architecture:** Single opt-in `data-autoscroll` attribute on any scroll-pinned region. One helper (`scrollAutoscrollTargets`) handles root, descendants, *and* ancestors so HTMX `beforeend` swaps that fire `htmx:load` on the inserted child still find and scroll the parent transcript. `tabs.js` calls the helper after activating a tab. `modals.js` already dispatches `modal:opened` on `showModal()`, so the Console modal hooks that event to scroll on first open.
|
||||||
|
|
||||||
|
**Tech Stack:** Flask + Jinja2 templates, vanilla JS, HTMX, Playwright for e2e, Claude-in-Chrome for live-browser verification.
|
||||||
|
|
||||||
|
**Reference spec:** `docs/superpowers/specs/2026-05-20-server-console-log-autoscroll-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
| Path | Action | Responsibility |
|
||||||
|
|------|--------|----------------|
|
||||||
|
| `l4d2web/l4d2web/routes/page_routes.py` | modify (~L318-345) | Add `console_history_overview = console_history[-20:]` to the render context |
|
||||||
|
| `l4d2web/l4d2web/templates/server_detail.html` | modify (L60-73, L101, L111-117, L159) | Inline loop uses `console_history_overview`; add `data-autoscroll` to both `<pre class="log-stream">`; Console modal transcript wires `modal:opened` → scroll |
|
||||||
|
| `l4d2web/l4d2web/static/js/console-history.js` | modify (L159-179) | Rename and generalise `scrollConsolesToBottom` → `scrollAutoscrollTargets` with ancestor walk; expose on `window` |
|
||||||
|
| `l4d2web/l4d2web/static/js/tabs.js` | modify (~L9-19) | After `activateTab` toggles `hidden`, scroll any `[data-autoscroll]` in the newly-active pane |
|
||||||
|
| `l4d2web/tests/test_servers.py` | extend | Server-side: assert inline pane caps at 20 and modal keeps 50 |
|
||||||
|
| `l4d2web/tests/e2e/test_server_detail.py` | extend | E2E: Console tab pinned to bottom on activation; Console pane pinned to bottom after command submit |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Server-side slice for inline Console history
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `l4d2web/l4d2web/routes/page_routes.py:318-347`
|
||||||
|
- Test: `l4d2web/tests/test_servers.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`test_servers.py` uses the `user_client_with_blueprints` fixture (returns `(client, data)` where `data` carries `user_id` and `blueprint_id`) plus direct DB writes via `session_scope()`. Mirror that pattern:
|
||||||
|
|
||||||
|
Append to `l4d2web/tests/test_servers.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_server_detail_inline_console_caps_at_20_modal_keeps_all(user_client_with_blueprints) -> None:
|
||||||
|
"""When > 20 CommandHistory rows exist for the server, the inline
|
||||||
|
Console transcript renders only the 20 newest (chronological order),
|
||||||
|
while the modal transcript renders the full set (capped at the
|
||||||
|
route-level 50)."""
|
||||||
|
import re
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from l4d2web.models import CommandHistory, Server
|
||||||
|
|
||||||
|
client, data = user_client_with_blueprints
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
server = Server(
|
||||||
|
user_id=data["user_id"],
|
||||||
|
blueprint_id=data["blueprint_id"],
|
||||||
|
name="console-cap",
|
||||||
|
port=27123,
|
||||||
|
rcon_password="x",
|
||||||
|
)
|
||||||
|
db.add(server)
|
||||||
|
db.flush()
|
||||||
|
sid = server.id
|
||||||
|
for i in range(35):
|
||||||
|
db.add(CommandHistory(
|
||||||
|
user_id=data["user_id"],
|
||||||
|
server_id=sid,
|
||||||
|
command=f"cmd_{i:02d}",
|
||||||
|
reply=f"reply {i}",
|
||||||
|
is_error=False,
|
||||||
|
created_at=datetime.now(UTC) - timedelta(minutes=40 - i),
|
||||||
|
))
|
||||||
|
|
||||||
|
resp = client.get(f"/servers/{sid}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
inline_match = re.search(
|
||||||
|
rf'<div id="console-transcript-inline-{sid}"[^>]*>(.*?)</div>\s*<form',
|
||||||
|
body,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
assert inline_match, "inline transcript container not found"
|
||||||
|
inline_lines = inline_match.group(1).count('class="console-line')
|
||||||
|
assert inline_lines == 20, f"inline expected 20, got {inline_lines}"
|
||||||
|
|
||||||
|
modal_match = re.search(
|
||||||
|
rf'<div id="console-transcript-modal-{sid}"[^>]*>(.*?)</div>\s*<form',
|
||||||
|
body,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
assert modal_match, "modal transcript container not found"
|
||||||
|
modal_lines = modal_match.group(1).count('class="console-line')
|
||||||
|
assert modal_lines == 35, f"modal expected 35, got {modal_lines}"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd l4d2web && pytest tests/test_servers.py::test_server_detail_inline_console_caps_at_20_modal_keeps_all -v
|
||||||
|
```
|
||||||
|
Expected: FAIL (inline returns 35, not 20)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Modify the route**
|
||||||
|
|
||||||
|
In `l4d2web/l4d2web/routes/page_routes.py`, locate `server_detail` (~L306) and the `console_history = list(reversed(...))` block (~L318-330). After it, add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
console_history_overview = console_history[-20:]
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in the `return render_template("server_detail.html", …)` call (~L335-347), add the new kwarg:
|
||||||
|
|
||||||
|
```python
|
||||||
|
return render_template(
|
||||||
|
"server_detail.html",
|
||||||
|
server=server,
|
||||||
|
blueprint=blueprint,
|
||||||
|
connect_host=connect_host,
|
||||||
|
file_tree_root_entries=file_tree_root_entries,
|
||||||
|
file_tree_truncated=file_tree_truncated_count > 0
|
||||||
|
if file_tree_root_entries is not None
|
||||||
|
else False,
|
||||||
|
file_tree_truncated_count=file_tree_truncated_count,
|
||||||
|
console_history=console_history,
|
||||||
|
console_history_overview=console_history_overview,
|
||||||
|
**ctx,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update the template to use the new variable**
|
||||||
|
|
||||||
|
In `l4d2web/l4d2web/templates/server_detail.html` at the **inline** Console pane (~L65-71, inside `<div role="tabpanel" data-tab="console">`):
|
||||||
|
|
||||||
|
Change:
|
||||||
|
```jinja
|
||||||
|
{% for h in console_history %}
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```jinja
|
||||||
|
{% for h in console_history_overview %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave the **modal** Console transcript (~L112-116) iterating over `console_history` unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the test to verify it passes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd l4d2web && pytest tests/test_servers.py::test_server_detail_inline_console_caps_at_20_modal_keeps_all -v
|
||||||
|
```
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add l4d2web/l4d2web/routes/page_routes.py \
|
||||||
|
l4d2web/l4d2web/templates/server_detail.html \
|
||||||
|
l4d2web/tests/test_servers.py
|
||||||
|
git commit -m "feat(server-detail): cap inline console to 20 newest; modal keeps 50"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Generalise `scrollConsolesToBottom` to walk ancestors
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `l4d2web/l4d2web/static/js/console-history.js:159-179`
|
||||||
|
|
||||||
|
The current helper only matches `root` and its descendants. When `htmx:load` fires after `hx-swap="beforeend"`, `event.detail.elt` is the newly inserted child line — neither it nor its descendants match `[data-autoscroll]`, so the transcript never scrolls. Adding an ancestor walk fixes this case without affecting the existing one.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rewrite the helper**
|
||||||
|
|
||||||
|
Replace lines 159-179 of `l4d2web/l4d2web/static/js/console-history.js` with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function scrollAutoscrollTargets(root) {
|
||||||
|
if (!root) return;
|
||||||
|
const targets = [];
|
||||||
|
// Case 1: root itself opts in.
|
||||||
|
if (root.matches && root.matches("[data-autoscroll]")) {
|
||||||
|
targets.push(root);
|
||||||
|
}
|
||||||
|
// Case 2: descendants opt in.
|
||||||
|
if (root.querySelectorAll) {
|
||||||
|
root.querySelectorAll("[data-autoscroll]").forEach((el) => targets.push(el));
|
||||||
|
}
|
||||||
|
// Case 3: neither — walk up. Handles htmx:load firing with the inserted
|
||||||
|
// child as the root after hx-swap="beforeend" on a console line.
|
||||||
|
if (targets.length === 0 && root.closest) {
|
||||||
|
const up = root.closest("[data-autoscroll]");
|
||||||
|
if (up) targets.push(up);
|
||||||
|
}
|
||||||
|
targets.forEach((el) => {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose for tabs.js (and any future cross-module consumer). The script
|
||||||
|
// is `defer`red in base.html, so it runs before DOMContentLoaded and the
|
||||||
|
// global is defined by the time tabs.js's DCL-deferred initStrips runs.
|
||||||
|
window.scrollAutoscrollTargets = scrollAutoscrollTargets;
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
scrollAutoscrollTargets(document);
|
||||||
|
bindAllConsoleForms(document);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("htmx:load", (event) => {
|
||||||
|
scrollAutoscrollTargets(event.detail.elt);
|
||||||
|
bindAllConsoleForms(event.detail.elt);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
That is: rename the function, add the third (ancestor-walk) case, expose on `window`, and update the two listeners to call the new name. Behavior preserved for the existing two cases.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Smoke-verify in a browser**
|
||||||
|
|
||||||
|
Start the dev server (or use a running one). Open `/servers/<id>` and run in the devtools console:
|
||||||
|
|
||||||
|
```js
|
||||||
|
typeof window.scrollAutoscrollTargets
|
||||||
|
```
|
||||||
|
Expected: `"function"`.
|
||||||
|
|
||||||
|
Then with a Console tab that already has > clientHeight of content, click into it and verify (manual eye check) that the transcript is no longer at the top. (It still won't be — tabs.js doesn't call the helper yet; that's Task 3. The smoke-check here is only that the helper is defined and reachable.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add l4d2web/l4d2web/static/js/console-history.js
|
||||||
|
git commit -m "feat(console): scrollAutoscrollTargets walks ancestors; expose on window"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `tabs.js` — scroll on tab activation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `l4d2web/l4d2web/static/js/tabs.js:9-19`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Edit `activateTab`**
|
||||||
|
|
||||||
|
In `l4d2web/l4d2web/static/js/tabs.js`, at the end of the `activateTab(strip, name)` function, after `strip.dataset.activeTab = name;`, append:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Pin any scroll-locked regions (log streams, console transcripts) in
|
||||||
|
// the newly-visible pane to the bottom. While the pane was hidden,
|
||||||
|
// their scrollHeight was 0 so previous appends couldn't anchor.
|
||||||
|
const activePane = strip.querySelector('[role="tabpanel"]:not([hidden])');
|
||||||
|
if (activePane && window.scrollAutoscrollTargets) {
|
||||||
|
window.scrollAutoscrollTargets(activePane);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The full block now looks like:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function activateTab(strip, name) {
|
||||||
|
strip.querySelectorAll('[role="tab"]').forEach((t) => {
|
||||||
|
const on = t.dataset.tab === name;
|
||||||
|
t.setAttribute("aria-selected", on ? "true" : "false");
|
||||||
|
t.tabIndex = on ? 0 : -1;
|
||||||
|
});
|
||||||
|
strip.querySelectorAll('[role="tabpanel"]').forEach((p) => {
|
||||||
|
p.hidden = p.dataset.tab !== name;
|
||||||
|
});
|
||||||
|
strip.dataset.activeTab = name;
|
||||||
|
|
||||||
|
const activePane = strip.querySelector('[role="tabpanel"]:not([hidden])');
|
||||||
|
if (activePane && window.scrollAutoscrollTargets) {
|
||||||
|
window.scrollAutoscrollTargets(activePane);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `data-autoscroll` to the log-stream elements**
|
||||||
|
|
||||||
|
In `l4d2web/l4d2web/templates/server_detail.html`:
|
||||||
|
|
||||||
|
- Inline log (~L61):
|
||||||
|
```jinja
|
||||||
|
<pre class="log-stream" data-autoscroll data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
|
||||||
|
```
|
||||||
|
- Modal log (~L101):
|
||||||
|
```jinja
|
||||||
|
<pre class="log-stream tall" data-autoscroll data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
|
||||||
|
```
|
||||||
|
- Modal job-log (~L159):
|
||||||
|
```jinja
|
||||||
|
<pre class="log-stream tall" data-autoscroll data-sse-url="/jobs/{{ latest_job.id }}/stream"></pre>
|
||||||
|
```
|
||||||
|
|
||||||
|
The Console transcripts already carry `data-autoscroll` and don't need editing.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Live-browser verification**
|
||||||
|
|
||||||
|
This is the moment to confirm the user-visible bug is fixed. Start (or reuse) the dev server and seed > 20 console rows for `demo-server` (the dev seed has only 9; bring that to 30+):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 .tmp/dev-server/l4d2web.db "
|
||||||
|
WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM seq WHERE n<30)
|
||||||
|
INSERT INTO command_history (user_id, server_id, command, reply, is_error, created_at)
|
||||||
|
SELECT 1, 1, 'verify_cmd_' || printf('%02d', n),
|
||||||
|
'reply ' || n, 0, datetime('now', '-'||(35-n)||' minutes') FROM seq;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in a browser, log into `/login` as `dev` / `devdevdev`, open `/servers/1`, click the **Console** tab, and run in devtools:
|
||||||
|
|
||||||
|
```js
|
||||||
|
(() => {
|
||||||
|
const t = document.querySelector('[id^="console-transcript-inline-"]');
|
||||||
|
return { scrollTop: t.scrollTop, scrollHeight: t.scrollHeight, clientHeight: t.clientHeight,
|
||||||
|
bottomDistance: t.scrollHeight - t.scrollTop - t.clientHeight,
|
||||||
|
inline_lines: t.querySelectorAll('.console-line').length };
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
Expected: `bottomDistance < 2` (pinned to bottom), `inline_lines == 20`.
|
||||||
|
|
||||||
|
Same exercise on the **Log** tab: switch to Console, then back to Log; the SSE-streamed log should be pinned to bottom. If the log stream has no content yet (server isn't running and never has been), this assertion vacuously passes (`scrollHeight === clientHeight`).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add l4d2web/l4d2web/static/js/tabs.js \
|
||||||
|
l4d2web/l4d2web/templates/server_detail.html
|
||||||
|
git commit -m "feat(server-detail): pin transcripts/logs to bottom on tab activation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Pin Console-modal transcript on `modal:opened`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `l4d2web/l4d2web/templates/server_detail.html:105-120` (Console modal)
|
||||||
|
|
||||||
|
When the Console modal opens via `data-inline-modal-open="console-modal"`, the dialog goes from `display:none` to displayed. The transcript inside it has `scrollHeight=0` while hidden, so any earlier autoscroll attempt did nothing. `modals.js:35` dispatches a `modal:opened` CustomEvent on `dialog.showModal()`; we listen for it on the dialog and re-pin.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the inline listener via `hx-on` (no JS file changes)**
|
||||||
|
|
||||||
|
In `l4d2web/l4d2web/templates/server_detail.html`, change the Console modal opening tag (~L105):
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
<dialog id="console-modal" class="modal" aria-labelledby="console-modal-title"
|
||||||
|
onmodal-opened="if(window.scrollAutoscrollTargets){window.scrollAutoscrollTargets(this)}">
|
||||||
|
```
|
||||||
|
|
||||||
|
The non-standard `onmodal-opened` attribute only works through an `addEventListener` registration — `dialog.dispatchEvent` doesn't invoke `onevent` handlers for custom events. So instead add a small DOMContentLoaded hook inside the template (one-off, not worth a new JS file):
|
||||||
|
|
||||||
|
Replace the opening `<dialog id="console-modal" …>` line with:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
<dialog id="console-modal" class="modal" aria-labelledby="console-modal-title">
|
||||||
|
```
|
||||||
|
|
||||||
|
…and **immediately before `{% endblock %}`** (end of file), add:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
<script>
|
||||||
|
// Pin the Console modal transcript to its bottom each time the modal
|
||||||
|
// opens. While the <dialog> is closed, its descendants have scrollHeight=0,
|
||||||
|
// so neither the page-load autoscroll nor htmx:load can anchor them.
|
||||||
|
// The 'modal:opened' CustomEvent is dispatched by modals.js on
|
||||||
|
// dialog.showModal().
|
||||||
|
(() => {
|
||||||
|
const dlg = document.getElementById("console-modal");
|
||||||
|
if (!dlg) return;
|
||||||
|
dlg.addEventListener("modal:opened", () => {
|
||||||
|
if (window.scrollAutoscrollTargets) {
|
||||||
|
window.scrollAutoscrollTargets(dlg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Live-browser verification**
|
||||||
|
|
||||||
|
Reload `/servers/1` (after seeding from Task 3 Step 3). Click the ⛶ expand button on the Console tab to open `#console-modal`. In devtools:
|
||||||
|
|
||||||
|
```js
|
||||||
|
(() => {
|
||||||
|
const t = document.querySelector('[id^="console-transcript-modal-"]');
|
||||||
|
return { scrollTop: t.scrollTop, scrollHeight: t.scrollHeight,
|
||||||
|
clientHeight: t.clientHeight,
|
||||||
|
bottomDistance: t.scrollHeight - t.scrollTop - t.clientHeight };
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
Expected: `bottomDistance < 2`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add l4d2web/l4d2web/templates/server_detail.html
|
||||||
|
git commit -m "feat(server-detail): pin Console-modal transcript on modal:opened"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: e2e — Console tab pinned on activation; pinned after submit
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `l4d2web/tests/e2e/test_server_detail.py` (append)
|
||||||
|
- Possibly: `l4d2web/tests/e2e/conftest.py` (add seeded-history fixture if helpful)
|
||||||
|
|
||||||
|
The dev server seed has too few rows to trigger overflow; the e2e fixture seeds its own. Reusing the `server_with_files` fixture is fine — it already builds a Server; we just need to insert `CommandHistory` rows before navigating.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add a fixture that seeds console history**
|
||||||
|
|
||||||
|
Append to `l4d2web/tests/e2e/conftest.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def server_with_console_history(server_with_files):
|
||||||
|
"""server_with_files + 30 seeded CommandHistory rows for that server,
|
||||||
|
so the inline Console transcript exceeds its visible height and the
|
||||||
|
autoscroll behaviour is observable."""
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from l4d2web.models import CommandHistory
|
||||||
|
|
||||||
|
sid = server_with_files["server_id"]
|
||||||
|
uid = server_with_files["user_id"]
|
||||||
|
with session_scope() as session:
|
||||||
|
for i in range(30):
|
||||||
|
session.add(CommandHistory(
|
||||||
|
user_id=uid,
|
||||||
|
server_id=sid,
|
||||||
|
command=f"seed_{i:02d}",
|
||||||
|
reply=f"reply {i}",
|
||||||
|
is_error=False,
|
||||||
|
created_at=datetime.now(UTC) - timedelta(minutes=35 - i),
|
||||||
|
))
|
||||||
|
|
||||||
|
return server_with_files
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing e2e tests**
|
||||||
|
|
||||||
|
Append to `l4d2web/tests/e2e/test_server_detail.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_console_tab_pinned_to_bottom_on_activation(page: Page, server_with_console_history) -> None:
|
||||||
|
"""Clicking the Console tab leaves the transcript scrolled to its
|
||||||
|
bottom — the newest seeded command must be visible, not the oldest."""
|
||||||
|
base = server_with_console_history["base_url"]
|
||||||
|
sid = server_with_console_history["server_id"]
|
||||||
|
login(page, base)
|
||||||
|
page.goto(f"{base}/servers/{sid}")
|
||||||
|
|
||||||
|
strip = page.locator("[data-tab-strip]")
|
||||||
|
strip.locator('[role="tab"][data-tab="console"]').click()
|
||||||
|
|
||||||
|
transcript = page.locator(f"#console-transcript-inline-{sid}")
|
||||||
|
expect(transcript).to_be_visible()
|
||||||
|
|
||||||
|
# Pinned to bottom: |scrollHeight - scrollTop - clientHeight| < 2
|
||||||
|
bottom_distance = transcript.evaluate(
|
||||||
|
"(el) => el.scrollHeight - el.scrollTop - el.clientHeight"
|
||||||
|
)
|
||||||
|
assert abs(bottom_distance) < 2, f"transcript not pinned to bottom: {bottom_distance}px"
|
||||||
|
|
||||||
|
# Inline pane caps at 20 lines.
|
||||||
|
line_count = transcript.locator(".console-line").count()
|
||||||
|
assert line_count == 20, f"inline expected 20 lines, got {line_count}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_console_pane_pinned_after_command_submit(page: Page, server_with_console_history) -> None:
|
||||||
|
"""After submitting a command, the transcript scrolls so the new line
|
||||||
|
is visible at the bottom.
|
||||||
|
|
||||||
|
The dev server has no live RCON, but the POST still records a
|
||||||
|
CommandHistory row and HTMX appends a console-line to the transcript;
|
||||||
|
that's enough to exercise the autoscroll path.
|
||||||
|
"""
|
||||||
|
base = server_with_console_history["base_url"]
|
||||||
|
sid = server_with_console_history["server_id"]
|
||||||
|
login(page, base)
|
||||||
|
page.goto(f"{base}/servers/{sid}")
|
||||||
|
|
||||||
|
strip = page.locator("[data-tab-strip]")
|
||||||
|
strip.locator('[role="tab"][data-tab="console"]').click()
|
||||||
|
|
||||||
|
transcript = page.locator(f"#console-transcript-inline-{sid}")
|
||||||
|
pane = page.locator('[role="tabpanel"][data-tab="console"]')
|
||||||
|
cmd_input = pane.locator('input[name="command"]')
|
||||||
|
cmd_input.fill("verify_submit")
|
||||||
|
cmd_input.press("Enter")
|
||||||
|
|
||||||
|
# Wait for the new line to appear in the DOM.
|
||||||
|
expect(transcript.locator(".console-line", has_text="verify_submit")).to_be_visible()
|
||||||
|
|
||||||
|
bottom_distance = transcript.evaluate(
|
||||||
|
"(el) => el.scrollHeight - el.scrollTop - el.clientHeight"
|
||||||
|
)
|
||||||
|
assert abs(bottom_distance) < 2, f"transcript not pinned after submit: {bottom_distance}px"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run e2e**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd l4d2web && pytest tests/e2e/test_server_detail.py -m e2e -v
|
||||||
|
```
|
||||||
|
|
||||||
|
If the dev-machine doesn't have Chromium installed: `playwright install chromium` first.
|
||||||
|
|
||||||
|
Expected: PASS for both new tests; existing tests still pass.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add l4d2web/tests/e2e/conftest.py l4d2web/tests/e2e/test_server_detail.py
|
||||||
|
git commit -m "test(e2e): console transcript pinned to bottom on tab + submit"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final verification (live browser)
|
||||||
|
|
||||||
|
After all tasks merge:
|
||||||
|
|
||||||
|
1. Reset / re-seed the dev DB if you used the `verify_cmd_*` seed above:
|
||||||
|
```bash
|
||||||
|
sqlite3 .tmp/dev-server/l4d2web.db "DELETE FROM command_history WHERE command LIKE 'verify_cmd_%' OR command LIKE 'seed_cmd_%';"
|
||||||
|
```
|
||||||
|
2. Restart `scripts/dev-server.py`.
|
||||||
|
3. In a browser, log in as `dev` / `devdevdev`, open `/servers/1`.
|
||||||
|
4. Click **Console** — transcript shows ≤20 most-recent commands, scrolled to bottom.
|
||||||
|
5. Submit any command (e.g. `status`) — the response error appends as a new line and the transcript scrolls to keep it visible.
|
||||||
|
6. Click ⛶ to open the Console modal — modal transcript shows all 50 most-recent (or however many exist), scrolled to bottom.
|
||||||
|
7. Switch to **Log**, then between Console and Log a few times — each transition leaves the active tab's transcript pinned to its bottom.
|
||||||
|
|
||||||
|
## Spec coverage check
|
||||||
|
|
||||||
|
- ✅ Server-side slice to 20 newest inline; modal keeps 50 — **Task 1**
|
||||||
|
- ✅ Both log-stream `<pre>`s get `data-autoscroll` — **Task 3 Step 2**
|
||||||
|
- ✅ Helper walks ancestors to handle htmx:load on appended child — **Task 2**
|
||||||
|
- ✅ Helper exposed on `window` — **Task 2**
|
||||||
|
- ✅ `tabs.js` pins on activation — **Task 3 Step 1**
|
||||||
|
- ✅ Console-modal pin on `modal:opened` — **Task 4**
|
||||||
|
- ✅ Unit/template coverage for cap — **Task 1**
|
||||||
|
- ✅ e2e coverage for tab activation + submit pin — **Task 5**
|
||||||
Loading…
Reference in a new issue