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

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

just ios-look draws the iOS app's screens in any state

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:feature/ios-look

Only what this pull adds. The iOS app comments, reviews, reacts, merges, queues, closes and stars landed as c8dcbf2, so its changes do not show here again.

ios/Knotless/API/Client.swift+71−1
@@ -1,4 +1,5 @@
import Foundation
import SwiftUI
struct APIError: LocalizedError {
let status: Int
@@ -28,6 +29,11 @@ actor Client {
func query<T: Decodable & Sendable>(_ method: String, _ params: [String: String?] = [:]) async throws -> T {
let nsid = "dev.knotless." + method
#if DEBUG
if let recorded = DevAccount.recording {
return try decoder.decode(T.self, from: recorded.answer(method, params))
}
#endif
var parts = URLComponents(url: Self.site.appendingPathComponent("xrpc/" + nsid), resolvingAgainstBaseURL: false)!
parts.queryItems = params.compactMap { k, v in v.map { URLQueryItem(name: k, value: $0) } }.sorted { $0.name < $1.name }
var request = URLRequest(url: parts.url!)
@@ -39,6 +45,9 @@ actor Client {
/// A write, as the account signed in. What it answers is left, as a screen
/// that writes reads what it shows again.
func call(_ method: String, _ input: [String: String]) async throws {
#if DEBUG
if DevAccount.recording != nil { return }
#endif
var request = URLRequest(url: Self.site.appendingPathComponent("xrpc/dev.knotless." + method))
request.httpMethod = "POST"
request.timeoutInterval = 60
@@ -101,7 +110,12 @@ enum DevAccount {
#if DEBUG
private static let env = ProcessInfo.processInfo.environment
static var route: String? { env["KNOTLESS_ROUTE"] }
static var signedIn: Bool { !tokens.isEmpty }
static var signedIn: Bool { !tokens.isEmpty || env["KNOTLESS_SIGNED_IN"] != nil }
/// The time to read others against, so a picture reads the same on any day.
static let now: Date? = env["KNOTLESS_NOW"].flatMap { ISO8601DateFormatter().date(from: $0) }
static let scheme: ColorScheme? = ["light": ColorScheme.light, "dark": .dark][env["KNOTLESS_SCHEME"] ?? ""]
/// Answers read from a file rather than a server, for `ios/look.mjs`.
static let recording: Recording? = env["KNOTLESS_ANSWERS"].flatMap { Recording(path: $0, misses: env["KNOTLESS_MISSES"]) }
private static let tokens: [String: String] = {
guard let path = env["KNOTLESS_TOKENS"],
@@ -115,6 +129,62 @@ enum DevAccount {
#else
static let route: String? = nil
static let signedIn = false
static let now: Date? = nil
static let scheme: ColorScheme? = nil
static func token(for nsid: String) -> String? { nil }
#endif
}
#if DEBUG
/// What `ios/look.mjs` has the app answered with. A method's answer is the one
/// whose parameters best match what was asked, as a recording's rarely match
/// the app's exactly.
final class Recording: Sendable {
private struct Answer: Decodable, Sendable {
let method: String
let params: [String: String]
let status: Int
let body: String
}
private let answers: [Answer]
private let misses: String?
init?(path: String, misses: String?) {
guard let data = FileManager.default.contents(atPath: path),
let answers = try? JSONDecoder().decode([Answer].self, from: data)
else { return nil }
self.answers = answers
self.misses = misses
}
func answer(_ method: String, _ params: [String: String?]) throws -> Data {
let asked = params.compactMapValues { $0 }
func score(_ answer: Answer) -> Int {
asked.reduce(0) { sum, pair in
guard let value = answer.params[pair.key] ?? answer.params.values.first(where: { $0.hasSuffix("/" + pair.value) }) else { return sum }
return value == pair.value || value.hasSuffix("/" + pair.value) ? sum + 1 : sum
}
}
var best: Answer?
var bestScore = -1
for answer in answers where answer.method == method && answer.status == 200 && score(answer) > bestScore {
best = answer
bestScore = score(answer)
}
guard let best else {
if let misses, let line = "\(method)\n".data(using: .utf8) {
if let file = FileHandle(forWritingAtPath: misses) {
file.seekToEndOfFile()
file.write(line)
file.closeFile()
} else {
FileManager.default.createFile(atPath: misses, contents: line)
}
}
throw APIError(status: 404, name: nil, message: "Nothing recorded for \(method).")
}
return Data(best.body.utf8)
}
}
#endif
ios/Knotless/Support/Theme.swift+1−1
@@ -72,7 +72,7 @@ enum When {
static func short(_ iso: String?) -> String {
guard let iso, let date = withFraction.date(from: iso) ?? plain.date(from: iso) else { return "" }
let s = Int(Date().timeIntervalSince(date))
let s = Int((DevAccount.now ?? Date()).timeIntervalSince(date))
switch s {
case ..<60: return "now"
case ..<3600: return "\(s / 60)m"
ios/Knotless/Views/ProfileView.swift+1−1
@@ -48,7 +48,7 @@ struct ProfileScreen: View {
}
}
if let about = p.description, !about.isEmpty { Text(about) }
Text("\(Text("\(p.followersCount ?? 0)").fontWeight(.semibold)) followers \(Text("\(p.followsCount ?? 0)").fontWeight(.semibold)) following")
Text("\(Text("\(p.followersCount ?? 0)").fontWeight(.semibold)) \((p.followersCount ?? 0) == 1 ? "follower" : "followers") \(Text("\(p.followsCount ?? 0)").fontWeight(.semibold)) following")
.font(.subheadline)
}
.padding(.vertical, 4)
ios/Knotless/Views/Writing.swift+2−2
@@ -116,7 +116,7 @@ struct ReactionBar: View {
Image(systemName: "face.smiling")
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(Color(.secondarySystemBackground), in: Capsule())
.background(Color(.tertiarySystemFill), in: Capsule())
}
.accessibilityLabel("Add a reaction")
}
@@ -129,7 +129,7 @@ struct ReactionBar: View {
.foregroundStyle(.primary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(reaction.reaction != nil ? Color.accentSoft : Color(.secondarySystemBackground), in: Capsule())
.background(reaction.reaction != nil ? Color.accentSoft : Color(.tertiarySystemFill), in: Capsule())
}
.buttonStyle(.borderless)
.disabled(!account.signedIn)
ios/Knotless/App.swift+3−2
@@ -5,7 +5,7 @@ struct KnotlessApp: App {
@State private var account = Account()
var body: some Scene {
WindowGroup { RootView().tint(.accent).environment(account).task { await account.restore() } }
WindowGroup { RootView().tint(.accent).environment(account).preferredColorScheme(DevAccount.scheme).task { await account.restore() } }
}
}
@@ -68,7 +68,7 @@ struct RootView: View {
}
/// Opens a screen at launch, for `ios/run.sh`: `inbox`, or
/// `thread|<did>|<handle>|<name>|<uri>`, `files|…`, `repo|<did>|<handle>|<name>`.
/// `thread|<did>|<handle>|<name>|<uri>`, `files|…`, `repo|<did>|<handle>|<name>`, `tree|…|<path>`.
enum DevRoute {
static func parse(_ text: String?) -> (page: RootView.Page, path: [Route]) {
guard let text else { return (.home, []) }
@@ -78,6 +78,7 @@ enum DevRoute {
let repo = RepoRef(did: p[1], handle: p[2], name: p[3])
switch p[0] {
case "repo": return (.home, [.repo(repo)])
case "tree" where p.count == 5: return (.home, [.tree(repo, ref: nil, path: p[4])])
case "thread" where p.count == 5: return (.home, [.thread(repo, uri: p[4])])
case "files" where p.count == 5: return (.home, [.files(repo, uri: p[4])])
case "items" where p.count == 5: return (.home, [.items(repo, kind: p[4])])
ios/KnotlessUITests/LookTests.swiftnew+92−0
@@ -0,0 +1,92 @@
import XCTest
/// Draws the screens `ios/look.mjs` asks for. It's that script's hands, not a
/// test: with no plan it's skipped.
final class LookTests: XCTestCase {
private struct Plan: Decodable {
let answers: String
let misses: String
let now: String
let signedIn: Bool
let runs: [Run]
}
private struct Run: Decodable {
let route: String
let scheme: String
let steps: [Step]
let top: Bool
let out: String
}
private struct Step: Decodable {
var tap: String?
var type: String?
var swipe: String?
}
func testLook() throws {
guard let path = ProcessInfo.processInfo.environment["KNOTLESS_LOOK"] else {
throw XCTSkip("ios/look.mjs names the plan")
}
let plan = try JSONDecoder().decode(Plan.self, from: Data(contentsOf: URL(fileURLWithPath: path)))
continueAfterFailure = true
for run in plan.runs {
let app = XCUIApplication()
app.launchEnvironment = [
"KNOTLESS_ANSWERS": plan.answers,
"KNOTLESS_MISSES": plan.misses,
"KNOTLESS_NOW": plan.now,
"KNOTLESS_ROUTE": run.route,
"KNOTLESS_SCHEME": run.scheme,
]
if plan.signedIn { app.launchEnvironment["KNOTLESS_SIGNED_IN"] = "1" }
app.launch()
sleep(2)
for step in run.steps { take(step, in: app) }
sleep(1)
var last = shot(to: "\(run.out).png")
guard !run.top else { continue }
for page in 2...8 {
app.swipeUp()
// Till the scroll bar has gone, or the end looks like a move.
sleep(3)
let now = XCUIScreen.main.screenshot().pngRepresentation
if now == last { break }
try? now.write(to: URL(fileURLWithPath: "\(run.out)-\(page).png"))
last = now
}
}
}
private func shot(to path: String) -> Data {
let png = XCUIScreen.main.screenshot().pngRepresentation
try? png.write(to: URL(fileURLWithPath: path))
return png
}
private func take(_ step: Step, in app: XCUIApplication) {
if let label = step.tap {
let match = NSPredicate(format: "label == %@ OR identifier == %@", label, label)
let found = app.descendants(matching: .any).matching(match).firstMatch
for _ in 0..<8 where !(found.exists && found.isHittable) { app.swipeUp() }
guard found.exists, found.isHittable else {
XCTFail("LOOK: nothing to tap called \(label)")
return
}
found.tap()
sleep(1)
}
if let text = step.type {
let box = app.textViews.firstMatch.exists ? app.textViews.firstMatch : app.textFields.firstMatch
guard box.waitForExistence(timeout: 5) else {
XCTFail("LOOK: nothing to type into")
return
}
box.tap()
box.typeText(text)
}
if step.swipe == "up" { app.swipeUp() }
if step.swipe == "down" { app.swipeDown() }
}
}
ios/look-answers.jsonnew+34−0
@@ -0,0 +1,34 @@
{
"getViewer": {"login": true, "actor": {"did": "did:plc:55j6e4wxzlzndxm4nov2lz7x", "handle": "alice.test"}},
"getActorStars": {"repos": []},
"getMergeStatus": {
"state": "clean",
"checks": "passed",
"method": "merge",
"base": "1111111111111111111111111111111111111111",
"head": "2222222222222222222222222222222222222222",
"may": {"merge": true, "force": true, "queue": true, "base": true, "head": true, "rerun": true}
},
"listNotifications": {
"notifications": [
{
"uri": "at://did:plc:abe24xewga4qdbzjcxmuzdt3/dev.knotless.comment/3myvwhcomment1",
"author": {"did": "did:plc:abe24xewga4qdbzjcxmuzdt3", "handle": "bob.test"},
"reason": "comment",
"title": "Print counts as JSON with --json",
"text": "This looks right to me, but `--json` should imply `--quiet`.",
"isRead": false,
"createdAt": "2026-10-20T11:00:00Z"
},
{
"uri": "at://did:plc:abe24xewga4qdbzjcxmuzdt3/dev.knotless.issue/3myvwhissue2",
"author": {"did": "did:plc:abe24xewga4qdbzjcxmuzdt3", "handle": "bob.test"},
"reason": "issue",
"title": "Fails on files that are not UTF-8",
"text": "It stops at the first bad byte.",
"isRead": true,
"createdAt": "2026-10-18T09:00:00Z"
}
]
}
}
ios/look.mjsnew+237−0
@@ -0,0 +1,237 @@
// 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.
// 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
// --type <text> type into the first text box, in turn
// --swipe <up|down> swipe, in turn
// --top only the screen as it opens, not all of it
// --light --dark only that; both if neither is given
//
// 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
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.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.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'
const pullOpen = thread('pull', (t) => t.item.state === 'open' && !t.queue)
const screens = {
home: 'home',
inbox: 'inbox',
explore: 'explore',
profile: 'profile',
repository: `repo|${tally}`,
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'),
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', 'getMergeStatus', 'listNotifications', 'getPullDiff', ...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']) {
runs.push({ route, scheme, steps, top, out: join(out, `ios-${name}-${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))
ios/README.md+15−0
@@ -21,6 +21,21 @@ and `KNOTLESS_ACTOR` set, `run.sh` mints an hour's service token for each method
into `build/.tokens.json` and hands the app its path. That code isn't in a
release build.
## Looking at a screen
`just ios-look <screen>` draws a screen in the simulator, light and dark, from
all the way down, as pictures in `target/look`, with no server and no account.
Look at what you changed before you push it:
just ios-look pull-open
just ios-look pull-open --set getMergeStatus.checks=failed --top --light
just ios-look pull-open --tap "Add to merge queue" --light
Its answers are what `harness/shots/fixtures.json` recorded for the web pages,
with what that lacks in `look-answers.json`. A write does nothing, so a `--tap`
that writes shows what's under way, not what comes after. `ios/look.mjs` lists
the screens and options. It needs Xcode, so no check runs it.
## Tests
`KnotlessUITests` taps through the app against live knotless.dev. It needs the
AGENTS.md+3−1
@@ -188,7 +188,9 @@ runs, and waits for them as ever.
`ios.yml` is not one of the seven either: on a pull that changes `ios/`, the
lexicons or the recorded answers, it reads those answers with the iOS app's
models. Xcode needs macOS, so the app itself is built by hand
(`ios/README.md`), and no deploy waits for this.
(`ios/README.md`), and no deploy waits for this. `just ios-look <screen>` shows
a screen in any state, as `just look` does a page: run it before pushing a
change to one.
`scenarios.yml` is not one of the seven: it runs the scenario tracks on
main only when the owner asks for it, as `knotless run rerun <repo> --ref
Justfile+8−0