From 80b424fd5033f3081af2cf8c98078ca966ccc8a0 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Tue, 6 Oct 2026 17:15:36 +0500 Subject: [PATCH 1/3] e2e-native: a real signal crash on iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crashNative('segv') now stores through a bad pointer (EXC_BAD_ACCESS, SIGSEGV) and crashNative('abort') calls abort() on iOS too, so an iOS device test can prove a signal crash with no NSException behind it is recovered. It used to only log that it was Android-only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- examples/e2e-native/ios/BugseeE2EModule.mm | 19 +++++++++++++++++-- examples/e2e-native/src/NativeBugseeE2E.ts | 3 ++- examples/e2e-native/src/index.ts | 5 ++--- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/examples/e2e-native/ios/BugseeE2EModule.mm b/examples/e2e-native/ios/BugseeE2EModule.mm index da16469f..886d5dbb 100644 --- a/examples/e2e-native/ios/BugseeE2EModule.mm +++ b/examples/e2e-native/ios/BugseeE2EModule.mm @@ -26,9 +26,24 @@ @implementation BugseeE2EModule RCT_EXPORT_MODULE(BugseeE2E) -/// The native crash is Android's (a JNI signal); on iOS this only says so. +/// A real signal crash, as Android's JNI one: `segv` stores through a bad +/// pointer (EXC_BAD_ACCESS, SIGSEGV), `abort` calls abort() (SIGABRT). Not an +/// NSException -- that is `Bugsee.testNativeCrash()` -- so the crash reporter +/// sees a signal with no Objective-C exception behind it. Any other kind is +/// logged and ignored (JS refuses it before it crosses). - (void)crashNative:(NSString *)kind { - NSLog(@"BugseeE2E: crashNative is Android-only; nothing happens on iOS"); + if ([kind isEqualToString:@"segv"]) { + NSLog(@"BugseeE2E: crashNative segv"); + // A low, never-mapped address; volatile so the store is not elided. + volatile int *bad = (volatile int *)(uintptr_t)0x10; + *bad = 1; + return; + } + if ([kind isEqualToString:@"abort"]) { + NSLog(@"BugseeE2E: crashNative abort"); + abort(); + } + NSLog(@"BugseeE2E: crashNative: unknown kind ignored"); } /// UTF-8 into NSTemporaryDirectory(), replacing any file there. No SDK call, diff --git a/examples/e2e-native/src/NativeBugseeE2E.ts b/examples/e2e-native/src/NativeBugseeE2E.ts index f6f5213b..d90c81ee 100644 --- a/examples/e2e-native/src/NativeBugseeE2E.ts +++ b/examples/e2e-native/src/NativeBugseeE2E.ts @@ -11,7 +11,8 @@ export interface Spec extends TurboModule { /** * Android: `'segv'` stores through a null pointer in JNI (SIGSEGV), * `'abort'` calls `abort()` (SIGABRT). Any other kind is logged and ignored. - * iOS: logs that it is Android-only and returns. + * iOS: `'segv'` stores through a bad pointer (EXC_BAD_ACCESS, SIGSEGV), + * `'abort'` calls `abort()` (SIGABRT); any other kind is logged and ignored. */ crashNative(kind: string): void; /** diff --git a/examples/e2e-native/src/index.ts b/examples/e2e-native/src/index.ts index 3327c05d..664c5394 100644 --- a/examples/e2e-native/src/index.ts +++ b/examples/e2e-native/src/index.ts @@ -18,9 +18,8 @@ const FILE_NAME = /^[\w.-]{1,64}$/; /** * Crashes the process natively: SIGSEGV (`'segv'`) or SIGABRT (`'abort'`), - * from JNI code on Android. On iOS the native side only logs that it is - * Android-only. Any other kind throws a TypeError here and never reaches - * native code. + * from JNI code on Android and from the module's Objective-C++ on iOS. Any + * other kind throws a TypeError here and never reaches native code. */ export function crashNative(kind: NativeCrashKind): void { if (typeof kind !== 'string' || !CRASH_KINDS.includes(kind)) { From e8276091cc5130b05af67d51ddc6ff84f2d3fd7f Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Tue, 6 Oct 2026 17:15:36 +0500 Subject: [PATCH 2/3] e2e: device coverage for the first beta (identity, feedback, options, lifecycle, spans, crashes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New suites, each run on the WOD_LX1, the iPhone 17 Pro simulator and the iPhone XS: - wrapper-identity: environment.sdk.wrapper in a report (API-45) - feedback: the feedback screen in front, the greeting, the chat color - launch-options: 20 option cases, each proven by what it changes in a report against a control run, plus the getLaunchOptions read-back; the Android enum keys use values whose ordinal names another constant - lifecycle: onStatusChange, onLifecycleEvent order, stop(), an upload after stop, deleteCollectedDataOnDevice on disk - span-lifecycle: getActiveSpan, setDescription, finish(status), setName - js-crash: testJsCrash as one crash at the launched crash priority - report-dialog-appearance: the dialog painted in appearance colors - ios-native-crash: SIGSEGV / SIGABRT recovered on the XS with the recovery handler's labels in the crash report Product bugs found are pinned with it.failing, each with its evidence. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- examples/bare/App.tsx | 27 +- examples/bare/e2e/feedback.test.ts | 121 +++++ examples/bare/e2e/ios-native-crash.test.ts | 126 +++++ examples/bare/e2e/js-crash.test.ts | 79 +++ examples/bare/e2e/launch-options.test.ts | 468 ++++++++++++++++++ examples/bare/e2e/lifecycle.test.ts | 128 +++++ examples/bare/e2e/observe.ts | 220 ++++++++ examples/bare/e2e/ocr.swift | 32 ++ .../bare/e2e/report-dialog-appearance.test.ts | 52 ++ examples/bare/e2e/span-lifecycle.test.ts | 105 ++++ examples/bare/e2e/wrapper-identity.test.ts | 68 +++ examples/bare/scenarios/coverage.ts | 328 ++++++++++++ examples/bare/scenarios/feedback.ts | 14 +- examples/bare/scenarios/native.ts | 33 +- 14 files changed, 1793 insertions(+), 8 deletions(-) create mode 100644 examples/bare/e2e/feedback.test.ts create mode 100644 examples/bare/e2e/ios-native-crash.test.ts create mode 100644 examples/bare/e2e/js-crash.test.ts create mode 100644 examples/bare/e2e/launch-options.test.ts create mode 100644 examples/bare/e2e/lifecycle.test.ts create mode 100644 examples/bare/e2e/observe.ts create mode 100644 examples/bare/e2e/ocr.swift create mode 100644 examples/bare/e2e/report-dialog-appearance.test.ts create mode 100644 examples/bare/e2e/span-lifecycle.test.ts create mode 100644 examples/bare/e2e/wrapper-identity.test.ts create mode 100644 examples/bare/scenarios/coverage.ts diff --git a/examples/bare/App.tsx b/examples/bare/App.tsx index 819664af..0d95bed9 100644 --- a/examples/bare/App.tsx +++ b/examples/bare/App.tsx @@ -22,7 +22,8 @@ * `breadcrumb-filter` is scenarios/breadcrumb-filter.ts, * `network-filter` is scenarios/network-filter.ts, * `add-network-event` is scenarios/add-network-event.ts, - * `feedback` is scenarios/feedback.ts, and `apm` is scenarios/apm.ts. + * `feedback` is scenarios/feedback.ts, `apm` is scenarios/apm.ts, and + * `cov-*` / `opt-` are scenarios/coverage.ts. */ import { useEffect, useState } from 'react'; import { @@ -115,6 +116,12 @@ import { import { isFeedbackScenario, runFeedbackScenario } from './scenarios/feedback'; import { isApmScenario, runApmScenario } from './scenarios/apm'; import { isAppearanceScenario, runAppearanceScenario } from './scenarios/appearance'; +import { + isCoverageScenario, + launchOverrides, + preLaunchCoverage, + runCoverageScenario, +} from './scenarios/coverage'; const STATUS_NAMES: Record = { [Status.Stopped]: 'Stopped', @@ -147,6 +154,10 @@ function launchOptions(endpoint: string, scenario: string): LaunchOptions { if (isBreadcrumbFilterScenario(scenario)) { options.captureBreadcrumbs = true; } + // The `opt-` scenarios (scenarios/coverage.ts): each case's option + // set, through setCustomOption -- the typed model's escape hatch, which + // writes the same native payload a first-class accessor does. + launchOverrides(scenario, (key, value) => options.setCustomOption(key, value)); const serialized = BugseeLaunchOptions.serialize(options) as LaunchOptions; // This scenario only. APM is on by default; setting the key makes the // launch explicit about the capture this scenario is here to produce. @@ -370,7 +381,7 @@ export default function App() { // Before launch(): a crash recovered at the next launch only reaches a // handler that is already registered (Task 7.6b). if (isNativeScenario(choice.scenario)) { - installNativeHandler(choice.scenario); + installNativeHandler(choice.scenario, choice.nonce); } // Before launch(): app ErrorUtils handler (so Bugsee chains to it), and // the pre-launch exception probe (must not reach a bundle). @@ -393,6 +404,11 @@ export default function App() { if (isAddNetworkEventScenario(choice.scenario)) { installAddNetworkEventFilter(choice.nonce); } + // Before launch(): cov-lifecycle's onLifecycleEvent/onStatusChange + // subscriptions, so Launching and Launched are seen. + if (isCoverageScenario(choice.scenario)) { + preLaunchCoverage(choice.scenario, choice.nonce); + } console.log(`BUGSEE_E2E launching on ${Platform.OS}`); // Polling starts before launch() is awaited, not after. The SDK brings // capture up off the main thread, so by the time the promise resolves the @@ -493,7 +509,7 @@ export default function App() { } if (isFeedbackScenario(choice.scenario)) { - runFeedbackScenario(choice.nonce); + runFeedbackScenario(choice.scenario, choice.nonce); return; } @@ -502,6 +518,11 @@ export default function App() { return; } + if (isCoverageScenario(choice.scenario)) { + await runCoverageScenario(choice.scenario, choice.nonce); + return; + } + if (isAppearanceScenario(choice.scenario)) { await runAppearanceScenario(choice.scenario, choice.nonce); return; diff --git a/examples/bare/e2e/feedback.test.ts b/examples/bare/e2e/feedback.test.ts new file mode 100644 index 00000000..89604731 --- /dev/null +++ b/examples/bare/e2e/feedback.test.ts @@ -0,0 +1,121 @@ +/** + * FB-01, FB-02, FB-04, FB-05: `@bugsee/react-native-feedback` on a device. + * + * Drives scenarios/feedback.ts. Both scenarios set a greeting carrying the + * nonce (`hello `), a chat background of #112233, a listener, and + * call `showFeedbackUI()`: + * feedback as the app ships it. The first time ever, the SDKs may + * open on a "notify me by e-mail" screen. + * feedback-chat first sets an e-mail-shaped user identifier, which both + * SDKs take as the chat's e-mail, so the chat itself opens + * with no tap. + * + * What is read, and from where -- nothing here taps the screen: + * - Android: the top-resumed activity (`dumpsys activity`) is the SDK's + * FeedbackActivity, in this app's task; a screen capture. + * - iOS: a screen capture (simctl / devicectl), read by macOS Vision + * (e2e/ocr.swift) -- iOS has no accessibility dump the e2e can read. + * + * Needs a human (MANUAL, see the beta-coverage report): sending a message + * and receiving a reply (setListener's two callbacks, FB-03), which also + * needs a real backend. + */ +import { colourOf, colourPixels } from './media'; +import { ON_ANDROID, ON_IOS, type Run, TARGET_NAME, describeDevice, must, report, startRun } from './harness'; +import { androidTopActivity, beginRetainingSuite, captureScreen, endRetainingSuite, ocrLines } from './observe'; +import { type DeviceLog, adbStatus } from './scenario'; + +jest.setTimeout(6 * 60_000); + +/** Folds the characters OCR confuses (1/l/I/|, 0/O/o) together. */ +function ocrFold(text: string): string { + return text.replace(/[lI|]/g, '1').replace(/[Oo]/g, '0'); +} + +const FEEDBACK_ACTIVITY = 'com.bareexample/com.bugsee.library.feedback.FeedbackActivity'; +/** scenarios/feedback.ts `appearance.backgroundColor`. */ +const CHAT_BACKGROUND = '#112233'; + +/** + * The iOS SwiftUI chat (7.0.0-beta3 and beta4) ignores BugseeTheme, a + * documented limitation (packages/react-native-feedback/README.md): seen + * white (#ffffff) on the simulator with #112233 set. Pinned until it does. + */ +const itColour = ON_IOS ? it.failing : it; +/** + * Android 7.3.0 stores the greeting (`setDefaultFeedbackGreeting` writes the + * feedback preferences' `greeting`) and nothing ever reads it back: the chat + * opens empty, offline. iOS shows it. Pinned until the Android SDK renders it. + */ +const itGreeting = ON_ANDROID ? it.failing : it; + +describeDevice(`the feedback chat on ${TARGET_NAME}`, () => { + let log: DeviceLog | undefined; + + beforeAll(async () => { + log = await beginRetainingSuite('beta-feedback'); + }); + + afterAll(() => endRetainingSuite(log)); + + /** Starts `scenario`, waits for its `shown` marker, lets the UI settle. */ + async function show(scenario: 'feedback' | 'feedback-chat'): Promise { + const run = await startRun(scenario); + must( + await log!.waitFor(new RegExp(`BUGSEE_E2E feedback shown nonce=${run.scenario.nonce}`), 20_000, run.start), + 'showFeedbackUI() returning', + run.start, + ); + // The chat is presented on the next main-thread turn (Android after a + // storage-thread hop); a capture right away can still see the app. + await new Promise(resolve => setTimeout(resolve, 6_000)); + return run; + } + + async function screenText(label: string): Promise { + const shot = await captureScreen(label); + const lines = await ocrLines(shot); + report(`${label} text`, lines); + return lines; + } + + it('showFeedbackUI() puts the feedback screen in front of the app', async () => { + await show('feedback'); + if (ON_ANDROID) { + const top = await androidTopActivity(); + report('top activity', top); + expect(top).toBe(FEEDBACK_ACTIVITY); + } + // Both platforms title the screen "Feedback", in every locale tried. + const text = await screenText('feedback'); + expect(text).toContain('Feedback'); + // The app's own screen is gone from view: its status label is not read. + expect(text).not.toContain('Bugsee React Native'); + }); + + itGreeting('the chat shows the greeting setGreeting() set', async () => { + const run = await show('feedback-chat'); + if (ON_ANDROID) { + expect(await androidTopActivity()).toBe(FEEDBACK_ACTIVITY); + } + const text = await screenText('feedback-chat'); + // OCR reads a hex nonce's 1 as l or I now and then: compare on the + // characters it can tell apart. + expect(text.map(ocrFold)).toContain(ocrFold(`hello ${run.scenario.nonce}`)); + }); + + itColour('the chat is painted in the appearance background color', async () => { + await show('feedback-chat'); + if (ON_ANDROID) { + expect(await androidTopActivity()).toBe(FEEDBACK_ACTIVITY); + } + const shot = await captureScreen('feedback-chat-colour'); + const found = await colourPixels(shot, colourOf(CHAT_BACKGROUND), 4); + report('chat background pixels', { count: found.count, of: found.width * found.height, box: found.box }); + // Most of the screen: everything but the status bar, title bar and input. + expect(found.count).toBeGreaterThan(0.5 * found.width * found.height); + if (ON_ANDROID) { + await adbStatus('shell', 'input', 'keyevent', 'KEYCODE_BACK'); + } + }); +}); diff --git a/examples/bare/e2e/ios-native-crash.test.ts b/examples/bare/e2e/ios-native-crash.test.ts new file mode 100644 index 00000000..56ab76bf --- /dev/null +++ b/examples/bare/e2e/ios-native-crash.test.ts @@ -0,0 +1,126 @@ +/** + * FLOW-10 / FLOW-09 on iOS: a real signal crash -- not an NSException -- is + * recovered at the next launch, and the recovery handler's edits reach it. + * + * `crashNative('segv' | 'abort')` (examples/e2e-native, iOS since this + * suite) stores through a bad pointer (EXC_BAD_ACCESS, SIGSEGV) or calls + * abort() (SIGABRT). report-handler.test.ts's iOS recovery case uses + * `testNativeCrash()`, an uncaught NSException; nothing before this proved a + * signal with no Objective-C exception behind it reaches a report. + * + * Per kind: + * 1. `native-crash-`: Launched, then `BUGSEE_E2E native crashing + * kind=`; devicectl's own last line names the signal the app + * died of, and the device's process list no longer has it. + * 2. `native-crash-recover`: relaunch with a report handler that labels + * the recovered crash `['e2e-recovered', ]` from whichever phase + * the SDK offers it in (scenarios/native.ts). + * 3. One `crash` bundle; its crash capture names the signal; its labels + * carry the relaunch's nonce. + * + * iPhone only: the simulator slice of the iOS SDK has no crash reporter + * (report-handler.test.ts), so a crash there is never recovered. + */ +import { type PulledBundle, crashOf } from './bundles'; +import { iosTarget } from './device'; +import { ON_IOS, type Run, TARGET_NAME, awaitBundles, clearBundles, describeDevice, must, report, startRun } from './harness'; +import { beginRetainingSuite, endRetainingSuite } from './observe'; +import { type DeviceLog, devicePidsOfApp, deviceTerminationSignal } from './scenario'; + +jest.setTimeout(10 * 60_000); + +const ON_IPHONE = ON_IOS && iosTarget() === 'device'; +const describeIphone = ON_IPHONE ? describeDevice : describe.skip; + +interface KindOutcome { + readonly crash: Run; + readonly signal: number | undefined; + readonly stillRunning: boolean; + readonly recover: Run; + readonly crashes: readonly PulledBundle[]; + readonly handlerLines: readonly string[]; +} + +describeIphone(`a native signal crash recovered on ${TARGET_NAME}`, () => { + let log: DeviceLog | undefined; + const outcomes = new Map(); + + beforeAll(async () => { + log = await beginRetainingSuite('beta-ios-crash'); + }); + + afterAll(() => endRetainingSuite(log)); + + async function crashAndRecover(kind: 'segv' | 'abort'): Promise { + const cached = outcomes.get(kind); + if (cached !== undefined) { + return cached; + } + await clearBundles(); + const crash = await startRun(`native-crash-${kind}`); + const pid = Number(/BareExample\[(\d+):/.exec(crash.banner.text)?.[1]); + must( + await log!.waitFor(new RegExp(`BUGSEE_E2E native crashing kind=${kind}`), 20_000, crash.start), + `the app calling crashNative('${kind}')`, + crash.start, + ); + // The console stream ends only when the process does. + const ended = await Promise.race([ + crash.launch!.ended.then(() => true), + new Promise(resolve => setTimeout(() => resolve(false), 30_000)), + ]); + expect(ended).toBe(true); + const signal = deviceTerminationSignal(crash.launch!.output); + const stillRunning = (await devicePidsOfApp()).includes(pid); + report(`${kind} died`, { pid, signal, consoleEnd: crash.launch!.output.slice(-3).map(line => line.text.trim()) }); + + const recover = await startRun('native-crash-recover'); + const bundles = await awaitBundles(1, 90_000); + // Let a late handler edit land before reading the bundle again. + await new Promise(resolve => setTimeout(resolve, 5_000)); + const crashes = (await awaitBundles(1, 1_000)).filter(b => b.request.type === 'crash'); + const handlerLines = log!.all(new RegExp(`BUGSEE_E2E native recover .*nonce=${recover.scenario.nonce}`), recover.start).map(l => l.text.trim()); + report(`${kind} bundles`, bundles.map(b => ({ file: b.file, type: b.request.type, labels: b.request.labels }))); + report(`${kind} handler`, handlerLines); + for (const bundle of crashes) { + report(`${kind} crash capture`, JSON.stringify(crashOf(bundle) ?? {}).slice(0, 1500)); + } + const outcome = { crash, signal, stillRunning, recover, crashes, handlerLines }; + outcomes.set(kind, outcome); + return outcome; + } + + const SIGNALS = { segv: { number: 11, name: 'SIGSEGV' }, abort: { number: 6, name: 'SIGABRT' } } as const; + + // Order matters, and is recorded rather than hidden: on the XS with + // 7.0.0-beta4, a SIGABRT crashed after a SIGSEGV had been crashed and + // recovered in the same suite was not recovered in 2 of 3 runs (no crash + // file at the next launch, the handler never called), while abort alone + // passed 4/4, segv alone 7/7, and abort-then-segv 2/2. Open in the + // beta-coverage report; this order keeps each kind's own proof stable. + for (const kind of ['abort', 'segv'] as const) { + const expected = SIGNALS[kind]; + + it(`crashNative('${kind}') kills the app with ${expected.name}`, async () => { + const outcome = await crashAndRecover(kind); + expect(outcome.signal).toBe(expected.number); + expect(outcome.stillRunning).toBe(false); + }); + + it(`the next launch recovers one crash report naming ${expected.name}`, async () => { + const outcome = await crashAndRecover(kind); + expect(outcome.crashes).toHaveLength(1); + const text = JSON.stringify(crashOf(outcome.crashes[0]!)); + expect(text).toContain(expected.name); + // A signal, not an Objective-C exception: no NSException behind it. + expect(text).not.toContain('NSGenericException'); + }); + + it(`the recovery handler's labels reach the ${kind} crash report`, async () => { + const outcome = await crashAndRecover(kind); + expect(outcome.handlerLines.some(line => / labels-set /.test(line))).toBe(true); + expect(outcome.crashes).toHaveLength(1); + expect(outcome.crashes[0]!.request.labels).toEqual(expect.arrayContaining(['e2e-recovered', outcome.recover.scenario.nonce])); + }); + } +}); diff --git a/examples/bare/e2e/js-crash.test.ts b/examples/bare/e2e/js-crash.test.ts new file mode 100644 index 00000000..e5a4bfa9 --- /dev/null +++ b/examples/bare/e2e/js-crash.test.ts @@ -0,0 +1,79 @@ +/** + * API-10 testJsCrash, and the Android crash-priority enum option. + * + * Scenario `cov-jscrash` (scenarios/coverage.ts) launches with + * `reporting.defaults.crash-priority` = IssueSeverity.Medium (2) -- an + * ordinal lookup would give values()[2], High (3), and the SDK default is + * neither -- and calls `Bugsee.testJsCrash()` on its own turn, outside any + * try, so only the global handler sees it. + * + * Android files the crash at once (the process lives on under the Debug red + * box). iOS stores a JS fatal and files it at the next launch, so the iOS + * case relaunches and looks then. The iOS simulator slice compiles the + * exception reporter out (exceptions.test.ts), so the case is device-only. + */ +import { type PulledBundle, crashOf } from './bundles'; +import { iosTarget } from './device'; +import { ON_IOS, type Run, TARGET_NAME, awaitBundles, describeDevice, must, report, startRun, stopApp } from './harness'; +import { beginRetainingSuite, endRetainingSuite } from './observe'; +import { type DeviceLog } from './scenario'; + +jest.setTimeout(6 * 60_000); + +/** The iOS simulator slice has no exception reporter: device-only there. */ +const itDevice = ON_IOS && iosTarget() === 'simulator' ? it.skip : it; +/** + * iOS 7.0.0-beta4 recovers the JS fatal at the next launch (beta3 lost it, + * api-device-coverage.md R2) but files it at Blocker (5), not the + * `reporting.defaults.crash-priority` of 2 it was launched with (XS, run + * 2026-10-06). Android files it at 2. + */ +const itSeverity = ON_IOS ? (iosTarget() === 'simulator' ? it.skip : it.failing) : it; + +describeDevice(`testJsCrash() on ${TARGET_NAME}`, () => { + let log: DeviceLog | undefined; + let run: Run; + let crashes: PulledBundle[] = []; + + beforeAll(async () => { + log = await beginRetainingSuite('beta-jscrash'); + if (ON_IOS && iosTarget() === 'simulator') { + return; + } + run = await startRun('cov-jscrash'); + must( + await log.waitFor(new RegExp(`BUGSEE_E2E cov jscrash throwing nonce=${run.scenario.nonce}`), 20_000, run.start), + 'the scenario throwing', + run.start, + ); + if (ON_IOS) { + await new Promise(resolve => setTimeout(resolve, 5_000)); + await stopApp(); + // The next launch is what sends a stored JS fatal on iOS. + await startRun('cov-identity'); + } + // iOS: the relaunch's own upload and the recovered crash. + const bundles = await awaitBundles(ON_IOS ? 2 : 1, 60_000); + report('bundles', bundles.map(b => ({ type: b.request.type, severity: b.request.severity, summary: b.request.summary }))); + crashes = bundles.filter(b => b.request.type === 'crash'); + }); + + afterAll(() => endRetainingSuite(log)); + + itDevice('is reported as one crash: the JS error, unhandled', () => { + expect(crashes).toHaveLength(1); + const crash = crashOf(crashes[0]!)!; + const exception = crash.exception as { name?: string; reason?: string }; + report('crash exception', { name: exception.name, reason: exception.reason?.slice(0, 200), handled: crash.handled }); + expect(exception.name).toMatch(/ReactNativeWebException$/); + const payload = JSON.parse(exception.reason!) as { name?: string; reason?: string }; + expect(payload.name).toBe('Error'); + expect(payload.reason).toBe('Bugsee test JS crash'); + expect(crash.handled).toBe(false); + }); + + itSeverity('is filed at the default crash priority it was launched with', () => { + expect(crashes).toHaveLength(1); + expect(crashes[0]!.request.severity).toBe(2); + }); +}); diff --git a/examples/bare/e2e/launch-options.test.ts b/examples/bare/e2e/launch-options.test.ts new file mode 100644 index 00000000..89c8b20e --- /dev/null +++ b/examples/bare/e2e/launch-options.test.ts @@ -0,0 +1,468 @@ +/** + * API-42, API-43, API-44 (and FLOW-19): launch options proven by what they + * change in a report, not by a read-back alone. + * + * Each case launches scenario `opt-` (scenarios/coverage.ts), which + * sets the case's options through `setCustomOption`, then does the same + * fixed work every case does -- console.log/warn/error, Bugsee.log at levels + * 1-5, a breadcrumb, a transaction, a fetch, four seconds of screen -- and + * uploads one report with no severity. `control` sets nothing: every "off" + * or "scaled" case is read against it, from the same suite run. + * + * Two things are asserted per case: + * 1. read-back: `getLaunchOptions()` reports each value as set. For an enum + * key on Android this is the bridge's outbound table (`wireValue`), the + * inbound one being proven by the effect. + * 2. effect: the report differs from control the way the option says. + * + * The Android enum keys (BugseeOptionEnums.java) are each launched with a + * value whose ORDINAL names a different, valid constant (or none), so an + * ordinal lookup would fail the effect: LogLevel.Error 1 (ordinal 0; + * values()[1] is Warning), FrameRate.Low 1 / High 3, VideoQuality.High 2, + * IssueSeverity.Critical 4 (values()[4] is Blocker) / VeryLow 1 / Medium 2 + * (js-crash.test.ts), VideoMode.None 0 and DirectBuffers 21 (no ordinal 21). + * + * Android 7.3.0 writes the options in effect into every report, + * `environment.sdk.options`, keys with `:` for `.` -- and writes an enum + * option as its constant's ORDINAL (IssueSeverity.Critical, value 4, is + * written 3). That record is the only device-visible witness for the frame + * rate (frames depend on what the screen does), so `androidRecorded` reads + * it with that rule stated. + * + * Options with no effect a device can observe without a human, a backend or + * hardware are listed in the beta-coverage report, not here. + */ +import { type PulledBundle, captureEvents } from './bundles'; +import { iosTarget } from './device'; +import { ON_ANDROID, ON_IOS, type Run, TARGET_NAME, awaitBundles, clearBundles, describeDevice, must, report, startRun, stopApp } from './harness'; +import { imageSize, probeCodec } from './media'; +import { beginRetainingSuite, endRetainingSuite, frameCount, sharpness } from './observe'; +import { type DeviceLog } from './scenario'; + +/** scenarios/coverage.ts POST_BODY, written out again. */ +const POST_BODY = 'cov-body-'.padEnd(64, 'x'); + +jest.setTimeout(10 * 60_000); + +type PlatformName = 'android' | 'ios'; +const PLATFORM: PlatformName = ON_IOS ? 'ios' : 'android'; +const ON_SIMULATOR = ON_IOS && iosTarget() === 'simulator'; + +interface Outcome { + readonly run: Run; + /** Every bundle the run retained. */ + readonly bundles: readonly PulledBundle[]; + /** The case's own upload (`opt--`), when it made exactly one. */ + readonly bundle: PulledBundle | undefined; + /** `getLaunchOptions()` for the case's keys, as the app logged it. */ + readonly effective: Record; +} + +interface Case { + readonly name: string; + readonly platforms: readonly PlatformName[]; + /** The options scenarios/coverage.ts sets for it, written out again here. */ + readonly set: Readonly>; + /** Uploads the case makes. */ + readonly uploads?: number; + readonly effect?: (outcome: Outcome, control: Outcome) => Promise | void; + /** Why the read-back is pinned failing on a platform (a product bug). */ + readonly readbackFails?: Partial>; + /** Why the effect is pinned failing on a platform (a product bug). */ + readonly effectFails?: Partial>; + /** Why the effect cannot be observed on the iOS simulator. */ + readonly simulatorSkip?: string; +} + +function types(bundle: PulledBundle): string[] { + return bundle.manifest.files.map(file => file.type); +} + +function the(outcome: Outcome): PulledBundle { + if (outcome.bundle === undefined) { + throw new Error( + `no single bundle for ${outcome.run.scenario.scenario}; retained: ${JSON.stringify( + outcome.bundles.map(b => b.request.summary), + )}`, + ); + } + return outcome.bundle; +} + +function file(bundle: PulledBundle, type: string): string { + const files = bundle.binaries.get(type) ?? []; + if (files.length !== 1) { + throw new Error(`${bundle.file} has ${files.length} ${type} file(s): ${JSON.stringify(bundle.manifest.files)}`); + } + return files[0]!; +} + +/** The log lines of this run (they carry its nonce). */ +function ownLines(outcome: Outcome): Array> { + const nonce = outcome.run.scenario.nonce; + return captureEvents(the(outcome), 'log').filter(event => String(event.message ?? '').includes(nonce)); +} + +function hasLine(outcome: Outcome, text: string): boolean { + return ownLines(outcome).some(event => event.message === `${text} ${outcome.run.scenario.nonce}`); +} + +/** + * An option as Android 7.3.0 recorded it in the report: dots become colons, + * and an enum is written as its constant's ordinal (see the top of the file). + */ +function androidRecorded(bundle: PulledBundle, key: string): unknown { + const sdk = (bundle.request.environment as { sdk?: { options?: Record } }).sdk; + return sdk?.options?.[key.replace(/\./g, ':')]; +} + +const CASES: readonly Case[] = [ + { + name: 'control', + platforms: ['android', 'ios'], + set: {}, + // FLOW-19 too: an ordinary report carries a decodable video and a + // screenshot, and every capture the "off" cases remove. + effect: async control => { + const { run } = control; + const bundle = the(control); + expect(types(bundle)).toEqual(expect.arrayContaining(['video', 'screenshot', 'log', 'viewtree', 'performance'])); + expect(await probeCodec(file(bundle, 'video'))).toBe('h264'); + expect(await frameCount(file(bundle, 'video'))).toBeGreaterThan(1); + expect(hasLine(control, 'cov-console info')).toBe(true); + expect(hasLine(control, 'cov-log L5')).toBe(true); + expect(bundle.captures.get('performance')).toContain(`cov-txn-${run.scenario.nonce}`); + expect(bundle.captures.get('breadcrumbs') ?? '').not.toContain('cov-crumb'); + expect(networkUrls(bundle)).toContain(fetchUrl(control)); + }, + }, + { + name: 'video-off', + platforms: ['android', 'ios'], + set: { 'com.bugsee.option.capture.video': false }, + effect: outcome => { + expect(types(the(outcome))).not.toContain('video'); + }, + effectFails: { + ios: 'iOS 7.0.0-beta3 and beta4 record capture.video=false (and turns the screenshot off) but still writes a real video into the report', + }, + }, + { + name: 'screenshot-off', + platforms: ['android', 'ios'], + set: { 'com.bugsee.option.capture.screenshot': false }, + effect: outcome => { + expect(types(the(outcome))).not.toContain('screenshot'); + expect(types(the(outcome))).toContain('video'); + }, + }, + { + name: 'logs-off', + platforms: ['android', 'ios'], + set: { 'com.bugsee.option.capture.logs': false }, + // The wrapper's console capture reads this option: no console line may + // reach the report. (Explicit Bugsee.log lines: Android drops the whole + // log, iOS still records them -- reported, not asserted.) + effect: outcome => { + report('logs-off own lines', outcome.bundle === undefined ? [] : ownLines(outcome)); + expect(hasConsoleLine(outcome)).toBe(false); + }, + }, + { + name: 'network-off', + platforms: ['android', 'ios'], + set: { 'com.bugsee.option.capture.network': false }, + effect: outcome => { + expect(types(the(outcome)).includes('network') ? networkUrls(the(outcome)) : []).not.toContain(fetchUrl(outcome)); + }, + }, + { + name: 'vh-off', + platforms: ['android', 'ios'], + set: { 'com.bugsee.option.capture.view-hierarchy': false }, + effect: outcome => { + expect(types(the(outcome))).not.toContain('viewtree'); + }, + }, + { + name: 'perf-off', + platforms: ['android', 'ios'], + set: { 'com.bugsee.option.performance.enabled': false }, + effect: outcome => { + expect(the(outcome).captures.get('performance') ?? '').not.toContain('cov-txn'); + }, + }, + { + name: 'crumbs-on', + platforms: ['android', 'ios'], + set: { 'com.bugsee.option.capture.breadcrumbs': true }, + effect: outcome => { + expect(the(outcome).captures.get('breadcrumbs')).toContain(`cov-crumb ${outcome.run.scenario.nonce}`); + }, + }, + { + name: 'log-level-error', + platforms: ['android', 'ios'], + set: { 'com.bugsee.option.capture.logs.level': 1 }, + // Android filters the platform's own log (logcat, source 3) by level and + // leaves the wrapper's Custom (98) lines alone; iOS filters the Custom + // lines. Each is read against control's levels. + effect: (outcome, control) => { + if (ON_ANDROID) { + const levels = (o: Outcome) => + captureEvents(the(o), 'log').filter(e => e.source === 3).map(e => Number(e.level)); + report('logcat levels', { set: [...new Set(levels(outcome))], control: [...new Set(levels(control))] }); + expect(levels(control).some(level => level > 1)).toBe(true); + expect(levels(outcome).length).toBeGreaterThan(0); + expect(levels(outcome).every(level => level <= 1)).toBe(true); + } else { + const own = ownLines(outcome).map(e => `${String(e.message)}@${String(e.level)}`); + report('own lines', own); + expect(hasLine(control, 'cov-log L5')).toBe(true); + expect(hasLine(outcome, 'cov-log L1')).toBe(true); + expect(hasLine(outcome, 'cov-log L2')).toBe(false); + expect(hasLine(outcome, 'cov-console warn')).toBe(false); + expect(ownLines(outcome).every(e => Number(e.level) <= 1)).toBe(true); + } + }, + }, + { + name: 'frame-rate-low', + platforms: ['android', 'ios'], + set: { 'com.bugsee.option.capture.video.frame-rate': 1 }, + effect: async (outcome, control) => { + if (ON_ANDROID) { + // FrameRate.Low is ordinal 0; values()[1] would be Medium (1). + expect(androidRecorded(the(outcome), 'com.bugsee.option.capture.video.frame-rate')).toBe(0); + } else { + const low = await frameCount(file(the(outcome), 'video')); + const normal = await frameCount(file(the(control), 'video')); + report('frames', { low, control: normal }); + expect(low).toBeLessThan(0.6 * normal); + } + }, + }, + { + name: 'frame-rate-high', + platforms: ['android'], + set: { 'com.bugsee.option.capture.video.frame-rate': 3 }, + // FrameRate.High is ordinal 2; values()[3] would be Raw (3). + effect: outcome => { + expect(androidRecorded(the(outcome), 'com.bugsee.option.capture.video.frame-rate')).toBe(2); + }, + }, + { + name: 'quality-high', + platforms: ['android', 'ios'], + set: { 'com.bugsee.option.capture.video.quality': 2 }, + effect: async (outcome, control) => { + const high = await imageSize(file(the(outcome), 'video')); + const normal = await imageSize(file(the(control), 'video')); + report('video size', { high, control: normal }); + expect(high.width).toBeGreaterThan(normal.width); + expect(high.height).toBeGreaterThan(normal.height); + }, + }, + { + name: 'bug-priority', + platforms: ['android', 'ios'], + set: { 'com.bugsee.option.reporting.defaults.bug-priority': 4 }, + // upload() with no severity takes the default bug priority. + effect: (outcome, control) => { + expect(the(control).request.severity).not.toBe(4); + expect(the(outcome).request.severity).toBe(4); + }, + }, + { + name: 'error-priority', + platforms: ['android', 'ios'], + set: { 'com.bugsee.option.reporting.defaults.error-priority': 1 }, + // The case also calls logException: the error report takes the default. + effect: outcome => { + const errors = outcome.bundles.filter(b => b.request.type === 'error'); + report('error reports', errors.map(b => ({ severity: b.request.severity, file: b.file }))); + expect(errors).toHaveLength(1); + expect(errors[0]!.request.severity).toBe(1); + }, + simulatorSkip: 'the simulator slice of the iOS SDK compiles logException out', + readbackFails: { + android: + 'BugseeOptionEnums.wireValue(IssueSeverity.VeryLow) is 0: IssueSeverity.fromIntValue(int) falls back to VeryLow, ' + + 'so the first value that maps to it is 0, not 1 (and coerce() snaps every unknown severity to VeryLow)', + }, + }, + { + name: 'screenshot-scale', + platforms: ['android', 'ios'], + set: { 'com.bugsee.option.capture.screenshot.scale': 0.25 }, + // A privacy scale: down and back up, same pixel size, blurred. + effect: async (outcome, control) => { + const scaled = await sharpness(file(the(outcome), 'screenshot')); + const normal = await sharpness(file(the(control), 'screenshot')); + report('screenshot sharpness', { scaled, control: normal }); + expect(scaled).toBeLessThan(0.7 * normal); + }, + }, + { + name: 'video-scale', + platforms: ['android', 'ios'], + set: { 'com.bugsee.option.capture.video.scale': 0.25 }, + effect: async (outcome, control) => { + const scaled = await sharpness(file(the(outcome), 'video')); + const normal = await sharpness(file(the(control), 'video')); + report('video sharpness', { scaled, control: normal }); + expect(scaled).toBeLessThan(0.7 * normal); + // The screenshot is a separate option and stays sharp. + expect(await sharpness(file(the(outcome), 'screenshot'))).toBeGreaterThan( + 0.7 * (await sharpness(file(the(control), 'screenshot'))), + ); + }, + }, + { + name: 'video-mode-none', + platforms: ['android'], + set: { 'com.bugsee.option.capture.video.mode': 0 }, + effect: outcome => { + expect(types(the(outcome))).not.toContain('video'); + expect(types(the(outcome))).toContain('screenshot'); + }, + }, + { + name: 'video-mode-direct', + platforms: ['android'], + set: { 'com.bugsee.option.capture.video.mode': 21 }, + // DirectBuffers is ordinal 4; an ordinal lookup has nothing at 21 and + // would leave the default, V2 (ordinal 2). + effect: async outcome => { + expect(androidRecorded(the(outcome), 'com.bugsee.option.capture.video.mode')).toBe(4); + expect(await probeCodec(file(the(outcome), 'video'))).toBe('h264'); + }, + }, + { + name: 'max-pending-reports', + platforms: ['android'], + set: { 'com.bugsee.option.config.max-pending-reports': 1 }, + uploads: 3, + // Three uploads, offline: only the newest may be kept. + effect: outcome => { + const summaries = outcome.bundles.map(b => b.request.summary); + report('retained', summaries); + expect(summaries).toEqual([`opt-max-pending-reports-${outcome.run.scenario.nonce}-3`]); + }, + }, + { + name: 'custom-option', + platforms: ['android', 'ios'], + set: { 'com.bugsee.option.capture.network.body-size-limit': 7 }, + // A key no accessor surfaces, through setCustomOption: network bodies + // are cut at 7 bytes, so the capture shrinks against control. + effect: (outcome, control) => { + const bodies = (o: Outcome) => + captureEvents(the(o), 'network') + .filter(event => event.url === `https://127.0.0.1:9/cov-post/${o.run.scenario.nonce}`) + .map(event => (event.custom as { body?: unknown } | undefined)?.body ?? event.body) + .filter((body): body is string => typeof body === 'string'); + report('cov-post bodies', { limited: bodies(outcome), control: bodies(control) }); + // Control keeps the whole 64-byte body (scenarios/coverage.ts POST_BODY). + expect(bodies(control)).toContain(POST_BODY); + expect(networkUrls(the(outcome))).toContain(`https://127.0.0.1:9/cov-post/${outcome.run.scenario.nonce}`); + expect(bodies(outcome).every(body => body.length <= 7)).toBe(true); + }, + readbackFails: { + ios: 'iOS 7.0.0-beta3 and beta4 drop com.bugsee.option.capture.network.body-size-limit: getLaunchOptions() reports 20480 and the report environment does not list it', + }, + effectFails: { ios: 'as the read-back: the SDK never applies the limit (the POST body is kept whole)' }, + }, +]; + +const outcomes = new Map(); + +/** The URLs in the report's network capture. */ +function networkUrls(bundle: PulledBundle): string[] { + return captureEvents(bundle, 'network').map(event => String(event.url ?? '')); +} + +const fetchUrl = (outcome: Outcome) => `https://127.0.0.1:9/cov-fetch/${outcome.run.scenario.nonce}`; + +function hasConsoleLine(outcome: Outcome): boolean { + if (outcome.bundle === undefined || !types(outcome.bundle).includes('log')) { + return false; + } + return ['cov-console info', 'cov-console warn', 'cov-console error'].some(text => hasLine(outcome, text)); +} + +describeDevice(`launch options and their effects on ${TARGET_NAME}`, () => { + let log: DeviceLog | undefined; + + beforeAll(async () => { + log = await beginRetainingSuite('beta-options'); + }); + + afterAll(() => endRetainingSuite(log)); + + async function runCase(item: Case): Promise { + const cached = outcomes.get(item.name); + if (cached !== undefined) { + return cached; + } + await clearBundles(); + const run = await startRun(`opt-${item.name}`); + const nonce = run.scenario.nonce; + const effectiveLine = must( + await log!.waitFor(new RegExp(`BUGSEE_E2E cov opt effective case=${item.name} nonce=${nonce} .*values=(\\{.*\\})`), 45_000, run.start), + `the effective options of ${item.name}`, + run.start, + ); + must( + await log!.waitFor(new RegExp(`BUGSEE_E2E cov opt uploaded case=${item.name} nonce=${nonce}`), 30_000, run.start), + `${item.name} uploading`, + run.start, + ); + const effective = JSON.parse(/values=(\{.*\})/.exec(effectiveLine.text)![1]!) as Record; + // Every upload, plus the error report error-priority files. + const expected = (item.uploads ?? 1) + (item.name === 'error-priority' && !ON_SIMULATOR ? 1 : 0); + const minimum = item.name === 'max-pending-reports' ? 1 : expected; + let bundles = await awaitBundles(minimum, 60_000); + if (item.name === 'max-pending-reports') { + // Give the evictions time to happen before reading what is left. + await new Promise(resolve => setTimeout(resolve, 8_000)); + bundles = await awaitBundles(1, 1_000); + } + await stopApp(); + const own = bundles.filter(b => b.request.summary === `opt-${item.name}-${nonce}`); + const outcome: Outcome = { run, bundles, bundle: own.length === 1 ? own[0] : undefined, effective }; + report(`${item.name} bundles`, bundles.map(b => ({ summary: b.request.summary, type: b.request.type, files: types(b) }))); + report(`${item.name} effective`, effective); + outcomes.set(item.name, outcome); + return outcome; + } + + for (const item of CASES) { + const here = item.platforms.includes(PLATFORM); + const label = `${item.name} ${JSON.stringify(item.set)}`; + if (!here) { + it.skip(`${label}: ${item.platforms.join('/')} only`, () => {}); + continue; + } + if (item.name !== 'control') { + const readback = item.readbackFails?.[PLATFORM] !== undefined ? it.failing : it; + readback(`${label}: getLaunchOptions() reports it as set${item.readbackFails?.[PLATFORM] ? ` [known: ${item.readbackFails[PLATFORM]}]` : ''}`, async () => { + const outcome = await runCase(item); + expect(outcome.effective).toEqual(item.set); + }); + } + if (item.effect === undefined) { + continue; + } + if (ON_SIMULATOR && item.simulatorSkip !== undefined) { + it.skip(`${label}: changes the report (${item.simulatorSkip})`, () => {}); + continue; + } + const effect = item.effectFails?.[PLATFORM] !== undefined ? it.failing : it; + effect(`${label}: changes the report${item.effectFails?.[PLATFORM] ? ` [known: ${item.effectFails[PLATFORM]}]` : ''}`, async () => { + const control = await runCase(CASES[0]!); + const outcome = await runCase(item); + await item.effect!(outcome, control); + }); + } +}); diff --git a/examples/bare/e2e/lifecycle.test.ts b/examples/bare/e2e/lifecycle.test.ts new file mode 100644 index 00000000..b1f764d8 --- /dev/null +++ b/examples/bare/e2e/lifecycle.test.ts @@ -0,0 +1,128 @@ +/** + * API-03 stop(), API-05 onStatusChange, API-41 deleteCollectedDataOnDevice, + * FLOW-12 the lifecycle events beyond blackout. + * + * Scenario `cov-lifecycle` (scenarios/coverage.ts) subscribes to both + * channels BEFORE launch(), then: upload -> waits for AfterReportAssembled + * -> holds 15 s (this test lists the device in that window) -> stop() -> + * getStatus() -> upload again (must be ignored) -> deleteCollectedDataOnDevice + * (true) -> logs every event and status it saw. + * + * Markers: + * BUGSEE_E2E cov assembled nonce= id= + * BUGSEE_E2E cov stopped nonce= resolved= status= + * BUGSEE_E2E cov deleted nonce= result= + * BUGSEE_E2E cov summary nonce= events=[...] statuses=[...] + * + * What "deleted" means is the SDKs' own: the rolling capture the next + * report would be cut from (`capture/generations`), not reports already + * assembled and waiting to be sent -- Android's deleteCaptureDataOnDisk + * removes every generation's parts and keeps `bundles/`. + */ +import { type PulledBundle } from './bundles'; +import { type Run, TARGET_NAME, awaitBundles, describeDevice, listBundles, must, report, startRun } from './harness'; +import { beginRetainingSuite, captureGenerationFiles, endRetainingSuite } from './observe'; +import { type DeviceLog, type LogLine } from './scenario'; + +jest.setTimeout(6 * 60_000); + +describeDevice(`stop, status, lifecycle events and data deletion on ${TARGET_NAME}`, () => { + let log: DeviceLog | undefined; + let run: Run; + let reportId: string; + let bundlesWhileLaunched: string[]; + let partsWhileLaunched: string[]; + let partsAfterDelete: string[]; + let stopped: LogLine; + let deleted: LogLine; + let events: string[]; + let statuses: number[]; + let finalBundles: PulledBundle[]; + + beforeAll(async () => { + log = await beginRetainingSuite('beta-lifecycle'); + run = await startRun('cov-lifecycle'); + const nonce = run.scenario.nonce; + const assembled = must( + await log.waitFor(new RegExp(`BUGSEE_E2E cov assembled nonce=${nonce} id=(\\S+)`), 45_000, run.start), + 'the upload being assembled', + run.start, + ); + reportId = /id=(\S+)/.exec(assembled.text)![1]!; + // Inside the scenario's 15 s hold: still launched, report on disk. + await new Promise(resolve => setTimeout(resolve, 3_000)); + bundlesWhileLaunched = await listBundles(); + partsWhileLaunched = await captureGenerationFiles(); + stopped = must( + await log.waitFor(new RegExp(`BUGSEE_E2E cov stopped nonce=${nonce} `), 45_000, assembled.index), + 'stop() settling', + run.start, + ); + deleted = must( + await log.waitFor(new RegExp(`BUGSEE_E2E cov deleted nonce=${nonce} `), 30_000, stopped.index), + 'deleteCollectedDataOnDevice settling', + run.start, + ); + partsAfterDelete = await captureGenerationFiles(); + const summary = must( + await log.waitFor(new RegExp(`BUGSEE_E2E cov summary nonce=${nonce} events=(\\[.*\\]) statuses=(\\[.*\\])`), 15_000, deleted.index), + 'the scenario summary', + run.start, + ); + const parsed = /events=(\[.*\]) statuses=(\[.*\])/.exec(summary.text)!; + events = JSON.parse(parsed[1]!) as string[]; + statuses = JSON.parse(parsed[2]!) as number[]; + finalBundles = await awaitBundles(1, 5_000); + report('report id', reportId); + report('bundles while launched', bundlesWhileLaunched); + report('capture parts while launched / after delete', { before: partsWhileLaunched.length, after: partsAfterDelete.length, left: partsAfterDelete.slice(0, 10) }); + report('events', events); + report('statuses', statuses); + report('stopped', stopped.text.trim()); + report('deleted', deleted.text.trim()); + }); + + afterAll(() => endRetainingSuite(log)); + + it('onStatusChange reports Launching, Launched, Stopping, Stopped in order', () => { + expect(statuses).toEqual([1, 2, 3, 0]); + }); + + it('onLifecycleEvent sees launch, the report assembled with its id, then stop, in order', () => { + const order = ['Launching', 'Launched', `BeforeReportAssembled:${reportId}`, `AfterReportAssembled:${reportId}`, 'Stopping', 'Stopped']; + // Upload attempts (iOS's dead endpoint) may come in between; nothing else. + const seen = events.filter(event => order.includes(event)); + expect(seen).toEqual(order); + const other = events.filter(event => !order.includes(event)).map(event => event.split(':')[0]); + expect(other.every(name => /^(BeforeReportUploaded|ReportUploadFailed|ReportUploadFailedWithFutureRetry)$/.test(name!))).toBe(true); + }); + + it('the assembled report is the bundle on disk', () => { + expect(bundlesWhileLaunched).toEqual([`${reportId}.bundle.zip`]); + }); + + it('stop() resolves true and getStatus() is then Stopped', () => { + expect(stopped.text).toMatch(/resolved=true status=0\b/); + }); + + it('an upload after stop() files nothing', () => { + const summaries = finalBundles.map(b => b.request.summary); + expect(summaries).toEqual([`cov-lifecycle-${run.scenario.nonce}`]); + }); + + /** + * Android 7.3.0: deleteCaptureDataOnDisk calls + * `removeOtherGenerationsFilesSync(-1)`, which keeps every generation + * numbered `>= -1` -- all of them (they are timestamps) -- and resolves + * true. Seen on the WOD_LX1: 30 part files before, 69 after "deleted". + * iOS 7.0.0-beta4: `clearGenerationsFolder` empties the legacy generations + * folder, which the nextgen capture no longer writes; the parts under + * `capture/generations` stay (simulator: 18 before, 66 after). + */ + const itDelete = it.failing; + itDelete('deleteCollectedDataOnDevice(true) after stop resolves true and removes the rolling capture', () => { + expect(deleted.text).toMatch(/result=true\b/); + expect(partsWhileLaunched.length).toBeGreaterThan(0); + expect(partsAfterDelete).toEqual([]); + }); +}); diff --git a/examples/bare/e2e/observe.ts b/examples/bare/e2e/observe.ts new file mode 100644 index 00000000..76776b56 --- /dev/null +++ b/examples/bare/e2e/observe.ts @@ -0,0 +1,220 @@ +/** + * What the beta-coverage suites read off a device beyond a report bundle: + * the screen itself (a capture, its colors and its text), how sharp an + * image is, and which capture files the SDK keeps on disk. + * + * Every spawn passes an argument array, never a shell string, and output + * this module cannot parse throws rather than passing for an answer (as + * media.ts does). + */ +import { execFile } from 'node:child_process'; +import { existsSync, readdirSync, statSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +import { airplane, newPulledRoot, removePulledBundles } from './bundles'; +import { ADB, ANDROID_PACKAGE, ANDROID_SERIAL, IOS_BUNDLE_ID, IOS_SIMULATOR_ID, iosTarget } from './device'; +import { ON_IOS, clearBundles, report, startDeviceLog, stopApp, stopDeviceLog } from './harness'; +import { imageSize } from './media'; +import { type DeviceLog, adbStatus, devicectl, resetScenario } from './scenario'; + +const execFileAsync = promisify(execFile); + +/** + * A PNG of the device's screen as it is now, in a temp root that + * `removePulledBundles` deletes. Android: `screencap`; the simulator: + * `simctl io screenshot`; the iPhone: `devicectl device capture screenshot` + * (the allowlisted, identity-checked device only). + */ +export async function captureScreen(label: string): Promise { + const file = join(newPulledRoot(), `${label}.png`); + if (!ON_IOS) { + const { stdout } = await execFileAsync(ADB, ['-s', ANDROID_SERIAL, 'exec-out', 'screencap', '-p'], { + encoding: 'buffer', + maxBuffer: 64 * 1024 * 1024, + }); + writeFileSync(file, stdout); + } else if (iosTarget() === 'simulator') { + await execFileAsync('xcrun', ['simctl', 'io', IOS_SIMULATOR_ID, 'screenshot', '--type=png', file]); + } else { + await devicectl('capture', 'screenshot', '--destination', file); + } + if (!existsSync(file) || statSync(file).size === 0) { + throw new Error(`captureScreen: no screenshot was written to ${file}`); + } + return file; +} + +/** `file`'s frames (all of a video, the one of a still) as 8-bit gray planes. */ +async function grayFrames(file: string): Promise<{ width: number; height: number; frames: Buffer[] }> { + const { width, height } = await imageSize(file); + const { stdout } = await execFileAsync( + 'ffmpeg', + ['-v', 'error', '-i', file, '-fps_mode', 'passthrough', '-pix_fmt', 'gray', '-f', 'rawvideo', '-'], + { encoding: 'buffer', maxBuffer: 512 * 1024 * 1024 }, + ); + const size = width * height; + if (stdout.length === 0 || stdout.length % size !== 0) { + throw new Error(`grayFrames: ${file}: ${stdout.length} byte(s) is not a whole number of ${width}x${height} frames`); + } + const frames: Buffer[] = []; + for (let at = 0; at < stdout.length; at += size) { + frames.push(stdout.subarray(at, at + size)); + } + return { width, height, frames }; +} + +/** + * How sharp `file` is: the mean absolute difference between horizontally + * adjacent pixels (0-255), of its sharpest frame. The SDKs' privacy scale + * (`capture.screenshot.scale` / `capture.video.scale` below 1) downscales and + * scales back up, at the same pixel size, so its effect is exactly this + * number dropping: text and edges smear. The sharpest frame, because a + * video opens on black frames that have no edges at all. + */ +export async function sharpness(file: string): Promise { + const { width, height, frames } = await grayFrames(file); + let best = 0; + for (const frame of frames) { + let sum = 0; + for (let y = 0; y < height; y += 1) { + const row = y * width; + for (let x = 0; x < width - 1; x += 1) { + sum += Math.abs(frame[row + x + 1]! - frame[row + x]!); + } + } + best = Math.max(best, sum / (height * (width - 1))); + } + return best; +} + +/** How many frames a video decodes to. */ +export async function frameCount(file: string): Promise { + return (await grayFrames(file)).frames.length; +} + +/** + * The lines of text macOS's Vision framework reads off `image` + * (e2e/ocr.swift, run with the system `swift`): the iOS stand-in for + * uiautomator, which has no counterpart on an iPhone or the simulator. + */ +export async function ocrLines(image: string): Promise { + const { stdout } = await execFileAsync('swift', [join(__dirname, 'ocr.swift'), image], { + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + timeout: 180_000, + }); + return stdout.split('\n').map(line => line.trim()).filter(line => line !== ''); +} + +/** The activity Android reports as top-resumed (`/`), or undefined. */ +export async function androidTopActivity(): Promise { + const { output } = await adbStatus('shell', 'dumpsys', 'activity', 'activities'); + const line = /topResumedActivity=ActivityRecord\{\S+ \S+ (\S+)/.exec(output) ?? /mResumedActivity: ActivityRecord\{\S+ \S+ (\S+)/.exec(output); + return line?.[1]; +} + +/** The capture parts the SDK keeps between reports, relative to its data root. */ +const ANDROID_GENERATIONS = 'files/bugsee_data/capture/generations'; +const IOS_GENERATIONS = 'Library/Caches/com.bugsee.data/capture/generations'; + +/** + * Every file under the SDK's capture generations directory -- the rolling + * capture a report is cut from, which `deleteCollectedDataOnDevice` exists + * to remove -- relative to it. Empty when the directory does not exist. + */ +export async function captureGenerationFiles(): Promise { + if (!ON_IOS) { + const { output } = await adbStatus('shell', 'run-as', ANDROID_PACKAGE, 'find', ANDROID_GENERATIONS, '-type', 'f'); + if (/No such file or directory/.test(output)) { + return []; + } + return output + .split('\n') + .map(line => line.trim()) + .filter(line => line.startsWith(ANDROID_GENERATIONS)) + .map(line => line.slice(ANDROID_GENERATIONS.length + 1)); + } + if (iosTarget() === 'simulator') { + const { stdout } = await execFileAsync( + 'xcrun', + ['simctl', 'get_app_container', IOS_SIMULATOR_ID, IOS_BUNDLE_ID, 'data'], + { encoding: 'utf8' }, + ); + const root = join(stdout.trim(), IOS_GENERATIONS); + return existsSync(root) ? walk(root, '') : []; + } + let result: Record; + try { + result = await devicectl( + 'info', + 'files', + '--domain-type', + 'appDataContainer', + '--domain-identifier', + IOS_BUNDLE_ID, + '--subdirectory', + IOS_GENERATIONS, + ); + } catch (error) { + if (/failed to get a list of files/.test(String(error))) { + return []; + } + throw error; + } + const files = (result.files ?? []) as Array<{ relativePath?: string; name?: string; isDirectory?: boolean }>; + return files + .filter(file => file.isDirectory !== true && !(file.relativePath ?? file.name ?? '').endsWith('/')) + .map(file => file.relativePath ?? file.name ?? '') + .filter(path => path !== ''); +} + +function walk(dir: string, prefix: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`; + if (entry.isDirectory()) { + out.push(...walk(join(dir, entry.name), rel)); + } else { + out.push(rel); + } + } + return out; +} + +/** + * The start every retaining suite here shares: the device log, the + * network cut (airplane mode on Android; iOS launches against the dead + * endpoint, harness.ts) and no report left from an earlier run. + */ +export async function beginRetainingSuite(tag: string): Promise { + const log = await startDeviceLog(tag, tag); + if (!ON_IOS) { + await airplane(true); + } + await clearBundles(); + return log; +} + +/** + * The end every retaining suite shares, each step attempted whatever the + * one before did: stop the app, drop what it retained, bring the network + * back (the handset is shared), delete pulled copies, stop the log. + */ +export async function endRetainingSuite(log: DeviceLog | undefined): Promise { + const steps: Array<() => Promise | unknown> = [ + () => stopApp(), + () => clearBundles(), + () => (ON_IOS ? undefined : airplane(false)), + () => report('pulled bundle roots', removePulledBundles()), + () => stopDeviceLog(log), + () => resetScenario(), + ]; + for (const step of steps) { + try { + await step(); + } catch (error) { + report('cleanup step failed', String(error)); + } + } +} diff --git a/examples/bare/e2e/ocr.swift b/examples/bare/e2e/ocr.swift new file mode 100644 index 00000000..f692bc59 --- /dev/null +++ b/examples/bare/e2e/ocr.swift @@ -0,0 +1,32 @@ +// Prints the lines of text macOS's Vision framework reads off an image, one +// per line: `swift ocr.swift `. Used by observe.ts (`ocrLines`) where +// iOS has no accessibility dump to read, as a screen's ground truth. +import AppKit +import Foundation +import Vision + +guard CommandLine.arguments.count == 2 else { + FileHandle.standardError.write("usage: swift ocr.swift \n".data(using: .utf8)!) + exit(64) +} +let url = URL(fileURLWithPath: CommandLine.arguments[1]) +guard let image = NSImage(contentsOf: url), + let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + FileHandle.standardError.write("ocr.swift: cannot read \(url.path)\n".data(using: .utf8)!) + exit(65) +} +let request = VNRecognizeTextRequest() +request.recognitionLevel = .accurate +// The text under test is a nonce, not a word: correction would "fix" it. +request.usesLanguageCorrection = false +do { + try VNImageRequestHandler(cgImage: cgImage, options: [:]).perform([request]) +} catch { + FileHandle.standardError.write("ocr.swift: \(error)\n".data(using: .utf8)!) + exit(70) +} +for observation in request.results ?? [] { + if let best = observation.topCandidates(1).first { + print(best.string) + } +} diff --git a/examples/bare/e2e/report-dialog-appearance.test.ts b/examples/bare/e2e/report-dialog-appearance.test.ts new file mode 100644 index 00000000..b29a86ac --- /dev/null +++ b/examples/bare/e2e/report-dialog-appearance.test.ts @@ -0,0 +1,52 @@ +/** + * API-40 / FLOW-21: `Bugsee.appearance` repaints the report dialog, read off + * the screen rather than back from the SDK (appearance.test.ts does that). + * + * Scenario `cov-dialog-color` (scenarios/coverage.ts) sets the report + * background to opaque pure red, reads it back, and opens the report dialog + * with `showReportDialog`. The app's own screen has no pure red at all, so + * a screen capture with a large pure-red area is the dialog wearing the + * color. + * Nothing taps the dialog; Android closes it with the Back key afterwards. + */ +import { ON_ANDROID, type Run, TARGET_NAME, describeDevice, must, report, startRun } from './harness'; +import { colourOf, colourPixels } from './media'; +import { beginRetainingSuite, captureScreen, endRetainingSuite } from './observe'; +import { type DeviceLog, adbStatus } from './scenario'; + +jest.setTimeout(5 * 60_000); + +describeDevice(`the report dialog in the appearance colors on ${TARGET_NAME}`, () => { + let log: DeviceLog | undefined; + + beforeAll(async () => { + log = await beginRetainingSuite('beta-dialog'); + }); + + afterAll(async () => { + if (ON_ANDROID) { + await adbStatus('shell', 'input', 'keyevent', 'KEYCODE_BACK'); + } + await endRetainingSuite(log); + }); + + it('the dialog is painted in the background color set before it opened', async () => { + const run: Run = await startRun('cov-dialog-color'); + const shown = must( + await log!.waitFor(new RegExp(`BUGSEE_E2E cov dialog shown nonce=${run.scenario.nonce} background=(\\S+)`), 20_000, run.start), + 'the dialog being opened', + run.start, + ); + expect(shown.text).toContain('background=#ff0000ff'); + // The dialog animates in. + await new Promise(resolve => setTimeout(resolve, 5_000)); + const shot = await captureScreen('report-dialog'); + const red = await colourPixels(shot, colourOf('#ff0000'), 24); + report('red pixels', { count: red.count, of: red.width * red.height, box: red.box }); + // Android paints the whole dialog body; iOS paints the background the + // grouped cells sit on (their own color is `cellBackgroundColor`), which + // shows as the bands between the cells and the area below them -- about + // a tenth of the screen on the iPhone 17 Pro simulator. + expect(red.count).toBeGreaterThan((ON_ANDROID ? 0.3 : 0.05) * red.width * red.height); + }); +}); diff --git a/examples/bare/e2e/span-lifecycle.test.ts b/examples/bare/e2e/span-lifecycle.test.ts new file mode 100644 index 00000000..5f802106 --- /dev/null +++ b/examples/bare/e2e/span-lifecycle.test.ts @@ -0,0 +1,105 @@ +/** + * API-18 getActiveSpan, API-19 span setName/setDescription/finish(status): + * what the report's `performance` capture says after each, on top of + * apm.test.ts's start/attribute/finish. + * + * Scenario `cov-apm` (scenarios/coverage.ts): + * txn = startTransaction('txn-start-', 'cov.flow'); getActiveSpan() === txn + * txn.setName('txn-renamed-'); txn.setDescription('txn-desc-') + * child = startSpan('cov.child', 'child-start-'); getActiveSpan() === child + * child.setDescription('child-desc-'); child.setStatus(Cancelled); child.finish() + * second = txn.startChildSpan('cov.second', 'second-'); second.finish(DeadlineExceeded) + * txn.setStatus(Error); txn.finish(); getActiveSpan() is null; upload + * + * `setStatus` followed by a no-argument `finish()` ends OK on both SDKs -- + * the documented meaning of the no-arg finish (span.ts) -- so a status set + * that way is not in the report and is not asserted; `finish(status)` is. + */ +import { type PulledBundle } from './bundles'; +import { ON_IOS, type Run, TARGET_NAME, awaitBundles, describeDevice, must, report, startRun } from './harness'; +import { beginRetainingSuite, endRetainingSuite } from './observe'; +import { type DeviceLog } from './scenario'; + +jest.setTimeout(5 * 60_000); + +interface Span { + spanId?: string; + parentSpanId?: string; + operation?: string; + description?: string; + status?: string; +} +interface Transaction { + name?: string; + operation?: string; + status?: string; + spans?: Span[]; +} + +/** + * span.ts documents `setName` as the native setName, "which sets the + * operation on both SDKs". Android does; iOS 7.0.0-beta4 renames the + * transaction instead and leaves the operation `cov.flow`. + */ +const itOperation = ON_IOS ? it.failing : it; + +describeDevice(`span names, descriptions, statuses and the active span on ${TARGET_NAME}`, () => { + let log: DeviceLog | undefined; + let run: Run; + let nonce: string; + let transaction: Transaction; + + beforeAll(async () => { + log = await beginRetainingSuite('beta-span'); + run = await startRun('cov-apm'); + nonce = run.scenario.nonce; + must( + await log.waitFor(new RegExp(`BUGSEE_E2E cov apm finished nonce=${nonce}`), 20_000, run.start), + 'the transaction finishing', + run.start, + ); + const bundles: PulledBundle[] = await awaitBundles(1); + const bundle = bundles.find(b => b.request.summary === `cov-apm-${nonce}`); + expect(bundle).toBeDefined(); + const capture = JSON.parse(bundle!.captures.get('performance') ?? '{"transactions":[]}') as { transactions: Transaction[] }; + const mine = capture.transactions.filter(t => JSON.stringify(t).includes(`txn-desc-${nonce}`)); + report('transaction', mine); + expect(mine).toHaveLength(1); + transaction = mine[0]!; + }); + + afterAll(() => endRetainingSuite(log)); + + function spanBy(predicate: (span: Span) => boolean): Span { + const found = (transaction.spans ?? []).filter(predicate); + expect(found).toHaveLength(1); + return found[0]!; + } + + const root = () => spanBy(span => span.parentSpanId === undefined); + + it('getActiveSpan() is the transaction, then the child, then null once finished', () => { + expect(log!.all(new RegExp(`BUGSEE_E2E cov apm active nonce=${nonce} same=true`), run.start)).toHaveLength(1); + expect(log!.all(new RegExp(`BUGSEE_E2E cov apm active-child nonce=${nonce} same=true`), run.start)).toHaveLength(1); + expect(log!.all(new RegExp(`BUGSEE_E2E cov apm finished nonce=${nonce} active-after=null`), run.start)).toHaveLength(1); + }); + + it('setDescription on the transaction and on a child replaces the description', () => { + expect(root().description).toBe(`txn-desc-${nonce}`); + const child = spanBy(span => span.operation === 'cov.child'); + expect(child.description).toBe(`child-desc-${nonce}`); + expect(child.parentSpanId).toBe(root().spanId); + }); + + it('finish(status) ends the span with that status', () => { + const second = spanBy(span => span.operation === 'cov.second'); + expect(second.description).toBe(`second-${nonce}`); + expect(second.status).toBe('DEADLINE_EXCEEDED'); + expect(second.parentSpanId).toBe(root().spanId); + }); + + itOperation('setName sets the operation, as span.ts documents', () => { + expect(root().operation).toBe(`txn-renamed-${nonce}`); + expect(transaction.operation).toBe(`txn-renamed-${nonce}`); + }); +}); diff --git a/examples/bare/e2e/wrapper-identity.test.ts b/examples/bare/e2e/wrapper-identity.test.ts new file mode 100644 index 00000000..0c4be142 --- /dev/null +++ b/examples/bare/e2e/wrapper-identity.test.ts @@ -0,0 +1,68 @@ +/** + * API-45: the wrapper identity the SDK writes into a report. + * + * `launch()` registers the wrapper (`setWrapperInfo`) before the native + * launch; both SDKs then put it under `environment.sdk.wrapper` in every + * report: `type`, `version`, and a string context naming the React Native + * version, the JS engine and the build configuration. Unit tests prove JS + * builds that object; only a report proves it crossed and the SDK kept it. + * + * The expected values are read here from the packages on disk, not from the + * app: `@bugsee/react-native`'s package.json version, the React Native the + * example resolves, Hermes (the example's engine on both platforms), and + * debug or release from the build the app reports it is (`dev=`). + * + * Scenario `cov-identity` (scenarios/coverage.ts) uploads one report. + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { type PulledBundle } from './bundles'; +import { type Run, TARGET_NAME, awaitBundles, describeDevice, must, report, startRun } from './harness'; +import { beginRetainingSuite, endRetainingSuite } from './observe'; +import { type DeviceLog } from './scenario'; + +jest.setTimeout(5 * 60_000); + +function versionOf(packageJson: string): string { + return (JSON.parse(readFileSync(packageJson, 'utf8')) as { version: string }).version; +} + +const WRAPPER_VERSION = versionOf(join(__dirname, '..', '..', '..', 'packages', 'react-native', 'package.json')); +const REACT_NATIVE_VERSION = versionOf(require.resolve('react-native/package.json', { paths: [join(__dirname, '..')] })); + +describeDevice(`the wrapper identity in a report on ${TARGET_NAME}`, () => { + let log: DeviceLog | undefined; + let run: Run; + let bundle: PulledBundle; + + beforeAll(async () => { + log = await beginRetainingSuite('beta-identity'); + run = await startRun('cov-identity'); + must( + await log.waitFor(new RegExp(`BUGSEE_E2E cov identity uploaded nonce=${run.scenario.nonce}`), 20_000, run.start), + 'the identity report being uploaded', + run.start, + ); + const bundles = await awaitBundles(1); + const mine = bundles.filter(b => b.request.summary === `cov-identity-${run.scenario.nonce}`); + expect(mine).toHaveLength(1); + bundle = mine[0]!; + }); + + afterAll(() => endRetainingSuite(log)); + + it('names this wrapper, its version, React Native, Hermes and the build configuration', () => { + const sdk = (bundle.request.environment as { sdk?: Record }).sdk; + report('environment.sdk.wrapper', sdk?.wrapper); + expect(sdk?.wrapper).toEqual({ + type: 'react_native', + version: WRAPPER_VERSION, + context: { + 'react-native': REACT_NATIVE_VERSION, + 'js-engine': 'hermes', + 'build-configuration': run.dev ? 'debug' : 'release', + }, + }); + }); +}); diff --git a/examples/bare/scenarios/coverage.ts b/examples/bare/scenarios/coverage.ts new file mode 100644 index 00000000..774b7368 --- /dev/null +++ b/examples/bare/scenarios/coverage.ts @@ -0,0 +1,328 @@ +/** + * The beta-coverage scenarios: what the API/device-coverage matrix + * (api-device-coverage.md) listed as automatable but unproven on a device. + * + * cov-identity one upload; the test reads the wrapper identity the SDK + * wrote into the bundle's environment (API-45). + * cov-lifecycle subscribes to onLifecycleEvent and onStatusChange before + * launch(), uploads, waits for the report to be assembled, + * then stops the SDK, uploads again (must be ignored) and + * deletes the collected data (API-03, API-05, API-41, + * FLOW-12). + * cov-apm a transaction whose name, description and status are + * changed after it starts, getActiveSpan(), and a child + * span finished with a status (API-18, API-19). + * cov-jscrash Bugsee.testJsCrash() thrown outside any try, so the + * global handler reports it as a crash (API-10). + * cov-dialog-color a report background color, then the report dialog, + * which the test screenshots (API-40, FLOW-21). + * opt- launches with the option set `OPTION_CASES[]` + * names, does the same fixed work every case does, and + * uploads (API-42, API-43, API-44). See launchOverrides. + * + * Every name, message and summary carries the run's nonce. Markers are + * `BUGSEE_E2E cov ...`. + */ +import { Platform } from 'react-native'; +import type { + Status} from '@bugsee/react-native'; +import Bugsee, { + type LifecycleEvent, + LogLevel, + SpanStatus +} from '@bugsee/react-native'; + +import { deadEndpointUrl } from '../endpoint'; + +export const COVERAGE_SCENARIOS = [ + 'cov-identity', + 'cov-lifecycle', + 'cov-apm', + 'cov-jscrash', + 'cov-dialog-color', +] as const; + +/** + * The launch-option cases. Each value is the option set the case adds to + * the app's own (endpoint and duration=90, App.tsx). Values are the + * numbers that cross the bridge; for an enum key that is the enum's + * internal value, which the Android bridge must convert through its + * enum table (BugseeOptionEnums.java), never by ordinal. + * + * The test (e2e/launch-options.test.ts) holds its own copy of each set and + * of what each must change in the report. Platform-only keys are only ever + * launched on their platform. + */ +export const OPTION_CASES = { + /** Everything at the SDK's defaults: the control every "off" case is read against. */ + control: {}, + 'video-off': { 'com.bugsee.option.capture.video': false }, + 'screenshot-off': { 'com.bugsee.option.capture.screenshot': false }, + 'logs-off': { 'com.bugsee.option.capture.logs': false }, + 'network-off': { 'com.bugsee.option.capture.network': false }, + 'vh-off': { 'com.bugsee.option.capture.view-hierarchy': false }, + 'perf-off': { 'com.bugsee.option.performance.enabled': false }, + 'crumbs-on': { 'com.bugsee.option.capture.breadcrumbs': true }, + /** LogLevel.Error (internal 1, ordinal 0 on Android). */ + 'log-level-error': { 'com.bugsee.option.capture.logs.level': LogLevel.Error }, + /** FrameRate.Low (1; ordinal 0) and High (3; ordinal 2). */ + 'frame-rate-low': { 'com.bugsee.option.capture.video.frame-rate': 1 }, + 'frame-rate-high': { 'com.bugsee.option.capture.video.frame-rate': 3 }, + /** VideoQuality.High (2; the one enum whose value is its ordinal). */ + 'quality-high': { 'com.bugsee.option.capture.video.quality': 2 }, + /** + * IssueSeverity.Critical (4) as the default bug priority. An ordinal + * lookup would pick values()[4], Blocker (5): a valid, different severity. + */ + 'bug-priority': { 'com.bugsee.option.reporting.defaults.bug-priority': 4 }, + /** IssueSeverity.VeryLow (1; ordinal 0) as the default error priority. */ + 'error-priority': { 'com.bugsee.option.reporting.defaults.error-priority': 1 }, + 'screenshot-scale': { 'com.bugsee.option.capture.screenshot.scale': 0.25 }, + 'video-scale': { 'com.bugsee.option.capture.video.scale': 0.25 }, + /** Android VideoMode.None (0) -- an enum key, Android only. */ + 'video-mode-none': { 'com.bugsee.option.capture.video.mode': 0 }, + /** + * Android VideoMode.DirectBuffers (21; ordinal 4). An ordinal lookup has no + * constant at 21, so the option would be dropped and V2 stay in effect. + */ + 'video-mode-direct': { 'com.bugsee.option.capture.video.mode': 21 }, + 'max-pending-reports': { 'com.bugsee.option.config.max-pending-reports': 1 }, + /** setCustomOption with a key no accessor surfaces. */ + 'custom-option': { 'com.bugsee.option.capture.network.body-size-limit': 7 }, +} as const satisfies Record>; + +export type OptionCase = keyof typeof OPTION_CASES; + +const OPTION_PREFIX = 'opt-'; + +export type CoverageScenario = (typeof COVERAGE_SCENARIOS)[number]; + +export function isCoverageScenario(name: string): boolean { + return ( + (COVERAGE_SCENARIOS as readonly string[]).includes(name) || + optionCaseOf(name) !== undefined + ); +} + +export function optionCaseOf(scenario: string): OptionCase | undefined { + if (!scenario.startsWith(OPTION_PREFIX)) { + return undefined; + } + const name = scenario.slice(OPTION_PREFIX.length); + return Object.prototype.hasOwnProperty.call(OPTION_CASES, name) + ? (name as OptionCase) + : undefined; +} + +/** 64 bytes of request body the network capture keeps, or cuts. */ +export const POST_BODY = 'cov-body-'.padEnd(64, 'x'); + +function mark(message: string): void { + console.log(`BUGSEE_E2E cov ${message}`); +} + +/** + * The option set the scenario launches with, written onto `options` (the + * app's serialized launch payload). `opt-custom-option` goes through + * `setCustomOption` on the typed model rather than the plain map, so the + * accessor path is what reaches the bridge. + */ +export function launchOverrides( + scenario: string, + apply: (key: string, value: unknown) => void, +): void { + const optionCase = optionCaseOf(scenario); + const set: Record | undefined = + optionCase !== undefined ? OPTION_CASES[optionCase] : SCENARIO_OPTIONS[scenario]; + for (const [key, value] of Object.entries(set ?? {})) { + apply(key, value); + } +} + +/** + * Options the cov-* scenarios launch with. cov-jscrash sets the default + * crash priority to IssueSeverity.Medium (2): an ordinal lookup would pick + * values()[2], High (3), and the SDK's own default is neither. + */ +export const SCENARIO_OPTIONS: Readonly>> = { + 'cov-jscrash': { 'com.bugsee.option.reporting.defaults.crash-priority': 2 }, +}; + +/** Every lifecycle event and status the scenario saw, in order. */ +const seenEvents: string[] = []; +const seenStatuses: number[] = []; +let assembled: ((id: string) => void) | undefined; + +/** Before launch(): the lifecycle subscriptions cov-lifecycle asserts. */ +export function preLaunchCoverage(scenario: string, nonce: string): void { + if (scenario !== 'cov-lifecycle') { + return; + } + Bugsee.onLifecycleEvent((event: LifecycleEvent) => { + const id = event.reportId ?? ''; + seenEvents.push(id === '' ? event.name : `${event.name}:${id}`); + mark(`lifecycle nonce=${nonce} event=${event.name} report=${id === '' ? '-' : id}`); + if (event.name === 'AfterReportAssembled' && id !== '') { + assembled?.(id); + } + }); + Bugsee.onStatusChange((status: Status) => { + seenStatuses.push(status); + mark(`status-change nonce=${nonce} status=${status}`); + }); + mark(`subscribed nonce=${nonce}`); +} + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function runLifecycle(nonce: string): Promise { + const reportId = new Promise(resolve => { + assembled = resolve; + }); + Bugsee.upload(`cov-lifecycle-${nonce}`, ''); + const id = await Promise.race([reportId, delay(30_000).then(() => 'timeout')]); + mark(`assembled nonce=${nonce} id=${id}`); + // The test lists the retained bundles in this window: the one just + // assembled must be on disk before the deletion below. + await delay(15_000); + const stopped = await Bugsee.stop(); + const statusAfterStop = await Bugsee.getStatus(); + mark(`stopped nonce=${nonce} resolved=${String(stopped)} status=${statusAfterStop}`); + // Ignored: the SDK is stopped. A bundle with this summary is the failure. + Bugsee.upload(`cov-after-stop-${nonce}`, ''); + Bugsee.log(`cov after-stop line ${nonce}`); + await delay(5_000); + const deleted = await Bugsee.deleteCollectedDataOnDevice(true); + mark(`deleted nonce=${nonce} result=${String(deleted)}`); + await delay(1_000); + mark( + `summary nonce=${nonce} events=${JSON.stringify(seenEvents)} statuses=${JSON.stringify(seenStatuses)}`, + ); +} + +function runApm(nonce: string): void { + const transaction = Bugsee.startTransaction(`txn-start-${nonce}`, 'cov.flow'); + const active = Bugsee.getActiveSpan(); + mark(`apm active nonce=${nonce} same=${String(active === transaction)}`); + transaction.setName(`txn-renamed-${nonce}`); + transaction.setDescription(`txn-desc-${nonce}`); + const child = Bugsee.startSpan('cov.child', `child-start-${nonce}`); + const activeChild = Bugsee.getActiveSpan(); + mark(`apm active-child nonce=${nonce} same=${String(activeChild === child)}`); + child.setDescription(`child-desc-${nonce}`); + child.setStatus(SpanStatus.Cancelled); + child.finish(); + const second = transaction.startChildSpan('cov.second', `second-${nonce}`); + second.finish(SpanStatus.DeadlineExceeded); + transaction.setStatus(SpanStatus.Error); + transaction.finish(); + const afterFinish = Bugsee.getActiveSpan(); + mark(`apm finished nonce=${nonce} active-after=${afterFinish === null ? 'null' : 'span'}`); + Bugsee.upload(`cov-apm-${nonce}`, ''); +} + +function runJsCrash(nonce: string): void { + mark(`jscrash throwing nonce=${nonce}`); + // Outside any try and off this turn, so only the global handler sees it. + setTimeout(() => { + Bugsee.testJsCrash(); + }, 0); +} + +/** Pure red, opaque: nothing else on the dialog or the app is this color. */ +export const DIALOG_COLOR = '#ff0000ff'; + +function runDialogColor(nonce: string): void { + Bugsee.appearance.backgroundColor = DIALOG_COLOR; + const read = Bugsee.appearance.backgroundColor ?? 'unread'; + Bugsee.showReportDialog(`cov-dialog-${nonce}`, ''); + mark(`dialog shown nonce=${nonce} background=${read}`); +} + +/** + * What every `opt-` run does, whatever the options: one line through + * each log route, a breadcrumb, a transaction, a fetch the network capture + * can see (no "bugsee" in its URL: the iOS SDK drops those), a few + * seconds of screen for the video, then the effective options and an + * upload with no severity (so the default bug priority applies). + */ +async function runOptionCase(optionCase: OptionCase, nonce: string): Promise { + console.log(`cov-console info ${nonce}`); + console.warn(`cov-console warn ${nonce}`); + console.error(`cov-console error ${nonce}`); + for (let level = 1; level <= 5; level += 1) { + Bugsee.log(`cov-log L${level} ${nonce}`, level as LogLevel); + } + Bugsee.addBreadcrumb({ + category: 'cov', + level: 'info', + message: `cov-crumb ${nonce}`, + type: 'user', + }); + const transaction = Bugsee.startTransaction(`cov-txn-${nonce}`, 'cov.flow'); + transaction.finish(); + try { + await fetch(deadEndpointUrl(`cov-fetch/${nonce}`)); + } catch { + // The URL is closed; the request is what the capture records. + } + // A request body longer than the custom-option case's 7-byte limit. + try { + await fetch(deadEndpointUrl(`cov-post/${nonce}`), { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: POST_BODY, + }); + } catch { + // As above. + } + if (optionCase === 'error-priority') { + Bugsee.logException(new Error(`cov-error ${nonce}`)); + } + await delay(4_000); + const keys = Object.keys(OPTION_CASES[optionCase]); + const effective = await Bugsee.getLaunchOptions(); + const picked = Object.fromEntries(keys.map(key => [key, effective[key] ?? null])); + mark( + `opt effective case=${optionCase} nonce=${nonce} platform=${Platform.OS} ` + + `keys=${Object.keys(effective).length} values=${JSON.stringify(picked)}`, + ); + const uploads = optionCase === 'max-pending-reports' ? 3 : 1; + for (let i = 1; i <= uploads; i += 1) { + const summary = uploads === 1 ? `opt-${optionCase}-${nonce}` : `opt-${optionCase}-${nonce}-${i}`; + Bugsee.upload(summary, ''); + if (i < uploads) { + await delay(3_000); + } + } + mark(`opt uploaded case=${optionCase} nonce=${nonce} count=${uploads}`); +} + +/** What the scenario does once the SDK is Launched. */ +export async function runCoverageScenario(scenario: string, nonce: string): Promise { + const optionCase = optionCaseOf(scenario); + if (optionCase !== undefined) { + await runOptionCase(optionCase, nonce); + return; + } + switch (scenario) { + case 'cov-identity': + Bugsee.upload(`cov-identity-${nonce}`, ''); + mark(`identity uploaded nonce=${nonce}`); + return; + case 'cov-lifecycle': + await runLifecycle(nonce); + return; + case 'cov-apm': + runApm(nonce); + return; + case 'cov-jscrash': + runJsCrash(nonce); + return; + case 'cov-dialog-color': + runDialogColor(nonce); + return; + } +} diff --git a/examples/bare/scenarios/feedback.ts b/examples/bare/scenarios/feedback.ts index c64fa5e9..eff003e7 100644 --- a/examples/bare/scenarios/feedback.ts +++ b/examples/bare/scenarios/feedback.ts @@ -5,6 +5,7 @@ * listener, then calls `showFeedbackUI`. The lines are the later device * pass's contract. This scenario does not upload a report. */ +import Bugsee from '@bugsee/react-native'; import { appearance, setGreeting, @@ -12,7 +13,7 @@ import { showFeedbackUI, } from '@bugsee/react-native-feedback'; -export const FEEDBACK_SCENARIOS = ['feedback'] as const; +export const FEEDBACK_SCENARIOS = ['feedback', 'feedback-chat'] as const; export type FeedbackScenario = (typeof FEEDBACK_SCENARIOS)[number]; @@ -20,7 +21,16 @@ export function isFeedbackScenario(name: string): name is FeedbackScenario { return (FEEDBACK_SCENARIOS as readonly string[]).includes(name); } -export function runFeedbackScenario(nonce: string): void { +/** + * `feedback-chat` first sets an e-mail-shaped user identifier: the SDKs + * then prefill the chat's e-mail and skip the "notify me by e-mail" + * screen, so the chat itself -- where the greeting belongs -- is what + * opens, with no tap. `feedback` leaves the identifier alone. + */ +export function runFeedbackScenario(scenario: FeedbackScenario, nonce: string): void { + if (scenario === 'feedback-chat') { + Bugsee.setUserIdentifier(`e2e-${nonce}@example.com`); + } setGreeting(`hello ${nonce}`); appearance.backgroundColor = '#112233'; setListener({ diff --git a/examples/bare/scenarios/native.ts b/examples/bare/scenarios/native.ts index ae2cfd9c..8d3cb382 100644 --- a/examples/bare/scenarios/native.ts +++ b/examples/bare/scenarios/native.ts @@ -16,7 +16,7 @@ * and then call `crashNative(kind)`. Observe installs the same handler and * does nothing else. */ -import Bugsee, { type BugseeReportHandler } from '@bugsee/react-native'; +import Bugsee, { type BugseeReport, type BugseeReportHandler } from '@bugsee/react-native'; import { crashNative, fileExists, writeTempFile } from 'bugsee-e2e-native'; export const NATIVE_SCENARIOS = [ @@ -24,6 +24,7 @@ export const NATIVE_SCENARIOS = [ 'native-crash-segv', 'native-crash-abort', 'native-crash-observe', + 'native-crash-recover', ] as const; export type NativeScenario = (typeof NATIVE_SCENARIOS)[number]; @@ -47,6 +48,27 @@ function needsHandler(scenario: NativeScenario): boolean { return isCrashScenario(scenario) || scenario === 'native-crash-observe'; } +/** + * `native-crash-recover` (iOS signal crash, ios-native-crash.test.ts): the + * relaunch's handler labels the recovered crash with this run's nonce in + * `onBeforeReportCreated` or `onAfterReportCreated`, whichever the SDK + * offers, then says so. The labels in the crash bundle are what the test + * asserts: the edits a recovery handler makes must reach the report. + */ +function recoverHandler(nonce: string): BugseeReportHandler { + const label = async (phase: string, report: BugseeReport) => { + mark(`recover ${phase} type=${report.type} id=${report.id} nonce=${nonce}`); + if (report.type === 'crash') { + await report.setLabels(['e2e-recovered', nonce]); + mark(`recover ${phase} labels-set id=${report.id} nonce=${nonce}`); + } + }; + return { + onBeforeReportCreated: report => label('before', report), + onAfterReportCreated: report => label('after', report), + }; +} + function mark(message: string): void { console.log(`BUGSEE_E2E native ${message}`); } @@ -85,7 +107,12 @@ async function runSmoke(nonce: string): Promise { * thread, so the handler must not log `after type=crash`. It is not how an * NDK crash is delivered to JS. */ -export function installNativeHandler(scenario: NativeScenario): void { +export function installNativeHandler(scenario: NativeScenario, nonce = ''): void { + if (scenario === 'native-crash-recover') { + Bugsee.setReportHandler(recoverHandler(nonce)); + mark(`recover handler installed nonce=${nonce}`); + return; + } if (!needsHandler(scenario)) { return; } @@ -98,7 +125,7 @@ export function runNativeScenario(scenario: NativeScenario, nonce: string): void runSmoke(nonce).catch((error: unknown) => mark(`smoke threw ${nameOf(error)}`)); return; } - if (scenario === 'native-crash-observe') { + if (scenario === 'native-crash-observe' || scenario === 'native-crash-recover') { return; } if (isCrashScenario(scenario)) { From 676b612a0359acf19ffe136dc63dd5138f175fa3 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Tue, 6 Oct 2026 17:45:57 +0500 Subject: [PATCH 3/3] e2e: address review: clear the feedback identity, wait for the stored fatal, no crash-order dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feedback.test.ts: afterAll runs attributes-persist to clear the e-mail user identifier feedback-chat sets (it outlives the wipe). - js-crash.test.ts: on iOS, stop the app only after the bridge logs `exception unhandled completed`, as exceptions.test.ts does. - ios-native-crash.test.ts: no array-order dependency; segv and abort each run once from a wiped container, the intermittent SIGABRT loss (3 of 16 runs, either order) is stated where it bites, and the crash reporter's queue is reported before each relaunch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- examples/bare/e2e/feedback.test.ts | 16 ++++- examples/bare/e2e/ios-native-crash.test.ts | 82 ++++++++++++++-------- examples/bare/e2e/js-crash.test.ts | 10 ++- examples/bare/e2e/observe.ts | 27 +++++++ 4 files changed, 101 insertions(+), 34 deletions(-) diff --git a/examples/bare/e2e/feedback.test.ts b/examples/bare/e2e/feedback.test.ts index 89604731..3f88b219 100644 --- a/examples/bare/e2e/feedback.test.ts +++ b/examples/bare/e2e/feedback.test.ts @@ -56,7 +56,21 @@ describeDevice(`the feedback chat on ${TARGET_NAME}`, () => { log = await beginRetainingSuite('beta-feedback'); }); - afterAll(() => endRetainingSuite(log)); + afterAll(async () => { + try { + // feedback-chat set an e-mail user identifier, which outlives the + // container wipe (the iOS Keychain; Android's SDK store): clear it + // the way attributes.test.ts does, with its `attributes-persist`. + const clearer = await startRun('attributes-persist'); + must( + await log!.waitFor(/BUGSEE_E2E attr cleared-id \{"type":"undefined"\}/, 30_000, clearer.start), + 'the user identifier cleared after the feedback-chat runs', + clearer.start, + ); + } finally { + await endRetainingSuite(log); + } + }); /** Starts `scenario`, waits for its `shown` marker, lets the UI settle. */ async function show(scenario: 'feedback' | 'feedback-chat'): Promise { diff --git a/examples/bare/e2e/ios-native-crash.test.ts b/examples/bare/e2e/ios-native-crash.test.ts index 56ab76bf..6049ed0c 100644 --- a/examples/bare/e2e/ios-native-crash.test.ts +++ b/examples/bare/e2e/ios-native-crash.test.ts @@ -24,7 +24,7 @@ import { type PulledBundle, crashOf } from './bundles'; import { iosTarget } from './device'; import { ON_IOS, type Run, TARGET_NAME, awaitBundles, clearBundles, describeDevice, must, report, startRun } from './harness'; -import { beginRetainingSuite, endRetainingSuite } from './observe'; +import { beginRetainingSuite, endRetainingSuite, iosCrashQueueFiles } from './observe'; import { type DeviceLog, devicePidsOfApp, deviceTerminationSignal } from './scenario'; jest.setTimeout(10 * 60_000); @@ -72,6 +72,8 @@ describeIphone(`a native signal crash recovered on ${TARGET_NAME}`, () => { expect(ended).toBe(true); const signal = deviceTerminationSignal(crash.launch!.output); const stillRunning = (await devicePidsOfApp()).includes(pid); + // What the crash left for the next launch to claim. + report(`${kind} crash queue before the relaunch`, await iosCrashQueueFiles()); report(`${kind} died`, { pid, signal, consoleEnd: crash.launch!.output.slice(-3).map(line => line.text.trim()) }); const recover = await startRun('native-crash-recover'); @@ -92,35 +94,53 @@ describeIphone(`a native signal crash recovered on ${TARGET_NAME}`, () => { const SIGNALS = { segv: { number: 11, name: 'SIGSEGV' }, abort: { number: 6, name: 'SIGABRT' } } as const; - // Order matters, and is recorded rather than hidden: on the XS with - // 7.0.0-beta4, a SIGABRT crashed after a SIGSEGV had been crashed and - // recovered in the same suite was not recovered in 2 of 3 runs (no crash - // file at the next launch, the handler never called), while abort alone - // passed 4/4, segv alone 7/7, and abort-then-segv 2/2. Open in the - // beta-coverage report; this order keeps each kind's own proof stable. - for (const kind of ['abort', 'segv'] as const) { - const expected = SIGNALS[kind]; - - it(`crashNative('${kind}') kills the app with ${expected.name}`, async () => { - const outcome = await crashAndRecover(kind); - expect(outcome.signal).toBe(expected.number); - expect(outcome.stillRunning).toBe(false); - }); - - it(`the next launch recovers one crash report naming ${expected.name}`, async () => { - const outcome = await crashAndRecover(kind); - expect(outcome.crashes).toHaveLength(1); - const text = JSON.stringify(crashOf(outcome.crashes[0]!)); - expect(text).toContain(expected.name); - // A signal, not an Objective-C exception: no NSException behind it. - expect(text).not.toContain('NSGenericException'); - }); - - it(`the recovery handler's labels reach the ${kind} crash report`, async () => { - const outcome = await crashAndRecover(kind); - expect(outcome.handlerLines.some(line => / labels-set /.test(line))).toBe(true); - expect(outcome.crashes).toHaveLength(1); - expect(outcome.crashes[0]!.request.labels).toEqual(expect.arrayContaining(['e2e-recovered', outcome.recover.scenario.nonce])); - }); + function assertRecovered(outcome: KindOutcome, name: string): void { + expect(outcome.crashes).toHaveLength(1); + const text = JSON.stringify(crashOf(outcome.crashes[0]!)); + expect(text).toContain(name); + // A signal, not an Objective-C exception: no NSException behind it. + expect(text).not.toContain('NSGenericException'); } + + function assertLabelled(outcome: KindOutcome): void { + expect(outcome.handlerLines.some(line => / labels-set /.test(line))).toBe(true); + expect(outcome.crashes).toHaveLength(1); + expect(outcome.crashes[0]!.request.labels).toEqual(expect.arrayContaining(['e2e-recovered', outcome.recover.scenario.nonce])); + } + + it("crashNative('segv') kills the app with SIGSEGV", async () => { + const outcome = await crashAndRecover('segv'); + expect(outcome.signal).toBe(SIGNALS.segv.number); + expect(outcome.stillRunning).toBe(false); + }); + + it('the next launch recovers one crash report naming SIGSEGV', async () => { + assertRecovered(await crashAndRecover('segv'), SIGNALS.segv.name); + }); + + it("the recovery handler's labels reach the segv crash report", async () => { + assertLabelled(await crashAndRecover('segv')); + }); + + it("crashNative('abort') kills the app with SIGABRT", async () => { + const outcome = await crashAndRecover('abort'); + expect(outcome.signal).toBe(SIGNALS.abort.number); + expect(outcome.stillRunning).toBe(false); + }); + + // iOS 7.0.0-beta4 on the XS loses a SIGABRT now and then: in 3 of 16 + // abort runs on 2026-10-06 the next launch claimed nothing and the handler + // was never called (SIGSEGV: 0 of 11). It is not the order of the kinds + // (seen abort-first and segv-first) and not a stale queue (the container, + // crash queue included, is wiped and asserted gone before each kind). An + // intermittent loss cannot be pinned with it.failing without making the + // pin flaky, so these two cases state the expected behaviour and go red + // when the SDK drops the crash. Open in the beta-coverage report. + it('the next launch recovers one crash report naming SIGABRT', async () => { + assertRecovered(await crashAndRecover('abort'), SIGNALS.abort.name); + }); + + it("the recovery handler's labels reach the abort crash report", async () => { + assertLabelled(await crashAndRecover('abort')); + }); }); diff --git a/examples/bare/e2e/js-crash.test.ts b/examples/bare/e2e/js-crash.test.ts index e5a4bfa9..8fcc0414 100644 --- a/examples/bare/e2e/js-crash.test.ts +++ b/examples/bare/e2e/js-crash.test.ts @@ -14,7 +14,7 @@ */ import { type PulledBundle, crashOf } from './bundles'; import { iosTarget } from './device'; -import { ON_IOS, type Run, TARGET_NAME, awaitBundles, describeDevice, must, report, startRun, stopApp } from './harness'; +import { ON_IOS, type Run, TARGET_NAME, awaitBundles, bridgeLine, describeDevice, must, report, startRun, stopApp } from './harness'; import { beginRetainingSuite, endRetainingSuite } from './observe'; import { type DeviceLog } from './scenario'; @@ -47,7 +47,13 @@ describeDevice(`testJsCrash() on ${TARGET_NAME}`, () => { run.start, ); if (ON_IOS) { - await new Promise(resolve => setTimeout(resolve, 5_000)); + // Stored first: the bridge says when the SDK has the fatal on disk + // (exceptions.test.ts exc-fatal), and only then is the app stopped. + must( + await log.waitFor(bridgeLine('exception unhandled completed'), 20_000, run.launched.index), + 'the JS fatal stored (exception unhandled completed)', + run.start, + ); await stopApp(); // The next launch is what sends a stored JS fatal on iOS. await startRun('cov-identity'); diff --git a/examples/bare/e2e/observe.ts b/examples/bare/e2e/observe.ts index 76776b56..fb099941 100644 --- a/examples/bare/e2e/observe.ts +++ b/examples/bare/e2e/observe.ts @@ -114,6 +114,33 @@ export async function androidTopActivity(): Promise { return line?.[1]; } +/** + * The iOS crash reporter's queue on the iPhone (PLCrashReporter's + * `Library/Caches/com.bugsee.crashreporter`): what a crash wrote before the + * next launch claims it. Every file, relative to the queue. + */ +export async function iosCrashQueueFiles(): Promise { + try { + const result = await devicectl( + 'info', + 'files', + '--domain-type', + 'appDataContainer', + '--domain-identifier', + IOS_BUNDLE_ID, + '--subdirectory', + 'Library/Caches/com.bugsee.crashreporter', + ); + const files = (result.files ?? []) as Array<{ relativePath?: string; name?: string; isDirectory?: boolean }>; + return files.filter(file => file.isDirectory !== true).map(file => file.relativePath ?? file.name ?? ''); + } catch (error) { + if (/failed to get a list of files/.test(String(error))) { + return []; + } + throw error; + } +} + /** The capture parts the SDK keeps between reports, relative to its data root. */ const ANDROID_GENERATIONS = 'files/bugsee_data/capture/generations'; const IOS_GENERATIONS = 'Library/Caches/com.bugsee.data/capture/generations';