diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..0ad0e1f --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,135 @@ +# Ourocode Design System + +## 1. Atmosphere & Identity + +Ourocode feels like a quiet terminal command center: dense enough for real work, but deliberate about hierarchy so the next action is visible. The signature is a productized terminal surface with mono typography, restrained mint accents, and verification evidence treated as first-class UI. + +## 2. Color + +### Palette + +| Role | Token | Light | Dark | Usage | +| --- | --- | --- | --- | --- | +| Surface/primary | `--bg` | `#fafaf7` | `#0a0a0b` | Page background | +| Surface/panel | `--panel` | `#ffffff` | `#111111` | Main panels and terminal shells | +| Surface/secondary | `--panel-2` | `#f1f2ed` | `#1a1a1d` | Secondary panels, selected surfaces | +| Text/primary | `--ink` | `#111513` | `#e2e2e5` | Headlines and terminal text | +| Text/secondary | `--muted` | `#61655f` | `#a8a8a8` | Captions, hints, inactive rows | +| Border/default | `--line` | `rgba(17, 21, 19, 0.14)` | `rgba(226, 226, 229, 0.18)` | Dividers and panel outlines | +| Accent/primary | `--mint` | `#087c58` | `#66d9c2` | Primary action, selected state, healthy checks | +| Accent/warning | `--amber` | `#a96c00` | `#e8a45c` | Workflow/interview emphasis | +| Status/error | `--rose` | `#b84646` | `#f87171` | Failed checks or destructive actions | +| Shadow | `--shadow` | `rgba(17, 21, 19, 0.16)` | `rgba(0, 0, 0, 0.42)` | Elevated browser/tool surfaces | + +### Rules + +- Use mint for active verification and primary affordances only. +- Use amber for interview/workflow context, never as a general accent. +- Raw terminal text may remain monochrome; color is reserved for state and action. + +## 3. Typography + +### Scale + +| Level | Size | Weight | Line Height | Tracking | Usage | +| --- | --- | --- | --- | --- | --- | +| Display | `clamp(3rem, 10vw, 8rem)` | 700 | 0.9 | 0 | Product hero only | +| H1 | `40px` | 700 | 1.1 | 0 | Tool/page title | +| H2 | `28px` | 700 | 1.2 | 0 | Section heading | +| H3 | `18px` | 700 | 1.35 | 0 | Panel heading | +| Body | `15px` | 400 | 1.6 | 0 | Default copy | +| Body/sm | `13px` | 400 | 1.5 | 0 | Metadata, helper text | +| Caption | `12px` | 700 | 1.4 | 0 | Labels and status chips | +| Terminal | `14px` | 400 | 1.45 | 0 | Rendered TUI frame text | + +### Font Stack + +- Primary and mono: `"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace`. + +### Rules + +- Letter spacing is 0 unless a pre-existing terminal label requires uppercase density. +- Body text must not drop below 13px in web QA tools. + +## 4. Spacing & Layout + +### Base Unit + +All spacing derives from 4px. + +| Token | Value | Usage | +| --- | --- | --- | +| `--space-1` | `4px` | Tight inline gaps | +| `--space-2` | `8px` | Compact row gaps | +| `--space-3` | `12px` | Control padding | +| `--space-4` | `16px` | Panel inner spacing | +| `--space-5` | `20px` | Dense section spacing | +| `--space-6` | `24px` | Tool shell spacing | +| `--space-8` | `32px` | Major group spacing | +| `--space-10` | `40px` | Page bands | + +### Grid + +- Max content width: `1440px`. +- Breakpoints: mobile `< 760px`, tablet `760px`, desktop `1100px`. +- QA tool layout: control rail plus terminal preview on desktop; stacked controls on mobile. + +### Rules + +- Terminal preview dimensions are stable and must not resize when frame content changes. +- Toolbars wrap before text overflows. + +## 5. Components + +### Terminal Shell + +- **Structure**: title bar, status dots, fixed-height preformatted frame area. +- **Variants**: dark, light, compact. +- **Spacing**: `--space-4` and `--space-6`. +- **States**: default, playing, paused, empty, error. +- **Accessibility**: labelled region; text remains selectable; no color-only status. +- **Motion**: frame changes use opacity only. + +### Scenario Rail + +- **Structure**: list of frame buttons with title, duration, and check count. +- **Variants**: selected, failed, warning. +- **Spacing**: `--space-2` and `--space-3`. +- **States**: default, hover, active, focus, disabled. +- **Accessibility**: native buttons with `aria-current` on the selected scenario. +- **Motion**: hover color transition under 160ms. + +### Evidence Panel + +- **Structure**: key-value checks, viewport controls, export/copy actions. +- **Variants**: pass, warning, blocked. +- **Spacing**: `--space-3` and `--space-4`. +- **States**: default, loading, error. +- **Accessibility**: status text is explicit, not icon-only. +- **Motion**: none beyond control feedback. + +## 6. Motion & Interaction + +| Type | Duration | Easing | Usage | +| --- | --- | --- | --- | +| Micro | `120ms` | `ease` | Button and row hover | +| Standard | `180ms` | `ease-out` | Frame opacity change | + +Rules: + +- Animate only opacity and transform. +- Respect `prefers-reduced-motion` by disabling auto-play transitions. +- Keyboard QA must support play/pause, previous/next, and direct scenario selection. + +## 7. Depth & Surface + +### Strategy + +Mixed: web documentation uses subtle shadows for elevated browser-like surfaces; terminal tool surfaces use borders plus tonal shifts. + +| Level | Value | Usage | +| --- | --- | --- | +| Subtle border | `1px solid var(--line)` | Panels, controls | +| Elevated shadow | `0 1.2rem 3rem var(--shadow)` | Main terminal shell | +| Tonal shift | `var(--panel-2)` | Selected rows and secondary controls | + diff --git a/docs/tui-qa/app.js b/docs/tui-qa/app.js new file mode 100644 index 0000000..e4a8fe0 --- /dev/null +++ b/docs/tui-qa/app.js @@ -0,0 +1,180 @@ + +const state = { + frames: [], + index: 0, + playing: false, + timer: 0, + columns: window.innerWidth <= 640 ? 80 : 100, +}; + +const elements = { + play: document.querySelector("#play"), + prev: document.querySelector("#prev"), + next: document.querySelector("#next"), + copy: document.querySelector("#copy"), + terminal: document.querySelector("#terminal"), + title: document.querySelector("#frame-title"), + meta: document.querySelector("#frame-meta"), + status: document.querySelector("#qa-status"), + scenarios: document.querySelector("#scenarios"), + checks: document.querySelector("#checks"), + viewportButtons: document.querySelectorAll("[data-width]"), +}; + + +function validateFrames(value) { + if (!Array.isArray(value)) { + throw new Error("frames.json must be an array"); + } + + for (const frame of value) { + if (!frame || typeof frame.title !== "string" || typeof frame.text !== "string") { + throw new Error("frames.json contains an invalid frame"); + } + if (!Number.isInteger(frame.duration_ms) || frame.duration_ms <= 0) { + throw new Error(`invalid duration for frame: ${frame.title}`); + } + if (!Array.isArray(frame.checks)) { + throw new Error(`invalid checks for frame: ${frame.title}`); + } + } + + return value; +} + +async function loadFrames() { + try { + const response = await fetch("./frames.json", { cache: "no-store" }); + if (!response.ok) { + throw new Error(`frames request failed: ${response.status}`); + } + + state.frames = validateFrames(await response.json()); + elements.status.textContent = `${state.frames.length} live frames loaded`; + renderScenarioList(); + renderFrame(); + } catch (error) { + elements.status.textContent = `Live frame load failed: ${error.message}`; + elements.terminal.textContent = "Start with: mix run --no-start scripts/tui_qa_server.exs"; + } +} + +function renderScenarioList() { + elements.scenarios.replaceChildren( + ...state.frames.map((frame, index) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "scenario"; + button.setAttribute("aria-current", String(index === state.index)); + button.innerHTML = `${frame.title}${frame.duration_ms}ms · ${frame.checks.length} checks`; + button.addEventListener("click", () => selectFrame(index)); + return button; + }), + ); +} + +function renderFrame() { + const frame = state.frames[state.index]; + if (!frame) { + return; + } + + elements.title.textContent = frame.title; + elements.meta.textContent = `${state.index + 1}/${state.frames.length} · ${frame.duration_ms}ms`; + elements.terminal.textContent = frame.text; + elements.terminal.className = `cols-${state.columns}`; + elements.checks.replaceChildren( + ...frame.checks.map((check) => { + const item = document.createElement("span"); + item.className = "check"; + item.dataset.status = check.status; + item.textContent = `${check.status}: ${check.label}`; + return item; + }), + ); + renderScenarioList(); + syncViewportButtons(); +} + +function selectFrame(index) { + state.index = Math.max(0, Math.min(index, state.frames.length - 1)); + renderFrame(); + scheduleNext(); +} + +function nextFrame() { + selectFrame((state.index + 1) % state.frames.length); +} + +function prevFrame() { + selectFrame((state.index - 1 + state.frames.length) % state.frames.length); +} + +function togglePlay() { + state.playing = !state.playing; + elements.play.textContent = state.playing ? "Pause" : "Play"; + scheduleNext(); +} + +function scheduleNext() { + window.clearTimeout(state.timer); + if (!state.playing || state.frames.length === 0) { + return; + } + + const duration = state.frames[state.index].duration_ms || 1000; + state.timer = window.setTimeout(nextFrame, duration); +} + +async function copyFrame() { + const frame = state.frames[state.index]; + if (!frame) { + return; + } + + try { + await navigator.clipboard.writeText(frame.text); + elements.status.textContent = "Current frame copied"; + } catch (_error) { + elements.status.textContent = "Clipboard unavailable; select terminal text manually"; + } +} + +function syncViewportButtons() { + for (const button of elements.viewportButtons) { + button.setAttribute("aria-current", String(button.dataset.width === String(state.columns))); + } +} + +function setColumns(width) { + state.columns = Number(width); + syncViewportButtons(); + renderFrame(); +} + +elements.play.addEventListener("click", togglePlay); +elements.prev.addEventListener("click", prevFrame); +elements.next.addEventListener("click", nextFrame); +elements.copy.addEventListener("click", copyFrame); + +for (const button of elements.viewportButtons) { + button.addEventListener("click", () => setColumns(button.dataset.width)); +} + +document.addEventListener("keydown", (event) => { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + return; + } + + if (event.key === " ") { + event.preventDefault(); + togglePlay(); + } else if (event.key === "ArrowRight") { + nextFrame(); + } else if (event.key === "ArrowLeft") { + prevFrame(); + } +}); + +loadFrames(); + diff --git a/docs/tui-qa/index.html b/docs/tui-qa/index.html new file mode 100644 index 0000000..ed385ff --- /dev/null +++ b/docs/tui-qa/index.html @@ -0,0 +1,65 @@ + + + + + + Ourocode TUI QA + + + + +
+
+
+

TUI QA

+

Ourocode frame review

+

+ Replay generated terminal states, inspect wrapping, and collect visible checks before recording or manual QA. +

+
+ +
+ + + + +
+ +
+ + + +
+ +
+
+ +
+
+
+ + + + Loading + +
+

+        
+ + +
+
+ + + + + diff --git a/docs/tui-qa/styles.css b/docs/tui-qa/styles.css new file mode 100644 index 0000000..5c1489c --- /dev/null +++ b/docs/tui-qa/styles.css @@ -0,0 +1,300 @@ +:root { + color-scheme: light; + --bg: #fafaf7; + --panel: #ffffff; + --panel-2: #f1f2ed; + --ink: #111513; + --muted: #61655f; + --line: rgba(17, 21, 19, 0.14); + --mint: #087c58; + --amber: #a96c00; + --rose: #b84646; + --shadow: rgba(17, 21, 19, 0.16); + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + font-family: + "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + monospace; +} + +* { + box-sizing: border-box; +} + +html { + min-width: 320px; + background: + linear-gradient(180deg, rgba(8, 124, 88, 0.09), transparent 34rem), + var(--bg); + color: var(--ink); +} + +body { + margin: 0; +} + +button { + min-height: 38px; + border: 1px solid var(--line); + padding: 0 var(--space-3); + background: var(--panel); + color: var(--ink); + font: inherit; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, transform 120ms ease; +} + +button:hover, +button[aria-current="true"] { + border-color: rgba(8, 124, 88, 0.42); + background: rgba(8, 124, 88, 0.08); +} + +button:active { + transform: translateY(1px); +} + +button:focus-visible, +pre:focus-visible { + outline: 2px solid var(--mint); + outline-offset: 2px; +} + +.qa-shell { + display: grid; + grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); + gap: var(--space-6); + width: min(1440px, 100%); + min-height: 100dvh; + margin: 0 auto; + padding: var(--space-6); +} + +.control-panel, +.preview-panel, +.evidence, +.terminal-card { + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.82); +} + +.control-panel { + display: flex; + flex-direction: column; + gap: var(--space-5); + padding: var(--space-5); +} + +.intro h1 { + margin: var(--space-2) 0 var(--space-3); + font-size: 40px; + line-height: 1.1; +} + +.intro p { + margin: 0; + color: var(--muted); + font-size: 15px; + line-height: 1.6; +} + +.label { + color: var(--mint); + font-size: 12px; + font-weight: 700; +} + +.toolbar, +.viewport-controls { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.scenario-list { + display: grid; + gap: var(--space-2); +} + +.scenario { + display: grid; + gap: var(--space-1); + width: 100%; + min-height: 64px; + text-align: left; +} + +.scenario span { + color: var(--muted); + font-size: 12px; +} + +.preview-panel { + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + gap: var(--space-4); + padding: var(--space-5); +} + +.terminal-card { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + min-width: 0; + min-height: 560px; + overflow: hidden; + box-shadow: 0 1.2rem 3rem var(--shadow); +} + +.terminal-bar { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: 48px; + border-bottom: 1px solid var(--line); + padding: 0 var(--space-4); + background: var(--panel-2); +} + +.terminal-bar strong { + margin-left: var(--space-2); +} + +.terminal-bar span:last-child { + margin-left: auto; + color: var(--muted); + font-size: 12px; +} + +.dot { + width: 10px; + height: 10px; + border-radius: 50%; +} + +.danger { + background: var(--rose); +} + +.warn { + background: var(--amber); +} + +.ok { + background: var(--mint); +} + +pre { + width: 100%; + min-height: 0; + margin: 0; + overflow: auto; + padding: var(--space-5); + background: var(--panel); + color: var(--ink); + font-size: 14px; + line-height: 1.45; + white-space: pre; +} + +pre.cols-80 { + max-width: 80ch; +} + +pre.cols-100 { + max-width: 100ch; +} + +pre.cols-120 { + max-width: 120ch; +} + +.evidence { + display: grid; + grid-template-columns: minmax(180px, 260px) minmax(0, 1fr); + gap: var(--space-4); + padding: var(--space-4); +} + +.evidence strong { + display: block; + margin-top: var(--space-1); +} + +.checks { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.check { + border: 1px solid var(--line); + padding: var(--space-2) var(--space-3); + background: var(--panel-2); + color: var(--muted); + font-size: 13px; +} + +.check[data-status="pass"] { + color: var(--mint); +} + +.check[data-status="trace"] { + color: var(--amber); +} + +@media (max-width: 900px) { + .qa-shell { + grid-template-columns: 1fr; + padding: var(--space-4); + } + + .control-panel { + order: 2; + } + + .preview-panel { + order: 1; + } +} + +@media (max-width: 640px) { + .qa-shell { + padding: var(--space-3); + } + + .intro h1 { + font-size: 32px; + } + + .evidence { + grid-template-columns: 1fr; + } + + .terminal-card { + min-height: 420px; + } + + pre { + font-size: 11px; + line-height: 1.45; + padding: var(--space-4); + } + + pre.cols-100, + pre.cols-120 { + max-width: 80ch; + } +} + +@media (prefers-reduced-motion: reduce) { + * { + transition: none; + } +} + diff --git a/lib/ourocode/terminal/qa_frames.ex b/lib/ourocode/terminal/qa_frames.ex new file mode 100644 index 0000000..25988fb --- /dev/null +++ b/lib/ourocode/terminal/qa_frames.ex @@ -0,0 +1,135 @@ +defmodule Ourocode.Terminal.QaFrames do + @moduledoc false + + alias Ourocode.Terminal.{Tui, WorkspaceModel} + + @base_frame """ + +-- ourocode terminal region=header_status x=0 y=0 w=88 h=5 + | app=ourocode status=healthy runtime=ready session=demo + | project=/Users/dev/Project/ourocode + | cwd=/Users/dev/Project/ourocode + +-- + +-- Parent/Child Sessions region=runtime_panes layout=terminal_split + | [parent-region] x=0 y=0 w=80 h=8 + | parent empty + | [child-region] x=0 y=9 w=80 h=12 + | child empty + +-- + +-- Plugin Status (1) region=plugin_status x=0 y=18 w=80 h=4 + | status=ready visible=1 + | [BUILT-IN] Ouroboros workflows - Official plugin - loaded + +-- + +-- State + | surface=terminal focus=task_prompt layout=compact + | runtime=ready stream=streaming journal=ready + | queued=0 replayable?=true connections=ready + | hooks=idle events=0 + +-- + """ + + @spec all() :: [map()] + def all do + [ + frame("Start", 950, "", [], %{}), + frame("Open guided work", 850, "ooo", [], %{}), + frame("Type a goal", 750, "ooo pm design plugin onboarding", [], %{}), + frame("Answer the interview", 1_450, "", ["you> ooo pm design plugin onboarding", "task: first picker ready"], %{ + interview_block: pm_picker_block(), + wonder_focus: true + }), + frame("Continue safely", 1_200, "", ["you> ooo pm design plugin onboarding", "task: answer accepted"], %{ + interview_block: accepted_block() + }), + frame("Track active work", 1_250, "", [], %{workspace: agents_workspace()}), + frame("Auto is approval-gated", 1_350, "", [], %{ + workspace: WorkspaceModel.workflow_start("ooo auto improve onboarding") + }) + ] + end + + @spec all_json() :: String.t() + def all_json, do: Ourocode.Json.encode!(all()) + + defp frame(title, duration_ms, prompt, activity, opts) do + %{ + title: title, + duration_ms: duration_ms, + text: + @base_frame + |> Tui.frame_lines(activity, prompt, 100, 24, Map.put_new(opts, :auth, {"model: codex cli", :ok})) + |> Enum.join("\n"), + checks: checks_for(title) + } + end + + defp pm_picker_block do + {"INTERVIEW", + [ + "Round 1 · PM interview", + "What outcome should this PM interview produce?", + ">> [1] Define the target user - anchor the PM brief around the primary audience", + " [2] Define the activation outcome - focus on the proof moment", + " [3] Audit the existing flow - start from the current path" + ], "Choose an option or type a custom answer"} + end + + defp accepted_block do + {"INTERVIEW", + [ + {"Round accepted", :strong}, + {"Question What outcome should this PM interview produce?", :warn}, + {"Answer Define the target user", :strong}, + {"Next answer sent; generating choices", :dim}, + :rule, + {"■■⬝ building next answer choices (~6s) - no input needed; Esc pauses", :dim}, + {"No input needed; choices will appear automatically", :dim} + ], "type your answer + Enter Esc pause"} + end + + defp agents_workspace do + %{ + kind: "agents", + title: "Agents", + status: "running", + selected: "agent:pm", + records: [ + %{ + id: "agent:pm", + title: "PM interview", + state: "waiting", + health: "live", + fields: %{phase: "generating answer choices", progress: "answer accepted"} + }, + %{ + id: "agent:verify", + title: "Health checks", + state: "ready", + health: "ready", + fields: %{phase: "ready", progress: "17 checks passed"} + } + ], + detail: %{ + id: "agent:pm", + title: "PM interview", + state: "waiting", + fields: %{ + phase: "generating answer choices", + progress: "answer accepted", + controls: "Esc pause, /cancel, /sessions" + } + }, + actions: [], + shortcuts: ["Up/Dn rows", "Enter row action", "type to compose"], + next: "Watch active work or type a new command." + } + end + + defp checks_for(title) do + [ + %{label: "frame generated", status: "pass"}, + %{label: "terminal text present", status: "pass"}, + %{label: title |> String.downcase() |> String.replace(" ", "-"), status: "trace"} + ] + end +end + diff --git a/scripts/tui_qa_frames.exs b/scripts/tui_qa_frames.exs new file mode 100644 index 0000000..ef85656 --- /dev/null +++ b/scripts/tui_qa_frames.exs @@ -0,0 +1 @@ +IO.write(Ourocode.Terminal.QaFrames.all_json()) diff --git a/scripts/tui_qa_server.exs b/scripts/tui_qa_server.exs new file mode 100644 index 0000000..4590461 --- /dev/null +++ b/scripts/tui_qa_server.exs @@ -0,0 +1,128 @@ +defmodule Ourocode.TuiQaServer do + @moduledoc false + + @root File.cwd!() + @ui_dir Path.join(@root, "docs/tui-qa") + + def run(argv) do + port = port_from(argv) + {:ok, socket} = :gen_tcp.listen(port, [:binary, packet: :raw, active: false, reuseaddr: true]) + url = "http://127.0.0.1:#{port}/" + IO.puts("TUI QA server: #{url}") + IO.puts("Frames: #{url}frames.json") + accept(socket) + end + + defp port_from(argv) do + case Enum.find_index(argv, &(&1 == "--port")) do + nil -> env_port() + index -> argv |> Enum.at(index + 1, "") |> parse_port(env_port()) + end + end + + defp env_port do + System.get_env("TUI_QA_PORT", "5199") |> parse_port(5199) + end + + defp parse_port(value, fallback) do + case Integer.parse(to_string(value)) do + {port, ""} when port > 0 and port < 65_536 -> port + _other -> fallback + end + end + + defp accept(socket) do + {:ok, client} = :gen_tcp.accept(socket) + serve(client) + accept(socket) + end + + defp serve(client) do + request = read_request(client) + {path, _query} = request_path(request) + {status, headers, body} = response(path) + :ok = :gen_tcp.send(client, http_response(status, headers, body)) + :gen_tcp.close(client) + rescue + _exception -> + :gen_tcp.close(client) + end + + defp read_request(client) do + case :gen_tcp.recv(client, 0, 2_000) do + {:ok, data} -> data + {:error, _reason} -> "" + end + end + + defp request_path(request) do + request + |> String.split("\r\n", parts: 2) + |> List.first("") + |> String.split(" ") + |> case do + [_method, target, _version] -> URI.parse(target) + _other -> URI.parse("/") + end + |> then(&{normalize_path(&1.path), &1.query}) + end + + defp normalize_path(nil), do: "/" + defp normalize_path(""), do: "/" + defp normalize_path(path), do: URI.decode(path) + + defp response("/health"), do: {200, [{"content-type", "text/plain; charset=utf-8"}], "ok"} + + defp response("/frames.json") do + {200, [{"content-type", "application/json; charset=utf-8"}], Ourocode.Terminal.QaFrames.all_json()} + end + + defp response(path) do + file_path = static_path(path) + + if File.regular?(file_path) do + {200, [{"content-type", content_type(file_path)}], File.read!(file_path)} + else + {404, [{"content-type", "text/plain; charset=utf-8"}], "not found"} + end + end + + defp static_path("/"), do: Path.join(@ui_dir, "index.html") + + defp static_path(path) do + safe = + path + |> String.trim_leading("/") + |> Path.expand("/") + |> Path.relative_to("/") + + Path.join(@ui_dir, safe) + end + + defp content_type(path) do + case Path.extname(path) do + ".html" -> "text/html; charset=utf-8" + ".css" -> "text/css; charset=utf-8" + ".js" -> "text/javascript; charset=utf-8" + ".json" -> "application/json; charset=utf-8" + ".svg" -> "image/svg+xml" + ".png" -> "image/png" + _other -> "application/octet-stream" + end + end + + defp http_response(status, headers, body) do + body = IO.iodata_to_binary(body) + reason = if status == 200, do: "OK", else: "Not Found" + + header_lines = + headers + |> Enum.concat([{"content-length", byte_size(body)}, {"connection", "close"}]) + |> Enum.map(fn {key, value} -> "#{key}: #{value}\r\n" end) + + ["HTTP/1.1 #{status} #{reason}\r\n", header_lines, "\r\n", body] + end +end + +Ourocode.TuiQaServer.run(System.argv()) + diff --git a/test/ourocode/terminal/qa_frames_test.exs b/test/ourocode/terminal/qa_frames_test.exs new file mode 100644 index 0000000..2c4514f --- /dev/null +++ b/test/ourocode/terminal/qa_frames_test.exs @@ -0,0 +1,22 @@ +defmodule Ourocode.Terminal.QaFramesTest do + use ExUnit.Case, async: true + + alias Ourocode.Terminal.QaFrames + + test "builds browser QA frames from current TUI renderer" do + frames = QaFrames.all() + + assert length(frames) >= 6 + assert Enum.all?(frames, &is_binary(&1.text)) + assert Enum.any?(frames, &(&1.title == "Answer the interview")) + assert Enum.any?(frames, &String.contains?(&1.text, "INTERVIEW")) + assert Enum.all?(frames, &(is_integer(&1.duration_ms) and &1.duration_ms > 0)) + assert Enum.all?(frames, &(is_list(&1.checks) and &1.checks != [])) + end + + test "exports frame payload as json" do + assert {:ok, decoded} = Ourocode.Json.decode(QaFrames.all_json()) + assert is_list(decoded) + assert decoded |> List.first() |> Map.fetch!("title") == "Start" + end +end