@testerpaykit/expo
v0.4.1
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: shake detection (expo-sensors), snapshots
(expo-device, expo-application), file upload helpers
(expo-file-system), and voice recording (expo-audio).
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.
