Files
orca/config/scripts/mobile-web-app-source-control-text-inputs.test.mjs
T
Jinwoo Hong e736a9f29b feat(mobile): take the session screen's inputs, links, clipboard and routers through the platform seams (OTA phase C, C7.2) (#21790)
* feat(mobile): put the session screen's nine text inputs on the web font seam (OTA phase C, C7.2)

The landed text-input census, run over `app/h/[hostId]/session/[worktreeId].tsx`,
reports nine sizes that do not come from `TEXT_INPUT_FONT_SIZE`. Six declare the
app's body size and move in place, which is the same number natively. Three do
not — a 22px key-capture field and the chat's two 15px fields — so each gets a
`.web.ts` sibling of the address bar's shape, with a shared base so the two
halves can differ in nothing but the size.

The capture field is the one the move shrinks rather than raises: 22 already
clears the focus-zoom floor, and the census reads the seam as a binding rather
than as a number, so there is no expression that keeps 22 and still says where
the size came from.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): drop the theme import the composer's style split left behind

`oxlint` over the whole tree, which CI runs, reads it as an error.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): open the session screen's three external URLs through the platform seam (OTA phase C, C7.2)

The landed external-link census, run over the session route's closure, reports
three modules reaching react-native's `Linking`: a terminal link tap whose open
mode is the phone's browser, and the two WebView-backed readers, each of which
sends a tapped link to the system browser rather than navigating the artifact
away.

Inside the shell `Linking.openURL` calls `window.open`, which both shells refuse
and which resolves either way, so all three reported success into a tap that did
nothing. The seam also stops swallowing the failure: each site caught and
discarded, and `openExternalLink` names it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): take the session screen's seven clipboard sites through the platform seam (OTA phase C, C7.2)

`expo-clipboard` resolves to `navigator.clipboard` on the web, which needs a
secure context — iOS serves the page from a custom scheme and Android from
`https`, so that path works on one platform and silently not on the other. The
landed census now reports the module out of the route's closure entirely.

The seam grows its reader half, on the landed `native.clipboard.read` verb: text,
a PNG, and a presence probe. Two degradations are recorded rather than implied.
No shell serves an image, so the page answers null and the terminal's paste takes
the branch an empty clipboard already took; and the shell serves no presence verb,
so `contents` answers what this side knows rather than reading to find out, which
would raise iOS's paste-consent prompt on every foreground.

The copy-path sheet gains the failure toast its two neighbours already had: it
showed "Path copied" before the write, and the seam rejects rather than returning
false.

The route parity pin moves with it: five clipboard hooks join the expanded route
and one runtime string joins the sheet.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): take the session domain's three routers through the handoff seam (OTA phase C, C7.2)

Inside the page a screen is one document standing in for one screen, and
`useRouteHandoff` is the only thing that knows which targets the page keeps and
which it hands back to the app. The three holders here are the workspace-missing
bounce, the file-tap preview push, and the pane-tap param consume.

The domain's census is narrower than the two landed ones because it has to be:
eight of its hooks take `useFocusEffect` and two take `useLocalSearchParams`,
neither of which can navigate, so the rule is a closed list of names rather than
a ban on any value import — which also catches expo-router's module-singleton
`router`, a spelling a `useRouter` rule would have read as clean.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): list the two style siblings the session screen's inputs added (OTA phase C, C7.2)

The overrides census fails on an unlisted `.web.*`. One raises the chat's two
15px fields past the focus-zoom floor; the other lowers a 22px capture field onto
the seam, and its entry says why a reduction is the right answer there.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): let the text-input census read a literal already clear of the floor (OTA phase C, C7.2, ruling 12)

The floor is the rule and the seam is the mechanism. A binding rule alone made
the custom-key capture field an offender at 22, where nothing can zoom, and the
only way to satisfy it was to lower a one-character field to 16 — the tail
wagging the dog.

The seam's web half now exports the floor it already computed `Math.max` against,
and the census reads that number out of that file rather than carrying a second
copy of 16. The rule becomes "the seam's binding, or a literal at or above the
floor", with no per-site exemption: a literal under the floor is still reported,
which is the case the seam exists for. A tree whose seam declares no floor is
refused rather than judged against a number the census invented.

So the capture field goes back to 22 on both platforms and its split, its
override entry and its parity test go with it. The chat's two fields stay split,
because 15 is under the floor however it is spelled.

Red-first: with the rule removed, a planted literal 16 and a literal 22 are both
reported and the refusal case does not throw; a literal 15 is reported either
way. All three route closures that run this census — session, source-control,
review — report 0 offenders and 0 unresolved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): answer clipboard reads on the read grant and catch refused writes

`contents()` reported no text on a route that granted `native.clipboard.read`
without the write, because it read `verbs.granted`, which is write AND read. The
verbs hook now exposes the two grants separately and the web seam answers on the
read one; `granted` keeps its meaning for the callers that need both.

The Markdown copy action was the one write of eight in the session domain with
nowhere for a rejection to go: the seam rejects when the pasteboard refused the
text, the callback had no failure branch, and its caller drops the promise, so a
refused write raised an unhandled rejection and still left "Copied" on screen. It
now takes the error haptic and the "Couldn't copy" toast the other copy paths
show. A census over `src/session` fails if any `writeText` call site lacks a
failure branch, so the ninth site cannot arrive without one.

The route parity pin moves with it: one callback body, one runtime string. Its
refresh note claimed six clipboard hook sites for a delta of five; the walk from
`SessionScreen` reaches five, and the terminal's paste is not among them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): probe both clipboard kinds at once and judge sizes against the floor

Moving the two clipboard probes into an object literal serialised them: the
migrated `contents()` awaited `hasStringAsync` before `hasImageAsync` was called,
where both callers had used `Promise.all`. That path runs on mount, on every
AppState foreground and on every select-mode toggle. Restored, with an ordering
probe that deadlocks unless both probes start before either answers.

The floor case could not fail for the reason it named: its fixture declared 16,
so a census carrying its own copy of 16 passed it. It now plants a seam declaring
20 and a literal 18, the size that is clean under one floor and an offence under
the other.

Two stale wordings from the reverted split: one closure case still said "both
split style modules" over a one-element list.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): buzz the quick-command row when a copy is refused

The last of the seven migrated writes without the error haptic. The row already
said "Couldn't copy" on its own control, in red, for the 1500 ms the toast the
other six show would have lasted, so it never claimed a refused write had landed;
what it had no way to say was anything the thumb still on the button could feel.

Its first test, on the harness its list already uses: the seam rejects when the
pasteboard refuses, and the two cases are the difference between the row that
shows a green check over nothing copied and the row that does not. The list's own
test gains the haptics mock the row's new import needs.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make the clipboard census require the await its rule depends on

`hasFailureBranch` accepted any enclosing `try` with a `catch`, so the one shape
the census exists to stop passed it: `void clipboard.writeText(...)` inside a
try/catch is an unhandled rejection with a handler three lines above it that can
never run, because the block returns before the promise settles. It now requires
the call to be awaited inside the try's own block, or to carry a `.catch` along
its own chain. The boundary walk stopped only at function and method
declarations, so a `catch` outside an arrow answered for the call left running
inside it; every function-like node ends the search now.

Five cases over snippets read through the same reader, because a `void` write
would have to be committed to be tested against the real tree. Control on a real
site: making the Markdown write un-awaited inside its own try reports it.

Two provenance fixes. The runtime-string delta across C7.2 is two literals, not
one: "Couldn't copy path" took the count from 532 to 533 and "Couldn't copy" took
it to 534. And main's C7.4 made `BRIDGE_CLIPBOARD_MIMES` `['text']`, so an image
mime is a value the schema does not admit rather than a refusal the verb spells
out, with `native.media.pick { source: 'clipboard' }` waiting on C7.6; the web
seam and its test said otherwise. Behaviour unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let an unmounted quick-command row emit nothing on a refusal

The haptic I added ran before the mounted guard, so a copy pressed on a row that
then scrolled out of the list, or a sheet closed over it, still buzzed when the
rejection arrived. A buzz with no row to explain it is feedback for nothing, and
the guard was already there for the feedback state one line below.

Red-first: press, unmount, then reject. The success path already guarded first.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make the clipboard census require a catch with something in it

Any `.catch` property access counted as a failure branch, so two shapes that
handle nothing passed: `clipboard.writeText(text).catch` reads the handler's name
and registers nothing, and `.catch()` swallows the rejection while the caller goes
on to say the write landed. The rule now requires `.catch` to be the callee of a
call carrying at least one argument.

Red-first with both shapes in the snippet reader, the accepting cases unchanged.
Control on the real tree: emptying the notes sheet's handler reports
`MobileSessionSheets.tsx:174`, and restoring it greens.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 09:49:59 -04:00

458 lines
18 KiB
JavaScript

/**
* Every text input the source-control hub and the diff review page reach, and the size it declares.
*
* iOS zooms the page on focus of any input under 16px and does not zoom back out, so the document
* spends the rest of that typing session at a scale other than 1 — which `keyboard-occlusion.web.ts`
* reads as "not a keyboard" on purpose, because geometry cannot separate a zoom from a keyboard.
* One 14px input anywhere in the closure is therefore enough to stop the commit bar and the note
* composer lifting, whatever those two inputs themselves declare.
*
* So the rule is the closure rather than the two seam-served sites: the first version of this fix
* raised those two and left eight others in the same closures at 14px, which made the seam's own
* rationale false page-wide.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
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 { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import {
TEXT_INPUT_FONT_SIZE_SEAM,
textInputFontSizeFloor,
textInputFontSizeOffenders,
unresolvedTextInputStyles
} from './mobile-web-app-text-input-font-size-seam.mjs'
const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url))
const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip
const HUB = 'app/h/[hostId]/source-control/[worktreeId].tsx'
const REVIEW = 'app/h/[hostId]/review/[worktreeId].tsx'
/** The seam module itself, which a fixture needs on disk for an import of it to resolve. */
const SEAM_SOURCE = {
'src/platform/text-input-font-size.ts': 'export const TEXT_INPUT_FONT_SIZE = 14'
}
/**
* The seam's web half, seeded into every scratch tree below.
*
* Not a fixture detail: the floor is declared here and the census reads it here, so a tree without
* this file is one the rule refuses to judge at all. Seeding it makes every case a tree with a
* seam, which is what a real one is; the case that checks the refusal writes its own over the top.
*/
const FLOOR_SOURCE = {
'src/platform/text-input-font-size.web.ts': [
'export const TEXT_INPUT_FONT_SIZE_FLOOR = 16',
'export const TEXT_INPUT_FONT_SIZE = 16'
].join('\n')
}
const SEAM_IMPORT = "import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'"
/** A scratch module tree, so a planted offender never lands in the tree other censuses walk. */
function plant(files) {
const root = mkdtempSync(join(tmpdir(), 'orca-text-input-census-'))
for (const [path, source] of Object.entries({ ...FLOOR_SOURCE, ...files })) {
mkdirSync(join(root, path.slice(0, path.lastIndexOf('/'))), { recursive: true })
writeFileSync(join(root, path), source)
}
return root
}
describe('the size a text input declares, as the census reads it', () => {
it('names the line the size is set on, which may not be the file the input is in', () => {
const root = plant({
'src/ui/Field.tsx': [
"import { styles } from './field-styles'",
'export const Field = () => <TextInput style={styles.input} />'
].join('\n'),
'src/ui/field-styles.ts': [
'export const styles = {',
' label: { fontSize: 12 },',
' input: { fontSize: 14 }',
'}'
].join('\n')
})
try {
expect(
textInputFontSizeOffenders(root, { local: ['src/ui/Field.tsx', 'src/ui/field-styles.ts'] })
).toEqual(['src/ui/field-styles.ts:3'])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('follows a spread into the module that really holds the key', () => {
// Both screens this rule exists for reach their input through `{ ...base, ...list }`. A walk
// that stopped at the first module would find no size here and report the offence as absent.
const root = plant({
'src/ui/Field.tsx': [
"import { styles } from './field-styles'",
'export const Field = () => <TextInput style={styles.input} />'
].join('\n'),
'src/ui/field-styles.ts': [
"import { listStyles } from './list-styles'",
'export const styles = { ...listStyles }'
].join('\n'),
'src/ui/list-styles.ts': 'export const listStyles = { input: { fontSize: 14 } }'
})
try {
expect(
textInputFontSizeOffenders(root, {
local: ['src/ui/Field.tsx', 'src/ui/field-styles.ts', 'src/ui/list-styles.ts']
})
).toEqual(['src/ui/list-styles.ts:1'])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('separates a style it could not follow from one that sets no size', () => {
// An offender list only says every input is on the seam if every input was read. A style the
// walk cannot follow has to surface here rather than pass as a clean input.
const root = plant({
'src/ui/Field.tsx': [
"import { styles } from './field-styles'",
"import { missing } from 'some-package'",
'export const Bare = () => <TextInput style={styles.bare} />',
'export const Gone = () => <TextInput style={missing.input} />'
].join('\n'),
'src/ui/field-styles.ts': 'export const styles = { bare: { padding: 8 } }'
})
try {
const closure = { local: ['src/ui/Field.tsx', 'src/ui/field-styles.ts'] }
expect(textInputFontSizeOffenders(root, closure)).toEqual([])
expect(unresolvedTextInputStyles(root, closure)).toEqual(['src/ui/Field.tsx:4 (input)'])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('takes the seam as the answer, and an absent size as nothing to answer for', () => {
// A style with no `fontSize` inherits; the floor is about the size an input declares.
const root = plant({
'src/ui/Field.tsx': [
"import { styles } from './field-styles'",
'export const Field = () => <TextInput style={styles.input} />',
'export const Other = () => <TextInput style={styles.bare} />'
].join('\n'),
// Imported, not merely spelled: the rule reads the binding now, so a fixture that wrote the
// name without importing it from the seam would be an offender like any other shadow.
'src/ui/field-styles.ts': [
SEAM_IMPORT,
'export const styles = {',
' input: { fontSize: TEXT_INPUT_FONT_SIZE },',
' bare: { padding: 8 }',
'}'
].join('\n'),
...SEAM_SOURCE
})
try {
expect(
textInputFontSizeOffenders(root, { local: ['src/ui/Field.tsx', 'src/ui/field-styles.ts'] })
).toEqual([])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('reads an inline style literal in place rather than losing it', () => {
// `style={{ fontSize: 14 }}` names no style key, so a walk that only followed `styles.key`
// recorded nothing at all for it: neither an offender nor a hole.
const root = plant({
'src/ui/Inline.tsx': [
'export const Sized = () => <TextInput style={{ fontSize: 14 }} />',
'export const Bare = () => <TextInput style={{ padding: 8 }} />'
].join('\n')
})
try {
const closure = { local: ['src/ui/Inline.tsx'] }
expect(textInputFontSizeOffenders(root, closure)).toEqual(['src/ui/Inline.tsx:1'])
expect(unresolvedTextInputStyles(root, closure)).toEqual([])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('reads an inline literal beside a style key, and the condition between them', () => {
// `[styles.input, disabled && styles.disabled]` is the shape this tree actually uses, so the
// members of an array — and the right of an `&&` — have to be followed, not walked as a blob.
const root = plant({
'src/ui/Mixed.tsx': [
"import { styles } from './mixed-styles'",
'export const Mixed = () => (',
' <TextInput style={[styles.input, disabled && styles.disabled, { fontSize: 14 }]} />',
')'
].join('\n'),
'src/ui/mixed-styles.ts': [
SEAM_IMPORT,
'export const styles = {',
' input: { fontSize: TEXT_INPUT_FONT_SIZE },',
' disabled: { opacity: 0.5 }',
'}'
].join('\n'),
...SEAM_SOURCE
})
try {
const closure = {
local: ['src/ui/Mixed.tsx', 'src/ui/mixed-styles.ts', ...Object.keys(SEAM_SOURCE)]
}
expect(textInputFontSizeOffenders(root, closure)).toEqual(['src/ui/Mixed.tsx:3'])
expect(unresolvedTextInputStyles(root, closure)).toEqual([])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
/**
* The floor is the rule and the seam is the mechanism, so a literal already clear of the floor
* satisfies it without binding to anything.
*
* Written as three sizes rather than one: a rule that only proved 22 passes would also be
* satisfied by a census that stopped reading literals at all, and the 15 is the case the whole
* seam exists for. The boundary is included because "at or above" is where an off-by-one lives.
*/
it.each([
['under the floor, which is the offence the seam exists for', 15, ['src/ui/Sized.tsx:1']],
['exactly the floor', 16, []],
['well above the floor, which no binding could keep', 22, []]
])('reads a literal %s', (_label, size, expected) => {
const root = plant({
'src/ui/Sized.tsx': `export const Sized = () => <TextInput style={{ fontSize: ${size} }} />`,
...SEAM_SOURCE
})
try {
const closure = { local: ['src/ui/Sized.tsx'] }
expect(textInputFontSizeOffenders(root, closure)).toEqual(expected)
expect(unresolvedTextInputStyles(root, closure)).toEqual([])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
/**
* The floor really is the seam's, checked against a seam that does not say 16.
*
* The first version of this case planted a floor of 16 and asserted the census read 16, which a
* census carrying its own copy of the number passes just as happily. A tree whose seam says 20
* is the only fixture that can tell the two apart, and the 18 below is the size that is clean
* under one floor and an offence under the other.
*/
it('judges against the floor the seam declares, not against a number of its own', () => {
const root = plant({
'src/ui/Sized.tsx': 'export const Sized = () => <TextInput style={{ fontSize: 18 }} />',
'src/platform/text-input-font-size.web.ts': [
'export const TEXT_INPUT_FONT_SIZE_FLOOR = 20',
'export const TEXT_INPUT_FONT_SIZE = 20'
].join('\n')
})
try {
expect(textInputFontSizeFloor(root)).toBe(20)
expect(textInputFontSizeOffenders(root, { local: ['src/ui/Sized.tsx'] })).toEqual([
'src/ui/Sized.tsx:1'
])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('refuses to judge a tree whose seam declares no floor, rather than assuming one', () => {
const root = plant({
'src/ui/Sized.tsx': 'export const Sized = () => <TextInput style={{ fontSize: 22 }} />',
'src/platform/text-input-font-size.ts': 'export const TEXT_INPUT_FONT_SIZE = 14',
'src/platform/text-input-font-size.web.ts': 'export const TEXT_INPUT_FONT_SIZE = 16'
})
try {
expect(() => textInputFontSizeOffenders(root, { local: ['src/ui/Sized.tsx'] })).toThrow(
/declares no numeric/
)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('names a style shape it cannot follow rather than dropping it', () => {
const root = plant({
'src/ui/Called.tsx': 'export const Called = () => <TextInput style={makeStyle()} />'
})
try {
expect(unresolvedTextInputStyles(root, { local: ['src/ui/Called.tsx'] })).toEqual([
'src/ui/Called.tsx:1 (makeStyle())'
])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('lets a later spread beat a direct key, as the runtime object does', () => {
// `{ input: safe, ...legacy }` is `legacy.input` at runtime. Answering the direct key first
// read the safe one and called the override clean.
const root = plant({
'src/ui/Order.tsx': [
"import { styles } from './order-styles'",
'export const Order = () => <TextInput style={styles.input} />'
].join('\n'),
'src/ui/order-styles.ts': [
SEAM_IMPORT,
"import { legacy } from './legacy-styles'",
'export const styles = { input: { fontSize: TEXT_INPUT_FONT_SIZE }, ...legacy }'
].join('\n'),
'src/ui/legacy-styles.ts': 'export const legacy = { input: { fontSize: 14 } }',
...SEAM_SOURCE
})
try {
expect(
textInputFontSizeOffenders(root, {
local: [
'src/ui/Order.tsx',
'src/ui/order-styles.ts',
'src/ui/legacy-styles.ts',
...Object.keys(SEAM_SOURCE)
]
})
).toEqual(['src/ui/legacy-styles.ts:1'])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it.each([
['a local constant wearing the name', 'const TEXT_INPUT_FONT_SIZE = 14'],
['an import of the name from elsewhere', "import { TEXT_INPUT_FONT_SIZE } from './elsewhere'"]
])('reads the seam as a binding, not a spelling: %s', (_label, preamble) => {
const root = plant({
'src/ui/Shadow.tsx': [
"import { styles } from './shadow-styles'",
'export const Shadow = () => <TextInput style={styles.input} />'
].join('\n'),
'src/ui/shadow-styles.ts': [
preamble,
'export const styles = { input: { fontSize: TEXT_INPUT_FONT_SIZE } }'
].join('\n'),
'src/ui/elsewhere.ts': 'export const TEXT_INPUT_FONT_SIZE = 14',
...SEAM_SOURCE
})
try {
expect(
textInputFontSizeOffenders(root, {
local: [
'src/ui/Shadow.tsx',
'src/ui/shadow-styles.ts',
'src/ui/elsewhere.ts',
...Object.keys(SEAM_SOURCE)
]
})
).toEqual(['src/ui/shadow-styles.ts:2'])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})
/**
* The platform sibling, which is the file the page actually runs.
*
* `mobileWebAppRouteClosure` reports what esbuild resolved, and esbuild prefers `.web.tsx`/`.web.ts`
* ahead of the native file. A census that followed an import to the native sibling would judge a
* module no browser loads: it would clear a split whose web half is off the seam, and report one
* whose web half is on it. Both directions are below, because only one of them is a false pass.
*/
describe('a style module with a platform sibling', () => {
const FIELD = [
"import { styles } from './field-styles'",
'export const Field = () => <TextInput style={styles.input} />'
].join('\n')
it('is read through its .web sibling, so a raised web size clears the native one', () => {
const root = plant({
'src/ui/Field.tsx': FIELD,
'src/ui/field-styles.ts': 'export const styles = { input: { fontSize: 12 } }',
'src/ui/field-styles.web.ts': [
SEAM_IMPORT.replace('../platform', '../platform'),
'export const styles = { input: { fontSize: TEXT_INPUT_FONT_SIZE } }'
].join('\n'),
...SEAM_SOURCE
})
try {
const closure = { local: ['src/ui/Field.tsx', 'src/ui/field-styles.web.ts'] }
expect(textInputFontSizeOffenders(root, closure)).toEqual([])
expect(unresolvedTextInputStyles(root, closure)).toEqual([])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('is reported when the .web half is the one off the seam, native half notwithstanding', () => {
const root = plant({
'src/ui/Field.tsx': FIELD,
'src/ui/field-styles.ts': [
SEAM_IMPORT,
'export const styles = { input: { fontSize: TEXT_INPUT_FONT_SIZE } }'
].join('\n'),
'src/ui/field-styles.web.ts': 'export const styles = { input: { fontSize: 12 } }',
...SEAM_SOURCE
})
try {
expect(
textInputFontSizeOffenders(root, {
local: ['src/ui/Field.tsx', 'src/ui/field-styles.web.ts']
})
).toEqual(['src/ui/field-styles.web.ts:1'])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
/**
* The seam has a `.web.ts` sibling of its own, and that is the whole point of it: the native file
* is the app's body size and the web one raises it past the focus-zoom floor. Preferring the web
* file when following an import must not make every seam binding stop naming the seam.
*/
it('still counts the seam as the seam when the seam itself is the split one', () => {
const root = plant({
'src/ui/Field.tsx': FIELD,
'src/ui/field-styles.ts': [
SEAM_IMPORT,
'export const styles = { input: { fontSize: TEXT_INPUT_FONT_SIZE } }'
].join('\n'),
...SEAM_SOURCE,
'src/platform/text-input-font-size.web.ts': 'export const TEXT_INPUT_FONT_SIZE = 16'
})
try {
const closure = { local: ['src/ui/Field.tsx', 'src/ui/field-styles.ts'] }
expect(textInputFontSizeOffenders(root, closure)).toEqual([])
expect(unresolvedTextInputStyles(root, closure)).toEqual([])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})
describeClosure(
'the text inputs the source-control and review pages reach',
() => {
it.each([HUB, REVIEW])('takes every input size through the seam: %s', async (route) => {
const closure = await mobileWebAppRouteClosure(route)
expect(textInputFontSizeOffenders(mobileDir, closure)).toEqual([])
})
it.each([HUB, REVIEW])(
'reads every input it found, so the list above is complete: %s',
async (route) => {
const closure = await mobileWebAppRouteClosure(route)
expect(unresolvedTextInputStyles(mobileDir, closure)).toEqual([])
}
)
it.each([HUB, REVIEW])('carries the seam, so the rule is not vacuous: %s', async (route) => {
// Without this an empty offender list would also be what a closure reaching no text input at
// all produces, and the census would pass against a page that has nothing to raise.
const closure = await mobileWebAppRouteClosure(route)
expect(closure.local).toContain(TEXT_INPUT_FONT_SIZE_SEAM)
})
},
240_000
)