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
21 changes: 21 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -999,3 +999,24 @@ 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}}"
55 changes: 52 additions & 3 deletions desktop/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ import ReactDOM from "react-dom/client";
import { App } from "@/app/App";
import { RootErrorBoundary } from "@/app/RootErrorBoundary";
import { NostrBindConsentDialog } from "@/features/profile/ui/NostrBindConsentDialog";
import "@fontsource-variable/inter/opsz.css";
import "@fontsource-variable/inter/opsz-italic.css";
import "@fontsource-variable/inter/wght.css";
import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/700.css";
import "@/shared/styles/globals.css";
Expand All @@ -20,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";
import { initializeConversationDensityPreference } from "@/shared/lib/conversationDensityPreference";
import { initializeFontSizePreference } from "@/shared/lib/fontSizePreference";

Expand All @@ -31,6 +31,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 @@ -89,7 +137,7 @@ function renderApp() {
enabled={huddleWindowChannelId() === null}
>
<ThemeProvider defaultTheme="buzz">
<TooltipProvider>
<TooltipProvider delayDuration={300}>
<EmojiBurstProvider>
<PoofBurstProvider>
<UpdaterProvider>
Expand Down Expand Up @@ -124,6 +172,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);
});
}
6 changes: 6 additions & 0 deletions mobile/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ PODS:
- FlutterMacOS
- image_picker_ios (0.0.1):
- Flutter
- integration_test (0.0.1):
- Flutter
- local_auth_darwin (0.0.1):
- Flutter
- FlutterMacOS
Expand Down Expand Up @@ -48,6 +50,7 @@ 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`)
Expand Down Expand Up @@ -75,6 +78,8 @@ 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:
Expand Down Expand Up @@ -103,6 +108,7 @@ 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
Expand Down
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
Loading