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:
@@ -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 | — |
|
||||||
|
|||||||
+262
@@ -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')" && pytest --collect-only tests/e2e/ 2>&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.
|
||||||
|
|||||||
Reference in New Issue
Block a user