From 71c808601cd14804cb9dfd66d410e439cc0ce67a Mon Sep 17 00:00:00 2001 From: Kawa Date: Mon, 13 Apr 2026 10:29:43 +0200 Subject: [PATCH] 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. --- .planning/ROADMAP.md | 7 +- ...1-driver-upload-fix-and-inline-oob-PLAN.md | 262 ++++++++++++++++++ .../09-02-playwright-port-autofill-PLAN.md | 245 ++++++++++++++++ .../09-03-script-download-links-PLAN.md | 217 +++++++++++++++ .../09-ux-tech-debt-closure/09-VALIDATION.md | 54 ++-- 5 files changed, 757 insertions(+), 28 deletions(-) create mode 100644 .planning/phases/09-ux-tech-debt-closure/09-01-driver-upload-fix-and-inline-oob-PLAN.md create mode 100644 .planning/phases/09-ux-tech-debt-closure/09-02-playwright-port-autofill-PLAN.md create mode 100644 .planning/phases/09-ux-tech-debt-closure/09-03-script-download-links-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index b0bd866..b6ed752 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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 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 -**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 **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 | | 7. Dashboard & Nav Polish | v1.0 | 1/1 | Complete | 2026-04-13 | | 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 | — | | 11. Real-World Rollout & Feedback | v1.1 | 0/? | Not started | — | diff --git a/.planning/phases/09-ux-tech-debt-closure/09-01-driver-upload-fix-and-inline-oob-PLAN.md b/.planning/phases/09-ux-tech-debt-closure/09-01-driver-upload-fix-and-inline-oob-PLAN.md new file mode 100644 index 0000000..dfe6403 --- /dev/null +++ b/.planning/phases/09-ux-tech-debt-closure/09-01-driver-upload-fix-and-inline-oob-PLAN.md @@ -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 " + - "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 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
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 ` 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. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +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 + +``` + +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). + + + + + + + Task 1: Wave 0 — write failing driver-upload regression + OOB contract tests + tests/test_driver_upload.py + + - 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 ` + {% for item in driver_data %} + + {% endfor %} + + ``` + + 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` + + + pytest tests/test_driver_upload.py -x -v && pytest tests/ -x -q --ignore=tests/e2e + + 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. + + + + Task 3: Wire inline driver upload form into printer_form.html + imptune/templates/partials/printer_form.html, tests/test_printer_form.py + + - printer_form.html renders a separate inline `` OUTSIDE the main printer `` but inside the Alpine x-data wrapper div. + - The driver `` (line 30). + - AFTER the closing `` of the printer form (line 85) but BEFORE the closing `` of the x-data wrapper (line 86), add a separate inline upload form: + ```html +
+ + + +
+ ``` + - CRITICAL: Do NOT nest this form inside the printer `
` — HTML forbids nested forms and browsers silently drop the inner one. Place it as a sibling, still within the outer `
` 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 `
` anchor, add a hidden one next to the upload form: `` 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 `` does NOT contain `hx-post="/drivers/upload"` (naive check is fine: split on `` 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` + + + pytest tests/test_printer_form.py -x -v && pytest tests/ -x -q --ignore=tests/e2e + + 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. + + + + + +- `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 + + + +- 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 + + + +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. + diff --git a/.planning/phases/09-ux-tech-debt-closure/09-02-playwright-port-autofill-PLAN.md b/.planning/phases/09-ux-tech-debt-closure/09-02-playwright-port-autofill-PLAN.md new file mode 100644 index 0000000..2c6bd73 --- /dev/null +++ b/.planning/phases/09-ux-tech-debt-closure/09-02-playwright-port-autofill-PLAN.md @@ -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_" + - "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:" + 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" +--- + + +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. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.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/templates/partials/printer_form.html +@imptune/main.py +@tests/conftest.py +@requirements-dev.txt + + + + +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 + + +``` +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 + + + + + + + Task 1: Add Playwright dev deps + e2e package scaffolding + requirements-dev.txt, tests/e2e/__init__.py, tests/e2e/conftest.py + + 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. + + + python -c "import pytest_playwright, playwright; print('playwright ok')" && pytest --collect-only tests/e2e/ 2>&1 | head -20 + + 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. + + + + Task 2: Write UX-02 Playwright test for IP→port auto-fill + tests/e2e/test_port_autofill.py + + - 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). + + + 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` + + + pytest tests/e2e/test_port_autofill.py -v + + Playwright test green. tests/e2e/test_port_autofill.py file path cited as evidence for UX-02 in 09-VALIDATION.md. + + + + + +- `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 + + + +- 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) + + + +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. + diff --git a/.planning/phases/09-ux-tech-debt-closure/09-03-script-download-links-PLAN.md b/.planning/phases/09-ux-tech-debt-closure/09-03-script-download-links-PLAN.md new file mode 100644 index 0000000..30380ca --- /dev/null +++ b/.planning/phases/09-ux-tech-debt-closure/09-03-script-download-links-PLAN.md @@ -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 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: '' + 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" +--- + + +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. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +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 +

Export

+
Download NinjaRMM ZIP +Download .intunewin +``` + +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). + + + + + + + Task 1: Wave 0 — failing tests for .ps1 routes + detail page script links + tests/test_script_download.py, tests/test_packages.py + + - 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`. + + + 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` + + + pytest tests/test_script_download.py tests/test_packages.py::TestCommandPreview::test_detail_page_shows_script_links -x + + All 6 new tests exist and go RED. Failing output proves routes + links are missing. + + + + Task 2: Add .ps1 route aliases + printer_detail.html script links + imptune/api/scripts.py, imptune/templates/printer_detail.html + + - 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. + + + 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 `
` closing the Uninstall command block (line 47) and BEFORE `

Export

` (line 48), add: + ```html +

Scripts

+ + Download Install Script + + + Download Uninstall Script + + + Download Detect Script + + ``` + + 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` + + + pytest tests/test_script_download.py tests/test_packages.py::TestCommandPreview -x -v && pytest tests/ -x -q --ignore=tests/e2e + + All new tests green, full non-e2e suite green, printer_detail.html shows 3 script download links alongside existing package export buttons. + + + + + +- `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 + + + +- 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) + + + +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. + diff --git a/.planning/phases/09-ux-tech-debt-closure/09-VALIDATION.md b/.planning/phases/09-ux-tech-debt-closure/09-VALIDATION.md index e694f88..57ddcf1 100644 --- a/.planning/phases/09-ux-tech-debt-closure/09-VALIDATION.md +++ b/.planning/phases/09-ux-tech-debt-closure/09-VALIDATION.md @@ -2,9 +2,10 @@ phase: 9 slug: ux-tech-debt-closure status: draft -nyquist_compliant: false +nyquist_compliant: true wave_0_complete: false created: 2026-04-13 +updated: 2026-04-13 --- # Phase 9 — Validation Strategy @@ -37,31 +38,32 @@ created: 2026-04-13 ## Per-Task Verification Map +Task IDs follow `{phase}-{plan}-{task}` where task numbers match the `` order in each PLAN.md. + | 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 | -| 9-01-02 | 01 | 1 | UX-01 | integration | `pytest tests/test_driver_upload.py::test_upload_500_regression -x` | ❌ W0 | ⬜ 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 | -| 9-01-04 | 01 | 1 | UX-01 | integration | `pytest tests/test_driver_upload.py::test_upload_oob_autoselects_new_driver -x` | ❌ W0 | ⬜ pending | -| 9-01-05 | 01 | 1 | UX-01 | integration | `pytest tests/test_printer_form.py -x` | ✅ | ⬜ pending | -| 9-02-01 | 02 | 2 | UX-02 | e2e | `pytest tests/e2e/test_port_autofill.py -v` | ❌ W0 | ⬜ pending | -| 9-03-01 | 03 | 1 | UX-03 | integration | `pytest tests/test_script_download.py -x` | ❌ W0 | ⬜ pending | -| 9-03-02 | 03 | 1 | UX-03 | integration | `pytest tests/test_packages.py::TestCommandPreview::test_detail_page_shows_script_links -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 | +| 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 | +| 09-01-03 | 01 | 1 | UX-01 | integration | `pytest tests/test_printer_form.py -x -v && pytest tests/ -x -q --ignore=tests/e2e` | ✅ | ⬜ 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 | +| 09-02-02 | 02 | 2 | UX-02 | e2e (Playwright) | `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 | +| 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 | *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 `` verify command. No 3 consecutive tasks without feedback. Wave 0 gaps tracked below. --- ## 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/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/test_port_autofill.py` — stub Playwright test for UX-02 -- [ ] `tests/test_script_download.py` — stubs for `.ps1` URL shape routes (UX-03) -- [ ] `tests/test_packages.py::TestCommandPreview::test_detail_page_shows_script_links` — stub (UX-03) -- [ ] Dev deps install: add `pytest-playwright` and `playwright` to `requirements-dev.txt`; run `pip install -r requirements-dev.txt && playwright install chromium` +- [ ] `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` — 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` — UX-02 Playwright test — **Plan 09-02 Task 2** +- [ ] `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` — new assertion — **Plan 09-03 Task 1** +- [ ] 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 | |----------|-------------|------------|-------------------| -| 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 | 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 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 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 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 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.* @@ -79,11 +81,11 @@ created: 2026-04-13 ## Validation Sign-Off -- [ ] All tasks have `` verify or Wave 0 dependencies -- [ ] Sampling continuity: no 3 consecutive tasks without automated verify -- [ ] Wave 0 covers all MISSING references -- [ ] No watch-mode flags -- [ ] Feedback latency < 30s (quick suite) -- [ ] `nyquist_compliant: true` set in frontmatter (after planner finalizes task IDs) +- [x] All tasks have `` verify or Wave 0 dependencies +- [x] Sampling continuity: no 3 consecutive tasks without automated verify +- [x] Wave 0 covers all MISSING references +- [x] No watch-mode flags +- [x] Feedback latency < 30s (quick suite) +- [x] `nyquist_compliant: true` set in frontmatter (task IDs finalized against PLAN.md) -**Approval:** pending +**Approval:** nyquist contract approved; execution pending.