Adopted screenmap on a private Expo monorepo (project: apps/mobile) with a self-hosted macOS runner, driving screenmap-ci directly at c54219e. Four things needed local patches to work there; sharing them as diffs against that commit in case any are worth upstreaming. Happy to turn any of these into a PR.
1. PR lane: changed files are repo-relative, route files are project-relative → suspects are always empty in a monorepo
screenmap-ci runs git diff --name-only with cwd = project, but git prints paths relative to the repo root regardless of cwd, while diff-map.mjs suspects compares them against graph.routes[].file (project-relative, e.g. src/app/...). With project: apps/mobile every PR reports "No screen is affected". --relative fixes both the prefix and the scope:
diff --git a/action/cli/screenmap-ci.mjs b/action/cli/screenmap-ci.mjs
index 02a3dc2..b7fb392 100755
--- a/action/cli/screenmap-ci.mjs
+++ b/action/cli/screenmap-ci.mjs
@@ -256,7 +256,12 @@ async function baseline() {
if (opts.previous && exists(opts.previous) && !opts.full) {
prev = readBaseline(opts.previous, path.join(work, 'prev'))
prevCommit = prev.manifest.source?.commit
- const changed = prevCommit ? git(['diff', '--name-only', prevCommit, 'HEAD'], project) : null
+ // Rivory patch (scripts/ci/screenmap/git-diff-relative.patch): git prints
+ // repo-root paths whatever the cwd; suspects compare against the graph's
+ // PROJECT-relative route files, so in a monorepo subdirectory nothing ever
+ // matched and every PR reported "no screen is affected". --relative fixes
+ // both the prefix and the scope.
+ const changed = prevCommit ? git(['diff', '--name-only', '--relative', prevCommit, 'HEAD'], project) : null
if (changed === null) {
log('previous baseline commit not in history — doing a full capture')
prev = null
@@ -355,7 +360,8 @@ async function pr() {
const platforms = opts.platform ? [String(opts.platform)] : config.platforms
const multi = platforms.length > 1
let changed = opts['changed-files'] ? fs.readFileSync(opts['changed-files'], 'utf8').split('\n').filter(Boolean) : null
- if (!changed && baseSha) changed = (git(['diff', '--name-only', `${baseSha}...${headSha}`], project) ?? git(['diff', '--name-only', baseSha, headSha], project) ?? '').split('\n').filter(Boolean)
+ // Rivory patch: --relative (see the baseline command's note)
+ if (!changed && baseSha) changed = (git(['diff', '--name-only', '--relative', `${baseSha}...${headSha}`], project) ?? git(['diff', '--name-only', '--relative', baseSha, headSha], project) ?? '').split('\n').filter(Boolean)
if (!changed) throw new Error('cannot determine changed files: pass --changed-files <list> or make sure the base commit is fetched')
const diffDir = path.join(work, 'diff')
2. Composite install step cannot handle a pnpm monorepo sub-project
Install project dependencies runs in inputs.project, finds no pnpm-lock.yaml there (it lives at the workspace root), falls through to npm ci and fails ("can only install with an existing package-lock.json"). Running the lockfile detection from git rev-parse --show-toplevel (or letting the caller opt out of the install) would cover monorepos. I worked around it by driving the CLI from my own composite.
3. ensureBooted takes "whatever is booted" — on a shared mac that is another agent's simulator
On a machine where other automation keeps simulators booted, the lane installed the app onto and screenshotted someone else's device. A way to pin the device (env var, config key, or CLI flag) would make shared hosts safe:
--- a/action/cli/lib/sim.mjs
+++ b/action/cli/lib/sim.mjs
@@ -58,6 +58,22 @@
}
export async function ensureBooted(config) {
+ // Rivory patch (scripts/ci/screenmap/pin-simulator-by-udid.patch): on a
+ // shared mac other agents keep their own simulators booted, and "use whatever
+ // is booted" would hijack one of those. SCREENMAP_UDID pins the lane to the
+ // device the workflow created for it; unset, the upstream behaviour stands.
+ const pinned = process.env.SCREENMAP_UDID
+ if (pinned) {
+ const j = JSON.parse(sh('xcrun', ['simctl', 'list', 'devices', '-j']))
+ const dev = Object.values(j.devices).flat().find((d) => d.udid === pinned)
+ if (!dev) throw new Error(`SCREENMAP_UDID ${pinned} is not a known simulator`)
+ if (dev.state !== 'Booted') {
+ log(`booting pinned simulator ${dev.name} (${pinned})`)
+ sh('xcrun', ['simctl', 'boot', pinned])
+ sh('xcrun', ['simctl', 'bootstatus', pinned, '-b'])
+ } else log(`using pinned simulator ${dev.name} (${pinned})`)
+ return { id: pinned, name: dev.name }
+ }
const booted = listBooted()
if (booted.length) { log(`simulator already booted: ${booted[0].name} (${booted[0].id})`); return booted[0] }
const j = JSON.parse(sh('xcrun', ['simctl', 'list', 'devices', 'available', '-j']))
4. Deterministic lane: honest capture statuses + recovery for two stuck states
With no agent key every deep-link capture is filed as ok. On the first map that meant 76 auth walls, ~13 captures showing the previous route (a modal swallowed the next deep link), one expo-dev-launcher and four React error screens, all "ok". The OCR helper is already in the tree for landing checks, so a cheap classifier can stamp error-boundary / auth-wall / not-found / missing with a note, relaunch after an error boundary (they stick and poison the next route), and relaunch-and-retry once when the launcher or a capture byte-identical to the previous route's shows up. Regexes are app-agnostic (React "Render Error", expo-dev-launcher strings, common not-found copy) except the auth-wall row, which each app would set:
diff --git a/action/cli/screenmap-ci.mjs b/action/cli/screenmap-ci.mjs
index a5e903d..02a3dc2 100755
--- a/action/cli/screenmap-ci.mjs
+++ b/action/cli/screenmap-ci.mjs
@@ -27,6 +27,39 @@
import fs from 'node:fs'
import path from 'node:path'
import { parseArgs, loadConfig, platformConfig, readJson, writeJson, ensureDir, exists, log, sh, deepLinkFor } from './lib/util.mjs'
+import { createHash } from 'node:crypto'
+import { ocr as ocrShot, ocrAvailable as ocrReady } from './lib/ocr.mjs'
+
+// ---- Rivory patch (scripts/ci/screenmap/classify-captures.patch) -----------
+// The keyless lane deep-links unchecked, so a React error screen, the sign-in
+// wall, a not-found screen or the expo-dev-launcher all land in the map as
+// "ok". OCR every deep-link capture (Apple Vision / tesseract, already in the
+// tree for landing checks) and give it an honest status. Two states are also
+// recovered: the launcher (re-nudge the app once) and a capture identical to
+// the previous route's — a modal that swallowed the deep link — by relaunching
+// and retrying once. An error boundary triggers a relaunch AFTER the capture
+// because it sticks and would poison the next route. Audit that produced this:
+// Rivory issue #1042.
+function hashFile(p) { try { return createHash('md5').update(fs.readFileSync(p)).digest('hex') } catch { return null } }
+const VERDICT_SIGNS = [
+ { kind: 'launcher', status: 'missing', retry: true, re: /development build|development servers|searching for development|enter url manually/i, note: 'expo-dev-launcher captured instead of the app' },
+ { kind: 'error-boundary', status: 'error-boundary', retry: false, re: /render error|something went wrong|error boundary|invariant violation|cannot read propert|is not a function/i, note: 'React error screen' },
+ { kind: 'auth-wall', status: 'auth-wall', retry: false, re: /join or sign in|continue with email|continue with apple/i, note: 'sign-in wall — the simulator is not signed in' },
+ { kind: 'not-found', status: 'not-found', retry: false, re: /unmatched route|page not found|screen doesn.t exist|could not be found/i, note: 'not-found screen' },
+]
+function verdictFor(shot, prevHash) {
+ let items = []
+ try { items = ocrReady() ? ocrShot(shot) : [] } catch { items = [] }
+ const text = items.map((i) => i.text).join(' ')
+ for (const sign of VERDICT_SIGNS) {
+ if (!sign.re.test(text)) continue
+ const note = sign.kind === 'error-boundary' ? `${sign.note}: ${text.replace(/^[\s\S]*?render error/i, '').trim().slice(0, 160)}` : sign.note
+ return { kind: sign.kind, status: sign.status, retry: sign.retry, note }
+ }
+ if (prevHash && hashFile(shot) === prevHash) return { kind: 'stale', status: 'ok', retry: true, note: 'capture identical to the previous route — the deep link may not have navigated' }
+ return null
+}
+// ---------------------------------------------------------------------------
import { openSession } from './lib/device.mjs'
import { readBaseline, parseRoutes, computeSuspects, packBaseline, packDiff, downscaleAll, baselineSide, platformsIn } from './lib/bundle.mjs'
import { loadFlows, replayFlow, verifyLanding, verifyDeepLink } from './lib/replay.mjs'
@@ -53,7 +86,8 @@ const copyShots = (fromDir, toDir, slug) => {
// Capture a list of routes on the live session: committed flow replay first,
// deep link otherwise, agent for what's left (budgeted). Shared by both jobs.
async function captureRoutes({ project, config, scheme, session, routes, flows, outDir, work, agentMode, prContext, agentEnabled }) {
- const result = { replay: [], deeplink: [], agent: [], failed: [], unflowed: [], drifted: [], unverified: [], navigationOnly: [] }
+ const result = { replay: [], deeplink: [], agent: [], failed: [], unflowed: [], drifted: [], unverified: [], navigationOnly: [], verdicts: {} }
+ let prevShotHash = null // Rivory patch: last deep-link capture, to catch a swallowed deep link
const canReplay = flows.size > 0 && argentAvailable()
if (flows.size > 0 && !canReplay) log('argent not available — committed flows will not be replayed this run')
for (const r of routes) {
@@ -100,6 +134,23 @@ async function captureRoutes({ project, config, scheme, session, routes, flows,
const wait = (r.params?.length ? config.waits.network : config.waits.transition)
const shot = path.join(outDir, `${r.slug}.png`)
await session.visit(deepLinkFor(scheme, r, config.params), shot, wait)
+ // Rivory patch: honest status + recovery for the launcher and a swallowed deep link
+ let verdict = verdictFor(shot, prevShotHash)
+ if (verdict?.retry) {
+ log(`${r.id}: ${verdict.note} — relaunching and retrying the deep link once`)
+ await session.relaunch()
+ await session.visit(deepLinkFor(scheme, r, config.params), shot, wait)
+ const again = verdictFor(shot, prevShotHash)
+ verdict = again?.retry
+ ? { kind: again.kind, status: again.kind === 'launcher' ? 'missing' : 'ok', retry: false, note: `${again.note} (still, after a relaunch and retry)` }
+ : again
+ }
+ if (verdict) {
+ result.verdicts[r.id] = { status: verdict.status, note: verdict.note }
+ log(`${r.id}: ${verdict.status} — ${verdict.note}`)
+ if (verdict.status === 'error-boundary') await session.relaunch() // error boundaries stick
+ }
+ prevShotHash = hashFile(shot)
result.deeplink.push(r.id)
// A resolved deep link is not an arrived one. Check it the same way a
// replay is checked, so a route that quietly renders its not-found
@@ -253,6 +304,7 @@ async function baseline() {
} finally { session.close() }
}
for (const id of cap.failed) captureStatus[id] = { status: 'missing', note: `deep link failed in CI (${platform})` }
+ for (const [id, v] of Object.entries(cap.verdicts ?? {})) captureStatus[id] = v // Rivory patch
downscaleAll(screensDir)
sides.push({ platform, device: deviceName, screensDir, cap, captureStatus, reused })
}
@@ -347,6 +399,7 @@ async function pr() {
if (cap.notes && !exists(path.join(diffDir, 'notes.json'))) writeJson(path.join(diffDir, 'notes.json'), cap.notes)
const headStatus = {}
for (const id of cap.failed) headStatus[id] = { status: 'missing', note: `deep link failed in CI (${platform})` }
+ for (const [id, v] of Object.entries(cap.verdicts ?? {})) headStatus[id] = v // Rivory patch
headStatusByPlatform[platform] = headStatus
downscaleAll(headScreens)
sides.push({ platform, device: deviceName, cap })
Two smaller notes: parse-routes.mjs needs an absolute project path (a relative one fails provider detection with "no app/ or src/app/"), and on iOS 26 the "Open in …?" scheme prompt appears for a simctl-booted device even after approveScheme unless SpringBoard is resprung — you already do that in sim.mjs, mentioning it only because a runner that boots the device itself needs the same.
Thanks for screenmap — the map-plus-PR-diff loop is exactly the shape we wanted, and every patch above is small because the code is easy to follow.
Adopted screenmap on a private Expo monorepo (
project: apps/mobile) with a self-hosted macOS runner, drivingscreenmap-cidirectly atc54219e. Four things needed local patches to work there; sharing them as diffs against that commit in case any are worth upstreaming. Happy to turn any of these into a PR.1. PR lane: changed files are repo-relative, route files are project-relative → suspects are always empty in a monorepo
screenmap-cirunsgit diff --name-onlywithcwd = project, but git prints paths relative to the repo root regardless of cwd, whilediff-map.mjs suspectscompares them againstgraph.routes[].file(project-relative, e.g.src/app/...). Withproject: apps/mobileevery PR reports "No screen is affected".--relativefixes both the prefix and the scope:2. Composite install step cannot handle a pnpm monorepo sub-project
Install project dependenciesruns ininputs.project, finds nopnpm-lock.yamlthere (it lives at the workspace root), falls through tonpm ciand fails ("can only install with an existing package-lock.json"). Running the lockfile detection fromgit rev-parse --show-toplevel(or letting the caller opt out of the install) would cover monorepos. I worked around it by driving the CLI from my own composite.3.
ensureBootedtakes "whatever is booted" — on a shared mac that is another agent's simulatorOn a machine where other automation keeps simulators booted, the lane installed the app onto and screenshotted someone else's device. A way to pin the device (env var, config key, or CLI flag) would make shared hosts safe:
4. Deterministic lane: honest capture statuses + recovery for two stuck states
With no agent key every deep-link capture is filed as
ok. On the first map that meant 76 auth walls, ~13 captures showing the previous route (a modal swallowed the next deep link), one expo-dev-launcher and four React error screens, all "ok". The OCR helper is already in the tree for landing checks, so a cheap classifier can stamperror-boundary/auth-wall/not-found/missingwith a note, relaunch after an error boundary (they stick and poison the next route), and relaunch-and-retry once when the launcher or a capture byte-identical to the previous route's shows up. Regexes are app-agnostic (React "Render Error", expo-dev-launcher strings, common not-found copy) except the auth-wall row, which each app would set:Two smaller notes:
parse-routes.mjsneeds an absolute project path (a relative one fails provider detection with "no app/ or src/app/"), and on iOS 26 the "Open in …?" scheme prompt appears for a simctl-booted device even afterapproveSchemeunless SpringBoard is resprung — you already do that insim.mjs, mentioning it only because a runner that boots the device itself needs the same.Thanks for screenmap — the map-plus-PR-diff loop is exactly the shape we wanted, and every patch above is small because the code is easy to follow.