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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
__pycache__/
*.pyc

# Swift native-review build output
tools/native-review/swift/.build/

# lefthook-generated hook scripts (machine-specific)
.hooks/

Expand Down
17 changes: 17 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -973,3 +973,20 @@ benchmark *ARGS:
# Stop the benchmark Docker stack (state and channels are kept)
benchmark-down:
docker compose --project-name buzz-benchmark down

# Validate macOS native-review tooling and report required OS permissions.
native-review-doctor:
./tools/native-review/bin/review-native doctor

# Run one declarative journey against the isolated local desktop fixture.
native-review-desktop JOURNEY="tools/native-review/desktop/tooltip-fresh-dwell.yaml":
./tools/native-review/bin/review-native run "{{JOURNEY}}"

# Capture a repeatable native performance cohort (minimum 3 runs).
native-review-benchmark JOURNEY="tools/native-review/desktop/tooltip-fresh-dwell.yaml" RUNS="5":
./tools/native-review/bin/review-native benchmark "{{JOURNEY}}" --runs "{{RUNS}}"

# Compare baseline and candidate receipt cohorts with explicit budget policy.
# Pass BASELINE/CANDIDATE as repeated CLI args, e.g. "--baseline a --baseline b".
native-review-compare BASELINE CANDIDATE BUDGET="tools/native-review/performance/tooltip-fresh-dwell.yaml":
./tools/native-review/bin/review-native compare {{BASELINE}} {{CANDIDATE}} --budget "{{BUDGET}}"
50 changes: 50 additions & 0 deletions desktop/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { Toaster } from "@/shared/ui/sonner";
import { TooltipProvider } from "@/shared/ui/tooltip";
import { recoverLocalStorageQuotaOnStartup } from "@/shared/lib/localStorageQuota";
import { startLocalStorageSweep } from "@/shared/lib/localStorageSweep";
import { installNativeReviewSemanticProbe } from "@/testing/nativeReviewSemanticProbe";

type E2eWindow = Window & {
__BUZZ_E2E__?: unknown;
Expand All @@ -28,6 +29,54 @@ const E2E_DEFAULT_PUBKEY = "deadbeef".repeat(8);
const E2E_COMMUNITY_ID = "e2e-default-community";
const ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX = "buzz-onboarding-complete.v1:";
const DEV_STATE_RESET_PARAM = "resetDevState";
const NATIVE_REVIEW_PARAM = "nativeReview";

function configureNativeReviewFixtureFromUrl() {
const buildEnabled = import.meta.env.VITE_NATIVE_REVIEW === "1";
if (!import.meta.env.DEV && !buildEnabled) return;
const url = new URL(window.location.href);
const enabled =
url.searchParams.get(NATIVE_REVIEW_PARAM) === "1" || buildEnabled;
if (!enabled) return;

const relayUrl =
url.searchParams.get("reviewRelay") ??
import.meta.env.VITE_NATIVE_REVIEW_RELAY;
const pubkey =
url.searchParams.get("reviewPubkey") ??
import.meta.env.VITE_NATIVE_REVIEW_PUBKEY;
if (
!relayUrl ||
!pubkey ||
!/^(ws|http):\/\/(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?\/?$/.test(
relayUrl,
)
) {
throw new Error(
"native review bootstrap requires a loopback relay and pubkey",
);
}
const communityId = "native-review-local";
const community = {
addedAt: new Date().toISOString(),
id: communityId,
name: "Native Review",
pubkey,
relayUrl,
};
window.localStorage.setItem("buzz-communities", JSON.stringify([community]));
window.localStorage.setItem("buzz-active-community-id", communityId);
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
window.localStorage.setItem(`buzz-onboarding-complete.v1:${pubkey}`, "true");
window.localStorage.setItem(
`buzz-community-onboarding-complete.v1:${encodeURIComponent(relayUrl)}:${pubkey}`,
"true",
);
installNativeReviewSemanticProbe();
}

function resetDevWebviewStateFromUrl() {
if (!import.meta.env.DEV) {
Expand Down Expand Up @@ -121,6 +170,7 @@ async function installE2eBridgeIfConfigured() {

async function bootstrap() {
resetDevWebviewStateFromUrl();
configureNativeReviewFixtureFromUrl();
configureDevE2eBridgeFromUrl();
recoverLocalStorageQuotaOnStartup();
startLocalStorageSweep();
Expand Down
121 changes: 121 additions & 0 deletions desktop/src/testing/nativeReviewSemanticProbe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
type SemanticNode = {
id?: string;
role?: string;
name?: string;
value?: string;
scrollY: number;
enabled: boolean;
focused: boolean;
frame: { x: number; y: number; width: number; height: number };
viewport: { width: number; height: number };
};

const IMPLICIT_ROLES: Partial<Record<string, string>> = {
A: "link",
BUTTON: "button",
INPUT: "text-field",
TEXTAREA: "text-area",
};

function accessibleName(element: HTMLElement): string | undefined {
const labelledBy = element.getAttribute("aria-labelledby");
const labelledText = labelledBy
?.split(/\s+/)
.map((id) => document.getElementById(id)?.textContent?.trim())
.filter(Boolean)
.join(" ");
return (
element.getAttribute("aria-label")?.trim() ||
labelledText ||
element.getAttribute("title")?.trim() ||
(element.getAttribute("role") === "tooltip"
? element.textContent?.trim()
: undefined) ||
undefined
);
}

function snapshot(): SemanticNode[] {
const nodes: SemanticNode[] = [];
for (const candidate of document.querySelectorAll<HTMLElement>(
"[data-testid], [role], button, textarea, input, a[href]",
)) {
const rect = candidate.getBoundingClientRect();
const style = window.getComputedStyle(candidate);
if (
rect.width <= 0 ||
rect.height <= 0 ||
style.display === "none" ||
style.visibility === "hidden"
) {
continue;
}
const id = candidate.dataset.testid;
const role =
candidate.getAttribute("role") ?? IMPLICIT_ROLES[candidate.tagName];
const name = accessibleName(candidate);
const value =
candidate instanceof HTMLInputElement ||
candidate instanceof HTMLTextAreaElement
? candidate.value
: candidate.isContentEditable
? candidate.innerText.replace(/\r\n?/g, "\n").replace(/\n$/, "")
: undefined;
if (!id && !role && !name) continue;
nodes.push({
...(id ? { id } : {}),
...(role ? { role } : {}),
...(name ? { name } : {}),
...(value !== undefined ? { value } : {}),
scrollY: candidate.scrollTop,
enabled:
!candidate.hasAttribute("disabled") &&
candidate.getAttribute("aria-disabled") !== "true",
focused: candidate === document.activeElement,
frame: {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
},
viewport: {
width: window.innerWidth,
height: window.innerHeight,
},
});
}
return nodes;
}

export function installNativeReviewSemanticProbe(): void {
let scheduled = false;
const publish = () => {
scheduled = false;
const payload = JSON.stringify(snapshot());
if (
!navigator.sendBeacon(
import.meta.env.VITE_NATIVE_REVIEW_PROBE_URL,
payload,
)
) {
console.error("native review semantic probe beacon was rejected");
}
};
const schedule = () => {
if (scheduled) return;
scheduled = true;
window.requestAnimationFrame(publish);
};
new MutationObserver(schedule).observe(document.documentElement, {
attributes: true,
childList: true,
subtree: true,
});
window.addEventListener("input", schedule, true);
window.addEventListener("change", schedule, true);
window.addEventListener("focusin", schedule);
window.addEventListener("focusout", schedule);
window.addEventListener("resize", schedule);
window.addEventListener("scroll", schedule, true);
schedule();
}
16 changes: 16 additions & 0 deletions scripts/setup-desktop-test-data.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ BOB_PUBKEY="bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260"
CHARLIE_PUBKEY="554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea"
TYLER_PUBKEY="e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34"
AGENT_PUBKEY="db0b028cd36f4d3e36c8300cce87252c1f7fc9495ffecc53f393fcac341ffd36"
REVIEW_PUBKEY="${BUZZ_REVIEW_PUBKEY:-}"
if [[ -n "$REVIEW_PUBKEY" && ! "$REVIEW_PUBKEY" =~ ^[0-9a-fA-F]{64}$ ]]; then
echo "BUZZ_REVIEW_PUBKEY must be exactly 64 hexadecimal characters." >&2
exit 1
fi

if command -v psql >/dev/null 2>&1; then
run_psql() { PGPASSWORD="$DB_PASS" psql -h"$DB_HOST" -p"$DB_PORT" -U"$DB_USER" -d"$DB_NAME" -qtA "$@"; }
Expand Down Expand Up @@ -129,4 +134,15 @@ ON CONFLICT DO NOTHING
;
"

if [[ -n "$REVIEW_PUBKEY" ]]; then
run_sql "
INSERT INTO channel_members
(community_id, channel_id, pubkey, role, invited_by)
VALUES
('${COMMUNITY_ID}', '${UUID_GENERAL}', decode('${REVIEW_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex'))
ON CONFLICT DO NOTHING
;
"
fi

echo "Desktop e2e data ready."
76 changes: 76 additions & 0 deletions tools/native-review/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Buzz native review harness

This macOS-only MVP drives the real Tauri/WKWebView app with Accessibility and
CGEvent, captures its window with Core Graphics and AVFoundation, and writes an
exact-SHA run receipt. It is a targeted review lane, not a replacement for
`just ci` or Playwright.

## Safety contract

- Only loopback `ws://`/`http://` relays are accepted.
- Every run gets an ephemeral Nostr key, run-specific dev bundle ID, keyring
service, HOME, WebKit/app-data state, and artifact directory.
- The launcher environment is allowlisted before the ephemeral key is added;
inherited tokens and production keys never enter the reviewed process.
- Production bundle IDs, keyring services, and remote relays fail closed.
- This protects reviewer state from accidents. It is **not** containment for
hostile code; use a dedicated macOS user or disposable VM for untrusted PRs.

## Commands

```bash
just native-review-doctor
just native-review-desktop tools/native-review/desktop/tooltip-fresh-dwell.yaml
python3 -m unittest discover -s tools/native-review/tests -p 'test_*.py'
```

The desktop command expects the isolated `buzz-harness` relay on port 3030
(`scripts/start-isolated-test-relay.sh`). Doctor reports Accessibility and
Screen Recording separately and the run refuses to proceed unless both are
already granted to the invoking terminal/agent.

Runs are written under
`test-results/native-review/<sha>/<flow>/<run-id>/`. A failed locator,
postcondition, recording, evidence capture, or cleanup produces a failed partial
receipt. `tests/fixtures/broken-tooltip.yaml` is the deliberate fail-loud
mutation.

## Performance comparison and budgets

A step with `measure: <name>` persists its complete native action-to-observed-
postcondition duration in the receipt. While the journey runs, the harness also
samples the app process every 100 ms and records median/peak CPU percentage and
resident memory. Capture a cohort rather than trusting one noisy laptop run:

```bash
just native-review-benchmark tools/native-review/desktop/tooltip-fresh-dwell.yaml 5
```

Compare at least three clean baseline receipts with at least three clean
candidate receipts using `compare` and a checked-in policy such as
`performance/tooltip-fresh-dwell.yaml`:

```bash
./tools/native-review/bin/review-native compare \
--baseline /path/base-1/receipt.json --baseline /path/base-2/receipt.json --baseline /path/base-3/receipt.json \
--candidate /path/head-1/receipt.json --candidate /path/head-2/receipt.json --candidate /path/head-3/receipt.json \
--budget tools/native-review/performance/tooltip-fresh-dwell.yaml \
--output test-results/native-review/performance-comparison.json
```

Comparison uses cohort medians, reports every raw sample and min/max, and exits
nonzero when an absolute ceiling or relative regression limit is breached. It
fails closed for dirty-tree runs, failed cleanup, mixed source revisions within a
cohort, wrong flows, missing metrics, too few samples, or different machine/OS
fingerprints. Baseline and candidate therefore need to run on the same host;
thermal/load noise is reduced by repeated samples, not disguised as universal
lab-grade benchmarking. Recording overhead is intentionally present in both
cohorts because this tool measures the reviewer-visible workflow.

## Current limits

macOS desktop and the local review-channel fixture are implemented. The schema
covers role/name/identifier locators; native click, hover, text entry, keyboard
shortcuts, scrolling, and waits; value/focus/existence assertions; window
screenshots/video; and per-step semantic + AX snapshots. iOS Simulator remains
a later phase.
8 changes: 8 additions & 0 deletions tools/native-review/bin/review-native
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
# The repository pins Rust/Node tooling through Hermit; native review must build
# with the same toolchain as the artifact it records.
source "$REPO_ROOT/bin/activate-hermit"
exec python3 "$SCRIPT_DIR/review_native.py" "$@"
49 changes: 49 additions & 0 deletions tools/native-review/desktop/composer-keyboard.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
schema_version: 1
flow: composer_keyboard
platforms: [macos]
fixture: local_review_channel
record:
video: window
screenshots: true
accessibility: true
steps:
- name: activate_buzz
act: {type: activate}
expect: {exists: {role: window}}
timeout_ms: 15000
- name: await_interactive_app
act: {type: wait, duration_ms: 100}
expect: {not_exists: {id: app-loading-gate}}
timeout_ms: 60000
- name: reach_seeded_channel
locate:
- {id: channel-welcome-everyone}
- {role: button, name: welcome-everyone}
act: {type: click}
expect: {exists: {id: message-composer}}
timeout_ms: 15000
- name: focus_composer
locate:
- {id: message-input}
- {role: text-area, name: Message}
act: {type: click}
expect: {focused: {id: message-input}}
- name: type_draft
act: {type: type_text, text: "native review line 1\nnative review line 2\nnative review line 3\nnative review line 4\nnative review line 5\nnative review line 6\nnative review line 7\nnative review line 8\nnative review line 9\nnative review line 10\nnative review line 11\nnative review line 12\nnative review line 13\nnative review line 14\nnative review line 15"}
expect: {value: "native review line 1\nnative review line 2\nnative review line 3\nnative review line 4\nnative review line 5\nnative review line 6\nnative review line 7\nnative review line 8\nnative review line 9\nnative review line 10\nnative review line 11\nnative review line 12\nnative review line 13\nnative review line 14\nnative review line 15"}
- name: keep_draft_while_scrolling
locate:
- {id: message-input-scroll}
act: {type: scroll, delta_y: 240}
expect: {scroll_y_less_than: 1}
- name: draft_survives_scroll
locate:
- {id: message-input}
act: {type: wait, duration_ms: 50}
expect: {value: "native review line 1\nnative review line 2\nnative review line 3\nnative review line 4\nnative review line 5\nnative review line 6\nnative review line 7\nnative review line 8\nnative review line 9\nnative review line 10\nnative review line 11\nnative review line 12\nnative review line 13\nnative review line 14\nnative review line 15"}
- name: dismiss_focus
act: {type: press, key: escape}
expect: {value: "native review line 1\nnative review line 2\nnative review line 3\nnative review line 4\nnative review line 5\nnative review line 6\nnative review line 7\nnative review line 8\nnative review line 9\nnative review line 10\nnative review line 11\nnative review line 12\nnative review line 13\nnative review line 14\nnative review line 15"}
cleanup:
terminate_app: true
remove_state: true
Loading