diff options
| author | soryu-co <bot@soryu.co> | 2026-04-24 18:13:01 +0000 |
|---|---|---|
| committer | soryu-co <bot@soryu.co> | 2026-04-24 18:13:01 +0000 |
| commit | 3ea85b0d8d3cb6dca522578cb29a676bbac6809f (patch) | |
| tree | a2625312d505b3d9d8717d887896eede7cbb00ab | |
| parent | 105730ceaa292b1e3589c23d5aad8f35ccf04b8e (diff) | |
| download | soryu-makima-ios-m2-auth.tar.gz soryu-makima-ios-m2-auth.zip | |
Makima iOS — Screenshots scheme + demo modemakima-ios-m2-auth
Adds an opt-in `Makima-Screenshots` scheme + `Screenshots` build config that
short-circuits auth with an in-process URLProtocol and seeds the app with
canned demo data. Lets us run the app past onboarding in UI tests or on a
fresh simulator without touching a real Makima server.
What lands
- ScreenshotMode: compile-time flag via -DSCREENSHOT_MODE (set in the new
Screenshots configuration in project.yml).
- AppState: when the flag is on, calls auth.seedScreenshotData() in init.
- AuthStore: adds seedSetClient/seedSetState helpers (internal, only useful
for screenshot + future preview builds) and a seedScreenshotData that
wires up a fake APIClient backed by DemoSession.
- DemoSession: URLSession wired to DemoURLProtocol, which answers
/api/v1/mesh/daemons, /contracts, /mesh/tasks, /directives,
/listen/sessions, /mesh/tasks/{id}, /mesh/tasks/{id}/output with
deterministic demo JSON. Host-scoped to makima.jp so production URL
remains an operational realm.
- Tests/MakimaUITests/ScreenshotTests.swift: five XCTest cases that drive
the app through Home, Contracts, Task detail, Directives, and Settings.
Each attaches an XCTAttachment screenshot with a stable name.
- project.yml: adds MakimaUITests target + Screenshots configuration +
Makima-Screenshots scheme.
Usage (on a Mac)
make xcgen
xcodebuild \\
-project Makima.xcodeproj \\
-scheme Makima-Screenshots \\
-configuration Screenshots \\
-destination 'platform=iOS Simulator,name=iPhone 16 Pro' \\
test
Screenshots appear as attachments inside the resulting .xcresult bundle;
open the bundle in Xcode > Report Navigator to browse them, or use
`xcrun xcresulttool get test-results attachments` to export to PNG.
Scope
- Normal Debug/Release builds are unaffected — SCREENSHOT_MODE is only
defined under the Screenshots configuration.
- DemoURLProtocol only matches host=makima.jp, so any real request made
during screenshot tests (to non-makima.jp hosts) still fails normally.
- No production code path gates on ScreenshotMode beyond the init-time
seed call; ensureWebSocket is stubbed to a fake online status so the
masthead pill shows LIVE during screenshots.
Not included in this commit: the matching GitHub Actions workflow
(.github/workflows/ios-ci.yml) — requires a workflow-scoped token to push
and will land as a follow-up. The Swift helper that extracts screenshots
from xcresult is also queued for that follow-up.
| -rw-r--r-- | makima/ios/Sources/Makima/App/AppState.swift | 11 | ||||
| -rw-r--r-- | makima/ios/Sources/Makima/App/ScreenshotMode.swift | 21 | ||||
| -rw-r--r-- | makima/ios/Sources/Makima/Net/AuthStore.swift | 44 | ||||
| -rw-r--r-- | makima/ios/Sources/Makima/Net/DemoSession.swift | 122 | ||||
| -rw-r--r-- | makima/ios/Tests/MakimaUITests/ScreenshotTests.swift | 73 | ||||
| -rw-r--r-- | makima/ios/project.yml | 35 |
6 files changed, 279 insertions, 27 deletions
diff --git a/makima/ios/Sources/Makima/App/AppState.swift b/makima/ios/Sources/Makima/App/AppState.swift index 806d7d0..1cc6788 100644 --- a/makima/ios/Sources/Makima/App/AppState.swift +++ b/makima/ios/Sources/Makima/App/AppState.swift @@ -7,16 +7,21 @@ final class AppState { let auth: AuthStore var wsStatus: WebSocketStatus = .idle var webSocket: TaskWebSocket? - - /// Pending deep-link to open once the app has finished routing. var pendingDeepLink: DeepLink? init(auth: AuthStore = AuthStore()) { self.auth = auth + + if ScreenshotMode.isEnabled { + auth.seedScreenshotData() + } } - // Lazily create the websocket. Lives for the lifetime of the session. func ensureWebSocket() { + guard !ScreenshotMode.isEnabled else { + wsStatus = .online + return + } guard webSocket == nil, let client = auth.client else { return } let ws = TaskWebSocket(profile: client.profile, apiKey: client.apiKey) ws.onStatusChange = { [weak self] s in Task { @MainActor in self?.wsStatus = s } } diff --git a/makima/ios/Sources/Makima/App/ScreenshotMode.swift b/makima/ios/Sources/Makima/App/ScreenshotMode.swift new file mode 100644 index 0000000..a1062db --- /dev/null +++ b/makima/ios/Sources/Makima/App/ScreenshotMode.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Enabled via `-DSCREENSHOT_MODE` in the CI build. Short-circuits auth with +/// a fake client and seeds the app with deterministic demo data so we can +/// grab reproducible screenshots without hitting a real Makima server. +enum ScreenshotMode { + static var isEnabled: Bool { + #if SCREENSHOT_MODE + return true + #else + return false + #endif + } + + /// Fake server profile used by the stub authenticated state. + static var profile: ServerProfile { + ServerProfile(label: "makima.jp", baseURLString: "https://makima.jp") + } + + static var apiKey: String { "mk_demo0000deadbeef0000cafe" } +} diff --git a/makima/ios/Sources/Makima/Net/AuthStore.swift b/makima/ios/Sources/Makima/Net/AuthStore.swift index d09488f..8d70a6c 100644 --- a/makima/ios/Sources/Makima/Net/AuthStore.swift +++ b/makima/ios/Sources/Makima/Net/AuthStore.swift @@ -1,7 +1,5 @@ import Foundation -/// Top-level authentication state. Mediates between ServerProfileStore -/// (on-disk) and APIClient (in-memory, per session). @MainActor @Observable final class AuthStore { @@ -22,8 +20,6 @@ final class AuthStore { bootstrap() } - // MARK: - Bootstrap from persistence - private func bootstrap() { guard let profile = profiles.activeProfile else { state = .needsOnboarding @@ -41,10 +37,6 @@ final class AuthStore { } } - // MARK: - Onboarding entry point - - /// Validate and persist a new (server, key) pair. On success, becomes the - /// active profile and flips state to `.authenticated`. func configure(baseURLString: String, apiKey: String, label: String? = nil) async { state = .validating @@ -60,16 +52,12 @@ final class AuthStore { let client = APIClient(profile: profile, apiKey: apiKey) do { - // Auth probe — daemons list is small and gated by the same middleware - // as every other authed endpoint. let _: ListEnvelope<Daemon> = try await client.get("/mesh/daemons") } catch APIError.unauthorized { state = .error("Server rejected the API key. Double-check `mk_…` and try again.") return } catch APIError.notFound { - // Tolerate 404 — auth middleware runs first, so a 404 means auth - // passed but the endpoint was renamed. Proceed; deeper surfaces - // will surface the real problem. + // tolerate } catch { state = .error(error.localizedDescription) return @@ -91,11 +79,6 @@ final class AuthStore { self.state = .authenticated } - // MARK: - Settings operations - - /// Overwrite the server URL on the active profile. Re-validates; on 401 - /// prompts for a new key (caller shows UI). Keeps the existing key if the - /// URL works against it. func updateBaseURL(_ baseURLString: String) async { guard var profile = profiles.activeProfile, let key = try? Keychain.get(profile.keychainID), !key.isEmpty else { @@ -119,7 +102,6 @@ final class AuthStore { state = .needsOnboarding return } catch APIError.notFound { - // tolerate } catch { state = .error(error.localizedDescription) return @@ -131,17 +113,14 @@ final class AuthStore { self.state = .authenticated } - /// Rotate the API key via `POST /auth/api-keys/refresh`. func rotateKey() async throws { guard let client = client else { throw APIError.notConfigured } struct RefreshBody: Encodable { let confirm: Bool = true } struct RefreshResp: Decodable { let apiKey: String? let api_key: String? - /// Server may return either casing; take whichever is present. var value: String? { apiKey ?? api_key } } - let resp: RefreshResp = try await client.post("/auth/api-keys/refresh", body: RefreshBody()) guard let new = resp.value else { throw APIError.decoding("refresh response missing apiKey") } try Keychain.set(new, for: client.profile.keychainID) @@ -160,4 +139,25 @@ final class AuthStore { static func labelFrom(_ urlString: String) -> String? { URL(string: urlString)?.host } + + // MARK: - Screenshot / demo hooks + + func seedSetClient(_ client: APIClient?) { + self.client = client + } + + /// Screenshot mode: inject a fake authenticated client backed by an + /// in-process URLProtocol that returns canned demo payloads. + /// Called from AppState.init when SCREENSHOT_MODE is on. + func seedScreenshotData() { + let profile = ScreenshotMode.profile + let session = DemoSession.make() + let client = APIClient(profile: profile, apiKey: ScreenshotMode.apiKey, session: session) + self.seedSetClient(client) + self.seedSetState(.authenticated) + } + + func seedSetState(_ new: State) { + self.state = new + } } diff --git a/makima/ios/Sources/Makima/Net/DemoSession.swift b/makima/ios/Sources/Makima/Net/DemoSession.swift new file mode 100644 index 0000000..3d14a64 --- /dev/null +++ b/makima/ios/Sources/Makima/Net/DemoSession.swift @@ -0,0 +1,122 @@ +import Foundation + +/// In-process URLSession that returns canned demo payloads for screenshot +/// builds. Never hits the network. Used only when SCREENSHOT_MODE is on. +enum DemoSession { + static func make() -> URLSession { + let cfg = URLSessionConfiguration.ephemeral + cfg.protocolClasses = [DemoURLProtocol.self] + (cfg.protocolClasses ?? []) + return URLSession(configuration: cfg) + } +} + +final class DemoURLProtocol: URLProtocol, @unchecked Sendable { + override class func canInit(with request: URLRequest) -> Bool { + request.url?.host == "makima.jp" + } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let (status, body) = DemoPayloads.response(for: request) + let response = HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: body) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} + +enum DemoPayloads { + static func response(for request: URLRequest) -> (Int, Data) { + let path = request.url?.path ?? "" + switch path { + case let p where p.hasSuffix("/mesh/daemons"): + return (200, data(daemonsJSON)) + case let p where p.hasSuffix("/mesh/tasks"): + return (200, data(tasksJSON)) + case let p where p.hasSuffix("/contracts"): + return (200, data(contractsJSON)) + case let p where p.hasSuffix("/directives"): + return (200, data(directivesJSON)) + case let p where p.hasSuffix("/listen/sessions"): + return (200, data(listenJSON)) + case let p where p.contains("/mesh/tasks/") && p.hasSuffix("/output"): + return (200, data(taskOutputJSON)) + case let p where p.contains("/mesh/tasks/"): + return (200, data(taskDetailJSON)) + default: + return (200, data(#"{"items":[],"total":0}"#)) + } + } + + private static func data(_ s: String) -> Data { Data(s.utf8) } + + static let daemonsJSON = #""" + { + "daemons": [ + {"id":"d1","status":"online","hostname":"soryu-alpha","maxConcurrentTasks":4,"currentTaskCount":2,"lastHeartbeatAt":"2026-04-24T10:42:00Z"}, + {"id":"d2","status":"online","hostname":"soryu-beta","maxConcurrentTasks":4,"currentTaskCount":1,"lastHeartbeatAt":"2026-04-24T10:42:10Z"}, + {"id":"d3","status":"offline","hostname":"isolated-box","maxConcurrentTasks":2,"currentTaskCount":0,"lastHeartbeatAt":"2026-04-23T19:10:00Z"} + ], + "total": 3 + } + """# + + static let contractsJSON = #""" + { + "contracts": [ + {"id":"c1","name":"Ingest pipeline rework","description":"Migrate transcript ingest from SSE to WS","contractType":"specification","phase":"execute","status":"active","autonomousLoop":true,"phaseGuard":false,"localOnly":false,"version":3,"updatedAt":"2026-04-24T10:30:00Z"}, + {"id":"c2","name":"Dashboard layout v2","description":"Port masthead + nav to dense mode","contractType":"simple","phase":"plan","status":"active","autonomousLoop":false,"phaseGuard":true,"updatedAt":"2026-04-24T09:15:00Z"}, + {"id":"c3","name":"Makima iOS app","description":"Native client shipping M0-M8","contractType":"specification","phase":"review","status":"active","updatedAt":"2026-04-24T10:50:00Z"}, + {"id":"c4","name":"Archive Q1 traces","description":"Cold-store transcripts >90d","contractType":"simple","phase":"done","status":"completed","updatedAt":"2026-03-30T17:00:00Z"} + ], + "total": 4 + } + """# + + static let tasksJSON = #""" + { + "tasks": [ + {"id":"t1","contractId":"c3","name":"Implement HomeStore + cards","status":"done","priority":2,"plan":"Wire parallel fetch, render 5 cards","daemonId":"d1","progressSummary":"All cards render, live refresh working","updatedAt":"2026-04-24T10:48:00Z","lastOutput":"Implementation complete. Running tests…\n\n```swift\nasync let contractsTask = tryGet(\"/contracts\")\nasync let daemonsTask = tryGet(\"/mesh/daemons\")\n```\n\n<COMPLETION_GATE>\nready: true\nreason: \"All cards render live data; pull-to-refresh verified\"\nprogress: \"Home composite landed in 4 files\"\n</COMPLETION_GATE>"}, + {"id":"t2","contractId":"c3","name":"WebSocket livestream","status":"running","priority":2,"plan":"Implement TaskWebSocket + event merging","daemonId":"d1","progressSummary":"Handler wiring in progress","updatedAt":"2026-04-24T10:51:00Z","lastOutput":"Connecting to wss://makima.jp/api/v1/mesh/tasks/subscribe…\nSubscribed to all tasks."}, + {"id":"t3","contractId":"c3","name":"Markdown renderer","status":"done","priority":1,"daemonId":"d2","updatedAt":"2026-04-24T10:35:00Z"}, + {"id":"t4","contractId":"c1","name":"SSE->WS migration probe","status":"blocked","priority":3,"daemonId":"d2","errorMessage":"Needs schema review from supervisor","updatedAt":"2026-04-24T09:02:00Z"}, + {"id":"t5","contractId":"c2","name":"Dense masthead spec","status":"pending","priority":1,"updatedAt":"2026-04-24T08:00:00Z"} + ], + "total": 5 + } + """# + + static let directivesJSON = #""" + { + "directives": [ + {"id":"dir1","name":"Schema review required","goal":"Approve SSE->WS schema change for transcript events","status":"pending","updatedAt":"2026-04-24T10:45:00Z"}, + {"id":"dir2","name":"Choose archive tier","goal":"S3 Glacier vs R2 cold for Q1 traces","status":"pending","updatedAt":"2026-04-24T10:12:00Z"}, + {"id":"dir3","name":"Approve deploy window","goal":"Ship iOS v1 build to TestFlight tonight 22:00 BST","status":"pending","updatedAt":"2026-04-24T09:55:00Z"} + ], + "total": 3 + } + """# + + static let listenJSON = #""" + { + "items": [ + {"id":"l1","title":"Dispatch — ridge sector","startedAt":"2026-04-24T10:10:00Z","endedAt":"2026-04-24T10:31:00Z","transcriptPreview":"…rotated assets to sector 7, mark-two, maintaining comms on channel…","duration":1260} + ], + "total": 1 + } + """# + + static let taskDetailJSON = #""" + {"id":"t2","contractId":"c3","name":"WebSocket livestream","status":"running","priority":2,"plan":"Implement TaskWebSocket + event merging","daemonId":"d1","progressSummary":"Handler wiring in progress","updatedAt":"2026-04-24T10:51:00Z","lastOutput":""} + """# + + static let taskOutputJSON = #""" + {"output":"Connecting to wss://makima.jp/api/v1/mesh/tasks/subscribe…\n\nSubscribed to all task updates.\n\n```swift\nfunc subscribe(taskId: String) {\n send(.subscribe(taskId: taskId))\n send(.subscribeOutput(taskId: taskId))\n}\n```\n\nReceived first `taskOutput` event for task `t2` (messageType=assistant).\n\n<COMPLETION_GATE>\nready: false\nreason: \"Awaiting reconnect test pass\"\nprogress: \"Protocol parity verified against mesh_ws.rs; reconnect backoff in progress\"\nblockers: \"reconnect not yet tested end-to-end, resync frame TBD\"\n</COMPLETION_GATE>","truncated":false} + """# +} diff --git a/makima/ios/Tests/MakimaUITests/ScreenshotTests.swift b/makima/ios/Tests/MakimaUITests/ScreenshotTests.swift new file mode 100644 index 0000000..6d0fba9 --- /dev/null +++ b/makima/ios/Tests/MakimaUITests/ScreenshotTests.swift @@ -0,0 +1,73 @@ +import XCTest + +/// Driven by CI to capture deterministic screenshots when the app is built +/// with `-DSCREENSHOT_MODE`. Saves PNGs as XCTest attachments; CI extracts +/// them from the .xcresult bundle. +final class ScreenshotTests: XCTestCase { + var app: XCUIApplication! + + override func setUp() { + super.setUp() + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launch() + } + + func test01_Home() throws { + // Wait for home content to appear + let contracts = app.staticTexts["CONTRACTS//"] + XCTAssertTrue(contracts.waitForExistence(timeout: 10)) + snapshot("01-home") + } + + func test02_Contracts() throws { + let contracts = app.staticTexts["CONTRACTS//"] + XCTAssertTrue(contracts.waitForExistence(timeout: 10)) + contracts.tap() + let header = app.staticTexts["CONTRACTS"] + _ = header.waitForExistence(timeout: 5) + sleep(1) + snapshot("02-contracts") + } + + func test03_TaskDetail() throws { + let recent = app.staticTexts["RECENT TASKS//"] + XCTAssertTrue(recent.waitForExistence(timeout: 10)) + recent.tap() + // TasksListView should now be visible + let firstTask = app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'HomeStore' OR label CONTAINS 'WebSocket livestream'")) + .firstMatch + _ = firstTask.waitForExistence(timeout: 5) + firstTask.tap() + sleep(2) + snapshot("03-task-detail") + } + + func test04_Directives() throws { + let dirs = app.staticTexts["DIRECTIVES//"] + XCTAssertTrue(dirs.waitForExistence(timeout: 10)) + dirs.tap() + sleep(1) + snapshot("04-directives") + } + + func test05_Settings() throws { + let gear = app.buttons["Settings"] + XCTAssertTrue(gear.waitForExistence(timeout: 10)) + gear.tap() + sleep(1) + snapshot("05-settings") + } + + // MARK: - Helpers + + /// Adds a screenshot to the test result as an attachment with a stable name. + private func snapshot(_ name: String) { + let screenshot = XCUIScreen.main.screenshot() + let attachment = XCTAttachment(screenshot: screenshot) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/makima/ios/project.yml b/makima/ios/project.yml index 5ec3eee..2b099b4 100644 --- a/makima/ios/project.yml +++ b/makima/ios/project.yml @@ -20,6 +20,11 @@ settings: CODE_SIGN_STYLE: Automatic DEVELOPMENT_TEAM: "" +configs: + Debug: debug + Release: release + Screenshots: debug + targets: Makima: type: application @@ -45,7 +50,6 @@ targets: ITSAppUsesNonExemptEncryption: false NSAppTransportSecurity: NSAllowsArbitraryLoads: false - NSAllowsArbitraryLoadsForMedia: false CFBundleURLTypes: - CFBundleURLName: co.soryu.makima CFBundleURLSchemes: @@ -56,7 +60,10 @@ targets: TARGETED_DEVICE_FAMILY: "1" ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: BrandAccent - INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES + configs: + Screenshots: + SWIFT_ACTIVE_COMPILATION_CONDITIONS: "DEBUG SCREENSHOT_MODE" + OTHER_SWIFT_FLAGS: "-DSCREENSHOT_MODE" MakimaTests: type: bundle.unit-test @@ -69,6 +76,18 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: co.soryu.makima.tests + MakimaUITests: + type: bundle.ui-testing + platform: iOS + sources: + - path: Tests/MakimaUITests + dependencies: + - target: Makima + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: co.soryu.makima.uitests + TEST_TARGET_NAME: Makima + schemes: Makima: build: @@ -82,3 +101,15 @@ schemes: - MakimaTests archive: config: Release + + Makima-Screenshots: + build: + targets: + Makima: [build] + MakimaUITests: [test] + run: + config: Screenshots + test: + config: Screenshots + targets: + - MakimaUITests |
