diff --git a/.qlty/qlty.toml b/.qlty/qlty.toml index 782012b..05d4242 100644 --- a/.qlty/qlty.toml +++ b/.qlty/qlty.toml @@ -6,6 +6,31 @@ exclude_patterns = [ "**/node_modules/**", ] +[file_types.osv-scanner] +# Qlty replaces this list, so retain its existing lockfile targets. +globs = [ + "**/conan.lock", + "**/pubspec.lock", + "**/mix.lock", + "**/go.mod", + "**/buildscript-gradle.lockfile", + "**/gradle.lockfile", + "**/gradle/verification-metadata.xml", + "**/pom.xml", + "**/package-lock.json", + "**/pnpm-lock.yaml", + "**/yarn.lock", + "**/composer.lock", + "**/Pipfile.lock", + "**/poetry.lock", + "**/requirements.txt", + "**/pdm.lock", + "**/renv.lock", + "**/Gemfile.lock", + "**/Cargo.lock", + "**/Package.resolved", +] + [smells] mode = "comment" @@ -21,6 +46,7 @@ name = "bandit" [[plugin]] name = "osv-scanner" +version = "2.4.0" [[plugin]] name = "radarlint-python" diff --git a/README.md b/README.md index 2e1a1cb..8a6fea8 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Keeps your Mac awake. A native macOS 26 menu bar app for Apple Silicon. +From [CodesWhat](https://codeswhat.com): small, sharp tools from one developer and a lot of agents. + The local preview includes timed and indefinite awake holds, display-sleep controls, battery and Low Power Mode guards, four menu bar glyphs, optional compact/clock countdowns or end time, Settings, configurable global shortcuts, local history and statistics, URL automation, AppleScript, diff --git a/Sources/DilatedUI/Automation/Downloads.swift b/Sources/DilatedUI/Automation/Downloads.swift index 645bc9c..21792f5 100644 --- a/Sources/DilatedUI/Automation/Downloads.swift +++ b/Sources/DilatedUI/Automation/Downloads.swift @@ -190,6 +190,8 @@ private final class DownloadEventTarget: Sendable { requestRefresh() } + var pendingReadForTesting: Task? { readTask } + func tick() { guard active, let quietDeadline, clock() >= quietDeadline, readTask == nil else { return } requestRefresh() diff --git a/Sources/DilatedUI/Automation/TriggersSettings.swift b/Sources/DilatedUI/Automation/TriggersSettings.swift index 3f741ec..2ac15d1 100644 --- a/Sources/DilatedUI/Automation/TriggersSettings.swift +++ b/Sources/DilatedUI/Automation/TriggersSettings.swift @@ -126,6 +126,9 @@ import UniformTypeIdentifiers } @MainActor struct TriggerRuleEditor: View { + #if DEBUG + var didAppear: ((Self) -> Void)? + #endif @State var rule: TriggerRule @State private var criteria: TriggerCriteriaRows var save: (TriggerRule) throws -> Void @@ -220,6 +223,9 @@ import UniformTypeIdentifiers } .padding(24).frame(width: 560, height: 600) .font(DesignFont.sans(13)).foregroundStyle(DesignPalette.ink).background(DesignPalette.bg) + #if DEBUG + .onAppear { didAppear?(self) } + #endif } } diff --git a/Sources/DilatedUI/Automation/WiFi.swift b/Sources/DilatedUI/Automation/WiFi.swift index 73c908e..98e23c6 100644 --- a/Sources/DilatedUI/Automation/WiFi.swift +++ b/Sources/DilatedUI/Automation/WiFi.swift @@ -133,6 +133,8 @@ private final class NativeWiFiAuthorization: NSObject, WiFiAuthorizationSource, } @MainActor final class WiFiObserver { + typealias Schedule = @MainActor (@escaping @MainActor @Sendable () -> Void) -> Void + private let schedule: Schedule private let suppliedAccess: WiFiAccess? private let readSSIDs: () -> Set? private let changed: () -> Void @@ -144,8 +146,10 @@ private final class NativeWiFiAuthorization: NSObject, WiFiAuthorizationSource, init( access: WiFiAccess? = nil, readSSIDs: (() -> Set?)? = nil, + schedule: @escaping Schedule = { action in Task { @MainActor in action() } }, changed: @escaping () -> Void ) { + self.schedule = schedule suppliedAccess = access self.readSSIDs = readSSIDs ?? { WiFiCatalog.read() } self.changed = changed @@ -187,7 +191,7 @@ private final class NativeWiFiAuthorization: NSObject, WiFiAuthorizationSource, guard !notificationPending else { return } notificationPending = true let generation = generation - Task { @MainActor [weak self] in + schedule { [weak self] in guard let self, self.access != nil, self.generation == generation else { return } self.notificationPending = false self.changed() diff --git a/Sources/DilatedUI/Feedback/FeedbackController.swift b/Sources/DilatedUI/Feedback/FeedbackController.swift index 382a8e3..c3c40e0 100644 --- a/Sources/DilatedUI/Feedback/FeedbackController.swift +++ b/Sources/DilatedUI/Feedback/FeedbackController.swift @@ -54,6 +54,8 @@ struct FeedbackNotice: Sendable { @ObservationIgnored private var requests: [FeedbackNotice.Kind: UUID] = [:] @ObservationIgnored private var tasks: [FeedbackNotice.Kind: Task] = [:] + var pendingDeliveriesForTesting: [Task] { Array(tasks.values) } + init(service: any FeedbackService, sounds: any FeedbackSoundPlaying) { self.service = service self.sounds = sounds diff --git a/Sources/DilatedUI/Settings/SettingsWindow.swift b/Sources/DilatedUI/Settings/SettingsWindow.swift index 5b111b3..ca38834 100644 --- a/Sources/DilatedUI/Settings/SettingsWindow.swift +++ b/Sources/DilatedUI/Settings/SettingsWindow.swift @@ -247,7 +247,7 @@ private enum SettingsPage: String, CaseIterable, Identifiable { } .padding(.horizontal, 28).padding(.top, 12).padding(.bottom, 28) .frame(maxWidth: .infinity, alignment: .leading) - }.background(DesignPalette.bg) + }.background(DesignPalette.bg).id(page) } .font(DesignFont.sans(13)).foregroundStyle(DesignPalette.ink).tint(SettingsTheme.control) .frame(minWidth: 860, minHeight: 600) diff --git a/Tests/DilatedCoreTests/PackageHelperTests.swift b/Tests/DilatedCoreTests/PackageHelperTests.swift index b63c1ab..25be382 100644 --- a/Tests/DilatedCoreTests/PackageHelperTests.swift +++ b/Tests/DilatedCoreTests/PackageHelperTests.swift @@ -19,7 +19,20 @@ struct PackageHelperTests { process.waitUntilExit() } } - try await Task.sleep(for: .milliseconds(50)) + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + let pattern = "^" + NSRegularExpression.escapedPattern(for: executable.path) + "($|[[:space:]])" + var ready = false + while ContinuousClock.now < deadline { + let found = try await BoundedProcess.run( + executable: URL(fileURLWithPath: "/usr/bin/pgrep"), + arguments: ["-f", pattern], timeout: 0.5) + if !found.timedOut, found.status == 0 { + ready = true + break + } + try await Task.sleep(for: .milliseconds(20)) + } + try #require(ready, "The child did not adopt the expected app identity.") await #expect(throws: (any Error).self) { try await PackageHelperCheck.verifyAppAbsent(executable: executable) } diff --git a/Tests/DilatedCoreTests/TriggerTests.swift b/Tests/DilatedCoreTests/TriggerTests.swift index 44b33db..de06a93 100644 --- a/Tests/DilatedCoreTests/TriggerTests.swift +++ b/Tests/DilatedCoreTests/TriggerTests.swift @@ -21,7 +21,9 @@ struct TriggerTests { var input = TriggerInputs() input.processNames = ["make"] evaluator.reconcile(rules: [rule], inputs: input, engine: &engine, now: start) - #expect(evaluator.activeHoldIDs[rule.id] == engine.holds.first?.id) + try #require(engine.holds.count == 1) + let hold = try #require(engine.holds.first) + #expect(evaluator.activeHoldIDs[rule.id] == hold.id) #expect(!engine.desiredState.closedLid) evaluator.reconcile(rules: [], inputs: input, engine: &engine, now: start) #expect(evaluator.activeHoldIDs.isEmpty && engine.holds.isEmpty) diff --git a/Tests/DilatedUITests/DownloadTests.swift b/Tests/DilatedUITests/DownloadTests.swift index 3b4189d..5a83946 100644 --- a/Tests/DilatedUITests/DownloadTests.swift +++ b/Tests/DilatedUITests/DownloadTests.swift @@ -192,7 +192,7 @@ import Testing if failure { fixture.readFails = true } else { fixture.snapshot.files = [:] } fixture.event?() await fixture.drain() - #expect(fixture.ends.count == 1) + try #require(fixture.ends.count == 1) #expect((fixture.ends[0] != nil) == failure) #expect(fixture.cancellations == 1) } @@ -303,6 +303,7 @@ import Testing defer { try? FileManager.default.removeItem(at: directory) } var power = PowerState() let model = AppModel(endpoint: .init(stateDirectory: directory), power: { power }) + defer { model.shutdown() } let fixture = DownloadFixture() _ = try model.startDownload(fixture.target, quietSeconds: 30, makeMonitor: fixture.monitor) await fixture.drain() @@ -311,9 +312,43 @@ import Testing #expect(model.engine.holds.isEmpty && fixture.cancellations == 1) power.lowPowerMode = false model.refreshPower() - _ = try model.startDownload(fixture.target, quietSeconds: 30, makeMonitor: fixture.monitor) + var readGate: CheckedContinuation? + var readReturned = false + var completions = 0 + var retainedMonitor: DownloadMonitor? + _ = try model.startDownload(fixture.target, quietSeconds: 30) { target, seconds, finished in + let monitor = DownloadMonitor( + target: target, quietSeconds: seconds, + read: { _ in + let snapshot = await withCheckedContinuation { readGate = $0 } + readReturned = true + return snapshot + }, + subscribe: { _, _ in { fixture.cancellations += 1 } }, clock: { 0 }, + finished: { + completions += 1 + finished($0) + }) + retainedMonitor = monitor + return monitor + } + let monitor = try #require(retainedMonitor) + defer { + readGate?.resume(returning: .init(files: [:])) + monitor.stop() + } + let pending = try #require(monitor.pendingReadForTesting) + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while readGate == nil && ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(1)) + } + let gate = try #require(readGate) model.shutdown() - await fixture.drain() + #expect(pending.isCancelled) + gate.resume(returning: .init(files: [:])) + readGate = nil + await pending.value + #expect(readReturned && completions == 0) #expect(model.engine.holds.isEmpty && fixture.cancellations == 2) } } @@ -331,6 +366,7 @@ import Testing var cancellations = 0 var ends: [String?] = [] var event: (@MainActor @Sendable () -> Void)? + private weak var lastMonitor: DownloadMonitor? func monitor() -> DownloadMonitor { monitor(target, 30) { [weak self] in self?.ends.append($0) } } @@ -338,9 +374,10 @@ import Testing _ target: DownloadTarget, _ quietSeconds: TimeInterval, _ finished: @escaping @MainActor (String?) -> Void ) -> DownloadMonitor { - DownloadMonitor( + let monitor = DownloadMonitor( target: target, quietSeconds: quietSeconds, read: { [self] _ in + try await Task.sleep(for: .milliseconds(5)) reads += 1 if readFails { throw CocoaError(.fileReadNoPermission) } if let readError { throw readError } @@ -350,6 +387,9 @@ import Testing self.event = event return { [self] in cancellations += 1 } }, clock: { [self] in uptime }, finished: finished) + lastMonitor = monitor + return monitor } - func drain() async { for _ in 0..<20 { await Task.yield() } } + var pendingRead: Task? { lastMonitor?.pendingReadForTesting } + func drain() async { await pendingRead?.value } } diff --git a/Tests/DilatedUITests/DriveAliveTests.swift b/Tests/DilatedUITests/DriveAliveTests.swift index c833518..7e503e6 100644 --- a/Tests/DilatedUITests/DriveAliveTests.swift +++ b/Tests/DilatedUITests/DriveAliveTests.swift @@ -327,69 +327,76 @@ import Testing #expect(delays == [120]) } - @Test(.enabled(if: ProcessInfo.processInfo.environment["DILATED_DRIVE_FIXTURE"] != nil)) - func nativeSelectedVolumeAcceptsWritesOrRejectsReadOnlyMounts() async throws { - let path = try #require(ProcessInfo.processInfo.environment["DILATED_DRIVE_FIXTURE"]) - let folder = URL(fileURLWithPath: path) - let selected = try #require(try NativeDriveAlive.mounted().first { $0.volume.path == path }) - let before = try FileManager.default.contentsOfDirectory(atPath: path).sorted() - if selected.readOnly { - await #expect(throws: (any Error).self) { try await NativeDriveAlive.pulse(selected.volume) } - } else { - try await NativeDriveAlive.pulse(selected.volume) + @Suite(.serialized) + @MainActor struct NativeFixtureTests { + @Test(.enabled(if: ProcessInfo.processInfo.environment["DILATED_DRIVE_FIXTURE"] != nil)) + func nativeSelectedVolumeAcceptsWritesOrRejectsReadOnlyMounts() async throws { + let path = try #require(ProcessInfo.processInfo.environment["DILATED_DRIVE_FIXTURE"]) + let folder = URL(fileURLWithPath: path) + let selected = try #require(try NativeDriveAlive.mounted().first { $0.volume.path == path }) + let before = try FileManager.default.contentsOfDirectory(atPath: path).sorted() + if selected.readOnly { + await #expect(throws: (any Error).self) { + try await NativeDriveAlive.pulse(selected.volume) + } + } else { + try await NativeDriveAlive.pulse(selected.volume) + } + #expect(try FileManager.default.contentsOfDirectory(atPath: path).sorted() == before) + #expect(try Data(contentsOf: folder.appendingPathComponent("user-file")) == Data([1, 2, 3])) } - #expect(try FileManager.default.contentsOfDirectory(atPath: path).sorted() == before) - #expect(try Data(contentsOf: folder.appendingPathComponent("user-file")) == Data([1, 2, 3])) - } - @Test( - .enabled(if: ProcessInfo.processInfo.environment["DILATED_DRIVE_FIXTURE"] != nil), - .timeLimit(.minutes(1))) - func nativeMixedModesPulseOnlyTheEligibleVolumeAndStillPause() async throws { - let path = try #require(ProcessInfo.processInfo.environment["DILATED_DRIVE_FIXTURE"]) - let mount = try #require(try NativeDriveAlive.mounted().first { $0.volume.path == path }) - let before = try FileManager.default.contentsOfDirectory(atPath: path).sorted() - var selected = mount.volume - selected.mode = .always - var pulses: [String] = [] - var scheduled: (@MainActor @Sendable () -> Void)? - let didSchedule = Signal() - let controller = DriveAliveController( - clock: { 1000 }, - pulse: { volume in - pulses.append(volume.id) - try await NativeDriveAlive.pulse(volume) - }, - schedule: { _, fire in - scheduled = fire - didSchedule.fire() - return {} - }) - defer { controller.stop() } - var settings = DriveAliveSettings() - settings.enabled = true - settings.volumes = [ - .init(id: "native-awake-only", name: "Inactive", path: "/Volumes/Missing", mode: .awakeOnly), - selected, - ] - controller.configure(settings, awake: false, paused: false) - try await didSchedule.wait() - try #require(scheduled != nil) - #expect(pulses == [selected.id]) - #expect(controller.statuses["native-awake-only"] == nil) - if mount.readOnly { - #expect(controller.statuses[selected.id]?.contains("read-only") == true) - } else { - #expect(controller.statuses[selected.id] == "Active") + @Test( + .enabled(if: ProcessInfo.processInfo.environment["DILATED_DRIVE_FIXTURE"] != nil), + .timeLimit(.minutes(1))) + func nativeMixedModesPulseOnlyTheEligibleVolumeAndStillPause() async throws { + let path = try #require(ProcessInfo.processInfo.environment["DILATED_DRIVE_FIXTURE"]) + let mount = try #require(try NativeDriveAlive.mounted().first { $0.volume.path == path }) + let before = try FileManager.default.contentsOfDirectory(atPath: path).sorted() + var selected = mount.volume + selected.mode = .always + var pulses: [String] = [] + var scheduled: (@MainActor @Sendable () -> Void)? + let didSchedule = Signal() + let controller = DriveAliveController( + clock: { 1000 }, + pulse: { volume in + pulses.append(volume.id) + try await NativeDriveAlive.pulse(volume) + }, + schedule: { _, fire in + scheduled = fire + didSchedule.fire() + return {} + }) + defer { controller.stop() } + var settings = DriveAliveSettings() + settings.enabled = true + settings.volumes = [ + .init( + id: "native-awake-only", name: "Inactive", path: "/Volumes/Missing", mode: .awakeOnly), + selected, + ] + controller.configure(settings, awake: false, paused: false) + try await didSchedule.wait() + try #require(scheduled != nil) + #expect(pulses == [selected.id]) + #expect(controller.statuses["native-awake-only"] == nil) + if mount.readOnly { + #expect(controller.statuses[selected.id]?.contains("read-only") == true) + } else { + #expect(controller.statuses[selected.id] == "Active") + } + controller.configure(settings, awake: false, paused: true) + scheduled?() + for _ in 0..<20 { await Task.yield() } + #expect(pulses == [selected.id]) + #expect(try FileManager.default.contentsOfDirectory(atPath: path).sorted() == before) + #expect( + try Data(contentsOf: URL(fileURLWithPath: path).appendingPathComponent("user-file")) + == Data([1, 2, 3])) } - controller.configure(settings, awake: false, paused: true) - scheduled?() - for _ in 0..<20 { await Task.yield() } - #expect(pulses == [selected.id]) - #expect(try FileManager.default.contentsOfDirectory(atPath: path).sorted() == before) - #expect( - try Data(contentsOf: URL(fileURLWithPath: path).appendingPathComponent("user-file")) - == Data([1, 2, 3])) + } @Test func writerCreatesOnlyANewFileAndCleansUpWithoutTouchingExistingFiles() throws { diff --git a/Tests/DilatedUITests/FeedbackTests.swift b/Tests/DilatedUITests/FeedbackTests.swift index 01e5bfe..c558833 100644 --- a/Tests/DilatedUITests/FeedbackTests.swift +++ b/Tests/DilatedUITests/FeedbackTests.swift @@ -365,8 +365,14 @@ import Testing var permissionGate: CheckedContinuation? var delaySend = false var sendGate: CheckedContinuation? - func controller() -> FeedbackController { FeedbackController(service: self, sounds: self) } + private weak var lastController: FeedbackController? + func controller() -> FeedbackController { + let controller = FeedbackController(service: self, sounds: self) + lastController = controller + return controller + } func authorization() async -> FeedbackAuthorization { + try? await Task.sleep(for: .milliseconds(5)) if delayAuthorization { let result = await withCheckedContinuation { authorizationGate = $0 } authorizationReturned = true @@ -389,5 +395,7 @@ import Testing played.append(choice) } func stop() { stops += 1 } - func drain() async { for _ in 0..<30 { await Task.yield() } } + func drain() async { + for delivery in lastController?.pendingDeliveriesForTesting ?? [] { await delivery.value } + } } diff --git a/Tests/DilatedUITests/MountedSettingsTests.swift b/Tests/DilatedUITests/MountedSettingsTests.swift new file mode 100644 index 0000000..d2aea87 --- /dev/null +++ b/Tests/DilatedUITests/MountedSettingsTests.swift @@ -0,0 +1,87 @@ +import AppKit +import DilatedCore +import Observation +import SwiftUI +import Testing + +@testable import DilatedUI + +@MainActor struct MountedSettingsTests { + @Test func insertingARowPreservesTheExistingRowsStateAndFocus() throws { + _ = NSApplication.shared + FontRegistry.registerBundledFonts() + let fixture = GroupFixtureState() + let view = NSHostingView(rootView: GroupFixture(fixture: fixture).frame(width: 400)) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 200), styleMask: [], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = view + defer { window.close() } + try render(view) + let originalHeight = view.fittingSize.height + let field = try #require(descendants(of: view).compactMap { $0 as? NSTextField }.first) + let original = field.stringValue + try #require(!original.isEmpty) + try #require(window.makeFirstResponder(field)) + let editor = try #require(field.currentEditor()) + #expect(window.firstResponder === editor) + + fixture.inserted = true + try render(view) + try #require(view.fittingSize.height > originalHeight) + let survivor = try #require(descendants(of: view).compactMap { $0 as? NSTextField }.first) + #expect(survivor.stringValue == original) + #expect(survivor === field) + #expect(window.firstResponder === editor) + } + + @Test func specificProcessPickerLoadsWithoutPressingRefresh() throws { + _ = NSApplication.shared + FontRegistry.registerBundledFonts() + let pid = ProcessInfo.processInfo.processIdentifier + let process = try #require(ProcessCatalog.read().first { $0.id == pid }) + let rule = TriggerRule(name: "This process", criteria: [.processInstance(process.identity)]) + let locale = Locale(identifier: "en_US") + let view = NSHostingView( + rootView: TriggerRuleEditor(rule: rule, save: { _ in }, remove: {}).environment( + \.locale, locale)) + try render(view) + let pickers = descendants(of: view).compactMap { $0 as? NSPopUpButton } + let title = "\(process.name) (\(pid.formatted(.number.locale(locale))))" + let picker = try #require(pickers.first { $0.title == title }) + #expect(picker.itemTitles.contains(title)) + } + + private func descendants(of root: NSView) -> [NSView] { + [root] + root.subviews.flatMap { descendants(of: $0) } + } + + private func render(_ view: NSView) throws { + view.setFrameSize(view.fittingSize) + view.layoutSubtreeIfNeeded() + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + } +} + +@MainActor @Observable private final class GroupFixtureState { + var inserted = false +} + +@MainActor private struct GroupFixture: View { + @Bindable var fixture: GroupFixtureState + + var body: some View { + SettingsGroup(eyebrow: "TEST") { + if fixture.inserted { Text("Inserted above") } + StatefulGroupRow() + } + } +} + +@MainActor private struct StatefulGroupRow: View { + @State private var value = UUID().uuidString + + var body: some View { TextField("Retained value", text: $value) } +} diff --git a/Tests/DilatedUITests/SettingsScrollTests.swift b/Tests/DilatedUITests/SettingsScrollTests.swift new file mode 100644 index 0000000..d0d11a1 --- /dev/null +++ b/Tests/DilatedUITests/SettingsScrollTests.swift @@ -0,0 +1,73 @@ +import AppKit +import DilatedCore +import SwiftUI +import Testing +import ViewInspector + +@testable import DilatedUI + +#if DEBUG + @MainActor struct SettingsScrollTests { + @Test func changingPagesStartsTheDetailPaneAtTheTop() throws { + _ = NSApplication.shared + FontRegistry.registerBundledFonts() + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let model = AppModel(endpoint: .init(stateDirectory: directory), power: { .init() }) + defer { model.shutdown() } + let controller = SettingsWindow(model: model) + defer { + controller.stop() + controller.close() + } + let window = try #require(controller.window) + let host = try #require(controller.hostingViewForTesting as? NSHostingView) + var mounted: SettingsContent? + defer { mounted = nil } + var content = host.rootView + content.didAppear = { mounted = $0 } + host.rootView = content + try render(host) + let live = try #require(mounted) + for page in ["General", "Menu Bar", "General"] { + try live.inspect().find(ViewType.Button.self) { + (try? $0.accessibilityLabel().string()) == page + }.tap() + try render(host) + let scroll = try #require( + descendants(of: host).compactMap { $0 as? NSScrollView }.max { + $0.bounds.width * $0.bounds.height < $1.bounds.width * $1.bounds.height + }) + let document = try #require(scroll.documentView) + let clip = scroll.contentView + try #require(clip.bounds.height > 500) + try #require(document.bounds.height > clip.bounds.height + 1) + var topBounds = clip.bounds + topBounds.origin.y = + document.isFlipped + ? document.frame.minY - clip.bounds.height : document.frame.maxY + clip.bounds.height + let top = clip.constrainBoundsRect(topBounds).minY + let bottom = + document.isFlipped + ? document.bounds.maxY - clip.bounds.height : document.bounds.minY + #expect(abs(clip.bounds.minY - top) < 0.5, "\(page) opens at the top") + clip.scroll(to: NSPoint(x: clip.bounds.minX, y: bottom)) + scroll.reflectScrolledClipView(clip) + try render(host) + try #require(abs(clip.bounds.minY - top) > 1) + #expect(!window.isVisible) + } + } + + private func descendants(of root: NSView) -> [NSView] { + [root] + root.subviews.flatMap { descendants(of: $0) } + } + + private func render(_ view: NSView) throws { + view.layoutSubtreeIfNeeded() + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + } + } +#endif diff --git a/Tests/DilatedUITests/TriggerEditorInteractionTests.swift b/Tests/DilatedUITests/TriggerEditorInteractionTests.swift new file mode 100644 index 0000000..de38a94 --- /dev/null +++ b/Tests/DilatedUITests/TriggerEditorInteractionTests.swift @@ -0,0 +1,68 @@ +import AppKit +import DilatedCore +import SwiftUI +import Testing +import ViewInspector + +@testable import DilatedUI + +#if DEBUG + @MainActor struct TriggerEditorInteractionTests { + @Test func removingTheFirstConditionSavesTheSurvivors() throws { + _ = NSApplication.shared + FontRegistry.registerBundledFonts() + let criteria: [TriggerCriterion] = [ + .power(onBattery: true), .process(name: "Xcode"), .cpu(percent: 50, atLeast: true), + ] + let rule = TriggerRule(name: "Three conditions", criteria: criteria) + var saved: TriggerRule? + var mounted: TriggerRuleEditor? + defer { mounted = nil } + var editor = TriggerRuleEditor(rule: rule, save: { saved = $0 }, remove: {}) + editor.didAppear = { mounted = $0 } + let host = NSHostingView(rootView: editor) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 560, height: 600), styleMask: [], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + defer { window.close() } + try render(host) + let field = try #require( + descendants(of: host).compactMap { $0 as? NSTextField }.first { + $0.placeholderString == "Executable name or full path" + }) + try #require(window.makeFirstResponder(field)) + let fieldEditor = try #require(field.currentEditor() as? NSTextView) + fieldEditor.insertText( + "swift-build", + replacementRange: NSRange(location: 0, length: fieldEditor.string.utf16.count)) + try render(host) + let live = try #require(mounted) + let remove = try live.inspect().findAll(ViewType.Button.self).filter { + try $0.labelView().text().string() == "Remove condition" + } + try #require(remove.count == 3) + try remove[0].tap() + try render(host) + #expect(descendants(of: host).contains { $0 === field }) + #expect(fieldEditor.string == "swift-build") + #expect(window.firstResponder === fieldEditor) + try live.inspect().find(button: "Save").tap() + let result = try #require(saved) + #expect(result.criteria == [.process(name: "swift-build"), criteria[2]]) + #expect(!window.isVisible) + } + + private func descendants(of root: NSView) -> [NSView] { + [root] + root.subviews.flatMap { descendants(of: $0) } + } + + private func render(_ view: NSView) throws { + view.setFrameSize(view.fittingSize) + view.layoutSubtreeIfNeeded() + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + } + } +#endif diff --git a/Tests/DilatedUITests/WiFiTests.swift b/Tests/DilatedUITests/WiFiTests.swift index 4e24b98..4efaf9a 100644 --- a/Tests/DilatedUITests/WiFiTests.swift +++ b/Tests/DilatedUITests/WiFiTests.swift @@ -67,12 +67,14 @@ import Testing #expect(!observer.isAuthorized && observer.read() == nil && reads == 2) } - @Test func queuedPermissionChangesAreIgnoredAfterStopOrDeinit() async { + @Test func queuedPermissionChangesAreIgnoredAfterStopOrDeinit() { let source = TestWiFiAuthorization() let access = WiFiAccess { source } var changes = 0 + var pending: [@MainActor @Sendable () -> Void] = [] var observer: WiFiObserver? = WiFiObserver( - access: access, readSSIDs: { [] }, changed: { changes += 1 }) + access: access, readSSIDs: { [] }, schedule: { pending.append($0) }, changed: { changes += 1 } + ) observer?.setEnabled(true) observer?.setEnabled(true) source.permission = .allowed @@ -80,19 +82,22 @@ import Testing source.permission = .denied source.changed?() observer?.stop() - await Task.yield() + for callback in pending { callback() } + pending.removeAll() #expect(changes == 0) observer?.setEnabled(true) source.permission = .allowed source.changed?() - await Task.yield() + for callback in pending { callback() } + pending.removeAll() #expect(changes == 1) weak var released = observer source.permission = .denied source.changed?() observer = nil #expect(released == nil) - await Task.yield() + for callback in pending { callback() } + pending.removeAll() #expect(changes == 1) } diff --git a/scripts/verify.sh b/scripts/verify.sh index 878bbb5..7ba7188 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -2,7 +2,10 @@ set -euo pipefail cd "$(dirname "$0")/.." swift build --arch arm64 --jobs 4 -bash scripts/swift-test.sh --jobs 4 +# Mounted views render synchronously on the main actor; keep deadline tests in a separate run. +mounted_suites='Settings(ContentInteraction|GroupLayout|Render|Scroll)Tests|MountedSettingsTests|TriggerEditorInteractionTests' +bash scripts/swift-test.sh --jobs 4 --skip "$mounted_suites" +bash scripts/swift-test.sh --jobs 4 --filter "$mounted_suites" python3 -m unittest discover -s Tests/ReleaseTests node --test website/tests/*.test.mjs for module in website/dist/*.mjs; do node --check "$module"; done diff --git a/website/dist/index.html b/website/dist/index.html index 8271722..72b6884 100644 --- a/website/dist/index.html +++ b/website/dist/index.html @@ -3,12 +3,13 @@ - + + Dilated · Keep your Mac awake. - + @@ -17,7 +18,7 @@
dilated - +
@@ -25,7 +26,7 @@

NOT
DONE
YET.

The render. The download. The agent having a moment. Keep your Mac up until the job is done.

-

IN TESTING · MACOS 26+ · APPLE SILICON

+

PUBLIC BETA · MACOS 26+ · APPLE SILICON

@@ -80,10 +81,10 @@

Let it finish.

The screen can sleep.

Keeping your Mac awake doesn't have to keep the display on.

Stop is a full stop.

Clears every hold. Automation waits until you explicitly resume it.

Power gets a say.

Battery and Low Power Mode guards help put a limit on late nights.

-
Dilated's yellow and red pixel-eyes app icon, sitting in a Dock

STILL IN TESTING.

Nearly on
the shelf.

Dilated is free Mac software from CodesWhat.
The first release is in testing. There's no public download yet.

Closed-lid mode is still being checked on real hardware. Installation and update checks are also underway.

See how it works

IN TESTING · MACOS 26+ · APPLE SILICON · MIT LICENSE

+
Dilated's yellow and red pixel-eyes app icon, sitting in a Dock

PUBLIC BETA.

Try the
public beta.

Dilated is a free public beta for macOS 26+ on Apple Silicon. From CodesWhat, under MIT.

Closed-lid mode is experimental. Hardware recovery checks remain incomplete. If the download details don't load, see the release history for published builds and their verification status.

Release history

PUBLIC BETA · MACOS 26+ · APPLE SILICON · MIT LICENSE

- +