left4me/docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md
mwiegand 86ac10a1d9
docs(files): handoff plan for files-overlay Playwright e2e tests
Companion to the just-shipped files-overlay rewrite. The rewrite
verified each step's behavior live in Chromium but left no automated
browser regression net. This handoff plan specifies what to add:

  * Fixture extension (conftest.py): a files_overlay_server fixture
    that seeds a files-type Overlay with one text file, one binary
    file, and a nested folder under tmp_path-rooted LEFT4ME_ROOT.
  * 11 test cases in three tiers — Tier 1 covers the critical paths
    (text edit save, create-new, 409→askConflict, binary replace,
    new-folder + delete, rename-on-save), Tier 2 rounds out drag /
    upload / deep-link, Tier 3 hits the server-detail download
    button.
  * Patterns to follow + pitfalls (the SESSION_COOKIE_SECURE=0 gotcha,
    the data-rel-path location split between text and binary modes,
    the htmx.ajax async wait, why os-drag-with-folders can't be
    synthesized).

Pinned references at the bottom point at the existing test_editor.py
pattern model and the relevant module-header comments. Estimated
half-day for the critical 7 cases, full day for the full 11.

Lives under docs/superpowers/plans/ per the project's planning-
artifact convention. Move to specs/ if it's executed and turned into
shipped tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:20:37 +02:00

11 KiB

Files-overlay E2E test handoff

Context

The files-overlay rewrite (commits 4fa3964..8dc14f0, May 2026) moved all editor flows behind URL-addressable modals and split the 1091-line files-overlay.js monolith into four focused modules under l4d2web/l4d2web/static/js/files-overlay/. Behavior was verified step-by-step in Chromium during the rewrite, but there is no automated browser regression coverage for the editor / dialog / upload flows.

The existing Playwright suite (l4d2web/tests/e2e/test_editor.py) covers only the CodeMirror 6 controller — autocomplete, form-bridge, copy/paste — invoked through a blueprint detail page. Nothing exercises the file manager UI.

This handoff specifies what to add: fixture extensions, the test cases worth writing, and the patterns / pitfalls a future implementer should know before starting. Estimated effort: a focused half-day for the seven critical cases, a full day for the full matrix.

Goal

Lock down the user-visible behavior of the four files-overlay modules against future regressions. The rewrite proved each module works in isolation; e2e proves they cooperate over real DOM, real HTTP, real HTMX, and real CodeMirror.

Out of scope

  • Re-testing pure CodeMirror behavior (the existing test_editor.py covers this on a non-files page; the controller is the same one).
  • Replacing the existing pytest route tests (tests/test_overlay_files_routes.py, tests/test_url_addressable_modals.py). E2E adds integration coverage on top of those, not in place.
  • Performance / load testing of the upload queue (concurrency 3 is the current behavior; testing it would need 4+ simultaneous uploads and is high-flake low-value).
  • The drag-drop-from-OS path. Playwright can't synthesize a real OS drag (webkitGetAsEntry returns null for synthetic drops, so the fallback getAsFile branch always runs). The internal-drag path (row → folder) is testable; the external drag fallback is covered enough by the route tests.

Fixture work

l4d2web/tests/e2e/conftest.py currently seeds only a User and a Blueprint. The files-overlay tests need a files-type overlay with a working filesystem root. Add a new fixture (or extend live_server):

# tests/e2e/conftest.py

@pytest.fixture(scope="function")
def files_overlay_server(tmp_path, monkeypatch):
    """live_server + a files-type Overlay seeded with a small fixture
    set: one editable text file, one binary file, one nested folder
    with one file inside.

    Returns {base_url, user_id, overlay_id, overlay_root: Path}.
    """
    # Same boot as live_server (extract a helper to avoid duplication).
    # Set LEFT4ME_ROOT to tmp_path before create_app() so the files
    # overlay's path resolution lands under tmp_path.
    monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
    ...

    with session_scope() as session:
        user = User(username="alice", password_digest=hash_password("secret"), admin=False)
        session.add(user); session.flush()
        overlay = Overlay(name="cfgs", path="", type="files", user_id=user.id)
        session.add(overlay); session.flush()
        overlay.path = str(overlay.id)
        overlay_root = tmp_path / "overlays" / str(overlay.id)
        overlay_root.mkdir(parents=True)
        (overlay_root / "server.cfg").write_text("hostname \"left4me\"\n")
        (overlay_root / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 60)
        (overlay_root / "cfg").mkdir()
        (overlay_root / "cfg" / "admins.txt").write_text("STEAM_1:0:1\n")
        user_id, overlay_id = user.id, overlay.id
    ...
    yield {
        "base_url": ...,
        "user_id": user_id,
        "overlay_id": overlay_id,
        "overlay_root": overlay_root,
    }

The LEFT4ME_ROOT env-var monkey-patch is critical — without it, overlay_files.resolve_overlay_root falls back to the production /var/lib/left4me path (per the AGENTS.md "symptom-to-cause" note) and every route returns 404. Set it BEFORE create_app().

Test cases to add

Suggested file: l4d2web/tests/e2e/test_files_overlay.py. Pattern each test like the existing test_editor.py: log in via the form, navigate to /overlays/<id>, drive the UI through Playwright page locators, assert on DOM state + filesystem state under overlay_root.

Tier 1 — critical paths (write these first)

  1. test_edit_text_file_save_round_trip

    • Click server.cfg filename. Wait for #modal-content textarea[data-rel-path="server.cfg"]. URL should contain ?modal=%2Foverlays%2F<id>%2Ffiles%2Fedit%3Fpath%3Dserver.cfg.
    • Modify content via Playwright page.fill on the textarea (or via the __filesEditor.setContent controller for the CM6 case — the existing test_editor.py shows both approaches).
    • Click .files-editor-save. Modal closes (modal-container aria-modal gone / open false).
    • Assert overlay_root / "server.cfg" on disk has the new content.
  2. test_create_new_file_routed

    • Click + new file on the overlay-root row. Wait for #modal-content textarea[data-rel-path=""] and save button labeled Create.
    • Type a filename and content. Click Create.
    • Assert file appears on disk + the file tree refreshes to show the new row.
  3. test_create_new_file_409_askConflict_keep_both

    • Click + new file. Type cfg as the filename (collides with the seeded directory). Click Create.
    • Wait for #files-conflict-modal[open]. Its .files-conflict-path should read cfg.
    • Click [data-files-conflict-action="keep-both"].
    • Assert the file cfg (1) appears on disk and the routed modal closes.
    • This is the path F4 (8dc14f0) added; without coverage it can regress silently.
  4. test_open_binary_file_renders_replace_ui

    • Click icon.png. Modal opens.
    • Assert #modal-content .files-editor-binary[data-rel-path="icon.png"] exists, save button reads Replace and is disabled, .files-editor-replace-zone and the download anchor are present.
  5. test_binary_replace_via_browse_writes_new_bytes

    • Open icon.png editor (as above).
    • Click .files-editor-replace-browse. Use Playwright's page.expect_file_chooser() to attach a small File buffer.
    • Save button enables. Click it. Modal closes.
    • Assert the file's bytes on disk are the new content.
  6. test_new_folder_then_delete

    • Click + new folder on the overlay root. Inline dialog opens.
    • Type a name, press Enter (keydown path). Dialog closes.
    • Assert folder exists on disk + appears in tree.
    • Click the folder's . Delete-confirm dialog opens with the folder name. Click .files-delete-confirm.
    • Assert folder gone from disk + from tree.
  7. test_filename_rename_on_save

    • Open server.cfg. Change the filename input to server-renamed.cfg. Click Save.
    • Assert disk has the new name + old name gone + tree row updated.

Tier 2 — round out the matrix

  1. test_drag_row_to_folder_moves_file — internal drag. Playwright's locator.drag_to() can move a row onto a folder. Assert the move via /files/move succeeded and disk reflects it.

  2. test_upload_queue_progress — drop a single file onto the tree root. The progress panel becomes visible; the row enters data-state="active", then data-state="done". Assert the uploaded file is on disk. (Skip the 409 / conflict / cancel permutations — they're covered by the route tests.)

  3. test_modal_close_on_escape_preserves_no_state — open the routed editor, type some content but don't save, press Escape. Modal closes. Reopen — content is fresh (no stale buffer), routedReplacement cleared.

  4. test_share_url_deep_link_reopens_editor — navigate directly to /overlays/<id>?modal=%2Foverlays%2F<id>%2Ffiles%2Fedit%3Fpath%3Dserver.cfg. Modal should auto-open on DOMContentLoaded (the bootstrap path from modals.js). This is the URL-addressable spec's central promise; without coverage it regresses easily.

Tier 3 — nice to have

  1. Server detail page hover-download button (the F0 prefactor): seed a server, navigate to /servers/<id>, hover a file row, click the button, assert a file download initiates.

Patterns to follow / pitfalls

  • The existing test_editor.py is the closest pattern. Read it end-to-end before starting. The login helper, the live_server fixture shape, the expect-based assertions, and the way Playwright interacts with the CM6 controller (page.evaluate(...) on window.__filesEditor) all transfer.
  • Run with uv run pytest -m e2e tests/e2e/test_files_overlay.py. Anything else crashes Chromium under macOS sandbox. uv run playwright install chromium once per fresh checkout.
  • Routed modals load via htmx.ajax — they're async. Don't assert immediately after the click. Use expect(page.locator(...)).to_be_visible() with a timeout (Playwright's default 5s is fine).
  • Reading the file tree after a refresh is also async. The JS scheduleRefresh debounces by 50ms then fetches the directory partial via HTMX. Use expect(page.locator(".file-tree-row-file[data-target-path='...']")).to_be_visible() rather than polling DOM directly.
  • data-rel-path lives on the textarea in text mode and on the binary panel in binary mode. Tests asserting "the editor opened for X" should query whichever matches — or use the fragment wrapper #files-editor-fragment as a stable container.
  • The conflict dialog is inline, not routed. Don't expect URL changes when it opens. The decision tree:
    • "Did the URL change?" → routed modal (editor) vs. inline modal (new-folder, conflict, delete-confirm).
  • SESSION_COOKIE_SECURE=0 is non-optional. The fixture must set it; otherwise the browser drops the session cookie over http and every test redirects back to /login. The existing conftest.py has the right pattern at line 39.

Verification

Per AGENTS.md: uv run pytest -m e2e tests/e2e/test_files_overlay.py -v. The tier-1 seven cases should pass green in <60s on a warm Chromium. The full matrix (12 cases) target <2 minutes.

When wiring CI / pre-push hooks: the e2e marker is excluded from the default fast suite, so the existing 580-passing uv run pytest tests/ run remains the quick check. The e2e suite runs explicitly when -m e2e is set.

References

  • l4d2web/tests/e2e/test_editor.py — pattern model
  • l4d2web/tests/e2e/conftest.py:39SESSION_COOKIE_SECURE note
  • l4d2web/tests/test_url_addressable_modals.py — non-browser route tests that already cover the server-side contract (200/404/415/400 on edit, new, save). E2E shouldn't duplicate these.
  • l4d2web/l4d2web/static/js/files-overlay/{core,editor,dialogs,uploads}.js — read each file's module header comment for the listener layout before writing assertions.
  • AGENTS.md "Files overlay: module layout" — high-level orientation.
  • AGENTS.md "Modals: inline vs routed" — decision tree the test matrix follows.