@testerpaykit/expo
v0.7.3
Published
TesterPayKit Expo / React-Native bindings — shake-to-report, device info, bug-report hook
Maintainers
Readme
@testerpaykit/expo
TesterPayKit bindings for Expo / React Native — anonymous bug-report submission, device snapshots, shake-to-report hook, and a small package-owned bug-report UI shell.
Install
npx expo install \
@testerpaykit/expo \
@react-native-async-storage/async-storage \
expo-sensors expo-device expo-application expo-file-system expo-audioNative modules are peer-optional. Without them the related feature no-ops or falls back — and since 0.6.x a dev-mode setup check logs one line per missing module at init so gaps surface at integration time, not in the moment a tester hits the feature:
| Missing module | Feature that is OFF |
|---|---|
| react-native-view-shot | automatic screenshots on bug reports |
| react-native-record-screen | screen recording attachments |
| @shopify/react-native-skia | screenshot annotation editor |
| react-native-draggable-flatlist | annotation layer reordering |
| expo-audio | voice bug reports (recording) |
| expo-file-system | voice note / recording upload |
| expo-sensors | shake-to-report |
| expo-device / expo-application | device metadata on reports |
No polyfill is required for bug-report submission: ids are generated
with a built-in fallback chain (crypto.randomUUID → crypto.
getRandomValues → expo-crypto → seeded Math.random), so
react-native-get-random-values is optional, not a hidden must.
Microphone permission (voice bug reports)
Voice bug reports record audio. iOS crashes the first time the app
touches the microphone if NSMicrophoneUsageDescription is missing from
Info.plist — and that crash happens in the native layer, so the SDK's
error handling cannot catch it. Add this package's config plugin to
inject the iOS usage description and the Android RECORD_AUDIO
permission:
// app.json
{
"expo": {
"plugins": [
["@testerpaykit/expo", {
"microphonePermission": "Allow $(PRODUCT_NAME) to record voice bug reports."
}]
]
}
}microphonePermission is optional (a sensible default is used) and can
be set to false to opt out entirely. Config-plugin changes require a
fresh native build (npx expo prebuild --clean, a new dev-client, or
an EAS build) — they do not apply via Metro fast-refresh.
Quick start
import { TpkExpo, useShake } from '@testerpaykit/expo';
import { useEffect, useState } from 'react';
import { Alert } from 'react-native';
export function App() {
const [tpk, setTpk] = useState<TpkExpo | null>(null);
useEffect(() => {
(async () => {
const client = await TpkExpo.init({
projectId: '8980ca35-8697-4d8c-ae3f-0677c7d177d1',
testerCode: 'demo-tester-01',
// Point at local Mac during dev, omit for prod:
apiBaseUrl: __DEV__ ? 'http://192.168.178.69:18080' : undefined,
});
setTpk(client);
})();
}, []);
useShake({
enabled: !!tpk,
onShake: async () => {
if (!tpk) return;
const result = await tpk.submitBug({
title: 'Shake-reported bug',
description: 'User shook the device on the home screen.',
currentScreen: 'HomeScreen',
severity: 'medium',
});
Alert.alert('Bug reported', `Server id: ${result.id}`);
},
});
return null; // your real UI here
}End the session when the tester finishes:
await tpk.endSession({
reason: 'completed',
bugsFound: 1,
tasksCompleted: 2,
});For a logical restart, rotate to a fresh server session:
await tpk.rotateSession();Package-owned UI shell
Use the provider when you want the package to own initialization:
import {
BugReportSheet,
FloatingFeedbackButton,
TesterPayKitProvider,
useTesterPayKit,
} from '@testerpaykit/expo';
import { useState } from 'react';
function FeedbackControls() {
const { client, ready } = useTesterPayKit();
const [open, setOpen] = useState(false);
return (
<>
<FloatingFeedbackButton
disabled={!ready}
onPress={() => setOpen(true)}
/>
<BugReportSheet
currentScreen="Home"
onClose={() => setOpen(false)}
tpk={client}
visible={open}
/>
</>
);
}
export function App() {
return (
<TesterPayKitProvider
config={{
projectId: '8980ca35-8697-4d8c-ae3f-0677c7d177d1',
testerCode: 'demo-tester-01',
}}
>
<FeedbackControls />
</TesterPayKitProvider>
);
}If your app already owns TpkExpo.init(), pass the initialized client
into TesterPayKitProvider instead of config.
What TpkExpo.init() does
- Reads cached anonymous-tester JWT from AsyncStorage (key
@testerpaykit/auth). If the cached entry matches the currentprojectId+testerCode, reuse it — skip the network round trip. - Otherwise POST
/auth/tester/anonymouswith the{ projectId, testerCode }pair, store the response, and use the returned access token for subsequent submits. - Starts a real server-side tester session via
POST /v1/testers/sessions.submitBug()uses that server session id so coordinator session timelines and fair-pay counters stay connected.
Pass storage to use an app-owned persistence adapter (MMKV/SQLite)
instead of AsyncStorage for auth, queues, and cached crashes:
await TpkExpo.init({
projectId,
testerCode,
storage: myStorageAdapter, // { get, set, remove, keys? }
});What submitBug() does
tpk.submitBug({ title, description, … }):
- Auto-gathers
deviceSnapshotviaexpo-device(model, manufacturer, OS version, isPhysicalDevice) plusappSnapshotviaexpo-application(app version, build number, bundle id). - POSTs to
/v1/testers/me/bug-reportswith all required fields. - Returns
{ id, status, createdAt, submittedAt }from the server.
Server-side enum validation:
type∈ {bug, crash, uiIssue, performance, featureRequest, generalFeedback}severity∈ {critical, high, medium, low}category∈ {functional, ui, performance, security, compatibility, localization, accessibility}
Defaults: type=bug, severity=medium, category=functional.
Local-first dev
Point apiBaseUrl at your Mac's LAN IP when developing against the
local TPK stack. iOS simulator can use http://localhost:18080; real
device needs http://<mac-lan-ip>:18080 (get via
ipconfig getifaddr en0 on macOS).
The Flutter SDK's TPK_LOCAL flag has the same effect in Bonblick —
they share the same tpk CLI + same Docker stack defined under
moinsen-workspace/TesterPayKit/.
Run the package-level local API smoke test when the Docker stack is up:
TPK_EXPO_RUN_LOCAL_API=1 \
TPK_EXPO_API_BASE_URL=http://localhost:18080 \
pnpm --filter @testerpaykit/expo exec vitest run src/__tests__/local-api.integration.test.tsFeature parity with the Flutter SDK
All three tiers ship today. Every peer-optional native module is auto-detected at runtime — install only the ones you actually use.
Tier 1 — wired by TpkExpo.init()
| Feature | What it does |
|---|---|
| Anonymous-tester JWT | First call to /auth/tester/anonymous, cached in AsyncStorage |
| Server sessions | init() starts /v1/testers/sessions; endSession() closes it |
| submitBug() | POSTs /v1/testers/me/bug-reports with device + app + breadcrumbs |
| Breadcrumbs | Ring buffer (default 50) auto-attached to every bug report |
| trackScreenView / trackTap / trackEvent | Push breadcrumbs and session events |
| flushTelemetry() | POSTs pending timeline data to /events and /breadcrumbs |
| Crash handler | Global ErrorUtils hook + unhandledrejection listener, caches crash context before retry |
| getPendingCrashes() / flushCachedCrashes() | Post-crash review and retry surface |
| Offline queues | Failed bug submits and attachments (5xx / network) queued and retried |
| Device + app snapshot | Auto-gathered via expo-device + expo-application |
| Shake-to-report | useShake({ onShake }) hook |
Tier 2 — task fetching, network, screenshots, dashboard
| Feature | API | Peer dep |
|---|---|---|
| Network capture | enabled by default in init; toggle via captureNetwork: false | none |
| Task fetching | tpk.api.getMyTasks() / enrollInProject() / unenrollFromProject() | none |
| Tester profile + points | tpk.api.getMyProfile() / getMyPoints() | none |
| Dashboard hook | useTpkDashboard(tpk) → {tasks, summary, profile, points} | none |
| Screenshot capture | captureView(viewRef) → base64 PNG | react-native-view-shot |
| Attachment upload | tpk.attachToBugReport(...) queues transient failures | expo-file-system for file helpers |
Tier 3 — annotations, recording, earnings
| Feature | API | Peer dep |
|---|---|---|
| Widget-annotation registry | <TpkAnnotated task="…" labels={…}> + useTaskAnnotation() + tpk.annotations.subscribe(…) | none |
| Annotated screenshot editor | <TpkAnnotationOverlay tool="freehand" …> with finalize() → base64 | react-native-svg, react-native-view-shot |
| Screen recording | const rec = new ScreenRecorder(); await rec.start(); await rec.stop() then uploadAsAttachment(...) | react-native-record-screen, expo-file-system |
| Voice notes | VoiceNoteRecorder wraps Expo Audio recorder output and uploads voice_note attachments | expo-audio, expo-file-system |
| Earnings summary | tpk.api.getEarningsSummary() / getMyPayouts() + useTpkEarnings(tpk) hook | none |
Annotated-screenshot example
import {
captureView, uploadScreenshotAttachment,
TpkAnnotationOverlay, type TpkAnnotationOverlayHandle,
} from '@testerpaykit/expo';
const rootRef = useRef<View>(null);
const editor = useRef<TpkAnnotationOverlayHandle>(null);
// 1. Capture the screen
const shot = await captureView(rootRef);
// 2. Show the editor with `imageUri={`data:image/png;base64,${shot.base64}`}`
// 3. When the user taps "Send":
const flat = await editor.current?.finalize();
const bug = await tpk.submitBug({ title: '...', description: '...' });
if (flat && bug) await uploadScreenshotAttachment(tpk.api, bug.id, flat);Screen-recording example
import { ScreenRecorder } from '@testerpaykit/expo';
const rec = new ScreenRecorder();
await rec.start({ maxDurationSeconds: 30 });
// …user reproduces the bug, then taps "Stop"…
const clip = await rec.stop();
if (clip) {
const bug = await tpk.submitBug({ title: '...', description: '...' });
await rec.uploadAsAttachment(tpk.api, bug.id, clip);
}Voice-note example
import { RecordingPresets, useAudioRecorder } from 'expo-audio';
import { VoiceNoteRecorder } from '@testerpaykit/expo';
const expoRecorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
const voice = new VoiceNoteRecorder({ recorder: expoRecorder });
await voice.start();
// ...tester describes the bug...
const note = await voice.stop();
if (note) {
const bug = await tpk.submitBug({ title: '...', description: '...' });
await voice.uploadAsAttachment(tpk.api, bug.id, note);
}What this package is NOT
- Not a full console app. The provider, floating button, and bug-report sheet are package primitives; app-specific navigation, task workflows, and branding stay in your app.
- Not a video-editing tool. Recordings are uploaded as-is; if you
need trimming or thumbnails do it client-side with
expo-video-thumbnailsbefore callinguploadAsAttachment.
