// 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 idle = WaitingEntry(date: .now, content: .counts(try await gateway.summary(), stale: false)) try write(ComplicationGallery(waiting: waiting, waitingCount: feed.needsYou.count, idle: idle), "complication.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) }, "feed-recent.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 with threads waiting and once with nothing waiting. private struct ComplicationGallery: View { var waiting: WaitingEntry var waitingCount: Int var idle: WaitingEntry var body: some View { VStack(alignment: .leading, spacing: 10) { section("\(waitingCount) waiting", waiting) section("Nothing waiting", idle) } .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) .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) } } } }