From 4faf1ae80b3fc05728e238655b46626426c17b3c Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Sat, 31 Oct 2026 14:02:15 +0100 Subject: [PATCH 1/5] build: draw a fixed date when a render asks for one A fixedDate environment value replaces the live time in the views that draw one: every bar widget, the clock's calendar popup, and the clock row and format samples in Settings. Renders set it, so a clock no longer changes their pixels from one run to the next. --- Sources/StableBar/BarInspector.swift | 5 +- Sources/StableBar/BarView.swift | 10 +++- Sources/StableBar/ClockCalendar.swift | 10 ++-- Sources/StableBar/WidgetSettings.swift | 4 +- Tests/StableBarTests/FixedDateTests.swift | 58 +++++++++++++++++++++++ 5 files changed, 77 insertions(+), 10 deletions(-) create mode 100644 Tests/StableBarTests/FixedDateTests.swift diff --git a/Sources/StableBar/BarInspector.swift b/Sources/StableBar/BarInspector.swift index 9366e013..43bc812f 100644 --- a/Sources/StableBar/BarInspector.swift +++ b/Sources/StableBar/BarInspector.swift @@ -44,6 +44,7 @@ private extension WidgetTransition { struct BarInspector: View { @Bindable var editor: ConfigurationEditor let screen: ScreenKind + @Environment(\.fixedDate) private var fixedDate let kinds: [ScreenKind] let names: [String: String] let source: (WidgetConfiguration) -> PackageOrigin @@ -401,7 +402,7 @@ struct BarInspector: View { case let .clock(format): SettingsGroup("Clock & date") { TimelineView(.periodic(from: .now, by: format.contains("s") ? 1 : 60)) { context in - Text(widget.label(output: nil, activeApp: nil, date: context.date).text) + Text(widget.label(output: nil, activeApp: nil, date: fixedDate ?? context.date).text) .font(.system(size: 24, weight: .medium, design: .rounded)).monospacedDigit() .frame(maxWidth: .infinity, alignment: .leading).padding(.vertical, 8) } @@ -516,7 +517,7 @@ struct BarInspector: View { } label: { VStack(alignment: .leading, spacing: 4) { Text(choice.title).font(.system(size: 10)).opacity(0.65) - Text(choice.sample).font(.system(size: 12, weight: .medium)).monospacedDigit() + Text(choice.sample(at: fixedDate ?? .now)).font(.system(size: 12, weight: .medium)).monospacedDigit() } .frame(maxWidth: .infinity, alignment: .leading).padding(9) .background(!custom && format == choice.value ? Color.accentColor.opacity(0.16) : SettingsPalette.control, diff --git a/Sources/StableBar/BarView.swift b/Sources/StableBar/BarView.swift index 08fda952..0c0188b4 100644 --- a/Sources/StableBar/BarView.swift +++ b/Sources/StableBar/BarView.swift @@ -227,6 +227,8 @@ extension EnvironmentValues { } @Entry var moreWidgetsAnchor: NSView? @Entry var barExpansion: BarExpansion? + /// A date to draw instead of the current time, so a render shows the same clock on every run. + @Entry var fixedDate: Date? } enum BarExpansion { @@ -329,6 +331,7 @@ struct WidgetView: View { @Environment(\.screenKind) private var screenKind @Environment(\.moreWidgetsAnchor) private var moreWidgetsAnchor @Environment(\.barExpansion) private var barExpansion + @Environment(\.fixedDate) private var fixedDate private var resolution: ResolvedWidgetStyle { store.presentations[widget.id]?.style(on: screenKind) ?? ResolvedWidgetStyle(base: resolveStyle([defaults, widget.style])) @@ -618,10 +621,13 @@ struct WidgetView: View { } } - /// `content` drawn for the time the widget shows: a clock's, redrawn on its schedule, otherwise now. + /// `content` drawn for the time the widget shows: the render's fixed date when it has one, a clock's + /// redrawn on its schedule, otherwise now. @ViewBuilder private func timed(@ViewBuilder _ content: @escaping (Date) -> Content) -> some View { - if case let .clock(format) = widget.content { + if let fixedDate { + content(fixedDate) + } else if case let .clock(format) = widget.content { TimelineView(clockSchedule(format)) { context in content(context.date) } } else { content(.now) diff --git a/Sources/StableBar/ClockCalendar.swift b/Sources/StableBar/ClockCalendar.swift index 2261feeb..978d6cf1 100644 --- a/Sources/StableBar/ClockCalendar.swift +++ b/Sources/StableBar/ClockCalendar.swift @@ -25,16 +25,18 @@ struct ClockCalendar: View { let openCalendar: () -> Void @State private var offset = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.fixedDate) private var fixedDate var body: some View { TimelineView(.periodic(from: .now, by: 60)) { context in + let now = fixedDate ?? context.date let zone = timeZone.flatMap(TimeZone.init(identifier:)) ?? .autoupdatingCurrent - let month = ClockMonth(date: context.date, offset: offset, timeZone: zone) + let month = ClockMonth(date: now, offset: offset, timeZone: zone) VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 3) { - Text(formatted(context.date, in: zone, pattern: nil)) + Text(formatted(now, in: zone, pattern: nil)) .font(.system(size: 34, weight: .medium, design: .rounded)).monospacedDigit() - Text(formatted(context.date, in: zone, pattern: "EEEE, d MMMM yyyy")) + Text(formatted(now, in: zone, pattern: "EEEE, d MMMM yyyy")) .font(.system(size: 12)).opacity(0.6) if timeZone != nil { Text(zone.identifier.replacingOccurrences(of: "_", with: " ")) @@ -61,7 +63,7 @@ struct ClockCalendar: View { LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 0), count: 7), spacing: 3) { ForEach(0..<42) { index in if let date = month.days[index] { - let today = month.calendar.isDate(date, inSameDayAs: context.date) + let today = month.calendar.isDate(date, inSameDayAs: now) Text(String(month.calendar.component(.day, from: date))) .font(.system(size: 12, weight: today ? .semibold : .regular)).monospacedDigit() .foregroundStyle(today ? appearance.accentText : appearance.foreground) diff --git a/Sources/StableBar/WidgetSettings.swift b/Sources/StableBar/WidgetSettings.swift index d50efea7..c3e8cf5e 100644 --- a/Sources/StableBar/WidgetSettings.swift +++ b/Sources/StableBar/WidgetSettings.swift @@ -131,10 +131,10 @@ struct WidgetClockFormat { Self(title: "Date + seconds", value: "EEE d MMM HH:mm:ss") ] - var sample: String { + func sample(at date: Date) -> String { let formatter = DateFormatter() formatter.dateFormat = value - return formatter.string(from: .now) + return formatter.string(from: date) } } diff --git a/Tests/StableBarTests/FixedDateTests.swift b/Tests/StableBarTests/FixedDateTests.swift new file mode 100644 index 00000000..53e93bc7 --- /dev/null +++ b/Tests/StableBarTests/FixedDateTests.swift @@ -0,0 +1,58 @@ +import AppKit +import SwiftUI +import Testing +@testable import StableBar + +/// Thursday 22 October 2026, 09:41 UTC: the time the docs images and scenes draw. +private let fixed = Date(timeIntervalSince1970: 1_792_662_060) +private let display = BarDisplay(id: 1, isBuiltIn: false, isMain: true, notch: nil) + +/// The largest difference of one color channel between two drawings. A process draws some antialiased +/// pixels one step (1/255) differently between two drawings of the same content (see DefaultLayoutTests), +/// so drawings that match differ by at most 1. +private func difference(_ first: [UInt8], _ second: [UInt8]) -> Int { + guard first.count == second.count else { return 255 } + return zip(first, second).reduce(0) { max($0, abs(Int($1.0) - Int($1.1))) } +} + +/// `content` drawn at 2× as 8-bit sRGB bytes. +@MainActor private func pixels(_ content: some View) throws -> [UInt8] { + let renderer = ImageRenderer(content: content) + renderer.scale = 2 + let image = try #require(renderer.cgImage) + let context = try #require(CGContext(data: nil, width: image.width, height: image.height, bitsPerComponent: 8, + bytesPerRow: image.width * 4, space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + let data = try #require(context.data) + return Array(UnsafeRawBufferPointer(start: data, count: image.width * image.height * 4)) +} + +@Test @MainActor func barClockDrawsTheFixedDate() throws { + let fixture = try ThemeGalleryFixture(#"{"widgets":[{"id":"clock","type":"clock","position":"right"}]}"#) + defer { fixture.close() } + let store = try fixture.store() + func bar(_ date: Date) throws -> [UInt8] { + try pixels(BarView(store: store, display: display).frame(width: 600, height: 40).environment(\.fixedDate, date)) + } + let first = try bar(fixed) + #expect(try difference(bar(fixed.addingTimeInterval(3_600)), first) > 1) + #expect(try difference(bar(fixed), first) <= 1) +} + +@Test @MainActor func clockCalendarDrawsTheFixedDate() throws { + func calendar(_ date: Date) throws -> [UInt8] { + try pixels(ClockCalendar(timeZone: "UTC", appearance: WidgetAppearance(WidgetStyle(), defaults: WidgetStyle()), openCalendar: {}) + .frame(width: 320) + .environment(\.fixedDate, date)) + } + let first = try calendar(fixed) + #expect(try difference(calendar(fixed.addingTimeInterval(40 * 86_400)), first) > 1) + #expect(try difference(calendar(fixed), first) <= 1) +} + +@Test func clockFormatSamplesUseTheGivenDate() { + let format = WidgetClockFormat.common[4] + #expect(format.value == "EEE d MMM HH:mm") + #expect(format.sample(at: fixed) != format.sample(at: fixed.addingTimeInterval(86_400))) +} From ce2f1c5aaf7268f5a6988ffdfbdf49bb5d0c2dce Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Sat, 31 Oct 2026 14:08:01 +0100 Subject: [PATCH 2/5] build: draw the docs images the same on every run The Essentials images drew the time of the render, and Settings asked the Store's index over the network. The renderer now draws a fixed date in UTC, Thursday 22 October 2026 at 09:41, the time the bar's sample clock already shows, and gives every store a local, empty Store index. Two renders of this commit on one Mac, a minute apart, differ in 0 pixels across all 14 images. --- CONTRIBUTING.md | 2 +- Sources/StableBar/DocsImages.swift | 27 +++++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ce00e680..8a387463 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -127,7 +127,7 @@ Release jobs set `STABLEBAR_RELEASE_BUILD=1` when packaging to preserve their st ## Docs images -`scripts/render-docs-images.sh` draws the images in `docs/images` from the app's own bar, popup and Settings views, with sample data and a fixed time, and writes them as WebP. It needs `cwebp`: `brew install webp`. The **Docs images** CI job draws them on every push to `main` and on pull requests that change the app, and a stable release commits them, so a change to the bar, popups or Settings doesn't need new images. To show something new, add a scene to [DocsImages.swift](Sources/StableBar/DocsImages.swift). +`scripts/render-docs-images.sh` draws the images in `docs/images` from the app's own bar, popup and Settings views, with sample data, a fixed date (Thursday 22 October 2026, 09:41 UTC) and a local, empty Store index, and writes them as WebP. Two runs of one commit on one Mac draw the same pixels. It needs `cwebp`: `brew install webp`. The **Docs images** CI job draws them on every push to `main` and on pull requests that change the app, and a stable release commits them, so a change to the bar, popups or Settings doesn't need new images. To show something new, add a scene to [DocsImages.swift](Sources/StableBar/DocsImages.swift). ## Agent skills diff --git a/Sources/StableBar/DocsImages.swift b/Sources/StableBar/DocsImages.swift index 249cc163..949b1869 100644 --- a/Sources/StableBar/DocsImages.swift +++ b/Sources/StableBar/DocsImages.swift @@ -2,11 +2,32 @@ import AppKit import SwiftUI /// Draws the images in docs/images from the real bar, popup and Settings views, in the dark and -/// light appearance. Sample data and a fixed time give the same pixels on every run until the views -/// change. `scripts/render-docs-images.sh` runs it and converts the images to WebP. +/// light appearance. Sample data, a fixed date in UTC and a local, empty Store index give the same +/// pixels on every run until the views change. `scripts/render-docs-images.sh` runs it and converts +/// the images to WebP. @MainActor enum DocsImages { + /// Thursday 22 October 2026, 09:41 UTC, the time the bar's sample clock shows: what every clock in a + /// render draws instead of the current time. + static let date = Date(timeIntervalSince1970: 1_792_662_060) + + /// Draws times in UTC, so a render shows the same clock on every Mac. + static func useUTC() { + setenv("TZ", "UTC", 1) + NSTimeZone.resetSystemTimeZone() + } + + /// Points the store's Store client at a local, empty index in `directory`, so the theme gallery, + /// the widget catalog and Packages never reach the network while a render draws them. + static func offline(_ store: BarStore, in directory: URL) throws { + let index = directory.appending(path: "index.json") + try Data(#"{"packages":[]}"#.utf8).write(to: index) + store.packageStoreClient = PackageStoreClient(packages: WidgetPackageStore(directory: directory.appending(path: "packages")), + indexURL: index) + } + static func render(to directory: URL) async throws { + useUTC() let samples = FileManager.default.temporaryDirectory.appending(path: "stablebar-docs-\(UUID().uuidString)") defer { try? FileManager.default.removeItem(at: samples) } try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) @@ -191,6 +212,7 @@ enum DocsImages { return type }) let store = BarStore(configurationURL: url, registry: registry) + try offline(store, in: directory) store.workspaceStore.executablePath = { aerospace.path } store.reloadConfiguration() store.activeApp = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.finder").first @@ -203,6 +225,7 @@ enum DocsImages { private static func snapshot(_ content: some View, size: NSSize, dark: Bool, stores: [BarStore], ready: () -> Bool, to url: URL) async throws { let view = NSHostingView(rootView: content + .environment(\.fixedDate, date) .frame(width: size.width, height: size.height) .clipShape(RoundedRectangle(cornerRadius: 14)) .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(Color.primary.opacity(0.14)))) From 72d6ebbd383be18a02a7416f20697a1636687b04 Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Sat, 31 Oct 2026 15:21:10 +0100 Subject: [PATCH 3/5] build: draw the docs images without the running apps, remote images or a moving shimmer A review found three more things that could change the docs images from one run to the next. The active app came from the running apps, and the lookup could miss Finder and show Desktop: SampleApp now takes an app's name and icon from its bundle. An image block fetched remote URLs: renders set an offline environment value, and remote images stay empty there. The shimmer followed the live time: it draws the fixed date when a render sets one. Two renders of this commit on one Mac differ in 0 pixels across all 14 images. --- Sources/StableBar/BarView.swift | 2 + Sources/StableBar/DocsImages.swift | 3 +- Sources/StableBar/PopupView.swift | 5 ++- Sources/StableBar/SampleApp.swift | 22 +++++++++++ Tests/StableBarTests/FixedDateTests.swift | 10 +++++ Tests/StableBarTests/SampleAppTests.swift | 48 +++++++++++++++++++++++ 6 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 Sources/StableBar/SampleApp.swift create mode 100644 Tests/StableBarTests/SampleAppTests.swift diff --git a/Sources/StableBar/BarView.swift b/Sources/StableBar/BarView.swift index 0c0188b4..e32305d8 100644 --- a/Sources/StableBar/BarView.swift +++ b/Sources/StableBar/BarView.swift @@ -229,6 +229,8 @@ extension EnvironmentValues { @Entry var barExpansion: BarExpansion? /// A date to draw instead of the current time, so a render shows the same clock on every run. @Entry var fixedDate: Date? + /// Draws without the network, as a render does: remote images stay empty. + @Entry var offline = false } enum BarExpansion { diff --git a/Sources/StableBar/DocsImages.swift b/Sources/StableBar/DocsImages.swift index 949b1869..ffe4acdd 100644 --- a/Sources/StableBar/DocsImages.swift +++ b/Sources/StableBar/DocsImages.swift @@ -215,7 +215,7 @@ enum DocsImages { try offline(store, in: directory) store.workspaceStore.executablePath = { aerospace.path } store.reloadConfiguration() - store.activeApp = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.finder").first + store.activeApp = SampleApp(bundleIdentifier: "com.apple.finder") return store } @@ -226,6 +226,7 @@ enum DocsImages { to url: URL) async throws { let view = NSHostingView(rootView: content .environment(\.fixedDate, date) + .environment(\.offline, true) .frame(width: size.width, height: size.height) .clipShape(RoundedRectangle(cornerRadius: 14)) .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(Color.primary.opacity(0.14)))) diff --git a/Sources/StableBar/PopupView.swift b/Sources/StableBar/PopupView.swift index 4c942c9f..ee21fad4 100644 --- a/Sources/StableBar/PopupView.swift +++ b/Sources/StableBar/PopupView.swift @@ -838,12 +838,13 @@ struct Shimmer: ViewModifier { let active: Bool @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.measuringBarWidths) private var measuring + @Environment(\.fixedDate) private var fixedDate func body(content: Content) -> some View { if active && !reduceMotion && !measuring { content.overlay { TimelineView(.animation) { timeline in - let phase = timeline.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 1.8) / 1.8 + let phase = (fixedDate ?? timeline.date).timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 1.8) / 1.8 GeometryReader { proxy in LinearGradient(colors: [.clear, .white.opacity(0.85), .clear], startPoint: .leading, endPoint: .trailing) .frame(width: proxy.size.width * 0.4) @@ -934,6 +935,7 @@ struct WidgetImage: View { let fit: ImageFit @State private var image: NSImage? @State private var error = "" + @Environment(\.offline) private var offline var body: some View { Group { @@ -956,6 +958,7 @@ struct WidgetImage: View { } else { url = URL(filePath: NSString(string: path).expandingTildeInPath) } + if offline, !url.isFileURL { return } if url.isFileURL { let file = try url.resourceValues(forKeys: [.isApplicationKey, .isExecutableKey, .isRegularFileKey, .contentTypeKey]) if file.isApplication == true || file.isExecutable == true && file.isRegularFile == true && file.contentType?.conforms(to: .image) != true { diff --git a/Sources/StableBar/SampleApp.swift b/Sources/StableBar/SampleApp.swift new file mode 100644 index 00000000..3b66156f --- /dev/null +++ b/Sources/StableBar/SampleApp.swift @@ -0,0 +1,22 @@ +import AppKit + +/// An app as a render shows it: the name and icon of its bundle, whether it runs or not, so a render +/// doesn't depend on the apps running on the Mac that draws it. +final class SampleApp: NSRunningApplication, @unchecked Sendable { + private let name: String? + private let image: NSImage? + private let identifier: String + + init(bundleIdentifier: String) { + identifier = bundleIdentifier + let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) + let bundle = url.flatMap(Bundle.init(url:)) + name = (bundle?.object(forInfoDictionaryKey: "CFBundleDisplayName") ?? bundle?.object(forInfoDictionaryKey: "CFBundleName")) as? String + image = url.map { NSWorkspace.shared.icon(forFile: $0.path) } + super.init() + } + + override var localizedName: String? { name } + override var icon: NSImage? { image } + override var bundleIdentifier: String? { identifier } +} diff --git a/Tests/StableBarTests/FixedDateTests.swift b/Tests/StableBarTests/FixedDateTests.swift index 53e93bc7..adfc2b53 100644 --- a/Tests/StableBarTests/FixedDateTests.swift +++ b/Tests/StableBarTests/FixedDateTests.swift @@ -56,3 +56,13 @@ private func difference(_ first: [UInt8], _ second: [UInt8]) -> Int { #expect(format.value == "EEE d MMM HH:mm") #expect(format.sample(at: fixed) != format.sample(at: fixed.addingTimeInterval(86_400))) } + +@Test @MainActor func shimmerDrawsTheFixedDate() async throws { + func shimmer() throws -> [UInt8] { + try pixels(Text("Loading the answer").font(.system(size: 20)).modifier(Shimmer(active: true)) + .frame(width: 240, height: 40).background(Color.black).environment(\.fixedDate, fixed)) + } + let first = try shimmer() + try await Task.sleep(for: .milliseconds(600)) + #expect(try difference(shimmer(), first) <= 1) +} diff --git a/Tests/StableBarTests/SampleAppTests.swift b/Tests/StableBarTests/SampleAppTests.swift new file mode 100644 index 00000000..f50e6670 --- /dev/null +++ b/Tests/StableBarTests/SampleAppTests.swift @@ -0,0 +1,48 @@ +import AppKit +import SwiftUI +import Testing +@testable import StableBar + +/// Records requests to `offline-image.invalid` and fails them, so a test can tell which images asked the network. +private final class RecordingURLProtocol: URLProtocol, @unchecked Sendable { + nonisolated(unsafe) static var requests: [URL] = [] + override class func canInit(with request: URLRequest) -> Bool { request.url?.host == "offline-image.invalid" } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + if let url = request.url { Self.requests.append(url) } + client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet)) + } + override func stopLoading() {} +} + +@Test @MainActor func sampleAppComesFromItsBundle() { + let app = SampleApp(bundleIdentifier: "com.apple.calculator") + #expect(app.localizedName == "Calculator") + #expect(app.icon != nil) + #expect(app.bundleIdentifier == "com.apple.calculator") +} + +@Test @MainActor func offlineImagesMakeNoRequest() async throws { + URLProtocol.registerClass(RecordingURLProtocol.self) + defer { URLProtocol.unregisterClass(RecordingURLProtocol.self) } + RecordingURLProtocol.requests = [] + func show(_ path: String, offline: Bool) -> NSWindow { + let window = NSWindow(contentRect: CGRect(x: -9000, y: -6000, width: 120, height: 120), styleMask: [.borderless], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = NSHostingView(rootView: WidgetImage(path: path, fit: .fit).frame(width: 100, height: 100) + .environment(\.offline, offline)) + window.orderFrontRegardless() + return window + } + let windows = [show("https://offline-image.invalid/offline.png", offline: true), + show("https://offline-image.invalid/online.png", offline: false)] + defer { windows.forEach { $0.close() } } + // The online image is the control: once its request arrives, both images have loaded. Wait for it by + // polling, since a busy main actor can delay the images' tasks well past any fixed sleep. + for _ in 0..<150 where RecordingURLProtocol.requests.isEmpty { + try await Task.sleep(for: .milliseconds(100)) + } + try await Task.sleep(for: .milliseconds(200)) + #expect(RecordingURLProtocol.requests.map(\.lastPathComponent) == ["online.png"]) +} From 26012b3cde4b16ab54f88e8321470da6486a47ca Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Sat, 31 Oct 2026 18:31:28 +0100 Subject: [PATCH 4/5] test: guard the image tests' request list and Settings' clock samples From CodeRabbit's review: - The offline image test's recorder appended requests from URL loading threads while the test read and reset the list on the main actor. A lock guards it now. - No test drew Settings' clock format samples, which take the render's fixed date. A test draws them a day apart in an offscreen window, where their lazy grid lays out, and fails when they draw the live date instead. --- Tests/StableBarTests/FixedDateTests.swift | 34 +++++++++++++++++++++++ Tests/StableBarTests/SampleAppTests.swift | 10 +++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/Tests/StableBarTests/FixedDateTests.swift b/Tests/StableBarTests/FixedDateTests.swift index adfc2b53..c9cd9c6e 100644 --- a/Tests/StableBarTests/FixedDateTests.swift +++ b/Tests/StableBarTests/FixedDateTests.swift @@ -57,6 +57,40 @@ private func difference(_ first: [UInt8], _ second: [UInt8]) -> Int { #expect(format.sample(at: fixed) != format.sample(at: fixed.addingTimeInterval(86_400))) } +/// `content` drawn in an offscreen window, where a scroll view lays out its lazy grids, as the bitmap's bytes. +@MainActor private func windowPixels(_ content: some View, size: NSSize) throws -> [UInt8] { + let window = NSWindow(contentRect: CGRect(origin: CGPoint(x: -9000, y: -6000), size: size), styleMask: [.borderless], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + let view = NSHostingView(rootView: content) + view.frame = CGRect(origin: .zero, size: size) + window.contentView = view + window.orderFrontRegardless() + view.layoutSubtreeIfNeeded() + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + let data = try #require(bitmap.bitmapData) + return Array(UnsafeBufferPointer(start: data, count: bitmap.bytesPerRow * bitmap.pixelsHigh)) +} + +@Test @MainActor func clockSettingsSamplesDrawTheFixedDate() throws { + // The clock shows only the time, so its own row draws the same a day later: only the format samples, which show + // dates, can tell the two days apart, and they do only if they draw the date the render gives them. + let fixture = try ThemeGalleryFixture(#"{"widgets":[{"id":"clock","type":"clock","position":"right","format":"HH:mm"}]}"#) + defer { fixture.close() } + let editor = ConfigurationEditor(store: try fixture.store()) + editor.selectedID = "clock" + func settings(_ date: Date) throws -> [UInt8] { + try windowPixels(BarInspector(editor: editor, screen: .laptop, kinds: [.laptop], names: [:], source: { _ in .builtIn }, + useSharedBar: { _ in }, remove: { _ in }, scrollPositions: .constant([:])) + .environment(\.fixedDate, date), size: NSSize(width: 560, height: 1000)) + } + let first = try settings(fixed) + #expect(try difference(settings(fixed.addingTimeInterval(86_400)), first) > 1) + #expect(try difference(settings(fixed), first) <= 1) +} + @Test @MainActor func shimmerDrawsTheFixedDate() async throws { func shimmer() throws -> [UInt8] { try pixels(Text("Loading the answer").font(.system(size: 20)).modifier(Shimmer(active: true)) diff --git a/Tests/StableBarTests/SampleAppTests.swift b/Tests/StableBarTests/SampleAppTests.swift index f50e6670..920f19ed 100644 --- a/Tests/StableBarTests/SampleAppTests.swift +++ b/Tests/StableBarTests/SampleAppTests.swift @@ -1,15 +1,19 @@ import AppKit +import os import SwiftUI import Testing @testable import StableBar /// Records requests to `offline-image.invalid` and fails them, so a test can tell which images asked the network. +/// The URL loading system records from its own threads while the test reads on the main actor, so a lock guards the list. private final class RecordingURLProtocol: URLProtocol, @unchecked Sendable { - nonisolated(unsafe) static var requests: [URL] = [] + private static let recorded = OSAllocatedUnfairLock<[URL]>(initialState: []) + static var requests: [URL] { recorded.withLock { $0 } } + static func reset() { recorded.withLock { $0 = [] } } override class func canInit(with request: URLRequest) -> Bool { request.url?.host == "offline-image.invalid" } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } override func startLoading() { - if let url = request.url { Self.requests.append(url) } + if let url = request.url { Self.recorded.withLock { $0.append(url) } } client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet)) } override func stopLoading() {} @@ -25,7 +29,7 @@ private final class RecordingURLProtocol: URLProtocol, @unchecked Sendable { @Test @MainActor func offlineImagesMakeNoRequest() async throws { URLProtocol.registerClass(RecordingURLProtocol.self) defer { URLProtocol.unregisterClass(RecordingURLProtocol.self) } - RecordingURLProtocol.requests = [] + RecordingURLProtocol.reset() func show(_ path: String, offline: Bool) -> NSWindow { let window = NSWindow(contentRect: CGRect(x: -9000, y: -6000, width: 120, height: 120), styleMask: [.borderless], backing: .buffered, defer: false) From c3354ff552593e1de2eb3891bb474d01426fdad9 Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Sat, 31 Oct 2026 18:44:31 +0100 Subject: [PATCH 5/5] test: compare the sample app's name with Finder's, in any language From CodeRabbit's review: the test expected "Calculator", but an app's display name can be localized, so a Mac in another language could fail it. It now expects the name Finder shows for the app, which comes from the same localized bundle information. --- Tests/StableBarTests/SampleAppTests.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Tests/StableBarTests/SampleAppTests.swift b/Tests/StableBarTests/SampleAppTests.swift index 920f19ed..877c9814 100644 --- a/Tests/StableBarTests/SampleAppTests.swift +++ b/Tests/StableBarTests/SampleAppTests.swift @@ -19,9 +19,11 @@ private final class RecordingURLProtocol: URLProtocol, @unchecked Sendable { override func stopLoading() {} } -@Test @MainActor func sampleAppComesFromItsBundle() { +@Test @MainActor func sampleAppComesFromItsBundle() throws { let app = SampleApp(bundleIdentifier: "com.apple.calculator") - #expect(app.localizedName == "Calculator") + // The name in the system's language, as Finder shows it: "Calculator" in English, "Calculette" in French. + let url = try #require(NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.calculator")) + #expect(app.localizedName == (FileManager.default.displayName(atPath: url.path) as NSString).deletingPathExtension) #expect(app.icon != nil) #expect(app.bundleIdentifier == "com.apple.calculator") }