mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
* refactor(mobile): move the review route body onto a component and the handoff seam (OTA phase C, C4.4) The review route file called `useMobileDiffReviewController` at its top level. A switch cannot keep it there: hooks are unconditional, so the whole controller — its client subscriptions included — would run behind the shell's page whenever the shell renders. As an element passed for `fallback` it is created and not mounted, which is how the explorer switch already behaves. `useRouter` becomes `useRouteHandoff` in the same move. It was the one raw expo-router router left in the review closure (measured: the only other value import of one is the seam's own web sibling), and inside the page the session screen `openSession` replaces to is native, so that target has to be handed back to the app rather than posted into a document that does not render it. The params are read in the component rather than handed down, so this is the route body and the route file above it is free to become a switch. `session-router-seam-census.test.ts` gains the module by name. Kept with `useRouter` the census reds twice — `imports nothing from expo-router that can navigate` names `MobileDiffReviewRouteScreen.tsx (useRouter)`, and the completeness case gains `useRouter` — which is what forces the swap rather than leaving it to a reviewer. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): switch the source-control and review routes to the shell, still unregistered (OTA phase C, C4.4) Both take the files switch's shape: `firstParam`/`firstReviewParam` on every param, `shellScreenRoute` as the one predicate, `MobileWebShellScreen` keyed on `shellScreenRouteKey`, the native screen built as an element and passed for `fallback`. Both gain a `.web.tsx` sibling for `index.web.tsx`'s reason — the native file reaches OrcaMobileWebShellView, whose module throws at import in a browser, and the route manifest imports every route. Inert on its own. A switched route renders the shell only once `MOBILE_WEB_PAGE_ROUTES` lists it, which is the next commit; until then the flag is the only thing that changes and it is off. Query params are omitted rather than sent empty, and the whole record is omitted when none was named: `tab=` is a lens named nothing and lands on `changes` through a different branch than an absent one, and the same holds for `name`, `origin`, `scope`, `file` and `area`. `pr` and `history` are deliberately not switched. Both are `Redirect`s into `source-control`, and a redirect inside the page would leave the session bound to a pathname the page has left; left native they replace into this route and its switch mounts the shell. Three censuses red without their rows, measured on this tree: - `mobile-web-app-web-overrides.test.mjs` `lists exactly the .web.* files on disk` names the two new siblings; `states a reason for every override` reds on a placeholder under 20 characters. - `mobile-web-shell-flag-census.test.ts` `reaches the switched routes through that hook and no others` reds without the two `SWITCHED_ROUTES` names. - `shell-screen-route-census.test.ts` `walks the route tree and finds them` reds without the two switch names. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): register the source-control and review page routes (OTA phase C, C4.4) Two entries in `MOBILE_WEB_PAGE_ROUTES`, five grants each, with the reason for each grant read off the screen that needs it. The two lists are equal on purpose: the hub's changed-file rows push review and review replaces back, and a target declaring no more than its opener is a hop the handoff keeps inside the document. Registering either alone would have put a native frame and a second bridge session between a changed-file row and its diff. `pageRouteGrants` is derived from this list, so the two rows are a consequence of the entries and there is no second table to edit. `pr` and `history` stay native redirects and are never listed; the derived target list at this tree is [files, files/preview, source-control, [p], accounts, agent-history, review, session, tasks, web], with no `pr` or `history` row, because the census reads call sites and both redirects name `source-control`. Measured on this tree, not carried from the draft: - The hop census goes 8 -> 16. The eight new rows are exactly `{/h/[hostId], agent-history, files/[worktreeId], files/preview} -> {source-control, review}`, each handed off for `native.clipboard.write` and the first four also for `externalLink`. `source-control <-> review` is absent in both directions, which a new case now asserts as grant-list equality rather than as the absence of a row — absent is also what an unregistered route looks like. - The Back census now walks six trees and finds 8 controls, both rules printing empty. The two new ones are `MobileSourceControlHeader.tsx:46 role=button label=Back to session` and `MobileDiffReviewHeader.tsx:48 role=button label=Back`, which is what C4.3 bought. The `ARRIVING_SCREENS` describe it wrote for this moment is removed: with the rows in `PAGE_SERVED_SCREENS` its trees are covered and its cases were a second reading of the same thing. - Both closures reach the haptics seam, so `haptics` is declared by measurement: the seam census derives the reaching set and its two cases pass with the routes in its `ROUTE_MODULES` map. Without the two manifest entries these red on this tree: `pins every hop the handoff must take away from the page`, `keeps the hub and review local to each other`, `declares only routes the bundle has a module for`, `reaches the built manifest`, `covers every page route and finds a control in each`, and both haptics-seam cases. `build-mobile-web-app-bundle.test.mjs` is split rather than fenced. The two pinned entries put it at 607 non-comment lines against the 600 cap, and the declaration block is a different concern from how the bundle is built — it grows once per registered domain while that file does not. It moves whole into `mobile-web-page-routes.test.mjs`, named for the module it is written against, so the next route to register does not have to choose between a lint fence and a split it did not ask for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): render-check the two page routes, and make the oversized stage-all readable (OTA phase C, C4.4) The render check mounts both routes in a real browser, asserts each paints its own screen rather than the Unmatched route with no console error and no page fault, and asserts each fetches its own chunk on a client-side navigation. It also reads the shipped `img-src 'self' data:` out of the Kotlin source it is served with, pins the Swift twin beside it, and asserts neither route leaves the origin or logs a policy violation while it paints. The avatar skip itself (ruling 3) is `PRCommentCard`: on web it renders its existing empty-avatar `View` rather than letting one `<Image>` per comment attempt a fetch the policy refuses. Its branch is pinned by a component test, which reds on the platform check being removed. The render check's off-origin case is honest about being the negative half only — no comment card renders there, because the PR chain behind it is not scripted, and the file says so. The `useAnimatedScrollHandler` risk is answered by the two static facts rather than by a probe, and they are recorded as assertions: the hook is deliberately outside the four `MAPPER_HOOKS` because it is an event handler, and its updater's only effect is a write to `scrollOffsetY`, which `RightDrawer.tsx` assigns in two places and reads in none. A later read reds that case the moment it is added. The `oversized` stage-all refusal (ruling 2, made testable by ruling 5) was a silent no-op, and this is the fix as well as the case. Measured on this tree before it: `git.bulkStage` with 12,000 paths posts one 1,033,012-byte frame, the shell's reader drops it with `{ kind: 'refused', refusal: 'oversized' }`, and the page's promise never settles — `busyAction` never cleared and `setActionError` was never called. Both new cases red by timing out at 15s against that path. Refused at the page's own send boundary instead, under the shell reader's own predicate rather than a second spelling of it: `isBridgeFrameWithinCap` is extracted from `parseBridgeMessage` and used by both sides. `sendFrame` answers `sent` / `oversized` / `port-failed`, so `sendRequest` rejects with a `BridgeRequestOversizedError` whose message is a sentence the panel puts on its error surface, and the members whose contract is a boolean keep it. No delivery-unknown mark: the frame never left, so nothing ran on the desktop and the smaller retry is safe to offer. The case runs the real chain — bridge port pair, `useMobileGitRequests`, `runGitWorkflow` — with only react-native and the haptics seam mocked, and asserts the message that lands is a sentence and that the busy flag is raised and then cleared. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drop the type assertions from the two new C4.4 test files (OTA phase C, C4.4) The changed-code quality gate named five, all in the files the previous commit added, and a fence is not the answer to any of them. A separate commit because a reported head does not move by amend. - The comment fixture is a real `PRComment` rather than a cast: the type's six required fields are all this case needs, and the SAFETY disable that stood in for them was inert anyway — oxfmt had wrapped it onto three lines, and a wrapped `oxlint-disable-next-line` matches nothing. - The image lookup goes through `findAll` on the host tag rather than `findAllByType`, which takes a component. Through `String`, because `node.type` is `ElementType` and React Native declares no intrinsic elements, so the compiler reads a bare tag comparison as unreachable. - The runners hook takes its router from `useRouteHandoff` with expo-router mocked under it, which is how a `RouteHandoff` is obtained rather than asserted into existence. No target is pressed. - The rejection and the diagnostic are read through narrowings instead of casts, which also drops an `expect.any` that only type-checked because of one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a frame the page cannot serialize inside the send contract (OTA phase C, C4.4 round 1) Round 1 finding 1. The oversized refusal moved `JSON.stringify` outside `sendFrame`'s `try`, so a frame carrying a cycle, a `BigInt` or a throwing `toJSON` threw past the whole send path. Three things followed, all measured here on a cyclic `params`: - the caller was rejected with a bare `TypeError` from `JSON.stringify` instead of the `BridgeSendFailedError` every other undelivered frame raises; - no `send-failed` diagnostic was raised, so nothing recorded that a frame had been lost; - `sendRequest` opens the id before it posts and abandons it on the way out, and the throw skipped the abandon: 63 of the 64 in-flight slots were usable afterwards, against 64 on a client that sent no such frame. Sixty-four of them and every later request is refused with nothing to say why. `posted()` carried the same escape into the members whose contract is a boolean, where a throw is worse still: those callers are taps and teardowns with no catch on them. Serialization goes back inside the `try`, with the oversized refusal kept in front of the post. The docstring said the port arm's throw is never `JSON.stringify`'s, which was exactly the assumption that broke; it now says why the call sits where it does. The new file is the pin: the rejection's name, the diagnostic, nothing reaching the shell, and the slot count with a no-cyclic-frame control beside it so the count cannot pass by the cap moving. Both changed cases red on the serialization moving back out — `expected 'TypeError' to be 'BridgeSendFailedError'` and `expected 63 to be 64`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the page's frame cap to the reader's, at the boundary and by construction (OTA phase C, C4.4 round 1) Round 1 finding 2. Nothing held the sender's predicate to the reader's. Replacing `isBridgeFrameWithinCap(json)` with an inline `json.length > BRIDGE_MAX_MESSAGE_BYTES + 1` passed 85 of the 86 mobile-web-shell and source-control test files on this tree, and a frame at exactly cap+1 would then be posted and silently dropped — the hang the refusal exists to end, back for every frame in that one-unit band. Two rules, because either alone passes against the defect: - The boundary. A frame of exactly the cap is posted, arrives at `parseBridgeMessage` and is accepted; a frame one byte over is refused with `BridgeRequestOversizedError`, posts nothing, and is the same string the reader answers `oversized` to. An off-by-one reds the second. - The census. The client reaches the cap through the shared predicate and does not name `BRIDGE_MAX_MESSAGE_BYTES` at all, and the module that exports the predicate is the module that parses inbound frames. A private copy that is correct on the day it is written reds here. The overhead the boundary frames are built from is itself checked rather than trusted: a frame asked for at exactly the cap must serialize to exactly the cap, so the constant cannot rot behind an envelope that grew a field. Against the mutation both new rules red — `expected null to be 'BridgeRequestOversizedError'` and the census failing to find the predicate — while the rest of the suite stays green, which is the finding reproduced. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): count the outbound frame in the unit both shells count it in (OTA phase C, C4.4 round 1) Round 1 finding 3. Read off both shells rather than assumed, and they agree: iOS gates the inbound frame on `json.utf8.count` (`MobileWebShellView.swift`, through `MobileWebShellBridge.acceptsByteCount`) and Android on `json.toByteArray(Charsets.UTF_8).size` (`MobileWebShellView.kt`, through `acceptsMobileWebShellBridgeByteCount`), both against `640 * 1024`. UTF-8 bytes on each platform. The predicate was already right. `isBridgeFrameWithinCap` decides on `utf8ByteLength`, and the `raw.length` clause in front of it is a cheap refusal in the safe direction, not a second rule: every code unit encodes to at least one byte, so a string over the cap in units is over it in bytes too. The diagnostic was not. It reported `json.length` — UTF-16 code units — in a field named `bytes`, so a frame of CJK text read as a quarter of the cap at the moment it was refused by it. It now reports `utf8ByteLength(json)`, and the type says which unit that is. Pinned with a 250,000-character frame of three-byte characters, which is under the cap in code units and over it in bytes, plus a source case reading the measuring expression out of each shell. Three mutations, all red: dropping the byte clause from the predicate reds the refusal (`expected null to be 'BridgeRequestOversizedError'`) and the diagnostic; reporting `json.length` again reds the diagnostic alone (`expected 250094 to be greater than 655360`), which is the defect this commit fixes, in the number it would have printed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): drop the render check's avatar assertion, which could not fail (OTA phase C, C4.4 round 1) Round 1 finding 4. The case asserted that no avatar host was requested while both routes painted, which reads as a proof of the web skip and is not one: no comment card renders on either page, because the PR chain the file's own closing note names is not scripted. Reproduced here — deleting the `Platform.OS !== 'web'` guard from `PRCommentCard` leaves the file at 5 passed. Deleted rather than propped up. Giving the page a presence precondition means five hand-written fixtures against five Zod schemas inside the shell double, which is exactly what the harness's docstring says that double must not become. So the only proof of that branch is `pr-comment-card-web-avatar.test.tsx`, which reds when the check is removed, and the render check now says so in its header instead of implying otherwise. What survives is a property of these two closures rather than of that component: not one request leaves the origin while either route paints, and nothing either paints violates the policy. That one can fail — planting a `fetch` to a provider host in a module both routes reach reds it twice, on the console-error case and on the off-origin case, with the `connect-src 'self'` refusal in the output. The CSP half is unchanged and was never in question: the served header is read from the Kotlin source and the Swift twin is pinned beside it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
304 lines
12 KiB
JavaScript
304 lines
12 KiB
JavaScript
/**
|
|
* What a page's taps reach for a haptic, and the grant every page route needs to get one.
|
|
*
|
|
* Inside the shell's WebView `expo-haptics` fakes an iOS haptic by clicking a hidden checkbox it
|
|
* appends to `document.head`, which is what killed a long press on the worktree list (C1.9). So the
|
|
* page's seam posts `native.haptics.trigger` instead and the app plays the device's own — and a
|
|
* route that imports the seam without declaring `haptics` is a page whose taps go quiet, because
|
|
* grants are resolved once from the route the shell opened.
|
|
*
|
|
* The scan has a control rather than an empty list: the same walk over the native sibling finds the
|
|
* same five functions and no posting site, which is what says it can tell the two apart.
|
|
*/
|
|
import { readFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs'
|
|
import { MOBILE_WEB_PAGE_ROUTES } from './mobile-web-page-routes.mjs'
|
|
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
|
|
import {
|
|
HAPTICS_KINDS_MODULE,
|
|
HAPTICS_NATIVE,
|
|
HAPTICS_SEAM,
|
|
bridgeHapticsKinds,
|
|
hapticsImportedNames,
|
|
hapticsPostedKinds,
|
|
hapticsSeamImporters,
|
|
hapticsTriggerSites
|
|
} from './mobile-web-app-haptics-seam.mjs'
|
|
|
|
const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url))
|
|
const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip
|
|
|
|
const read = (file) => readFileSync(join(mobileDir, file), 'utf8')
|
|
|
|
/** The route module behind each declared page route, which is what a closure is read from. */
|
|
const ROUTE_MODULES = new Map([
|
|
['/h/[hostId]', 'app/h/[hostId]/index.tsx'],
|
|
['/h/[hostId]/agent-history/[worktreeId]', 'app/h/[hostId]/agent-history/[worktreeId].tsx'],
|
|
['/h/[hostId]/tasks', 'app/h/[hostId]/tasks.tsx'],
|
|
['/h/[hostId]/files/[worktreeId]', 'app/h/[hostId]/files/[worktreeId].tsx'],
|
|
['/h/[hostId]/files/preview/[worktreeId]', 'app/h/[hostId]/files/preview/[worktreeId].tsx'],
|
|
['/h/[hostId]/source-control/[worktreeId]', 'app/h/[hostId]/source-control/[worktreeId].tsx'],
|
|
['/h/[hostId]/review/[worktreeId]', 'app/h/[hostId]/review/[worktreeId].tsx']
|
|
])
|
|
|
|
const HAPTICS_GRANT = 'haptics'
|
|
|
|
/** The shell's mapping from a notify kind to one of the app's own functions. */
|
|
const SHELL_MAPPING = 'src/mobile-web-shell/page-haptics.ts'
|
|
|
|
describe('the seam reader', () => {
|
|
it('names the kind each exported trigger posts', () => {
|
|
expect(
|
|
hapticsTriggerSites(
|
|
[
|
|
'let post = () => false',
|
|
'export function publishHapticsNotifier(notify) {',
|
|
' post = notify',
|
|
'}',
|
|
"export function triggerSelection() { post('selection') }"
|
|
].join('\n'),
|
|
'haptics.web.ts'
|
|
)
|
|
).toEqual([{ name: 'triggerSelection', line: 5, kind: 'selection' }])
|
|
})
|
|
|
|
it('reads the binding the publisher assigns rather than a name called post', () => {
|
|
// Keyed on `post`, renaming the local would turn every posting site into a non-posting one and
|
|
// leave this census green on a page whose taps buzz for nothing.
|
|
expect(
|
|
hapticsPostedKinds(
|
|
[
|
|
'let ask = () => false',
|
|
'export function publishHapticsNotifier(notify) { ask = notify }',
|
|
"export function triggerError() { ask('error') }"
|
|
].join('\n'),
|
|
'haptics.web.ts'
|
|
)
|
|
).toEqual(['error'])
|
|
})
|
|
|
|
it('reports a function that posts nothing as a site with no kind', () => {
|
|
expect(
|
|
hapticsTriggerSites('export function triggerSelection() {}\n', 'haptics.web.ts')
|
|
).toEqual([{ name: 'triggerSelection', line: 1, kind: null }])
|
|
})
|
|
|
|
it('reports no kind for a function that posts one it computed, which nothing can pin', () => {
|
|
expect(
|
|
hapticsPostedKinds(
|
|
[
|
|
'let post = () => false',
|
|
'export function publishHapticsNotifier(notify) { post = notify }',
|
|
'export function triggerSelection(kind) { post(kind) }'
|
|
].join('\n'),
|
|
'haptics.web.ts'
|
|
)
|
|
).toEqual([])
|
|
})
|
|
|
|
it('reports no kind for a function that posts twice, which is two taps for one gesture', () => {
|
|
expect(
|
|
hapticsPostedKinds(
|
|
[
|
|
'let post = () => false',
|
|
'export function publishHapticsNotifier(notify) { post = notify }',
|
|
"export function triggerSelection() { post('selection'); post('success') }"
|
|
].join('\n'),
|
|
'haptics.web.ts'
|
|
)
|
|
).toEqual([])
|
|
})
|
|
|
|
it('leaves alone a trigger the module does not export', () => {
|
|
expect(
|
|
hapticsTriggerSites(
|
|
[
|
|
'let post = () => false',
|
|
'export function publishHapticsNotifier(notify) { post = notify }',
|
|
"function triggerLocal() { post('selection') }"
|
|
].join('\n'),
|
|
'haptics.web.ts'
|
|
)
|
|
).toEqual([])
|
|
})
|
|
|
|
it('ignores the seam named inside a comment or a string, which text matching cannot', () => {
|
|
expect(
|
|
hapticsPostedKinds(
|
|
[
|
|
'let post = () => false',
|
|
'export function publishHapticsNotifier(notify) { post = notify }',
|
|
"// export function triggerSelection() { post('selection') }",
|
|
'const hint = "post(\'success\')"'
|
|
].join('\n'),
|
|
'haptics.web.ts'
|
|
)
|
|
).toEqual([])
|
|
})
|
|
|
|
it('reads the kinds off the tuple that declares them', () => {
|
|
expect(bridgeHapticsKinds("export const BRIDGE_HAPTICS_KINDS = ['a', 'b'] as const\n")).toEqual(
|
|
['a', 'b']
|
|
)
|
|
// A mention is not a declaration, which is why this is parsed rather than matched.
|
|
expect(bridgeHapticsKinds("// BRIDGE_HAPTICS_KINDS = ['a']\n")).toEqual([])
|
|
})
|
|
})
|
|
|
|
/**
|
|
* The two siblings measured against each other, which is what makes "all five post" a number.
|
|
*
|
|
* The native file is the control: same five names, same walk, no posting site. Without it an empty
|
|
* result and a file the scan could not read would report the same thing.
|
|
*/
|
|
describe('the two haptics siblings', () => {
|
|
it('posts every kind the notify admits from the web sibling, and nothing more', () => {
|
|
const kinds = bridgeHapticsKinds(read(HAPTICS_KINDS_MODULE), HAPTICS_KINDS_MODULE)
|
|
expect(kinds).toHaveLength(5)
|
|
const posted = hapticsPostedKinds(read(HAPTICS_SEAM), HAPTICS_SEAM)
|
|
expect([...posted].sort()).toEqual([...kinds].sort())
|
|
})
|
|
|
|
it('finds five functions in the native sibling and no posting site at all', () => {
|
|
const sites = hapticsTriggerSites(read(HAPTICS_NATIVE), HAPTICS_NATIVE)
|
|
expect(sites).toHaveLength(5)
|
|
expect(sites.filter((site) => site.kind !== null)).toEqual([])
|
|
})
|
|
|
|
it('exports the same five names from both, which is what makes one a substitution', () => {
|
|
const names = (file) => hapticsTriggerSites(read(file), file).map((site) => site.name)
|
|
expect(names(HAPTICS_SEAM)).toEqual(names(HAPTICS_NATIVE))
|
|
})
|
|
|
|
/**
|
|
* The third direction, which no type in the app states.
|
|
*
|
|
* The shell's table refuses a kind with no row and a row naming a function that does not exist,
|
|
* both at compile time. It says nothing about a haptic `haptics.ts` grows with no kind of its own,
|
|
* which would be one the page can never ask for however many rows the table has.
|
|
*/
|
|
it('maps every function the app exports from the shell side, so none is unreachable', () => {
|
|
const exported = hapticsTriggerSites(read(HAPTICS_NATIVE), HAPTICS_NATIVE).map(
|
|
(site) => site.name
|
|
)
|
|
expect(exported).toHaveLength(5)
|
|
expect(hapticsImportedNames(read(SHELL_MAPPING), SHELL_MAPPING)).toEqual([...exported].sort())
|
|
})
|
|
})
|
|
|
|
describe('the imported-name reader', () => {
|
|
it('names what a module takes from the app haptics', () => {
|
|
expect(
|
|
hapticsImportedNames(
|
|
"import { triggerError, triggerSuccess } from '../platform/haptics'\n",
|
|
'page-haptics.ts'
|
|
)
|
|
).toEqual(['triggerError', 'triggerSuccess'])
|
|
})
|
|
|
|
it('reads the imported name and not the local one, a renamed import being the same export', () => {
|
|
expect(
|
|
hapticsImportedNames(
|
|
"import { triggerError as boom } from '../platform/haptics'\n",
|
|
'page-haptics.ts'
|
|
)
|
|
).toEqual(['triggerError'])
|
|
})
|
|
|
|
it('leaves alone an import of the web sibling or of something else entirely', () => {
|
|
expect(
|
|
hapticsImportedNames(
|
|
[
|
|
"import { triggerError } from '../platform/haptics.web'",
|
|
"import { triggerSuccess } from './other-haptics'",
|
|
"// import { triggerEdgeBump } from '../platform/haptics'"
|
|
].join('\n'),
|
|
'page-haptics.ts'
|
|
)
|
|
).toEqual([])
|
|
})
|
|
})
|
|
|
|
describeClosure(
|
|
'every page route closure and the haptics seam',
|
|
() => {
|
|
it.each([...ROUTE_MODULES])('resolves the seam to the web sibling: %s', async (_route, mod) => {
|
|
const closure = await mobileWebAppRouteClosure(mod)
|
|
expect(closure.local).toContain(HAPTICS_SEAM)
|
|
expect(closure.local).not.toContain(HAPTICS_NATIVE)
|
|
// The precondition an assertion about a closure needs: the walk read a page, not nothing.
|
|
expect(closure.local.length).toBeGreaterThan(250)
|
|
})
|
|
|
|
it.each([...ROUTE_MODULES])(
|
|
'imports the seam from at least one module, so the grant is not idle: %s',
|
|
async (_route, mod) => {
|
|
const closure = await mobileWebAppRouteClosure(mod)
|
|
expect(hapticsSeamImporters(mobileDir, closure).length).toBeGreaterThan(0)
|
|
}
|
|
)
|
|
|
|
/**
|
|
* The grant list derived from the closures rather than written by hand.
|
|
*
|
|
* Grants are resolved once, from the route the shell opened, and carried for the life of the
|
|
* session. A route that imports the seam and declares nothing is a page whose taps are silent
|
|
* with nothing on screen to say why.
|
|
*
|
|
* The cost of the answer being every route: `implementedPageRoutes` filters on
|
|
* `grants.every(implementsGrant)`, so against a shell that does not carry the token no page
|
|
* route is served at all and the phone renders the native screens. The mechanism is pinned in
|
|
* `mobile/src/mobile-web-shell/page-route-policy.test.ts`.
|
|
*/
|
|
it('declares haptics on exactly the routes whose closure reaches the seam', async () => {
|
|
const reaching = []
|
|
for (const [route, mod] of ROUTE_MODULES) {
|
|
const closure = await mobileWebAppRouteClosure(mod)
|
|
if (hapticsSeamImporters(mobileDir, closure).length > 0) {
|
|
reaching.push(route)
|
|
}
|
|
}
|
|
expect(reaching.length).toBeGreaterThan(0)
|
|
const declared = MOBILE_WEB_PAGE_ROUTES.filter((route) =>
|
|
route.grants.includes(HAPTICS_GRANT)
|
|
).map((route) => route.pathname)
|
|
expect([...declared].sort()).toEqual([...reaching].sort())
|
|
})
|
|
|
|
it('covers every declared page route, so a new one cannot be missed by this file', () => {
|
|
// The map above is a hand list of route modules; this is what holds it to the declarations.
|
|
expect([...ROUTE_MODULES.keys()].sort()).toEqual(
|
|
MOBILE_WEB_PAGE_ROUTES.map((route) => route.pathname).sort()
|
|
)
|
|
})
|
|
|
|
/**
|
|
* What the notify costs a page to download: one module.
|
|
*
|
|
* Measured, not assumed: every page closure grew by exactly `bridge-haptics-notify.ts`, and it
|
|
* arrives through `page-route-policy.ts` reading the grant token rather than through the seam,
|
|
* whose own import of the kind type is erased. Its only dependency is `zod`, which the envelope
|
|
* already put in every closure, so the module total moved by the same one.
|
|
*
|
|
* Pinned structurally rather than as a total: an absolute closure count is main's to move, and a
|
|
* number that drifts for unrelated reasons is one nobody reads.
|
|
*/
|
|
it('adds one module to a page closure, and only the two haptics modules are in it', async () => {
|
|
for (const mod of ROUTE_MODULES.values()) {
|
|
const closure = await mobileWebAppRouteClosure(mod)
|
|
expect(closure.local.filter((file) => file.includes('haptics')).sort(), mod).toEqual([
|
|
HAPTICS_KINDS_MODULE,
|
|
HAPTICS_SEAM
|
|
])
|
|
// The engine of the delta: the grant token is a value the route policy reads, and the
|
|
// policy is in every page closure. Without this the +1 would have no stated cause.
|
|
expect(closure.local, mod).toContain('src/mobile-web-shell/page-route-policy.ts')
|
|
}
|
|
})
|
|
},
|
|
240_000
|
|
)
|