@vitrinka/expo
v0.2.3
Published
vitrinka toolkit for Expo apps — the journey recorder: capture manual-testing sessions (screenshots, interactions, network, notes) straight into vitrinka boards.
Downloads
1,705
Readme
@vitrinka/expo
The vitrinka toolkit for Expo apps. Today it ships one tool: the journey recorder — record manual-testing sessions (screenshot keyframes, taps, navigation, network calls, console errors, notes) from a real device straight into a vitrinka board.
What it captures and where it goes is documented in
docs/PROTOCOL.md; this package's source is public
precisely so you can verify it.
Install
npx expo install @vitrinka/expo react-native-view-shot react-native-keyboard-controller react-native-safe-area-context expo-file-system expo-constantsAll native dependencies are peers on purpose (your app's versions win). MMKV and expo-router are optional — only needed if you import their subpaths.
Wire it up
1. Config plugin (app.json) — the recorder's build guard. It refuses any
build where the ingest token could leak into a store bundle:
{
"expo": {
"plugins": [["@vitrinka/expo", { "allowedProfiles": ["development", "development-simulator"] }]]
}
}If you publish OTA updates from scripts, also set updateLaneVar to the env
var your update scripts export (e.g. "updateLaneVar": "MYAPP_UPDATE_LANE") —
it is the reliable signal for refusing a token on a store-facing channel;
without it only best-effort argv scanning guards eas update. Prod-backed
test lanes use laneMarkerVar/laneMarkerValues (see plugin/build-guard.js).
2. Metro strip (metro.config.js) — one required line (config plugins
cannot modify metro config; the plugin verifies it is present):
const { withRecorderStrip } = require('@vitrinka/expo/recorder/metro');
module.exports = withRecorderStrip(config);3. Mount (your root layout):
import { VitrinkaRecorderRoot, VitrinkaRecorderPill } from '@vitrinka/expo/recorder';
import { useExpoRouterRecorderRoute } from '@vitrinka/expo/recorder/expo-router';
export default function Layout() {
const route = useExpoRouterRecorderRoute(); // or supply {pathname, lane} from your own nav
return (
<VitrinkaRecorderRoot route={route}>
<Stack />
<VitrinkaRecorderPill />
</VitrinkaRecorderRoot>
);
}4. Enable it — only on builds that should record:
EXPO_PUBLIC_VITRINKA_URL=https://your-vitrinka.example # the URL alone enables the recorderWithout it, the entire recorder is stripped from the bundle at compile time — the gate folds to a no-op and the metro hook redirects the recorder's modules to an empty stub.
5. Link the device — no secret is baked. The first time, a tester
taps the puck (Link recorder) and gets a short code, Open vitrinka (approve
on the same device) — or type the code in vitrinka on any other device. The
server mints an ingest-only vkr_ token that the recorder stores
(vitrinka.recorder.link, through the storage driver) and uses from then on;
Unlink (long-press the puck, or the ⋯ menu) forgets it, and so does a 401 from the server. A URL
addressing /w/<slug> preselects that workspace on the approve page; a
token approved into another workspace is discarded and the sheet says which
one to pick.
Unattended builds (CI, machine-driven runs) can still bake a token:
EXPO_PUBLIC_VITRINKA_TOKEN=<vkr_ recorder key> # admin-minted, ingest-only — never a workspace tokenCredentials in URL paths (sanitizeUrl)
The redaction engine scrubs secrets in query strings and fragments
(?token=…), but it cannot know which path segments of your routes are
credentials. An expo-router screen at app/reset-password/[token].tsx
reports /reset-password/<token> as its pathname, and the pathname is the
route of every event recorded on that screen. Name those segments with
sanitizeUrl:
// Each rule rewrites a path segment that IS a credential into a placeholder.
const SECRET_PATHS: [RegExp, string][] = [
[/\/(reset-password|invite)\/[^/?#]+/g, '/$1/[token]'],
];
const sanitizeUrl = (url: string) => SECRET_PATHS.reduce((u, [rx, to]) => u.replace(rx, to), url);
<VitrinkaRecorderRoot route={route} sanitizeUrl={sanitizeUrl}>- Coverage: every route the recorder records (each event's
tabHost, therouteof navigations, taps and keyframes) and every network request URL. - Input: bare pathnames, and request URLs as your code passed them (absolute or relative). Match on path segments, not on a URL you parse.
- Order: it runs first. The query/fragment scrub and your workspace's patterns still apply to what it returns, and routes pass them too.
fullFidelity: it applies even under afullFidelitypolicy. It is your statement about your own URLs, not a workspace redaction rule.- Failure: it fails closed. If it throws or returns a non-string, the
recorder keeps only the URL's origin (
[redacted]for a bare route), never the raw value. It logs onevitrinka:warning per sanitizer. A line your sanitizer logs reaches the console but is never recorded. - Not covered: free text stays under the workspace policy: request and
response headers and bodies, and console lines (a router error that quotes
the path, say). Add a policy pattern for a token shape that shows up in
text. Keyframes show what the screen shows. The lane
comes from expo-router's segments (
[token], never its value) or your ownlane.hideIdleGripOnmatches the raw pathname.
The production-strip guarantee
Three layers, all shipped here:
- Gate — the exported components check
EXPO_PUBLIC_VITRINKA_*as inlined literals; without them the recorder code is provably unreachable. - Metro stub —
withRecorderStripredirects the recorder's entry modules to an empty stub on unconfigured builds, so Metro never bundles the subtree. - Build guard — the config plugin throws on any build/OTA-publish where a
baked token is present outside your allowlisted profiles (the URL alone is
never gated — it is not a secret) (see
plugin/build-guard.jsfor the full semantics: forbidden OTA channels are checked first and have no escape hatch).
We recommend asserting the strip in your own repo against your real export
pipeline (bundle without recorder env; grep the output for vitrinka).
Storage
The recorder's durable queue uses a synchronous storage driver. Default: expo-file-system (zero extra installs). To use MMKV instead:
import { configureRecorderStorage } from '@vitrinka/expo/recorder/storage';
import { mmkvRecorderStorage } from '@vitrinka/expo/recorder/storage-mmkv';
configureRecorderStorage(mmkvRecorderStorage());