@dg-superapp/bridge-testing
v1.1.0
Published
Testing utilities for DG Super App Bridge - zero-config vitest plugin, matchers, and helpers
Maintainers
Readme
@dg-superapp/bridge-testing
Zero-config vitest plugin and testing utilities for DG SuperApp Bridge mini-apps.
Quick Start
1. Install
npm install --save-dev @dg-superapp/bridge-testing
# or
pnpm add -D @dg-superapp/bridge-testing2. Configure vitest
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { dgBridgePlugin } from '@dg-superapp/bridge-testing/plugin';
export default defineConfig({
plugins: [dgBridgePlugin()],
test: {
globals: true,
environment: 'jsdom',
},
});3. Write Your First Test
// src/photo-upload.test.ts
import { describe, it, expect } from 'vitest';
import { setupBridge, withProfile } from '@dg-superapp/bridge-testing';
describe('photo upload', () => {
setupBridge(withProfile('basic'));
it('captures a photo with basic profile', async () => {
const result = await bridge.callHandler('capturePhoto', []);
expect(result.success).toBe(true);
expect(bridge).toHaveCalledBridge('capturePhoto');
});
});How Testing Works
The testing framework uses two separate APIs — one for production, one for tests:
Production code (src/) Test code (*.test.ts)
───────────────────── ─────────────────────
import { callBridge } bridge.callHandler()
from '@dg-superapp/bridge-sdk' (global, from dgBridgePlugin)
│ │
▼ ▼
Real native bridge MockBridge instance
(WebView ↔ Flutter) (in-memory, configurable)Why two APIs? Production code runs inside a real super app WebView where callBridge() communicates with Flutter. Tests run in Node.js/jsdom where there's no WebView — so dgBridgePlugin creates a global bridge (MockBridge) that responds to bridge.callHandler() calls.
What this means for you:
- Write features in
src/usingcallBridge()or named imports (capturePhoto(),userProfile()) - Write tests in
*.test.tsusingbridge.callHandler('handlerName', [args]) - Use
dg bridge testto verify your productioncallBridge()calls match registered handler contracts - The plugin handles setup/teardown automatically — no manual wiring needed
Testing your business logic: Write unit tests for your functions by calling handlers through bridge.callHandler() with the same handler names and args your production code uses. The mock responses match production schemas, so your logic is tested against realistic data.
Plugin Options
The dgBridgePlugin() accepts optional configuration:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| profile | 'anonymous' \| 'basic' \| 'verified' | 'basic' | Default user profile level for all tests |
| timing | 'instant' \| 'realistic' | 'instant' | Response timing: instant (no delay) or realistic (network simulation) |
| debug | boolean | false | Enable console logging of all bridge calls |
// Example: realistic timing with verified profile
export default defineConfig({
plugins: [
dgBridgePlugin({
profile: 'verified',
timing: 'realistic',
debug: true,
}),
],
});Helpers
The setupBridge() function configures the global bridge instance for a test suite. It accepts one or more configurator functions that are applied left-to-right (later configurators override earlier ones for conflicting handlers).
setupBridge(...configurators)
describe('auth flow', () => {
setupBridge(
withProfile('anonymous'),
withOverride('userProfile', async () => ({
userId: 'custom-123',
level: '0',
hash: 'mock-hash',
payload: 'mock-payload',
}))
);
// Tests here have anonymous profile + custom userProfile override
});Configurator Functions
| Configurator | Signature | Purpose |
|--------------|-----------|---------|
| withProfile(level) | withProfile('anonymous' \| 'basic' \| 'verified') | Set user profile level (L0/L1/L2) |
| withOverride(handler, fn) | withOverride(name, async (args, ctx) => rawData) | Override a specific handler (return raw data, throw for errors) |
| withTiming(mode) | withTiming('instant' \| 'realistic') | Set response timing for this suite |
| withDebug(enabled) | withDebug(true \| false) | Enable/disable debug logging |
| withScenario(preset \| definition) | withScenario('offline') or custom | Apply a scenario preset or custom scenario |
Configurator Precedence
When multiple configurators affect the same handler, the last one wins:
setupBridge(
withScenario('no-permissions'), // denies camera
withOverride('capturePhoto', async () => ({ // this override wins
path: '/mock/photo.jpg', extension: 'jpg', fileName: 'photo.jpg',
})),
);
// capturePhoto now returns success (override beats scenario)withOverride Example
Override functions receive (args, context) and return raw data (the value that becomes result.data). To simulate errors, throw an Error.
// Success override — return raw data (NOT a { success, data, message } wrapper)
setupBridge(
withOverride('capturePhoto', async (args, context) => ({
path: 'test://photo.jpg',
extension: 'jpg',
fileName: 'photo.jpg',
}))
);
// Error override — throw an Error
setupBridge(
withOverride('capturePhoto', async () => {
throw new Error('Camera hardware not available');
})
);
// Context-aware override
setupBridge(
withOverride('capturePhoto', async (args, context) => {
if (context.profile.level === '0') {
throw new Error('Anonymous users cannot capture photos');
}
return { path: '/photo.jpg', extension: 'jpg', fileName: 'photo.jpg' };
})
);The context parameter has this shape:
interface MockContext {
profile: {
level: string; // '0', '1', or '2'
defaultLevel: string;
// ... profile template fields
};
}Utility Functions
// Get all calls to the bridge
const calls = getBridgeCallLog();
console.log(calls); // Array<{ handlerName, args, timestamp, duration }>
// Clear call log and overrides between tests
resetBridge();Scenario Presets
Apply common real-world scenarios that override multiple handlers:
import { setupBridge, withScenario } from '@dg-superapp/bridge-testing';
describe('offline resilience', () => {
setupBridge(withScenario('offline'));
// All network-dependent handlers now fail with connection errors
});Available Presets
| Preset | Simulated Behavior | Use Case |
|--------|-------------|----------|
| slow-network | Network handlers have 500-2000ms latency | Test perceived slowness, timeouts |
| no-permissions | Camera, contacts, location all fail with permission denied | Test permission request flows |
| expired-session | Auth handlers fail with token errors | Test re-authentication flows |
| offline | Network-dependent handlers fail with connection error | Test offline-first caching |
| low-storage | Storage and file handlers fail with quota exceeded | Test storage management |
| first-launch | Empty data for contacts/files, minimal profile | Test onboarding flows |
Handlers Affected by Each Preset
Important: Scenario presets only affect the handlers listed below. Local-device handlers (
mobileScanner, camera capture, file picker) work even inofflinemode — they don't need network. To simulate their failure, usewithOverride('handlerName', async () => { throw EnhancedBridgeError.create('NETWORK_ERROR'); }).
| Preset | Handlers That THROW | Handlers That Return Empty/Default |
|--------|--------------------|------------------------------------|
| no-permissions | capturePhoto, capturePhotoBase64, pickPhoto, pickPhotoBase64, requestCameraPermission, requestPhotoPermission, getContacts, getDeviceLocation, watchPositionLocation | — |
| offline | getNetworkStatus, getCarrierInfo, sendDataToMobile, onFormSubmit, shareContent, downloadAndShare, downloadAndSaveResource, openLink | — |
| expired-session | getUserProfile, authenticateBiometric, canAuthenticateWithBiometrics, sendEncryptedData, receiveEncryptedData, performFaceLivenessCheck | — |
| low-storage | capturePhoto, capturePhotoBase64, pickPhoto, pickPhotoBase64, selectFile, selectFileBase64, selectMultiFile, selectMultiFileBase64, saveResource | — |
| first-launch | capturePhoto, pickPhoto, selectFile | getContacts returns [], getUserProfile returns { level: "0", displayName: "New User" } |
| slow-network | — (adds 500-2000ms delay, no throws) | All network handlers respond with delay |
Error Messages by Preset
| Preset | Error Message Pattern |
|--------|----------------------|
| no-permissions | "Permission denied: {resource} access not granted" |
| offline | "Network unavailable: device is offline" |
| expired-session | "Session expired: authentication required" |
| low-storage | "Storage full: insufficient space available" |
import { getScenarioPresetNames } from '@dg-superapp/bridge-testing';
// List all available preset names
const presets = getScenarioPresetNames(); // ['slow-network', 'offline', ...]Error Handling in Scenarios
Scenario presets simulate failures by throwing errors, not by returning { success: false }. When a handler throws, the bridge call rejects. Use .rejects.toThrow() to test error scenarios:
describe('no-permissions scenario', () => {
setupBridge(withScenario('no-permissions'));
it('denies camera access', async () => {
// The handler THROWS — use rejects.toThrow(), not result.success
await expect(
bridge.callHandler('capturePhoto', [])
).rejects.toThrow('Permission denied');
});
});
describe('offline scenario', () => {
setupBridge(withScenario('offline'));
it('fails network calls', async () => {
await expect(
bridge.callHandler('getNetworkStatus', [])
).rejects.toThrow('Network unavailable');
});
});Exception: The first-launch preset returns empty/default data for some handlers (e.g., getContacts returns []) instead of throwing. Only handlers that truly fail (camera, file access) throw errors.
Combining Presets
Merge multiple scenarios. Last scenario wins for overlapping handler names:
import { mergeScenarios, getScenarioPreset, withScenario } from '@dg-superapp/bridge-testing';
// mergeScenarios() is variadic — pass 2 or more scenarios
const complexScenario = mergeScenarios(
getScenarioPreset('offline'),
getScenarioPreset('low-storage'),
);
// Three or more works too
const tripleScenario = mergeScenarios(
getScenarioPreset('offline'),
getScenarioPreset('low-storage'),
getScenarioPreset('no-permissions'),
);
describe('offline + low storage', () => {
setupBridge(withScenario(complexScenario));
// Both offline AND low-storage overrides active
});
// Merge a preset with a custom scenario
const customScenario = createScenario({
name: 'custom-camera',
overrides: {
capturePhoto: async () => ({ path: '/test.jpg', extension: 'jpg', fileName: 'test.jpg' }),
},
});
const merged = mergeScenarios(
getScenarioPreset('offline'),
customScenario, // custom ScenarioDefinition objects work too
);Custom Scenarios
Define your own scenario. Override functions return raw data for success or throw for errors:
import { createScenario, withScenario } from '@dg-superapp/bridge-testing';
const partialPermissions = createScenario({
name: 'partial-permissions',
description: 'Camera denied, contacts allowed but empty',
overrides: {
// Failure: throw an Error
capturePhoto: async () => {
throw new Error('Permission denied: camera access not granted');
},
// Success: return raw data (the value that becomes result.data)
getContacts: async () => [],
},
});
describe('partial permissions', () => {
setupBridge(withScenario(partialPermissions));
it('denies camera', async () => {
await expect(bridge.callHandler('capturePhoto', [])).rejects.toThrow('Permission denied');
});
it('allows contacts but returns empty', async () => {
const result = await bridge.callHandler('getContacts', []);
expect(result.success).toBe(true);
expect(result.data).toEqual([]);
});
});Resolving Scenarios Programmatically
import { resolveScenario } from '@dg-superapp/bridge-testing';
// Resolve a preset by name
const definition = resolveScenario('offline');
// Apply it without setupBridge
bridge.clearOverrides();
Object.entries(definition.overrides).forEach(([name, fn]) => {
bridge.addOverride(name, fn);
});Custom Matchers
All matchers are registered automatically by the plugin and available on expect(bridge).
toHaveCalledBridge(handlerName)
Assert that a handler was called at least once.
it('calls userProfile', async () => {
await bridge.callHandler('userProfile', []);
expect(bridge).toHaveCalledBridge('userProfile');
});
it('fails when handler not called', async () => {
// Does not call userProfile
expect(bridge).not.toHaveCalledBridge('userProfile');
});toHaveCalledBridgeTimes(handlerName, times)
Assert that a handler was called exactly N times.
it('retries failed requests', async () => {
const handler = 'fetchData';
await bridge.callHandler(handler, []);
await bridge.callHandler(handler, []);
expect(bridge).toHaveCalledBridgeTimes(handler, 2);
});toHaveCalledBridgeWith(handlerName, args)
Assert that a handler was called with specific arguments.
it('passes correct arguments', async () => {
await bridge.callHandler('copyToClipboard', ['Hello world']);
expect(bridge).toHaveCalledBridgeWith('copyToClipboard', ['Hello world']);
});toMatchBridgeSchema(handlerName)
Assert that response data matches the handler's registered schema. For handlers that return null (e.g., copyToClipboard, closeMiniApp), the matcher validates that result.data is null — the schema check still passes since null is the expected response type.
it('returns valid photo data', async () => {
const result = await bridge.callHandler('capturePhoto', []);
// Validates result.data against the schema in bridge-core registry
expect(result).toMatchBridgeSchema('capturePhoto');
});
it('works for null-response handlers too', async () => {
const result = await bridge.callHandler('copyToClipboard', ['text']);
// result.data is null — matcher validates null matches the schema
expect(result).toMatchBridgeSchema('copyToClipboard');
});Contract Testing
Validate that mock responses conform to the bridge's registered schemas across multiple scenarios. Three levels of testing available:
Level 1: CLI Commands
# Run all contract tests against baseline
pnpm test:contracts
# Update baseline with current snapshot (safe: creates backup)
pnpm test:contracts:update-baseline
# Detect schema drift (breaking changes in contracts)
pnpm test:contracts --detect-driftFailure output example:
Running contract tests...
[PASS] capturePhoto: Response matches schema
[PASS] userProfile: Response matches schema
[FAIL] getContacts: Expected array items to match ContactModel schema
[FAIL] openLink: Missing required field "launchMode" in args schema
Results: 50 passed, 2 failed, 52 totalDrift detection output example:
Schema drift detected against baseline:
[BREAKING] capturePhoto: Response field "path" changed from required to optional
[BREAKING] userProfile: New required field "requestId" added
[ADDITIVE] mobileScanner: New optional field "format" added
2 breaking, 1 additive, 49 unchanged
Run 'pnpm test:contracts:update-baseline' to accept these changes.Exit codes: Drift detection exits with code
1only for BREAKING changes. ADDITIVE changes (new optional fields) are reported but do not fail the test — safe for CI pipelines.
Level 2: NPM Test Scripts
{
"scripts": {
"test:contracts": "vitest run packages/testing/src/contracts/__tests__/contracts.test.ts",
"test:contracts:update-baseline": "tsx packages/testing/src/contracts/__tests__/generate-baseline.ts"
}
}Level 3: Programmatic API
import {
validateMockContracts,
validateScenarioContracts,
runContractTests,
detectSchemaDrift,
generateBaseline,
} from '@dg-superapp/bridge-testing';
// Validate all handlers against their schemas
const results = await validateMockContracts();
if (!results.allValid) {
console.error('Contract violations:', results.violations);
}
// Validate a specific scenario
const scenarioResults = await validateScenarioContracts('offline');
// Run full contract test suite
const testResults = await runContractTests();
// Detect breaking schema changes from baseline
const drift = await detectSchemaDrift();
if (drift.hasDrift) {
console.warn('Breaking changes detected:', drift.findings);
}
// Generate new baseline snapshot
await generateBaseline('./contracts/baseline.json');Baseline Format
The baseline file (contracts/baseline.json) is a JSON snapshot of all handler schemas. Commit it to git — it's the reference point for drift detection.
{
"version": 1,
"generatedAt": "2026-04-11T10:00:00.000Z",
"handlers": {
"capturePhoto": {
"category": "camera",
"responseSchemaShape": { /* serialized Zod schema */ }
},
"userProfile": {
"category": "identity",
"responseSchemaShape": { /* ... */ }
}
}
}Regenerate after intentional schema changes with pnpm test:contracts:update-baseline.
Complete Handler Reference
All 52 registered bridge handlers grouped by category. Use dg bridge list to see this list, or dg bridge list -c camera to filter by category.
Note on args format: The
Argscolumn shows the positional arguments as a tuple. In production code withcallBridge(), pass them as spread arguments:callBridge('handler', arg1, arg2). In test code withbridge.callHandler(), pass them as an array:bridge.callHandler('handler', [arg1, arg2]).
Biometric
| Handler | Args | Response | Description |
|---------|------|----------|-------------|
| authenticateBiometric | [] | { can: boolean } | Trigger biometric auth (fingerprint/face) |
| canAuthenticateWithBiometrics | [] | { can: boolean } | Check if biometric auth is available |
Camera
| Handler | Args | Response | Description |
|---------|------|----------|-------------|
| capturePhoto | [] | { path?, extension?, fileName? } | Take photo, returns file path |
| capturePhotoBase64 | [] | { base64: string } | Take photo, returns base64 |
| pickPhoto | [] | { path?, extension?, fileName? } | Pick from gallery, file path |
| pickPhotoBase64 | [] | { base64: string } | Pick from gallery, base64 |
Clipboard
| Handler | Args | Response | Description |
|---------|------|----------|-------------|
| copyToClipboard | [text: string] | null | Copy text to clipboard |
| pasteFromClipboard | [] | string | Get text from clipboard |
Contacts
| Handler | Args | Response | Description |
|---------|------|----------|-------------|
| getContacts | [] | Array<{ displayName?, phones?, emails? }> | Get device contacts |
Data
| Handler | Args | Response | Description |
|---------|------|----------|-------------|
| onFormSubmit | [{ formId?, data }] | null | Notify native of form submission |
| sendDataToMobile | [data: unknown] | null | Send arbitrary data to native app |
Identity Verification
| Handler | Args | Response | Description |
|---------|------|----------|-------------|
| performFaceLivenessCheck | [{ token: string }] | { status: string } | Face liveness check |
| performFullIdentityVerification | [{ token: string }] | { status: string } | Full document identity verification |
| scanIdentityQrCode | [] | string | Scan identity document QR code |
Encryption
| Handler | Args | Response | Description |
|---------|------|----------|-------------|
| receiveEncryptedData | [encrypted: string] | string (decrypted) | Decrypt data from native |
| sendEncryptedData | [{ sensitive?: string }] | { sensitive?: string } | Send small string data (auth tokens, keys) to native for encryption. Not for file blobs — use shareFileBase64 for files |
Files
| Handler | Args | Response | Description |
|---------|------|----------|-------------|
| selectFile | [] | { path?, extension?, fileName? } | Select single file |
| selectFileBase64 | [] | { base64: string } | Select file, base64 |
| selectMultiFile | [] | Array<{ path?, extension?, fileName? }> | Select multiple files |
| selectMultiFileBase64 | [] | Array<{ base64: string }> | Select multiple files, base64 |
Identity
| Handler | Args | Response | Description |
|---------|------|----------|-------------|
| userProfile / getUserProfile | [] | { userId, level, hash, payload, firstName?, lastName?, displayName?, phone?, email?, ... } | Get user profile (L0/L1/L2). level is string "0", "1", or "2". Both names work — userProfile is canonical, getUserProfile is a registered alias |
Profile data by level:
| Level | Key Fields |
|-------|-----------|
| "0" (anonymous) | userId, level, hash, payload only |
| "1" (basic) | + providerId, firstName, lastName, displayName, gender, dateOfBirth, address |
| "2" (verified) | All L1 fields + documentType, legalId, expiryDate, phone, email, requestId |
Lifecycle
| Handler | Args | Response | Description |
|---------|------|----------|-------------|
| initializeMiniApp | [] | { isDGSuperApp: boolean, deviceInfo?, deviceId?, locale?, os?, features?: string[], viewPaddingTop?, viewPaddingBottom?, viewPaddingLeft?, viewPaddingRight?, devicePixelRatio?, appBarHeight? } | Bootstrap mini-app with device context. Only isDGSuperApp is required; all other fields are optional |
| closeMiniApp | [] | null | Exit mini-app, return to super app |
| backgroundMiniContainer | [] | null | Move mini-app to background |
Location
| Handler | Args | Response | Description |
|---------|------|----------|-------------|
| getDeviceLocation | [] | { latitude, longitude, accuracy } | Get current GPS location |
| watchPositionLocation | [] | stream initiation | Start GPS streaming |
| clearWatchLocation | [] | { message: string } | Stop GPS streaming |
Navigation
| Handler | Args | Response | Description |
|---------|------|----------|-------------|
| openLink | [url: string, launchMode?] | null | Open URL in browser/webview. launchMode is one of: "platformDefault" | "inAppWebView" | "inAppBrowserView" | "externalApplication" | "externalNonBrowserApplication" |
| getIsInitialRouteApp | [] | boolean | Check if on initial route |
| setIsInitialRouteApp | [value?: boolean] | null | Set initial route flag |
Permissions
| Handler | Args | Response | Description |
|---------|------|----------|-------------|
| requestCameraPermission | [] | null | Request camera access |
| requestPhotoPermission | [] | null | Request photo library access |
| goToSettingToGrantPermission | [] | null | Open device settings |
Scanner
| Handler | Args | Response | Description |
|---------|------|----------|-------------|
| mobileScanner | [] | string | Scan QR code or barcode |
Sharing
| Handler | Args | Response | Description |
|---------|------|----------|-------------|
| shareContent | [{ text?, subject?, url? }] | null | Share via share sheet |
| shareFileBase64 | [base64, name?, type?] | null | Share base64 file |
| shareFileArrbuffer | [bytes[], name?, type?] | null | Share byte array file |
| downloadAndShare | [url, fileType, ext?, name?] | null | Download and share file |
| downloadAndSaveResource | [url, fileType, ext?, name?] | null | Download and save file |
| saveResource | [name, base64, type, ext] | null | Save base64 to device |
Sharing example with positional tuple args:
// shareFileBase64 uses positional args: [base64, name?, type?]
await bridge.callHandler('shareFileBase64', [
'base64EncodedFileData==', // required: base64 content
'report.pdf', // optional: file name
'application/pdf', // optional: MIME type
]);
// shareContent uses a named object
await bridge.callHandler('shareContent', [{ text: 'Check this out', url: 'https://example.com' }]);SIM
| Handler | Args | Response | Description |
|---------|------|----------|-------------|
| getNetworkStatus | [] | { radioTechnology?, signalStrength?, isRoaming? } | Get network info |
| getCarrierInfo | [] | { carrierName?, isSimInserted? } | Get SIM/carrier info |
UI
| Handler | Args | Response | Description |
|---------|------|----------|-------------|
| appBarUpdate | [title?, titleColor?, backgroundColor?] | null | Update app bar |
| appBarVisibility | [visible: boolean] | null | Show/hide app bar |
| customAppBarUpdate | [title?, titleColor?, backgroundColor?] | null | Update custom app bar |
| customAppBarVisibility | [visible: boolean] | null | Show/hide custom app bar |
| uiShowloadingDialog | [message?: string] | null | Show loading dialog |
| uiHideloadingDialog | [] | null | Hide loading dialog |
| uiYesNoDialog | [{ title, message, yesLabel?, noLabel? }] | { result: boolean } | Show confirmation dialog |
Web Control
| Handler | Args | Response | Description |
|---------|------|----------|-------------|
| webReload | [] | null | Reload web view |
| webClearCached | [] | null | Clear web cache |
| webClearCachedAndRefresh | [] | null | Clear cache and reload |
callBridge() vs bridge.callHandler()
These are two different APIs that often confuse new developers:
| API | Where | Purpose |
|-----|-------|---------|
| callBridge('handlerName', args) | Production code (your mini-app's src/) | The real bridge SDK call that communicates with the native app |
| bridge.callHandler('handlerName', args) | Test code (your *.test.ts files) | The mock bridge call provided by dgBridgePlugin |
The dg bridge test CLI command scans your production source (src/) for callBridge() calls and validates their handler contracts. It does NOT scan test files.
Re-exports
This package re-exports commonly-used types and utilities from @dg-superapp/bridge-mocks:
import {
MockBridge, // The mock bridge instance type
MockProfiles, // Profile presets
createMockProfile, // Create custom profiles
createMockContextWithProfile, // Build mock context with profile
} from '@dg-superapp/bridge-testing';
// Type-safe profile creation
const customProfile = createMockProfile({
level: '2',
userId: 'custom-user-123',
permissions: ['camera', 'location'],
});
const context = createMockContextWithProfile('1');
console.log(context.profile.level); // '1'TypeScript
Full TypeScript support. All matchers, helpers, and types are included:
import type {
BridgeConfigurator,
ScenarioDefinition,
DgBridgePluginOptions,
ContractTestResults,
DriftResults,
} from '@dg-superapp/bridge-testing';License
MIT
