~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
273 lines · 13.1 KB Raw History
// 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))