// Renders screens with demo data to PNGs in watch/screenshots. Skipped unless WC_SCREENSHOT_DIR is // set, which xcodebuild passes through from TEST_RUNNER_WC_SCREENSHOT_DIR: // // TEST_RUNNER_WC_SCREENSHOT_DIR=$PWD/screenshots xcodebuild ... test -only-testing:WristCodeTests/SnapshotTests import ImageIO import SwiftUI import Testing import UniformTypeIdentifiers import WidgetKit @testable import WristCode private let outputDirectory = ProcessInfo.processInfo.environment["WC_SCREENSHOT_DIR"] /// The Series 12 (46mm) screen in points. private let screen = CGSize(width: 208, height: 248) @MainActor @Suite(.enabled(if: outputDirectory != nil)) struct SnapshotTests { @Test func complication() async throws { let gateway = DemoGateway() let waiting = WaitingEntry(date: .now, content: .counts(try await gateway.summary(), stale: false)) let feed = try await gateway.feed() for item in feed.needsYou { let target = item.requestThread try await gateway.respond( hostId: target.hostId, threadId: target.threadId, request: .decision(requestId: item.request!.id, decision: .decline)) } let working = WaitingEntry(date: .now, content: .counts(try await gateway.summary(), stale: false)) try write(ComplicationGallery(sections: [("\(feed.needsYou.count) waiting", waiting), ("Nothing waiting", working)]), "complication.png") try write(ComplicationGallery(sections: [("\(feed.needsYou.count) waiting", waiting)]), "complication-waiting.png") try write(ComplicationGallery(sections: [("Nothing waiting", working)]), "complication-working.png") } @Test func smartStack() async throws { // Answering the release question leaves the homelab card's subagent approval waiting longest. let gateway = DemoGateway() let release = try await gateway.thread(hostId: "mac-mini", threadId: "t-release") try await gateway.respond( hostId: "mac-mini", threadId: "t-release", request: .answers(requestId: release.item.request!.id, answers: ["checks": .values(["unit"])])) let summary = try await gateway.summary() let approval = try #require(await WaitingEntry.Approval.load(for: summary, api: gateway)) let waiting = WaitingEntry(date: .now, content: .counts(summary, stale: false), approval: approval) // Denying everything on a fresh gateway leaves the scaffold thread working most recently. let idle = DemoGateway() for item in try await idle.feed().needsYou { let target = item.requestThread try await idle.respond( hostId: target.hostId, threadId: target.threadId, request: .decision(requestId: item.request!.id, decision: .decline)) } let working = WaitingEntry(date: .now, content: .counts(try await idle.summary(), stale: false)) try write(SmartStackGallery(entries: [waiting, working]), "smart-stack.png") } @Test func changes() async throws { let gateway = DemoGateway() let feed = try await gateway.feed() try write(feedColumn { RecentHeader(recent: feed.recent, more: feed.moreRecent) ForEach(feed.recent.prefix(4)) { StatusCard(item: $0) } }, "feed-changes.png") let detail = try await gateway.thread(hostId: "littleman", threadId: "t-next") try write(titled("Thread", ThreadView(hostId: "littleman", threadId: "t-next", detail: detail, scrolls: false)), "thread-changes.png") } @Test func search() async throws { let gateway = DemoGateway() let hits = try await gateway.search("healthcheck") try write(titled("\u{201C}healthcheck\u{201D}", SearchView(query: "healthcheck", response: hits, scrolls: false)), "search.png") let none = try await gateway.search("terraform") try write(titled("\u{201C}terraform\u{201D}", SearchView(query: "terraform", response: none, scrolls: false)), "search-empty.png") } @Test func questionNotification() throws { let question = PushQuestion( id: "base", options: [.init(label: "bun:1-alpine", value: "alpine"), .init(label: "bun:1-debian", value: "debian")], allowCustomAnswer: true) try write(NotificationPreview( view: InputNotificationView(title: "Gateway Containerfile", question: "Which base image?"), actions: NotificationHandler.inputActions(question).map(\.title)), "notification-question.png") } @Test func multiSelectQuestion() async throws { let detail = try await DemoGateway().thread(hostId: "mac-mini", threadId: "t-release") let view = ThreadView( hostId: "mac-mini", threadId: "t-release", detail: detail, selections: ["checks": ["unit", "lint"]], scrolls: false) .environment(AppModel.preview()) .fontDesign(.rounded) .tint(Theme.work) try write(view, "thread-multiselect.png") } @Test func subagentCard() async throws { let feed = try await DemoGateway().feed() let item = try #require(feed.needsYou.first { $0.via != nil }) try write(feedColumn { NeedsYouCard(item: item) }, "feed-subagent.png") } @Test func recentWithSettleAll() async throws { let feed = try await DemoGateway().feed() try write(feedColumn { RecentHeader(recent: feed.recent, more: feed.moreRecent) ForEach(feed.recent) { StatusCard(item: $0) } MoreRecentRow(count: feed.moreRecent) AllThreadsRow() }, "feed-recent.png") } @Test func workingCardsWithActivity() async throws { let feed = try await DemoGateway().feed() try write(feedColumn { ForEach(feed.working) { StatusCard(item: $0) } }, "feed-activity.png") } @Test func browseHostsAndThreads() async throws { // The preview model has the demo hosts and projects loaded. try write(titled("All threads", HostsView(scrolls: false)), "browse-hosts.png") let rows = try await DemoGateway().projectThreads(hostId: "mac-mini", projectId: "p-wc-mac").items try write(titled("wristcode", ProjectThreadsView(hostId: "mac-mini", projectId: "p-wc-mac", rows: rows, scrolls: false)), "browse-threads.png") } @Test func threadWithSubagents() async throws { let detail = try await DemoGateway().thread(hostId: "server-gib", threadId: "t-container") try write(titled("Thread", ThreadView(hostId: "server-gib", threadId: "t-container", detail: detail, scrolls: false)), "thread-subagents.png") try write(titled("Thread", ThreadView(hostId: "server-gib", threadId: "t-container", detail: detail, subagentsOpen: true, scrolls: false)), "thread-subagents-open.png") } @Test func threadWithEarlierAndMarkdown() async throws { let detail = try await DemoGateway().thread(hostId: "server-gib", threadId: "t-hyprlock") try write(titled("Thread", ThreadView(hostId: "server-gib", threadId: "t-hyprlock", detail: detail, scrolls: false)), "thread-earlier.png") } @Test func fullRecentList() async throws { // The preview model has every demo Recent thread loaded. try write(titled("Recent", RecentView(scrolls: false)), "recent.png") } @Test func subagentThread() async throws { let detail = try await DemoGateway().thread(hostId: "mac-mini", threadId: "t-homelab") try write(titled("Thread", ThreadView(hostId: "mac-mini", threadId: "t-homelab", detail: detail, scrolls: false)), "thread-subagent.png") } @Test func finishedThreadWithSettle() async throws { let detail = try await DemoGateway().thread(hostId: "laptop", threadId: "t-status") try write(titled("Thread", ThreadView(hostId: "laptop", threadId: "t-status", detail: detail, scrolls: false)), "thread-settle.png") } @Test func runningThreadWithAskFirst() async throws { let detail = try await DemoGateway().thread(hostId: "mac-mini", threadId: "t-scaffold") try write(titled("Thread", ThreadView(hostId: "mac-mini", threadId: "t-scaffold", detail: detail, scrolls: false)), "thread.png") } @Test func newThreadAndAgentList() async throws { let projects = try await DemoGateway().projects() // The demo opens on Mac mini › wristcode, with Ask before acting on as the mock shows it. UserDefaults.standard.removeObject(forKey: "lastModels") UserDefaults.standard.set(true, forKey: "askFirst") defer { UserDefaults.standard.removeObject(forKey: "askFirst") } try write(titled("New thread", NewThreadView(projects: projects, scrolls: false)), "new-thread.png") let host = try #require(projects.hosts.first) let project = try #require(host.projects.first) let list = AgentList( providers: host.providers, selected: project.defaultModel, defaultInstanceId: project.instanceId, scrolls: false) { _ in } try write(titled("Agent", list), "agent-list.png") } @Test func statusScreens() async throws { let status = try await DemoGateway().status() try write(titled("Status", StatusView(status: status, scrolls: false)), "status.png") try write(titled("Laptop", HostStatusView(hostId: "laptop", status: status, scrolls: false)), "status-environment.png") let result = PushResult(ok: true, at: .now, status: 200, reason: nil) try write(titled("Notifications", NotificationsView(status: status, result: result, scrolls: false)), "status-notifications.png") } /// Feed rows stacked as the feed's List lays them out, which ImageRenderer leaves blank. private func feedColumn(@ViewBuilder _ rows: () -> some View) -> some View { VStack(spacing: 6) { rows() } .padding(.horizontal, 4) .padding(.top, 10) .environment(AppModel.preview()) .fontDesign(.rounded) .tint(Theme.work) } /// The screen under a title like the navigation bar's, which ImageRenderer leaves out. private func titled(_ title: String, _ view: some View) -> some View { VStack(alignment: .leading, spacing: 6) { Text(title) .font(.system(size: 17, weight: .semibold)) .foregroundStyle(.white) .padding(.horizontal, 10) .padding(.top, 10) view } .environment(AppModel.preview()) .fontDesign(.rounded) .tint(Theme.work) } /// Renders at the screen's width and as tall as the content, as if scrolled through. private func write(_ view: some View, _ name: String) throws { let renderer = ImageRenderer(content: view .frame(width: screen.width) .fixedSize(horizontal: false, vertical: true) .padding(.bottom, 8) .background(.black) .environment(\.colorScheme, .dark)) renderer.scale = 2 let image = try #require(renderer.cgImage) let url = URL(fileURLWithPath: try #require(outputDirectory)).appending(path: name) let destination = try #require(CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil)) CGImageDestinationAddImage(destination, image, nil) #expect(CGImageDestinationFinalize(destination)) } } /// Every family at its 46mm size, once per titled entry. private struct ComplicationGallery: View { var sections: [(title: String, entry: WaitingEntry)] var body: some View { VStack(alignment: .leading, spacing: 10) { ForEach(sections.indices, id: \.self) { index in section(sections[index].title, sections[index].entry) } } .padding(.horizontal, 10) .padding(.top, 8) .foregroundStyle(.white) } private func section(_ title: String, _ entry: WaitingEntry) -> some View { VStack(alignment: .leading, spacing: 6) { Text(title.uppercased()) .font(.system(size: 10, weight: .semibold, design: .rounded)) .foregroundStyle(Theme.done) WaitingView(entry: entry, familyOverride: .accessoryInline) .font(.system(size: 13, weight: .semibold, design: .rounded)) .frame(height: 18) WaitingView(entry: entry, familyOverride: .accessoryRectangular, inStackOverride: false) .frame(width: 184, height: 72) HStack(spacing: 16) { // Outside a face AccessoryWidgetBackground fills a square; a face clips it round. WaitingView(entry: entry, familyOverride: .accessoryCircular) .frame(width: 50, height: 50) .clipShape(Circle()) WaitingView(entry: entry, familyOverride: .accessoryCorner) .frame(width: 50, height: 50) } } } } /// The rectangular widget as the Smart Stack shows it, on its card and with Allow and Deny when the /// waiting thread wants an approval. private struct SmartStackGallery: View { var entries: [WaitingEntry] var body: some View { VStack(spacing: 10) { ForEach(entries.indices, id: \.self) { index in WaitingView(entry: entries[index], familyOverride: .accessoryRectangular, inStackOverride: true) .padding(.horizontal, 10) .padding(.vertical, 8) .frame(width: 196, height: 96, alignment: .topLeading) .background(Color(white: 0.11), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) } } .padding(.top, 8) .foregroundStyle(.white) .fontDesign(.rounded) } } /// The question notification's long look over its actions. The system draws the actions; these /// stand in for them with the same titles. private struct NotificationPreview: View { var view: InputNotificationView var actions: [String] var body: some View { VStack(alignment: .leading, spacing: 6) { Text("WRISTCODE") .font(.system(size: 11, weight: .semibold, design: .rounded)) .foregroundStyle(Theme.secondaryText) view .padding(.bottom, 4) ForEach(actions, id: \.self) { title in Text(title) .font(.system(size: 15, weight: .semibold, design: .rounded)) .frame(maxWidth: .infinity) .padding(.vertical, 9) .background(Theme.control, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) } } .padding(.horizontal, 8) .padding(.top, 10) .foregroundStyle(.white) } }