mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-29 05:33:45 +00:00
feat(servers): add form-based new-server creation with toolbar entry
Servers view toolbar gains an icon-only new-server button opening a sheet that mirrors the edit form (port 8388 / aes-256-gcm defaults, managed-plugin section). New CatalogWorkflow.createServer command keeps validation symmetric with edit commit: form validation fails bare before the credential journal; password and plugin options commit with the catalog as one logical change and roll back on persistence failure. Placement reuses importTargetParent semantics; success dismisses and selects the new server. Fresh UUID manual source, no content dedup, activation target untouched.
This commit is contained in:
@@ -27,7 +27,48 @@ extension CatalogWorkflow {
|
||||
).encode()
|
||||
}
|
||||
|
||||
// MARK: - URI → 服务器叶子
|
||||
// MARK: - URI 批量导入与叶子字段
|
||||
|
||||
/// ss:// 批量导入(URL 表单和二维码识别入口的共同落点,story 20/21)。
|
||||
/// 逐行解码,可解析行全部添加(每次新建身份,不按内容去重);每条失败行以
|
||||
/// 行号 + 类型化原因点名,已成功记录不被局部失败回滚。
|
||||
func createServers(fromURIs text: String, into parent: NodeID?) async throws
|
||||
-> BatchImportOutcome
|
||||
{
|
||||
var prepared: [(uri: SsUri, fields: ServerFields)] = []
|
||||
var failures: [ImportLineFailure] = []
|
||||
for (index, line) in text.split(whereSeparator: \.isNewline).enumerated() {
|
||||
do {
|
||||
let uri = try SsUri.decode(String(line))
|
||||
let fields = try Self.serverFields(from: uri, credentials: dependencies.credentials)
|
||||
prepared.append((uri: uri, fields: fields))
|
||||
} catch {
|
||||
let reason: ImportLineFailureReason
|
||||
if let uriError = error as? SsUriError {
|
||||
reason = .decode(uriError)
|
||||
} else if let credentialError = error as? CredentialStoreError {
|
||||
reason = .credential(credentialError)
|
||||
} else {
|
||||
reason = .decode(.malformed(detail: String(describing: error)))
|
||||
}
|
||||
failures.append(ImportLineFailure(lineIndex: index, reason: reason))
|
||||
}
|
||||
}
|
||||
guard !prepared.isEmpty else { return BatchImportOutcome(addedCount: 0, failures: failures) }
|
||||
do {
|
||||
try commit { catalog in
|
||||
for item in prepared {
|
||||
try catalog.addServer(item.fields, to: parent)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
for item in prepared {
|
||||
Self.deleteCredentialRefs(for: item.fields, credentials: dependencies.credentials)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return BatchImportOutcome(addedCount: prepared.count, failures: failures)
|
||||
}
|
||||
|
||||
/// URI → 服务器叶子字段:密码与插件参数入凭据存储、目录只持引用。
|
||||
static func serverFields(
|
||||
|
||||
@@ -130,45 +130,44 @@ final class CatalogWorkflow: ObservableObject {
|
||||
|
||||
// MARK: - 手动服务器/分组命令
|
||||
|
||||
/// ss:// 批量导入(URL 表单和二维码识别入口的共同落点,story 20/21)。
|
||||
/// 逐行解码,可解析行全部添加(每次新建身份,不按内容去重);每条失败行以
|
||||
/// 行号 + 类型化原因点名,已成功记录不被局部失败回滚。
|
||||
func createServers(fromURIs text: String, into parent: NodeID?) async throws
|
||||
-> BatchImportOutcome
|
||||
{
|
||||
var prepared: [(uri: SsUri, fields: ServerFields)] = []
|
||||
var failures: [ImportLineFailure] = []
|
||||
for (index, line) in text.split(whereSeparator: \.isNewline).enumerated() {
|
||||
do {
|
||||
let uri = try SsUri.decode(String(line))
|
||||
let fields = try Self.serverFields(from: uri, credentials: dependencies.credentials)
|
||||
prepared.append((uri: uri, fields: fields))
|
||||
} catch {
|
||||
let reason: ImportLineFailureReason
|
||||
if let uriError = error as? SsUriError {
|
||||
reason = .decode(uriError)
|
||||
} else if let credentialError = error as? CredentialStoreError {
|
||||
reason = .credential(credentialError)
|
||||
} else {
|
||||
reason = .decode(.malformed(detail: String(describing: error)))
|
||||
}
|
||||
failures.append(ImportLineFailure(lineIndex: index, reason: reason))
|
||||
}
|
||||
/// 表单新建手动服务器:校验与提交语义与编辑对称——表单校验在建 journal
|
||||
/// 前失败保持裸 `ServerFormError`;密码与可选插件参数和目录作为一个逻辑
|
||||
/// 变更提交,持久化失败经 journal 回滚并以 `CommitError` 报出。新身份恒
|
||||
/// 全新 UUID,不按内容去重(CONTEXT.md 不变量);返回新节点身份供选中。
|
||||
@discardableResult
|
||||
func createServer(_ draft: ServerEditDraft, into parent: NodeID?) async throws -> NodeID {
|
||||
let trimmedAddress = draft.address.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmedAddress.isEmpty else { throw ServerFormError.invalidAddress }
|
||||
guard (1...65_535).contains(draft.port) else { throw ServerFormError.invalidPort }
|
||||
let trimmedMethod = draft.encryptionMethod.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedMethod.isEmpty else { throw ServerFormError.missingEncryptionMethod }
|
||||
guard EncryptionMethodCatalog.isSupported(trimmedMethod) else {
|
||||
throw ServerFormError.unsupportedEncryptionMethod(trimmedMethod)
|
||||
}
|
||||
guard !prepared.isEmpty else { return BatchImportOutcome(addedCount: 0, failures: failures) }
|
||||
guard !draft.password.isEmpty else { throw ServerFormError.invalidPassword }
|
||||
// 插件表单校验与其它字段同处写入之前:表单拒绝不触碰凭据、不进提交管线。
|
||||
if case .managed(let program) = draft.plugin,
|
||||
ManagedPluginCatalog.info(forProgram: program) == nil
|
||||
{
|
||||
throw ServerFormError.pluginNotManaged(program)
|
||||
}
|
||||
var journal = CredentialWriteJournal(credentials: dependencies.credentials)
|
||||
do {
|
||||
try commit { catalog in
|
||||
for item in prepared {
|
||||
try catalog.addServer(item.fields, to: parent)
|
||||
}
|
||||
return try commit { [self] catalog in
|
||||
let passwordRef = CredentialReference.fresh()
|
||||
try journal.save(draft.password, for: passwordRef)
|
||||
var fields = ServerFields(
|
||||
address: trimmedAddress, port: draft.port, encryptionMethod: trimmedMethod,
|
||||
passwordRef: passwordRef,
|
||||
remark: draft.remark.trimmingCharacters(in: .whitespaces))
|
||||
try Self.applyPluginSelection(
|
||||
draft.plugin, options: draft.pluginOptions, to: &fields,
|
||||
credentials: dependencies.credentials, journal: &journal)
|
||||
return try catalog.addServer(fields, to: parent)
|
||||
}
|
||||
} catch {
|
||||
for item in prepared {
|
||||
Self.deleteCredentialRefs(for: item.fields, credentials: dependencies.credentials)
|
||||
}
|
||||
throw error
|
||||
throw CommitError(underlying: error, credentialRollback: journal.rollback())
|
||||
}
|
||||
return BatchImportOutcome(addedCount: prepared.count, failures: failures)
|
||||
}
|
||||
|
||||
/// 新建空手动分组(story 5);返回新分组身份。
|
||||
@@ -253,8 +252,9 @@ final class CatalogWorkflow: ObservableObject {
|
||||
|
||||
// MARK: - 提交管线(module 内部;UI 不得调用,独立 target 拆分前靠 deletion check)
|
||||
|
||||
/// 仅目录变更的提交便捷入口(订阅扩展经 `commitSubscriptionDocument`)。
|
||||
private func commit<T>(
|
||||
/// 仅目录变更的提交便捷入口(module 内部扩展共用,同 `commitSubscriptionDocument`
|
||||
/// 的窄缝口径)。
|
||||
func commit<T>(
|
||||
_ mutate: (inout ConfigurationCatalog) throws -> T
|
||||
) throws -> T {
|
||||
try commitDocument { catalog, _ in
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 「新建服务器」表单(服务器视图工具栏入口):字段、校验与插件区镜像编辑
|
||||
/// 表单,默认值端口 8388、加密 aes-256-gcm、插件「无」。落点由父视图按既有
|
||||
/// `importTargetParent` 语义在打开时固定;提交成功关闭表单并把新身份交给
|
||||
/// selection(由父视图选中);校验/提交失败留在表单,错误经共享呈现器点名。
|
||||
struct NewServerSheet: View {
|
||||
let workflow: CatalogWorkflow
|
||||
let errors: ErrorAlertPresenter
|
||||
let parent: NodeID?
|
||||
@Binding var selection: NodeID?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var address = ""
|
||||
@State private var port = 8388
|
||||
@State private var encryptionMethod = "aes-256-gcm"
|
||||
@State private var password = ""
|
||||
@State private var remark = ""
|
||||
@State private var pluginChoice: PluginSelection = .none
|
||||
@State private var pluginOptionsText = ""
|
||||
@State private var showPassword = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("新建服务器")
|
||||
.font(.title2)
|
||||
Text("保存后加入目录;密码存入钥匙串,目录只持引用。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 18) {
|
||||
GridRow {
|
||||
column("服务器地址") {
|
||||
TextField("服务器地址", text: $address)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
column("端口") {
|
||||
TextField("端口", value: $port, format: .number.grouping(.never))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
GridRow {
|
||||
column("加密方式") {
|
||||
Picker("加密方式", selection: $encryptionMethod) {
|
||||
ForEach(EncryptionMethodCatalog.supported.sorted(), id: \.self) {
|
||||
Text($0).tag($0)
|
||||
}
|
||||
}
|
||||
}
|
||||
column("备注") {
|
||||
TextField("备注", text: $remark)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
GridRow {
|
||||
column("密码") {
|
||||
HStack(spacing: 8) {
|
||||
Group {
|
||||
if showPassword {
|
||||
TextField("密码", text: $password)
|
||||
} else {
|
||||
SecureField("密码", text: $password)
|
||||
}
|
||||
}
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button {
|
||||
showPassword.toggle()
|
||||
} label: {
|
||||
Image(systemName: showPassword ? "eye.slash" : "eye")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help(showPassword ? "隐藏密码" : "显示密码")
|
||||
}
|
||||
}
|
||||
.gridCellColumns(2)
|
||||
}
|
||||
GridRow {
|
||||
column("受管理插件与参数") {
|
||||
ServerPluginSection(
|
||||
selection: $pluginChoice,
|
||||
optionsText: $pluginOptionsText,
|
||||
plugin: pluginSection,
|
||||
isEditable: true)
|
||||
}
|
||||
.gridCellColumns(2)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("取消", role: .cancel) { dismiss() }
|
||||
Button("创建") { create() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(minWidth: 520, minHeight: 400)
|
||||
}
|
||||
|
||||
/// 插件区状态:无既有引用,参数按未配置呈现。新表单只能产生「无」或受管
|
||||
/// 选择;`provided` 恒为真——新建尚无引用可点名,可执行文件缺失由激活
|
||||
/// 语义拒绝并在编辑面呈现。
|
||||
private var pluginSection: PluginSectionState {
|
||||
PluginSectionState(
|
||||
selection: pluginChoice,
|
||||
managed: ManagedPluginCatalog.plugins,
|
||||
provided: true,
|
||||
optionsPresent: false,
|
||||
options: "")
|
||||
}
|
||||
|
||||
private func create() {
|
||||
Task {
|
||||
do {
|
||||
let id = try await workflow.createServer(
|
||||
ServerEditDraft(
|
||||
address: address,
|
||||
port: port,
|
||||
encryptionMethod: encryptionMethod,
|
||||
password: password,
|
||||
remark: remark,
|
||||
plugin: pluginChoice,
|
||||
pluginOptions: pluginOptionsText),
|
||||
into: parent)
|
||||
selection = id
|
||||
dismiss()
|
||||
} catch {
|
||||
errors.present(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func column<Content: View>(
|
||||
_ label: String, @ViewBuilder content: () -> Content
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(label)
|
||||
.font(.callout.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
content()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ struct ServersView: View {
|
||||
@State private var newGroupParent: NodeID?
|
||||
@State private var newGroupName = ""
|
||||
@State private var isPresentingNewGroup = false
|
||||
@State private var isPresentingNewServer = false
|
||||
@State private var deleteTarget: NodeID?
|
||||
@State private var moveTarget: NodeID?
|
||||
@State private var rootDropHovering = false
|
||||
@@ -84,6 +85,12 @@ struct ServersView: View {
|
||||
) { context in
|
||||
MoveNodeSheet(workflow: workflow, errors: errors, nodeID: context.nodeID)
|
||||
}
|
||||
.sheet(isPresented: $isPresentingNewServer) {
|
||||
NewServerSheet(
|
||||
workflow: workflow, errors: errors,
|
||||
parent: workflow.importTargetParent(for: selection),
|
||||
selection: $selection)
|
||||
}
|
||||
}
|
||||
|
||||
private var serverSidebar: some View {
|
||||
@@ -121,7 +128,7 @@ struct ServersView: View {
|
||||
if workflow.tree.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"暂无服务器", systemImage: "server.rack",
|
||||
description: Text("用工具栏的「添加」菜单导入 ss:// 链接,或用「新建分组」按钮创建分组")
|
||||
description: Text("用工具栏的「新建服务器」按钮手动录入,或用「添加」菜单导入 ss:// 链接、创建分组")
|
||||
)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
@@ -181,10 +188,19 @@ extension ServersView {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 工具栏(新建分组)
|
||||
// MARK: - 工具栏(新建服务器 / 新建分组)
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbarContent: some ToolbarContent {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
isPresentingNewServer = true
|
||||
} label: {
|
||||
Label("新建服务器", systemImage: "plus")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.help("新建服务器")
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
presentNewGroup(in: workflow.importTargetParent(for: selection))
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import XCTest
|
||||
|
||||
@testable import ShadowsocksX_NG2
|
||||
|
||||
/// 表单新建手动服务器的点名校验用例(标签 + 草稿 + 预期 typed error)。
|
||||
private struct InvalidCreateDraftCase {
|
||||
let label: String
|
||||
let draft: ServerEditDraft
|
||||
let expected: ServerFormError
|
||||
|
||||
init(_ label: String, _ draft: ServerEditDraft, _ expected: ServerFormError) {
|
||||
self.label = label
|
||||
self.draft = draft
|
||||
self.expected = expected
|
||||
}
|
||||
}
|
||||
|
||||
/// 表单新建手动服务器命令(`createServer`)的 UI-facing interface 测试:
|
||||
/// 落点与身份、凭据入存储、点名校验拒绝与插件选择落盘,语义与编辑提交对称。
|
||||
@MainActor
|
||||
final class CatalogWorkflowCreateServerTests: XCTestCase {
|
||||
private var workDir: URL!
|
||||
private var fileURL: URL!
|
||||
private var credentials: InMemoryCredentialStore!
|
||||
private var runtime: FakeCatalogRuntime!
|
||||
var workflow: CatalogWorkflow!
|
||||
|
||||
override func setUp() async throws {
|
||||
try await super.setUp()
|
||||
// 专用工作目录:写入器会把父目录强制 0700(不得指向临时根)。
|
||||
workDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("catalog-create-server-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true)
|
||||
fileURL = workDir.appendingPathComponent("catalog.json")
|
||||
credentials = InMemoryCredentialStore()
|
||||
runtime = FakeCatalogRuntime()
|
||||
runtime.hasActiveTarget = true
|
||||
workflow = makeWorkflow()
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
try? FileManager.default.removeItem(at: workDir)
|
||||
try await super.tearDown()
|
||||
}
|
||||
|
||||
private func makeWorkflow() -> CatalogWorkflow {
|
||||
let coordinator = CatalogCommitCoordinator(
|
||||
fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime)
|
||||
return makeCatalogWorkflow(
|
||||
coordinator: coordinator,
|
||||
credentials: credentials,
|
||||
plugins: NoManagedPluginProvider())
|
||||
}
|
||||
|
||||
func testCreateServerAddsManualLeafWithCredentialAndReturnsIdentity() async throws {
|
||||
let groupID = try await workflow.createGroup(named: "手动组", into: nil)
|
||||
let id = try await workflow.createServer(
|
||||
ServerEditDraft(
|
||||
address: "203.0.113.10", port: 8389, encryptionMethod: "aes-256-gcm",
|
||||
password: "表单密码", remark: "新来的", plugin: .none, pluginOptions: nil),
|
||||
into: groupID)
|
||||
let node = try XCTUnwrap(workflow.tree.node(withID: id))
|
||||
XCTAssertFalse(node.isGroup)
|
||||
XCTAssertTrue(node.isManual)
|
||||
XCTAssertEqual(node.parentID, groupID)
|
||||
let form = try XCTUnwrap(workflow.serverEditForm(for: id))
|
||||
XCTAssertEqual(form.address, "203.0.113.10")
|
||||
XCTAssertEqual(form.port, 8389)
|
||||
XCTAssertEqual(form.remark, "新来的")
|
||||
XCTAssertEqual(form.encryptionMethod, "aes-256-gcm")
|
||||
XCTAssertEqual(form.password, "表单密码", "密码作为凭据写入,目录持引用")
|
||||
XCTAssertTrue(node.invalidReasons.isEmpty)
|
||||
}
|
||||
|
||||
func testCreateServerRejectsInvalidDraftsLeavingCatalogAndCredentialsUntouched() async throws {
|
||||
let groupID = try await workflow.createGroup(named: "组", into: nil)
|
||||
for caseItem in Self.invalidDraftCases {
|
||||
await expectThrowsAsync(
|
||||
{ _ = try await workflow.createServer(caseItem.draft, into: groupID) },
|
||||
onThrow: { error in
|
||||
XCTAssertEqual(error as? ServerFormError, caseItem.expected, caseItem.label)
|
||||
})
|
||||
}
|
||||
|
||||
XCTAssertEqual(workflow.tree.node(withID: groupID)?.childCount, 0, "校验失败不进目录")
|
||||
XCTAssertTrue(credentials.storageSnapshot.isEmpty, "表单校验在建 journal 前失败,不触碰凭据")
|
||||
}
|
||||
|
||||
func testCreateServerPluginSelectionStoresProgramAndOptions() async throws {
|
||||
let id = try await workflow.createServer(
|
||||
ServerEditDraft(
|
||||
address: "203.0.113.10", port: 8388, encryptionMethod: "aes-256-gcm",
|
||||
password: "密码", remark: "", plugin: .managed(program: "v2ray-plugin"),
|
||||
pluginOptions: "mode=websocket;host=example.com"),
|
||||
into: nil)
|
||||
let plugin = try XCTUnwrap(workflow.serverEditForm(for: id)?.plugin)
|
||||
XCTAssertEqual(plugin.selection, .managed(program: "v2ray-plugin"))
|
||||
XCTAssertTrue(plugin.optionsPresent)
|
||||
XCTAssertEqual(plugin.options, "mode=websocket;host=example.com")
|
||||
}
|
||||
|
||||
/// 表单校验用例表:与 `updateServer` 同规则的点名拒绝(含受管集外插件)。
|
||||
private static var invalidDraftCases: [InvalidCreateDraftCase] {
|
||||
func draft(
|
||||
address: String = "203.0.113.10", port: Int = 8388,
|
||||
encryptionMethod: String = "aes-256-gcm", password: String = "密码",
|
||||
plugin: PluginSelection = .none
|
||||
) -> ServerEditDraft {
|
||||
ServerEditDraft(
|
||||
address: address, port: port, encryptionMethod: encryptionMethod, password: password,
|
||||
remark: "", plugin: plugin, pluginOptions: nil)
|
||||
}
|
||||
return [
|
||||
InvalidCreateDraftCase("空地址", draft(address: " "), .invalidAddress),
|
||||
InvalidCreateDraftCase("端口下界", draft(port: 0), .invalidPort),
|
||||
InvalidCreateDraftCase("端口上界", draft(port: 65_536), .invalidPort),
|
||||
InvalidCreateDraftCase("缺加密方式", draft(encryptionMethod: " "), .missingEncryptionMethod),
|
||||
InvalidCreateDraftCase(
|
||||
"不支持加密方式", draft(encryptionMethod: "future-cipher"),
|
||||
.unsupportedEncryptionMethod("future-cipher")),
|
||||
InvalidCreateDraftCase("空密码", draft(password: ""), .invalidPassword),
|
||||
InvalidCreateDraftCase(
|
||||
"受管集外插件", draft(plugin: .managed(program: "future-plugin")),
|
||||
.pluginNotManaged("future-plugin")),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,35 @@ final class CatalogWorkflowRollbackTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - story 13:表单新建持久化失败 → 新写凭据回滚为不存在
|
||||
|
||||
func testCreateServerRollsBackFreshCredentialWhenPersistenceFails() async throws {
|
||||
let workflow = makeWorkflow()
|
||||
// 先落一份空目录建立 gate 存储(写入器才会创建父目录),再破坏持久化。
|
||||
try CatalogFileStore(fileURL: fileURL).save(CatalogDocument(catalog: ConfigurationCatalog()))
|
||||
|
||||
try breakPersistence()
|
||||
|
||||
await expectThrowsAsync {
|
||||
try await workflow.createServer(
|
||||
ServerEditDraft(
|
||||
address: "198.51.100.9", port: 8388, encryptionMethod: "aes-256-gcm",
|
||||
password: "新密码", remark: "", plugin: .none, pluginOptions: nil),
|
||||
into: nil)
|
||||
} onThrow: { error in
|
||||
guard let commitError = error as? CommitError else {
|
||||
XCTFail("预期 CommitError,收到 \(error)")
|
||||
return
|
||||
}
|
||||
guard case .restored(let refs) = commitError.credentialRollback, refs.count == 1 else {
|
||||
return XCTFail("预期恰有一个全新引用回滚为删除,实际 \(commitError.credentialRollback)")
|
||||
}
|
||||
}
|
||||
|
||||
XCTAssertTrue(workflow.tree.isEmpty, "目录提交失败则已发布状态不动")
|
||||
XCTAssertTrue(credentials.storageSnapshot.isEmpty, "新写秘密已删除,不留孤儿")
|
||||
}
|
||||
|
||||
// MARK: - story 38:提交完成与运行时收敛是分离的观察面
|
||||
|
||||
func testCommitCompletesWhileRuntimeConvergenceIsStillSyncing() async throws {
|
||||
|
||||
Reference in New Issue
Block a user