johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
// A look at the iOS app's screens as this checkout draws them, in any state,
// as pictures to open: what `just look` is for the pages. The app answers from
// harness/shots/fixtures.json, with what that doesn't record from
// look-answers.json, so nothing reaches a server and a write changes nothing,
// but for a picture Markdown names on another site, which loads from there.
// Needs a Mac with Xcode and xcodegen, so it isn't a check.
//
// node ios/look.mjs <screen|route>... [options] `just ios-look`
//
// A screen is a name below, or a route as DevRoute in Knotless/App.swift takes
// it. Options:
//
// --set <method>.<field>=<json> change what a method answers, as
// getMergeStatus.state=conflicting; a value
// that isn't JSON is a string, @file.json is
// the file's JSON, and null takes it out
// --signed-out as no one; signed in as alice.test otherwise
// --tap <label> tap what has that label or accessibility
// identifier, scrolling to it, in turn; or
// @x,y, that point on the screen, as for a
// link in some text, which has no label
// --type <text> type into the first text box, in turn
// --swipe <up|down|left|right> swipe, in turn
// --top only the screen as it opens, not all of it
// --light --dark only that; both if neither is given
// --theme <name> in a theme the pages offer, as dracula, or a
// day and a night one, as latte/mocha, which
// --light and --dark then choose between
//
// A changed answer must still be as its method's lexicon says, when `npm ci`
// has been run in harness/. A method nothing answers is said, as the screen
// then shows what it shows when a read fails. The pictures go to target/look,
// each path printed: <screen>-<light|dark>.png, then -2, -3 for what's further down.
import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ios = dirname(fileURLToPath(import.meta.url))
const root = dirname(ios)
const out = join(root, 'target/look')
const work = join(out, '.ios')
// When the world was recorded, so times read the same on any day.
const now = '2026-10-20T14:00:00Z'
const device = process.env.KNOTLESS_DEVICE ?? 'iPhone 17 Pro'
const say = (line) => console.log(`==> ${line}`)
const fail = (why) => {
say(why)
process.exit(2)
}
const targets = []
const sets = []
const steps = []
const schemes = new Set()
let signedIn = true
let top = false
let theme = null
const argv = process.argv.slice(2)
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
const next = () => argv[++i] ?? fail(`${a} needs a value`)
if (a === '--set') {
const v = next()
const [, method, path, value] = v.match(/^([^.=]+)\.([^=]+)=(.*)$/s) ?? fail(`--set ${v}: not <method>.<field>=<json>`)
let parsed
if (value.startsWith('@')) parsed = JSON.parse(readFileSync(value.slice(1), 'utf8'))
else {
try {
parsed = JSON.parse(value)
} catch {
parsed = value
}
}
sets.push({ method, path: path.split('.'), value: parsed })
} else if (a === '--signed-out') signedIn = false
else if (a === '--tap') steps.push({ tap: next() })
else if (a === '--type') steps.push({ type: next() })
else if (a === '--swipe') steps.push({ swipe: next() })
else if (a === '--top') top = true
else if (a === '--light' || a === '--dark') schemes.add(a.slice(2))
else if (a === '--theme') theme = next()
else if (a.startsWith('--')) fail(`${a}: no such option`)
else targets.push(a)
}
const answers = []
const prefix = '/xrpc/dev.knotless.'
const recorded = JSON.parse(readFileSync(join(root, 'harness/shots/fixtures.json'), 'utf8')).answers
for (const [url, answer] of Object.entries(recorded)) {
if (url.includes('/raw/') && answer.body) answers.push({ method: 'file', params: { path: url }, status: 200, body: answer.body })
if (!url.startsWith(prefix) || !answer.body) continue
const [method, query] = url.slice(prefix.length).split('?')
answers.push({ method, params: Object.fromEntries(new URLSearchParams(query ?? '')), status: answer.status, body: answer.body })
}
const threads = answers.filter((a) => a.method === 'getThread' && a.status === 200).map((a) => ({ ...JSON.parse(a.body), repo: a.params.repo }))
for (const [method, body] of Object.entries(JSON.parse(readFileSync(join(ios, 'look-answers.json'), 'utf8')))) {
answers.unshift({ method, params: {}, status: 200, body: JSON.stringify(body) })
}
if (!signedIn) answers.unshift({ method: 'getViewer', params: {}, status: 200, body: JSON.stringify({ login: false }) })
// The diff of each file a recorded thread changes, as only their lists were recorded.
for (const file of threads.flatMap((t) => t.changes?.files ?? [])) {
const rows = [
{ kind: 'h', line: false, text: '@@ -1,4 +1,4 @@' },
{ kind: '', line: true, old: 1, new: 1, text: 'use std::io;' },
{ kind: 'd', line: true, old: 2, text: 'fn count(input: &str) -> usize {' },
{ kind: 'a', line: true, new: 2, text: 'fn count(input: &[u8]) -> usize {' },
{ kind: '', line: true, old: 3, new: 3, text: ' input.len()' },
{ kind: '', line: true, old: 4, new: 4, text: '}' },
]
answers.push({ method: 'getPullDiff', params: { paths: file.new || file.old }, status: 200, body: JSON.stringify({ files: [{ ...file, rows }] }) })
}
// Whether each recorded pull merges, as only the pages' signed-in ask would have
// recorded that: as its recorded checks stand.
const defaults = JSON.parse(answers.find((a) => a.method === 'getMergeStatus').body)
for (const t of threads.filter((t) => t.item.kind === 'pull')) {
const stands = (...of) => t.checks?.some((c) => of.includes(c.status))
const checks = !t.checks?.length ? 'none' : stands('failure', 'error') ? 'failed' : stands('queued', 'running', 'pending') ? 'running' : 'passed'
const state = t.item.draft ? 'draft' : ['passed', 'none'].includes(checks) ? 'clean' : 'blocked'
answers.unshift({ method: 'getMergeStatus', params: { pull: t.item.uri }, status: 200, body: JSON.stringify({ ...defaults, state, checks }) })
}
const thread = (kind, test) => {
const t = threads.find((t) => t.item.kind === kind && test(t))
if (!t) return null
const [, owner, name] = t.repo.match(/^at:\/\/([^/]+)\/dev\.knotless\.repo\/(.+)$/)
return `thread|${owner}|${owner}|${name}|${t.item.uri}`
}
const tally = 'alice.test|alice.test|tally'
// The run the recording has whole, its steps and log.
const ranCheck = 'at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.check/qdjbmvovu2tnodagwluxk6jxmq.3myvwh72yvtgz.uuiududme6qwohxvshbn32i4nw.bea82d97f7448b5d6d93b6a8a108ccb9d86277ad.test'
const pullOpen = thread('pull', (t) => t.item.state === 'open' && !t.queue)
const screens = {
home: 'home',
inbox: 'inbox',
explore: 'explore',
profile: 'profile',
repository: `repo|${tally}`,
commits: `history|${tally}`,
'my-issues': 'mine|issue',
'my-pulls': 'mine|pull',
starred: 'starred|alice.test',
organizations: 'organizations|alice.test',
followers: 'followers|alice.test',
following: 'following|alice.test',
blocks: 'blocks',
search: 'search',
'search-code': 'search-code|count',
'search-repositories': 'search-repositories|count',
'search-people': 'search-people|t',
commit: `commit|${tally}|1be297a440088f5808a7cdcf5254ac71c024458f`,
releases: `releases|${tally}`,
actions: `runs|${tally}`,
'workflow-runs': `workflow-runs|${tally}|test`,
run: `run|${tally}|${ranCheck}`,
'run-step': `step|${tally}|${ranCheck}|1`,
person: 'user|bob.test',
settings: 'settings',
'notification-settings': 'notification-settings',
appearance: 'appearance',
repositories: 'repositories|alice.test',
pulls: `items|${tally}|pull`,
issues: `items|${tally}|issue`,
'pull-open': pullOpen,
'pull-merged': thread('pull', (t) => t.item.state === 'merged'),
'pull-queued': thread('pull', (t) => t.queue?.state === 'queued'),
'pull-ejected': thread('pull', (t) => t.queue?.state === 'ejected'),
'issue-open': thread('issue', (t) => t.item.state === 'open'),
'issue-closed': thread('issue', (t) => t.item.state === 'closed'),
files: pullOpen?.replace(/^thread/, 'files'),
'pull-commits': pullOpen?.replace(/^thread/, 'commits'),
directory: `tree|${tally}|src`,
file: `file|${tally}|src/lib.rs`,
}
if (!targets.length) fail(`which screen? one of ${Object.keys(screens).join(', ')}, or a route`)
function changed(body, change) {
let at = body
const path = change.path
for (const key of path.slice(0, -1)) at = at[key] ??= {}
const last = path[path.length - 1]
if (change.value === null) delete at[last]
else at[last] = change.value
}
for (const change of sets) {
const these = answers.filter((a) => a.method === change.method)
if (!these.length) fail(`--set ${change.method}: no answer of that method, here or in ios/look-answers.json`)
for (const answer of these) {
const body = JSON.parse(answer.body)
changed(body, change)
answer.body = JSON.stringify(body)
}
}
// The lexicons, to hold what's been made up or changed to its method's.
const require = createRequire(join(root, 'harness/package.json'))
let Lexicons
try {
;({ Lexicons } = require('@atproto/lexicon'))
} catch {
say('answers not checked against the lexicons: npm ci in harness/ for that')
}
if (Lexicons) {
const dir = join(root, 'lexicons/dev/knotless')
const docs = readdirSync(dir, { recursive: true }).filter((f) => f.endsWith('.json')).map((f) => JSON.parse(readFileSync(join(dir, f), 'utf8')))
const lexicons = new Lexicons(docs)
const held = new Set(['getViewer', 'getActorStars', 'getFollowers', 'getFollows', 'getMergeStatus', 'listNotifications', 'getPullDiff', 'getPreferences', 'listWorkflows', ...sets.map((s) => s.method)])
for (const answer of answers) {
if (!held.has(answer.method) || answer.status !== 200) continue
try {
lexicons.assertValidXrpcOutput(`dev.knotless.${answer.method}`, JSON.parse(answer.body))
} catch (e) {
fail(`${answer.method}: the answer is not as its lexicon says, so no server could give it: ${e.message}`)
}
}
}
rmSync(work, { recursive: true, force: true })
mkdirSync(work, { recursive: true })
const answersFile = join(work, 'answers.json')
const missesFile = join(work, 'misses.txt')
writeFileSync(answersFile, JSON.stringify(answers))
const runs = []
for (const target of targets) {
const route = target.includes('|') ? target : screens[target]
if (!route) fail(`${target}: no such screen, or the recording has none: ${Object.keys(screens).join(', ')}`)
const name = target.includes('|') ? target.replace(/[^a-z0-9]+/gi, '-').slice(0, 40) : target
for (const scheme of schemes.size ? schemes : ['light', 'dark']) {
const themed = theme ? `-${theme.replace(/[^a-z0-9]+/gi, '-')}` : ''
runs.push({ route, scheme, theme, steps, top, out: join(out, `ios-${name}${themed}-${scheme}`) })
}
}
const plan = join(work, 'plan.json')
writeFileSync(plan, JSON.stringify({ answers: answersFile, misses: missesFile, now, signedIn, runs }))
// A run's pictures, by the name each is given: <run> or <run>-<page>.
const picturesOf = (r) => {
const base = r.out.split('/').pop()
return readdirSync(out).filter((f) => f === `${base}.png` || new RegExp(`^${base.replace(/[^a-z0-9-]/gi, '\\$&')}-\\d+\\.png$`).test(f)).sort()
}
for (const r of runs) for (const file of picturesOf(r)) rmSync(join(out, file))
const run = (cmd, args, env = {}) => execFileSync(cmd, args, { cwd: ios, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1 << 28 }).toString()
const xcode = ['-project', 'Knotless.xcodeproj', '-scheme', 'Knotless', '-jobs', '2', '-destination', `platform=iOS Simulator,name=${device}`, '-derivedDataPath', 'build', '-quiet']
try {
run('xcodegen', ['generate', '--quiet'])
run('xcodebuild', ['build-for-testing', ...xcode])
} catch (e) {
console.log(`${e.stdout ?? ''}${e.stderr ?? ''}`.split('\n').filter((l) => /error:/.test(l)).slice(0, 12).join('\n'))
fail('the build failed')
}
try {
run('xcrun', ['simctl', 'boot', device])
} catch {
// Already booted.
}
run('xcrun', ['simctl', 'status_bar', device, 'override', '--time', '9:41', '--dataNetwork', 'wifi', '--wifiBars', '3', '--cellularBars', '4', '--batteryState', 'charged', '--batteryLevel', '100'])
try {
run('xcodebuild', ['test-without-building', ...xcode, '-only-testing:KnotlessUITests/LookTests/testLook'], { TEST_RUNNER_KNOTLESS_LOOK: plan })
} catch (e) {
const told = `${e.stdout ?? ''}${e.stderr ?? ''}`
console.log(told.split('\n').filter((l) => /LOOK|failed -|error:/.test(l)).slice(0, 12).join('\n'))
say('a step failed: its screen is still shot, as it then stood')
process.exitCode = 1
}
if (existsSync(missesFile)) {
for (const miss of new Set(readFileSync(missesFile, 'utf8').split('\n').filter(Boolean))) say(`nothing answers ${miss}`)
}
for (const r of runs) for (const file of picturesOf(r)) console.log(join(out, file))