oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1import CryptoKit
2import Foundation
3import RastrilloApple
4import RastrilloTestSupport
5import XCTest
6@testable import DocsKit
7
8final class OfflineKillTests: XCTestCase {
9 @MainActor func testTypingProcessAndRecovery() async throws {
10 let env = ProcessInfo.processInfo.environment
11 guard let directory = env["DOCS_KILL_DIR"], let mode = env["DOCS_KILL_MODE"] else {
12 throw XCTSkip("Run native/scripts/test-offline-kill.py")
13 }
14 let randomKill = env["DOCS_KILL_RANDOM"] == "1"
15 let root = URL(fileURLWithPath: directory)
16 let config = try JSONDecoder().decode([String: String].self, from: Data(contentsOf: root.appendingPathComponent("connection.json")))
17 let client = NativeHTTP(origin: URL(string: config["origin"]!)!, credential: NativeSession(name: config["name"]!, value: config["value"]!))
18 let path = ScriptedPathSource(mode == "write" ? .satisfied : .unsatisfied)
19 let connectivity = NativeConnectivity(source: path)
20 let keys = ScriptedKeyProvider(key: SymmetricKey(data: Data(repeating: 73, count: 32)))
21 let store = NativeSealedStore(root: root.appendingPathComponent("account"), accountID: "kill-test", keys: keys)
22 let account = DocsOfflineAccount(store: store, accountID: "kill-test", connectivity: connectivity, transport: { client })
23 let model = DocsModel(transport: { client }, connectivity: connectivity, offlineAccount: account)
24 await model.open(config["sqid"]!)
25 XCTAssertNotNil(model.projection)
26 if mode == "write" {
27 guard await model.saveAll() else {
28 XCTFail("Initial save failed: \(model.syncStatus)"); return
29 }
30 path.send(.unsatisfied)
31 while connectivity.isPathSatisfied { try await Task.sleep(for: .milliseconds(5)) }
32 try String(ProcessInfo.processInfo.processIdentifier).write(to: root.appendingPathComponent("pid"), atomically: true, encoding: .utf8)
33 // Exercise I1a with writer ticks racing command/enqueue. No sleep
34 // or handshake freezes the typing process in random mode.
35 let writer: Task<Void, Never>? = randomKill ? Task {
36 while !Task.isCancelled { await model.persistReplica(); await Task.yield() }
37 } : nil
38 defer { writer?.cancel() }
39 for index in 1...(randomKill ? 10_000 : 200) {
40 model.didRender(model.projectionGeneration)
41 let text = model.projection?.children.map(\.plainText).joined() ?? ""
42 XCTAssertTrue(model.enqueue(["op": .string("display_edit"), "at": .number(Double(text.utf16.count)), "length": .number(0), "text": .string("k3-\(index);")]))
43 while model.hasPendingInput { await Task.yield() }
44 if randomKill || index == 12 {
45 let expected = model.projection?.children.map(\.plainText).joined() ?? ""
46 try expected.write(to: root.appendingPathComponent("expected"), atomically: true, encoding: .utf8)
47 try Data().write(to: root.appendingPathComponent("kill-now"), options: .atomic)
48 // The controller kills us in the middle of this typing run,
49 // before the two-second replica writer can run.
50 if !randomKill { try await Task.sleep(for: .seconds(1)) }
51 }
52 if !randomKill { try await Task.sleep(for: .milliseconds(15)) }
53 }
54 XCTFail("The controller did not kill the typing process")
55 } else {
56 let expected = try String(contentsOf: root.appendingPathComponent("expected"), encoding: .utf8)
57 let reopened = model.projection?.children.map(\.plainText).joined() ?? ""
58 if randomKill {
59 XCTAssertFalse(expected.isEmpty, "The controller must observe durable typing before killing")
60 XCTAssertTrue(reopened.hasPrefix(expected), "Offline reopen must preserve the last recorded durable prefix")
61 let tokens = reopened.split(separator: ";")
62 XCTAssertEqual(reopened, (1...max(1, tokens.count)).map { "k3-\($0);" }.joined(), "Recovered text must have no gaps, torn edits, or duplicates")
63 } else {
64 XCTAssertEqual(reopened, expected, "Offline reopen must include every durable keystroke exactly once")
65 }
66 path.send(.satisfied)
67 while !connectivity.isPathSatisfied { try await Task.sleep(for: .milliseconds(5)) }
68 await model.retry()
69 let deadline = ContinuousClock.now.advanced(by: .seconds(10))
70 while model.hasUnsavedChanges && .now < deadline { await model.flush(); try await Task.sleep(for: .milliseconds(10)) }
71 XCTAssertFalse(model.hasUnsavedChanges)
72 let reply = try await client.responseDetails("/d/\(config["sqid"]!)/updates", maxBytes: 8 * 1024 * 1024)
73 try reply.requireSuccess()
74 let server = CRDTReplica(guid: model.detail!.guid)
75 let projected = try await server.open(update: reply.data)
76 XCTAssertEqual(projected.children.map(\.plainText).joined(), reopened, "Real server converges after the killed client's lane drains")
77 await server.close(); await model.stop(); account.drainer.stop()
78 }
79 }
80}
81