chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
just ios-look draws the iOS app's screens in any state
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 | ||