From 30a5ff58e096ebd67a323d136d80bca1eae3036f Mon Sep 17 00:00:00 2001 From: howlcipher Date: Sun, 11 Oct 2026 21:14:31 -0400 Subject: [PATCH] fix: restore browser workflows with current HowlFrame --- Makefile | 4 +-- README.md | 21 ++++++++++++ backend/contract_test.go | 28 ++++++++++++++-- backend/server.howl | 14 ++++++-- change_log.md | 8 +++++ docs/demo.js | 14 ++++++-- docs/index.html | 1 + frontend/index.html | 2 ++ frontend/styles.css | 1 + scripts/test_browser.py | 72 ++++++++++++++++++++++++++++++++++++++++ 10 files changed, 156 insertions(+), 9 deletions(-) create mode 100644 change_log.md create mode 100644 scripts/test_browser.py diff --git a/Makefile b/Makefile index 50d9e5f..e909908 100644 --- a/Makefile +++ b/Makefile @@ -7,13 +7,13 @@ FRONTEND_PORT ?= 3000 ## Compile both tiers plus the ledger import tool through HowlFrame. build: mkdir -p build - $(HOWLFRAME_BIN) frontend/app.howl -o frontend + $(HOWLFRAME_BIN) build frontend/app.howl --target=js -o frontend $(HOWLFRAME_BIN) -compile-bc backend/server.howl -o backend/server.hfbc $(HOWLFRAME_BIN) -compile-bc tools/ledger_import/ledger_import.howl -o build/ledger_import.hfbc @# The published showcase is compiled from .howl too, importing the same @# rendering module as the interface. The JS backend names its output @# app.js, so it is renamed to sit beside the site's own script.js. - $(HOWLFRAME_BIN) docs/demo.howl -o docs + $(HOWLFRAME_BIN) build docs/demo.howl --target=js -o docs mv docs/app.js docs/demo.js ## Black-box contract tests against the real compiled bytecode server. diff --git a/README.md b/README.md index 933cc53..db27b92 100644 --- a/README.md +++ b/README.md @@ -171,3 +171,24 @@ reuses their vocabulary rather than inventing a parallel one — see ## License MIT. See [LICENSE](LICENSE). + +## Browser compatibility and smoke check + +The frontend and published demo compile with `build --target=js`; bare-path +compilation in current HowlFrame selects standalone bytecode. The hosting page +grants only `network` through `globalThis.HOWLFRAME_ALLOW_CAPS` before loading +the compiled frontend. This requires the HowlFrame browser host-grant fix; +older binaries can compile but cannot complete browser network calls. + +For a read-only browser check, run an isolated API and frontend using the +quickstart above. Optionally load demo missions with `make seed`. With Python +Playwright and its Chromium runtime installed, run: + +```bash +python3 scripts/test_browser.py --output /tmp/howlboard-browser-evidence +``` + +The check verifies real API loading, desktop/mobile overflow, keyboard focus, +and browser errors, and saves screenshots. It creates no missions or approvals. +It is a smoke check, not a complete accessibility audit. Backend contract +tests remain the gate for state transitions and authority. diff --git a/backend/contract_test.go b/backend/contract_test.go index f2daa31..70c87a3 100644 --- a/backend/contract_test.go +++ b/backend/contract_test.go @@ -435,6 +435,30 @@ func TestMissionAPIContract(t *testing.T) { } }) + t.Run("dependency scalar types have actionable errors and null is empty", func(t *testing.T) { + for _, value := range []string{`""`, `17`, `true`, `false`} { + status, body := post(t, "/api/missions/create", + fmt.Sprintf(`{"title":"Scalar deps","project":"HowlBoard","depends_on":%s}`, value)) + if status != http.StatusBadRequest || body["error"] != "INVALID_DEPENDS_ON" || body["reason"] != "not_a_list" || body["field"] != "depends_on" { + t.Errorf("dependency value %s = %d %v, want actionable 400", value, status, body) + } + } + status, body := post(t, "/api/missions/create", + `{"title":"Null deps","project":"HowlBoard","depends_on":null}`) + if status != http.StatusCreated { + t.Fatalf("null dependencies = %d %v, want 201", status, body) + } + deps, ok := body["depends_on"].([]any) + if !ok || len(deps) != 0 { + t.Fatalf("null dependencies = %v, want empty list", body["depends_on"]) + } + status, _ = post(t, "/api/missions/delete", + fmt.Sprintf(`{"id":%q}`, body["id"].(string))) + if status != http.StatusOK { + t.Fatalf("null dependency fixture cleanup = %d", status) + } + }) + // store_keys enumerates the store directly, so a delete must disappear from // listings with no separate index record to keep in step. t.Run("create and delete round-trip through store enumeration", func(t *testing.T) { @@ -689,7 +713,7 @@ func TestDependsOnRender(t *testing.T) { root := repoRoot(t) outDir := t.TempDir() bin := filepath.Join(root, "howlframe_bin") - cmd := exec.Command(bin, filepath.Join(root, "frontend", "render_harness.howl"), "-o", outDir) + cmd := exec.Command(bin, "build", filepath.Join(root, "frontend", "render_harness.howl"), "--target=js", "-o", outDir) cmd.Dir = root if output, err := cmd.CombinedOutput(); err != nil { t.Fatalf("compile render harness: %v\n%s", err, output) @@ -1510,7 +1534,7 @@ func TestFactoryViewEscapes(t *testing.T) { root := repoRoot(t) outDir := t.TempDir() bin := filepath.Join(root, "howlframe_bin") - cmd := exec.Command(bin, filepath.Join(root, "frontend", "factory_harness.howl"), "-o", outDir) + cmd := exec.Command(bin, "build", filepath.Join(root, "frontend", "factory_harness.howl"), "--target=js", "-o", outDir) cmd.Dir = root if output, err := cmd.CombinedOutput(); err != nil { t.Fatalf("compile factory harness: %v\n%s", err, output) diff --git a/backend/server.howl b/backend/server.howl index 207fd50..fd4a481 100644 --- a/backend/server.howl +++ b/backend/server.howl @@ -190,9 +190,14 @@ (defun validate_depends_on (raw) (type_hint raw "any") (type_hint return "dict") - (if (= (to_string raw) "") + (if (is_nil raw) (return (dict ("ok" "true") ("deps" (list)))) - (try_let (n (list_len raw)) + (if (= raw (to_string raw)) + (return (dict + ("ok" "false") + ("error" "INVALID_DEPENDS_ON") + ("reason" "not_a_list"))) + (try_let (n (list_len raw)) (catch err (return (dict ("ok" "false") ("error" "INVALID_DEPENDS_ON") @@ -217,6 +222,7 @@ ) ) ) + ) ) ) @@ -1173,7 +1179,9 @@ (if (= prio "") (set prio "NORMAL") (do)) (if (and (!= prio "LOW") (and (!= prio "NORMAL") (!= prio "HIGH"))) (res_json 400 (dict ("error" "INVALID_PRIORITY") ("valid" "LOW, NORMAL, HIGH"))) - (let (dep_check (call validate_depends_on (map_get body "depends_on"))) + (let (dep_check (if (map_has body "depends_on") + (call validate_depends_on (map_get body "depends_on")) + (dict ("ok" "true") ("deps" (list))))) (if (!= (map_get dep_check "ok") "true") (res_json 400 (dict ("error" (map_get dep_check "error")) diff --git a/change_log.md b/change_log.md new file mode 100644 index 0000000..05a0ed8 --- /dev/null +++ b/change_log.md @@ -0,0 +1,8 @@ +# Changelog + +## HOWL-E2E-UX-001 + +- Compile browser targets explicitly and declare only the frontend network grant. +- Keep malformed dependency inputs at HTTP 400 under the current runtime. +- Wrap long Factory blocker reasons on mobile. +- Add a read-only desktop/mobile browser smoke that detects real API failures. diff --git a/docs/demo.js b/docs/demo.js index 43684b0..822ca8d 100644 --- a/docs/demo.js +++ b/docs/demo.js @@ -1,5 +1,11 @@ function howlFrameGrantHas(name) { - var raw = process.env.HOWLFRAME_ALLOW_CAPS || ""; + var raw = ""; + if (typeof process !== "undefined" && process.env) { + raw = process.env.HOWLFRAME_ALLOW_CAPS || ""; + } else if (typeof globalThis !== "undefined") { + raw = globalThis.HOWLFRAME_ALLOW_CAPS; + } + if (typeof raw !== "string") return false; var parts = raw.split(","); for (var i = 0; i < parts.length; i++) { if (parts[i].trim() === name) return true; @@ -1449,5 +1455,9 @@ let first = howlFrameListGet(missions, 0); })().catch((err) => { console.error(err && err.message ? err.message : err); - process.exit(1); + if (typeof process !== "undefined" && typeof process.exit === "function") { + process.exit(1); + } else { + throw err; + } }); diff --git a/docs/index.html b/docs/index.html index aae6583..97cbd20 100644 --- a/docs/index.html +++ b/docs/index.html @@ -563,6 +563,7 @@

How does HowlBoard integrate with the wider Howl ecosystem?

+ diff --git a/frontend/index.html b/frontend/index.html index 16ccd55..a5f445d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -163,6 +163,8 @@

Ecosystem activity

+ + diff --git a/frontend/styles.css b/frontend/styles.css index 3bc326d..888d71f 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -550,6 +550,7 @@ textarea { min-height: 64px; resize: vertical; } border-left: 3px solid var(--text-faint); padding: 6px 0 6px 10px; margin-bottom: 8px; + overflow-wrap: anywhere; } .blocker-owner { border-left-color: var(--awaiting); } .blocker-blocked { border-left-color: var(--blocked); } diff --git a/scripts/test_browser.py b/scripts/test_browser.py new file mode 100644 index 0000000..040e192 --- /dev/null +++ b/scripts/test_browser.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Read-only browser smoke against an already running isolated HowlBoard.""" + +import argparse +import json +from pathlib import Path +from urllib.parse import urlsplit + +from playwright.sync_api import sync_playwright + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--url", default="http://localhost:3000") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if urlsplit(args.url).hostname not in {"localhost", "127.0.0.1", "::1"}: + parser.error("use a local isolated application URL") + args.output.mkdir(parents=True, exist_ok=True) + results = [] + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + try: + viewports = [("desktop", 1280, 900), ("mobile", 390, 844)] + for label, width, height in viewports: + page = browser.new_page( + viewport={"width": width, "height": height} + ) + errors = [] + page.on("pageerror", lambda error: errors.append(str(error))) + with page.expect_response( + lambda response: "/api/missions" in response.url, + timeout=15000, + ) as response: + page.goto(args.url, wait_until="networkidle") + assert response.value.status == 200, "mission API failed" + missions = response.value.json()["missions"] + if missions: + page.locator(".mission-row").first.wait_for() + row_count = page.locator(".mission-row").count() + assert row_count == len(missions) + else: + assert page.locator("#mission-list").inner_text().strip() + error_text = page.locator("#error").inner_text() + assert error_text == "", error_text + assert not errors, errors + assert not page.evaluate( + "document.documentElement.scrollWidth > innerWidth" + ), "horizontal overflow" + page.keyboard.press("Tab") + assert page.evaluate( + "document.activeElement.tagName !== 'BODY'" + ), "keyboard focus did not reach a control" + page.screenshot( + path=str(args.output / f"{label}.png"), full_page=True + ) + results.append({ + "viewport": [width, height], "api_status": 200, + "missions_rendered": len(missions), + "page_errors": errors, + "mission_text": page.locator("#mission-list").inner_text(), + }) + page.close() + finally: + browser.close() + report = json.dumps(results, indent=2) + "\n" + (args.output / "result.json").write_text(report) + print("PASS: desktop/mobile API loading, keyboard focus, and layout") + + +if __name__ == "__main__": + main()