diff --git a/mobile/src/test-support/rpc-recording/README.md b/mobile/src/test-support/rpc-recording/README.md index c94b188d58e..8878136f648 100644 --- a/mobile/src/test-support/rpc-recording/README.md +++ b/mobile/src/test-support/rpc-recording/README.md @@ -22,23 +22,24 @@ follows `stable-logical-rpc-client.test.ts`. Neither test exported a reusable mo rendered: the inert primitives it chose, the copy it put on them, the labels it gave them, and the crash instead if a reply took it down. A screen that throws is a recording, not a suite failure — several reply partitions do exactly that, and refusing to record them would leave the shapes that -break a screen the only ones this oracle cannot see. +break a screen the only ones this oracle cannot see. The boundary also reports the crash to the +effect sink, so a hook mount, whose projection is the hook's own value and never a crash, still +carries it into a golden: an effect forces a cleanup checkpoint even when no adapter looks. The view packages a screen imports are in `screen-native-substitutes.ts`, under the table's usual -rule: only what a screen reads is listed, the rest throws. Every element there is inert. It renders -its children and keeps its props where a projection can read them, and does nothing else: no -callback it is handed is ever invoked, nothing is measured, no gesture is recognised, no animation -runs and no navigation happens. `renderedElementProps` is the consequence — an inert list never -calls `renderItem`, so the data it was handed is the only record of what the screen would have -drawn. `screen-native-substitutes.test.ts` is the census; it renders every element with a callback -prop and a render callback as children and fails if either is called. +rule: only what a recording is known to read is listed, the rest throws. Every element there is +inert. It renders its children and keeps its props where a projection can read them, and does +nothing else: no callback it is handed is ever invoked, nothing is measured and no navigation +happens. `renderedElementProps` is the consequence — an inert list never calls `renderItem`, so the +data it was handed is the only record of what the screen would have drawn. +`screen-native-substitutes.test.ts` is the census; it renders every element with a callback prop and +a render callback as children and fails if either is called. -One member deserves naming because inertness costs something. `Alert.alert` never answers, so a -flow gated on a confirmation stops there; a recording that needs one has to drive it through the -operation's own API. `InteractionManager.runAfterInteractions` is the exception to the inert rule: -it runs its task on a microtask and returns the real handle, because a scheduler that drops its -task swallows the send the screen deferred, which is the one failure this oracle must not have. -Cancelling the handle before the task runs still prevents it. +Nothing is listed ahead of a reader, and that is a rule rather than an oversight. A member +provisioned before any recording reads it converts a refusal that would have forced a decision into +a silent stand-in, and a silent stand-in is how an inert `InteractionManager` or `Alert` swallows the +send a screen deferred behind it. The table was cut back to the members a recording actually reads; +whoever mounts the next screen adds what it needs together with the recording that reads it. ## What a scenario declares about its device @@ -161,7 +162,15 @@ file rather than of a restatement of it; `golden-header-digest.test.ts` pins wha buy. Checkpoints contain ordered sender calls and serialized physical application payloads, action and -request settlements, projected state, and ordered external effects. Sender args have three +request settlements, projected state, and ordered external effects. Each effect also carries `sent`, +the number of requests sent when it was recorded: sender and effects are two independent lists, so +without it a send reordered ahead of a device write moves neither list and no golden notices. +Scheduling the journal write in `codex-reset-attempt-journal.ts` on a timer instead of awaiting it +moved none of the 520 goldens before `sent` existed and moves two now, `codex-reset-credit-consumed` +and its reply matrix, where the write's `sent` goes from 0 to 1. What `sent` cannot see is a defer +shorter than the product's own await chain: dropping that `await`, or deferring the write by one +microtask, still lands it before the send, because resolving the journal's promise chain costs more +microtask ticks than the defer saved. Sender args have three positional slots; absent, undefined and null are distinct `$rpc` tags. Literal objects containing `$rpc` are escaped. Only object keys are sorted; array/effect order, options, budgets, settlement times and errors stay observable. Errors contain category, message and `isRpcDeliveryUnknown`, never diff --git a/mobile/src/test-support/rpc-recording/declared-device-state.ts b/mobile/src/test-support/rpc-recording/declared-device-state.ts index ee5da0a9d10..fda5eaca596 100644 --- a/mobile/src/test-support/rpc-recording/declared-device-state.ts +++ b/mobile/src/test-support/rpc-recording/declared-device-state.ts @@ -77,8 +77,8 @@ function declaredNotificationTray( effect: DeviceEffect ): unknown { return partialNativeModule('expo-notifications', { - // `__esModule` so a namespace import keeps reading through the trap: flattened into a fresh - // object, an unlisted member would come back undefined instead of refusing. + // Namespace-imported by every consumer, so the marker keeps reads going through the trap; see + // the `__esModule` paragraph in `native-module-traps.ts`. __esModule: true, // Cloned per read, so a screen that mutates a notification cannot change what the next read of // the declaration returns. diff --git a/mobile/src/test-support/rpc-recording/native-module-traps.ts b/mobile/src/test-support/rpc-recording/native-module-traps.ts index e243cf08499..da9ae89977d 100644 --- a/mobile/src/test-support/rpc-recording/native-module-traps.ts +++ b/mobile/src/test-support/rpc-recording/native-module-traps.ts @@ -9,12 +9,17 @@ * call instead. Whether that failure is visible depends on the caller; `host-app-version-store.ts` * catches and degrades to its unread state, which is what it does on a device too. * - * `__esModule` is exempt from both refusals, because it is the module system's interop marker - * rather than a native API. A store leaves it undefined: the store *is* the default export, and - * answering truthfully would bind `import X from` to the trap's own `default` instead of the trap, - * leaving the consumer holding a member-less stand-in. A partial module answers it only when it - * declares a `default` member, because `import X, { y }` compiles to `__importStar`, which - * otherwise overwrites that default with the module object. + * `__esModule` is exempt from both refusals, because it is the module system's interop marker rather + * than a native API, and what a trap answers there is the whole of the interop rule for every + * substitute in this directory — the other sites point here rather than restating it. Both emitted + * helpers short-circuit on a truthy marker: `__importDefault` returns the module instead of wrapping + * it, and `__importStar` returns it instead of copying its own keys into a fresh object. + * + * So a trap answers `true` when it has to survive being imported: the loader's refusing proxy, and + * any partial whose consumer takes a default or a namespace, because a flattened copy has no trap + * left and would answer an unlisted member with `undefined` instead of the named refusal. A store + * answers `undefined`, because the store *is* the default export — a truthy marker would bind + * `import X from` to the trap's own `default`, a throwing stub, instead of to the trap. */ export function partialNativeModule(module: string, members: Record): unknown { return new Proxy(members, { diff --git a/mobile/src/test-support/rpc-recording/operation-module-loader.ts b/mobile/src/test-support/rpc-recording/operation-module-loader.ts index 5d495357f39..e1979895572 100644 --- a/mobile/src/test-support/rpc-recording/operation-module-loader.ts +++ b/mobile/src/test-support/rpc-recording/operation-module-loader.ts @@ -55,12 +55,10 @@ export function operationModuleLoader( return new Proxy( {}, { - // `__esModule` is the module system's interop marker, not a native API. Answering `true` - // makes both emitted interop helpers hand this trap straight back — `__importDefault` - // returns it instead of wrapping it, `__importStar` returns it instead of copying its - // (absent) own keys — so all three import forms load and refuse at the first member read, - // naming the member. Substitute partials answer `undefined` instead, because there a real - // member object must bind as the module's default. + // Answering `__esModule` binds this trap as the module itself in every import form; the + // rule is in the `__esModule` paragraph of `native-module-traps.ts`. The refusal then + // lands on the first member the emit reads, which for a default import is `.default` + // rather than whichever member the product went on to touch. get: (_target, key) => { if (key === '__esModule') { return true