Files
2026-04-15 17:57:12 +02:00

31 KiB

Phase 9: UX Tech Debt Closure - Research

Researched: 2026-04-13 Domain: FastAPI / HTMX / Alpine.js / Playwright — frontend UX defect closure Confidence: HIGH (all findings grounded in direct codebase inspection)


<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

Plan structure (3 plans, sequential):

  • 09-01: UX-01 + driver upload 500 fix (bundled). TDD: failing pytest first, fix root cause in imptune/api/drivers.py, then add inline driver upload to printer form template, HTMX OOB swap refreshes <select name="driver_id">, auto-select new driver.
  • 09-02: UX-02 live verification via Playwright headless test. Add Playwright as dev dep, write headless test that loads printer form, types IP, asserts port auto-fill. Test IS the VALIDATION.md evidence.
  • 09-03: UX-03 per-script download links. Three distinct GET routes (/printers/{id}/scripts/install.ps1, /uninstall.ps1, /detect.ps1), reuse existing script generators, Content-Disposition: attachment, wire 3 <a role="button"> links into printer_detail.html.

UX-01 specifics:

  • Inline upload control inside printer_form.html (not a separate page flow)
  • HTMX OOB swap on POST /drivers/upload response — <select name="driver_id"> refreshed
  • Auto-select newly uploaded driver (overrides any prior selection — user-confirmed behavior)
  • TDD mandatory: failing test → red → fix → green → atomic commit
  • Fix the specific 500 root cause only; do not add global exception swallowing

UX-02 specifics:

  • Playwright headless test in tests/ (e2e subdir or similar)
  • pytest-playwright plugin; app started via uvicorn subprocess/thread fixture
  • chromium browser (default)
  • Test file path + green pytest run = VALIDATION.md evidence

UX-03 specifics:

  • Three distinct GET routes (not ?kind= param)
  • Reuse generators from imptune/services/ (same as package export)
  • Content-Type: text/plain; charset=utf-8, Content-Disposition: attachment; filename="<kind>.ps1"
  • New <h2>Scripts</h2> block in printer_detail.html above/below existing Export section
  • Existing package export buttons stay untouched

Claude's Discretion

  • Exact Alpine.js / HTMX OOB wiring syntax for the driver refresh
  • Whether to inline the upload control inside the printer form or stack it above
  • Playwright config file location and browser choice (chromium is default, fine)
  • Exact wording of the 3 download link labels
  • Whether the 3 new script routes live in imptune/api/printers.py or a new imptune/api/scripts.py
  • Test fixture format for the 500 repro (real driver ZIP vs. synthetic ZIP)

Deferred Ideas (OUT OF SCOPE)

  • Hardening /drivers/upload against unknown future exceptions (global try/except, structured error pages)
  • A "Download all scripts as ZIP" button alongside the 3 individual links
  • Playwright tests for anything beyond UX-02
  • Refactoring upload_driver into a service layer
  • Fixing /drivers/upload to return JSON for programmatic clients </user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
UX-01 After a new driver is uploaded on the printer form, the DriverDesc dropdown refreshes automatically (no manual page reload) — verified live in browser HTMX OOB swap pattern documented; existing upload_driver handler and printer form template inspected; OOB fragment wiring described
UX-02 PRNT-03 Alpine.js IP→port auto-derivation is verified live in a real browser session, with the verification recorded in VALIDATION.md Alpine.js @input handler already present in printer_form.html:14; Playwright + pytest-playwright integration pattern documented
UX-03 The printer detail page exposes direct download links for each generated script (install / uninstall / detect) in addition to the package export buttons Script routes ALREADY EXIST in imptune/api/scripts.py — only template links are missing; generator functions confirmed reusable
</phase_requirements>

Summary

Phase 9 closes three UX defects carried over from v1.0, plus one HTTP 500 bug blocking UX-01. All work is surgical: two of the three requirements touch existing files in limited ways, and one (UX-03) is nearly complete — the server-side routes already exist, only the template links are missing.

UX-01 requires the most work. The printer form has no inline upload control today; the upload endpoint works but throws HTTP 500 in at least one code path. The fix requires: (1) diagnosing and TDD-fixing the 500, (2) adding an inline <form> + file input to printer_form.html, (3) extending upload_driver to emit an HTMX OOB fragment that refreshes the driver <select> in the printer form, and (4) marking the new driver as selected. The HTMX OOB swap is the key mechanism: the response must include both the existing #driver-list fragment AND a second fragment with hx-swap-oob="true" targeting a stable id on the driver <select>.

UX-02 is a verification task only: the Alpine.js handler already exists and works (line 14 of printer_form.html). The requirement is to produce permanent machine-readable evidence by writing a Playwright headless test. No application code changes needed — only a new dev dependency and a new test file.

UX-03 is almost entirely done: imptune/api/scripts.py already implements the three GET routes (/printers/{id}/scripts/install, .../uninstall, .../detect) with correct Content-Disposition headers. The only gap is the URL shape (no .ps1 extension in current routes) and the missing template links in printer_detail.html. Decision: whether to rename routes to include .ps1 extension or keep as-is is a Claude's Discretion call — the .ps1 extension in URLs was specified in CONTEXT.md decisions, so new routes or route aliases must match.

Primary recommendation: Read imptune/api/scripts.py before implementing UX-03 — the routes exist, check if renaming vs. adding aliases is cleaner. For UX-01, reproduce the 500 in the test suite first before touching template code.


Standard Stack

Core (already in use — no new prod dependencies)

Library Version Purpose Why Standard
FastAPI 0.115.* HTTP routing, response types Project standard
HTMX (baked into /static) Partial HTML swaps, OOB swap Project standard for dynamic UI
Alpine.js (baked into /static) Inline reactive state (x-data, @input) Project standard for client-side reactivity
Jinja2 3.1.* HTML template rendering Project standard
Peewee 3.17.* ORM for Driver/Printer queries Project standard
pytest + httpx >=8.0 / >=0.27 API integration tests Project standard

New Dev Dependency (UX-02 only)

Library Version Purpose When to Use
pytest-playwright latest stable Playwright integration for pytest UX-02 headless browser test
playwright latest stable Browser automation (chromium) UX-02 headless browser test

Installation (dev only):

pip install pytest-playwright playwright
playwright install chromium

Add to requirements-dev.txt:

pytest>=8.0
httpx>=0.27
pytest-playwright
playwright

What Already Exists (Do Not Rebuild)

Problem Existing Solution Location
Script download routes (install/uninstall/detect) Already implemented imptune/api/scripts.py
Script generators render_install, render_uninstall, render_detect imptune/generators/script_generator.py
Driver upload handler upload_driver at POST /drivers/upload imptune/api/drivers.py
Alpine.js IP→port derivation @input handler on ip_address input printer_form.html:14
HTMX partial response pattern _error_response() + template response imptune/api/drivers.py, imptune/api/printers.py

Architecture Patterns

Pattern 1: HTMX OOB (Out-of-Band) Swap

What: HTMX allows a single response to update multiple DOM regions. The primary content targets the element specified in hx-target; additional hx-swap-oob="true" fragments in the same response are swapped into their respective id-matched DOM elements.

When to use: When a single user action (driver upload) needs to update two independent regions (the driver list on the current view AND the driver <select> in the printer form).

Key constraint: The OOB-swapped element MUST have a stable HTML id attribute in the page DOM. The printer form's <select name="driver_id"> currently has no id — it must be given one (e.g., id="printer-form-driver-select").

Response structure required:

<!-- Primary: updates hx-target="#driver-list" -->
<div id="driver-list">
  ... existing driver list content ...
</div>

<!-- OOB: updates #printer-form-driver-select anywhere in the page -->
<select name="driver_id" id="printer-form-driver-select" hx-swap-oob="true">
  <option value="">-- No driver --</option>
  {% for item in driver_data %}
  <option value="{{ item.driver.id }}"
          {% if item.driver.id == new_driver_id %}selected{% endif %}>
    {{ item.driver.original_filename }} ({{ item.names | join(', ') }})
  </option>
  {% endfor %}
</select>

How the handler knows it's called from the printer form: Check for HX-Target header value, or include a hidden sentinel field in the inline upload form (e.g., <input type="hidden" name="caller" value="printer_form">). The sentinel field is simpler and more explicit.

Source: HTMX documentation — hx-swap-oob attribute (HIGH confidence, direct HTMX docs concept, also in CONTEXT.md decisions)

Pattern 2: Inline Upload Form Within an Existing Form

What: The printer form is a single <form> element. The inline driver upload must NOT be nested inside that form (invalid HTML). It must be a separate <form> element, visually grouped near the driver <select>.

Correct approach:

<!-- In printer_form.html, AFTER closing the driver <label> block but still
     within the Alpine x-data div -->

<!-- Existing driver select label (with added id on the select) -->
<label>
  Driver
  <select name="driver_id" id="printer-form-driver-select">
    ...
  </select>
</label>

<!-- Separate upload form — NOT nested inside the printer <form> -->
<form hx-post="/drivers/upload"
      hx-target="#driver-list"
      hx-encoding="multipart/form-data"
      hx-swap="outerHTML">
  <input type="hidden" name="caller" value="printer_form">
  <input type="file" name="file" accept=".zip">
  <button type="submit">Upload Driver</button>
</form>

Anti-pattern: Nesting <form> inside <form> — browsers silently ignore the inner form; the upload will never fire.

Pattern 3: Playwright Test with App Fixture

What: pytest-playwright provides page and browser fixtures. The app must be running and accessible on a URL before the test can load pages. Use a pytest fixture that starts uvicorn in a background thread.

Fixture pattern:

# tests/e2e/conftest.py
import threading
import time
import pytest
import uvicorn
from imptune.main import app

@pytest.fixture(scope="session")
def live_server(tmp_path_factory):
    """Start the FastAPI app on a random port for E2E tests."""
    # Setup tmp data dir (similar to unit test conftest)
    ...
    config = uvicorn.Config(app, host="127.0.0.1", port=8765, log_level="error")
    server = uvicorn.Server(config)
    thread = threading.Thread(target=server.run, daemon=True)
    thread.start()
    time.sleep(0.5)  # Let server start
    yield "http://127.0.0.1:8765"
    server.should_exit = True

UX-02 test body:

def test_port_autofill(page, live_server):
    page.goto(f"{live_server}/printers")
    # Trigger printer form (HTMX-loaded partial or direct URL)
    page.fill("input[name='ip_address']", "192.168.1.100")
    # Alpine.js reacts synchronously on @input
    expected = "IP_192_168_1_100"
    assert page.input_value("input[name='port_name']") == expected

Source: pytest-playwright documentation (MEDIUM confidence — verified pattern from official docs concept; exact fixture API confirmed via library knowledge)

Pattern 4: Script Download Routes (UX-03 — Near Zero Work)

Existing state: imptune/api/scripts.py already provides:

  • GET /printers/{printer_id}/scripts/installContent-Disposition: attachment; filename="install.ps1"
  • GET /printers/{printer_id}/scripts/uninstallContent-Disposition: attachment; filename="uninstall.ps1"
  • GET /printers/{printer_id}/scripts/detectContent-Disposition: attachment; filename="detect.ps1"

Gap vs. CONTEXT.md decision: CONTEXT.md locked URLs include .ps1 extension in the path (e.g., /printers/{id}/scripts/install.ps1). Current routes do NOT have .ps1 in the path — they use /scripts/install without extension.

Resolution (Claude's Discretion): Two options:

  1. Add new routes with .ps1 extension alongside existing routes (minimal risk, no breakage)
  2. Rename existing routes (simpler, but technically a breaking change if anything already links to the old URLs — unlikely since there are no template links yet)

Option 1 (add aliases) is safer. The existing routes have no template links so breakage risk is zero either way, but aliases are unambiguous.

Template gap: printer_detail.html has NO links to any script download routes. The entire <h2>Scripts</h2> block needs to be added.

Anti-Patterns to Avoid

  • Nesting forms: Never put the inline driver upload <form> inside the printer <form> — browsers silently reject nested forms.
  • Using hx-swap-oob without a matching DOM id: The OOB target element must exist in the current page DOM with the exact matching id.
  • Returning OOB fragment on the /drivers page route: The /drivers page does NOT contain #printer-form-driver-select. The upload handler must detect its caller context and only emit the OOB fragment when called from the printer form.
  • Installing Playwright in the production image: Playwright headless browsers are large. Keep in requirements-dev.txt only. The Docker production image must not install Playwright.
  • Running Playwright tests in the standard unit test suite without a live server: pytest-playwright tests require a running HTTP server — they cannot use TestClient. Use a session-scoped live server fixture.

Don't Hand-Roll

Problem Don't Build Use Instead Why
OOB DOM updates after upload Custom JS to manually refresh the <select> HTMX hx-swap-oob Already in the project's HTMX bundle; no JS needed
Headless browser testing Selenium setup, manual browser control pytest-playwright Built-in pytest fixtures, automatic browser management
Script content generation New generator logic render_install, render_uninstall, render_detect in script_generator.py Already implemented, tested, and used by package export
Per-script download routes New API module imptune/api/scripts.py (already exists!) Routes already implemented — only template links missing

Common Pitfalls

Pitfall 1: The 500 — Unknown Root Cause (Must Reproduce First)

What goes wrong: POST /drivers/upload returns HTTP 500. The exact traceback is unknown at research time — it was reported during Phase 8 kickoff but not captured in CONTEXT.md.

Why it happens: Looking at drivers.py, the handler has no try/except around the critical path. Likely candidates:

  1. parse_inf() raises an unhandled exception for certain INF content
  2. DriverStore.save() raises a filesystem error (e.g., DRIVERS_DIR not created at call time in some edge case)
  3. Driver.get_or_create() raises a Peewee IntegrityError or similar ORM exception

How to avoid: The locked TDD process is the correct approach: reproduce first in a pytest case, then diagnose from the traceback. Do NOT guess the fix from code reading alone.

Warning signs: Test passes with synthetic INF but fails with real-world driver ZIPs — suggests parse_inf() chokes on real INF content (encoding edge cases, unusual section names).

Pitfall 2: HTMX OOB Fragment Injected on Wrong Page

What goes wrong: The upload handler emits the OOB <select> fragment even when called from the /drivers standalone page. The fragment is silently discarded by HTMX (no matching DOM id) but the presence of junk HTML in the response may cause unexpected behavior.

How to avoid: Gate the OOB fragment emission on the sentinel field: if request.form.get("caller") == "printer_form": emit_oob = True. Only include the OOB block in that branch.

Pitfall 3: Alpine.js x-data Scope and the Inline Upload Form

What goes wrong: The inline upload form is placed inside the printer form's Alpine x-data div. If upload state (e.g., uploading: false) needs to be tracked, it must be added to the x-data initialization object on the outer div — not declared in a nested Alpine component, which would create a separate reactive scope that can't interact with the parent form's ip/port variables.

How to avoid: Extend the existing x-data="{ ip: ..., port: ..., portEdited: ... }" declaration with upload state: x-data="{ ip: ..., port: ..., portEdited: ..., uploading: false }".

Pitfall 4: Playwright Test Startup Race

What goes wrong: The live server fixture starts uvicorn in a thread and immediately yields — the server may not be bound and listening before the first test navigates to a URL, causing connection refused.

How to avoid: Add a brief readiness poll after starting the server thread (e.g., retry GET /health up to 10 times with 100ms sleep). The existing GET /health endpoint is available for this purpose.

Pitfall 5: pytest-playwright Not Finding Chromium Binaries

What goes wrong: playwright install must be run separately from pip install playwright. A pip install alone does not download browser binaries.

How to avoid: Document playwright install chromium as a required setup step in the plan. In CI/CD this would be a setup step; for local dev, the implementing developer must run it once.

Pitfall 6: UX-03 — Confusing Existing Routes with CONTEXT.md URL Shape

What goes wrong: Developer reads imptune/api/scripts.py, sees the three routes exist, declares UX-03 done, forgets (a) the .ps1 extension in the URL was locked in CONTEXT.md, and (b) there are no template links yet.

How to avoid: Check both the route URL shape and the template. Two tasks: add URL aliases/renames if needed, and add template links.


Code Examples

OOB Swap in Upload Handler

# imptune/api/drivers.py — extended upload_driver response branch
# Source: HTMX OOB swap pattern + existing project codebase

from fastapi.responses import HTMLResponse

@router.post("/upload", response_class=HTMLResponse)
def upload_driver(request: Request, file: UploadFile) -> HTMLResponse:
    # ... existing validation and persistence logic ...

    # Build driver_data for template
    drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
    driver_data = []
    for d in drivers:
        names = json.loads(d.driver_desc) if d.driver_desc else []
        driver_data.append({"driver": d, "names": names})

    # Detect caller context via form field
    # (caller field included in inline upload form in printer_form.html)
    called_from_printer_form = False
    try:
        form_data = ...  # access via request if needed, or pass as Form() param
        called_from_printer_form = (form_data.get("caller") == "printer_form")
    except Exception:
        pass

    if called_from_printer_form:
        # Return primary fragment + OOB select fragment
        return templates.TemplateResponse(
            request=request,
            name="partials/driver_upload_with_oob.html",
            context={
                "driver_data": driver_data,
                "new_driver_id": new_driver.id,
                "parsed": parsed,
            },
        )
    else:
        # Existing behavior — driver list only
        return templates.TemplateResponse(
            request=request,
            name="partials/driver_list.html",
            context={"driver_data": driver_data, "parsed": parsed},
        )

Adding Sentinel Field to Inline Upload Form

<!-- In printer_form.html — separate <form> outside the printer <form> -->
<form hx-post="/drivers/upload"
      hx-target="#driver-list"
      hx-encoding="multipart/form-data"
      hx-swap="outerHTML"
      hx-indicator="#upload-indicator">
  <input type="hidden" name="caller" value="printer_form">
  <label>
    Upload Driver
    <input type="file" name="file" accept=".zip" required>
  </label>
  <button type="submit" :disabled="uploading">Upload</button>
  <span id="upload-indicator" class="htmx-indicator">Uploading…</span>
</form>

Adding id to the Existing Driver Select

<!-- printer_form.html:30 — add id attribute to the existing <select> -->
<select name="driver_id" id="printer-form-driver-select">
  <option value="">-- No driver --</option>
  {% for item in driver_data %}
  <option value="{{ item.driver.id }}"
          {% if printer and printer.driver_id == item.driver.id %}selected{% endif %}>
    {{ item.driver.original_filename }} ({{ item.names | join(', ') }})
  </option>
  {% endfor %}
</select>

OOB Partial Template Fragment

<!-- partials/driver_upload_with_oob.html (new file) -->

<!-- Primary swap target: #driver-list (from hx-target) -->
{% include "partials/driver_list.html" %}

<!-- OOB swap: refreshes driver select in printer form -->
<select name="driver_id" id="printer-form-driver-select" hx-swap-oob="true">
  <option value="">-- No driver --</option>
  {% for item in driver_data %}
  <option value="{{ item.driver.id }}"
          {% if item.driver.id == new_driver_id %}selected{% endif %}>
    {{ item.driver.original_filename }} ({{ item.names | join(', ') }})
  </option>
  {% endfor %}
</select>
<!-- printer_detail.html — new Scripts section, inside {% if has_driver %} -->
<h2>Scripts</h2>
<a href="/printers/{{ printer.id }}/scripts/install.ps1" role="button" class="secondary">
  Download Install Script
</a>
<a href="/printers/{{ printer.id }}/scripts/uninstall.ps1" role="button" class="secondary">
  Download Uninstall Script
</a>
<a href="/printers/{{ printer.id }}/scripts/detect.ps1" role="button" class="secondary">
  Download Detect Script
</a>

TDD Pattern for 500 Repro (Follow Existing Test Structure)

# tests/test_driver_upload.py — add regression test FIRST, before fixing

def test_upload_500_regression(client: TestClient) -> None:
    """POST /drivers/upload must not return 500 for a valid driver ZIP.
    
    This test was added to capture the repro of the HTTP 500 reported
    during Phase 8 kickoff (2026-04-13). It should go RED first, then
    GREEN after the fix is applied.
    """
    zip_bytes = _make_driver_zip()  # or use real driver ZIP that triggers the bug
    resp = client.post(
        "/drivers/upload",
        files={"file": ("driver.zip", zip_bytes, "application/zip")},
    )
    assert resp.status_code != 500, f"Upload returned 500: {resp.text}"
    assert resp.status_code == 200

State of the Art

Old Approach Current Approach Impact on Phase 9
Script routes did not exist imptune/api/scripts.py ships in v1.0 with all three routes UX-03 is mostly done — only template links missing
No per-script download links in template Template has Export section but no script links Add <h2>Scripts</h2> block to printer_detail.html
Alpine.js port handler untested Handler exists at printer_form.html:14, known-working UX-02 only needs a Playwright test as evidence
No inline driver upload in printer form v1.0 had separate /drivers upload flow UX-01 requires both the 500 fix and the inline upload addition

Key discovery — UX-03 near-complete: The three script download routes already exist in imptune/api/scripts.py, registered in imptune/main.py, with correct Content-Disposition headers. The only work is:

  1. Determine if URL shape needs .ps1 extension (CONTEXT.md says yes — /printers/{id}/scripts/install.ps1)
  2. Add template links in printer_detail.html

Current route URLs (/scripts/install) differ from locked decision URLs (/scripts/install.ps1). Claude's Discretion on whether to add aliases or rename.


Open Questions

  1. What triggers the HTTP 500 in POST /drivers/upload?

    • What we know: Reported during Phase 8 kickoff (2026-04-13); exact traceback not captured in planning docs
    • What's unclear: Whether it's parse_inf(), DriverStore.save(), or Driver.get_or_create() failing
    • Recommendation: The TDD plan (reproduce → diagnose → fix) is correct; do not guess the fix from code reading. The most likely candidates based on code inspection: (a) parse_inf() with unusual INF content, (b) DriverStore.save() with a missing directory in certain startup sequences.
  2. Passing caller field from an HTMX form — Form() parameter or body access?

    • What we know: FastAPI Form() parameters work for application/x-www-form-urlencoded and multipart/form-data
    • What's unclear: The upload endpoint uses UploadFile which is already multipart; adding caller: str = Form("") as a parameter alongside file: UploadFile should work with FastAPI's multipart handling
    • Recommendation: Add caller: str = Form("") parameter to upload_driver signature — FastAPI handles mixed multipart fields + files natively.
  3. Playwright live server fixture — thread vs. subprocess?

    • What we know: uvicorn can run in a thread via uvicorn.Server.run(); subprocess is more isolated but harder to share DB state
    • What's unclear: Whether the thread-based approach handles the Peewee SQLite connection properly in a test context (SQLite has per-thread connection behavior)
    • Recommendation: Use thread-based fixture but initialize a fresh in-memory or tmp SQLite DB for the E2E session, same pattern as conftest.py tmp_data_dir fixture. Alternatively, set up one driver + printer record before starting the server so the form has data to interact with.

Validation Architecture

workflow.nyquist_validation is true in .planning/config.json — this section is required.

Test Framework

Property Value
Framework pytest >= 8.0
Config file None — no pytest.ini or pyproject.toml detected
Quick run command pytest tests/ -x -q
Full suite command pytest tests/ -v
E2E run command pytest tests/e2e/ -v (after Playwright install)

Phase Requirements → Test Map

Req ID Behavior Test Type Automated Command File Exists?
UX-01 (500 fix) POST /drivers/upload never returns HTTP 500 for valid input unit/integration pytest tests/test_driver_upload.py::test_upload_500_regression -x Wave 0 — add test case
UX-01 (OOB swap) Upload response includes OOB <select> fragment when caller=printer_form integration pytest tests/test_driver_upload.py::test_upload_returns_oob_when_called_from_form -x Wave 0 — add test case
UX-01 (auto-select) OOB fragment marks newly uploaded driver as selected integration pytest tests/test_driver_upload.py::test_upload_oob_autoselects_new_driver -x Wave 0 — add test case
UX-02 input[name='port_name'] fills with IP_192_168_1_100 after typing IP e2e/browser pytest tests/e2e/test_port_autofill.py -v Wave 0 — create file
UX-03 (routes) GET /printers/{id}/scripts/install.ps1 returns 200 with Content-Disposition: attachment integration pytest tests/test_script_download.py -x Wave 0 — create file
UX-03 (template) printer_detail.html contains links to all 3 script download URLs integration pytest tests/test_packages.py::TestCommandPreview::test_detail_page_shows_script_links -x Wave 0 — add test case

Sampling Rate

  • Per task commit: pytest tests/ -x -q (skip e2e unless Playwright installed)
  • Per wave merge: pytest tests/ -v
  • Phase gate: Full suite green (including pytest tests/e2e/ -v) before /gsd:verify-work

Wave 0 Gaps

  • tests/test_driver_upload.py — add: test_upload_500_regression, test_upload_returns_oob_when_called_from_form, test_upload_oob_autoselects_new_driver
  • tests/e2e/conftest.py — live server fixture (uvicorn thread + tmp data dir)
  • tests/e2e/test_port_autofill.py — UX-02 Playwright test
  • tests/test_script_download.py — UX-03 route tests for .ps1 URL shape
  • tests/test_packages.py — add test_detail_page_shows_script_links to TestCommandPreview
  • Framework install: pip install pytest-playwright playwright && playwright install chromium

Sources

Primary (HIGH confidence)

  • Direct codebase inspection — imptune/api/drivers.py (upload handler, OOB target analysis)
  • Direct codebase inspection — imptune/api/scripts.py (existing script routes confirmed)
  • Direct codebase inspection — imptune/templates/partials/printer_form.html (Alpine x-data, driver select, no inline upload form)
  • Direct codebase inspection — imptune/templates/printer_detail.html (Export section, no script links)
  • Direct codebase inspection — imptune/generators/script_generator.py (generator function signatures)
  • Direct codebase inspection — tests/test_driver_upload.py, tests/conftest.py (test patterns)
  • Direct codebase inspection — requirements.txt, requirements-dev.txt (dependency baseline)
  • .planning/phases/09-ux-tech-debt-closure/09-CONTEXT.md (locked decisions)

Secondary (MEDIUM confidence)

  • HTMX hx-swap-oob documentation pattern — documented mechanism; confirmed consistent with HTMX version bundled in project static assets
  • pytest-playwright fixture pattern — standard plugin API; page fixture and browser fixture are stable

Tertiary (LOW confidence)

  • Root cause of the HTTP 500 — inferred from code reading (no traceback available); confirmed candidates but not verified against a live repro

Metadata

Confidence breakdown:

  • UX-03 scope: HIGH — routes exist, confirmed in code; only template + URL shape work remains
  • UX-02 scope: HIGH — Alpine handler confirmed in template; Playwright pattern is standard
  • UX-01 scope: HIGH for template/HTMX work; MEDIUM for 500 root cause (traceback not available)
  • Standard stack: HIGH — verified by direct file inspection
  • Architecture patterns: HIGH — grounded in existing codebase conventions
  • Pitfalls: HIGH — derived from actual code gaps found during inspection

Research date: 2026-04-13 Valid until: 2026-05-13 (stable codebase; only changes if v1.0 files are modified)