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
30 changes: 30 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -999,3 +999,33 @@ 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}}"

# Run the native iOS Simulator pairing journey with MP4 and screenshot evidence.
native-review-ios DEVICE="iPhone 17 Pro":
./tools/native-review/bin/review-ios --device "{{DEVICE}}"

# Emit a redacted, relay-compatible clip + receipt + focused log excerpt.
native-review-finding RECEIPT OUTPUT MATCH="":
./tools/native-review/bin/review-native finding-bundle "{{RECEIPT}}" --output "{{OUTPUT}}" {{ if MATCH == "" { "" } else { "--match " + quote(MATCH) } }}

# Publish the review report with its exact-SHA video and optional timecoded highlights.
# MENTION is the delegator pubkey; pass an empty string only when no callback is required.
native-review-publish RECEIPT SUMMARY CHANNEL REPLY_TO HIGHLIGHTS="" MENTION="":
./tools/native-review/bin/review-native publish-review "{{RECEIPT}}" --summary "{{SUMMARY}}" --channel "{{CHANNEL}}" --reply-to "{{REPLY_TO}}" {{ if HIGHLIGHTS == "" { "" } else { "--highlights " + quote(HIGHLIGHTS) } }} {{ if MENTION == "" { "" } else { "--mention " + quote(MENTION) } }}
50 changes: 50 additions & 0 deletions desktop/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { recoverLocalStorageQuotaOnStartup } from "@/shared/lib/localStorageQuot
import { startLocalStorageSweep } from "@/shared/lib/localStorageSweep";
import { initializeConversationDensityPreference } from "@/shared/lib/conversationDensityPreference";
import { initializeFontSizePreference } from "@/shared/lib/fontSizePreference";
import { installNativeReviewSemanticProbe } from "@/testing/nativeReviewSemanticProbe";

type E2eWindow = Window & {
__BUZZ_E2E__?: unknown;
Expand All @@ -31,6 +32,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 @@ -124,6 +173,7 @@ async function installE2eBridgeIfConfigured() {

async function bootstrap() {
resetDevWebviewStateFromUrl();
configureNativeReviewFixtureFromUrl();
configureDevE2eBridgeFromUrl();
recoverLocalStorageQuotaOnStartup();
initializeConversationDensityPreference();
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();
}
39 changes: 39 additions & 0 deletions mobile/integration_test/native_review_pairing_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import 'package:buzz/features/pairing/pairing_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:integration_test/integration_test.dart';

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

testWidgets('pairing code reveal, edit, and hide survives native rendering', (
tester,
) async {
await tester.pumpWidget(
const ProviderScope(child: MaterialApp(home: PairingPage())),
);
await tester.pumpAndSettle();

expect(find.text('Welcome to Buzz'), findsOneWidget);
expect(find.byKey(const Key('pairing-code-input')), findsNothing);

await tester.tap(find.byKey(const Key('pairing-code-toggle')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('pairing-code-input')), findsOneWidget);
await tester.pump(const Duration(seconds: 1));

await tester.enterText(
find.byKey(const Key('pairing-code-input')),
'nostrpair://native-review',
);
await tester.pump();
expect(find.text('nostrpair://native-review'), findsOneWidget);
expect(find.byKey(const Key('pairing-connect')), findsOneWidget);
await tester.pump(const Duration(seconds: 1));

await tester.tap(find.byKey(const Key('pairing-code-toggle')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('pairing-code-input')), findsNothing);
});
}
12 changes: 11 additions & 1 deletion mobile/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@ PODS:
- FlutterMacOS
- image_picker_ios (0.0.1):
- Flutter
- integration_test (0.0.1):
- Flutter
- local_auth_darwin (0.0.1):
- Flutter
- FlutterMacOS

- mobile_scanner (7.0.0):
- Flutter
- FlutterMacOS
Expand Down Expand Up @@ -48,7 +51,9 @@ DEPENDENCIES:
- Flutter (from `Flutter`)
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)

- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
- open_filex (from `.symlinks/plugins/open_filex/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
Expand All @@ -75,8 +80,11 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
integration_test:
:path: ".symlinks/plugins/integration_test/ios"
local_auth_darwin:
:path: ".symlinks/plugins/local_auth_darwin/darwin"

mobile_scanner:
:path: ".symlinks/plugins/mobile_scanner/darwin"
open_filex:
Expand All @@ -103,7 +111,9 @@ SPEC CHECKSUMS:
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb

mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
Expand All @@ -115,4 +125,4 @@ SPEC CHECKSUMS:

PODFILE CHECKSUM: bd29822c3d5baf6b44b726f00ea3293a19339ef2

COCOAPODS: 1.16.2
COCOAPODS: 1.17.0
6 changes: 5 additions & 1 deletion mobile/ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import UserNotifications
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { _, _ in }
// Integration tests cannot interact with SpringBoard's notification prompt.
// Production launches still request badge authorization as before.
if ProcessInfo.processInfo.environment["BUZZ_NATIVE_REVIEW"] != "1" {
UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { _, _ in }
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}

Expand Down
2 changes: 2 additions & 0 deletions mobile/lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ class App extends HookConsumerWidget {
// cold-start link survives until the authenticated UI can dispatch it.
ref.watch(pendingDeepLinkProvider);

const nativeReview = bool.fromEnvironment('BUZZ_NATIVE_REVIEW');
void applyBadge(UnreadBadgeState state) {
if (nativeReview) return;
if (state.highPriorityCount > 0) {
AppBadgePlus.updateBadge(state.highPriorityCount);
} else if (state.generalUnreadCount > 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ class _PairingWelcomeView extends StatelessWidget {
),
const SizedBox(height: Grid.xxs),
TextButton(
key: const Key('pairing-code-toggle'),
style: _onboardingSecondaryButtonStyle,
onPressed: isBusy ? null : onTogglePairingCode,
child: Text(
Expand Down Expand Up @@ -125,6 +126,7 @@ class _PairingWelcomeView extends StatelessWidget {
children: [
const SizedBox(height: Grid.twelve),
TextField(
key: const Key('pairing-code-input'),
controller: codeController,
style: context.textTheme.bodyMedium
?.copyWith(color: _onboardingInk),
Expand Down Expand Up @@ -168,6 +170,7 @@ class _PairingWelcomeView extends StatelessWidget {
SizedBox(
width: double.infinity,
child: FilledButton(
key: const Key('pairing-connect'),
style: _onboardingButtonStyle,
onPressed: isBusy ? null : onConnect,
child: isBusy
Expand Down
Loading