Skip to content

Monorepo + shared-runner findings, with patches: relative diff paths, install step, simulator pinning, honest deterministic-lane statuses #10

Description

@KShad10

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions