Files
ShadowsocksX-NG/ShadowsocksX-NG2/Tests/Servers/PasswordAutofillOptOutTests.swift
Qiu Yuzhou 20a62117f2 refactor(tests): 按功能领域重组 Tests/ 目录
Tests/ 根层原有 138 个平铺测试文件,无法从目录读出测试覆盖的功能领域。

按功能领域重组为 17 个目录(Catalog/Workflow 与 Rules/Custom 两处嵌套,分别
对应 App/Application/CatalogWorkflow 与 Domain/Rules/Custom):Architecture /
Support / Agent / Activation / Catalog / Subscription / Rules / ProxyRuntime /
ProxyControl / Runtime / Settings / SystemProxy / Plugin / Diagnostics /
Credentials / Workspace / Servers;Smoke/ 保持独立 target 不动。138 个移动中
132 个是 100% 相似度的纯重命名,其余 6 个在重命名之上带路径反推改写(见下)。

本次不是纯搬移:测试代码比生产代码多一层位置耦合,有 7 处用固定层数的
deletingLastPathComponent() 从 #filePath 反推仓库路径(都假定文件就在 Tests/
根层)。这 7 处改走新增的 Tests/Support/TestSourceTree.swift——从调用点沿父
目录上溯找到含 project.yml 的目录,因此不再依赖父目录层数,以后移动测试文件
也不会静默指错。

配套改动:

- 新增 Tests 布局守卫 TestsLayoutTests,断言 Tests/ 根层无平铺 .swift。比
  Domain 那条弱一档——根层的 Info.plist(info.path 生成物)与
  Fixtures/RuleSnapshots 下的 JSON(folder resource)都必须保留,不能用
  「树下只有 .swift」覆盖。
- project.yml:Smoke target 按文件引用的 Tests/AppArtifact.swift 改指
  Tests/Support/;两处注释里写死的 Tests/SkeletonTests.swift 随移动更新。
  excludes 的裸名 "Smoke" / "Fixtures" 仍匹配 Tests/ 直属子目录,无需改动。
- README 的 Tests 条目展开为目录说明,并修掉 Domain 条目里写死的
  Tests/SkeletonTests.swift。
- 顺带删去 DomainLayoutTests 文档注释里「整理前 62 个文件」这类易漂移的数字。

验证:xcodegen generate 后单测 target 为 139 个源文件(全部非 Smoke 文件,0 个
Smoke 文件混入)且仍以 Fixtures/RuleSnapshots 为资源,Smoke target 为 8 个
Smoke 文件加 AppArtifact;依赖路径反推的 6 个测试类全绿(路径指错即失败);
task format 未改写任何被移动文件;task lint 与整理前同为 21 条内容型 warning;
build 0 error;task test 1025 用例全通过(整理前 1024,+1 为新守卫);
TestsLayoutTests 经变异验证(根层投放探针即失败);packaging-gate ALL CHECKS
PASSED。
2026-10-05 20:37:35 +08:00

113 lines
4.7 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import SwiftUI
import Testing
@testable import ShadowsocksX_NG2
/// 系统对安全输入框默认提供「密码自动填充」,`textContentType(nil)` 只清字段
/// 语义、关不掉它;生产代码改字段级私有开关 `_setPasswordAutofillEnabled:`
/// (`PasswordAutofillOptOut` 探针认领同帧文本框后经 KVC 写入)。
///
/// 这些用例固定认领范围:只关密码列自己的文本框,同一表单的其他输入框保持
/// 系统默认。私有开关哪天消失,用例失败即提示重做这个缝,而不是静默回退。
@MainActor
struct PasswordAutofillOptOutTests {
@Test
func securePasswordFieldOptsOutWithoutTouchingNeighbours() async throws {
let fields = ServerFormFields.newForm()
let hosted = await Self.hostGrid(fields)
let textFields = Self.textFields(in: hosted.host)
let secure = textFields.filter { $0 is NSSecureTextField }
#expect(secure.count == 1, "密码列是全表单唯一的安全输入框")
#expect(Self.disabledAutofillFields(in: hosted.host).count == 1, "只允许关掉密码列")
#expect(Self.passwordAutofillEnabled(try #require(secure.first)) == false)
#expect(
textFields.filter { !($0 is NSSecureTextField) }
.allSatisfy { Self.passwordAutofillEnabled($0) == true },
"名称/服务器地址/端口等字段保持系统默认")
}
@Test
func visiblePasswordBranchOptsOutToo() async throws {
let fields = ServerFormFields.newForm()
fields.showPassword = true
let hosted = await Self.hostGrid(fields)
let textFields = Self.textFields(in: hosted.host)
#expect(textFields.allSatisfy { !($0 is NSSecureTextField) }, "明文分支不渲染安全输入框")
#expect(Self.disabledAutofillFields(in: hosted.host).count == 1, "只允许关掉密码列")
}
@Test
func togglingPasswordVisibilityKeepsAutofillOff() async throws {
let fields = ServerFormFields.newForm()
let hosted = await Self.hostGrid(fields)
#expect(Self.disabledAutofillFields(in: hosted.host).count == 1)
// 明文/密文切换会换掉承载的文本框,认领必须跟着新实例重做。
fields.showPassword = true
await Self.settle(hosted.host)
#expect(Self.textFields(in: hosted.host).allSatisfy { !($0 is NSSecureTextField) })
#expect(Self.disabledAutofillFields(in: hosted.host).count == 1)
fields.showPassword = false
await Self.settle(hosted.host)
#expect(Self.textFields(in: hosted.host).filter { $0 is NSSecureTextField }.count == 1)
#expect(Self.disabledAutofillFields(in: hosted.host).count == 1)
}
// MARK: 夹具
/// 复用生产表单:探针、栅格与字段实现都在真实布局里跑一遍。
private struct GridHarness: View {
@ObservedObject var fields: ServerFormFields
@FocusState private var fieldFocus: ServerFormField?
var body: some View {
ServerFormFieldsGrid(
fields: fields, plugin: nil, isEditable: true, fieldFocus: $fieldFocus)
}
}
/// 探针在窗口坐标里认领文本框:无窗口的 hosting view 不构成呈现,认领会落空,
/// 因此夹具必须给一个离屏窗口(不呈现、不抢 key)。
private static func hostGrid(
_ fields: ServerFormFields
) async -> (window: NSWindow, host: NSHostingView<GridHarness>) {
let host = NSHostingView(rootView: GridHarness(fields: fields))
host.frame = NSRect(x: 0, y: 0, width: 560, height: 720)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 560, height: 720),
styleMask: [.titled], backing: .buffered, defer: false)
window.contentView = host
await settle(host)
return (window, host)
}
/// 让 SwiftUI 的渲染事务与探针的布局回调都跑完再断言。
private static func settle(_ host: NSHostingView<GridHarness>) async {
for _ in 0..<2 {
await Task { @MainActor in }.value
host.layoutSubtreeIfNeeded()
}
}
private static func textFields(in view: NSView) -> [NSTextField] {
if let field = view as? NSTextField { return [field] + view.subviews.flatMap(textFields) }
return view.subviews.flatMap(textFields)
}
private static func disabledAutofillFields(in view: NSView) -> [NSTextField] {
textFields(in: view).filter { passwordAutofillEnabled($0) == false }
}
/// BOOL 返回值不能走 `perform`(会被当成对象返回值),只能取 IMP 直调。
private static func passwordAutofillEnabled(_ field: NSTextField) -> Bool? {
let selector = NSSelectorFromString("_isPasswordAutofillEnabled")
guard let method = class_getInstanceMethod(type(of: field), selector) else { return nil }
typealias Getter = @convention(c) (AnyObject, Selector) -> Bool
return unsafeBitCast(method_getImplementation(method), to: Getter.self)(field, selector)
}
}