import CryptoKit import Foundation import RastrilloApple import RastrilloTestSupport import XCTest @testable import DocsKit final class OfflineKillTests: XCTestCase { @MainActor func testTypingProcessAndRecovery() async throws { let env = ProcessInfo.processInfo.environment guard let directory = env["DOCS_KILL_DIR"], let mode = env["DOCS_KILL_MODE"] else { throw XCTSkip("Run native/scripts/test-offline-kill.py") } let randomKill = env["DOCS_KILL_RANDOM"] == "1" let root = URL(fileURLWithPath: directory) let config = try JSONDecoder().decode([String: String].self, from: Data(contentsOf: root.appendingPathComponent("connection.json"))) let client = NativeHTTP(origin: URL(string: config["origin"]!)!, credential: NativeSession(name: config["name"]!, value: config["value"]!)) let path = ScriptedPathSource(mode == "write" ? .satisfied : .unsatisfied) let connectivity = NativeConnectivity(source: path) let keys = ScriptedKeyProvider(key: SymmetricKey(data: Data(repeating: 73, count: 32))) let store = NativeSealedStore(root: root.appendingPathComponent("account"), accountID: "kill-test", keys: keys) let account = DocsOfflineAccount(store: store, accountID: "kill-test", connectivity: connectivity, transport: { client }) let model = DocsModel(transport: { client }, connectivity: connectivity, offlineAccount: account) await model.open(config["sqid"]!) XCTAssertNotNil(model.projection) if mode == "write" { guard await model.saveAll() else { XCTFail("Initial save failed: \(model.syncStatus)"); return } path.send(.unsatisfied) while connectivity.isPathSatisfied { try await Task.sleep(for: .milliseconds(5)) } try String(ProcessInfo.processInfo.processIdentifier).write(to: root.appendingPathComponent("pid"), atomically: true, encoding: .utf8) // Exercise I1a with writer ticks racing command/enqueue. No sleep // or handshake freezes the typing process in random mode. let writer: Task? = randomKill ? Task { while !Task.isCancelled { await model.persistReplica(); await Task.yield() } } : nil defer { writer?.cancel() } for index in 1...(randomKill ? 10_000 : 200) { model.didRender(model.projectionGeneration) let text = model.projection?.children.map(\.plainText).joined() ?? "" XCTAssertTrue(model.enqueue(["op": .string("display_edit"), "at": .number(Double(text.utf16.count)), "length": .number(0), "text": .string("k3-\(index);")])) while model.hasPendingInput { await Task.yield() } if randomKill || index == 12 { let expected = model.projection?.children.map(\.plainText).joined() ?? "" try expected.write(to: root.appendingPathComponent("expected"), atomically: true, encoding: .utf8) try Data().write(to: root.appendingPathComponent("kill-now"), options: .atomic) // The controller kills us in the middle of this typing run, // before the two-second replica writer can run. if !randomKill { try await Task.sleep(for: .seconds(1)) } } if !randomKill { try await Task.sleep(for: .milliseconds(15)) } } XCTFail("The controller did not kill the typing process") } else { let expected = try String(contentsOf: root.appendingPathComponent("expected"), encoding: .utf8) let reopened = model.projection?.children.map(\.plainText).joined() ?? "" if randomKill { XCTAssertFalse(expected.isEmpty, "The controller must observe durable typing before killing") XCTAssertTrue(reopened.hasPrefix(expected), "Offline reopen must preserve the last recorded durable prefix") let tokens = reopened.split(separator: ";") XCTAssertEqual(reopened, (1...max(1, tokens.count)).map { "k3-\($0);" }.joined(), "Recovered text must have no gaps, torn edits, or duplicates") } else { XCTAssertEqual(reopened, expected, "Offline reopen must include every durable keystroke exactly once") } path.send(.satisfied) while !connectivity.isPathSatisfied { try await Task.sleep(for: .milliseconds(5)) } await model.retry() let deadline = ContinuousClock.now.advanced(by: .seconds(10)) while model.hasUnsavedChanges && .now < deadline { await model.flush(); try await Task.sleep(for: .milliseconds(10)) } XCTAssertFalse(model.hasUnsavedChanges) let reply = try await client.responseDetails("/d/\(config["sqid"]!)/updates", maxBytes: 8 * 1024 * 1024) try reply.requireSuccess() let server = CRDTReplica(guid: model.detail!.guid) let projected = try await server.open(update: reply.data) XCTAssertEqual(projected.children.map(\.plainText).joined(), reopened, "Real server converges after the killed client's lane drains") await server.close(); await model.stop(); account.drainer.stop() } } }