导出诊断信息与运行日志、界面逻辑测试

This commit is contained in:
gentpan committed 2026-09-13 08:11:54 +05:00
1 parent f9f4201654
commit 3117b69deb
14 files changed
+481 -12

No files matched your search

+2 -1
View File
@@ -10,7 +10,8 @@
#### 新增
- 在线升级:启动时和之后每天检查官网版本清单,发现新版本时弹出版本号与更新摘要,可一键安装、以后再说或跳过此版本;下载后依次核对 sha256、包名与版本、开发者签名团队和 Apple 公证,任何一项不通过都放弃安装;旧版先备份再原地替换,失败自动还原,装完自动重启到新版。「设置 · 关于」与应用菜单里可以手动检查更新。
- 辅助工具版本检查:启动时核对正在运行的辅助工具版本,比应用旧时在风扇、合盖运行和辅助工具设置里提示“重新安装”,侧边栏对应项目显示提醒圆点;有新版本时“关于”旁同样显示圆点。
- 辅助工具版本检查:启动时核对正在运行的辅助工具版本;旧进程还在运行时先断开让它退出,由系统启动新版本,仍然旧时在风扇、合盖运行和辅助工具设置里提示“重新安装”,侧边栏对应项目显示提醒圆点;有新版本时“关于”旁同样显示圆点。
- 导出诊断信息(设置 · 关于):把版本、系统与辅助工具状态、主要设置、最近 3 天的运行日志、清理记录和最近的崩溃报告打包成 zip,反馈问题时附上;自动去掉用户目录名、IP 与硬件地址,不含序列号。应用与辅助工具的关键操作和错误写入系统日志。
#### 调整
+12
View File
@@ -8,8 +8,12 @@
import Foundation
import HelperShared
import os
import SMC
/// 写入系统统一日志,应用“导出诊断信息”时一并收集
private let log = Logger(subsystem: "com.openstats.helper", category: "helper")
final class HelperService: NSObject, NSXPCListenerDelegate, OpenStatsHelperProtocol, @unchecked Sendable {
/// 所有可变状态只在该队列上访问
private let queue = DispatchQueue(label: "com.openstats.helper.state")
@@ -26,8 +30,10 @@ final class HelperService: NSObject, NSXPCListenerDelegate, OpenStatsHelperProto
private var idleExit: DispatchWorkItem?
func run() {
log.notice("辅助工具启动,协议版本 \(HelperConstants.protocolVersion)")
queue.sync {
fans = (try? SMCConnection()).map(FanControl.init)
if fans == nil { log.error("无法打开 SMC") }
recoverFromPreviousRun()
scheduleIdleExit()
}
@@ -72,6 +78,7 @@ final class HelperService: NSObject, NSXPCListenerDelegate, OpenStatsHelperProto
manualFans.insert(fan)
reply(nil)
} catch {
log.error("设置风扇 \(fan) 失败:\(String(describing: error), privacy: .public)")
reply("设置风扇失败:\(error)")
}
}
@@ -86,6 +93,7 @@ final class HelperService: NSObject, NSXPCListenerDelegate, OpenStatsHelperProto
manualFans.remove(fan)
reply(nil)
} catch {
log.error("恢复风扇 \(fan) 失败:\(String(describing: error), privacy: .public)")
reply("恢复风扇失败:\(error)")
}
}
@@ -123,6 +131,7 @@ final class HelperService: NSObject, NSXPCListenerDelegate, OpenStatsHelperProto
let result = runTool("/usr/sbin/networksetup", DNSConfiguration.networksetupArguments(service: service, servers: servers))
guard result.status == 0 else {
log.error("networksetup 失败:\(result.output, privacy: .public)")
return reply("networksetup 执行失败:\(result.output.trimmingCharacters(in: .whitespacesAndNewlines))")
}
reply(run(.flushDNS))
@@ -135,6 +144,7 @@ final class HelperService: NSObject, NSXPCListenerDelegate, OpenStatsHelperProto
connections.remove(id)
guard connections.isEmpty else { return }
// 应用已退出或崩溃:把系统恢复到默认状态
log.info("客户端全部断开,恢复风扇与睡眠设置")
_ = resetFans()
if sleepDisabledByHelper { _ = applySleepDisabled(false) }
scheduleIdleExit()
@@ -143,6 +153,7 @@ final class HelperService: NSObject, NSXPCListenerDelegate, OpenStatsHelperProto
private func recoverFromPreviousRun() {
guard let data = try? Data(contentsOf: stateURL),
let state = try? PropertyListDecoder().decode(PersistedState.self, from: data) else { return }
log.notice("上次异常退出,恢复风扇与睡眠设置")
if state.sleepDisabled {
sleepDisabledByHelper = true
_ = applySleepDisabled(false)
@@ -154,6 +165,7 @@ final class HelperService: NSObject, NSXPCListenerDelegate, OpenStatsHelperProto
idleExit?.cancel()
let work = DispatchWorkItem { [weak self] in
guard let self, self.connections.isEmpty else { return }
log.info("空闲退出")
exit(0)
}
idleExit = work
+1
View File
@@ -25,6 +25,7 @@ let package = Package(
.testTarget(name: "CleanerTests", dependencies: ["Cleaner"]),
.testTarget(name: "HelperSharedTests", dependencies: ["HelperShared"]),
.testTarget(name: "UpdatesTests", dependencies: ["Updates"]),
.testTarget(name: "OpenStatsUITests", dependencies: ["OpenStatsUI", "Metrics"]),
],
swiftLanguageModes: [.v6]
)
@@ -16,6 +16,9 @@ public final class AppController: NSObject, NSApplicationDelegate {
}
public func applicationDidFinishLaunching(_ notification: Notification) {
_ = DiagnosticsExporter.launchDate
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "开发版"
Log.app.notice("OpenStats \(version, privacy: .public) 启动,macOS \(ProcessInfo.processInfo.operatingSystemVersionString, privacy: .public)")
NSApp.mainMenu = MainMenu.make(target: self, settingsAction: #selector(openSettingsFromMenu),
updateAction: #selector(checkForUpdatesFromMenu))
menuBar = MenuBarController(model: model)
@@ -73,7 +73,7 @@ struct GeneralSettings: View {
}
}
GroupRow {
SettingRow(title: "刷新频率", subtitle: "只显示菜单栏时的采样间隔;打开弹窗或主窗口时固定为 1 秒") {
SettingRow(title: "刷新频率", subtitle: "只显示菜单栏时的采样间隔;打开弹窗或主窗口时为 1 秒(进程页 2 秒)") {
SegmentedControl(selection: $settings.refreshSeconds,
options: AppSettings.refreshOptions.map { ($0, "\($0) 秒") })
.frame(width: DS.Size.sidebarWidth + DS.Space.s12)
@@ -89,6 +89,40 @@ struct GeneralSettings: View {
}
}
// MARK: - 诊断
struct DiagnosticsSettings: View {
@Environment(AppModel.self) private var model
var body: some View {
let diagnostics = model.diagnostics
SettingsGroup(caption: "反馈问题") {
GroupRow(showsDivider: false) {
SettingRow(title: "导出诊断信息",
subtitle: "打包版本、系统与辅助工具状态、主要设置、最近 3 天的运行日志和崩溃报告,反馈问题时附上;会去掉序列号、IP 与硬件地址") {
Button(diagnostics.phase == .collecting ? "正在导出…" : "导出…") { diagnostics.export(model: model) }
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(diagnostics.phase == .collecting)
}
}
switch diagnostics.phase {
case .finished(let url):
GroupRow {
InfoBanner(icon: "checkmark.circle.fill", text: "已导出 \(url.lastPathComponent)", tone: .success) {
Button("在访达中显示") { diagnostics.reveal() }
.buttonStyle(DSButtonStyle(kind: .secondary))
}
}
case .failed(let message):
GroupRow { InfoBanner(icon: "exclamationmark.triangle.fill", text: message, tone: .error) }
default:
EmptyView()
}
}
}
}
// MARK: - 菜单栏
struct MenuBarSettings: View {
@@ -612,6 +646,7 @@ struct AboutSettings: View {
}
UpdateSettings()
DiagnosticsSettings()
SettingsGroup(caption: "致谢") {
GroupRow(showsDivider: false) {
@@ -19,6 +19,7 @@ public final class AppModel {
public let geo: GeoDatabaseController
public let explainer = ProcessExplainer()
public let updates: UpdateController
let diagnostics = DiagnosticsExporter()
@ObservationIgnored public let hub = MetricsHub()
public var isMainWindowVisible = false
@@ -0,0 +1,203 @@
import AppKit
import Foundation
import HelperShared
import Metrics
import Observation
import os
/// 运行日志:写入系统统一日志(子系统 com.openstats.app),导出诊断信息时一并收集
enum Log {
static let subsystem = "com.openstats.app"
static let app = Logger(subsystem: subsystem, category: "app")
static let helper = Logger(subsystem: subsystem, category: "helper")
static let fans = Logger(subsystem: subsystem, category: "fans")
static let power = Logger(subsystem: subsystem, category: "power")
static let network = Logger(subsystem: subsystem, category: "network")
static let update = Logger(subsystem: subsystem, category: "update")
}
/// 导出诊断信息:版本与系统、辅助工具状态、主要设置、最近 3 天的运行日志、清理记录与崩溃报告,打成一个 zip。
/// 不包含序列号、IP 地址与硬件地址。
@MainActor
@Observable
final class DiagnosticsExporter {
enum Phase: Equatable {
case idle
case collecting
case finished(URL)
case failed(String)
}
private(set) var phase: Phase = .idle
func export(model: AppModel) {
guard phase != .collecting else { return }
let panel = NSSavePanel()
panel.nameFieldStringValue = "OpenStats-诊断-\(Self.fileDate()).zip"
panel.allowedContentTypes = [.zip]
panel.directoryURL = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first
NSApp.activate()
guard panel.runModal() == .OK, let destination = panel.url else { return }
phase = .collecting
Task {
let summary = await Self.summary(model: model)
do {
try await Task.detached { try Self.writeArchive(summary: summary, to: destination) }.value
Log.app.notice("已导出诊断信息")
phase = .finished(destination)
} catch {
phase = .failed("导出失败:\(error.localizedDescription)")
}
}
}
func reveal() {
if case .finished(let url) = phase { NSWorkspace.shared.activateFileViewerSelecting([url]) }
}
// MARK: 内容
private static func summary(model: AppModel) async -> String {
let bundle = Bundle.main
let settings = model.settings
let store = model.store
let process = ProcessInfo.processInfo
let remoteVersion = await model.helper.remoteProtocolVersion()
var lines: [String] = []
func section(_ title: String) { lines.append(""); lines.append("## \(title)") }
func row(_ key: String, _ value: Any?) { lines.append("\(key): \(value.map { "\($0)" } ?? "—")") }
lines.append("# OpenStats 诊断信息")
row("导出时间", ISO8601DateFormatter().string(from: Date()))
section("应用")
row("版本", bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString"))
row("构建", bundle.object(forInfoDictionaryKey: "CFBundleVersion"))
row("位置", bundle.bundleURL.path.replacingOccurrences(of: NSHomeDirectory(), with: "~"))
row("签名团队", CodeSigningInfo.currentTeamIdentifier() ?? "未签名(开发构建)")
row("已运行", Format.uptime(since: launchDate))
section("系统")
row("机型", "\(store.system.modelName)(\(store.system.modelIdentifier))")
row("macOS", "\(store.system.osVersion)(\(store.system.osBuild))")
row("处理器核心", process.activeProcessorCount)
row("内存", Format.bytes(process.physicalMemory))
row("开机以来", Format.uptime(since: Date().addingTimeInterval(-process.systemUptime)))
row("语言", Locale.preferredLanguages.prefix(3).joined(separator: ", "))
row("温度传感器", store.sensors.map { "\($0.temperatures.count) 组" })
row("风扇", store.sensors.map { "\($0.fans.count) 个" })
row("电池", store.battery.map { "\(Format.percent($0.level)),\($0.isPluggedIn ? "接通电源" : "使用电池"),健康 \($0.health.map(Format.percent) ?? "—")" } ?? "无")
section("辅助工具")
row("状态", model.helper.status.title)
if case .unavailable(let reason) = model.helper.status { row("原因", reason) }
row("协议版本", "应用 \(HelperConstants.protocolVersion) / 辅助工具 \(remoteVersion.map(String.init) ?? "未连接")")
row("最近错误", model.helper.lastError)
section("状态")
row("风扇模式", model.fans.mode.title)
row("防休眠", model.keepAwake.isActive ? model.keepAwake.mode.title : "关闭")
row("合盖运行", model.keepAwake.lidClosedActive ? "开启" : "关闭")
row("登录时启动", model.launchAtLoginEnabled ? "开启" : "关闭")
row("在线升级", "上次检查 \(model.updates.lastChecked.map { ISO8601DateFormatter().string(from: $0) } ?? "从未"),最新 \(model.updates.release?.version ?? "当前版本")")
let geo = model.geo.installed.values.sorted { $0.edition < $1.edition }.map { "\($0.edition) \($0.build)" }
row("归属地数据库", geo.isEmpty ? "未安装" : geo.joined(separator: ","))
section("设置")
row("菜单栏项目", settings.orderedMenuBarItems.map(\.rawValue).joined(separator: ", "))
row("菜单栏布局", settings.menuBarLayout.rawValue)
row("菜单栏风格", settings.menuBarStyle.rawValue)
row("刷新间隔", "\(settings.refreshSeconds) 秒")
row("外观", settings.appearance.rawValue)
row("连接探测", settings.probeEnabled ? "\(settings.probeTarget.rawValue),\(settings.probeSeconds) 秒,后台\(settings.probeInBackground ? "开启" : "关闭")" : "关闭")
row("公网 IP 查询", settings.publicIPLookup ? "开启" : "关闭")
row("风扇安全温度", "\(settings.fanSafetyTemperature)°C")
row("合盖电量下限", "\(settings.lidModeBatteryFloor)%")
return lines.joined(separator: "\n") + "\n"
}
nonisolated static func writeArchive(summary: String, to destination: URL) throws {
let manager = FileManager.default
let work = manager.temporaryDirectory.appendingPathComponent("OpenStats-diagnostics-\(UUID().uuidString)")
let folder = work.appendingPathComponent("OpenStats-诊断-\(fileDate())")
try manager.createDirectory(at: folder, withIntermediateDirectories: true)
defer { try? manager.removeItem(at: work) }
try summary.write(to: folder.appendingPathComponent("summary.txt"), atomically: true, encoding: .utf8)
// 统一日志:应用与辅助工具最近 3 天的记录
let log = run("/usr/bin/log", ["show", "--style", "compact", "--last", "3d", "--info",
"--predicate", "subsystem == \"\(Log.subsystem)\" OR subsystem == \"com.openstats.helper\""])
try redact(log).write(to: folder.appendingPathComponent("openstats.log"), atomically: true, encoding: .utf8)
let home = URL(fileURLWithPath: NSHomeDirectory())
let cleanup = home.appendingPathComponent("Library/Logs/OpenStats/cleanup.log")
if let text = try? String(contentsOf: cleanup, encoding: .utf8) {
let recent = text.split(separator: "\n").suffix(500).joined(separator: "\n")
try redact(recent).write(to: folder.appendingPathComponent("cleanup.log"), atomically: true, encoding: .utf8)
}
// 最近 5 份崩溃 / 卡死报告
let reports = home.appendingPathComponent("Library/Logs/DiagnosticReports")
let crashes = ((try? manager.contentsOfDirectory(at: reports, includingPropertiesForKeys: [.contentModificationDateKey])) ?? [])
.filter { $0.lastPathComponent.hasPrefix("OpenStats") }
.sorted { modified($0) > modified($1) }
.prefix(5)
if !crashes.isEmpty {
let crashFolder = folder.appendingPathComponent("crashes")
try manager.createDirectory(at: crashFolder, withIntermediateDirectories: true)
for crash in crashes {
try? manager.copyItem(at: crash, to: crashFolder.appendingPathComponent(crash.lastPathComponent))
}
}
try? manager.removeItem(at: destination)
let zip = run("/usr/bin/ditto", ["-c", "-k", "--sequesterRsrc", "--keepParent", folder.path, destination.path])
guard manager.fileExists(atPath: destination.path) else {
throw NSError(domain: "OpenStats", code: 1, userInfo: [NSLocalizedDescriptionKey: zip])
}
}
/// 去掉用户目录名、IPv4 / IPv6 地址与硬件地址
nonisolated static func redact(_ text: String) -> String {
var result = text.replacingOccurrences(of: NSHomeDirectory(), with: "~")
// 顺序有关:硬件地址先于 IPv6;IPv6 至少 4 段或含 “::”,避免把 12:30:45 这样的时间当成地址
let patterns: [(String, String)] = [
(#"\b(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}\b"#, "<MAC>"),
(#"\b(?:[0-9a-fA-F]{1,4}:){3,7}[0-9a-fA-F]{1,4}\b"#, "<IPv6>"),
(#"(?:\b[0-9a-fA-F]{1,4})?(?::[0-9a-fA-F]{1,4})*::(?:[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4})*\b)?"#, "<IPv6>"),
(#"\b(?:\d{1,3}\.){3}\d{1,3}\b"#, "<IPv4>"),
]
for (pattern, replacement) in patterns {
result = result.replacingOccurrences(of: pattern, with: replacement, options: .regularExpression)
}
return result
}
nonisolated private static func modified(_ url: URL) -> Date {
(try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
}
nonisolated private static func fileDate() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmm"
return formatter.string(from: Date())
}
nonisolated private static func run(_ executable: String, _ arguments: [String]) -> String {
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do { try process.run() } catch { return error.localizedDescription }
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return String(decoding: data, as: UTF8.self)
}
/// AppController 启动时读取一次,记下应用启动时间
static let launchDate = Date()
}
@@ -104,6 +104,7 @@ public final class FanController {
}
private func show(_ message: String, isError: Bool) {
if isError { Log.fans.error("\(message, privacy: .public)") } else { Log.fans.info("\(message, privacy: .public)") }
notice = message
noticeIsError = isError
}
@@ -91,6 +91,7 @@ public final class GeoDatabaseController {
message = (failure.text, true)
} catch {
message = ("更新失败:\(error.localizedDescription)", true)
Log.network.error("归属地数据库更新失败:\(error.localizedDescription, privacy: .public)")
}
}
@@ -68,6 +68,7 @@ public final class HelperClient {
try service.register()
} catch {
lastError = "安装失败:\(error.localizedDescription)"
Log.helper.error("安装失败:\(error.localizedDescription, privacy: .public)")
}
refreshStatus()
if status == .requiresApproval {
@@ -90,6 +91,7 @@ public final class HelperClient {
}
} catch {
lastError = "卸载失败:\(error.localizedDescription)"
Log.helper.error("卸载失败:\(error.localizedDescription, privacy: .public)")
}
refreshStatus()
}
@@ -98,22 +100,32 @@ public final class HelperClient {
public func reinstall() async {
await uninstall()
install()
await verifyVersion(recheckDelay: .zero)
await verifyVersion(allowRestart: false)
}
/// 核对辅助工具的协议版本。辅助工具空闲 30 秒后自动退出,下次调用时 launchd 会启动应用包里的新版本,
/// 所以第一次发现版本旧时等它退出后再确认一次,避免刚升级完就误报
func verifyVersion(recheckDelay: Duration = .seconds(45)) async {
guard let version = await remoteProtocolVersion(), version < HelperConstants.protocolVersion else {
/// 核对辅助工具的协议版本。版本旧通常是替换应用后旧的辅助工具进程还在运行(新应用一连上它就不会空闲退出):
/// 先断开连接让它空闲 30 秒后退出,再连接时 launchd 会启动应用包里的新版本;仍然旧才提示重新安装
func verifyVersion(allowRestart: Bool = true) async {
let remote = await remoteProtocolVersion()
Log.helper.info("辅助工具状态 \(self.status.title, privacy: .public),协议版本 \(remote.map(String.init) ?? "未连接", privacy: .public)")
guard let version = remote, version < HelperConstants.protocolVersion else {
isOutdated = false
return
}
if recheckDelay > .zero {
try? await Task.sleep(for: recheckDelay)
guard let again = await remoteProtocolVersion() else { return }
isOutdated = again < HelperConstants.protocolVersion
} else {
guard allowRestart else {
isOutdated = true
return
}
connection?.invalidate()
connection = nil
try? await Task.sleep(for: .seconds(40))
guard let again = await remoteProtocolVersion() else { return }
isOutdated = again < HelperConstants.protocolVersion
if isOutdated {
Log.helper.error("辅助工具协议版本 \(again) 低于应用的 \(HelperConstants.protocolVersion)")
} else {
// 新的辅助工具进程没有之前的风扇与睡眠设置,重新下发
onReconnect?()
}
}
@@ -187,6 +199,7 @@ public final class HelperClient {
return await withCheckedContinuation { (continuation: CheckedContinuation<String?, Never>) in
let once = ResumeOnce(continuation)
let proxy = connection.remoteObjectProxyWithErrorHandler { error in
Log.helper.error("XPC 调用失败:\(error.localizedDescription, privacy: .public)")
once.resume("无法连接辅助工具:\(error.localizedDescription)")
} as? OpenStatsHelperProtocol
guard let proxy else {
@@ -183,6 +183,7 @@ public final class KeepAwakeController {
}
private func show(_ message: String, isError: Bool) {
if isError { Log.power.error("\(message, privacy: .public)") } else { Log.power.info("\(message, privacy: .public)") }
notice = message
noticeIsError = isError
}
@@ -36,6 +36,7 @@ public final class MaintenanceController {
: await Self.runWithAdministratorPrompt(shell: command.shellCommand, prompt: prompt)
if let error {
Log.app.error("\(command.rawValue, privacy: .public) 失败:\(error, privacy: .public)")
outcomes[command] = Outcome(text: error, isError: error != Self.cancelled)
return
}
@@ -71,6 +72,7 @@ public final class MaintenanceController {
error = await Self.runWithAdministratorPrompt(shell: shell, prompt: "OpenStats 需要管理员权限来修改“\(service)”的 DNS。")
}
if let error {
Log.network.error("设置 DNS 失败:\(error, privacy: .public)")
dnsOutcome = Outcome(text: error, isError: error != Self.cancelled)
} else {
dnsOutcome = Outcome(text: servers.isEmpty ? "已恢复自动获取 DNS" : "DNS 已设置为 \(servers.joined(separator: "、"))", isError: false)
@@ -87,9 +87,11 @@ public final class UpdateController {
}
release = latest
phase = .available
Log.update.notice("发现新版本 \(latest.version, privacy: .public)")
// 手动检查时总是提示;自动检查时跳过用户选择忽略的版本
if userInitiated || latest.version != skippedVersion { onPrompt() }
case .failure(let error):
Log.update.error("检查更新失败:\(error.localizedDescription, privacy: .public)")
// 自动检查失败不打扰用户,只在关于页里显示
phase = userInitiated ? .failed(error.localizedDescription) : (release == nil ? .idle : .available)
}
@@ -168,11 +170,13 @@ public final class UpdateController {
try await replaceWithAdministratorPrompt(app, with: candidate)
}
skippedVersion = nil
Log.update.notice("已安装 \(release.version, privacy: .public),重新启动")
try? FileManager.default.removeItem(at: work)
try UpdateInstaller.relaunch(app)
terminate()
} catch {
// 取消下载时 URLSession 抛出的是 URLError,按任务是否被取消判断
if !Task.isCancelled { Log.update.error("安装更新失败:\(error.localizedDescription, privacy: .public)") }
phase = Task.isCancelled ? .available
: .failed((error as? LocalizedError)?.errorDescription ?? error.localizedDescription)
}
@@ -0,0 +1,191 @@
import Foundation
import Metrics
import Testing
@testable import OpenStatsUI
private func isolatedDefaults() -> UserDefaults {
let name = "OpenStatsUITests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
defaults.removePersistentDomain(forName: name)
return defaults
}
@MainActor
@Suite struct SettingsTests {
@Test func defaultsAndInvalidValues() {
let defaults = isolatedDefaults()
defaults.set(7, forKey: "refreshSeconds")
defaults.set("bogus", forKey: "panelTab")
let settings = AppSettings(defaults: defaults)
#expect(settings.refreshSeconds == 2)
#expect(settings.panelTab == .overview)
#expect(settings.menuBarItems == [.cpu, .memory, .network])
#expect(settings.probeEnabled && settings.probeInBackground && settings.autoCheckUpdates)
}
@Test func persistsChanges() {
let defaults = isolatedDefaults()
let settings = AppSettings(defaults: defaults)
settings.menuBarItems = [.gpu, .fan]
settings.probeInBackground = false
settings.hiddenPopoverSections = [.cpuHeatmap]
let reloaded = AppSettings(defaults: defaults)
#expect(reloaded.menuBarItems == [.gpu, .fan])
#expect(!reloaded.probeInBackground)
#expect(reloaded.hiddenPopoverSections == [.cpuHeatmap])
#expect(reloaded.orderedMenuBarItems == [.gpu, .fan])
}
}
@Suite struct PanelTabTests {
@Test func sidebarGroupsCoverEveryTabOnce() {
let grouped = PanelTab.monitors + PanelTab.tools + PanelTab.settings
#expect(grouped.count == PanelTab.allCases.count)
#expect(Set(grouped) == Set(PanelTab.allCases))
#expect(PanelTab.settings.allSatisfy { $0.isSettings })
#expect(PanelTab.settingsAbout.headerTitle == "设置 · 关于")
}
@Test func menuBarItemsMapToPages() {
for item in MenuBarItem.allCases {
let tab = PanelTab(item: item)
#expect(tab.menuBarItem == item || (item == .fan && tab == .thermal))
}
}
}
@MainActor
@Suite struct DemandTests {
private func model() -> AppModel {
AppModel(settings: AppSettings(defaults: isolatedDefaults()))
}
@Test func menuBarOnlySamplesAtUserInterval() {
let model = model()
model.settings.refreshSeconds = 5
#expect(model.demand.interval == .seconds(5))
#expect(!model.demand.systemProcesses)
#expect(!model.demand.disk)
}
@Test func processesPageRefreshesEveryTwoSeconds() {
let model = model()
model.isMainWindowVisible = true
model.settings.panelTab = .processes
#expect(model.demand.interval == .seconds(2))
#expect(model.demand.processes && model.demand.systemProcesses)
model.openPopover = .cpu
#expect(model.demand.interval == .seconds(1))
model.openPopover = nil
model.settings.panelTab = .overview
#expect(model.demand.interval == .seconds(1))
#expect(!model.demand.systemProcesses)
}
@Test func networkDetailVisibility() {
let model = model()
#expect(!model.isNetworkDetailVisible)
model.openPopover = .network
#expect(model.isNetworkDetailVisible)
model.openPopover = nil
model.isMainWindowVisible = true
model.settings.panelTab = .network
#expect(model.isNetworkDetailVisible)
}
}
@MainActor
@Suite struct UpdateControllerTests {
@Test func automaticCheckRespectsSettingAndInterval() {
let defaults = isolatedDefaults()
let settings = AppSettings(defaults: defaults)
settings.autoCheckUpdates = false
let controller = UpdateController(settings: settings, defaults: defaults)
controller.checkIfNeeded()
#expect(controller.phase == .idle)
settings.autoCheckUpdates = true
defaults.set(Date(), forKey: "updateLastChecked")
let recent = UpdateController(settings: settings, defaults: defaults)
recent.checkIfNeeded()
#expect(recent.phase == .idle)
}
@Test func developmentBuildCannotInstall() {
// 测试进程不是签名的 OpenStats.app
let controller = UpdateController(settings: AppSettings(defaults: isolatedDefaults()))
#expect(controller.installBlockedReason != nil)
}
}
@Suite struct DiagnosticsTests {
@Test func redactsAddressesAndHome() {
let text = "\(NSHomeDirectory())/Library/x 192.168.1.20 fe80::1c2b:3aff:fe4d:5e6f 2001:db8:0:0:1:2:3:4 ::1 a4:83:e7:12:34:56 12:30:45 版本 0.2.0"
let redacted = DiagnosticsExporter.redact(text)
#expect(redacted.hasPrefix("~/Library/x"))
#expect(!redacted.contains("192.168.1.20"))
#expect(!redacted.contains("a4:83:e7"))
#expect(!redacted.contains("fe80::1c2b"))
#expect(!redacted.contains("2001:db8"))
#expect(!redacted.contains("::1"))
#expect(redacted.contains("<MAC>"))
#expect(redacted.contains("12:30:45"))
#expect(redacted.contains("0.2.0"))
}
}
@MainActor
@Suite struct ProcessRowTests {
private func process(_ pid: Int32, _ name: String, bundle: String?, cpu: Double, memory: UInt64, owned: Bool = true) -> ProcessUsage {
var usage = ProcessUsage(pid: pid, name: name, executablePath: nil, appBundlePath: bundle, cpu: cpu, memory: memory)
usage.isOwned = owned
usage.threads = 4
return usage
}
@Test func groupsHelpersIntoTheirApp() {
let rows = ProcessRowModel.grouped([
process(10, "Safari", bundle: "/Applications/Safari.app", cpu: 10, memory: 100),
process(11, "Safari Web Content", bundle: "/Applications/Safari.app", cpu: 5, memory: 300),
process(12, "cloudd", bundle: nil, cpu: 1, memory: 50),
])
#expect(rows.count == 2)
let safari = rows.first { $0.count == 2 }
#expect(safari?.cpu == 15)
#expect(safari?.memory == 400)
#expect(safari?.threads == 8)
#expect(safari?.pid == nil)
}
@Test func blocksQuittingProtectedProcesses() {
#expect(ProcessRowModel(process: process(1, "launchd", bundle: nil, cpu: 0, memory: 1, owned: false)).quitBlockedReason != nil)
#expect(ProcessRowModel(process: process(getpid(), "OpenStats", bundle: nil, cpu: 0, memory: 1)).quitBlockedReason != nil)
#expect(ProcessRowModel(process: process(99, "loginwindow", bundle: nil, cpu: 0, memory: 1)).quitBlockedReason != nil)
#expect(ProcessRowModel(process: process(98, "Notes", bundle: nil, cpu: 0, memory: 1)).quitBlockedReason == nil)
}
@Test func narrowTablesDropColumns() {
#expect(ProcessColumn.visible(forWidth: 2000) == ProcessColumn.allCases)
#expect(ProcessColumn.visible(forWidth: 300) == [.name, .cpu, .memory, .user])
}
}
@Suite struct DiagnosticsArchiveTests {
@Test func writesZipWithSummaryAndLog() throws {
let destination = FileManager.default.temporaryDirectory.appendingPathComponent("diag-\(UUID().uuidString).zip")
defer { try? FileManager.default.removeItem(at: destination) }
try DiagnosticsExporter.writeArchive(summary: "# 测试\n", to: destination)
let listing = Process()
listing.executableURL = URL(fileURLWithPath: "/usr/bin/zipinfo")
listing.arguments = ["-1", destination.path]
let pipe = Pipe()
listing.standardOutput = pipe
try listing.run()
let names = String(decoding: pipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
listing.waitUntilExit()
#expect(names.contains("summary.txt"))
#expect(names.contains("openstats.log"))
}
}