docs(09): create phase plan for UX tech debt closure

Three plans covering UX-01 (driver upload 500 fix + inline HTMX OOB
refresh on printer form), UX-02 (Playwright headless test for PRNT-03
IP->port auto-fill), and UX-03 (.ps1 script download routes + detail
page links). VALIDATION.md finalized with real task IDs and
nyquist_compliant=true. ROADMAP Phase 9 plan list filled in.
This commit is contained in:
2026-04-13 10:29:43 +02:00
parent 84a350f233
commit 71c808601c
5 changed files with 757 additions and 28 deletions
+5 -2
View File
@@ -49,7 +49,10 @@ Full details: [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROADMAP.md)
1. A technician uploading a new driver on the printer form sees the new DriverDesc appear in the dropdown without manually reloading the page 1. A technician uploading a new driver on the printer form sees the new DriverDesc appear in the dropdown without manually reloading the page
2. A technician typing an IP address into the printer form sees the port field auto-populate via the PRNT-03 Alpine.js handler, observed live in a real browser and recorded in VALIDATION.md 2. A technician typing an IP address into the printer form sees the port field auto-populate via the PRNT-03 Alpine.js handler, observed live in a real browser and recorded in VALIDATION.md
3. A technician on the printer detail page can click direct download links for the install, uninstall, and detect scripts individually, in addition to the existing package export buttons 3. A technician on the printer detail page can click direct download links for the install, uninstall, and detect scripts individually, in addition to the existing package export buttons
**Plans**: TBD **Plans**: 3 plans
- [ ] 09-01-driver-upload-fix-and-inline-oob-PLAN.md — Fix POST /drivers/upload 500 + add inline upload to printer form with HTMX OOB refresh (UX-01)
- [ ] 09-02-playwright-port-autofill-PLAN.md — Add Playwright dev dep + headless test for PRNT-03 IP->port auto-fill (UX-02)
- [ ] 09-03-script-download-links-PLAN.md — Add .ps1 route aliases + printer_detail.html script download links (UX-03)
### Phase 10: Real-World Runtime Validation ### Phase 10: Real-World Runtime Validation
**Goal**: Generated .intunewin, install, detect, and uninstall artifacts are proven to work end-to-end on a real Intune tenant against a real Windows endpoint, with evidence recorded. **Goal**: Generated .intunewin, install, detect, and uninstall artifacts are proven to work end-to-end on a real Intune tenant against a real Windows endpoint, with evidence recorded.
@@ -86,6 +89,6 @@ Full details: [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROADMAP.md)
| 6. Wire Icon into .intunewin | v1.0 | 1/1 | Complete | 2026-04-10 | | 6. Wire Icon into .intunewin | v1.0 | 1/1 | Complete | 2026-04-10 |
| 7. Dashboard & Nav Polish | v1.0 | 1/1 | Complete | 2026-04-13 | | 7. Dashboard & Nav Polish | v1.0 | 1/1 | Complete | 2026-04-13 |
| 8. Nyquist Validation Track | v1.1 | 0/? | Not started | — | | 8. Nyquist Validation Track | v1.1 | 0/? | Not started | — |
| 9. UX Tech Debt Closure | v1.1 | 0/? | Not started | — | | 9. UX Tech Debt Closure | v1.1 | 0/3 | Planned | — |
| 10. Real-World Runtime Validation | v1.1 | 0/? | Not started | — | | 10. Real-World Runtime Validation | v1.1 | 0/? | Not started | — |
| 11. Real-World Rollout & Feedback | v1.1 | 0/? | Not started | — | | 11. Real-World Rollout & Feedback | v1.1 | 0/? | Not started | — |
@@ -0,0 +1,262 @@
---
phase: 09-ux-tech-debt-closure
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- tests/test_driver_upload.py
- imptune/api/drivers.py
- imptune/templates/partials/driver_list.html
- imptune/templates/partials/driver_upload_with_oob.html
- imptune/templates/partials/printer_form.html
autonomous: true
requirements: [UX-01]
must_haves:
truths:
- "POST /drivers/upload never returns HTTP 500 for a valid driver ZIP"
- "Uploading a driver from the printer form refreshes the driver <select> via HTMX OOB swap without a page reload"
- "The newly uploaded driver is auto-selected in the refreshed <select>"
- "Uploading from the standalone /drivers page still returns only the #driver-list fragment (no OOB noise)"
artifacts:
- path: "tests/test_driver_upload.py"
provides: "Regression test for the 500 + OOB contract tests"
contains: "test_upload_500_regression"
- path: "imptune/api/drivers.py"
provides: "Fixed upload_driver handler with caller-aware OOB branch"
contains: "caller"
- path: "imptune/templates/partials/driver_upload_with_oob.html"
provides: "Template emitting primary driver_list fragment + OOB <select>"
contains: "hx-swap-oob"
- path: "imptune/templates/partials/printer_form.html"
provides: "Driver <select> has stable id + separate inline upload form"
contains: "printer-form-driver-select"
key_links:
- from: "imptune/templates/partials/printer_form.html"
to: "POST /drivers/upload"
via: "separate <form hx-post=/drivers/upload> with hidden caller=printer_form field"
pattern: 'name="caller"\s+value="printer_form"'
- from: "imptune/api/drivers.py (upload_driver)"
to: "partials/driver_upload_with_oob.html"
via: "TemplateResponse when caller == 'printer_form'"
pattern: 'driver_upload_with_oob\.html'
- from: "partials/driver_upload_with_oob.html"
to: "printer_form.html #printer-form-driver-select"
via: 'hx-swap-oob="true" on <select id="printer-form-driver-select">'
pattern: 'hx-swap-oob="true"'
---
<objective>
Fix the HTTP 500 on `POST /drivers/upload` (blocking UX-01), then add an inline driver upload form inside the printer form template that, on success, refreshes the driver `<select>` via HTMX Out-of-Band swap and auto-selects the newly uploaded driver.
Purpose: Closes UX-01 — technicians uploading a driver while creating/editing a printer see it appear in the dropdown and get it auto-selected, no manual reload.
Output: Green regression + OOB tests, a working inline upload control, and an OOB-capable upload handler.
</objective>
<execution_context>
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/09-ux-tech-debt-closure/09-CONTEXT.md
@.planning/phases/09-ux-tech-debt-closure/09-RESEARCH.md
@.planning/phases/09-ux-tech-debt-closure/09-VALIDATION.md
@imptune/api/drivers.py
@imptune/templates/partials/printer_form.html
@imptune/templates/partials/driver_list.html
@tests/test_driver_upload.py
@tests/conftest.py
<interfaces>
<!-- Key contracts this plan operates on. Use these directly; no codebase exploration needed. -->
From imptune/api/drivers.py:
```python
router = APIRouter(prefix="/drivers") # mounted at /drivers in main.py
MAX_UPLOAD_BYTES = 100 * 1024 * 1024
def _error_response(message: str, status_code: int = 400) -> HTMLResponse: ...
@router.post("/upload", response_class=HTMLResponse)
def upload_driver(request: Request, file: UploadFile) -> HTMLResponse: ...
# Validates zip, parses INF via parse_inf, persists via DriverStore(_cfg.DRIVERS_DIR),
# upserts Driver via Driver.get_or_create(sha256=..., defaults={...}),
# returns TemplateResponse("partials/driver_list.html", {driver_data, parsed}).
# There is NO try/except around parse_inf / DriverStore.save / Driver.get_or_create —
# any of these can bubble into a FastAPI 500.
```
From imptune/db/models.Driver: fields include id, sha256, original_filename, driver_desc (JSON list), inf_filename, architecture, has_cat_file, uploaded_at.
From imptune/services/inf_parser: `parse_inf(inf_text, inf_filename, zip_names) -> ParsedInf` with attributes `driver_names: list[str]`, `inf_filename: str`, `architecture: str | None`, `has_cat_file: bool`.
Current printer_form.html driver select (lines 28-39):
```html
<label>
Driver
<select name="driver_id"> <!-- NO id attribute today -->
<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>
</label>
```
HTMX OOB contract: response body contains the primary swap fragment (targets `#driver-list`) PLUS one or more sibling elements with `hx-swap-oob="true"` whose `id` matches an element in the current page DOM. Out-of-band elements MUST be top-level in the response body (not nested inside the primary fragment).
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Wave 0 — write failing driver-upload regression + OOB contract tests</name>
<files>tests/test_driver_upload.py</files>
<behavior>
- test_upload_500_regression: POST /drivers/upload with a valid driver ZIP fixture MUST return status_code != 500. Use the existing test fixture pattern from tests/conftest.py (tmp_data_dir) and the same synthetic driver ZIP builder already used in this test module if present; otherwise create `_make_driver_zip()` helper that writes a minimal valid INF + `.cat` file into a ZIP. Assertion: `assert resp.status_code == 200, resp.text`.
- test_upload_returns_oob_when_called_from_form: POST /drivers/upload with multipart fields `{file: valid_zip, caller: "printer_form"}` MUST return 200 AND the response body MUST contain `hx-swap-oob="true"` AND `id="printer-form-driver-select"`.
- test_upload_oob_autoselects_new_driver: Same call as above — response body MUST contain the newly created driver's `<option value="{new_id}" selected>` inside the OOB `<select>`. Parse the new id from the response or query the DB post-upload and assert the selected marker appears on that option.
- test_upload_no_oob_from_standalone_drivers_page: POST /drivers/upload with `{file: valid_zip}` and NO `caller` field MUST return the existing `#driver-list` fragment and MUST NOT contain `hx-swap-oob`. This guards against OOB junk leaking into the standalone /drivers page.
</behavior>
<action>
Open `tests/test_driver_upload.py`. Add the four test functions above following the existing httpx TestClient pattern (see `tests/conftest.py` for `client` fixture). Reuse any existing driver-zip helper in the module; if none exists, create `_make_driver_zip() -> bytes` that builds a minimal ZIP with a real `.inf` body (encoding utf-8) whose `[Version]` section declares `Signature="$Windows NT$"` and a single `[Strings]` entry so `parse_inf` returns at least one driver name. Include a `.cat` sibling so `has_cat_file` is True.
Run the tests. At least `test_upload_500_regression` MAY pass or fail depending on synthetic fixture vs. real-world root cause — if it still passes with a synthetic ZIP, ALSO add a parametrized variant that feeds a ZIP containing an INF with a BOM + Windows-1252 encoded `[Strings]` section (the most likely real-world repro per 09-RESEARCH.md pitfall 1). At least one variant MUST go red before proceeding to Task 2.
The other three OOB tests MUST go red — the current handler has no `caller` support and no OOB template.
Commit: `test(09-01): add failing driver upload 500 regression + OOB contract tests`
</action>
<verify>
<automated>pytest tests/test_driver_upload.py::test_upload_500_regression tests/test_driver_upload.py::test_upload_returns_oob_when_called_from_form tests/test_driver_upload.py::test_upload_oob_autoselects_new_driver -x</automated>
</verify>
<done>All four new tests exist in tests/test_driver_upload.py. At least one test is RED (the intended failure). Failing test output captured in commit message or task notes so Task 2 has the traceback.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Fix the 500 + extend upload_driver with caller-aware OOB branch</name>
<files>imptune/api/drivers.py, imptune/templates/partials/driver_upload_with_oob.html, imptune/templates/partials/driver_list.html</files>
<behavior>
- All four tests from Task 1 MUST go GREEN.
- `pytest tests/test_driver_upload.py -x` passes fully (no regressions on existing tests).
- `pytest tests/ -x -q --ignore=tests/e2e` passes.
</behavior>
<action>
Step 1 — Diagnose the 500 from the Task 1 red test traceback. Likely candidates per 09-RESEARCH.md pitfall 1: `parse_inf()` choking on encoding, `DriverStore.save()` on missing DRIVERS_DIR, or `Driver.get_or_create()` on constraint. Fix the SPECIFIC root cause only — do NOT wrap the whole handler in `try/except Exception`. If it's `parse_inf`, fix the parser; if it's `DriverStore.save`, ensure the dir exists before writing; if it's ORM, fix the field.
Step 2 — Add `caller: str = Form("")` parameter to `upload_driver(request, file, caller="")` (import `Form` from fastapi). FastAPI handles mixed multipart `UploadFile` + `Form` fields natively.
Step 3 — After the existing success path builds `driver_data`, capture `new_driver` from the `get_or_create` return tuple: `new_driver, _created = Driver.get_or_create(...)`. Currently the code discards this — fix it.
Step 4 — Branch on `caller`:
```python
if caller == "printer_form":
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 unchanged
return templates.TemplateResponse(
request=request,
name="partials/driver_list.html",
context={"driver_data": driver_data, "parsed": parsed},
)
```
Step 5 — Create `imptune/templates/partials/driver_upload_with_oob.html`:
```jinja
{% include "partials/driver_list.html" %}
<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>
```
Step 6 — Inspect `partials/driver_list.html`. Confirm its root element has `id="driver-list"` (hx-target from the inline upload form will swap it). If the partial currently wraps itself differently, leave as-is; the OOB template simply includes it. Do NOT refactor driver_list.html unless necessary.
Step 7 — Run the failing tests. Iterate until GREEN. Then run full non-e2e suite.
Commit: `fix(09-01): resolve driver upload 500 and add HTMX OOB refresh path`
</action>
<verify>
<automated>pytest tests/test_driver_upload.py -x -v && pytest tests/ -x -q --ignore=tests/e2e</automated>
</verify>
<done>All Task 1 tests green. Full non-e2e suite green. `upload_driver` accepts a `caller` form field and returns OOB-enabled response only when caller=="printer_form". New partial file exists.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Wire inline driver upload form into printer_form.html</name>
<files>imptune/templates/partials/printer_form.html, tests/test_printer_form.py</files>
<behavior>
- printer_form.html renders a separate inline `<form hx-post="/drivers/upload">` OUTSIDE the main printer `<form>` but inside the Alpine x-data wrapper div.
- The driver `<select>` has `id="printer-form-driver-select"` (required OOB target).
- The inline upload form posts `caller=printer_form` as a hidden field and `file` as the upload.
- `pytest tests/test_printer_form.py` passes (add one assertion: rendered HTML contains `id="printer-form-driver-select"` and contains `name="caller" value="printer_form"` and an inline `hx-post="/drivers/upload"` NOT nested inside `<form hx-post="/printers"`).
</behavior>
<action>
Step 1 — Edit `imptune/templates/partials/printer_form.html`:
- Add `id="printer-form-driver-select"` attribute to the existing `<select name="driver_id">` (line 30).
- AFTER the closing `</form>` of the printer form (line 85) but BEFORE the closing `</div>` of the x-data wrapper (line 86), add a separate inline upload form:
```html
<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">
<label>
Upload New Driver
<input type="file" name="file" accept=".zip" required>
</label>
<button type="submit" class="secondary">Upload Driver</button>
</form>
```
- CRITICAL: Do NOT nest this form inside the printer `<form>` — HTML forbids nested forms and browsers silently drop the inner one. Place it as a sibling, still within the outer `<div x-data="...">` so visual grouping and Alpine scope are preserved.
- Also ensure `partials/driver_list.html` (or wherever the `#driver-list` anchor lives) is reachable from the page that renders printer_form.html. If the printer form page doesn't currently include a `<div id="driver-list">` anchor, add a hidden one next to the upload form: `<div id="driver-list" style="display:none"></div>` so the primary HTMX swap target exists even on the printer form page. Alternatively render the full driver_list partial for visibility (preferred if space allows — shows technician the uploaded driver landed).
Step 2 — Extend `tests/test_printer_form.py` with `test_printer_form_has_inline_driver_upload`:
- GET the printer form route (`/printers/new` or the HTMX partial route used by the existing tests — match the existing pattern in this test file).
- Assert response body contains `id="printer-form-driver-select"`.
- Assert response body contains `name="caller"` with value `printer_form`.
- Assert response body contains `hx-post="/drivers/upload"`.
- Assert nested form check: the substring between `<form hx-post="/printers"` and its matching `</form>` does NOT contain `hx-post="/drivers/upload"` (naive check is fine: split on `</form>` and verify the printer form chunk is clean).
Step 3 — Run the test. GREEN.
Commit: `feat(09-01): add inline driver upload to printer form with OOB refresh`
</action>
<verify>
<automated>pytest tests/test_printer_form.py -x -v && pytest tests/ -x -q --ignore=tests/e2e</automated>
</verify>
<done>Printer form template contains stable-id driver select + sibling inline upload form with caller sentinel. test_printer_form.py guards the wiring. Full non-e2e suite green.</done>
</task>
</tasks>
<verification>
- `pytest tests/ -x -q --ignore=tests/e2e` passes
- Manual eye check (captured in 09-VALIDATION.md manual section): start app, open printer form, upload a real driver ZIP, confirm driver list refreshes AND the new driver becomes the selected option in the dropdown without a page reload
</verification>
<success_criteria>
- UX-01 observable truth #1 achieved: technician uploading a driver on the printer form sees new DriverDesc in the dropdown and auto-selected, no reload
- No HTTP 500 from `POST /drivers/upload` for the captured repro case
- All 4 new tests (500 regression, OOB contract, auto-select, no-oob-on-standalone) green
</success_criteria>
<output>
After completion, create `.planning/phases/09-ux-tech-debt-closure/09-01-SUMMARY.md` documenting: actual root cause of the 500, files changed, test results, and link to commits.
</output>
@@ -0,0 +1,245 @@
---
phase: 09-ux-tech-debt-closure
plan: 02
type: execute
wave: 2
depends_on: ["09-01"]
files_modified:
- requirements-dev.txt
- tests/e2e/__init__.py
- tests/e2e/conftest.py
- tests/e2e/test_port_autofill.py
autonomous: true
requirements: [UX-02]
must_haves:
truths:
- "A headless chromium browser loads the printer form, types an IP, and observes the port_name input auto-populate with IP_<dotted_underscore>"
- "The Playwright test file path is the cited evidence for UX-02 in 09-VALIDATION.md"
- "The e2e suite runs in isolation from unit tests via --ignore path and has its own live server fixture"
artifacts:
- path: "requirements-dev.txt"
provides: "pytest-playwright + playwright dev deps"
contains: "pytest-playwright"
- path: "tests/e2e/conftest.py"
provides: "Session-scoped live_server fixture (uvicorn in thread) with tmp data dir + /health readiness poll"
contains: "live_server"
- path: "tests/e2e/test_port_autofill.py"
provides: "UX-02 Playwright headless test"
contains: "test_port_autofill"
key_links:
- from: "tests/e2e/test_port_autofill.py"
to: "imptune.main:app (uvicorn thread)"
via: "live_server fixture yields http://127.0.0.1:<port>"
pattern: "live_server"
- from: "tests/e2e/test_port_autofill.py"
to: "printer_form.html Alpine @input handler"
via: "page.fill on ip_address, assert on port_name input_value"
pattern: "port_name"
---
<objective>
Add Playwright as a dev dependency and write a headless chromium test that loads the printer form, types an IP address, and asserts the port_name field auto-populates via the existing Alpine.js `@input` handler. The test file itself becomes the permanent evidence for UX-02.
Purpose: Closes UX-02 — produces a live-browser-verified, regression-guarded record of PRNT-03 port auto-derivation.
Output: Installable dev env (`pip install -r requirements-dev.txt && playwright install chromium`), green e2e test.
Depends on 09-01 because 09-01 modifies printer_form.html (adds stable id + inline upload form) and the Playwright test must run against that final template — running it on the pre-09-01 template would bake in stale assertions.
</objective>
<execution_context>
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/09-ux-tech-debt-closure/09-CONTEXT.md
@.planning/phases/09-ux-tech-debt-closure/09-RESEARCH.md
@.planning/phases/09-ux-tech-debt-closure/09-VALIDATION.md
@imptune/templates/partials/printer_form.html
@imptune/main.py
@tests/conftest.py
@requirements-dev.txt
<interfaces>
<!-- Key contracts for this plan -->
From imptune/main.py: exports `app: FastAPI`. Has a `GET /health` endpoint suitable for readiness polling.
Alpine handler already present in printer_form.html:
```html
<input type="text" name="ip_address"
x-model="ip"
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')" ...>
<input type="text" name="port_name" x-model="port" ...>
```
Typing `192.168.1.100` into ip_address produces `IP_192_168_1_100` in port_name.
Printer form route: served as an HTMX partial or full page. The e2e test must hit a route that renders printer_form.html top-level so Alpine loads. Check imptune/main.py / routers for the GET route — most likely `/printers/new` or `/printers` with a "new" partial. Confirm at implementation time.
Playwright/pytest-playwright basics:
- Plugin auto-provides `page` fixture.
- `page.goto(url)` — navigate
- `page.fill(selector, value)` — fill an input (triggers `input` event, which Alpine `@input` listens to)
- `page.input_value(selector)` — read current value of an input
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add Playwright dev deps + e2e package scaffolding</name>
<files>requirements-dev.txt, tests/e2e/__init__.py, tests/e2e/conftest.py</files>
<action>
Step 1 — Append to `requirements-dev.txt`:
```
pytest-playwright
playwright
```
Do NOT touch `requirements.txt` — production image must not install Playwright.
Step 2 — Create empty `tests/e2e/__init__.py`.
Step 3 — Create `tests/e2e/conftest.py` with a session-scoped `live_server` fixture:
```python
"""E2E test fixtures: live uvicorn server for Playwright."""
from __future__ import annotations
import socket
import threading
import time
from pathlib import Path
import httpx
import pytest
import uvicorn
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
@pytest.fixture(scope="session")
def live_server(tmp_path_factory):
"""Start the FastAPI app on a random port in a background thread."""
# Isolated data dir for E2E session
data_dir = tmp_path_factory.mktemp("imptune_e2e_data")
import imptune.config as _cfg
_cfg.DATA_DIR = data_dir
_cfg.DRIVERS_DIR = data_dir / "drivers"
_cfg.DRIVERS_DIR.mkdir(parents=True, exist_ok=True)
# Re-init DB against the tmp dir — follow the same pattern tests/conftest.py uses
from imptune.db.models import init_db # adapt import if name differs
init_db(data_dir / "imptune.db")
from imptune.main import app
port = _free_port()
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
server = uvicorn.Server(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
base_url = f"http://127.0.0.1:{port}"
# Readiness poll via /health (up to 5 s)
deadline = time.time() + 5.0
while time.time() < deadline:
try:
r = httpx.get(f"{base_url}/health", timeout=0.5)
if r.status_code == 200:
break
except Exception:
pass
time.sleep(0.1)
else:
raise RuntimeError("live_server did not become ready within 5 s")
yield base_url
server.should_exit = True
thread.join(timeout=2.0)
```
If `imptune/main.py` does NOT expose `GET /health`, either (a) add a trivial `@app.get("/health") def health(): return {"ok": True}` in main.py, or (b) poll the printer list route. Prefer adding /health because 09-RESEARCH.md references it.
Adapt imports if `init_db` / config names differ — match the exact pattern already used in `tests/conftest.py`. This is a straight port of the existing unit-test fixture into a session-scoped uvicorn variant.
Step 4 — Run `pip install -r requirements-dev.txt` then `playwright install chromium` in the dev environment.
</action>
<verify>
<automated>python -c "import pytest_playwright, playwright; print('playwright ok')" &amp;&amp; pytest --collect-only tests/e2e/ 2&gt;&amp;1 | head -20</automated>
</verify>
<done>pytest-playwright + playwright on requirements-dev.txt. chromium binary installed. tests/e2e/ package exists with live_server fixture. `pytest --collect-only tests/e2e/` reports 0 tests with no import errors.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Write UX-02 Playwright test for IP→port auto-fill</name>
<files>tests/e2e/test_port_autofill.py</files>
<behavior>
- Loads the printer form page in chromium.
- Fills `input[name='ip_address']` with `192.168.1.100`.
- Asserts `input[name='port_name']` input value equals `IP_192_168_1_100`.
- Test passes (Alpine handler already exists and is known-working).
</behavior>
<action>
Create `tests/e2e/test_port_autofill.py`:
```python
"""UX-02: live-browser verification of PRNT-03 Alpine IP→port auto-derivation."""
from __future__ import annotations
import pytest
def test_port_autofill(page, live_server: str) -> None:
# Navigate to the route that renders printer_form.html as a full page.
# CONFIRM the exact path at implementation time — candidates:
# /printers/new | /printers (with HTMX modal) | /printers/form
# Pick the one that renders the Alpine x-data wrapper top-level.
page.goto(f"{live_server}/printers/new", wait_until="domcontentloaded")
# Wait for Alpine to initialise (x-data hydration)
page.wait_for_selector("input[name='ip_address']")
page.fill("input[name='ip_address']", "192.168.1.100")
# Alpine @input reacts synchronously; a short wait keeps test stable
page.wait_for_function(
"document.querySelector(\"input[name='port_name']\").value === 'IP_192_168_1_100'",
timeout=2000,
)
assert page.input_value("input[name='port_name']") == "IP_192_168_1_100"
```
Verify the printer form route name by reading `imptune/main.py` / `imptune/api/printers.py` first. If no full-page route exists and printer_form.html is only rendered as an HTMX partial, ADD a minimal GET route (e.g., `/printers/new`) that returns a full page rendering of the form (extend base.html, include printer_form.html). This is the smallest possible change and matches the user-facing flow described in 09-CONTEXT.md (technician opens the printer form).
Run: `pytest tests/e2e/test_port_autofill.py -v` — must be GREEN.
Commit: `test(09-02): add Playwright UX-02 port autofill test`
</action>
<verify>
<automated>pytest tests/e2e/test_port_autofill.py -v</automated>
</verify>
<done>Playwright test green. tests/e2e/test_port_autofill.py file path cited as evidence for UX-02 in 09-VALIDATION.md.</done>
</task>
</tasks>
<verification>
- `pytest tests/e2e/ -v` passes
- `pytest tests/ -x -q --ignore=tests/e2e` still passes (no unit regressions)
- Optional live eyeball: `pytest tests/e2e/test_port_autofill.py -v --headed` to observe the auto-fill in a visible browser window
</verification>
<success_criteria>
- UX-02 observable truth achieved: typing an IP into the printer form auto-populates port_name, verified in a real (headless) chromium session
- tests/e2e/test_port_autofill.py file path is the cited VALIDATION.md evidence
- No Playwright dependency in production image (only requirements-dev.txt)
</success_criteria>
<output>
After completion, create `.planning/phases/09-ux-tech-debt-closure/09-02-SUMMARY.md` with: exact printer form route used, pytest command run, green output snippet, and 09-VALIDATION.md citation update.
</output>
@@ -0,0 +1,217 @@
---
phase: 09-ux-tech-debt-closure
plan: 03
type: execute
wave: 1
depends_on: []
files_modified:
- imptune/api/scripts.py
- imptune/templates/printer_detail.html
- tests/test_script_download.py
- tests/test_packages.py
autonomous: true
requirements: [UX-03]
must_haves:
truths:
- "GET /printers/{id}/scripts/install.ps1 returns 200 with Content-Disposition: attachment; filename=install.ps1 and a non-empty PowerShell body"
- "GET /printers/{id}/scripts/uninstall.ps1 returns 200 with attachment disposition and uninstall content"
- "GET /printers/{id}/scripts/detect.ps1 returns 200 with attachment disposition and detect content"
- "printer_detail.html renders three direct download links for install/uninstall/detect in addition to existing package export buttons"
artifacts:
- path: "imptune/api/scripts.py"
provides: "Three new .ps1 route aliases alongside existing extensionless routes"
contains: "scripts/install.ps1"
- path: "imptune/templates/printer_detail.html"
provides: "Scripts section with 3 direct download <a role=button> links"
contains: "scripts/install.ps1"
- path: "tests/test_script_download.py"
provides: "Integration tests for the 3 new .ps1 routes"
contains: "test_install_ps1_route"
key_links:
- from: "imptune/templates/printer_detail.html"
to: "GET /printers/{id}/scripts/{install,uninstall,detect}.ps1"
via: '<a href="/printers/{{printer.id}}/scripts/install.ps1" role="button">'
pattern: 'scripts/(install|uninstall|detect)\.ps1'
- from: "imptune/api/scripts.py (.ps1 aliases)"
to: "imptune/generators/script_generator.render_{install,uninstall,detect}"
via: "delegation to the same handler logic as the existing extensionless routes"
pattern: "render_install|render_uninstall|render_detect"
---
<objective>
Add three `.ps1`-suffixed route aliases (`/printers/{id}/scripts/install.ps1`, `uninstall.ps1`, `detect.ps1`) alongside the existing extensionless routes in `imptune/api/scripts.py`, and wire three direct-download links into `printer_detail.html` next to the existing package export buttons.
Purpose: Closes UX-03 — technicians can download each script individually from the printer detail page without going through the package export flow.
Output: Three new API routes, three template links, two test cases.
Independent of 09-01 (no shared files). Can run in Wave 1 parallel with 09-01.
</objective>
<execution_context>
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/phases/09-ux-tech-debt-closure/09-CONTEXT.md
@.planning/phases/09-ux-tech-debt-closure/09-RESEARCH.md
@.planning/phases/09-ux-tech-debt-closure/09-VALIDATION.md
@imptune/api/scripts.py
@imptune/templates/printer_detail.html
@imptune/generators/script_generator.py
@tests/test_packages.py
<interfaces>
<!-- Existing contracts this plan extends -->
From imptune/api/scripts.py:
```python
router = APIRouter(prefix="/printers")
def _get_printer_and_driver(printer_id: int):
"""Returns ((printer, driver, driver_name), None) on success or (None, PlainTextResponse) on error."""
...
@router.get("/{printer_id}/scripts/install")
def get_install_script(printer_id: int):
# validates, calls render_install(...), returns PlainTextResponse with
# Content-Disposition: attachment; filename="install.ps1"
...
@router.get("/{printer_id}/scripts/uninstall") # similar
@router.get("/{printer_id}/scripts/detect") # similar
```
From imptune/generators/script_generator:
`render_install(printer_name, ip_address, port_name, driver_name, inf_filename, duplex_mode, color_mode, paper_size, collate) -> str`
`render_uninstall(printer_name, driver_name, port_name) -> str`
`render_detect(printer_name) -> str`
Existing printer_detail.html Export section (lines 48-51):
```html
<h2>Export</h2>
<a href="/printers/{{ printer.id }}/packages/ninja" role="button">Download NinjaRMM ZIP</a>
<a href="/printers/{{ printer.id }}/packages/intunewin" role="button">Download .intunewin</a>
```
Guard: the Export section is wrapped in `{% if has_driver %}` — the new Scripts section must be inside the same guard (no scripts without a driver).
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Wave 0 — failing tests for .ps1 routes + detail page script links</name>
<files>tests/test_script_download.py, tests/test_packages.py</files>
<behavior>
- test_install_ps1_route: GET /printers/{id}/scripts/install.ps1 with a printer that has a driver assigned returns 200, `Content-Disposition` contains `attachment; filename="install.ps1"`, body is non-empty and starts with a PowerShell-ish marker (e.g., contains `Add-Printer` or `$PSScriptRoot`).
- test_uninstall_ps1_route: same for /scripts/uninstall.ps1 — contains `Remove-Printer`.
- test_detect_ps1_route: same for /scripts/detect.ps1 — contains `Get-Printer`.
- test_ps1_routes_missing_printer: GET /printers/99999/scripts/install.ps1 returns 404.
- test_ps1_routes_no_driver: printer without a driver returns 422 (matches `_get_printer_and_driver` contract).
- test_detail_page_shows_script_links (added to TestCommandPreview class in tests/test_packages.py): GET printer detail page for a printer with a driver MUST contain the three href substrings `/printers/{id}/scripts/install.ps1`, `.../uninstall.ps1`, `.../detect.ps1`.
</behavior>
<action>
Step 1 — Create `tests/test_script_download.py`. Use the existing `client` fixture and the same printer+driver setup pattern used by `tests/test_packages.py::TestCommandPreview`. Reference that file for the exact fixture / seed-data recipe.
Step 2 — Add `test_detail_page_shows_script_links` to `TestCommandPreview` (or a sibling class if more natural) in `tests/test_packages.py`. It should seed a printer with a driver, GET `/printers/{id}`, and assert the three `.ps1` href substrings.
Step 3 — Run tests. All new tests MUST go RED (routes don't exist, template links don't exist).
Commit: `test(09-03): add failing .ps1 route and detail-page link tests`
</action>
<verify>
<automated>pytest tests/test_script_download.py tests/test_packages.py::TestCommandPreview::test_detail_page_shows_script_links -x</automated>
</verify>
<done>All 6 new tests exist and go RED. Failing output proves routes + links are missing.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Add .ps1 route aliases + printer_detail.html script links</name>
<files>imptune/api/scripts.py, imptune/templates/printer_detail.html</files>
<behavior>
- All 6 tests from Task 1 go GREEN.
- `pytest tests/ -x -q --ignore=tests/e2e` passes with no regressions.
- Existing extensionless `/scripts/install` routes continue to work unchanged.
</behavior>
<action>
Step 1 — In `imptune/api/scripts.py`, refactor the three existing handlers to use shared body logic and then add `.ps1` aliases. Minimal-churn approach:
```python
def _install_response(printer_id: int):
result, error = _get_printer_and_driver(printer_id)
if error is not None:
return error
printer, driver, driver_name = result
rendered = render_install(
printer_name=printer.name,
ip_address=printer.ip_address,
port_name=printer.port_name,
driver_name=driver_name,
inf_filename=driver.inf_filename,
duplex_mode=printer.duplex_mode,
color_mode=printer.color_mode,
paper_size=printer.paper_size,
collate=printer.collate,
)
return PlainTextResponse(
content=rendered,
headers={"Content-Disposition": 'attachment; filename="install.ps1"'},
)
@router.get("/{printer_id}/scripts/install")
def get_install_script(printer_id: int):
return _install_response(printer_id)
@router.get("/{printer_id}/scripts/install.ps1")
def get_install_script_ps1(printer_id: int):
return _install_response(printer_id)
```
Repeat for uninstall and detect. Keeps the existing behavior untouched (existing routes still respond 200) while adding the `.ps1` URL shape locked in 09-CONTEXT.md.
FastAPI caveat: route paths with a `.` are valid — no special escaping needed. Confirm both routes register by checking `pytest --collect-only` imports scripts.py without error and the OpenAPI path table (if generated) lists both.
Step 2 — Edit `imptune/templates/printer_detail.html`. Inside the existing `{% if has_driver %}` block, immediately after the `</div>` closing the Uninstall command block (line 47) and BEFORE `<h2>Export</h2>` (line 48), add:
```html
<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>
```
Do NOT touch the existing Export section — UX-03 requires scripts "in addition to" package exports.
Step 3 — Run tests. All 6 green.
Commit: `feat(09-03): add .ps1 script download routes and detail-page links`
</action>
<verify>
<automated>pytest tests/test_script_download.py tests/test_packages.py::TestCommandPreview -x -v && pytest tests/ -x -q --ignore=tests/e2e</automated>
</verify>
<done>All new tests green, full non-e2e suite green, printer_detail.html shows 3 script download links alongside existing package export buttons.</done>
</task>
</tasks>
<verification>
- `pytest tests/ -x -q --ignore=tests/e2e` green
- Manual eye check (captured in 09-VALIDATION.md manual section): start app, open a printer detail page with a driver assigned, click each of the 3 download links, confirm `install.ps1` / `uninstall.ps1` / `detect.ps1` files download with correct content
</verification>
<success_criteria>
- UX-03 observable truth achieved: technician on printer detail page clicks 3 direct download links and receives the individual .ps1 files
- Existing package export buttons remain untouched
- Existing extensionless script routes still work (backward compatible)
</success_criteria>
<output>
After completion, create `.planning/phases/09-ux-tech-debt-closure/09-03-SUMMARY.md` documenting: files changed, whether `.ps1` was added as alias or rename (locked decision: alias), test results, link to commits.
</output>
@@ -2,9 +2,10 @@
phase: 9 phase: 9
slug: ux-tech-debt-closure slug: ux-tech-debt-closure
status: draft status: draft
nyquist_compliant: false nyquist_compliant: true
wave_0_complete: false wave_0_complete: false
created: 2026-04-13 created: 2026-04-13
updated: 2026-04-13
--- ---
# Phase 9 — Validation Strategy # Phase 9 — Validation Strategy
@@ -37,31 +38,32 @@ created: 2026-04-13
## Per-Task Verification Map ## Per-Task Verification Map
Task IDs follow `{phase}-{plan}-{task}` where task numbers match the `<task>` order in each PLAN.md.
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status | | Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|-----------|-------------------|-------------|--------| |---------|------|------|-------------|-----------|-------------------|-------------|--------|
| 9-01-01 | 01 | 1 | UX-01 | integration | `pytest tests/test_driver_upload.py::test_upload_500_regression -x` | ❌ W0 | ⬜ pending | | 09-01-01 | 01 (driver-upload-fix-and-inline-oob) | 1 | UX-01 | integration (TDD red) | `pytest tests/test_driver_upload.py::test_upload_500_regression tests/test_driver_upload.py::test_upload_returns_oob_when_called_from_form tests/test_driver_upload.py::test_upload_oob_autoselects_new_driver -x` | ✅ (file) / ❌ (test fns Wave 0) | ⬜ pending |
| 9-01-02 | 01 | 1 | UX-01 | integration | `pytest tests/test_driver_upload.py::test_upload_500_regression -x` | ❌ W0 | ⬜ pending | | 09-01-02 | 01 | 1 | UX-01 | integration (TDD green) | `pytest tests/test_driver_upload.py -x -v && pytest tests/ -x -q --ignore=tests/e2e` | | ⬜ pending |
| 9-01-03 | 01 | 1 | UX-01 | integration | `pytest tests/test_driver_upload.py::test_upload_returns_oob_when_called_from_form -x` | ❌ W0 | ⬜ pending | | 09-01-03 | 01 | 1 | UX-01 | integration | `pytest tests/test_printer_form.py -x -v && pytest tests/ -x -q --ignore=tests/e2e` | | ⬜ pending |
| 9-01-04 | 01 | 1 | UX-01 | integration | `pytest tests/test_driver_upload.py::test_upload_oob_autoselects_new_driver -x` | ❌ W0 | ⬜ pending | | 09-02-01 | 02 (playwright-port-autofill) | 2 | UX-02 | setup | `python -c "import pytest_playwright, playwright; print('ok')" && pytest --collect-only tests/e2e/` | ❌ W0 (tests/e2e/) | ⬜ pending |
| 9-01-05 | 01 | 1 | UX-01 | integration | `pytest tests/test_printer_form.py -x` | | ⬜ pending | | 09-02-02 | 02 | 2 | UX-02 | e2e (Playwright) | `pytest tests/e2e/test_port_autofill.py -v` | ❌ W0 | ⬜ pending |
| 9-02-01 | 02 | 2 | UX-02 | e2e | `pytest tests/e2e/test_port_autofill.py -v` | ❌ W0 | ⬜ pending | | 09-03-01 | 03 (script-download-links) | 1 | UX-03 | integration (TDD red) | `pytest tests/test_script_download.py tests/test_packages.py::TestCommandPreview::test_detail_page_shows_script_links -x` | ❌ W0 (test_script_download.py) / ✅ (test_packages.py) | ⬜ pending |
| 9-03-01 | 03 | 1 | UX-03 | integration | `pytest tests/test_script_download.py -x` | ❌ W0 | ⬜ pending | | 09-03-02 | 03 | 1 | UX-03 | integration (TDD green) | `pytest tests/test_script_download.py tests/test_packages.py::TestCommandPreview -x -v && pytest tests/ -x -q --ignore=tests/e2e` | | ⬜ pending |
| 9-03-02 | 03 | 1 | UX-03 | integration | `pytest tests/test_packages.py::TestCommandPreview::test_detail_page_shows_script_links -x` | ❌ W0 | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* *Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
*Task IDs above are placeholders — planner refines to match actual PLAN.md task numbering.* **Nyquist compliance:** Every task has an `<automated>` verify command. No 3 consecutive tasks without feedback. Wave 0 gaps tracked below.
--- ---
## Wave 0 Requirements ## Wave 0 Requirements
- [ ] `tests/test_driver_upload.py` — add stubs: `test_upload_500_regression`, `test_upload_returns_oob_when_called_from_form`, `test_upload_oob_autoselects_new_driver` (UX-01) - [ ] `tests/test_driver_upload.py` — add three new test functions: `test_upload_500_regression`, `test_upload_returns_oob_when_called_from_form`, `test_upload_oob_autoselects_new_driver` (+ optional `test_upload_no_oob_from_standalone_drivers_page`) — **Plan 09-01 Task 1**
- [ ] `tests/e2e/__init__.py` + `tests/e2e/conftest.py` — live server fixture (uvicorn thread on 127.0.0.1, readiness poll via `/health`, session-scoped tmp data dir) (UX-02) - [ ] `tests/e2e/__init__.py` + `tests/e2e/conftest.py`create e2e package with session-scoped `live_server` fixture (uvicorn thread, free port, /health readiness poll, tmp data dir) — **Plan 09-02 Task 1**
- [ ] `tests/e2e/test_port_autofill.py`stub Playwright test for UX-02 - [ ] `tests/e2e/test_port_autofill.py`UX-02 Playwright test **Plan 09-02 Task 2**
- [ ] `tests/test_script_download.py`stubs for `.ps1` URL shape routes (UX-03) - [ ] `tests/test_script_download.py`new file with 5 tests covering .ps1 routes — **Plan 09-03 Task 1**
- [ ] `tests/test_packages.py::TestCommandPreview::test_detail_page_shows_script_links`stub (UX-03) - [ ] `tests/test_packages.py::TestCommandPreview::test_detail_page_shows_script_links`new assertion — **Plan 09-03 Task 1**
- [ ] Dev deps install: add `pytest-playwright` and `playwright` to `requirements-dev.txt`; run `pip install -r requirements-dev.txt && playwright install chromium` - [ ] Dev deps: add `pytest-playwright` and `playwright` to `requirements-dev.txt`; run `pip install -r requirements-dev.txt && playwright install chromium`**Plan 09-02 Task 1**
--- ---
@@ -69,9 +71,9 @@ created: 2026-04-13
| Behavior | Requirement | Why Manual | Test Instructions | | Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------| |----------|-------------|------------|-------------------|
| Live browser visual confirmation of IP→port auto-fill | UX-02 | Success criterion explicitly requires "observed live in a real browser and recorded in VALIDATION.md" | Run `pytest tests/e2e/test_port_autofill.py -v --headed`, observe the browser window, save terminal output snippet here when executed | | Live browser visual confirmation of driver upload OOB refresh + auto-select | UX-01 | Success criterion explicitly requires "without manually reloading the page" — automated OOB contract tests cover the response shape; a one-time eyeball confirms the browser actually swaps the DOM | Start app, open the printer form, upload a real driver ZIP via the inline upload, confirm the driver dropdown updates and the new driver is auto-selected — no F5 pressed |
| Live browser visual confirmation of driver upload OOB refresh | UX-01 | Success criterion explicitly requires "without manually reloading the page" — Playwright headless covers this, but a live eyeball check is recommended once | Start app, open printer form, upload driver ZIP, confirm dropdown updates without F5 | | Live browser visual confirmation of IP→port auto-fill (headed run) | UX-02 | Success criterion requires "observed live in a real browser and recorded in VALIDATION.md" — the Playwright headless test IS the record, but a `--headed` run once provides human-visible evidence | Run `pytest tests/e2e/test_port_autofill.py -v --headed`, observe the chromium window, paste terminal output snippet into 09-VALIDATION sign-off |
| Live browser click of 3 script download links | UX-03 | Success criterion says "can click direct download links ... individually" | Start app, open printer detail page with a driver selected, click each of the 3 links, confirm `.ps1` file downloads | | Live browser click of 3 script download links | UX-03 | Success criterion says "can click direct download links ... individually" | Start app, open a printer detail page with a driver assigned, click each of the 3 links, confirm `install.ps1` / `uninstall.ps1` / `detect.ps1` download with correct PowerShell content |
*Automated coverage is primary; manual checks serve as the live-verification evidence required by the phase success criteria.* *Automated coverage is primary; manual checks serve as the live-verification evidence required by the phase success criteria.*
@@ -79,11 +81,11 @@ created: 2026-04-13
## Validation Sign-Off ## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies - [x] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify - [x] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references - [x] Wave 0 covers all MISSING references
- [ ] No watch-mode flags - [x] No watch-mode flags
- [ ] Feedback latency < 30s (quick suite) - [x] Feedback latency < 30s (quick suite)
- [ ] `nyquist_compliant: true` set in frontmatter (after planner finalizes task IDs) - [x] `nyquist_compliant: true` set in frontmatter (task IDs finalized against PLAN.md)
**Approval:** pending **Approval:** nyquist contract approved; execution pending.