Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
28 changes: 26 additions & 2 deletions backend/contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 11 additions & 3 deletions backend/server.howl
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -217,6 +222,7 @@
)
)
)
)
)
)

Expand Down Expand Up @@ -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"))
Expand Down
8 changes: 8 additions & 0 deletions change_log.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 12 additions & 2 deletions docs/demo.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
}
});
1 change: 1 addition & 0 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,7 @@ <h3>How does HowlBoard integrate with the wider Howl ecosystem?</h3>
</div>

<script src="script.js"></script>
<script>globalThis.HOWLFRAME_ALLOW_CAPS = "network";</script>
<script src="demo.js"></script>
</body>
</html>
2 changes: 2 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ <h2 id="feed-heading">Ecosystem activity</h2>
</footer>
</div>

<!-- The host grants only browser network access to the compiled client. -->
<script>globalThis.HOWLFRAME_ALLOW_CAPS = "network";</script>
<script src="app.js"></script>
</body>
</html>
1 change: 1 addition & 0 deletions frontend/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
72 changes: 72 additions & 0 deletions scripts/test_browser.py
Original file line number Diff line number Diff line change
@@ -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()
Loading