diff --git a/.github/workflows/chat-coverage.yml b/.github/workflows/chat-coverage.yml
new file mode 100644
index 000000000..8ead24f2d
--- /dev/null
+++ b/.github/workflows/chat-coverage.yml
@@ -0,0 +1,145 @@
+name: Chat interaction coverage
+
+# Two jobs, deliberately gated differently.
+#
+# self-check Runs everywhere, on every pull request that touches the gate.
+# Needs no deployment, no session and no secrets: it serves its
+# own fixture from an intercepted origin and asserts the prover
+# still calls a dead button dead. This is the part that keeps
+# the gate from rotting into something that cannot go red.
+#
+# live-sweep The real sweep, against the deployed chat surface. It needs a
+# running deployment and a minted session, so it never fires on
+# a plain pull request: run it by hand, or label a pull request
+# run-chat-coverage. The same reason owui-nightly is not a
+# per-pull-request gate.
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, labeled]
+ push:
+ branches: [main]
+ workflow_dispatch:
+ inputs:
+ surfaces:
+ description: 'Surface filter (a regular expression, blank for a full sweep)'
+ required: false
+ default: ''
+
+concurrency:
+ group: chat-coverage-${{ github.ref }}
+ cancel-in-progress: true
+
+# Read only, and narrowed to what is actually read: the self-check job asks the
+# issues API whether a surface exclusion's blocking issue has closed. Nothing
+# here writes to the repository, and a job that walks a live OAuth flow should
+# not be holding a token that could.
+permissions:
+ contents: read
+ issues: read
+
+jobs:
+ self-check:
+ name: Chat coverage self-check (no deployment needed)
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ defaults:
+ run:
+ working-directory: apps/web-console
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ # Leave no usable git credential in the runner's config for the rest
+ # of the job, which runs a browser against a live deployment.
+ persist-credentials: false
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: apps/web-console/package-lock.json
+ - name: Install dependencies
+ run: npm ci
+ - name: Install Chromium
+ run: npx playwright install --with-deps chromium
+ - name: Prover break-proof, registry and exclusion expiry
+ # GITHUB_TOKEN is what the exclusion-expiry check reads issue state
+ # with. It fails rather than skips without one, on purpose: an
+ # exclusion whose blocking issue has closed has to become a failure
+ # the day it closes, and a check that skips itself cannot do that.
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ run: npm run e2e:chat-coverage:self-check
+
+ live-sweep:
+ name: Chat coverage live sweep
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ if: >-
+ github.event_name == 'workflow_dispatch' ||
+ (github.event_name == 'pull_request' &&
+ contains(github.event.pull_request.labels.*.name, 'run-chat-coverage'))
+ defaults:
+ run:
+ working-directory: apps/web-console
+ env:
+ CHAT_URL: ${{ vars.CHAT_URL || 'https://chat-hive.scubed.co' }}
+ CONSOLE_URL: ${{ vars.CONSOLE_URL || 'https://console-hive.scubed.co' }}
+ # An existing account to mint a session for. live-auth.mjs mints through
+ # the admin one-time-token flow, so no password is read and none is
+ # changed; HIVE_QA_TESTER_PASSWORD is deliberately not passed in here.
+ HIVE_QA_AGENT_EMAIL: ${{ secrets.HIVE_QA_AGENT_EMAIL || secrets.HIVE_QA_TESTER_EMAIL }}
+ SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
+ SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
+ SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
+ COV_SURFACES: ${{ inputs.surfaces }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ # Leave no usable git credential in the runner's config for the rest
+ # of the job, which runs a browser against a live deployment.
+ persist-credentials: false
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: apps/web-console/package-lock.json
+ - name: Check the inputs the sweep cannot run without
+ # Loud, not quiet. A missing secret here used to leave the whole
+ # config matching zero files, which reports a green run of nothing.
+ run: |
+ set -euo pipefail
+ missing=""
+ for v in CHAT_URL CONSOLE_URL HIVE_QA_AGENT_EMAIL SUPABASE_URL SUPABASE_ANON_KEY SUPABASE_SERVICE_ROLE_KEY; do
+ [ -n "${!v:-}" ] || missing="$missing $v"
+ done
+ if [ -n "$missing" ]; then
+ echo "::error::the live sweep needs:$missing"
+ exit 1
+ fi
+ - name: Install dependencies
+ run: npm ci
+ - name: Install Chromium
+ run: npx playwright install --with-deps chromium
+ - name: Sweep the chat surface
+ run: npm run e2e:chat-coverage
+ - name: Upload the coverage ledger
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: chat-coverage-ledger
+ # Two named files, never a directory. Everything this suite writes
+ # itself goes through redactUrl, but a directory upload also sweeps
+ # up whatever the framework wrote beside them: Playwright's JSON and
+ # HTML reports carry raw error text, and a sign-in that outruns its
+ # ninety second wait quotes the OAuth callback with a live code and
+ # state in the timeout message. Those reports are configured to land
+ # outside this directory, and naming the files here means a future
+ # reporter added to the config cannot quietly rejoin the artifact.
+ # Traces and videos are off for the same reason and cannot be
+ # inspected by the text lint at all.
+ path: |
+ apps/web-console/chat-coverage-report/coverage.run.json
+ apps/web-console/chat-coverage-report/coverage.md
+ if-no-files-found: error
+ retention-days: 14
diff --git a/.gitignore b/.gitignore
index 39ed47e4e..85efbbe7e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -67,6 +67,9 @@ __pycache__/
# named anything other than test-results/ was landing traces and videos in
# `git status` where an `add -A` could commit them.
/apps/web-console/test-results*/
+/apps/web-console/playwright-report-chat-coverage/
+/apps/web-console/chat-coverage-report/
+/apps/web-console/e2e/chat-coverage/.auth/
# Added by code-review-graph
.code-review-graph/
diff --git a/apps/web-console/e2e/chat-coverage/auth.setup.ts b/apps/web-console/e2e/chat-coverage/auth.setup.ts
new file mode 100644
index 000000000..526f9f019
--- /dev/null
+++ b/apps/web-console/e2e/chat-coverage/auth.setup.ts
@@ -0,0 +1,36 @@
+// Signs the coverage sweep in and saves storageState.
+//
+// signInToChat lives in tests/e2e/support/live-auth.ts, and is the only
+// implementation of the chat hop: the mint itself stays in live-auth.mjs,
+// which this shells out to, so the service-role key never enters this worker's
+// module graph. It mints a session the way a magic link does, so no password
+// is read, written or rotated, then carries it through the real "Continue with
+// Hive" OIDC hop, which is the only thing that gets Open WebUI's own session
+// cookie set. See docs/live-test-auth.md.
+//
+// This project records no trace, video or screenshot (see the config): the
+// callback URL it walks through carries a live `code` and `state`, and no text
+// lint can read inside a trace or a video to catch them.
+import path from "node:path";
+
+import { test as setup } from "@playwright/test";
+
+import { signInToChat } from "../../tests/e2e/support/live-auth";
+
+const STATE = path.join(__dirname, ".auth", "state.json");
+const CHAT = process.env.CHAT_URL ?? process.env.OWUI_URL ?? "";
+const CONSOLE = process.env.CONSOLE_URL ?? "";
+const EMAIL = process.env.OWUI_E2E_EMAIL ?? process.env.HIVE_QA_AGENT_EMAIL ?? "";
+
+setup("authenticate against the chat surface", async ({ browser }) => {
+ setup.setTimeout(6 * 60_000);
+ if (!CHAT || !CONSOLE || !EMAIL) {
+ setup.skip(true, "CHAT_URL, CONSOLE_URL and an account are all required");
+ return;
+ }
+
+ const context = await browser.newContext({ viewport: { width: 1440, height: 950 } });
+ await signInToChat(context, { email: EMAIL, consoleUrl: CONSOLE, chatUrl: CHAT });
+ await context.storageState({ path: STATE });
+ await context.close();
+});
diff --git a/apps/web-console/e2e/chat-coverage/break-proof.spec.ts b/apps/web-console/e2e/chat-coverage/break-proof.spec.ts
new file mode 100644
index 000000000..846a75b1e
--- /dev/null
+++ b/apps/web-console/e2e/chat-coverage/break-proof.spec.ts
@@ -0,0 +1,239 @@
+// The checks that keep the coverage gate honest, none of which need a live
+// deployment or a session.
+//
+// They are in their own file, and their own Playwright project, because they
+// used to sit inside the live sweep behind the same credential gate: with no
+// CHAT_URL and no Supabase keys the config matched zero files, so the one test
+// that proves the detector can tell a working control from a broken one never
+// ran anywhere except on a machine already able to run the full sweep. A gate
+// nobody has watched fail is worth nothing.
+import { test, expect } from "@playwright/test";
+
+declare global {
+ interface Window {
+ /** Set by the fixture's destructive button if anything ever clicks it. */
+ __destructiveFired?: boolean;
+ }
+}
+
+import { EXCLUSIONS, REGISTRY } from "./data";
+import {
+ checkWithoutFiring,
+ enumerate,
+ exclusionFailures,
+ expiredExclusions,
+ isDeferred,
+ proveByClick,
+ sampleChatter,
+} from "./lib";
+
+const SELF_TEST_HTML = `
+
+ idle
+
+ Wired
+ Dead
+ Failing
+ Toasty
+ Pollster
+ Hanging
+ Cut Off
+ Delete Everything
+ Locked
+
+
+`;
+
+test.describe("chat coverage gate self-checks", () => {
+ test("the prover tells a working control from a broken one", async ({ page }) => {
+ test.setTimeout(120_000);
+ // Five buttons that look alike: wired does something and must be proven;
+ // dead has no handler and must be unproven; failing calls an endpoint that
+ // answers 500 and shows a toast and must be unproven, which is the case
+ // that used to come back proven twice over; toasty only shows an error and
+ // must be unproven; pollster does nothing at all while the page polls
+ // underneath it, and must be unproven despite the traffic.
+ //
+ // An intercepted origin rather than setContent, so the failing button has a
+ // same-origin endpoint whose status the prover can actually read.
+ await page.route("https://cov.selftest/**", async (route) => {
+ const url = route.request().url();
+ if (url.endsWith("/index")) {
+ await route.fulfill({ contentType: "text/html", body: SELF_TEST_HTML });
+ return;
+ }
+ if (url.endsWith("/api/poll")) {
+ await route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
+ return;
+ }
+ if (url.endsWith("/api/hang")) {
+ // Never fulfilled and never aborted: the request stays open past the
+ // settle window, exactly like an endpoint that accepts and hangs.
+ return;
+ }
+ if (url.endsWith("/api/cut")) {
+ await route.abort("connectionreset");
+ return;
+ }
+ await route.fulfill({
+ status: 500,
+ contentType: "application/json",
+ body: JSON.stringify({ detail: "boom" }),
+ });
+ });
+ await page.goto("https://cov.selftest/index");
+
+ const controls = await enumerate(page, "self-test");
+ const byName = (name: string) => {
+ const found = controls.find((c) => c.name === name);
+ expect(found, "the fixture must render " + name).toBeTruthy();
+ if (!found) throw new Error("unreachable: the assertion above already failed");
+ return found;
+ };
+
+ // What the page does with nobody touching it, the same sample the sweep
+ // takes per surface.
+ const chatter = await sampleChatter(page, 1500);
+ expect(
+ [...chatter].some((signature) => signature.endsWith("/api/poll")),
+ "the idle sample must see the fixture's own polling, or it is not sampling anything",
+ ).toBe(true);
+
+ const wired = await proveByClick(page, byName("Wired"), { ignoreRequests: chatter });
+ const dead = await proveByClick(page, byName("Dead"), { ignoreRequests: chatter });
+ const failing = await proveByClick(page, byName("Failing"), { ignoreRequests: chatter });
+ const toasty = await proveByClick(page, byName("Toasty"), { ignoreRequests: chatter });
+ const pollster = await proveByClick(page, byName("Pollster"), { ignoreRequests: chatter });
+
+ expect(wired.proven, "a working control was reported as broken: " + wired.detail).toBe(true);
+ expect(dead.proven, "a control with no handler was reported as working: " + dead.detail).toBe(
+ false,
+ );
+ expect(
+ failing.proven,
+ "a control whose endpoint answered 500 was reported as working: " + failing.detail,
+ ).toBe(false);
+ expect(failing.detail).toContain("500");
+ expect(
+ toasty.proven,
+ "a control that only raised an error was reported as working: " + toasty.detail,
+ ).toBe(false);
+ expect(toasty.detail).toContain("raised an error");
+ expect(
+ pollster.proven,
+ "a dead control was proven by the page's own background polling: " + pollster.detail,
+ ).toBe(false);
+
+ const hanging = await proveByClick(page, byName("Hanging"), { ignoreRequests: chatter });
+ expect(
+ hanging.proven,
+ "a control whose request nothing answered was reported as working: " + hanging.detail,
+ ).toBe(false);
+ expect(hanging.detail).toContain("nothing answered");
+
+ const cutOff = await proveByClick(page, byName("Cut Off"), { ignoreRequests: chatter });
+ expect(
+ cutOff.proven,
+ "a control whose request died in transport was reported as working: " + cutOff.detail,
+ ).toBe(false);
+ expect(cutOff.detail).toContain("never reached a server");
+
+ // Destructive and disabled controls are checked, not fired, and the
+ // verdict is never proof. Both used to count as coverage: the disabled one
+ // outright, which meant a broken control could be "fixed" by disabling it.
+ const destructive = await checkWithoutFiring(page, byName("Delete Everything"));
+ expect(destructive.proven, "a destructive control was reported as proven").toBe(false);
+ expect(destructive.proof).toBe("not-fired");
+ expect(isDeferred(destructive)).toBe(true);
+ // The verdict string is not the claim being tested. The claim is that the
+ // control was never activated, and only the page can answer that.
+ expect(
+ await page.evaluate(() => window.__destructiveFired === true),
+ "the harness fired a destructive control it reported as not fired",
+ ).toBe(false);
+
+ const locked = await proveByClick(page, byName("Locked"), { ignoreRequests: chatter });
+ expect(locked.proven, "a disabled control was reported as proven: " + locked.detail).toBe(
+ false,
+ );
+ expect(locked.proof).toBe("not-fired");
+ expect(locked.detail).toContain("finish the form first");
+ });
+
+ test("every inert-registry entry carries a justification", async () => {
+ const bad = REGISTRY.filter((e) => !e.key || !e.justification.trim());
+ expect(
+ bad.map((e) => e.key || "(no key)"),
+ "registry entries without a key or a justification",
+ ).toEqual([]);
+ expect(exclusionFailures(EXCLUSIONS), "malformed surface exclusions").toEqual([]);
+ });
+
+ // An exclusion ends when the thing blocking it is fixed, and the only
+ // authority on that is the issue tracker. Every path through this test that
+ // cannot read that state now fails. It used to skip on a missing token and
+ // `continue` past any non-ok response, which meant the expiry could never
+ // fire: an exclusion would outlive its own issue forever and the check would
+ // report green the whole time.
+ test("no excluded surface waits on an issue that already closed", async ({ request }) => {
+ const pending = EXCLUSIONS.filter((entry) => entry.issue !== undefined);
+ if (pending.length === 0) return;
+
+ const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? "";
+ expect(
+ token,
+ "GITHUB_TOKEN (or GH_TOKEN) is required: this check reads whether the issues that " +
+ `justify ${pending.length} surface exclusion(s) are still open, and it fails rather ` +
+ "than passes when it cannot find out",
+ ).not.toBe("");
+
+ const repo = process.env.GITHUB_REPOSITORY ?? "sakibsadmanshajib/hive";
+ const closed = new Set();
+ const unreadable: string[] = [];
+ for (const entry of pending) {
+ const url = "https://api.github.com/repos/" + repo + "/issues/" + String(entry.issue);
+ const response = await request.get(url, {
+ headers: { authorization: "Bearer " + token, accept: "application/vnd.github+json" },
+ });
+ if (!response.ok()) {
+ unreadable.push(`#${String(entry.issue)}: HTTP ${response.status()}`);
+ continue;
+ }
+ const body: unknown = await response.json();
+ const state =
+ typeof body === "object" && body !== null
+ ? new Map(Object.entries(body)).get("state")
+ : undefined;
+ if (state === "closed" && entry.issue !== undefined) closed.add(entry.issue);
+ }
+
+ expect(
+ unreadable,
+ "issue state could not be read, so an exclusion whose reason has expired would go unnoticed",
+ ).toEqual([]);
+ expect(expiredExclusions(EXCLUSIONS, closed)).toEqual([]);
+ });
+});
diff --git a/apps/web-console/e2e/chat-coverage/chat-coverage.spec.ts b/apps/web-console/e2e/chat-coverage/chat-coverage.spec.ts
new file mode 100644
index 000000000..3990f3dee
--- /dev/null
+++ b/apps/web-console/e2e/chat-coverage/chat-coverage.spec.ts
@@ -0,0 +1,811 @@
+// Live interaction coverage for the Hive chat surface.
+//
+// Owner mandate: "Every control surface, anything that can be clicked, dropped
+// down, changed, or swapped, should be tested live." This suite enumerates the
+// chat app's controls from the rendered DOM and requires each one to produce an
+// observable effect against a running deployment. It reports a ratio, writes a
+// machine-readable file so the number can be tracked between runs, and fails
+// loudly on any control that does nothing.
+//
+// Everything here needs a live deployment and a session. The checks that need
+// neither live in break-proof.spec.ts.
+import fs from "node:fs";
+import path from "node:path";
+
+import { test, expect, type Page } from "@playwright/test";
+
+import { EXCLUSIONS, FLOORS, REGISTRY, REMOVED } from "./data";
+import {
+ checkWithoutFiring,
+ enumerate,
+ exclusionFailures,
+ floorFailures,
+ flip,
+ isDeferred,
+ isDestructive,
+ identityOf,
+ isStateful,
+ locate,
+ proveByClick,
+ readState,
+ redactText,
+ redactUrl,
+ sampleChatter,
+ summarise,
+ type Control,
+ type Result,
+ type Swept,
+} from "./lib";
+import {
+ STATIC_SURFACES,
+ discoverSettingsTabs,
+ discoverWorkspaceTabs,
+ enumerateSurface,
+ relocate,
+ gotoHome,
+ openSettings,
+ type Surface,
+} from "./surfaces";
+
+const REPORT_DIR = path.join(__dirname, "../../chat-coverage-report");
+
+/**
+ * Safety net, and deliberately not a source of proof.
+ *
+ * Destructive controls are recognised by their label and never clicked (see
+ * checkWithoutFiring). This second guard exists for the control whose label
+ * says nothing about what it does: its write is aborted at the network layer
+ * so the demo account's data survives, and the control it came from is then
+ * recorded as not-fired rather than as proven, because an aborted request
+ * never reaches the server and so has no verdict to read.
+ */
+function isDestructiveRequest(method: string, url: string): boolean {
+ if (method === "DELETE") return true;
+ return /\/(delete|archive|clear|reset|unshare)/i.test(url);
+}
+
+async function saveIfPresent(page: Page): Promise {
+ const save = page.getByRole("button", { name: /^save$/i }).first();
+ if (await save.isVisible().catch(() => false)) {
+ await save.click().catch(() => {});
+ await page.waitForTimeout(1500);
+ }
+}
+
+/**
+ * Failures that say nothing about the control.
+ *
+ * The demo box has gone unreachable for minutes at a time mid-run (#815), and
+ * a click that lands in that window is indistinguishable from a dead control.
+ * Only these retry. A clean verdict of "this control does nothing" is final on
+ * the first attempt: retrying it until it passes is how an in-test loop turns
+ * itself into the retries the config sets to zero on purpose.
+ */
+function isInfrastructural(result: Result): boolean {
+ return /surface would not re-open|was not there when the surface was re-opened|no verdict within/.test(
+ result.detail,
+ );
+}
+
+type PassContext = {
+ /** Request signatures the page emits on its own, sampled while idle. */
+ chatter: ReadonlySet;
+ /** Writes the safety net aborted, appended to for the whole run. */
+ blockedWrites: string[];
+};
+
+/**
+ * Click pass: one control at a time, re-opening the surface when an action
+ * moved the app. Results are appended to the caller's array as they are
+ * produced: an earlier version returned them at the end, and one timeout in
+ * the middle of a surface threw away every proof collected before it.
+ */
+async function clickPass(
+ page: Page,
+ surface: Surface,
+ controls: Control[],
+ results: Result[],
+ ctx: PassContext,
+): Promise {
+ for (const ctl of controls) {
+ let res: Result = {
+ key: ctl.key,
+ surface: surface.id,
+ name: ctl.label || ctl.name,
+ role: ctl.role || ctl.tag,
+ proven: false,
+ proof: "none",
+ detail: "the pass produced no verdict for this control",
+ };
+
+ for (let attempt = 0; attempt < 2; attempt++) {
+ let target: Control | undefined;
+ try {
+ target = await relocate(page, surface, ctl.key);
+ } catch (err) {
+ res = {
+ ...res,
+ proven: false,
+ proof: "none",
+ detail: `surface would not re-open: ${String(err).split("\n")[0].slice(0, 120)}`,
+ };
+ continue;
+ }
+ if (!target) {
+ res = {
+ ...res,
+ proven: false,
+ proof: "none",
+ detail: "control was not there when the surface was re-opened",
+ };
+ continue;
+ }
+
+ if (isDestructive(target)) {
+ res = await checkWithoutFiring(page, target);
+ } else {
+ const before = ctx.blockedWrites.length;
+ res = await proveByClick(page, target, { ignoreRequests: ctx.chatter });
+ if (ctx.blockedWrites.length > before) {
+ // It fired a write the safety net stopped. Whatever the positive
+ // channels saw, the server never answered, so there is no verdict
+ // to record and this is not proof.
+ res = {
+ ...res,
+ proven: false,
+ proof: "not-fired",
+ detail: `fired a write that was intercepted (${ctx.blockedWrites[before]}), so the server never answered it`,
+ };
+ }
+ }
+ if (res.proven || isDeferred(res) || !isInfrastructural(res)) break;
+ }
+
+ results.push(res);
+ // eslint-disable-next-line no-console
+ console.log(`[control] ${res.proven ? res.proof : res.proof === "not-fired" ? "NOT-FIRED" : "UNPROVEN"} :: ${res.key}`);
+ }
+}
+
+/**
+ * Persistence pass. Open WebUI keeps user settings in a persisted config blob,
+ * so a toggle that flips visually and persists nothing is invisible until the
+ * next reload. Every stateful control on a persisting surface is flipped, saved,
+ * and re-read after a full reload, then put back the way it was found.
+ */
+async function persistencePass(
+ page: Page,
+ surface: Surface,
+ statefuls: Control[],
+ results: Result[],
+): Promise {
+ if (statefuls.length === 0) return;
+
+ let live = await enumerateSurface(page, surface);
+ const baselines = new Map();
+
+ for (const ctl of statefuls) {
+ const target = live.find((c) => c.key === ctl.key);
+ if (!target) continue;
+ baselines.set(ctl.key, target.state);
+ await flip(page, target).catch(() => {});
+ await page.waitForTimeout(200);
+ }
+ await saveIfPresent(page);
+
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await page.waitForTimeout(2000);
+ live = await enumerateSurface(page, surface);
+
+ const missing: Control[] = [];
+ const reverted: Control[] = [];
+ for (const ctl of statefuls) {
+ if (!baselines.has(ctl.key)) {
+ results.push(unprovable(surface, ctl, "control was not present when the pass started"));
+ continue;
+ }
+ const after = live.find((c) => c.key === ctl.key);
+ if (!after) {
+ missing.push(ctl);
+ continue;
+ }
+ const before = baselines.get(ctl.key) ?? null;
+ if (after.state !== before) {
+ results.push(proven(surface, ctl, "persisted", `${before} -> ${after.state} survived a reload`));
+ } else {
+ // Do not report a batch result as a defect. Open WebUI saves some
+ // sections on their own Save button and some immediately, and flipping a
+ // whole tab at once can leave one of them behind for reasons that have
+ // nothing to do with this control. Re-check it on its own before saying
+ // it saves nothing.
+ reverted.push(ctl);
+ }
+ }
+
+ // Put the surface back the way it was found before doing anything else.
+ for (const ctl of statefuls) {
+ if (!baselines.has(ctl.key)) continue;
+ const target = live.find((c) => c.key === ctl.key);
+ if (!target) continue;
+ const before = baselines.get(ctl.key) ?? null;
+ if (target.state === before) continue;
+ await restore(page, target, before).catch(() => {});
+ }
+ await saveIfPresent(page);
+
+ // Controls the batch could not re-locate (a flipped toggle can hide its own
+ // dependants) get an individual pass rather than a false negative.
+ for (const ctl of [...missing, ...reverted]) {
+ results.push(await persistOne(page, surface, ctl));
+ }
+}
+
+async function restore(page: Page, ctl: Control, before: string | null): Promise {
+ const el = locate(page, ctl);
+ if (ctl.role === "switch" || ctl.role === "checkbox" || ctl.type === "checkbox") {
+ await el.click({ timeout: 6000 });
+ return;
+ }
+ if (ctl.tag === "select") {
+ await el.selectOption(before ?? "", { timeout: 6000 });
+ return;
+ }
+ await el.fill(before ?? "", { timeout: 6000 });
+}
+
+async function persistOne(page: Page, surface: Surface, ctl: Control): Promise {
+ const live = await enumerateSurface(page, surface);
+ const target = live.find((c) => c.key === ctl.key);
+ if (!target) return unprovable(surface, ctl, "not present on a clean re-open");
+ const before = target.state;
+ await flip(page, target).catch(() => {});
+ await saveIfPresent(page);
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await page.waitForTimeout(2000);
+ const after = await enumerateSurface(page, surface);
+ const back = after.find((c) => c.key === ctl.key);
+ if (!back) return unprovable(surface, ctl, "disappeared after its own change");
+ const ok = back.state !== before;
+ if (ok) {
+ await restore(page, back, before).catch(() => {});
+ await saveIfPresent(page);
+ }
+ return ok
+ ? proven(surface, ctl, "persisted", `${before} -> ${back.state} survived a reload`)
+ : {
+ key: ctl.key,
+ surface: surface.id,
+ name: ctl.label || ctl.name,
+ role: ctl.role || ctl.tag,
+ proven: false,
+ proof: "none",
+ detail: `changed in the UI but reverted to ${before} after reload`,
+ };
+}
+
+function proven(surface: Surface, ctl: Control, proof: Result["proof"], detail: string): Result {
+ return {
+ key: ctl.key,
+ surface: surface.id,
+ name: ctl.label || ctl.name,
+ role: ctl.role || ctl.tag,
+ proven: true,
+ proof,
+ detail,
+ };
+}
+
+function unprovable(surface: Surface, ctl: Control, detail: string): Result {
+ return {
+ key: ctl.key,
+ surface: surface.id,
+ name: ctl.label || ctl.name,
+ role: ctl.role || ctl.tag,
+ proven: false,
+ proof: "none",
+ detail,
+ };
+}
+
+/** Non-persisting surfaces still have to show that typing lands somewhere. */
+async function valuePass(
+ page: Page,
+ surface: Surface,
+ statefuls: Control[],
+ results: Result[],
+): Promise {
+ if (statefuls.length === 0) return;
+ const live = await enumerateSurface(page, surface);
+ for (const ctl of statefuls) {
+ const target = live.find((c) => c.key === ctl.key);
+ if (!target) {
+ results.push(unprovable(surface, ctl, "not present on re-open"));
+ continue;
+ }
+ const before = target.state;
+ const written = await flip(page, target).catch(() => null);
+ await page.waitForTimeout(400);
+ const after = await readState(page, target.covId);
+ results.push(
+ after !== before
+ ? proven(surface, ctl, "value", `${before} -> ${after ?? written}`)
+ : unprovable(surface, ctl, `value did not change (still ${before})`),
+ );
+ await restore(page, target, before).catch(() => {});
+ }
+}
+
+test.describe("chat interaction coverage", () => {
+ test("every rendered chat control has a proven live effect", async ({ page }) => {
+ test.setTimeout(45 * 60_000);
+
+ const sabotage = process.env.COV_SABOTAGE ?? "";
+ if (sabotage) {
+ // Self-test hook: neuter one named control so the gate can be watched
+ // going red on a control that works in reality.
+ await page.addInitScript((needle: string) => {
+ // Every event a handler could be bound to, stopped in the capture phase
+ // so it never reaches Svelte's delegated listeners at the document root.
+ // Blocking "click" alone left pointerdown handlers running.
+ for (const type of ["pointerdown", "mousedown", "mouseup", "click", "keydown"]) {
+ window.addEventListener(
+ type,
+ (e) => {
+ const el =
+ e.target instanceof HTMLElement
+ ? e.target.closest("button, a, [role=button], [role=tab], [role=switch]")
+ : null;
+ if (!el) return;
+ const text = (el.getAttribute("aria-label") || el.textContent || "").trim();
+ if (text.toLowerCase() === needle.toLowerCase()) {
+ e.stopImmediatePropagation();
+ e.preventDefault();
+ window.__covBlocked = (window.__covBlocked ?? 0) + 1;
+ }
+ },
+ true,
+ );
+ }
+ }, sabotage);
+ }
+
+ const stage = (msg: string) => {
+ // eslint-disable-next-line no-console
+ console.log(`[stage ${new Date().toISOString().slice(11, 19)}] ${msg}`);
+ };
+
+ // Safety net for the whole run. Nothing that reaches this is proof of
+ // anything; see isDestructiveRequest.
+ const blockedWrites: string[] = [];
+ await page.route("**/*", async (route) => {
+ const req = route.request();
+ if (isDestructiveRequest(req.method(), req.url())) {
+ // Redacted, because this list is written into the ledger and the
+ // ledger is uploaded as a CI artifact.
+ blockedWrites.push(`${req.method()} ${redactUrl(req.url())}`);
+ await route.abort();
+ return;
+ }
+ await route.continue();
+ });
+
+ stage("opening the chat surface");
+ await gotoHome(page);
+
+ // Everything pushed here goes through redactText on the way out (see the
+ // ledger write below): these strings are mostly Playwright's own error
+ // messages, which quote the URLs a failed navigation walked through, and
+ // this suite signs in through an OAuth callback.
+ const errors: string[] = [];
+ errors.push(...exclusionFailures(EXCLUSIONS));
+
+ // Surface list is assembled live. Settings tabs and Workspace sections come
+ // from the DOM, so this run covers whatever the deployment actually renders.
+ // A discovery that finds nothing throws rather than returning an empty
+ // list; it is recorded and the sweep continues, so the run still produces a
+ // ledger, and the floor guard below fails on every surface it lost.
+ stage("discovering settings tabs");
+ let settings: Surface[] = [];
+ try {
+ settings = await discoverSettingsTabs(page);
+ } catch (err) {
+ errors.push(`settings discovery failed: ${String(err).split("\n")[0]}`);
+ }
+ await page.keyboard.press("Escape").catch(() => {});
+ stage(`discovering workspace sections (settings tabs: ${settings.length})`);
+ let workspace: Surface[] = [];
+ try {
+ workspace = await discoverWorkspaceTabs(page);
+ } catch (err) {
+ errors.push(`workspace discovery failed: ${String(err).split("\n")[0]}`);
+ }
+ stage(`workspace sections: ${workspace.length}`);
+ const allSurfaces: Surface[] = [
+ ...STATIC_SURFACES.filter((s) => s.id !== "user-menu"),
+ ...settings,
+ ...workspace,
+ // Last: it holds Sign Out, which ends the session.
+ ...STATIC_SURFACES.filter((s) => s.id === "user-menu"),
+ ];
+ // #782 kills a chat session about 55 minutes after sign-in, so a full sweep
+ // can outlive its own credentials. COV_SURFACES runs one slice per
+ // invocation; the tracker file below merges slices back into one number.
+ const filter = process.env.COV_SURFACES ? new RegExp(process.env.COV_SURFACES) : null;
+ // Exclusions are enumerated, owned, and tied to an open issue, and they
+ // leave the denominator entirely rather than counting as covered. The
+ // expiry test in break-proof.spec.ts fails once the blocking issue closes,
+ // so an exclusion cannot quietly outlive its reason.
+ const excludedIds = new Set(EXCLUSIONS.map((e) => e.id));
+ const inScope = (id: string) => !excludedIds.has(id) && (filter === null || filter.test(id));
+ const surfaces = allSurfaces.filter((s) => inScope(s.id));
+ expect(surfaces.length, "surface filter matched nothing").toBeGreaterThan(0);
+ // A sliced run measures a slice. It still writes a ledger and still fails
+ // on any control that does nothing, but it must never print a percentage
+ // of the whole, because its denominator is not the whole.
+ const partial = filter !== null;
+
+ const results: Result[] = [];
+ const swept: Swept[] = [];
+
+ for (const surface of surfaces) {
+ let controls: Control[] = [];
+ stage(`enumerating ${surface.id}`);
+ try {
+ controls = await enumerateSurface(page, surface);
+ swept.push({ surface: surface.id, enumerated: controls.length });
+ stage(`${surface.id}: ${controls.length} controls`);
+ } catch (err) {
+ errors.push(`${surface.id}: could not open (${String(err).split("\n")[0]})`);
+ continue;
+ }
+ if (controls.length === 0) {
+ errors.push(`${surface.id}: enumerated zero controls`);
+ continue;
+ }
+ // Destructive first, whatever kind of control it is. flip() fills every
+ // text input and then clicks Save, so a stateful control named "Clear
+ // memories" or a "Reset to default" select was being activated for real
+ // while only the click pass consulted isDestructive.
+ const destructive = controls.filter(isDestructive);
+ const rest = controls.filter((c) => !isDestructive(c));
+ const stateful = rest.filter(isStateful);
+ const clickable = rest.filter((c) => !isStateful(c));
+ for (const ctl of destructive) {
+ const target = await relocate(page, surface, ctl.key).catch(() => undefined);
+ results.push(
+ target
+ ? await checkWithoutFiring(page, target)
+ : {
+ key: ctl.key,
+ surface: surface.id,
+ name: ctl.label || ctl.name,
+ role: ctl.role || ctl.tag,
+ proven: false,
+ proof: "none",
+ detail: "destructive control was not there when the surface was re-opened",
+ },
+ );
+ }
+
+ // What this surface does while nobody is touching it. Anything in here
+ // cannot be counted as a control's network proof.
+ const chatter = await sampleChatter(page, 1500);
+ if (chatter.size > 0) {
+ stage(`${surface.id}: ignoring ${chatter.size} background request signature(s)`);
+ }
+
+ try {
+ await clickPass(page, surface, clickable, results, { chatter, blockedWrites });
+ } catch (err) {
+ errors.push(`${surface.id}: click pass aborted (${String(err).split("\n")[0]})`);
+ }
+ try {
+ // A search or filter box on a settings screen is a view control, not a
+ // setting, and nothing is wrong with it forgetting what was typed. It
+ // still has to prove that typing lands somewhere.
+ const transient = (c: Control) => /search|filter/i.test(c.label || c.name);
+ if (surface.persists) {
+ await persistencePass(page, surface, stateful.filter((c) => !transient(c)), results);
+ await valuePass(page, surface, stateful.filter(transient), results);
+ } else {
+ await valuePass(page, surface, stateful, results);
+ }
+ } catch (err) {
+ errors.push(`${surface.id}: state pass aborted (${String(err).split("\n")[0]})`);
+ }
+ // eslint-disable-next-line no-console
+ console.log(
+ `[coverage] ${surface.id}: ${results.filter((r) => r.surface === surface.id && r.proven).length}/${
+ results.filter((r) => r.surface === surface.id).length
+ } proven`,
+ );
+ }
+
+ if (sabotage) {
+ const blocked = await page
+ .evaluate(() => window.__covBlocked ?? 0)
+ .catch(() => 0);
+ stage(`sabotage "${sabotage}" intercepted ${blocked} events on the last page`);
+ }
+
+ // Denominator guard, before anything is said about the ratio. This run only
+ // ever reads the floors. Raising or lowering them is a separate, deliberate
+ // operation: scripts/update-chat-coverage-floors.mjs, run against a
+ // recorded ledger, in its own commit, with a reason. A run that could
+ // rewrite its own bar in the same pass that checks it has no bar at all,
+ // which is how a real degradation was once ratified as the new baseline.
+ errors.push(...floorFailures(FLOORS, swept, inScope));
+
+ const summary = summarise(results);
+ const registryHits = new Map();
+ for (const entry of REGISTRY) {
+ if (!entry.key) continue;
+ registryHits.set(entry.key, entry.justification.trim());
+ }
+ // A control nobody fired is not coverage, and it is not a free pass
+ // either. It fails the gate exactly like a dead control unless
+ // inert-registry.json carries a justification for its key, which is the
+ // same escape hatch every other unfireable control uses and which someone
+ // has to write down and sign. An earlier version dropped every not-fired
+ // result from the failure list, which would have turned two controls this
+ // repository's own ledger proves dead into a green gate.
+ // Exact key, or the identity the key is an instance of. Substring matching
+ // used to be the rule, and it excuses far more than whoever wrote the entry
+ // intended: "settings::button::Save" would also excuse
+ // "settings::button::Save and close". An excuse is a decision about one
+ // control, so it names one control.
+ const excused = results.filter((r) => {
+ if (r.proven) return false;
+ const justification =
+ registryHits.get(r.key) ?? registryHits.get(identityOf(r.key)) ?? "";
+ return justification.length > 0;
+ });
+ const deferred = results.filter(isDeferred);
+ const failures = results.filter(
+ (r) => !r.proven && !excused.some((e) => e.key === r.key),
+ );
+
+ fs.mkdirSync(REPORT_DIR, { recursive: true });
+ const generatedAt = new Date().toISOString();
+ const report = {
+ generatedAt,
+ target: process.env.CHAT_URL ?? process.env.OWUI_URL ?? "",
+ surfaceFilter: process.env.COV_SURFACES ?? null,
+ partial,
+ sabotage: sabotage || null,
+ summary,
+ swept,
+ excused: excused.map((r) => r.key),
+ deferred: deferred.map((r) => r.key),
+ blockedWrites,
+ surfaceErrors: errors.map(redactText),
+ results,
+ };
+ fs.writeFileSync(
+ path.join(REPORT_DIR, "coverage.run.json"),
+ JSON.stringify(report, null, 2),
+ );
+
+ // Tracker. Assertions below judge this run only; this file carries the
+ // running total across sliced runs.
+ //
+ // It is written from a full sweep only. A slice used to merge into it and
+ // then print summarise() over the merged set as though it were a total,
+ // mixing this run's verdicts with whatever a previous run happened to
+ // leave behind and labelling the result with no marker at all. A number
+ // built half from now and half from an unknown then is worse than no
+ // number.
+ const trackerPath = path.join(REPORT_DIR, "coverage.json");
+ const tracker = partial ? {} : readTracker(trackerPath);
+ if (partial) {
+ // eslint-disable-next-line no-console
+ console.log("[coverage] partial run: coverage.json left untouched");
+ }
+ // Drop stale keys before merging. A surface swept in this run is fully
+ // described by this run, so anything it used to contain and no longer does
+ // has to go, or a control that was removed (or an enumeration bug that has
+ // since been fixed) keeps dragging the ratio down forever.
+ const sweptSurfaces = new Set(results.map((r) => r.surface));
+ const seenKeys = new Set(results.map((r) => r.key));
+ for (const key of Object.keys(tracker)) {
+ if (sweptSurfaces.has(tracker[key].surface) && !seenKeys.has(key)) {
+ delete tracker[key];
+ }
+ }
+ if (!partial) {
+ for (const r of results) tracker[r.key] = { ...r, lastSeen: generatedAt };
+ fs.writeFileSync(
+ trackerPath,
+ JSON.stringify(
+ {
+ generatedAt,
+ target: report.target,
+ summary: summarise(Object.values(tracker)),
+ controls: tracker,
+ },
+ null,
+ 2,
+ ),
+ );
+ }
+ const headline = partial
+ ? [
+ `Partial run: surfaces matching ${String(process.env.COV_SURFACES)} only.`,
+ `Proven ${summary.identitiesProven} of the ${summary.identities} distinct control identities on those surfaces. No percentage is reported for a slice, because its denominator is not the app.`,
+ ]
+ : [
+ `Proven ${summary.identitiesProven}/${summary.identities} distinct control identities (${(summary.identityRatio * 100).toFixed(1)}%)`,
+ ``,
+ `Secondary, not comparable between runs: ${summary.proven}/${summary.total} raw instances (${(summary.ratio * 100).toFixed(1)}%). Instance counts move with account data, for example the number of chat rows, so only the identity figure above should be compared over time.`,
+ ];
+ const lines = [
+ `# Chat interaction coverage`,
+ ``,
+ ...headline,
+ ``,
+ `Checked but deliberately not activated: ${summary.identitiesDeferred} identities (${summary.deferred} instances). A control whose label says it destroys data is asserted present, enabled and named, and never clicked. That is not coverage and is never counted as proof.`,
+ ``,
+ `| surface | proven | not fired | total |`,
+ `| --- | ---: | ---: | ---: |`,
+ ...Object.entries(summary.surfaces).map(
+ ([s, v]) => `| ${s} | ${v.proven} | ${v.deferred} | ${v.total} |`,
+ ),
+ ``,
+ `## Unproven`,
+ ...(failures.length === 0
+ ? ["none"]
+ : failures.map((f) => `- \`${f.key}\`: ${f.detail}`)),
+ ];
+ fs.writeFileSync(path.join(REPORT_DIR, "coverage.md"), lines.join("\n"));
+
+ // eslint-disable-next-line no-console
+ console.log(
+ partial
+ ? `[coverage] PARTIAL slice ${String(process.env.COV_SURFACES)}: ${summary.identitiesProven}/${summary.identities} identities on the swept surfaces, ${summary.deferred} instances not fired. No total for a slice.`
+ : `[coverage] TOTAL ${summary.identitiesProven}/${summary.identities} identities (${(summary.identityRatio * 100).toFixed(1)}%), ${summary.proven}/${summary.total} instances, ${summary.deferred} not fired`,
+ );
+
+ // Redacted here too: a failing assertion's message is the one string that
+ // reaches the CI log, the JSON report and the HTML report at once.
+ const reportedErrors = errors.map(redactText);
+ expect(
+ reportedErrors,
+ `surfaces that could not be swept: ${reportedErrors.join(" | ")}`,
+ ).toEqual([]);
+ expect(
+ failures.map((f) => `${f.key} :: ${f.detail}`),
+ "controls with no proven effect and no registry justification",
+ ).toEqual([]);
+ });
+
+ test("surfaces removed from upstream Open WebUI stay absent", async ({ page }) => {
+ test.setTimeout(10 * 60_000);
+ await gotoHome(page);
+
+ const found: string[] = [];
+ const seen: Array<{ surface: string; label: string }> = [];
+ const enumerated = new Map();
+
+ const collect = async (surface: string) => {
+ const controls = await enumerate(page, surface);
+ enumerated.set(surface, controls.length);
+ for (const c of controls) {
+ seen.push({ surface, label: (c.name || c.label).trim() });
+ }
+ };
+
+ await openSettings(page);
+ await collect("settings:");
+ await page.keyboard.press("Escape").catch(() => {});
+
+ await gotoHome(page);
+ await page.getByRole("button", { name: /user menu/i }).first().click();
+ await page.waitForTimeout(1200);
+ await collect("user-menu");
+ await page.keyboard.press("Escape").catch(() => {});
+
+ await page.goto("/workspace", { waitUntil: "domcontentloaded" });
+ await page.waitForTimeout(3000);
+ await collect("workspace");
+
+ // Absence only means something if the enumeration happened. A settings
+ // modal that never opened produces the same empty list as a settings modal
+ // with nothing forbidden in it, and this test used to call that a pass.
+ for (const [surface, count] of enumerated) {
+ expect(
+ count,
+ `${surface} enumerated no controls at all, so it proves nothing about what was removed from it`,
+ ).toBeGreaterThan(0);
+ }
+
+ for (const rule of REMOVED.forbiddenControls) {
+ const surfaceRe = new RegExp(rule.surface);
+ const labelRe = new RegExp(rule.label);
+ for (const item of seen) {
+ if (surfaceRe.test(item.surface) && labelRe.test(item.label)) {
+ found.push(`${item.surface} still renders "${item.label}" (${rule.issue})`);
+ }
+ }
+ }
+
+ for (const route of REMOVED.forbiddenRoutes) {
+ await page.goto(route.path, { waitUntil: "domcontentloaded" });
+ await page.waitForTimeout(2500);
+ const landed = new URL(page.url()).pathname;
+ // A bounce to the sign-in page is a dead session, not a removed route.
+ // Treating it as removal is how this test would pass against an app
+ // nobody is signed in to.
+ expect(
+ landed.startsWith("/auth"),
+ `${route.path} redirected to the sign-in page, so the session died and absence proves nothing`,
+ ).toBe(false);
+ const body = (await page.locator("body").innerText()).toLowerCase();
+ expect(
+ body.trim().length,
+ `${route.path} rendered an empty document, so absence proves nothing`,
+ ).toBeGreaterThan(0);
+ const gone =
+ body.includes("not found") || body.includes("404") || !landed.startsWith(route.path);
+ if (!gone) found.push(`${route.path} still renders a page (${route.issue})`);
+ }
+
+ expect(found, "removed upstream surfaces are still reachable").toEqual([]);
+ });
+});
+
+type TrackedResult = Result & { lastSeen: string };
+
+/**
+ * The running tracker from a previous slice, if there is one. Read defensively:
+ * a truncated or hand-edited file is not worth failing a 45 minute sweep over,
+ * and starting from empty only costs this run its cross-slice history.
+ */
+function readTracker(file: string): Record {
+ if (!fs.existsSync(file)) return {};
+ const out: Record = {};
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
+ } catch {
+ return out;
+ }
+ if (typeof parsed !== "object" || parsed === null) return out;
+ const controls = Object.entries(parsed).find(([name]) => name === "controls")?.[1];
+ if (typeof controls !== "object" || controls === null) return out;
+ for (const [key, value] of Object.entries(controls)) {
+ if (typeof value !== "object" || value === null) continue;
+ const entry = new Map(Object.entries(value));
+ const surface = entry.get("surface");
+ const proofValue = entry.get("proof");
+ if (typeof surface !== "string" || typeof proofValue !== "string") continue;
+ out[key] = {
+ key,
+ surface,
+ name: String(entry.get("name") ?? ""),
+ role: String(entry.get("role") ?? ""),
+ proven: entry.get("proven") === true,
+ proof: proofOf(proofValue),
+ detail: String(entry.get("detail") ?? ""),
+ lastSeen: String(entry.get("lastSeen") ?? ""),
+ };
+ }
+ return out;
+}
+
+const PROOF_NAMES = [
+ "navigate",
+ "network",
+ "dom",
+ "overlay",
+ "download",
+ "filechooser",
+ "value",
+ "persisted",
+ "not-fired",
+ "none",
+] as const;
+
+function proofOf(value: string): Result["proof"] {
+ const match = PROOF_NAMES.find((name) => name === value);
+ return match ?? "none";
+}
diff --git a/apps/web-console/e2e/chat-coverage/data.ts b/apps/web-console/e2e/chat-coverage/data.ts
new file mode 100644
index 000000000..437e3dbe9
--- /dev/null
+++ b/apps/web-console/e2e/chat-coverage/data.ts
@@ -0,0 +1,33 @@
+// The committed data files this gate is configured by, read once and
+// validated on the way in.
+//
+// Separate from the specs so the live sweep and the offline self-checks read
+// exactly the same objects through exactly the same validators. A malformed
+// file fails loudly here rather than turning into an undefined floor or an
+// empty exclusion list at the moment the gate is deciding whether to fail.
+import fs from "node:fs";
+import path from "node:path";
+
+import {
+ parseDataDriven,
+ parseExclusions,
+ parseFloors,
+ parseRegistry,
+ parseRemoved,
+ type Floors,
+ type InertEntry,
+ type RemovedSurfaces,
+ type SurfaceExclusion,
+} from "./lib";
+
+export const FLOOR_FILE = path.join(__dirname, "surface-floors.json");
+
+function read(file: string): unknown {
+ return JSON.parse(fs.readFileSync(path.join(__dirname, file), "utf8"));
+}
+
+export const FLOORS: Floors = parseFloors(read("surface-floors.json"));
+export const DATA_DRIVEN: Set = parseDataDriven(read("surface-floors.json"));
+export const EXCLUSIONS: SurfaceExclusion[] = parseExclusions(read("surface-exclusions.json"));
+export const REGISTRY: InertEntry[] = parseRegistry(read("inert-registry.json"));
+export const REMOVED: RemovedSurfaces = parseRemoved(read("removed-surfaces.json"));
diff --git a/apps/web-console/e2e/chat-coverage/inert-registry.json b/apps/web-console/e2e/chat-coverage/inert-registry.json
new file mode 100644
index 000000000..2479b1dbc
--- /dev/null
+++ b/apps/web-console/e2e/chat-coverage/inert-registry.json
@@ -0,0 +1,4 @@
+{
+ "$comment": "Controls allowed to have no observable effect. Every entry needs a real justification; an entry with an empty or missing justification fails the gate on its own. Keys are matched as substrings against the control key emitted in coverage.json (surface::role::label).",
+ "allowed": []
+}
diff --git a/apps/web-console/e2e/chat-coverage/lib.ts b/apps/web-console/e2e/chat-coverage/lib.ts
new file mode 100644
index 000000000..7a060b746
--- /dev/null
+++ b/apps/web-console/e2e/chat-coverage/lib.ts
@@ -0,0 +1,1117 @@
+// Live interaction-coverage engine for the Hive chat surface (Open WebUI).
+//
+// The rule this file exists to enforce: every control the chat app renders has
+// to be shown to *do something*, live, against a running deployment. Nothing
+// here carries a hand-written list of controls. The control set is read out of
+// the rendered DOM on every run, so a control added upstream (or by a Hive
+// patch) is enumerated the first time it renders and has to earn a proof or
+// fail the gate. A hand-written inventory would go stale silently, which is
+// the exact failure mode the owner named.
+import type { Page, Locator } from "@playwright/test";
+
+import { redactText, redactUrl } from "../../tests/e2e/support/redact";
+
+export { redactText, redactUrl };
+
+declare global {
+ interface Window {
+ /** Mutation records counted by the watcher below. */
+ __covMut?: number;
+ __covObs?: MutationObserver;
+ /** Events the sabotage hook intercepted, for the self-test. */
+ __covBlocked?: number;
+ }
+}
+
+// Everything a user can reach with a mouse or a keyboard. Roles are included
+// alongside tags because Open WebUI builds switches, tabs and menu items out of
+// plain s with ARIA roles.
+export const SELECTOR = [
+ "a[href]",
+ "button",
+ "input:not([type=hidden])",
+ "select",
+ "textarea",
+ "summary",
+ "[role=button]",
+ "[role=link]",
+ "[role=menuitem]",
+ "[role=menuitemcheckbox]",
+ "[role=menuitemradio]",
+ "[role=tab]",
+ "[role=switch]",
+ "[role=checkbox]",
+ "[role=radio]",
+ "[role=option]",
+ "[role=combobox]",
+ "[role=slider]",
+ '[contenteditable="true"]',
+ '[tabindex]:not([tabindex="-1"])',
+].join(", ");
+
+export type Control = {
+ key: string;
+ surface: string;
+ covId: string;
+ tag: string;
+ type: string;
+ role: string;
+ name: string;
+ label: string;
+ disabled: boolean;
+ reason: string;
+ state: string | null;
+ href: string;
+ contentEditable: boolean;
+};
+
+export type Proof =
+ | "navigate"
+ | "network"
+ | "dom"
+ | "overlay"
+ | "download"
+ | "filechooser"
+ | "value"
+ | "persisted"
+ // Checked but deliberately never activated: a control whose own name says it
+ // destroys data, or one the app renders disabled. Its own category because
+ // it is NOT proof that the control works, and it is NOT excused either: the
+ // gate fails on it unless inert-registry.json carries a justification for
+ // that key. Two earlier versions of this file counted both as coverage, and
+ // a disabled control with a title attribute was worth as much as a working
+ // one, so #846 could have been "fixed" by disabling Admin Settings.
+ | "not-fired";
+
+export type Result = {
+ key: string;
+ surface: string;
+ name: string;
+ role: string;
+ proven: boolean;
+ proof: Proof | "none";
+ detail: string;
+};
+
+/**
+ * A control that was checked but not activated. Not proven, and not a
+ * failure either: it is carried in its own bucket so it can never be read as
+ * coverage, and so the count of controls nobody dares fire is visible.
+ */
+export function isDeferred(result: Result): boolean {
+ return result.proof === "not-fired";
+}
+
+/**
+ * Assets and background chatter never count as evidence that a control did
+ * something. socket.io is the important exclusion: Open WebUI long-polls it
+ * every few seconds, so counting it would hand a free pass to every control
+ * whose click happened to land near a poll.
+ */
+const NOT_MOUNTED = new Set([404, 405, 410, 501]);
+
+/**
+ * Wording an application uses to say that what the user asked for did not
+ * happen. Narrow on purpose: a live region that announces a result is not a
+ * failure, and treating one as a failure trades a false pass for a false
+ * accusation.
+ */
+const FAILURE_TEXT =
+ /\b(error|failed|failure|unable to|could ?n[o']t|went wrong|try again|invalid|denied|unauthori[sz]ed|forbidden|not found|timed out|too many requests)\b/i;
+
+function isMeaningfulRequest(url: string): boolean {
+ if (/\.(js|mjs|css|png|jpe?g|svg|webp|woff2?|ico|map)(\?|$)/i.test(url)) return false;
+ if (/\/_app\/|\/static\/|\/favicon/i.test(url)) return false;
+ if (/\/socket\.io\/|\/ws(\/|$)|\/__nextjs/i.test(url)) return false;
+ return true;
+}
+
+/**
+ * The shape of a request, with everything that varies between two otherwise
+ * identical calls removed: the query string, and every id-looking path
+ * segment. Two polls of the same endpoint collapse to one signature, so a poll
+ * observed while nothing was happening can be recognised again when it lands
+ * inside a control's settle window.
+ */
+export function requestSignature(url: string): string {
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return url;
+ }
+ const path = parsed.pathname
+ .split("/")
+ .map((segment) =>
+ /^[0-9a-f-]{8,}$/i.test(segment) || /^\d+$/.test(segment) ? "#" : segment,
+ )
+ .join("/");
+ return parsed.origin + path;
+}
+
+/**
+ * Requests the page makes on its own, sampled with nobody touching it.
+ *
+ * Open WebUI polls REST endpoints as well as socket.io, and any poll that
+ * lands inside a control's settle window used to be counted as that control's
+ * network proof. Only socket.io was excluded, by name, which covered one
+ * poller out of several. Sampling the idle page instead names them all, for
+ * whatever the deployment actually does, and nothing that fires while nobody
+ * is clicking can be evidence that a click did something.
+ */
+export async function sampleChatter(page: Page, ms: number): Promise> {
+ const seen = new Set();
+ const onRequest = (r: { url: () => string }) => {
+ const u = r.url();
+ if (isMeaningfulRequest(u)) seen.add(requestSignature(u));
+ };
+ page.on("request", onRequest);
+ await page.waitForTimeout(ms);
+ page.off("request", onRequest);
+ return seen;
+}
+
+// Runs inside the page. Tags every visible interactive element with a
+// data-cov-id so the prover can find it again after a re-render, and returns a
+// descriptor for each. Labels for unnamed controls (Open WebUI renders ~40
+// nameless toggle buttons in Settings > Interface alone) are lifted from the
+// nearest ancestor that carries readable text, which is what a sighted user
+// reads as the control's name.
+const ENUMERATE = (args: { selector: string; surface: string; scope: string | null }) => {
+ const root = args.scope ? document.querySelector(args.scope) : document.body;
+ if (!root) return [];
+ const visible = (el: Element): boolean => {
+ const r = el.getBoundingClientRect();
+ if (r.width === 0 && r.height === 0) return false;
+ if (el.closest('[aria-hidden="true"]')) return false;
+ const s = getComputedStyle(el);
+ return s.visibility !== "hidden" && s.display !== "none" && s.opacity !== "0";
+ };
+ const direct = (el: Element): string =>
+ (
+ el.getAttribute("aria-label") ||
+ el.getAttribute("placeholder") ||
+ el.getAttribute("title") ||
+ (el instanceof HTMLElement ? el.innerText : "") ||
+ el.getAttribute("name") ||
+ ""
+ )
+ .replace(/\s+/g, " ")
+ .trim()
+ .slice(0, 70);
+ const ambient = (el: Element): string => {
+ let node: Element | null = el.parentElement;
+ for (let i = 0; i < 5 && node; i++, node = node.parentElement) {
+ const text =
+ node instanceof HTMLElement ? node.innerText.replace(/\s+/g, " ").trim() : "";
+ if (text.length > 0 && text.length < 140) return text.slice(0, 70);
+ }
+ return "";
+ };
+ const stateOf = (el: Element): string | null => {
+ const tag = el.tagName.toLowerCase();
+ const role = el.getAttribute("role") ?? "";
+ const text = el instanceof HTMLElement ? el.innerText : "";
+ if (el.getAttribute("contenteditable") === "true") return text;
+ if (role === "switch" || role === "checkbox") {
+ return el.getAttribute("aria-checked") ?? text.trim();
+ }
+ if (el instanceof HTMLSelectElement) return el.value;
+ if (el instanceof HTMLInputElement) {
+ if (el.type === "checkbox" || el.type === "radio") return String(el.checked);
+ if (el.type === "file") return null;
+ return el.value;
+ }
+ if (el instanceof HTMLTextAreaElement) return el.value;
+ return null;
+ };
+ const seen = new Map();
+ const out: Control[] = [];
+ // Clear the previous enumeration's tags first. Ids are positional
+ // (surface#index), so an element that was tagged on the last pass and is no
+ // longer visible keeps an id the next pass hands to a different element, and
+ // locate() then matches two nodes and its click is ambiguous.
+ document.querySelectorAll("[data-cov-id]").forEach((el) => el.removeAttribute("data-cov-id"));
+ const els = [...root.querySelectorAll(args.selector)].filter(visible);
+ els.forEach((el, i) => {
+ const tag = el.tagName.toLowerCase();
+ const role = el.getAttribute("role") ?? "";
+ const name = direct(el);
+ const label = name || ambient(el);
+ const base = `${args.surface}::${role || tag}::${label || "(unnamed)"}`;
+ const n = (seen.get(base) ?? 0) + 1;
+ seen.set(base, n);
+ const covId = `${args.surface}#${i}`;
+ el.setAttribute("data-cov-id", covId);
+ out.push({
+ key: n > 1 ? `${base}#${n}` : base,
+ surface: args.surface,
+ covId,
+ tag,
+ type: el.getAttribute("type") ?? "",
+ role,
+ name,
+ label,
+ disabled:
+ el.hasAttribute("disabled") || el.getAttribute("aria-disabled") === "true",
+ // Human-readable text only. aria-describedby used to be read here, and
+ // it holds an element id, so "tooltip-7" was accepted as an explanation
+ // of why a control is disabled.
+ reason: el.getAttribute("title") ?? el.getAttribute("data-disabled-reason") ?? "",
+ state: stateOf(el),
+ href: el.getAttribute("href") ?? "",
+ contentEditable: el.getAttribute("contenteditable") === "true",
+ });
+ });
+ return out;
+};
+
+export async function enumerate(
+ page: Page,
+ surface: string,
+ scope?: string,
+): Promise {
+ // No cast: ENUMERATE builds Control values, so page.evaluate carries the
+ // type across on its own.
+ return page.evaluate(ENUMERATE, {
+ selector: SELECTOR,
+ surface,
+ scope: scope ?? null,
+ });
+}
+
+export function locate(page: Page, ctl: Control): Locator {
+ return page.locator(`[data-cov-id="${ctl.covId}"]`);
+}
+
+async function startWatch(page: Page): Promise<() => Promise<{ mutations: number }>> {
+ await page.evaluate(() => {
+ window.__covObs?.disconnect();
+ window.__covMut = 0;
+ const obs = new MutationObserver((records) => {
+ window.__covMut = (window.__covMut ?? 0) + records.length;
+ });
+ obs.observe(document.body, {
+ subtree: true,
+ childList: true,
+ attributes: true,
+ characterData: true,
+ });
+ window.__covObs = obs;
+ });
+ return async () => {
+ const mutations = await page.evaluate(() => {
+ const n = window.__covMut ?? 0;
+ window.__covObs?.disconnect();
+ return n;
+ });
+ return { mutations };
+ };
+}
+
+/**
+ * Error surfaces on screen right now.
+ *
+ * Open WebUI reports a failed action as a toast, which is both a change to the
+ * render and usually the tail of a request, so without this the two strongest
+ * evidence channels both vote to prove a control that visibly did not work.
+ */
+async function errorSurfaces(page: Page): Promise {
+ return page
+ .evaluate(() => {
+ const nodes = document.querySelectorAll(
+ '[role="alert"], [aria-live="assertive"], [data-sonner-toast], .toast-error, .Toastify__toast--error',
+ );
+ const out: string[] = [];
+ nodes.forEach((node) => {
+ if (node.getAttribute("role") === "alertdialog") return;
+ const text = (node.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 200);
+ if (text !== "") out.push(text);
+ });
+ return out;
+ })
+ .catch((): string[] => []);
+}
+
+async function overlayCount(page: Page): Promise {
+ return page.evaluate(
+ () =>
+ document.querySelectorAll('[role="dialog"], [role="menu"], [role="listbox"], [data-melt-dialog-content], .modal')
+ .length,
+ );
+}
+
+/**
+ * A cheap fingerprint of what the user can currently see.
+ *
+ * Counting mutation records alone could not tell a settings tab switch (6 or 7
+ * records) from idle churn, and any threshold that cleared the churn also
+ * swallowed the tab switch. Comparing the rendered text before and after says
+ * plainly whether the screen changed.
+ */
+async function visibleSignature(page: Page): Promise {
+ return page
+ .evaluate(() => {
+ const scope =
+ document.querySelector('[role="dialog"]') ??
+ document.querySelector("main") ??
+ document.body;
+ const text = scope instanceof HTMLElement ? scope.innerText : "";
+ // Digits are stripped because relative timestamps ("2d", "3m ago") tick
+ // on their own; without this a control that did nothing still came back
+ // proven whenever a clock in the sidebar rolled over during its click.
+ return text.replace(/\d+/g, "#").replace(/\s+/g, " ").trim().slice(0, 600);
+ })
+ .catch(() => "");
+}
+
+/**
+ * Click a control and report the first observable consequence: a navigation, a
+ * meaningful network call, a new overlay, a download, a file chooser, or a
+ * non-trivial DOM mutation. No consequence at all is a failure, which is the
+ * whole point of the gate.
+ */
+export type ProveOptions = {
+ settleMs?: number;
+ budgetMs?: number;
+ /**
+ * Request signatures the page produces on its own, from sampleChatter. A
+ * request whose signature is in here is background noise and can never be
+ * this control's proof.
+ */
+ ignoreRequests?: ReadonlySet;
+};
+
+export async function proveByClick(
+ page: Page,
+ ctl: Control,
+ opts: ProveOptions = {},
+): Promise {
+ // Hard budget. A single control that wedges the page (a native dialog, a
+ // beforeunload prompt, a permission request) must not be able to consume the
+ // whole run; it gets reported and the sweep moves on.
+ const budget = opts.budgetMs ?? 40_000;
+ return Promise.race([
+ proveByClickInner(page, ctl, opts),
+ new Promise((resolve) =>
+ setTimeout(
+ () =>
+ resolve({
+ key: ctl.key,
+ surface: ctl.surface,
+ name: ctl.label || ctl.name,
+ role: ctl.role || ctl.tag,
+ proven: false,
+ proof: "none",
+ detail: `no verdict within ${budget}ms (the page stopped responding to the harness)`,
+ }),
+ budget,
+ ),
+ ),
+ ]);
+}
+
+async function proveByClickInner(
+ page: Page,
+ ctl: Control,
+ opts: ProveOptions = {},
+): Promise {
+ const base = { key: ctl.key, surface: ctl.surface, name: ctl.label || ctl.name, role: ctl.role || ctl.tag };
+ if (ctl.disabled) {
+ // Never proof. A disabled control demonstrates nothing about whether the
+ // thing behind it works, and counting one as coverage means a broken
+ // control can be "fixed" by disabling it and the ratio goes UP. It needs a
+ // justification in inert-registry.json like any other unfireable control.
+ const reason = ctl.reason.trim();
+ return {
+ ...base,
+ proven: false,
+ proof: "not-fired",
+ detail: reason ? `disabled: ${reason.slice(0, 100)}` : "disabled with no stated reason",
+ };
+ }
+
+ const chatter = opts.ignoreRequests ?? new Set();
+ const requests: string[] = [];
+ // The request objects seen during THIS control's window, so a response can
+ // be matched back to a request the click caused. Without the identity check
+ // below, a response to something the page had already started before the
+ // click (a slow fetch from opening the surface) landed inside the settle
+ // window and was counted as this control's proof.
+ const mine = new Set();
+ const onRequest = (r: { url: () => string }) => {
+ const u = r.url();
+ if (!isMeaningfulRequest(u)) return;
+ if (chatter.has(requestSignature(u))) return;
+ mine.add(r);
+ requests.push(u);
+ };
+ page.on("request", onRequest);
+ // Statuses, not just URLs. This file never registered a response listener at
+ // all, so a click that answered 500 and rendered an error toast came back
+ // proven twice over, once on the request and once on the toast.
+ const failed: string[] = [];
+ // Answered, and answered well. A request on its own is not evidence that
+ // anything happened at the other end: a control pointing at an endpoint that
+ // hangs emits a request, never gets a response, and used to be proven for
+ // ever on the strength of the request alone. That is the same "no server
+ // verdict" hole that aborted writes had.
+ const answered: string[] = [];
+ const onResponse = (r: {
+ status: () => number;
+ url: () => string;
+ request: () => { method: () => string };
+ }) => {
+ const u = r.url();
+ if (!isMeaningfulRequest(u)) return;
+ if (chatter.has(requestSignature(u))) return;
+ if (!mine.has(r.request())) return;
+ if (r.status() >= 400) {
+ failed.push(String(r.status()) + " " + r.request().method() + " " + redactUrl(u));
+ return;
+ }
+ answered.push(String(r.status()) + " " + r.request().method() + " " + redactUrl(u));
+ };
+ page.on("response", onResponse);
+ // A request that never reaches a server emits `request` and then
+ // `requestfailed`, and no `response` at all. Without this listener a
+ // connection reset, a DNS failure, or a write this suite's own safety net
+ // aborted looks exactly like a successful call on the request channel, and
+ // is credited as network proof with no verdict behind it.
+ const transportFailed: string[] = [];
+ const onRequestFailed = (r: {
+ url: () => string;
+ method: () => string;
+ failure: () => { errorText: string } | null;
+ }) => {
+ const u = r.url();
+ if (!isMeaningfulRequest(u)) return;
+ if (chatter.has(requestSignature(u))) return;
+ // Same identity check as onResponse: a failure only counts against this
+ // control when it belongs to a request this control made.
+ if (!mine.has(r)) return;
+ transportFailed.push(
+ (r.failure()?.errorText ?? "failed") + " " + r.method() + " " + redactUrl(u),
+ );
+ };
+ page.on("requestfailed", onRequestFailed);
+ let download = false;
+ let filechooser = false;
+ const onDownload = () => {
+ download = true;
+ };
+ // Cancel it immediately. An open file chooser is modal to the page, and a
+ // registered handler that never answers leaves it open, which hangs every
+ // action that follows.
+ const onFileChooser = (chooser: { setFiles: (f: string[]) => Promise }) => {
+ filechooser = true;
+ void chooser.setFiles([]).catch(() => {});
+ };
+ page.on("download", onDownload);
+ page.on("filechooser", onFileChooser);
+ // External links carry target=_blank, so their whole effect is a new tab.
+ let popup = false;
+ const onPopup = () => {
+ popup = true;
+ };
+ page.context().on("page", onPopup);
+ // Cleanup in a finally, not on the happy path. Everything below can throw
+ // (the page can die mid-settle, and the budget race can abandon this call
+ // entirely), and a throw used to leave five listeners and a MutationObserver
+ // attached for the rest of a 45 minute run.
+ const release = () => {
+ page.off("request", onRequest);
+ page.off("response", onResponse);
+ page.off("requestfailed", onRequestFailed);
+ page.off("download", onDownload);
+ page.off("filechooser", onFileChooser);
+ page.context().off("page", onPopup);
+ };
+
+ try {
+ return await proveInner();
+ } finally {
+ release();
+ await page
+ .evaluate(() => {
+ window.__covObs?.disconnect();
+ })
+ .catch(() => {});
+ }
+
+ async function proveInner(): Promise {
+ const urlBefore = page.url();
+ const overlaysBefore = await overlayCount(page);
+ const signatureBefore = await visibleSignature(page);
+ const errorsBefore = new Set(await errorSurfaces(page));
+ const stop = await startWatch(page);
+
+ let clickError = "";
+ try {
+ await locate(page, ctl).click({ timeout: 6000, trial: false });
+ } catch (err) {
+ // A control that refuses the click (intercepted, detached, not stable) is
+ // reported rather than swallowed: silence here would read as "no effect"
+ // for a reason that has nothing to do with the control's wiring.
+ clickError = String(err).split("\n")[0].slice(0, 140);
+ }
+ await page.waitForTimeout(opts.settleMs ?? 1200);
+
+ const { mutations } = await stop();
+ const urlAfter = page.url();
+ const overlaysAfter = await overlayCount(page);
+ const signatureAfter = await visibleSignature(page);
+ const errorsAfter = (await errorSurfaces(page)).filter(
+ (text) => !errorsBefore.has(text) && FAILURE_TEXT.test(text),
+ );
+ // Failure first, and before any positive channel. Evidence that an
+ // activation was refused is not evidence that the control works, and a
+ // refusal produces a request and a rendered change just like a success does.
+ // No harnessSuppliedInput exception is carved out here: nothing in this
+ // sweep feeds a control synthetic input before clicking it, because typed
+ // values are proven by reading state back rather than by clicking, so every
+ // 4xx and 5xx a click provokes is unexpected by construction.
+ const notMounted = failed.filter((e) => {
+ const code = Number.parseInt(e.slice(0, 3), 10);
+ return NOT_MOUNTED.has(code) || code >= 500;
+ });
+ const refused = failed.filter((e) => !notMounted.includes(e));
+ if (notMounted.length > 0)
+ return {
+ ...base,
+ proven: false,
+ proof: "none",
+ detail: "called an endpoint that is not mounted or that failed: " + notMounted.slice(0, 2).join(", ").slice(0, 180),
+ };
+ if (refused.length > 0)
+ return {
+ ...base,
+ proven: false,
+ proof: "none",
+ detail: "the server refused the request it sent: " + refused.slice(0, 2).join(", ").slice(0, 180),
+ };
+ if (errorsAfter.length > 0)
+ return {
+ ...base,
+ proven: false,
+ proof: "none",
+ detail: "raised an error to the user: " + errorsAfter.slice(0, 2).join(" | ").slice(0, 180),
+ };
+ if (transportFailed.length > 0)
+ return {
+ ...base,
+ proven: false,
+ proof: "none",
+ detail:
+ "its request never reached a server: " +
+ transportFailed.slice(0, 2).join(", ").slice(0, 180),
+ };
+
+ if (download) return { ...base, proven: true, proof: "download", detail: "download started" };
+ if (filechooser) return { ...base, proven: true, proof: "filechooser", detail: "file chooser opened" };
+ if (popup) return { ...base, proven: true, proof: "navigate", detail: "opened a new tab" };
+ if (urlAfter !== urlBefore)
+ return {
+ ...base,
+ proven: true,
+ proof: "navigate",
+ detail: `${redactUrl(urlBefore)} -> ${redactUrl(urlAfter)}`,
+ };
+ // Either direction. Closing a modal is as real an effect as opening one, and
+ // an increase-only check reported every close button as dead.
+ if (overlaysAfter !== overlaysBefore)
+ return { ...base, proven: true, proof: "overlay", detail: `overlays ${overlaysBefore} -> ${overlaysAfter}` };
+ // A request only counts once a server has answered it, and answered without
+ // an error. Accepting the request alone proved a control pointing at a
+ // hanging endpoint for ever, which is the same missing verdict that made an
+ // aborted write look like proof. Needing a 2xx or 3xx costs no per-control
+ // inventory: it is the same generic rule for every control on every surface.
+ if (answered.length > 0)
+ return {
+ ...base,
+ proven: true,
+ proof: "network",
+ detail: answered.slice(0, 3).join(", ").slice(0, 200),
+ };
+ if (requests.length > 0 && signatureAfter === signatureBefore)
+ return {
+ ...base,
+ proven: false,
+ proof: "none",
+ detail:
+ "sent a request that nothing answered inside the settle window: " +
+ requests.slice(0, 2).map(redactUrl).join(", ").slice(0, 160),
+ };
+ if (signatureAfter !== signatureBefore)
+ return { ...base, proven: true, proof: "dom", detail: "what is on screen changed" };
+ // Deliberately no mutation-count fallback. A "more than eight records"
+ // threshold used to sit here, and nothing ever measured what an idle page
+ // emits in the same window, so it was a number that could pass a control on
+ // background churn. The rendered-text signature above is the measured test;
+ // the count survives only in the failure detail, where it explains rather
+ // than decides.
+ return {
+ ...base,
+ proven: false,
+ proof: "none",
+ detail: clickError || `no effect (mutations=${mutations})`,
+ };
+ }
+}
+
+/**
+ * A control whose OWN name says it destroys data.
+ *
+ * Matched on the name rather than on the request, because the decision has to
+ * be taken before the click. Matched on the control's own accessible name and
+ * never on the ambient text lifted from an ancestor: `label` falls back to up
+ * to 140 characters of the surrounding panel, so a single "Delete All Chats"
+ * heading in Settings > Data Controls made every button in that panel
+ * destructive, which both hid two controls the same ledger proves dead and
+ * threw away three controls that were genuinely proven.
+ *
+ * A control with no accessible name at all is not classified as destructive:
+ * unnamed and destructive is its own defect, and the gate reports it as an
+ * unnamed control rather than quietly declining to test it.
+ */
+export const DESTRUCTIVE = /\b(delete|archive|clear|reset|remove|unshare|erase|wipe)\b/i;
+
+export function isDestructive(ctl: Control): boolean {
+ return DESTRUCTIVE.test(ctl.name);
+}
+
+/**
+ * The check a destructive control gets instead of a click.
+ *
+ * It cannot be activated: the account is shared and the data is the demo's.
+ * It cannot be activated with its write aborted at the network layer either,
+ * which is what this suite used to do, because an aborted request has no
+ * server verdict at all, so a button wired to an endpoint that no longer
+ * exists came back proven. What is left that is worth asserting is that the
+ * control is there, that it is offered to the user rather than sitting inert
+ * and disabled with no explanation, and that it carries a name and, if it is
+ * a link, a destination. That is recorded as "not-fired", never as proof.
+ */
+export async function checkWithoutFiring(page: Page, ctl: Control): Promise {
+ const base = {
+ key: ctl.key,
+ surface: ctl.surface,
+ name: ctl.label || ctl.name,
+ role: ctl.role || ctl.tag,
+ };
+ const el = locate(page, ctl);
+ const visible = await el.isVisible().catch(() => false);
+ if (!visible) {
+ return { ...base, proven: false, proof: "none", detail: "destructive control is not on screen" };
+ }
+ if (ctl.disabled) {
+ const reason = ctl.reason.trim();
+ return {
+ ...base,
+ proven: false,
+ proof: "not-fired",
+ detail: reason ? `destructive and disabled: ${reason.slice(0, 90)}` : "destructive and disabled with no stated reason",
+ };
+ }
+ if (ctl.name.trim() === "") {
+ return {
+ ...base,
+ proven: false,
+ proof: "none",
+ detail: "destructive control carries no accessible name, so nobody can tell what it destroys",
+ };
+ }
+ if (ctl.tag === "a" && ctl.href.trim() === "") {
+ return { ...base, proven: false, proof: "none", detail: "destructive link has no href" };
+ }
+ return {
+ ...base,
+ proven: false,
+ proof: "not-fired",
+ detail:
+ "destructive by its own name, so it was checked (present, enabled, named) and deliberately not activated",
+ };
+}
+
+export function isStateful(ctl: Control): boolean {
+ if (ctl.role === "switch" || ctl.role === "checkbox") return true;
+ if (ctl.tag === "select" || ctl.tag === "textarea") return true;
+ if (ctl.contentEditable) return true;
+ if (ctl.tag === "input") return !["file", "submit", "button", "image"].includes(ctl.type);
+ return false;
+}
+
+/** Flip a stateful control to a value different from its current one. */
+export async function flip(page: Page, ctl: Control): Promise {
+ const el = locate(page, ctl);
+ if (ctl.role === "switch" || ctl.role === "checkbox" || ctl.type === "checkbox") {
+ await el.click({ timeout: 6000 });
+ return null;
+ }
+ if (ctl.tag === "select") {
+ const values = await el.evaluate((node) =>
+ node instanceof HTMLSelectElement ? [...node.options].map((o) => o.value) : [],
+ );
+ const next = values.find((v) => v !== ctl.state);
+ if (next === undefined) return null;
+ await el.selectOption(next, { timeout: 6000 });
+ return next;
+ }
+ if (ctl.type === "date") {
+ const next = ctl.state === "1990-01-01" ? "1991-02-03" : "1990-01-01";
+ await el.fill(next, { timeout: 6000 });
+ return next;
+ }
+ if (ctl.type === "number" || ctl.type === "range") {
+ const current = Number(ctl.state ?? "0");
+ const next = String(Number.isFinite(current) ? current + 1 : 1);
+ await el.fill(next, { timeout: 6000 });
+ return next;
+ }
+ const next = `hive-coverage-${Date.now() % 100000}`;
+ await el.fill(next, { timeout: 6000 });
+ return next;
+}
+
+export async function readState(page: Page, covId: string): Promise {
+ return page.evaluate((id) => {
+ const el = document.querySelector(`[data-cov-id="${id}"]`);
+ if (!el) return null;
+ const tag = el.tagName.toLowerCase();
+ const role = el.getAttribute("role") ?? "";
+ const text = el instanceof HTMLElement ? el.innerText : "";
+ if (el.getAttribute("contenteditable") === "true") return text;
+ if (role === "switch" || role === "checkbox")
+ return el.getAttribute("aria-checked") ?? text.trim();
+ if (el instanceof HTMLSelectElement) return el.value;
+ if (el instanceof HTMLInputElement) {
+ if (el.type === "checkbox" || el.type === "radio") return String(el.checked);
+ return el.value;
+ }
+ if (el instanceof HTMLTextAreaElement) return el.value;
+ return null;
+ }, covId);
+}
+
+/**
+ * Collapses instance keys to the identity they are instances of.
+ *
+ * The enumerator appends an ordinal to a duplicate key, so 52 rows of the chat
+ * list produce 52 keys that differ only by that ordinal. Counting them as 52
+ * controls made the ratio track how many chats the account happens to hold,
+ * which is our own test litter as much as anything, rather than tracking the
+ * product. One identity is one thing a user can do.
+ */
+export function identityOf(key: string): string {
+ return key.replace(/#\d+$/, "");
+}
+
+export function summarise(results: Result[]) {
+ const bySurface = new Map<
+ string,
+ { total: number; proven: number; deferred: number; unproven: string[] }
+ >();
+ for (const r of results) {
+ const s = bySurface.get(r.surface) ?? { total: 0, proven: 0, deferred: 0, unproven: [] };
+ s.total += 1;
+ if (r.proven) s.proven += 1;
+ else if (isDeferred(r)) s.deferred += 1;
+ else s.unproven.push(`${r.name || "(unnamed)"} [${r.role}] :: ${r.detail}`);
+ bySurface.set(r.surface, s);
+ }
+ const total = results.length;
+ const proven = results.filter((r) => r.proven).length;
+ const deferred = results.filter(isDeferred).length;
+ // Primary figure. An identity counts as proven only when every instance of
+ // it proved, so a control that works on the first chat row and fails on the
+ // seventh is unproven, and the gate fails on the failing instance anyway
+ // because it fails on any unproven result at all.
+ const byIdentity = new Map();
+ for (const r of results) {
+ const id = identityOf(r.key);
+ const cell = byIdentity.get(id) ?? { instances: 0, proven: 0, deferred: 0 };
+ cell.instances += 1;
+ if (r.proven) cell.proven += 1;
+ if (isDeferred(r)) cell.deferred += 1;
+ byIdentity.set(id, cell);
+ }
+ const identityTotal = byIdentity.size;
+ const identityProven = [...byIdentity.values()].filter(
+ (cell) => cell.proven === cell.instances,
+ ).length;
+ // An identity every instance of which was deliberately not fired. Counted
+ // apart from both proven and failing, so the headline can never absorb it.
+ const identityDeferred = [...byIdentity.values()].filter(
+ (cell) => cell.deferred === cell.instances,
+ ).length;
+ return {
+ // Primary. Distinct control identities, independent of how many rows of
+ // repeated chrome the account happens to render.
+ identities: identityTotal,
+ identitiesProven: identityProven,
+ identitiesDeferred: identityDeferred,
+ identityRatio:
+ identityTotal === 0 ? 0 : Number((identityProven / identityTotal).toFixed(4)),
+ // Secondary, and not comparable between runs: raw instances, whose count
+ // moves with account data rather than with the product.
+ total,
+ proven,
+ deferred,
+ ratio: total === 0 ? 0 : Number((proven / total).toFixed(4)),
+ surfaces: Object.fromEntries(bySurface),
+ identityInstances: Object.fromEntries(byIdentity),
+ };
+}
+
+/**
+ * Denominator guard.
+ *
+ * The ratio is proven over enumerated and both sides are read out of the
+ * rendered DOM, so a surface that renders less than it used to shrinks the
+ * denominator along with the numerator and the percentage holds or climbs
+ * while the app degrades. A floor per surface, committed to the repository,
+ * makes that drop a failure instead of an improvement. A floor rather than a
+ * comparison against the previous run because a run starts from a clean
+ * checkout with no artifact from the last one, so previous-run state can
+ * always be missing and the check would become no check at all.
+ */
+export type Floors = Record;
+
+export type Swept = { surface: string; enumerated: number };
+
+/**
+ * @param floors committed minimum control count per surface
+ * @param swept what this run actually enumerated
+ * @param scope surfaces this run was asked to cover. A floor key outside it is
+ * not checked; a floor key inside it that never got swept is a failure,
+ * because iterating the swept list alone means a surface that vanishes takes
+ * its own floor with it and the denominator shrinks in silence.
+ */
+export function floorFailures(
+ floors: Floors,
+ swept: Swept[],
+ scope: (surface: string) => boolean = () => true,
+): string[] {
+ const out: string[] = [];
+ const sweptIds = new Set(swept.map((entry) => entry.surface));
+ for (const surface of Object.keys(floors).sort()) {
+ if (sweptIds.has(surface) || !scope(surface)) continue;
+ out.push(
+ surface +
+ ": has a floor of " +
+ String(floors[surface]) +
+ " in surface-floors.json but this run never swept it, so its controls left the" +
+ " denominator entirely. Sweep it, exclude it deliberately in surface-exclusions.json," +
+ " or drop its floor with a reason",
+ );
+ }
+ for (const entry of swept) {
+ const floor = floors[entry.surface];
+ if (floor === undefined) {
+ out.push(
+ entry.surface +
+ ": enumerated " +
+ String(entry.enumerated) +
+ " controls but has no floor in surface-floors.json; record one, or a later run that renders nothing here reports full coverage of an empty surface",
+ );
+ continue;
+ }
+ if (entry.enumerated < floor) {
+ out.push(
+ entry.surface +
+ ": enumerated " +
+ String(entry.enumerated) +
+ " controls, below its recorded floor of " +
+ String(floor) +
+ "; the surface renders less than it used to, which shrinks the denominator and inflates coverage. Fix the surface, or lower the floor in this PR with a reason",
+ );
+ }
+ }
+ return out;
+}
+
+/**
+ * A surface deliberately left out of the sweep.
+ *
+ * Excluding a surface is allowed. Excluding it forever is not. An exclusion
+ * that exists because something is broken names the issue that is broken, and
+ * the gate fails the moment that issue closes, so the exclusion cannot outlive
+ * its reason. A standing decision says permanent instead and names no issue.
+ */
+export type SurfaceExclusion = {
+ id: string;
+ reason?: string;
+ issue?: number;
+ permanent?: boolean;
+ owner?: string;
+};
+
+export function exclusionFailures(exclusions: SurfaceExclusion[]): string[] {
+ const out: string[] = [];
+ for (const entry of exclusions) {
+ const label = "surface exclusion " + (entry.id || "(no id)");
+ if (!entry.id) out.push(label + " has no id");
+ if (!(entry.reason ?? "").trim()) out.push(label + " has no reason");
+ if (!(entry.owner ?? "").trim()) out.push(label + " has no owner");
+ if (entry.issue === undefined && entry.permanent !== true) {
+ out.push(
+ label +
+ " names no blocking issue and is not declared permanent; an exclusion with no end is how a denominator shrinks without anyone deciding it should",
+ );
+ }
+ if (entry.issue !== undefined && entry.permanent === true) {
+ out.push(label + " declares itself permanent and also names an issue; it is one or the other");
+ }
+ }
+ return out;
+}
+
+// --- Reading the committed data files ---------------------------------------
+//
+// Hand-written validators rather than a cast on JSON.parse. A cast asserts a
+// shape the compiler cannot check and the file cannot be made to honour, so a
+// floors file whose numbers were saved as strings, or an exclusions file
+// missing its array, would sail past the type system and turn into undefined
+// at the point where the gate is deciding whether to fail.
+
+function fields(value: unknown, where: string): Map {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
+ throw new Error(`${where}: expected a JSON object`);
+ }
+ return new Map(Object.entries(value));
+}
+
+function items(value: unknown, where: string): unknown[] {
+ if (!Array.isArray(value)) throw new Error(`${where}: expected a JSON array`);
+ return value;
+}
+
+function requiredString(map: Map, name: string, where: string): string {
+ const value = map.get(name);
+ if (typeof value !== "string" || value.trim() === "") {
+ throw new Error(`${where}: "${name}" must be a non-empty string`);
+ }
+ return value;
+}
+
+function optionalString(map: Map, name: string, where: string): string | undefined {
+ const value = map.get(name);
+ if (value === undefined) return undefined;
+ if (typeof value !== "string") throw new Error(`${where}: "${name}" must be a string`);
+ return value;
+}
+
+export function parseFloors(value: unknown): Floors {
+ const surfaces = fields(fields(value, "surface-floors.json").get("surfaces"), "surface-floors.json surfaces");
+ const out: Floors = {};
+ for (const [surface, count] of surfaces) {
+ if (typeof count !== "number" || !Number.isInteger(count) || count <= 0) {
+ throw new Error(`surface-floors.json: floor for ${surface} must be a positive integer`);
+ }
+ out[surface] = count;
+ }
+ return out;
+}
+
+/** Surfaces deliberately carrying no floor, because their size is account data. */
+export function parseDataDriven(value: unknown): Set {
+ const listed = fields(value, "surface-floors.json").get("dataDriven");
+ if (listed === undefined) return new Set();
+ return new Set(
+ items(listed, "surface-floors.json dataDriven").map((entry, index) => {
+ if (typeof entry !== "string" || entry.trim() === "") {
+ throw new Error(`surface-floors.json dataDriven entry ${index} must be a surface id`);
+ }
+ return entry;
+ }),
+ );
+}
+
+export function parseExclusions(value: unknown): SurfaceExclusion[] {
+ const raw = items(fields(value, "surface-exclusions.json").get("surfaces"), "surface-exclusions.json surfaces");
+ return raw.map((entry, index) => {
+ const where = `surface-exclusions.json entry ${index}`;
+ const map = fields(entry, where);
+ const issue = map.get("issue");
+ if (
+ issue !== undefined &&
+ (typeof issue !== "number" || !Number.isInteger(issue) || issue <= 0)
+ ) {
+ // A zero, a float or a string here would be looked up as an issue number
+ // that cannot exist, GitHub would answer 404, and the exclusion would sit
+ // there permanently with nothing able to expire it.
+ throw new Error(`${where}: "issue" must be a positive integer issue number`);
+ }
+ const permanent = map.get("permanent");
+ if (permanent !== undefined && typeof permanent !== "boolean") {
+ throw new Error(`${where}: "permanent" must be a boolean`);
+ }
+ return {
+ id: requiredString(map, "id", where),
+ reason: optionalString(map, "reason", where),
+ owner: optionalString(map, "owner", where),
+ issue,
+ permanent,
+ };
+ });
+}
+
+export type InertEntry = { key: string; justification: string };
+
+export function parseRegistry(value: unknown): InertEntry[] {
+ const raw = items(fields(value, "inert-registry.json").get("allowed"), "inert-registry.json allowed");
+ return raw.map((entry, index) => {
+ const where = `inert-registry.json entry ${index}`;
+ const map = fields(entry, where);
+ return {
+ key: optionalString(map, "key", where) ?? "",
+ justification: optionalString(map, "justification", where) ?? "",
+ };
+ });
+}
+
+export type RemovedSurfaces = {
+ forbiddenControls: Array<{ surface: string; label: string; issue: string }>;
+ forbiddenRoutes: Array<{ path: string; issue: string }>;
+};
+
+export function parseRemoved(value: unknown): RemovedSurfaces {
+ const top = fields(value, "removed-surfaces.json");
+ const controls = items(top.get("forbiddenControls"), "removed-surfaces.json forbiddenControls");
+ const routes = items(top.get("forbiddenRoutes"), "removed-surfaces.json forbiddenRoutes");
+ return {
+ forbiddenControls: controls.map((entry, index) => {
+ const where = `removed-surfaces.json forbiddenControls entry ${index}`;
+ const map = fields(entry, where);
+ return {
+ surface: requiredString(map, "surface", where),
+ label: requiredString(map, "label", where),
+ issue: requiredString(map, "issue", where),
+ };
+ }),
+ forbiddenRoutes: routes.map((entry, index) => {
+ const where = `removed-surfaces.json forbiddenRoutes entry ${index}`;
+ const map = fields(entry, where);
+ return {
+ path: requiredString(map, "path", where),
+ issue: requiredString(map, "issue", where),
+ };
+ }),
+ };
+}
+
+export function expiredExclusions(
+ exclusions: SurfaceExclusion[],
+ closed: ReadonlySet,
+): string[] {
+ return exclusions
+ .filter((entry) => entry.issue !== undefined && closed.has(entry.issue))
+ .map(
+ (entry) =>
+ "surface " +
+ entry.id +
+ " is excluded because issue " +
+ String(entry.issue) +
+ " blocks it, but that issue is closed; delete the exclusion and let the gate measure the surface, or cite the issue that still blocks it",
+ );
+}
diff --git a/apps/web-console/e2e/chat-coverage/playwright.chat-coverage.config.ts b/apps/web-console/e2e/chat-coverage/playwright.chat-coverage.config.ts
new file mode 100644
index 000000000..2e3040ccc
--- /dev/null
+++ b/apps/web-console/e2e/chat-coverage/playwright.chat-coverage.config.ts
@@ -0,0 +1,95 @@
+import path from "node:path";
+
+import { defineConfig, devices } from "@playwright/test";
+
+// The sweep is one long serial journey against a live deployment, so it gets
+// its own config rather than a project inside playwright.owui.config.ts: the
+// timeouts here are an order of magnitude larger and the auth path differs.
+const CHAT = process.env.CHAT_URL ?? process.env.OWUI_URL ?? "";
+const hasTarget = Boolean(CHAT);
+// live-auth.mjs mints the session, so what is needed is an account to mint for
+// and the keys it uses. No password is involved at any point.
+//
+// Reading the service-role key here is a presence check, and it is also the
+// honest statement of where that key lives: in this process's environment,
+// like any other job-level variable. The mint happens in a child process so
+// nothing derived from the key is in scope for a spec to hand to a page, but
+// the value itself is not isolated from the worker.
+const hasCreds = Boolean(
+ (process.env.OWUI_E2E_EMAIL || process.env.HIVE_QA_AGENT_EMAIL) &&
+ process.env.SUPABASE_URL &&
+ process.env.SUPABASE_SERVICE_ROLE_KEY &&
+ process.env.SUPABASE_ANON_KEY,
+);
+// Only the LIVE projects are gated on credentials. break-proof.spec.ts needs
+// none: it serves its own fixture from an intercepted origin, and it is the
+// check that proves the detector can still tell a working control from a
+// broken one. Gating it too meant the one test that watches the gate fail
+// could only run on a machine that could already run everything else.
+const runnable = hasTarget && hasCreds;
+
+export default defineConfig({
+ testDir: "./",
+ timeout: 45 * 60_000,
+ expect: { timeout: 15_000 },
+ fullyParallel: false,
+ workers: 1,
+ retries: 0,
+ reporter: [
+ ["list"],
+ // Both of these land OUTSIDE chat-coverage-report/, which is the directory
+ // CI uploads. Playwright's own reports embed raw error text and stdout,
+ // and a failure in the sign-in hop can quote the OAuth callback URL with a
+ // live code and state in it. redactUrl cannot reach text the framework
+ // produced, and tools/lint-no-token-in-proof-captures.mjs only scans
+ // docs/proof/, so the only safe answer is to keep them out of the
+ // artifact entirely.
+ ["json", { outputFile: "../../playwright-report-chat-coverage/playwright.json" }],
+ ["html", { outputFolder: "../../playwright-report-chat-coverage", open: "never" }],
+ ],
+ use: {
+ baseURL: CHAT || "http://localhost:3002",
+ // Playwright's default for both is 0, meaning "borrow the whole test
+ // timeout". With a 45 minute test that turns one stalled navigation into a
+ // dead run with no diagnosis, which is exactly how this suite lost its
+ // first two attempts at a number.
+ navigationTimeout: 60_000,
+ actionTimeout: 20_000,
+ // CREDENTIALS. A Playwright trace records every request with its headers,
+ // and a video records the address bar, so a trace or a video of any
+ // project here holds this account's live session cookies, and one of the
+ // setup project holds the OAuth callback's `code` and `state` as well.
+ // tools/lint-no-token-in-proof-captures.mjs cannot see inside either: it
+ // skips binaries by design. So both are off unless someone deliberately
+ // asks for them while debugging, and the artefact that produces must never
+ // be attached to a pull request. Screenshots stay on: they frame the page,
+ // not the URL bar, and they are the visual proof the merge rule wants.
+ trace: process.env.COV_TRACE === "1" ? "retain-on-failure" : "off",
+ video: process.env.COV_TRACE === "1" ? "retain-on-failure" : "off",
+ screenshot: "only-on-failure",
+ },
+ projects: [
+ {
+ name: "chat-coverage-break-proof",
+ testMatch: /break-proof\.spec\.ts$/,
+ },
+ {
+ name: "chat-coverage-setup",
+ testMatch: /auth\.setup\.ts$/,
+ // Belt and braces on top of the switch above: this is the project that
+ // walks the OAuth hop, so it never records a trace, a video or a
+ // screenshot, whatever COV_TRACE says.
+ use: { trace: "off", video: "off", screenshot: "off" },
+ },
+ {
+ name: "chat-coverage",
+ testMatch: runnable ? /chat-coverage\.spec\.ts$/ : [],
+ dependencies: ["chat-coverage-setup"],
+ use: {
+ ...devices["Desktop Chrome"],
+ viewport: { width: 1440, height: 950 },
+ storageState: path.join(__dirname, ".auth", "state.json"),
+ },
+ },
+ ],
+});
diff --git a/apps/web-console/e2e/chat-coverage/removed-surfaces.json b/apps/web-console/e2e/chat-coverage/removed-surfaces.json
new file mode 100644
index 000000000..8678ebc00
--- /dev/null
+++ b/apps/web-console/e2e/chat-coverage/removed-surfaces.json
@@ -0,0 +1,17 @@
+{
+ "$comment": "Upstream Open WebUI surfaces this deployment removes because Hive has no backend for them (#771, #772). They must be ABSENT, not present-and-inert: an element that still renders here is a finding, because the removal patches assert their own effect at image build time and a survivor means one silently stopped matching.",
+ "forbiddenControls": [
+ { "surface": "^settings:", "label": "^Integrations$", "issue": "#771" },
+ { "surface": "^workspace", "label": "^(Models|Prompts|Tools|Skills)$", "issue": "#772" },
+ { "surface": "^user-menu", "label": "^(Documentation|Releases)$", "issue": "#772" },
+ { "surface": ".", "label": "^Playground$", "issue": "#772" },
+ { "surface": ".", "label": "^(Manage Tool Servers|Open Terminal)$", "issue": "#771" }
+ ],
+ "forbiddenRoutes": [
+ { "path": "/workspace/models", "issue": "#772" },
+ { "path": "/workspace/prompts", "issue": "#772" },
+ { "path": "/workspace/tools", "issue": "#772" },
+ { "path": "/workspace/skills", "issue": "#772" },
+ { "path": "/playground", "issue": "#772" }
+ ]
+}
diff --git a/apps/web-console/e2e/chat-coverage/results/2026-08-10-morning-live-run.json b/apps/web-console/e2e/chat-coverage/results/2026-08-10-morning-live-run.json
new file mode 100644
index 000000000..d9064994d
--- /dev/null
+++ b/apps/web-console/e2e/chat-coverage/results/2026-08-10-morning-live-run.json
@@ -0,0 +1,242 @@
+{
+ "generatedAt": "2026-08-10T07:31:50.959Z",
+ "target": "https://chat-hive.scubed.co",
+ "note": "Live run against the demo box. The denominator is every focusable control enumerated from the rendered DOM on each surface; no control is hand listed. Surfaces that could not be opened are listed in surfacesNotSwept and are NOT in the denominator, so this figure is partial by exactly those surfaces.",
+ "summary": {
+ "proven": 196,
+ "total": 222,
+ "ratio": 0.8829
+ },
+ "surfaces": {
+ "composer-integrations": {
+ "proven": 2,
+ "total": 2
+ },
+ "composer-more": {
+ "proven": 6,
+ "total": 6
+ },
+ "home": {
+ "proven": 23,
+ "total": 25
+ },
+ "model-picker": {
+ "proven": 13,
+ "total": 15
+ },
+ "search": {
+ "proven": 48,
+ "total": 50
+ },
+ "settings:About": {
+ "proven": 3,
+ "total": 5
+ },
+ "settings:Account": {
+ "proven": 5,
+ "total": 5
+ },
+ "settings:Audio": {
+ "proven": 5,
+ "total": 8
+ },
+ "settings:Data Controls": {
+ "proven": 5,
+ "total": 7
+ },
+ "settings:General": {
+ "proven": 14,
+ "total": 17
+ },
+ "settings:Interface": {
+ "proven": 49,
+ "total": 56
+ },
+ "settings:Personalization": {
+ "proven": 2,
+ "total": 2
+ },
+ "user-menu": {
+ "proven": 6,
+ "total": 6
+ },
+ "workspace": {
+ "proven": 10,
+ "total": 12
+ },
+ "workspace:knowledge/create": {
+ "proven": 5,
+ "total": 6
+ }
+ },
+ "unproven": {
+ "home": [
+ {
+ "name": "",
+ "role": "div",
+ "detail": "not present on re-open"
+ },
+ {
+ "name": "hive-auto Set as default",
+ "role": "button",
+ "detail": "control was not there when the surface was re-opened"
+ }
+ ],
+ "model-picker": [
+ {
+ "name": "All",
+ "role": "button",
+ "detail": "no effect (mutations=0)"
+ },
+ {
+ "name": "External",
+ "role": "button",
+ "detail": "no effect (mutations=4)"
+ }
+ ],
+ "search": [
+ {
+ "name": "Show me a code snippet of a website's sticky header in CSS and JavaScr",
+ "role": "a",
+ "detail": "control was not there when the surface was re-opened"
+ },
+ {
+ "name": "Chat Menu",
+ "role": "button",
+ "detail": "control was not there when the surface was re-opened"
+ }
+ ],
+ "settings:About": [
+ {
+ "name": "See what's new",
+ "role": "button",
+ "detail": "no effect (mutations=0)"
+ },
+ {
+ "name": "CC-BY 4.0",
+ "role": "a",
+ "detail": "no effect (mutations=0)"
+ }
+ ],
+ "settings:Audio": [
+ {
+ "name": "Off",
+ "role": "switch",
+ "detail": "not present on a clean re-open"
+ },
+ {
+ "name": "Voice",
+ "role": "select",
+ "detail": "changed in the UI but reverted to after reload"
+ },
+ {
+ "name": "On",
+ "role": "switch",
+ "detail": "not present on a clean re-open"
+ }
+ ],
+ "settings:Data Controls": [
+ {
+ "name": "Archive All",
+ "role": "button",
+ "detail": "no effect (mutations=3)"
+ },
+ {
+ "name": "Delete All",
+ "role": "button",
+ "detail": "no effect (mutations=3)"
+ }
+ ],
+ "settings:General": [
+ {
+ "name": "General",
+ "role": "tab",
+ "detail": "no effect (mutations=0)"
+ },
+ {
+ "name": "Off",
+ "role": "switch",
+ "detail": "changed in the UI but reverted to false after reload"
+ },
+ {
+ "name": "",
+ "role": "div",
+ "detail": "not present on a clean re-open"
+ }
+ ],
+ "settings:Interface": [
+ {
+ "name": "Default",
+ "role": "button",
+ "detail": "control was not there when the surface was re-opened"
+ },
+ {
+ "name": "Allow User Location",
+ "role": "switch",
+ "detail": "changed in the UI but reverted to false after reload"
+ },
+ {
+ "name": "Auto-Copy Response to Clipboard",
+ "role": "switch",
+ "detail": "changed in the UI but reverted to false after reload"
+ },
+ {
+ "name": "Manage",
+ "role": "switch",
+ "detail": "disappeared after its own change"
+ },
+ {
+ "name": "Image Compression",
+ "role": "switch",
+ "detail": "not present on a clean re-open"
+ },
+ {
+ "name": "Floating Quick Actions",
+ "role": "switch",
+ "detail": "not present on a clean re-open"
+ },
+ {
+ "name": "Compress Images in Channels",
+ "role": "switch",
+ "detail": "not present on a clean re-open"
+ }
+ ],
+ "workspace": [
+ {
+ "name": "Open Sidebar",
+ "role": "button",
+ "detail": "control was not there when the surface was re-opened"
+ },
+ {
+ "name": "Knowledge",
+ "role": "a",
+ "detail": "no effect (mutations=2)"
+ }
+ ],
+ "workspace:knowledge/create": [
+ {
+ "name": "Create Knowledge",
+ "role": "button",
+ "detail": "no effect (mutations=0)"
+ }
+ ]
+ },
+ "surfacesNotSwept": [
+ {
+ "surface": "sidebar",
+ "reason": "enumerated zero controls: the collapse toggle is an unnamed button, so the harness could not pin the sidebar open for its delta"
+ },
+ {
+ "surface": "composer-controls",
+ "reason": "enumerated zero controls: the advanced-parameters panel did not open from a clean home state"
+ },
+ {
+ "surface": "chat-item-menu",
+ "reason": "could not open: the per-chat menu needs the sidebar expanded, same root cause as sidebar"
+ },
+ {
+ "surface": "chat-message-actions",
+ "reason": "could not open: reaching a message needs the sidebar expanded, same root cause as sidebar"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/web-console/e2e/chat-coverage/surface-exclusions.json b/apps/web-console/e2e/chat-coverage/surface-exclusions.json
new file mode 100644
index 000000000..7c3dc7729
--- /dev/null
+++ b/apps/web-console/e2e/chat-coverage/surface-exclusions.json
@@ -0,0 +1,11 @@
+{
+ "$comment": "Surfaces deliberately left out of the sweep. Each one leaves the denominator entirely rather than counting as covered. An exclusion that exists because something is broken names the issue that is broken, and the gate fails the moment that issue closes, so an exclusion cannot outlive its reason. A standing decision sets permanent instead and names no issue.",
+ "surfaces": [
+ {
+ "id": "composer-controls",
+ "reason": "the advanced parameters panel does not open from a clean home state, so the surface enumerates zero controls and none of them can be activated",
+ "issue": 844,
+ "owner": "@sakibsadmanshajib"
+ }
+ ]
+}
diff --git a/apps/web-console/e2e/chat-coverage/surface-floors.json b/apps/web-console/e2e/chat-coverage/surface-floors.json
new file mode 100644
index 000000000..a4a08e3b5
--- /dev/null
+++ b/apps/web-console/e2e/chat-coverage/surface-floors.json
@@ -0,0 +1,28 @@
+{
+ "$comment": "Minimum control count per surface, the highest count a live run has ever enumerated there. A run that enumerates fewer controls on a surface than the number here fails the gate: coverage is proven over enumerated, so a surface rendering less than it used to would otherwise shrink the denominator and hold the percentage up while the app degrades. A floor key this run never swept fails too, which is what stops a red surface being turned green by deleting the link that reaches it: the four surfaces sitting at 1 (sidebar, chat-item-menu, chat-message-actions, workspace:knowledge) do not sweep today, and their floor of 1 says the surface must exist and render something, so removing its entry point fails the gate instead of silently emptying the denominator. Raising or lowering a floor is a separate, deliberate commit made with scripts/update-chat-coverage-floors.mjs against a recorded ledger, never by the run that checks them.",
+ "dataDriven": [
+ "search"
+ ],
+ "$dataDrivenComment": "Surfaces whose control count is account data rather than product surface. They carry a floor of 1, a presence bar: the surface must still exist and render something, so deleting the link that reaches it still fails the gate, but the count itself is not pinned. The chat list renders one row per chat, so it enumerated 50 controls on one run and 106 on the next with no product change between them; pinning that number reds the gate whenever somebody grooms the account, and the fix everyone reaches for is to lower the floor, which is the ratchet this file exists to stop.",
+ "surfaces": {
+ "chat-item-menu": 1,
+ "chat-message-actions": 1,
+ "composer-integrations": 2,
+ "composer-more": 6,
+ "home": 28,
+ "model-picker": 15,
+ "search": 1,
+ "settings:About": 5,
+ "settings:Account": 5,
+ "settings:Audio": 8,
+ "settings:Data Controls": 7,
+ "settings:General": 17,
+ "settings:Interface": 56,
+ "settings:Personalization": 2,
+ "sidebar": 1,
+ "user-menu": 6,
+ "workspace": 12,
+ "workspace:knowledge": 1,
+ "workspace:knowledge/create": 6
+ }
+}
diff --git a/apps/web-console/e2e/chat-coverage/surfaces.ts b/apps/web-console/e2e/chat-coverage/surfaces.ts
new file mode 100644
index 000000000..138364070
--- /dev/null
+++ b/apps/web-console/e2e/chat-coverage/surfaces.ts
@@ -0,0 +1,335 @@
+// Surface openers. These are navigation recipes only: how to get the app into
+// the state where a group of controls is on screen. They deliberately contain
+// no list of controls, because what is on screen is read from the DOM by
+// lib.ts, so a control added to any of these surfaces later is picked up
+// without editing this file.
+import type { Page } from "@playwright/test";
+
+import { enumerate, redactUrl, type Control } from "./lib";
+
+export type Surface = {
+ id: string;
+ /** Puts the app into the state where this surface's controls are rendered. */
+ open: (page: Page) => Promise;
+ /**
+ * Enumerate only what this surface adds on top of the page underneath it, so
+ * the sidebar's buttons are not re-counted inside every dropdown.
+ */
+ delta?: boolean;
+ /**
+ * True for the settings tab that owns the modal's own chrome, whose delta is
+ * taken against the chat page rather than against the open modal.
+ */
+ chrome?: boolean;
+ /** Settings persist; a composer or search box does not. */
+ persists?: boolean;
+};
+
+const SETTLE = 1500;
+
+export async function gotoHome(page: Page): Promise {
+ // Always dismiss whatever is open first. A modal left over from the previous
+ // surface swallows the clicks that follow, and the resulting timeout would be
+ // recorded as a dead control rather than as the harness's own mess.
+ await page.keyboard.press("Escape").catch(() => {});
+ if (new URL(page.url()).pathname !== "/" || (await isOverlayOpen(page))) {
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ }
+ // Three attempts, because the demo box has gone unreachable for a few minutes
+ // at a time mid-run (#815) and recovered on its own. Failing here on the
+ // first miss turns an outage into a page of dead controls.
+ for (let attempt = 0; attempt < 3; attempt++) {
+ try {
+ await homeReady(page).waitFor({ timeout: 45_000 });
+ await page.waitForTimeout(SETTLE);
+ return;
+ } catch (err) {
+ if (attempt === 2) {
+ // Say where it actually was. "waitFor timed out" on its own cannot tell
+ // a signed-out session (#782) from an unreachable box (#815) from a
+ // control that navigated somewhere unexpected.
+ const names = await page
+ .$$eval("button, a[href]", (els) =>
+ els
+ .map((e) => (e.getAttribute("aria-label") || e.textContent || "").trim().slice(0, 30))
+ .filter(Boolean)
+ .slice(0, 12),
+ )
+ .catch(() => []);
+ // Redacted, because this message is written to the ledger and to CI
+ // logs. If the app bounced the run through its OAuth callback, the raw
+ // URL here would publish a live `code` and `state`.
+ throw new Error(
+ `could not get back to the chat home. url=${redactUrl(page.url())} visible=${JSON.stringify(names)}`,
+ );
+ }
+ await page.waitForTimeout(20_000);
+ await page.goto("/", { waitUntil: "domcontentloaded" }).catch(() => {});
+ }
+ }
+}
+
+/**
+ * "New Chat" is a button while the sidebar is collapsed and a link once it is
+ * open, so a role-specific wait passes before the first click and then times
+ * out for the rest of the run. Match either.
+ */
+export function homeReady(page: Page) {
+ return page
+ .getByRole("button", { name: /new chat/i })
+ .or(page.getByRole("link", { name: /new chat/i }))
+ .first();
+}
+
+async function isOverlayOpen(page: Page): Promise {
+ return page.evaluate(
+ () => document.querySelectorAll('[role="dialog"], [role="menu"], [role="listbox"]').length > 0,
+ );
+}
+
+/**
+ * The sidebar is the one piece of chrome that rewrites the whole control set
+ * when it moves: collapsed it offers "Open Sidebar" and a button "New Chat",
+ * expanded it offers "Close Sidebar", a link "New Chat" and the chat list. Each
+ * surface therefore pins it, so a control proven on one pass is still present
+ * on the next.
+ */
+export async function ensureSidebar(page: Page, open: boolean): Promise {
+ // The toggle names the action, not the state: "Open Sidebar" is offered
+ // while it is closed. So the button that ends the call visible is the
+ // opposite one, and its absence means the sidebar is in neither state the
+ // harness can name.
+ const actionNow = page.getByRole("button", { name: open ? /open sidebar/i : /close sidebar/i });
+ const actionAfter = page.getByRole("button", { name: open ? /close sidebar/i : /open sidebar/i });
+ if (await actionNow.isVisible().catch(() => false)) {
+ await actionNow.click().catch(() => {});
+ await page.waitForTimeout(SETTLE);
+ }
+ // Say so rather than carrying on. A missing toggle used to be
+ // indistinguishable from "already in the requested state", and the sweep
+ // then enumerated whatever happened to be on screen: that is how the sidebar
+ // surface came back with zero controls instead of an error naming the cause.
+ if (!(await actionAfter.isVisible().catch(() => false))) {
+ throw new Error(
+ `the sidebar is not ${open ? "open" : "closed"} and neither toggle is on screen, so this ` +
+ "surface cannot be pinned into a known state",
+ );
+ }
+}
+
+export async function openSidebar(page: Page): Promise {
+ await gotoHome(page);
+ await ensureSidebar(page, true);
+}
+
+export async function openSettings(page: Page): Promise {
+ await gotoHome(page);
+ await page.getByRole("button", { name: /user menu/i }).first().click();
+ await page.waitForTimeout(800);
+ await page.getByRole("button", { name: /^settings$/i }).first().click();
+ await page.getByRole("tab").first().waitFor({ timeout: 20_000 });
+ await page.waitForTimeout(SETTLE);
+}
+
+async function clickTop(page: Page, name: RegExp): Promise {
+ await gotoHome(page);
+ await ensureSidebar(page, false);
+ await page.getByRole("button", { name }).first().click();
+ await page.waitForTimeout(SETTLE);
+}
+
+/**
+ * Put the surface back exactly as it was first enumerated and find one control
+ * on it again. Every proof starts from this, because clicking a control can
+ * change what the surface contains, and attributing that drift to the *next*
+ * control reports working controls as dead ones.
+ */
+export async function relocate(
+ page: Page,
+ surface: Surface,
+ key: string,
+): Promise {
+ await surface.open(page);
+ const all = await enumerate(page, surface.id);
+ return all.find((c) => c.key === key);
+}
+
+/** The surfaces that exist regardless of what the deployment contains. */
+export const STATIC_SURFACES: Surface[] = [
+ {
+ id: "home",
+ open: async (page) => {
+ await gotoHome(page);
+ await ensureSidebar(page, false);
+ },
+ },
+ { id: "sidebar", open: openSidebar, delta: true },
+ {
+ id: "model-picker",
+ open: (page) => clickTop(page, /selected model/i),
+ delta: true,
+ },
+ { id: "composer-more", open: (page) => clickTop(page, /^more$/i), delta: true },
+ {
+ id: "composer-integrations",
+ open: (page) => clickTop(page, /^integrations$/i),
+ delta: true,
+ },
+ {
+ id: "composer-controls",
+ open: (page) => clickTop(page, /^controls$/i),
+ delta: true,
+ },
+ { id: "user-menu", open: (page) => clickTop(page, /user menu/i), delta: true },
+ { id: "search", open: (page) => clickTop(page, /^search$/i), delta: true },
+ {
+ id: "workspace",
+ open: async (page) => {
+ await page.goto("/workspace", { waitUntil: "domcontentloaded" });
+ await page.waitForTimeout(3000);
+ },
+ },
+ {
+ id: "chat-item-menu",
+ open: async (page) => {
+ await openSidebar(page);
+ await page.getByRole("button", { name: /^chat menu$/i }).first().click();
+ await page.waitForTimeout(SETTLE);
+ },
+ delta: true,
+ },
+ {
+ id: "chat-message-actions",
+ open: async (page) => {
+ await openSidebar(page);
+ const chat = page.locator('a[href^="/c/"]').first();
+ await chat.click({ timeout: 20_000 });
+ await page.waitForTimeout(4000);
+ // The action bar is hover-revealed on the last assistant message.
+ const messages = page.locator("[id^='message-']");
+ const count = await messages.count();
+ if (count > 0) await messages.nth(count - 1).hover();
+ await page.waitForTimeout(SETTLE);
+ },
+ delta: true,
+ },
+];
+
+/**
+ * Settings tabs are read from the rendered tablist, not from a constant, so a
+ * tab added or removed by an upstream bump or a Hive patch changes the surface
+ * list on the next run with no code change.
+ */
+export async function discoverSettingsTabs(page: Page): Promise {
+ await openSettings(page);
+ const tabs = await page.getByRole("tab").allInnerTexts();
+ const named = tabs.map((t) => t.replace(/\s+/g, " ").trim()).filter(Boolean);
+ // Discovering nothing is a broken modal, not a deployment with no settings.
+ // Returning an empty list quietly would drop every settings tab out of the
+ // denominator and report the remaining surfaces as the whole app.
+ if (named.length === 0) {
+ throw new Error(
+ "settings discovery found no tabs; the settings modal did not render, so its controls " +
+ "would silently leave the denominator",
+ );
+ }
+ return named
+ .map((tab, index) => ({
+ id: `settings:${tab}`,
+ persists: true,
+ // Always a delta. The first tab owns the modal chrome (close, search, the
+ // tablist, the Admin Settings link) but not the chat page underneath it,
+ // which is what the sidebar and composer surfaces already cover and which
+ // the modal makes unclickable anyway.
+ delta: true,
+ chrome: index === 0,
+ open: async (page: Page) => {
+ await openSettings(page);
+ if (index > 0) {
+ await page.getByRole("tab", { name: tab, exact: true }).first().click();
+ await page.waitForTimeout(SETTLE);
+ }
+ },
+ }));
+}
+
+/** Same idea for the Workspace section's own navigation. */
+export async function discoverWorkspaceTabs(page: Page): Promise {
+ await page.goto("/workspace", { waitUntil: "domcontentloaded" });
+ await page.waitForTimeout(3000);
+ const hrefs = await page
+ .locator('a[href^="/workspace/"]')
+ .evaluateAll((els) =>
+ [...new Set(els.map((e) => e.getAttribute("href") ?? ""))].filter(Boolean),
+ );
+ // Same reasoning as the settings tabs above. An empty result here used to be
+ // returned silently, which took every workspace section out of the
+ // denominator without a word in the report.
+ if (hrefs.length === 0) {
+ throw new Error(
+ "workspace discovery found no sections; /workspace rendered no links, so every " +
+ "workspace:* control would silently leave the denominator",
+ );
+ }
+ return hrefs.map((href) => ({
+ id: `workspace:${href.replace("/workspace/", "")}`,
+ delta: true,
+ open: async (page: Page) => {
+ await page.goto(href, { waitUntil: "domcontentloaded" });
+ await page.waitForTimeout(3000);
+ },
+ }));
+}
+
+/**
+ * Enumerate a surface. For delta surfaces the page underneath is enumerated
+ * first and its controls are subtracted, so each control is attributed to
+ * exactly one surface and counted once.
+ */
+export async function enumerateSurface(page: Page, surface: Surface): Promise {
+ // Signature without the instance ordinal, subtracted as a multiset.
+ //
+ // Subtracting a set of full keys looked right and was not: the ordinal is
+ // positional, so an overlay that injects or hides one identically-keyed
+ // element shifts every later one. A base control then carried an ordinal the
+ // baseline never held, survived the subtraction, and was clicked as though
+ // the surface had added it; a genuinely new control could be cancelled by a
+ // base entry whose ordinal it inherited. Counting instead of matching keys
+ // makes the arithmetic mean what it says: the surface adds however many more
+ // of a thing than the page underneath already had.
+ const signature = (key: string) => key.slice(key.indexOf("::")).replace(/#\d+$/, "");
+ const remaining = new Map();
+ if (surface.delta) {
+ if (surface.id.startsWith("settings:")) {
+ if (surface.chrome) {
+ await gotoHome(page);
+ await ensureSidebar(page, false);
+ } else {
+ await openSettings(page);
+ }
+ } else if (surface.id.startsWith("workspace:")) {
+ await page.goto("/workspace", { waitUntil: "domcontentloaded" });
+ await page.waitForTimeout(3000);
+ } else {
+ // The canonical home state, sidebar collapsed. Taking the baseline in
+ // whatever state the previous surface left behind made the sidebar's own
+ // delta come out empty, because the sidebar was already open.
+ await gotoHome(page);
+ await ensureSidebar(page, false);
+ }
+ for (const control of await enumerate(page, "__base")) {
+ const sig = signature(control.key);
+ remaining.set(sig, (remaining.get(sig) ?? 0) + 1);
+ }
+ }
+ await surface.open(page);
+ const all = await enumerate(page, surface.id);
+ if (!surface.delta) return all;
+ return all.filter((c) => {
+ const sig = signature(c.key);
+ const left = remaining.get(sig) ?? 0;
+ if (left === 0) return true;
+ remaining.set(sig, left - 1);
+ return false;
+ });
+}
diff --git a/apps/web-console/package.json b/apps/web-console/package.json
index b99b04026..4331027ad 100644
--- a/apps/web-console/package.json
+++ b/apps/web-console/package.json
@@ -14,7 +14,10 @@
"e2e:phase-19": "playwright test --project=phase-19",
"e2e:verify-collection": "node scripts/verify-spec-collection.mjs",
"e2e:owui": "playwright test --config=e2e/phase-19/owui/playwright.owui.config.ts --project=owui",
- "e2e:owui:perf": "playwright test --config=e2e/phase-19/owui/playwright.owui.config.ts --project=owui-perf"
+ "e2e:owui:perf": "playwright test --config=e2e/phase-19/owui/playwright.owui.config.ts --project=owui-perf",
+ "e2e:chat-coverage": "playwright test --config=e2e/chat-coverage/playwright.chat-coverage.config.ts --project=chat-coverage",
+ "e2e:chat-coverage:self-check": "playwright test --config=e2e/chat-coverage/playwright.chat-coverage.config.ts --project=chat-coverage-break-proof",
+ "chat-coverage:floors": "node scripts/update-chat-coverage-floors.mjs"
},
"dependencies": {
"@opennextjs/cloudflare": "~1.19.0",
diff --git a/apps/web-console/playwright-spec-manifest.json b/apps/web-console/playwright-spec-manifest.json
index 5aaa29bd0..9dddabc5c 100644
--- a/apps/web-console/playwright-spec-manifest.json
+++ b/apps/web-console/playwright-spec-manifest.json
@@ -10,11 +10,16 @@
"same PR. If the line changes and nobody meant to change it, the spec went dark by",
"accident, which is what this file exists to catch.",
"",
- "Project names come from playwright.config.ts and e2e/phase-19/owui/playwright.owui.config.ts.",
- "The owui projects gate their testMatch on credentials, so the guard lists that config",
- "with placeholders to keep collection deterministic everywhere."
+ "Project names come from playwright.config.ts, e2e/phase-19/owui/playwright.owui.config.ts",
+ "and e2e/chat-coverage/playwright.chat-coverage.config.ts.",
+ "The owui projects and the live chat-coverage project gate their testMatch on credentials,",
+ "so the guard lists those configs with placeholders to keep collection deterministic",
+ "everywhere."
],
"specs": {
+ "e2e/chat-coverage/auth.setup.ts": ["chat-coverage-setup"],
+ "e2e/chat-coverage/break-proof.spec.ts": ["chat-coverage-break-proof"],
+ "e2e/chat-coverage/chat-coverage.spec.ts": ["chat-coverage"],
"e2e/phase-19/01-signin-google.spec.ts": ["phase-19"],
"e2e/phase-19/02-first-chat.spec.ts": ["phase-19"],
"e2e/phase-19/03-tenant-isolation.spec.ts": ["phase-19"],
diff --git a/apps/web-console/scripts/update-chat-coverage-floors.mjs b/apps/web-console/scripts/update-chat-coverage-floors.mjs
new file mode 100644
index 000000000..cdeb713e8
--- /dev/null
+++ b/apps/web-console/scripts/update-chat-coverage-floors.mjs
@@ -0,0 +1,148 @@
+#!/usr/bin/env node
+// Raises or lowers the chat-coverage surface floors, from a recorded ledger.
+//
+// WHY THIS IS A SEPARATE PROGRAM
+// ------------------------------
+// The sweep used to do this itself: COV_FLOORS=update rewrote surface-floors.json
+// from the run in progress AND skipped checking the floors in that same pass.
+// A bar that the run being measured can move is not a bar. It shipped exactly
+// the failure it was built to prevent: three surfaces enumerated fewer controls
+// than the previous run had (Interface 48 against 56, General 17 against 16,
+// Audio 7 against 8), and the update wrote the smaller numbers in as the new
+// baseline with no reason recorded anywhere.
+//
+// So updating a floor is now a deliberate act with a diff to review: run the
+// sweep, look at the ledger it wrote, then run this against that ledger and
+// commit the result with the reason in the commit message.
+//
+// A floor only ever RISES automatically. Lowering one means a surface renders
+// less than it used to, which is either a regression to fix or a deliberate
+// removal to justify, and both deserve a human typing --allow-lower and saying
+// why in the commit message.
+//
+// Usage:
+// node scripts/update-chat-coverage-floors.mjs [ledger.json] [--allow-lower] [--dry-run]
+//
+// The default ledger is the one the last local sweep wrote:
+// apps/web-console/chat-coverage-report/coverage.run.json
+
+import { readFileSync, writeFileSync } from "node:fs";
+import { dirname, resolve, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const WEB_CONSOLE = resolve(HERE, "..");
+const FLOOR_FILE = join(WEB_CONSOLE, "e2e/chat-coverage/surface-floors.json");
+const DEFAULT_LEDGER = join(WEB_CONSOLE, "chat-coverage-report/coverage.run.json");
+
+const KNOWN_FLAGS = new Set(["--allow-lower", "--dry-run"]);
+
+function parseArgs(argv) {
+ const flags = new Set(argv.filter((a) => a.startsWith("--")));
+ const positional = argv.filter((a) => !a.startsWith("--"));
+ // A typo must not be read as its own absence. `--allowlower` used to be
+ // ignored silently, which for the one flag that permits lowering a floor
+ // means the program does the opposite of what was asked, quietly.
+ const unknown = [...flags].filter((flag) => !KNOWN_FLAGS.has(flag));
+ if (unknown.length > 0) {
+ console.error(
+ `unknown flag(s): ${unknown.join(", ")}. Known flags are ${[...KNOWN_FLAGS].join(", ")}.`,
+ );
+ process.exit(2);
+ }
+ return {
+ ledger: positional[0] ? resolve(positional[0]) : DEFAULT_LEDGER,
+ allowLower: flags.has("--allow-lower"),
+ dryRun: flags.has("--dry-run"),
+ };
+}
+
+function readJson(file) {
+ return JSON.parse(readFileSync(file, "utf8"));
+}
+
+function main(argv) {
+ const { ledger, allowLower, dryRun } = parseArgs(argv);
+ const run = readJson(ledger);
+
+ if (!Array.isArray(run.swept)) {
+ console.error(
+ `${ledger} carries no \`swept\` array, so there is nothing to read a floor from. ` +
+ "Only a ledger written by the current sweep records what each surface enumerated.",
+ );
+ process.exit(1);
+ }
+ if (run.partial) {
+ console.error(
+ `${ledger} is a partial run (surfaceFilter=${String(run.surfaceFilter)}). Floors are ` +
+ "only updated from a full sweep, or the surfaces the slice skipped would keep " +
+ "whatever stale number they carry while the rest move.",
+ );
+ process.exit(1);
+ }
+ if ((run.surfaceErrors ?? []).length > 0) {
+ console.error(
+ `${ledger} recorded ${run.surfaceErrors.length} surface error(s). A run that could not ` +
+ "sweep everything is not evidence of how much a surface renders:\n " +
+ run.surfaceErrors.join("\n "),
+ );
+ process.exit(1);
+ }
+
+ const floors = readJson(FLOOR_FILE);
+ const next = { ...floors.surfaces };
+ const raised = [];
+ const lowered = [];
+ const added = [];
+
+ for (const entry of run.swept) {
+ if (!entry || typeof entry.surface !== "string") continue;
+ if (!Number.isInteger(entry.enumerated) || entry.enumerated <= 0) {
+ // A surface that enumerated nothing measured nothing. A floor of zero is
+ // a bar nothing can fail.
+ continue;
+ }
+ const current = next[entry.surface];
+ if (current === undefined) {
+ added.push(`${entry.surface}: ${entry.enumerated}`);
+ next[entry.surface] = entry.enumerated;
+ } else if (entry.enumerated > current) {
+ raised.push(`${entry.surface}: ${current} -> ${entry.enumerated}`);
+ next[entry.surface] = entry.enumerated;
+ } else if (entry.enumerated < current) {
+ lowered.push(`${entry.surface}: ${current} -> ${entry.enumerated}`);
+ if (allowLower) next[entry.surface] = entry.enumerated;
+ }
+ }
+
+ if (lowered.length > 0 && !allowLower) {
+ console.error(
+ "refusing to lower a floor without --allow-lower. Each of these means the surface " +
+ "renders fewer controls than it used to, which shrinks the denominator and inflates " +
+ "coverage:\n " +
+ lowered.join("\n ") +
+ "\n\nFix the surface, or re-run with --allow-lower and say why in the commit message.",
+ );
+ process.exit(1);
+ }
+
+ const ordered = {};
+ for (const key of Object.keys(next).sort()) ordered[key] = next[key];
+ const body = JSON.stringify({ $comment: floors.$comment, surfaces: ordered }, null, 2) + "\n";
+
+ for (const line of added) console.log(`ADDED ${line}`);
+ for (const line of raised) console.log(`RAISED ${line}`);
+ for (const line of lowered) console.log(`LOWERED ${line}`);
+ if (added.length + raised.length + lowered.length === 0) {
+ console.log("no change: every swept surface matches its floor exactly.");
+ }
+
+ if (dryRun) {
+ console.log("--dry-run: surface-floors.json not written.");
+ return;
+ }
+ writeFileSync(FLOOR_FILE, body);
+ console.log(`wrote ${FLOOR_FILE}. Commit it with the reason for each line above.`);
+}
+
+main(process.argv.slice(2));
diff --git a/apps/web-console/scripts/verify-spec-collection.mjs b/apps/web-console/scripts/verify-spec-collection.mjs
index 0e2b50522..eeb0dfbed 100644
--- a/apps/web-console/scripts/verify-spec-collection.mjs
+++ b/apps/web-console/scripts/verify-spec-collection.mjs
@@ -68,6 +68,18 @@ const WEB_CONSOLE_LIST_ENV = {
E2E_RUN_KEY: "collection-guard",
};
+// Same reasoning as OWUI_LIST_ENV for the chat-coverage config: its live
+// project matches zero files without a target and the keys live-auth.mjs mints
+// with, so listing it bare would report the live sweep as collected by nothing
+// and the guard would call that fine. Nothing dials out during a listing.
+const CHAT_COVERAGE_LIST_ENV = {
+ CHAT_URL: "https://collection-guard.invalid",
+ OWUI_E2E_EMAIL: "collection-guard@example.invalid",
+ SUPABASE_URL: "https://collection-guard.invalid",
+ SUPABASE_SERVICE_ROLE_KEY: "unused-collection-guard-placeholder",
+ SUPABASE_ANON_KEY: "unused-collection-guard-placeholder",
+};
+
const CONFIGS = [
{ label: "playwright.config.ts", args: [], env: WEB_CONSOLE_LIST_ENV },
{
@@ -75,6 +87,11 @@ const CONFIGS = [
args: ["--config=e2e/phase-19/owui/playwright.owui.config.ts"],
env: OWUI_LIST_ENV,
},
+ {
+ label: "e2e/chat-coverage/playwright.chat-coverage.config.ts",
+ args: ["--config=e2e/chat-coverage/playwright.chat-coverage.config.ts"],
+ env: CHAT_COVERAGE_LIST_ENV,
+ },
];
// Where Playwright test modules live. Anything matching TEST_FILE under these
diff --git a/apps/web-console/tests/e2e/support/live-auth.ts b/apps/web-console/tests/e2e/support/live-auth.ts
index b96f7bdf0..5249801ed 100644
--- a/apps/web-console/tests/e2e/support/live-auth.ts
+++ b/apps/web-console/tests/e2e/support/live-auth.ts
@@ -3,7 +3,9 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
-import type { BrowserContext } from "@playwright/test";
+import type { BrowserContext, Page } from "@playwright/test";
+
+import { redactText } from "./redact";
const execFileAsync = promisify(execFile);
@@ -14,8 +16,15 @@ const execFileAsync = promisify(execFile);
// reset-profile.ts: Playwright's transform compiles spec imports to CommonJS,
// and importing a .mjs from a spec makes Node evaluate that CJS output in ES
// module scope ("exports is not defined"); and a child process keeps the
-// service-role key out of the Playwright worker's own module graph, so it can
-// never reach a page, a trace, or a video.
+// service-role key out of this module's own graph, so no value derived from it
+// is in scope where a spec could pass one into page.evaluate.
+//
+// What that does NOT mean, and what an earlier version of this comment claimed
+// wrongly: the key is not absent from the worker. It is ordinary job-level
+// environment, playwright.chat-coverage.config.ts reads SUPABASE_SERVICE_ROLE_KEY
+// to decide whether the live projects can run, and the spawn below forwards the
+// whole of process.env to the child. Anything in this process that dumps
+// process.env dumps the key with it. Only its USE is isolated.
export interface LiveSessionOptions {
/** An existing account. Its password is neither needed nor modified. */
@@ -76,3 +85,95 @@ export async function reauthenticate(
rmSync(dir, { recursive: true, force: true });
}
}
+
+export interface ChatSessionOptions {
+ /** An existing account. Its password is neither needed nor modified. */
+ email: string;
+ /** Console origin: where the minted Supabase session is valid. */
+ consoleUrl: string;
+ /** Chat origin (Open WebUI). */
+ chatUrl: string;
+}
+
+/**
+ * Signs a context in to the chat surface and returns a page already on it.
+ *
+ * The only implementation of the chat hop. live-auth.mjs deliberately does not
+ * carry a second one: the mint has to stay in a child process to keep the
+ * service-role key out of this worker's module graph, but the hop itself is
+ * pure browser driving with no credential in it, so it belongs on the side
+ * that can be imported from a spec. Two copies of a login path is how a repo
+ * ends up with one of them quietly broken.
+ *
+ * A mint alone does not sign in to Open WebUI. The minted cookies authenticate
+ * the console origin; Open WebUI keeps its own session cookie that only its
+ * OAuth callback can set, so a context carrying only the minted session lands
+ * on /auth and reads as a broken login. This carries the session through the
+ * real "Continue with Hive" hop, which is what a user does.
+ */
+export async function signInToChat(
+ context: BrowserContext,
+ options: ChatSessionOptions,
+): Promise {
+ try {
+ return await walkChatHop(context, options);
+ } catch (err: unknown) {
+ // Playwright writes the URLs it navigated through into a waitForURL
+ // timeout, and this function walks an OAuth callback, so the framework's
+ // own failure text can carry a live `code` and `state`. That text reaches
+ // the JSON report, the HTML report and the CI log, none of which any
+ // per-URL redaction can reach after the fact. Redact here, where the
+ // credential is still inside a string this code owns.
+ throw new Error(redactText(err instanceof Error ? err.message : String(err)));
+ }
+}
+
+async function walkChatHop(
+ context: BrowserContext,
+ options: ChatSessionOptions,
+): Promise {
+ await reauthenticate(context, { email: options.email, targetUrl: options.consoleUrl });
+ const page = await context.newPage();
+ await page.goto(`${options.consoleUrl}/console`, {
+ waitUntil: "domcontentloaded",
+ timeout: 60_000,
+ });
+ if (new URL(page.url()).pathname.startsWith("/auth")) {
+ throw new Error("live-auth: the console rejected the minted session");
+ }
+
+ await page.goto(options.chatUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
+ // The sign-in page hydrates late and animates continuously: a click dispatched
+ // before hydration lands on a button with no handler bound and silently does
+ // nothing, and Playwright's click-stability check never settles on it.
+ await page.waitForTimeout(4000);
+ const hive = page.getByRole("button", { name: /continue with hive/i });
+ if (await hive.isVisible().catch(() => false)) {
+ await hive.dispatchEvent("click");
+ await page.waitForTimeout(8000);
+ }
+ // Supabase auto-approves a client the user has already granted, so consent
+ // shows at most once per user and client and must stay optional.
+ const approve = page.getByRole("button", { name: /approve/i });
+ if (await approve.isVisible().catch(() => false)) {
+ await approve.click().catch(() => {});
+ await page.waitForTimeout(8000);
+ }
+
+ // Origin alone is not enough: the sign-in page is on the chat origin too, so
+ // this predicate used to be satisfied by still sitting on /auth, and the
+ // failure only surfaced later as a sweep that could not find the chat home.
+ await page.waitForURL(
+ (u) => u.origin === new URL(options.chatUrl).origin && !u.pathname.startsWith("/auth"),
+ { timeout: 90_000 },
+ );
+ // "New Chat" is a button while the sidebar is collapsed and a link once it is
+ // open, so a role-specific wait passes on a fresh profile and times out on a
+ // returning one.
+ await page
+ .getByRole("button", { name: /new chat/i })
+ .or(page.getByRole("link", { name: /new chat/i }))
+ .first()
+ .waitFor({ timeout: 90_000 });
+ return page;
+}
diff --git a/apps/web-console/tests/e2e/support/redact.ts b/apps/web-console/tests/e2e/support/redact.ts
new file mode 100644
index 000000000..ad7f4ae5b
--- /dev/null
+++ b/apps/web-console/tests/e2e/support/redact.ts
@@ -0,0 +1,70 @@
+// One redactor for anything a live test writes down.
+//
+// A capture of a live run reaches a log, a ledger, a CI artifact and a pull
+// request, and every auth flow that carries its credential in the URL (the
+// OAuth callback, a magic link, an invitation accept) puts a working
+// credential in the middle of an otherwise boring string. PR #578 published
+// four live invite tokens exactly that way.
+//
+// A .ts rather than an .mjs on purpose: Playwright compiles spec imports to
+// CommonJS and cannot load an .mjs from a spec, which is why redactSecrets in
+// e2e-fixture-seed.mjs (the same job for the mint's own output) cannot be
+// reused here.
+
+/**
+ * A parameter name carrying a credential. Substring matching, not exact:
+ * an exact list missed `id_token` and `provider_token`, which are as much a
+ * login as `access_token` is.
+ */
+const CREDENTIAL_NAME = /(token|code|state|otp|secret|password|passwd|jwt|apikey|api_key)/i;
+
+function scrubQuery(search: string): string {
+ const params = new URLSearchParams(search);
+ let touched = false;
+ for (const name of [...params.keys()]) {
+ if (CREDENTIAL_NAME.test(name)) {
+ params.set(name, "REDACTED");
+ touched = true;
+ }
+ }
+ return touched ? params.toString() : search;
+}
+
+/**
+ * Strips credential-bearing parameters out of one URL, in the query string
+ * and in the fragment, including the hash-router shape (`#/route?code=...`)
+ * where the credential sits inside the fragment's own query.
+ */
+export function redactUrl(url: string): string {
+ try {
+ const parsed = new URL(url);
+ parsed.search = scrubQuery(parsed.search.replace(/^\?/, ""));
+ const hash = parsed.hash.replace(/^#/, "");
+ if (hash.includes("=")) {
+ const cut = hash.indexOf("?");
+ parsed.hash =
+ cut === -1
+ ? "#" + scrubQuery(hash)
+ : "#" + hash.slice(0, cut) + "?" + scrubQuery(hash.slice(cut + 1));
+ }
+ return parsed.toString();
+ } catch {
+ return redactText(url);
+ }
+}
+
+/**
+ * The same job for free text: an error message, a stack, a framework-generated
+ * report. Playwright writes the URLs it navigated through into a
+ * `waitForURL` timeout, so the sign-in hop's own failure message enumerates
+ * the OAuth callback with a live `code` and `state` in it, and no per-URL
+ * redaction reaches that text because the framework produced it.
+ */
+export function redactText(text: string): string {
+ return text
+ .replace(
+ /([?][A-Za-z0-9_.-]*(?:token|code|state|otp|secret|password|passwd|jwt|api[_-]?key)[A-Za-z0-9_.-]*=)[^\s"'<>]+/gi,
+ "$1REDACTED",
+ )
+ .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, "REDACTED_JWT");
+}
diff --git a/apps/web-console/tests/unit/chat-coverage-lib.test.ts b/apps/web-console/tests/unit/chat-coverage-lib.test.ts
new file mode 100644
index 000000000..d93a3fcb4
--- /dev/null
+++ b/apps/web-console/tests/unit/chat-coverage-lib.test.ts
@@ -0,0 +1,350 @@
+// Unit cover for the chat-coverage gate's pure logic.
+//
+// These run in the required "Web console" CI job, with no browser and no
+// deployment, and they exist because the parts of the gate most able to lie
+// are the parts that never touch a page: how a ratio is computed, when a floor
+// fails, and what gets redacted before it is written down. The identity figure
+// this suite reports was once computed after the fact by hand, from a ledger,
+// with nothing exercising the function that is supposed to produce it.
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+import {
+ exclusionFailures,
+ parseDataDriven,
+ expiredExclusions,
+ floorFailures,
+ identityOf,
+ isDestructive,
+ parseFloors,
+ parseExclusions,
+ redactUrl,
+ requestSignature,
+ summarise,
+ type Control,
+ type Result,
+} from "../../e2e/chat-coverage/lib";
+
+const COVERAGE_DIR = join(__dirname, "../../e2e/chat-coverage");
+
+function result(key: string, over: Partial = {}): Result {
+ return {
+ key,
+ surface: key.split("::")[0],
+ name: "control",
+ role: "button",
+ proven: true,
+ proof: "dom",
+ detail: "",
+ ...over,
+ };
+}
+
+function control(over: Partial = {}): Control {
+ return {
+ key: "home::button::Thing",
+ surface: "home",
+ covId: "home#1",
+ tag: "button",
+ type: "",
+ role: "button",
+ name: "Thing",
+ label: "Thing",
+ disabled: false,
+ reason: "",
+ state: null,
+ href: "",
+ contentEditable: false,
+ ...over,
+ };
+}
+
+describe("summarise", () => {
+ it("counts one identity per control, however many rows render it", () => {
+ const summary = summarise([
+ result("sidebar::button::Chat Menu"),
+ result("sidebar::button::Chat Menu#2"),
+ result("sidebar::button::Chat Menu#3"),
+ result("home::button::New Chat"),
+ ]);
+ expect(summary.identities).toBe(2);
+ expect(summary.identitiesProven).toBe(2);
+ expect(summary.identityRatio).toBe(1);
+ // The instance figure still moves with the account's data. That is why it
+ // is the secondary number.
+ expect(summary.total).toBe(4);
+ });
+
+ it("does not prove an identity when one of its instances failed", () => {
+ const summary = summarise([
+ result("sidebar::button::Chat Menu"),
+ result("sidebar::button::Chat Menu#2", { proven: false, proof: "none", detail: "no effect" }),
+ ]);
+ expect(summary.identities).toBe(1);
+ expect(summary.identitiesProven).toBe(0);
+ });
+
+ it("keeps a control that was never fired out of both proven and failing", () => {
+ const summary = summarise([
+ result("home::button::New Chat"),
+ result("settings::button::Delete All Chats", {
+ proven: false,
+ proof: "not-fired",
+ detail: "destructive by label",
+ }),
+ ]);
+ expect(summary.proven).toBe(1);
+ expect(summary.deferred).toBe(1);
+ expect(summary.identitiesProven).toBe(1);
+ expect(summary.identitiesDeferred).toBe(1);
+ expect(summary.surfaces.settings.deferred).toBe(1);
+ // Counted apart from proven so it can never be read as coverage. It is
+ // still a failure at the gate unless inert-registry.json justifies the
+ // key, which the spec decides; this column only keeps the two apart in the
+ // report.
+ expect(summary.surfaces.settings.unproven).toEqual([]);
+ });
+
+ it("collapses only the ordinal suffix", () => {
+ expect(identityOf("search::button::Chat Menu#52")).toBe("search::button::Chat Menu");
+ expect(identityOf("search::button::GPT-4#1 turbo")).toBe("search::button::GPT-4#1 turbo");
+ });
+});
+
+describe("floorFailures", () => {
+ const floors = { home: 28, "settings:Interface": 56 };
+
+ it("fails a surface that renders less than it used to", () => {
+ const failures = floorFailures(floors, [
+ { surface: "home", enumerated: 28 },
+ { surface: "settings:Interface", enumerated: 48 },
+ ]);
+ expect(failures).toHaveLength(1);
+ expect(failures[0]).toContain("settings:Interface");
+ expect(failures[0]).toContain("below its recorded floor of 56");
+ });
+
+ it("fails a floor key the run never swept at all", () => {
+ // The defect this replaced: the guard iterated the swept list, so a
+ // surface that stopped opening took its own floor with it and the
+ // denominator shrank with nothing said.
+ const failures = floorFailures(floors, [{ surface: "home", enumerated: 28 }]);
+ expect(failures).toHaveLength(1);
+ expect(failures[0]).toContain("settings:Interface");
+ expect(failures[0]).toContain("never swept it");
+ });
+
+ it("does not fail a floor key outside the scope of a sliced run", () => {
+ const failures = floorFailures(
+ floors,
+ [{ surface: "home", enumerated: 28 }],
+ (surface) => surface === "home",
+ );
+ expect(failures).toEqual([]);
+ });
+
+ it("fails a swept surface that has no floor recorded", () => {
+ const failures = floorFailures({}, [{ surface: "brand-new", enumerated: 4 }]);
+ expect(failures[0]).toContain("no floor in surface-floors.json");
+ });
+
+ it("keeps a data-driven surface at a presence bar rather than a pinned count", () => {
+ // The chat list enumerated 50 controls one run and 106 the next with no
+ // product change: one row per chat. Pinning that count reds the gate when
+ // somebody deletes chats. A floor of 1 still catches the surface
+ // disappearing, which is the case that matters.
+ const dataDriven = parseDataDriven(
+ JSON.parse(readFileSync(join(COVERAGE_DIR, "surface-floors.json"), "utf8")),
+ );
+ const floors = parseFloors(
+ JSON.parse(readFileSync(join(COVERAGE_DIR, "surface-floors.json"), "utf8")),
+ );
+ for (const surface of dataDriven) {
+ expect(floors[surface], `${surface} must keep a presence bar`).toBe(1);
+ }
+ expect(floorFailures(floors, [{ surface: "search", enumerated: 40 }], (s) => s === "search")).toEqual([]);
+ });
+});
+
+describe("the committed floors", () => {
+ const floors = parseFloors(
+ JSON.parse(readFileSync(join(COVERAGE_DIR, "surface-floors.json"), "utf8")),
+ );
+
+ // EVERY recorded run, not one of them. Comparing against the morning ledger
+ // alone left `search` pinned at 50 while the committed floor says 106, so a
+ // fifth of the whole denominator could have been signed away and this test
+ // would still have passed. Highest wins: a floor is the most a surface has
+ // ever been seen to render.
+ function highestRecorded(): Map {
+ const seen = new Map();
+ const ledgers = [
+ JSON.parse(readFileSync(join(COVERAGE_DIR, "results/2026-08-10-morning-live-run.json"), "utf8")),
+ JSON.parse(
+ readFileSync(
+ join(COVERAGE_DIR, "../../../../docs/proof/chat-interaction-coverage-2026-08-10/coverage.run.json"),
+ "utf8",
+ ),
+ ),
+ ];
+ for (const ledger of ledgers) {
+ const surfaces = ledger.summary?.surfaces ?? ledger.surfaces ?? {};
+ for (const [surface, counts] of Object.entries(surfaces)) {
+ const total = Number(new Map(Object.entries(counts ?? {})).get("total"));
+ if (!Number.isFinite(total)) continue;
+ seen.set(surface, Math.max(seen.get(surface) ?? 0, total));
+ }
+ }
+ return seen;
+ }
+
+ it("is never below what any recorded live run enumerated", () => {
+ const dataDriven = parseDataDriven(
+ JSON.parse(readFileSync(join(COVERAGE_DIR, "surface-floors.json"), "utf8")),
+ );
+ const below: string[] = [];
+ for (const [surface, total] of highestRecorded()) {
+ // A data-driven surface keeps a presence bar of 1 rather than a pinned
+ // count, so the recorded totals are deliberately not enforced for it.
+ if (dataDriven.has(surface)) continue;
+ const floor = floors[surface];
+ // A missing key is the failure, not a reason to skip. Deleting a floor
+ // line is exactly how a surface leaves the denominator quietly, and
+ // `continue` here made that invisible.
+ if (floor === undefined) {
+ below.push(`${surface}: no floor at all, though a recorded run enumerated ${total}`);
+ continue;
+ }
+ if (floor < total) below.push(`${surface}: floor ${floor} < recorded ${total}`);
+ }
+ expect(below).toEqual([]);
+ });
+
+ it("keeps a floor for every surface that must exist even when it does not sweep", () => {
+ // sidebar, chat-item-menu, chat-message-actions and workspace:knowledge
+ // enumerate nothing today. Their floors are what turn "the entry point was
+ // deleted" into a failure rather than into a green run over a smaller app.
+ for (const surface of [
+ "sidebar",
+ "chat-item-menu",
+ "chat-message-actions",
+ "workspace:knowledge",
+ ]) {
+ expect(floors[surface], `${surface} lost its floor`).toBeGreaterThan(0);
+ }
+ });
+});
+
+describe("redactUrl", () => {
+ it("scrubs an OAuth callback in the query string", () => {
+ expect(redactUrl("https://chat.example.test/oauth/callback?code=abc123&state=xyz789")).toBe(
+ "https://chat.example.test/oauth/callback?code=REDACTED&state=REDACTED",
+ );
+ });
+
+ it("scrubs the same credentials out of a fragment", () => {
+ const redacted = redactUrl(
+ "https://chat.example.test/#access_token=abc123&refresh_token=def456&type=magiclink",
+ );
+ expect(redacted).not.toContain("abc123");
+ expect(redacted).not.toContain("def456");
+ expect(redacted).toContain("type=magiclink");
+ });
+
+ it("leaves an ordinary URL alone", () => {
+ expect(redactUrl("https://chat.example.test/api/v1/chats?page=2")).toBe(
+ "https://chat.example.test/api/v1/chats?page=2",
+ );
+ });
+});
+
+describe("requestSignature", () => {
+ it("collapses ids and query strings so a poll is recognisable again", () => {
+ expect(
+ requestSignature("https://x.test/api/v1/chats/9f1c42e0-6b1d-4d5e-9a10-2f1c8ab90001?ts=1"),
+ ).toBe(
+ requestSignature("https://x.test/api/v1/chats/0a2b77f1-1c2d-4e5f-8a90-3b1c8ab90002?ts=2"),
+ );
+ });
+
+ it("keeps two different endpoints apart", () => {
+ expect(requestSignature("https://x.test/api/v1/chats")).not.toBe(
+ requestSignature("https://x.test/api/v1/models"),
+ );
+ });
+});
+
+describe("isDestructive", () => {
+ it("matches the control's own accessible name", () => {
+ expect(isDestructive(control({ name: "Delete All Chats" }))).toBe(true);
+ expect(isDestructive(control({ name: "Archive All" }))).toBe(true);
+ expect(isDestructive(control({ name: "New Chat" }))).toBe(false);
+ });
+
+ it("never matches on text lifted from an ancestor", () => {
+ // `label` falls back to up to 140 characters of the surrounding panel, so
+ // matching it made every button under a "Delete All Chats" heading
+ // destructive: two controls the ledger proves dead would have been hidden
+ // behind not-fired, and three genuinely proven ones thrown away.
+ const inThePanel = control({
+ name: "Import Chats",
+ label: "Import Chats Export Chats Archive All Chats Delete All Chats",
+ });
+ expect(isDestructive(inThePanel)).toBe(false);
+ });
+
+ it("does not match a word that merely contains one", () => {
+ expect(isDestructive(control({ name: "Undeleted items" }))).toBe(false);
+ expect(isDestructive(control({ name: "Archived Chats" }))).toBe(false);
+ });
+});
+
+describe("exclusion bookkeeping", () => {
+ // Asserted against deliberately BROKEN input, not only against the committed
+ // file. Every earlier assertion here was "the valid file produces no
+ // complaints", which stays green if the function is gutted to return [].
+ const valid = { id: "composer-controls", reason: "panel will not open", owner: "@x", issue: 844 };
+
+ it("names every missing field", () => {
+ expect(exclusionFailures([{ ...valid, reason: "" }]).join(" ")).toContain("no reason");
+ expect(exclusionFailures([{ ...valid, owner: undefined }]).join(" ")).toContain("no owner");
+ expect(exclusionFailures([{ ...valid, issue: undefined }]).join(" ")).toContain(
+ "names no blocking issue",
+ );
+ expect(exclusionFailures([{ ...valid, permanent: true }]).join(" ")).toContain(
+ "one or the other",
+ );
+ expect(exclusionFailures([valid])).toEqual([]);
+ });
+
+ it("expires an exclusion the day its issue closes", () => {
+ expect(expiredExclusions([valid], new Set([844]))).toHaveLength(1);
+ expect(expiredExclusions([valid], new Set([844]))[0]).toContain("844");
+ expect(expiredExclusions([valid], new Set([999]))).toEqual([]);
+ expect(expiredExclusions([{ ...valid, issue: undefined, permanent: true }], new Set([844]))).toEqual(
+ [],
+ );
+ });
+});
+
+describe("the data-file validators", () => {
+ it("rejects a floor that is not a positive integer", () => {
+ expect(() => parseFloors({ surfaces: { home: "28" } })).toThrow(/positive integer/);
+ expect(() => parseFloors({ surfaces: { home: 0 } })).toThrow(/positive integer/);
+ });
+
+ it("rejects an exclusions file with no array", () => {
+ expect(() => parseExclusions({ surfaces: {} })).toThrow(/expected a JSON array/);
+ });
+
+ it("reads the committed exclusions", () => {
+ const parsed = parseExclusions(
+ JSON.parse(readFileSync(join(COVERAGE_DIR, "surface-exclusions.json"), "utf8")),
+ );
+ for (const entry of parsed) {
+ expect(entry.id).not.toBe("");
+ }
+ });
+});
diff --git a/docs/proof/chat-interaction-coverage-2026-08-10/README.md b/docs/proof/chat-interaction-coverage-2026-08-10/README.md
new file mode 100644
index 000000000..b2bde2926
--- /dev/null
+++ b/docs/proof/chat-interaction-coverage-2026-08-10/README.md
@@ -0,0 +1,170 @@
+# Chat interaction coverage, live, 2026-08-10
+
+Re-measurement after the engine was taught to read HTTP statuses. Run against
+https://chat-hive.scubed.co, signed in through the shared live-auth helper,
+which mints a session without touching any password.
+
+**No coverage figure is claimed for this run any more.**
+
+What this directory holds is the ledger of a real sweep, and it is kept for
+what it records rather than for a headline. The number that was here (144 of
+165 identities, 87.3 percent) is withdrawn, for two reasons that are both
+fatal on their own.
+
+That run predates the rework in this pull request, and no shipped code path
+can produce its figure. Its proven count includes destructive controls that
+were clicked with the write aborted and then recorded as proven, which is
+exactly the practice removed here, and it includes controls proven by a
+request that no server ever answered, which no longer counts either. The
+identity figure was derived offline from those results by a script that had
+none of the guards the floor updater has: it printed a percentage from a
+ledger carrying five surface errors, which also violates four of the floors
+this pull request restores. That script is deleted rather than hardened,
+because a number derived from a run the gate would reject is not evidence of
+anything.
+
+The figure returns when a sweep runs against the deployment under the rules in
+this pull request, writes its own summary, and passes its own floor and error
+checks. The workflow to run it is here (`npm run e2e:chat-coverage`, or the
+Chat coverage live sweep job), and it was deliberately not run for this pull
+request because the demo box is single tenant and another agent is on it.
+
+When a figure does come back, it is the identity figure: distinct control
+identities rather than raw instances, where an identity counts as proven only
+when every instance of it proved, so a control that works on the first chat
+row and fails on the seventh is unproven. The gate fails on the failing
+instance regardless, so collapsing instances cannot hide a broken one, and
+each identity carries its instance count in the ledger under
+`summary.identityInstances`.
+
+That figure has a known ceiling worth stating rather than hiding: an identity
+is keyed on the control's rendered name, and the chat list names its rows
+after the chats, so an account with a hundred differently titled chats reports
+a hundred identities on that surface. Instance counts have the same problem
+more loudly. Neither number is a product measurement until the chat list is
+keyed by row shape rather than by row content, which is not in this pull
+request.
+
+The earlier run the same day recorded 196 of 222 instances. Its ledger is
+`apps/web-console/e2e/chat-coverage/results/2026-08-10-morning-live-run.json`,
+renamed from `2026-08-08-live-run.json`, which was simply wrong: its own
+`generatedAt` reads 2026-08-10T07:31Z. It is kept because the floors are
+pinned against what it and the evening run enumerated, which is enumeration
+rather than verdicts and is unaffected by the rework.
+
+## Two controls the old predicate called proven are broken
+
+This engine never registered a response listener, so every status was
+invisible to it. Two controls moved from proven to unproven on exactly that:
+
+- settings:General, Admin Settings. Fires a request that answers 404. It was
+ counted as network proof.
+- composer-more, Upload Files. Renders File not found. and nothing else. It
+ was counted as dom proof.
+
+Both look like real defects in the surface rather than gate artefacts.
+
+## Why the ratio rose despite that
+
+The denominator changed shape. search enumerated 106 controls this run against
+50 last time and every one of them proved, which alone moves the ratio more
+than the two reclassifications move it back. The honest reading is that the
+two numbers are not directly comparable: this run swept a different set of
+surfaces, and several of them still refuse to open.
+
+## Five surface errors in this run, none of them in the denominator
+
+#834 merged and deployed, and it was expected to unblock the first four. It
+did not. All five are `surfaceErrors` in the ledger and they fail the gate, so
+they are visible rather than quietly dropped. They are not exclusions and they
+carry no issue, because the gate is supposed to stay red on them until they
+sweep:
+
+- sidebar enumerated zero controls.
+- chat-item-menu could not open, click timed out after 20s.
+- chat-message-actions could not open, same.
+- workspace:knowledge enumerated zero controls.
+- user-menu, click pass aborted: the execution context was destroyed by a
+ navigation partway through the pass, so that surface's numbers in this
+ ledger (5 results) are short of what it enumerated (6).
+
+## The one exclusion
+
+composer-controls, in surface-exclusions.json, tied to #844. The gate fails
+the moment that issue closes, so the exclusion cannot outlive its reason.
+
+## The denominator guard
+
+surface-floors.json records the highest control count any recorded live run
+has enumerated on each surface. A run that enumerates fewer controls on a
+surface than its floor fails, a surface with no floor at all fails, and a
+floor key the run never swept fails too. Floors are changed only by
+`scripts/update-chat-coverage-floors.mjs`, in a separate commit, against a
+recorded ledger. The run that checks a floor can never be the run that moves
+it.
+
+## The break proof
+
+The self test carries nine buttons, and it is the check that fails if the
+detector breaks. It needs no deployment and no credentials, so it runs in CI
+on every pull request that touches the gate:
+
+| button | expected | result |
+| --- | --- | --- |
+| Wired, changes the page | proven | proven |
+| Dead, no handler | unproven | unproven |
+| Failing, endpoint answers 500 then a toast | unproven | unproven, detail names the 500 |
+| Toasty, error toast only | unproven | unproven, raised an error to the user |
+| Pollster, dead button on a page that polls underneath it | unproven | unproven, the poll was excluded by the idle sample |
+| Hanging, sends a request nothing ever answers | unproven | unproven, "nothing answered" |
+| Cut Off, request dies in transport | unproven | unproven, "never reached a server" |
+| Delete Everything, destructive by its own name | not fired | not fired, never clicked |
+| Locked, disabled with a title | not fired | not fired, and never proof |
+
+```console
+cd apps/web-console
+npm run e2e:chat-coverage:self-check
+3 passed
+```
+
+Those three are the prover break-proof, the inert-registry and exclusion
+validity check, and the exclusion-expiry check that reads issue state from
+GitHub. The last one fails without a token rather than skipping, which is why
+the CI job passes `GITHUB_TOKEN`. Run locally without one, it fails with
+"GITHUB_TOKEN (or GH_TOKEN) is required", which is the intended behaviour.
+
+The fixture is served from an intercepted origin, not from the live
+deployment, so the failing button has a same origin endpoint whose status the
+prover can read. Nothing in this test talks to the demo box.
+
+No credential appears in this directory. The suite records no trace and no
+video by default: both carry the session's cookies, and a trace of the sign-in
+hop carries the OAuth callback's `code` and `state`, none of which the text
+lint in `tools/lint-no-token-in-proof-captures.mjs` can see inside a binary.
+Every URL the suite writes to a message, a ledger or a log goes through
+`redactUrl` first.
+
+## Run notes: the box timed out twice under our own load
+
+Recorded here because two independent surfaces failing under agent load is a
+stability signal rather than noise, and because a silent re-run would have
+hidden it.
+
+- The console box returned 504 on seven routes during an earlier attempt at the
+ console sweep on the same evening: /console/members, /console/settings/billing,
+ /console/settings/profile, /console/setup, /invitations/accept, /no-workspace
+ and /oauth/consent. It recovered unattended, matching #815.
+- The Supabase magic link mint answered 504 once, which failed a run at the
+ authentication step before any control was touched.
+
+Both recovered without intervention. Every figure recorded in this directory
+comes from a run with no 504 in it.
+
+## The percentage is not a stable measure yet
+
+The search surface is the chat list, so its instance count is the number of
+chats the account holds: 52 Chat Menu buttons and 25 New Chat links, one per
+row, several of them left behind by other automated runs. That is what the
+identity figure is for, and it is why the instance figure is kept as the
+secondary number rather than dropped: they answer different questions and only
+the identity one is comparable between runs.
diff --git a/docs/proof/chat-interaction-coverage-2026-08-10/coverage.run.json b/docs/proof/chat-interaction-coverage-2026-08-10/coverage.run.json
new file mode 100644
index 000000000..6cab4459a
--- /dev/null
+++ b/docs/proof/chat-interaction-coverage-2026-08-10/coverage.run.json
@@ -0,0 +1,2560 @@
+{
+ "generatedAt": "2026-08-10T23:37:11.819Z",
+ "target": "https://chat-hive.scubed.co",
+ "surfaceFilter": null,
+ "sabotage": null,
+ "summary": {
+ "total": 270,
+ "proven": 248,
+ "ratio": 0.9185,
+ "surfaces": {
+ "home": {
+ "total": 28,
+ "proven": 25,
+ "unproven": [
+ "(unnamed) [button] :: control was not there when the surface was re-opened",
+ "(unnamed) [button] :: control was not there when the surface was re-opened",
+ "(unnamed) [div] :: not present on re-open"
+ ]
+ },
+ "model-picker": {
+ "total": 15,
+ "proven": 13,
+ "unproven": [
+ "All [button] :: no effect (mutations=0)",
+ "External [button] :: no effect (mutations=4)"
+ ]
+ },
+ "composer-more": {
+ "total": 6,
+ "proven": 5,
+ "unproven": [
+ "Upload Files [button] :: raised an error to the user: File not found."
+ ]
+ },
+ "composer-integrations": {
+ "total": 2,
+ "proven": 2,
+ "unproven": []
+ },
+ "search": {
+ "total": 106,
+ "proven": 106,
+ "unproven": []
+ },
+ "workspace": {
+ "total": 12,
+ "proven": 10,
+ "unproven": [
+ "Open Sidebar [button] :: control was not there when the surface was re-opened",
+ "Knowledge [a] :: no effect (mutations=2)"
+ ]
+ },
+ "settings:General": {
+ "total": 16,
+ "proven": 12,
+ "unproven": [
+ "General [tab] :: no effect (mutations=0)",
+ "Admin Settings [a] :: called an endpoint that is not mounted or that failed: 404 GET https://chat-hive.scubed.co/api/v1/auths/admin/config",
+ "Show [button] :: no effect (mutations=3)",
+ "Off [switch] :: changed in the UI but reverted to false after reload"
+ ]
+ },
+ "settings:Interface": {
+ "total": 48,
+ "proven": 44,
+ "unproven": [
+ "Floating Quick Actions [switch] :: not present on a clean re-open",
+ "Image Compression [switch] :: not present on a clean re-open",
+ "Allow User Location [switch] :: changed in the UI but reverted to false after reload",
+ "Auto-Copy Response to Clipboard [switch] :: changed in the UI but reverted to false after reload"
+ ]
+ },
+ "settings:Personalization": {
+ "total": 2,
+ "proven": 2,
+ "unproven": []
+ },
+ "settings:Audio": {
+ "total": 7,
+ "proven": 5,
+ "unproven": [
+ "Off [switch] :: not present on a clean re-open",
+ "Voice [select] :: changed in the UI but reverted to after reload"
+ ]
+ },
+ "settings:Data Controls": {
+ "total": 7,
+ "proven": 5,
+ "unproven": [
+ "Archive All [button] :: no effect (mutations=3)",
+ "Delete All [button] :: no effect (mutations=3)"
+ ]
+ },
+ "settings:Account": {
+ "total": 5,
+ "proven": 5,
+ "unproven": []
+ },
+ "settings:About": {
+ "total": 5,
+ "proven": 4,
+ "unproven": [
+ "See what's new [button] :: no effect (mutations=0)"
+ ]
+ },
+ "workspace:knowledge/create": {
+ "total": 6,
+ "proven": 5,
+ "unproven": [
+ "Create Knowledge [button] :: no effect (mutations=0)"
+ ]
+ },
+ "user-menu": {
+ "total": 5,
+ "proven": 5,
+ "unproven": []
+ }
+ }
+ },
+ "excused": [],
+ "surfaceErrors": [
+ "sidebar: enumerated zero controls",
+ "chat-item-menu: could not open (TimeoutError: locator.click: Timeout 20000ms exceeded.)",
+ "chat-message-actions: could not open (TimeoutError: locator.click: Timeout 20000ms exceeded.)",
+ "workspace:knowledge: enumerated zero controls",
+ "user-menu: click pass aborted (Error: page.evaluate: Execution context was destroyed, most likely because of a navigation)"
+ ],
+ "results": [
+ {
+ "key": "home::button::Open Sidebar",
+ "surface": "home",
+ "name": "Open Sidebar",
+ "role": "button",
+ "proven": true,
+ "proof": "network",
+ "detail": "https://chat-hive.scubed.co/api/v1/folders/, https://chat-hive.scubed.co/api/v1/folders/, https://chat-hive.scubed.co/api/v1/chats/all/tags"
+ },
+ {
+ "key": "home::button::Open Sidebar#2",
+ "surface": "home",
+ "name": "Open Sidebar",
+ "role": "button",
+ "proven": true,
+ "proof": "network",
+ "detail": "https://chat-hive.scubed.co/api/v1/folders/, https://chat-hive.scubed.co/api/v1/folders/, https://chat-hive.scubed.co/api/v1/chats/all/tags"
+ },
+ {
+ "key": "home::a::New Chat",
+ "surface": "home",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "dom",
+ "detail": "what is on screen changed"
+ },
+ {
+ "key": "home::button::Search",
+ "surface": "home",
+ "name": "Search",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 0 -> 1"
+ },
+ {
+ "key": "home::a::Workspace",
+ "surface": "home",
+ "name": "Workspace",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/workspace/knowledge"
+ },
+ {
+ "key": "home::button::User menu",
+ "surface": "home",
+ "name": "User menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 0 -> 1"
+ },
+ {
+ "key": "home::button::Selected model: hive-auto",
+ "surface": "home",
+ "name": "Selected model: hive-auto",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 0 -> 1"
+ },
+ {
+ "key": "home::button::Add Model",
+ "surface": "home",
+ "name": "Add Model",
+ "role": "button",
+ "proven": true,
+ "proof": "dom",
+ "detail": "what is on screen changed"
+ },
+ {
+ "key": "home::button::Selected model: hive-auto#2",
+ "surface": "home",
+ "name": "Selected model: hive-auto",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 0 -> 1"
+ },
+ {
+ "key": "home::button::Remove Model",
+ "surface": "home",
+ "name": "Remove Model",
+ "role": "button",
+ "proven": true,
+ "proof": "dom",
+ "detail": "what is on screen changed"
+ },
+ {
+ "key": "home::button::Set as default",
+ "surface": "home",
+ "name": "Set as default",
+ "role": "button",
+ "proven": true,
+ "proof": "network",
+ "detail": "https://chat-hive.scubed.co/api/v1/users/user/settings/update"
+ },
+ {
+ "key": "home::button::Temporary Chat",
+ "surface": "home",
+ "name": "Temporary Chat",
+ "role": "button",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/?temporary-chat=true"
+ },
+ {
+ "key": "home::button::Controls",
+ "surface": "home",
+ "name": "Controls",
+ "role": "button",
+ "proven": true,
+ "proof": "dom",
+ "detail": "what is on screen changed"
+ },
+ {
+ "key": "home::button::User menu#2",
+ "surface": "home",
+ "name": "User menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 0 -> 1"
+ },
+ {
+ "key": "home::button::(unnamed)",
+ "surface": "home",
+ "name": "",
+ "role": "button",
+ "proven": false,
+ "proof": "none",
+ "detail": "control was not there when the surface was re-opened"
+ },
+ {
+ "key": "home::button::(unnamed)#2",
+ "surface": "home",
+ "name": "",
+ "role": "button",
+ "proven": false,
+ "proof": "none",
+ "detail": "control was not there when the surface was re-opened"
+ },
+ {
+ "key": "home::listitem::Show me a code snippet of a website's sticky header",
+ "surface": "home",
+ "name": "Show me a code snippet of a website's sticky header",
+ "role": "listitem",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/6d2e3e7b-00bb-483a-aacc-d48e409a3928"
+ },
+ {
+ "key": "home::listitem::Tell me a fun fact about the Roman Empire",
+ "surface": "home",
+ "name": "Tell me a fun fact about the Roman Empire",
+ "role": "listitem",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/2dc66df1-db56-435e-b0fe-308affd06315"
+ },
+ {
+ "key": "home::listitem::Explain options trading if I'm familiar with buying and selling stocks",
+ "surface": "home",
+ "name": "Explain options trading if I'm familiar with buying and selling stocks",
+ "role": "listitem",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/3ad77117-ede7-429a-8a2a-cf760d5bf866"
+ },
+ {
+ "key": "home::listitem::Overcome procrastination give me tips",
+ "surface": "home",
+ "name": "Overcome procrastination give me tips",
+ "role": "listitem",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/5f832d14-78a0-4c79-ac1b-5b9d5c999ff4"
+ },
+ {
+ "key": "home::listitem::Give me ideas for what to do with my kids' art",
+ "surface": "home",
+ "name": "Give me ideas for what to do with my kids' art",
+ "role": "listitem",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/e24d2d5f-5a70-48c8-af13-405e2a148bf7"
+ },
+ {
+ "key": "home::listitem::Help me study vocabulary for a college entrance exam",
+ "surface": "home",
+ "name": "Help me study vocabulary for a college entrance exam",
+ "role": "listitem",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/2deec651-99b2-4b62-b86f-7c2a08f3916c"
+ },
+ {
+ "key": "home::button::More",
+ "surface": "home",
+ "name": "More",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 0 -> 1"
+ },
+ {
+ "key": "home::button::Integrations",
+ "surface": "home",
+ "name": "Integrations",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 0 -> 1"
+ },
+ {
+ "key": "home::button::Voice Input",
+ "surface": "home",
+ "name": "Voice Input",
+ "role": "button",
+ "proven": true,
+ "proof": "dom",
+ "detail": "13 mutations"
+ },
+ {
+ "key": "home::button::Voice mode",
+ "surface": "home",
+ "name": "Voice mode",
+ "role": "button",
+ "proven": true,
+ "proof": "dom",
+ "detail": "13 mutations"
+ },
+ {
+ "key": "home::a::Open the Hive agent workspace",
+ "surface": "home",
+ "name": "Open the Hive agent workspace",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/agent-workspace/auth/sign-in"
+ },
+ {
+ "key": "home::div::(unnamed)",
+ "surface": "home",
+ "name": "",
+ "role": "div",
+ "proven": false,
+ "proof": "none",
+ "detail": "not present on re-open"
+ },
+ {
+ "key": "model-picker::button::All",
+ "surface": "model-picker",
+ "name": "All",
+ "role": "button",
+ "proven": false,
+ "proof": "none",
+ "detail": "no effect (mutations=0)"
+ },
+ {
+ "key": "model-picker::button::External",
+ "surface": "model-picker",
+ "name": "External",
+ "role": "button",
+ "proven": false,
+ "proof": "none",
+ "detail": "no effect (mutations=4)"
+ },
+ {
+ "key": "model-picker::option::Select hive-auto model",
+ "surface": "model-picker",
+ "name": "Select hive-auto model",
+ "role": "option",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 0"
+ },
+ {
+ "key": "model-picker::button::More Options",
+ "surface": "model-picker",
+ "name": "More Options",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "model-picker::option::Select hive-default model",
+ "surface": "model-picker",
+ "name": "Select hive-default model",
+ "role": "option",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 0"
+ },
+ {
+ "key": "model-picker::button::More Options#2",
+ "surface": "model-picker",
+ "name": "More Options",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "model-picker::option::Select hive-embedding-default model",
+ "surface": "model-picker",
+ "name": "Select hive-embedding-default model",
+ "role": "option",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 0"
+ },
+ {
+ "key": "model-picker::button::More Options#3",
+ "surface": "model-picker",
+ "name": "More Options",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "model-picker::option::Select hive-fast model",
+ "surface": "model-picker",
+ "name": "Select hive-fast model",
+ "role": "option",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 0"
+ },
+ {
+ "key": "model-picker::button::More Options#4",
+ "surface": "model-picker",
+ "name": "More Options",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "model-picker::option::Select hive-stt model",
+ "surface": "model-picker",
+ "name": "Select hive-stt model",
+ "role": "option",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 0"
+ },
+ {
+ "key": "model-picker::button::More Options#5",
+ "surface": "model-picker",
+ "name": "More Options",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "model-picker::option::Select hive-tts model",
+ "surface": "model-picker",
+ "name": "Select hive-tts model",
+ "role": "option",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 0"
+ },
+ {
+ "key": "model-picker::button::More Options#6",
+ "surface": "model-picker",
+ "name": "More Options",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "model-picker::input::Search In Models",
+ "surface": "model-picker",
+ "name": "Search In Models",
+ "role": "input",
+ "proven": true,
+ "proof": "value",
+ "detail": " -> hive-coverage-50560"
+ },
+ {
+ "key": "composer-more::button::Upload Files",
+ "surface": "composer-more",
+ "name": "Upload Files",
+ "role": "button",
+ "proven": false,
+ "proof": "none",
+ "detail": "raised an error to the user: File not found."
+ },
+ {
+ "key": "composer-more::button::Capture",
+ "surface": "composer-more",
+ "name": "Capture",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 0"
+ },
+ {
+ "key": "composer-more::button::Attach Webpage",
+ "surface": "composer-more",
+ "name": "Attach Webpage",
+ "role": "button",
+ "proven": true,
+ "proof": "dom",
+ "detail": "what is on screen changed"
+ },
+ {
+ "key": "composer-more::button::Attach Files",
+ "surface": "composer-more",
+ "name": "Attach Files",
+ "role": "button",
+ "proven": true,
+ "proof": "network",
+ "detail": "https://chat-hive.scubed.co/api/v1/files/search?filename=*&skip=0&limit=50&content=false"
+ },
+ {
+ "key": "composer-more::button::Attach Knowledge",
+ "surface": "composer-more",
+ "name": "Attach Knowledge",
+ "role": "button",
+ "proven": true,
+ "proof": "network",
+ "detail": "https://chat-hive.scubed.co/api/v1/knowledge/?page=1"
+ },
+ {
+ "key": "composer-more::button::Reference Chats",
+ "surface": "composer-more",
+ "name": "Reference Chats",
+ "role": "button",
+ "proven": true,
+ "proof": "network",
+ "detail": "https://chat-hive.scubed.co/api/v1/chats/?page=1&include_folders=true&include_pinned=true"
+ },
+ {
+ "key": "composer-integrations::button::Enable Code Interpreter",
+ "surface": "composer-integrations",
+ "name": "Enable Code Interpreter",
+ "role": "button",
+ "proven": true,
+ "proof": "dom",
+ "detail": "what is on screen changed"
+ },
+ {
+ "key": "composer-integrations::switch::Code Interpreter",
+ "surface": "composer-integrations",
+ "name": "Code Interpreter",
+ "role": "switch",
+ "proven": true,
+ "proof": "value",
+ "detail": "false -> true"
+ },
+ {
+ "key": "search::button::Start a new conversation",
+ "surface": "search",
+ "name": "Start a new conversation",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 0"
+ },
+ {
+ "key": "search::a::Help me study vocabulary: write a sentence for me to fill in the blank",
+ "surface": "search",
+ "name": "Help me study vocabulary: write a sentence for me to fill in the blank",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/2deec651-99b2-4b62-b86f-7c2a08f3916c"
+ },
+ {
+ "key": "search::button::Chat Menu",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::What are 5 creative things I could do with my kids' art? I don't want ",
+ "surface": "search",
+ "name": "What are 5 creative things I could do with my kids' art? I don't want ",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/e24d2d5f-5a70-48c8-af13-405e2a148bf7"
+ },
+ {
+ "key": "search::button::Chat Menu#2",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::Could you start by asking me about instances when I procrastinate the ",
+ "surface": "search",
+ "name": "Could you start by asking me about instances when I procrastinate the ",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/5f832d14-78a0-4c79-ac1b-5b9d5c999ff4"
+ },
+ {
+ "key": "search::button::Chat Menu#3",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::Explain options trading in simple terms if I'm familiar with buying an",
+ "surface": "search",
+ "name": "Explain options trading in simple terms if I'm familiar with buying an",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/3ad77117-ede7-429a-8a2a-cf760d5bf866"
+ },
+ {
+ "key": "search::button::Chat Menu#4",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::Tell me a random fun fact about the Roman Empire",
+ "surface": "search",
+ "name": "Tell me a random fun fact about the Roman Empire",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/2dc66df1-db56-435e-b0fe-308affd06315"
+ },
+ {
+ "key": "search::button::Chat Menu#5",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::Show me a code snippet of a website's sticky header in CSS and JavaScr",
+ "surface": "search",
+ "name": "Show me a code snippet of a website's sticky header in CSS and JavaScr",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/6d2e3e7b-00bb-483a-aacc-d48e409a3928"
+ },
+ {
+ "key": "search::button::Chat Menu#6",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#2",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/1edc7506-b968-4647-bc10-180de4fefade"
+ },
+ {
+ "key": "search::button::Chat Menu#7",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#3",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/21d94be1-1b84-48a1-9cad-95f2c8a5f91a"
+ },
+ {
+ "key": "search::button::Chat Menu#8",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#4",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/c7ad889c-e3f4-467a-8d52-e5e09d221160"
+ },
+ {
+ "key": "search::button::Chat Menu#9",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#5",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/8af16816-bc92-4cb6-bd57-5f78516930e0"
+ },
+ {
+ "key": "search::button::Chat Menu#10",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#6",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/97697dc2-e902-4fa8-ba6e-7b5fd62c701c"
+ },
+ {
+ "key": "search::button::Chat Menu#11",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#7",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/b7cb0bab-577b-4b82-90a8-74ebd8e2f113"
+ },
+ {
+ "key": "search::button::Chat Menu#12",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::Show me a code snippet of a website's sticky header in CSS and JavaScr#2",
+ "surface": "search",
+ "name": "Show me a code snippet of a website's sticky header in CSS and JavaScr",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/039b5a2d-e6a9-4bf3-bc8f-23691108658f"
+ },
+ {
+ "key": "search::button::Chat Menu#13",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::Tell me a random fun fact about the Roman Empire#2",
+ "surface": "search",
+ "name": "Tell me a random fun fact about the Roman Empire",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/a2697ce6-7bda-42a0-8640-010ac3a58a2a"
+ },
+ {
+ "key": "search::button::Chat Menu#14",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::Could you start by asking me about instances when I procrastinate the #2",
+ "surface": "search",
+ "name": "Could you start by asking me about instances when I procrastinate the ",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/eadc50ac-08c1-4735-b3c2-6bf5b7027933"
+ },
+ {
+ "key": "search::button::Chat Menu#15",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::Explain options trading in simple terms if I'm familiar with buying an#2",
+ "surface": "search",
+ "name": "Explain options trading in simple terms if I'm familiar with buying an",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/646a375b-7e39-4c54-8c9b-dac10ede6e3e"
+ },
+ {
+ "key": "search::button::Chat Menu#16",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::What are 5 creative things I could do with my kids' art? I don't want #2",
+ "surface": "search",
+ "name": "What are 5 creative things I could do with my kids' art? I don't want ",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/e64e721e-9424-4947-aca3-df83e9136dfe"
+ },
+ {
+ "key": "search::button::Chat Menu#17",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::Help me study vocabulary: write a sentence for me to fill in the blank#2",
+ "surface": "search",
+ "name": "Help me study vocabulary: write a sentence for me to fill in the blank",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/7ed3130a-5881-4683-8bcc-a7e06f165979"
+ },
+ {
+ "key": "search::button::Chat Menu#18",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#8",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/0e00e197-ae7c-4957-aa9a-4eb877985d73"
+ },
+ {
+ "key": "search::button::Chat Menu#19",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#9",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/3528e1ae-932e-48a4-9ca0-d8d5a5a69564"
+ },
+ {
+ "key": "search::button::Chat Menu#20",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#10",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/4af4dd72-8889-4b25-8d9f-e61c4f94021a"
+ },
+ {
+ "key": "search::button::Chat Menu#21",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#11",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/e6df67ac-675c-437e-ac51-14c0d237985f"
+ },
+ {
+ "key": "search::button::Chat Menu#22",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#12",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/3c927328-37f1-46aa-951a-ca6dea7b8253"
+ },
+ {
+ "key": "search::button::Chat Menu#23",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#13",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/999a46e8-3e89-4c54-84b6-85b2b1e66574"
+ },
+ {
+ "key": "search::button::Chat Menu#24",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::Explain options trading in simple terms if I'm familiar with buying an#3",
+ "surface": "search",
+ "name": "Explain options trading in simple terms if I'm familiar with buying an",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/ccbcf9fd-1322-4671-9dcf-4c4be93b9db1"
+ },
+ {
+ "key": "search::button::Chat Menu#25",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::What are 5 creative things I could do with my kids' art? I don't want #3",
+ "surface": "search",
+ "name": "What are 5 creative things I could do with my kids' art? I don't want ",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/c6cd4f82-bc14-4abe-9921-eb86ca8fee5b"
+ },
+ {
+ "key": "search::button::Chat Menu#26",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::Tell me a random fun fact about the Roman Empire#3",
+ "surface": "search",
+ "name": "Tell me a random fun fact about the Roman Empire",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/1a93beac-d252-4e9b-81d8-2111586c90c4"
+ },
+ {
+ "key": "search::button::Chat Menu#27",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::Help me study vocabulary: write a sentence for me to fill in the blank#3",
+ "surface": "search",
+ "name": "Help me study vocabulary: write a sentence for me to fill in the blank",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/96247d19-381b-41a0-8544-5be5d65104f8"
+ },
+ {
+ "key": "search::button::Chat Menu#28",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#14",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/60ac348e-3429-4093-879e-a4099f763623"
+ },
+ {
+ "key": "search::button::Chat Menu#29",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#15",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/0181abe8-d73e-4e03-8f6c-1c8459f16bdc"
+ },
+ {
+ "key": "search::button::Chat Menu#30",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#16",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/cabc7209-5713-4bd6-b2d5-5310b52a5dde"
+ },
+ {
+ "key": "search::button::Chat Menu#31",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#17",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/17e6c2fd-4d8d-45c9-93a2-ba09a477e365"
+ },
+ {
+ "key": "search::button::Chat Menu#32",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#18",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/ee2f8652-4d7b-4ee3-9a78-1fd6048cda33"
+ },
+ {
+ "key": "search::button::Chat Menu#33",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::Help me study vocabulary: write a sentence for me to fill in the blank#4",
+ "surface": "search",
+ "name": "Help me study vocabulary: write a sentence for me to fill in the blank",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/d337a76b-8c15-4c5c-8899-5209679173da"
+ },
+ {
+ "key": "search::button::Chat Menu#34",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::What are 5 creative things I could do with my kids' art? I don't want #4",
+ "surface": "search",
+ "name": "What are 5 creative things I could do with my kids' art? I don't want ",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/4ac32b33-7d87-41b9-a7a3-0a526bc1e8cb"
+ },
+ {
+ "key": "search::button::Chat Menu#35",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::Tell me a random fun fact about the Roman Empire#4",
+ "surface": "search",
+ "name": "Tell me a random fun fact about the Roman Empire",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/fe2151d4-1eb5-47b5-acd1-b0256aa9027d"
+ },
+ {
+ "key": "search::button::Chat Menu#36",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::Could you start by asking me about instances when I procrastinate the #3",
+ "surface": "search",
+ "name": "Could you start by asking me about instances when I procrastinate the ",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/a03b10d0-b9f2-4d07-b21e-81cbd8fe2314"
+ },
+ {
+ "key": "search::button::Chat Menu#37",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::Show me a code snippet of a website's sticky header in CSS and JavaScr#3",
+ "surface": "search",
+ "name": "Show me a code snippet of a website's sticky header in CSS and JavaScr",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/1b33a303-c5c3-4c49-b832-8609b6b879f5"
+ },
+ {
+ "key": "search::button::Chat Menu#38",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::Explain options trading in simple terms if I'm familiar with buying an#4",
+ "surface": "search",
+ "name": "Explain options trading in simple terms if I'm familiar with buying an",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/316610f2-8b39-4eca-b39b-3163b5c09cf0"
+ },
+ {
+ "key": "search::button::Chat Menu#39",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#19",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/8fde4ca4-de47-442c-a445-df14ed2617f6"
+ },
+ {
+ "key": "search::button::Chat Menu#40",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#20",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/a64c1513-55f9-43fd-97e9-ac193d61774d"
+ },
+ {
+ "key": "search::button::Chat Menu#41",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#21",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/00e4ceb1-b58b-4fa5-a74b-2686a6f4e28b"
+ },
+ {
+ "key": "search::button::Chat Menu#42",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#22",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/6076075e-a8df-4d82-a968-8763a3432d8b"
+ },
+ {
+ "key": "search::button::Chat Menu#43",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#23",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/7f429ba9-406d-476d-9242-71f33448e2af"
+ },
+ {
+ "key": "search::button::Chat Menu#44",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#24",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/5c7a9637-b843-4c6d-bc79-c717146f861f"
+ },
+ {
+ "key": "search::button::Chat Menu#45",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#25",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/d30db07d-fe4c-4a5c-8d90-1849d12f0669"
+ },
+ {
+ "key": "search::button::Chat Menu#46",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::New Chat#26",
+ "surface": "search",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/ae125ae5-0d7b-4e19-8bbe-e09db6f78227"
+ },
+ {
+ "key": "search::button::Chat Menu#47",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::Reply with exactly: ALPHA",
+ "surface": "search",
+ "name": "Reply with exactly: ALPHA",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/f8906d4e-80e1-4cbd-a75c-e2c28b55e6ba"
+ },
+ {
+ "key": "search::button::Chat Menu#48",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::Reply with exactly: SIERRA",
+ "surface": "search",
+ "name": "Reply with exactly: SIERRA",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/b61a198e-2a2c-4970-a029-553f24fbc602"
+ },
+ {
+ "key": "search::button::Chat Menu#49",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::Reply with exactly: ALPHA#2",
+ "surface": "search",
+ "name": "Reply with exactly: ALPHA",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/00618bd3-5d66-4f24-8ff0-394e3509faaf"
+ },
+ {
+ "key": "search::button::Chat Menu#50",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::Reply with exactly: PONYTAILTEST42",
+ "surface": "search",
+ "name": "Reply with exactly: PONYTAILTEST42",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/2d6b59b2-d455-43ef-b9b8-af7f875b199a"
+ },
+ {
+ "key": "search::button::Chat Menu#51",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::a::ping",
+ "surface": "search",
+ "name": "ping",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/c/811a056d-b9fb-4062-bf4e-71292c15fb7a"
+ },
+ {
+ "key": "search::button::Chat Menu#52",
+ "surface": "search",
+ "name": "Chat Menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "search::input::Search",
+ "surface": "search",
+ "name": "Search",
+ "role": "input",
+ "proven": true,
+ "proof": "value",
+ "detail": " -> hive-coverage-27203"
+ },
+ {
+ "key": "workspace::button::Open Sidebar",
+ "surface": "workspace",
+ "name": "Open Sidebar",
+ "role": "button",
+ "proven": true,
+ "proof": "network",
+ "detail": "https://chat-hive.scubed.co/api/v1/folders/, https://chat-hive.scubed.co/api/v1/folders/, https://chat-hive.scubed.co/api/v1/chats/all/tags"
+ },
+ {
+ "key": "workspace::button::Open Sidebar#2",
+ "surface": "workspace",
+ "name": "Open Sidebar",
+ "role": "button",
+ "proven": false,
+ "proof": "none",
+ "detail": "control was not there when the surface was re-opened"
+ },
+ {
+ "key": "workspace::a::New Chat",
+ "surface": "workspace",
+ "name": "New Chat",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/workspace/knowledge -> https://chat-hive.scubed.co/"
+ },
+ {
+ "key": "workspace::button::Search",
+ "surface": "workspace",
+ "name": "Search",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 0 -> 1"
+ },
+ {
+ "key": "workspace::a::Workspace",
+ "surface": "workspace",
+ "name": "Workspace",
+ "role": "a",
+ "proven": true,
+ "proof": "network",
+ "detail": "https://chat-hive.scubed.co/api/v1/knowledge/search?page=1"
+ },
+ {
+ "key": "workspace::button::User menu",
+ "surface": "workspace",
+ "name": "User menu",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 0 -> 1"
+ },
+ {
+ "key": "workspace::a::Knowledge",
+ "surface": "workspace",
+ "name": "Knowledge",
+ "role": "a",
+ "proven": false,
+ "proof": "none",
+ "detail": "no effect (mutations=2)"
+ },
+ {
+ "key": "workspace::a::New Knowledge",
+ "surface": "workspace",
+ "name": "New Knowledge",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/workspace/knowledge -> https://chat-hive.scubed.co/workspace/knowledge/create"
+ },
+ {
+ "key": "workspace::button::Select view",
+ "surface": "workspace",
+ "name": "Select view",
+ "role": "button",
+ "proven": true,
+ "proof": "dom",
+ "detail": "9 mutations"
+ },
+ {
+ "key": "workspace::a::Open the Hive agent workspace",
+ "surface": "workspace",
+ "name": "Open the Hive agent workspace",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/workspace/knowledge -> https://chat-hive.scubed.co/agent-workspace/auth/sign-in"
+ },
+ {
+ "key": "workspace::input::Search Knowledge",
+ "surface": "workspace",
+ "name": "Search Knowledge",
+ "role": "input",
+ "proven": true,
+ "proof": "value",
+ "detail": " -> hive-coverage-91628"
+ },
+ {
+ "key": "workspace::select::All Sources Local Connected",
+ "surface": "workspace",
+ "name": "All Sources Local Connected",
+ "role": "select",
+ "proven": true,
+ "proof": "value",
+ "detail": " -> local"
+ },
+ {
+ "key": "settings:General::button::Close settings modal",
+ "surface": "settings:General",
+ "name": "Close settings modal",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 0"
+ },
+ {
+ "key": "settings:General::tab::General",
+ "surface": "settings:General",
+ "name": "General",
+ "role": "tab",
+ "proven": false,
+ "proof": "none",
+ "detail": "no effect (mutations=0)"
+ },
+ {
+ "key": "settings:General::tab::Interface",
+ "surface": "settings:General",
+ "name": "Interface",
+ "role": "tab",
+ "proven": true,
+ "proof": "dom",
+ "detail": "what is on screen changed"
+ },
+ {
+ "key": "settings:General::tab::Personalization",
+ "surface": "settings:General",
+ "name": "Personalization",
+ "role": "tab",
+ "proven": true,
+ "proof": "dom",
+ "detail": "what is on screen changed"
+ },
+ {
+ "key": "settings:General::tab::Audio",
+ "surface": "settings:General",
+ "name": "Audio",
+ "role": "tab",
+ "proven": true,
+ "proof": "dom",
+ "detail": "what is on screen changed"
+ },
+ {
+ "key": "settings:General::tab::Data Controls",
+ "surface": "settings:General",
+ "name": "Data Controls",
+ "role": "tab",
+ "proven": true,
+ "proof": "dom",
+ "detail": "what is on screen changed"
+ },
+ {
+ "key": "settings:General::tab::Account",
+ "surface": "settings:General",
+ "name": "Account",
+ "role": "tab",
+ "proven": true,
+ "proof": "network",
+ "detail": "https://chat-hive.scubed.co/api/v1/auths/"
+ },
+ {
+ "key": "settings:General::tab::About",
+ "surface": "settings:General",
+ "name": "About",
+ "role": "tab",
+ "proven": true,
+ "proof": "network",
+ "detail": "https://chat-hive.scubed.co/ollama/api/version"
+ },
+ {
+ "key": "settings:General::a::Admin Settings",
+ "surface": "settings:General",
+ "name": "Admin Settings",
+ "role": "a",
+ "proven": false,
+ "proof": "none",
+ "detail": "called an endpoint that is not mounted or that failed: 404 GET https://chat-hive.scubed.co/api/v1/auths/admin/config"
+ },
+ {
+ "key": "settings:General::button::Show",
+ "surface": "settings:General",
+ "name": "Show",
+ "role": "button",
+ "proven": false,
+ "proof": "none",
+ "detail": "no effect (mutations=3)"
+ },
+ {
+ "key": "settings:General::button::Save",
+ "surface": "settings:General",
+ "name": "Save",
+ "role": "button",
+ "proven": true,
+ "proof": "network",
+ "detail": "https://chat-hive.scubed.co/api/models?, https://chat-hive.scubed.co/api/v1/users/user/settings/update"
+ },
+ {
+ "key": "settings:General::select::Select a theme",
+ "surface": "settings:General",
+ "name": "Select a theme",
+ "role": "select",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "system -> dark survived a reload"
+ },
+ {
+ "key": "settings:General::select::Select a language",
+ "surface": "settings:General",
+ "name": "Select a language",
+ "role": "select",
+ "proven": true,
+ "proof": "persisted",
+ "detail": " -> en-US survived a reload"
+ },
+ {
+ "key": "settings:General::textarea::Enter system prompt here",
+ "surface": "settings:General",
+ "name": "Enter system prompt here",
+ "role": "textarea",
+ "proven": true,
+ "proof": "persisted",
+ "detail": " -> hive-coverage-41507 survived a reload"
+ },
+ {
+ "key": "settings:General::switch::Off",
+ "surface": "settings:General",
+ "name": "Off",
+ "role": "switch",
+ "proven": false,
+ "proof": "none",
+ "detail": "changed in the UI but reverted to false after reload"
+ },
+ {
+ "key": "settings:General::input::Search",
+ "surface": "settings:General",
+ "name": "Search",
+ "role": "input",
+ "proven": true,
+ "proof": "value",
+ "detail": " -> hive-coverage-82555"
+ },
+ {
+ "key": "settings:Interface::button::Default#33",
+ "surface": "settings:Interface",
+ "name": "Default",
+ "role": "button",
+ "proven": true,
+ "proof": "dom",
+ "detail": "what is on screen changed"
+ },
+ {
+ "key": "settings:Interface::button::RTL",
+ "surface": "settings:Interface",
+ "name": "RTL",
+ "role": "button",
+ "proven": true,
+ "proof": "network",
+ "detail": "https://chat-hive.scubed.co/api/models?"
+ },
+ {
+ "key": "settings:Interface::button::Chat",
+ "surface": "settings:Interface",
+ "name": "Chat",
+ "role": "button",
+ "proven": true,
+ "proof": "network",
+ "detail": "https://chat-hive.scubed.co/api/models?, https://chat-hive.scubed.co/api/v1/users/user/settings/update"
+ },
+ {
+ "key": "settings:Interface::button::Upload",
+ "surface": "settings:Interface",
+ "name": "Upload",
+ "role": "button",
+ "proven": true,
+ "proof": "filechooser",
+ "detail": "file chooser opened"
+ },
+ {
+ "key": "settings:Interface::button::Default#34",
+ "surface": "settings:Interface",
+ "name": "Default",
+ "role": "button",
+ "proven": true,
+ "proof": "network",
+ "detail": "https://chat-hive.scubed.co/api/models?, https://chat-hive.scubed.co/api/v1/users/user/settings/update"
+ },
+ {
+ "key": "settings:Interface::button::Enter to Send",
+ "surface": "settings:Interface",
+ "name": "Enter to Send",
+ "role": "button",
+ "proven": true,
+ "proof": "network",
+ "detail": "https://chat-hive.scubed.co/api/models?, https://chat-hive.scubed.co/api/v1/users/user/settings/update"
+ },
+ {
+ "key": "settings:Interface::switch::High Contrast Mode",
+ "surface": "settings:Interface",
+ "name": "High Contrast Mode",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "false -> true survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Display Chat Title in Tab",
+ "surface": "settings:Interface",
+ "name": "Display Chat Title in Tab",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "true -> false survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Notification Sound",
+ "surface": "settings:Interface",
+ "name": "Notification Sound",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "true -> false survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Haptic Feedback (Android)",
+ "surface": "settings:Interface",
+ "name": "Haptic Feedback (Android)",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "false -> true survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Copy Formatted Text",
+ "surface": "settings:Interface",
+ "name": "Copy Formatted Text",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "false -> true survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Toast Notifications for New Updates",
+ "surface": "settings:Interface",
+ "name": "Toast Notifications for New Updates",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "true -> false survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Show \"What's New\" Modal on Login",
+ "surface": "settings:Interface",
+ "name": "Show \"What's New\" Modal on Login",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "true -> false survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Enable Message Queue",
+ "surface": "settings:Interface",
+ "name": "Enable Message Queue",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "true -> false survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Chat Bubble UI",
+ "surface": "settings:Interface",
+ "name": "Chat Bubble UI",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "true -> false survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Widescreen Mode",
+ "surface": "settings:Interface",
+ "name": "Widescreen Mode",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "false -> true survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Temporary Chat by Default",
+ "surface": "settings:Interface",
+ "name": "Temporary Chat by Default",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "false -> true survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Fade Effect for Streaming Text",
+ "surface": "settings:Interface",
+ "name": "Fade Effect for Streaming Text",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "true -> false survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Render Markdown in User Messages",
+ "surface": "settings:Interface",
+ "name": "Render Markdown in User Messages",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "true -> false survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Render Markdown in Assistant Messages",
+ "surface": "settings:Interface",
+ "name": "Render Markdown in Assistant Messages",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "true -> false survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Title Auto-Generation",
+ "surface": "settings:Interface",
+ "name": "Title Auto-Generation",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "true -> false survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Follow-Up Auto-Generation",
+ "surface": "settings:Interface",
+ "name": "Follow-Up Auto-Generation",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "true -> false survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Chat Tags Auto-Generation",
+ "surface": "settings:Interface",
+ "name": "Chat Tags Auto-Generation",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "true -> false survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Insert Suggestion Prompt to Input",
+ "surface": "settings:Interface",
+ "name": "Insert Suggestion Prompt to Input",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "false -> true survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Keep Follow-Up Prompts in Chat",
+ "surface": "settings:Interface",
+ "name": "Keep Follow-Up Prompts in Chat",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "false -> true survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Insert Follow-Up Prompt to Input",
+ "surface": "settings:Interface",
+ "name": "Insert Follow-Up Prompt to Input",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "false -> true survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Regenerate Menu",
+ "surface": "settings:Interface",
+ "name": "Regenerate Menu",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "true -> false survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Always Collapse Code Blocks",
+ "surface": "settings:Interface",
+ "name": "Always Collapse Code Blocks",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "false -> true survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Always Expand Details",
+ "surface": "settings:Interface",
+ "name": "Always Expand Details",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "false -> true survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Render Markdown in Previews",
+ "surface": "settings:Interface",
+ "name": "Render Markdown in Previews",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "true -> false survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Display Multi-model Responses in Tabs",
+ "surface": "settings:Interface",
+ "name": "Display Multi-model Responses in Tabs",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "false -> true survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Scroll On Branch Change",
+ "surface": "settings:Interface",
+ "name": "Scroll On Branch Change",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "true -> false survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Show Files on Terminal Select",
+ "surface": "settings:Interface",
+ "name": "Show Files on Terminal Select",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "true -> false survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Stylized PDF Export",
+ "surface": "settings:Interface",
+ "name": "Stylized PDF Export",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "true -> false survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Rich Text Input for Chat",
+ "surface": "settings:Interface",
+ "name": "Rich Text Input for Chat",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "true -> false survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Paste Large Text as File",
+ "surface": "settings:Interface",
+ "name": "Paste Large Text as File",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "false -> true survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Detect Artifacts Automatically",
+ "surface": "settings:Interface",
+ "name": "Detect Artifacts Automatically",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "true -> false survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::iframe Sandbox Allow Same Origin",
+ "surface": "settings:Interface",
+ "name": "iframe Sandbox Allow Same Origin",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "false -> true survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::iframe Sandbox Allow Forms",
+ "surface": "settings:Interface",
+ "name": "iframe Sandbox Allow Forms",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "false -> true survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Allow Voice Interruption in Call",
+ "surface": "settings:Interface",
+ "name": "Allow Voice Interruption in Call",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "false -> true survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Display Emoji in Call",
+ "surface": "settings:Interface",
+ "name": "Display Emoji in Call",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "false -> true survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Always Play Notification Sound",
+ "surface": "settings:Interface",
+ "name": "Always Play Notification Sound",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "false -> true survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Floating Quick Actions",
+ "surface": "settings:Interface",
+ "name": "Floating Quick Actions",
+ "role": "switch",
+ "proven": false,
+ "proof": "none",
+ "detail": "not present on a clean re-open"
+ },
+ {
+ "key": "settings:Interface::switch::Show Formatting Toolbar",
+ "surface": "settings:Interface",
+ "name": "Show Formatting Toolbar",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "false -> true survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Insert Prompt as Rich Text",
+ "surface": "settings:Interface",
+ "name": "Insert Prompt as Rich Text",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "false -> true survived a reload"
+ },
+ {
+ "key": "settings:Interface::switch::Image Compression",
+ "surface": "settings:Interface",
+ "name": "Image Compression",
+ "role": "switch",
+ "proven": false,
+ "proof": "none",
+ "detail": "not present on a clean re-open"
+ },
+ {
+ "key": "settings:Interface::switch::Allow User Location",
+ "surface": "settings:Interface",
+ "name": "Allow User Location",
+ "role": "switch",
+ "proven": false,
+ "proof": "none",
+ "detail": "changed in the UI but reverted to false after reload"
+ },
+ {
+ "key": "settings:Interface::switch::Auto-Copy Response to Clipboard",
+ "surface": "settings:Interface",
+ "name": "Auto-Copy Response to Clipboard",
+ "role": "switch",
+ "proven": false,
+ "proof": "none",
+ "detail": "changed in the UI but reverted to false after reload"
+ },
+ {
+ "key": "settings:Personalization::button::Manage",
+ "surface": "settings:Personalization",
+ "name": "Manage",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "settings:Personalization::switch::Memory EXPERIMENTAL",
+ "surface": "settings:Personalization",
+ "name": "Memory EXPERIMENTAL",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "true -> false survived a reload"
+ },
+ {
+ "key": "settings:Audio::select::Speech-to-Text Engine",
+ "surface": "settings:Audio",
+ "name": "Speech-to-Text Engine",
+ "role": "select",
+ "proven": true,
+ "proof": "persisted",
+ "detail": " -> web survived a reload"
+ },
+ {
+ "key": "settings:Audio::input::Speech-to-Text Language",
+ "surface": "settings:Audio",
+ "name": "Speech-to-Text Language",
+ "role": "input",
+ "proven": true,
+ "proof": "persisted",
+ "detail": " -> hive-coverage-61646 survived a reload"
+ },
+ {
+ "key": "settings:Audio::select::Text-to-Speech Engine",
+ "surface": "settings:Audio",
+ "name": "Text-to-Speech Engine",
+ "role": "select",
+ "proven": true,
+ "proof": "persisted",
+ "detail": " -> browser-kokoro survived a reload"
+ },
+ {
+ "key": "settings:Audio::input::Speech Playback Speed",
+ "surface": "settings:Audio",
+ "name": "Speech Playback Speed",
+ "role": "input",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "1 -> 2 survived a reload"
+ },
+ {
+ "key": "settings:Audio::switch::Off#2",
+ "surface": "settings:Audio",
+ "name": "Off",
+ "role": "switch",
+ "proven": false,
+ "proof": "none",
+ "detail": "not present on a clean re-open"
+ },
+ {
+ "key": "settings:Audio::select::Voice",
+ "surface": "settings:Audio",
+ "name": "Voice",
+ "role": "select",
+ "proven": false,
+ "proof": "none",
+ "detail": "changed in the UI but reverted to after reload"
+ },
+ {
+ "key": "settings:Audio::switch::Allow non-local voices",
+ "surface": "settings:Audio",
+ "name": "Allow non-local voices",
+ "role": "switch",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "false -> true survived a reload"
+ },
+ {
+ "key": "settings:Data Controls::button::Import",
+ "surface": "settings:Data Controls",
+ "name": "Import",
+ "role": "button",
+ "proven": true,
+ "proof": "filechooser",
+ "detail": "file chooser opened"
+ },
+ {
+ "key": "settings:Data Controls::button::Export",
+ "surface": "settings:Data Controls",
+ "name": "Export",
+ "role": "button",
+ "proven": true,
+ "proof": "download",
+ "detail": "download started"
+ },
+ {
+ "key": "settings:Data Controls::button::Manage",
+ "surface": "settings:Data Controls",
+ "name": "Manage",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "settings:Data Controls::button::Manage#2",
+ "surface": "settings:Data Controls",
+ "name": "Manage",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "settings:Data Controls::button::Archive All",
+ "surface": "settings:Data Controls",
+ "name": "Archive All",
+ "role": "button",
+ "proven": false,
+ "proof": "none",
+ "detail": "no effect (mutations=3)"
+ },
+ {
+ "key": "settings:Data Controls::button::Delete All",
+ "surface": "settings:Data Controls",
+ "name": "Delete All",
+ "role": "button",
+ "proven": false,
+ "proof": "none",
+ "detail": "no effect (mutations=3)"
+ },
+ {
+ "key": "settings:Data Controls::button::Manage#3",
+ "surface": "settings:Data Controls",
+ "name": "Manage",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 1 -> 2"
+ },
+ {
+ "key": "settings:Account::button::Remove Initials Gravatar",
+ "surface": "settings:Account",
+ "name": "Remove Initials Gravatar",
+ "role": "button",
+ "proven": true,
+ "proof": "filechooser",
+ "detail": "file chooser opened"
+ },
+ {
+ "key": "settings:Account::input::Name",
+ "surface": "settings:Account",
+ "name": "Name",
+ "role": "input",
+ "proven": true,
+ "proof": "persisted",
+ "detail": "demo@hive-demo.invalid -> hive-coverage-77811 survived a reload"
+ },
+ {
+ "key": "settings:Account::textarea::Bio",
+ "surface": "settings:Account",
+ "name": "Bio",
+ "role": "textarea",
+ "proven": true,
+ "proof": "persisted",
+ "detail": " -> hive-coverage-78092 survived a reload"
+ },
+ {
+ "key": "settings:Account::select::Gender",
+ "surface": "settings:Account",
+ "name": "Gender",
+ "role": "select",
+ "proven": true,
+ "proof": "persisted",
+ "detail": " -> male survived a reload"
+ },
+ {
+ "key": "settings:Account::input::Birth Date",
+ "surface": "settings:Account",
+ "name": "Birth Date",
+ "role": "input",
+ "proven": true,
+ "proof": "persisted",
+ "detail": " -> 1990-01-01 survived a reload"
+ },
+ {
+ "key": "settings:About::button::See what's new",
+ "surface": "settings:About",
+ "name": "See what's new",
+ "role": "button",
+ "proven": false,
+ "proof": "none",
+ "detail": "no effect (mutations=0)"
+ },
+ {
+ "key": "settings:About::a::Twemoji",
+ "surface": "settings:About",
+ "name": "Twemoji",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "opened a new tab"
+ },
+ {
+ "key": "settings:About::a::CC-BY 4.0",
+ "surface": "settings:About",
+ "name": "CC-BY 4.0",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "opened a new tab"
+ },
+ {
+ "key": "settings:About::a::Hive",
+ "surface": "settings:About",
+ "name": "Hive",
+ "role": "a",
+ "proven": true,
+ "proof": "dom",
+ "detail": "28 mutations"
+ },
+ {
+ "key": "settings:About::a::All rights reserved.",
+ "surface": "settings:About",
+ "name": "All rights reserved.",
+ "role": "a",
+ "proven": true,
+ "proof": "dom",
+ "detail": "26 mutations"
+ },
+ {
+ "key": "workspace:knowledge/create::button::Back",
+ "surface": "workspace:knowledge/create",
+ "name": "Back",
+ "role": "button",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/workspace/knowledge/create -> https://chat-hive.scubed.co/workspace/knowledge"
+ },
+ {
+ "key": "workspace:knowledge/create::button::Add Access",
+ "surface": "workspace:knowledge/create",
+ "name": "Add Access",
+ "role": "button",
+ "proven": true,
+ "proof": "overlay",
+ "detail": "overlays 0 -> 1"
+ },
+ {
+ "key": "workspace:knowledge/create::button::Create Knowledge",
+ "surface": "workspace:knowledge/create",
+ "name": "Create Knowledge",
+ "role": "button",
+ "proven": false,
+ "proof": "none",
+ "detail": "no effect (mutations=0)"
+ },
+ {
+ "key": "workspace:knowledge/create::input::Name your knowledge base",
+ "surface": "workspace:knowledge/create",
+ "name": "Name your knowledge base",
+ "role": "input",
+ "proven": true,
+ "proof": "value",
+ "detail": " -> hive-coverage-91247"
+ },
+ {
+ "key": "workspace:knowledge/create::textarea::Describe your knowledge base and objectives",
+ "surface": "workspace:knowledge/create",
+ "name": "Describe your knowledge base and objectives",
+ "role": "textarea",
+ "proven": true,
+ "proof": "value",
+ "detail": " -> hive-coverage-91775"
+ },
+ {
+ "key": "workspace:knowledge/create::select::Private Public",
+ "surface": "workspace:knowledge/create",
+ "name": "Private Public",
+ "role": "select",
+ "proven": true,
+ "proof": "value",
+ "detail": "private -> public"
+ },
+ {
+ "key": "user-menu::button::Update your status",
+ "surface": "user-menu",
+ "name": "Update your status",
+ "role": "button",
+ "proven": true,
+ "proof": "dom",
+ "detail": "what is on screen changed"
+ },
+ {
+ "key": "user-menu::button::Settings",
+ "surface": "user-menu",
+ "name": "Settings",
+ "role": "button",
+ "proven": true,
+ "proof": "dom",
+ "detail": "what is on screen changed"
+ },
+ {
+ "key": "user-menu::a::Admin Panel",
+ "surface": "user-menu",
+ "name": "Admin Panel",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/admin/users/overview"
+ },
+ {
+ "key": "user-menu::button::Archived Chats",
+ "surface": "user-menu",
+ "name": "Archived Chats",
+ "role": "button",
+ "proven": true,
+ "proof": "network",
+ "detail": "https://chat-hive.scubed.co/api/v1/chats/archived?page=1"
+ },
+ {
+ "key": "user-menu::a::Workspace#2",
+ "surface": "user-menu",
+ "name": "Workspace",
+ "role": "a",
+ "proven": true,
+ "proof": "navigate",
+ "detail": "https://chat-hive.scubed.co/ -> https://chat-hive.scubed.co/workspace/knowledge"
+ }
+ ]
+}
\ No newline at end of file