| 1 | import XCTest |
| 2 | import UIKit |
| 3 | final class NativeUITests: XCTestCase { |
| 4 | @MainActor func testInputRuleReversalAndMultilinePersistence() async throws { |
| 5 | continueAfterFailure = false |
| 6 | let app = XCUIApplication() |
| 7 | app.launch() |
| 8 | defer { app.terminate() } |
| 9 | let create = app.buttons["docs.create"] |
| 10 | XCTAssertTrue(create.waitForExistence(timeout: 15)) |
| 11 | create.tap() |
| 12 | let title = app.textFields["Title"] |
| 13 | XCTAssertTrue(title.waitForExistence(timeout: 5)) |
| 14 | title.tap(); title.typeText("Native input rule verification") |
| 15 | app.buttons["docs.create.submit"].tap() |
| 16 | let editor = app.textViews["docs.editor"] |
| 17 | XCTAssertTrue(editor.waitForExistence(timeout: 15)) |
| 18 | editor.tap(); editor.typeText("- ") |
| 19 | let transformed = editor.value as? String ?? "" |
| 20 | XCTAssertFalse(transformed.contains("-"), "The native input rule must remove its typed prefix") |
| 21 | editor.typeText(XCUIKeyboardKey.delete.rawValue) |
| 22 | let reverted = editor.value as? String ?? "" |
| 23 | print("Synthetic rule transformed: \(String(reflecting: transformed)); reverted: \(String(reflecting: reverted))") |
| 24 | let reversal = XCTAttachment(string: "Synthetic transformed value: \(String(reflecting: transformed))\nSynthetic reverted value: \(String(reflecting: reverted))") |
| 25 | reversal.name = "Native input rule reversal"; reversal.lifetime = .keepAlways; add(reversal) |
| 26 | XCTAssertTrue(reverted.contains("- "), "Backspace must restore the complete input-rule prefix") |
| 27 | editor.typeText("Alpha\nBeta") |
| 28 | let displayed = editor.value as? String ?? "" |
| 29 | XCTAssertTrue(displayed.contains("Alpha")) |
| 30 | XCTAssertTrue(displayed.contains("Beta")) |
| 31 | struct Config: Decodable { struct Credential: Decodable { let name: String; let value: String }; let origin: String; let credential: Credential } |
| 32 | let config = try JSONDecoder().decode(Config.self, from: Data(contentsOf: URL.documentsDirectory.appendingPathComponent("review.json"))) |
| 33 | func fetch(_ path: String) async throws -> Data { |
| 34 | var request = URLRequest(url: URL(string: config.origin + path)!) |
| 35 | request.setValue("\(config.credential.name)=\(config.credential.value)", forHTTPHeaderField: "Cookie") |
| 36 | request.setValue("application/json", forHTTPHeaderField: "Accept") |
| 37 | let (data, response) = try await URLSession.shared.data(for: request) |
| 38 | XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200) |
| 39 | return data |
| 40 | } |
| 41 | struct Listed: Decodable { struct Item: Decodable { let sqid: String; let title: String }; let documents: [Item] } |
| 42 | let list = try JSONDecoder().decode(Listed.self, from: await fetch("/")) |
| 43 | let item = try XCTUnwrap(list.documents.first { $0.title == "Native input rule verification" }) |
| 44 | var saved = "" |
| 45 | for _ in 0..<50 { |
| 46 | saved = String(decoding: try await fetch("/d/\(item.sqid)/export.md"), as: UTF8.self) |
| 47 | if saved.contains("Alpha") && saved.contains("Beta") { break } |
| 48 | try await Task.sleep(for: .milliseconds(100)) |
| 49 | } |
| 50 | let attachment = XCTAttachment(string: "Synthetic Markdown: \(String(reflecting: saved))\nSynthetic editor value: \(String(reflecting: displayed))") |
| 51 | attachment.name = "Native multiline persistence"; attachment.lifetime = .keepAlways; add(attachment) |
| 52 | let prefix = "# Native input rule verification\n\n" |
| 53 | XCTAssertTrue(saved.hasPrefix(prefix)) |
| 54 | let body = String(saved.dropFirst(prefix.count)).replacingOccurrences(of: "\\-", with: "-") |
| 55 | print("Synthetic rule persisted Markdown: \(String(reflecting: saved)); displayed: \(String(reflecting: displayed))") |
| 56 | XCTAssertEqual(body.split(whereSeparator: { $0.isWhitespace }).joined(separator: " "), "- Alpha Beta", "Reversal and multiline typing must preserve every character in the real CRDT store") |
| 57 | let screenshot = XCTAttachment(screenshot: app.screenshot()) |
| 58 | screenshot.name = "Docs native input rule and multiline editor"; screenshot.lifetime = .keepAlways; add(screenshot) |
| 59 | } |
| 60 | |
| 61 | @MainActor func testKeyboardJumpSearchOpensDocument() throws { |
| 62 | // Apple's XCUIElement overview supports keyboard injection on iPadOS/macOS, |
| 63 | // while iPhone automation supports gestures. On iPhone this inserts literal K. |
| 64 | try XCTSkipUnless(UIDevice.current.userInterfaceIdiom == .pad, |
| 65 | "XCTest hardware keyboard injection requires iPadOS; use the jump-button case on iPhone") |
| 66 | try exerciseJump(keyboard: true) |
| 67 | } |
| 68 | @MainActor func testJumpButtonSearchOpensDocument() throws { try exerciseJump(keyboard: false) } |
| 69 | @MainActor func testJumpButtonAndHardwareReturnOpensDocument() throws { |
| 70 | try XCTSkipUnless(UIDevice.current.userInterfaceIdiom == .pad, |
| 71 | "XCTest hardware keyboard injection requires iPadOS") |
| 72 | try exerciseJump(keyboard: false, hardwareReturn: true) |
| 73 | } |
| 74 | @MainActor func testRegisteredCommandAndSoftwareSubmitOpenDocument() throws { |
| 75 | try exerciseJump(keyboard: false, responderProbe: true) |
| 76 | } |
| 77 | @MainActor private func exerciseJump(keyboard: Bool, hardwareReturn: Bool = false, responderProbe: Bool = false) throws { |
| 78 | continueAfterFailure = false |
| 79 | let app = XCUIApplication() |
| 80 | if responderProbe { app.launchEnvironment["DOCS_REVIEW_COMMAND_PROBE"] = "1" } |
| 81 | app.launch() |
| 82 | defer { app.terminate() } |
| 83 | let first = app.buttons["Native review document"].firstMatch |
| 84 | XCTAssertTrue(first.waitForExistence(timeout: 15)) |
| 85 | first.tap() |
| 86 | let editor = app.textViews["docs.editor"] |
| 87 | XCTAssertTrue(editor.waitForExistence(timeout: 15)) |
| 88 | editor.tap() |
| 89 | if responderProbe { /* Review target dispatches the registered action after focus. */ } |
| 90 | else if keyboard { editor.typeKey("k", modifierFlags: .command) } |
| 91 | else { app.buttons["docs.jump"].firstMatch.tap() } |
| 92 | print("Synthetic jump shortcut hierarchy: \(app.debugDescription)") |
| 93 | try app.screenshot().pngRepresentation.write(to: URL.documentsDirectory.appendingPathComponent("jump-shortcut.review.png"), options: .atomic) |
| 94 | let query = app.textFields["docs.jump.query"] |
| 95 | XCTAssertTrue(query.waitForExistence(timeout: 5), "Command-K must open native document search from the editor") |
| 96 | query.typeText("Second review") |
| 97 | let result = app.buttons.containing(.staticText, identifier: "Second review document").firstMatch |
| 98 | XCTAssertTrue(result.waitForExistence(timeout: 10), "Native search must show the existing server-ranked result") |
| 99 | if responderProbe { query.typeText("\n") } |
| 100 | else if keyboard || hardwareReturn { query.typeKey(XCUIKeyboardKey.return, modifierFlags: []) } |
| 101 | else { result.tap() } |
| 102 | print("Synthetic jump return hierarchy: \(app.debugDescription)") |
| 103 | try app.screenshot().pngRepresentation.write(to: URL.documentsDirectory.appendingPathComponent("jump-return.review.png"), options: .atomic) |
| 104 | let opened = app.navigationBars["Second review document"].waitForExistence(timeout: 10) |
| 105 | if !opened { |
| 106 | print("Synthetic jump result hierarchy: \(app.debugDescription)") |
| 107 | let diagnostic = XCTAttachment(screenshot: app.screenshot()) |
| 108 | diagnostic.name = "Docs native jump failure"; diagnostic.lifetime = .keepAlways; add(diagnostic) |
| 109 | } |
| 110 | XCTAssertTrue(opened, "Enter must open the selected native search result") |
| 111 | XCTAssertTrue(editor.waitForExistence(timeout: 10)) |
| 112 | let screenshot = XCTAttachment(screenshot: app.screenshot()) |
| 113 | screenshot.name = "Docs native keyboard jump result"; screenshot.lifetime = .keepAlways; add(screenshot) |
| 114 | } |
| 115 | |
| 116 | @MainActor func testExportPDFAndMarkdownDestinationsAndCancellation() throws { |
| 117 | continueAfterFailure = false |
| 118 | let app = XCUIApplication() |
| 119 | app.launch() |
| 120 | defer { app.terminate() } |
| 121 | let document = app.buttons["Native review document"].firstMatch |
| 122 | XCTAssertTrue(document.waitForExistence(timeout: 15)) |
| 123 | document.tap() |
| 124 | XCTAssertTrue(app.textViews["docs.editor"].waitForExistence(timeout: 15)) |
| 125 | let export = app.buttons["Export and print"] |
| 126 | for format in ["PDF", "Markdown"] { |
| 127 | XCTAssertTrue(export.waitForExistence(timeout: 5)) |
| 128 | export.tap() |
| 129 | app.buttons["Download as " + format].tap() |
| 130 | let cancel = app.buttons["Cancel"] |
| 131 | XCTAssertTrue(cancel.waitForExistence(timeout: 20), "The native destination picker must open for " + format) |
| 132 | let screenshot = XCTAttachment(screenshot: app.screenshot()) |
| 133 | screenshot.name = "Docs native " + format + " export destination"; screenshot.lifetime = .keepAlways; add(screenshot) |
| 134 | let hierarchy = XCTAttachment(string: app.debugDescription) |
| 135 | hierarchy.name = "Synthetic " + format + " export picker hierarchy"; hierarchy.lifetime = .keepAlways; add(hierarchy) |
| 136 | cancel.tap() |
| 137 | XCTAssertTrue(export.waitForExistence(timeout: 10)) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | @MainActor func testCreateTypeFormatAndPersistDocument() async throws { |
| 142 | continueAfterFailure = false |
| 143 | let app = XCUIApplication() |
| 144 | app.launch() |
| 145 | defer { app.terminate() } |
| 146 | XCTAssertFalse(app.staticTexts["fixture.missing"].exists) |
| 147 | let create = app.buttons["docs.create"] |
| 148 | guard create.waitForExistence(timeout: 10) else { XCTFail("Fixture document list did not load"); throw NSError(domain: "NativeReview", code: 1) } |
| 149 | create.tap() |
| 150 | let title = app.textFields["Title"] |
| 151 | XCTAssertTrue(title.waitForExistence(timeout: 3)) |
| 152 | title.tap(); title.typeText("Native editing verification") |
| 153 | app.buttons["docs.create.submit"].tap() |
| 154 | let editor = app.textViews["docs.editor"] |
| 155 | guard editor.waitForExistence(timeout: 10) else { XCTFail("Native editor did not open"); throw NSError(domain: "NativeReview", code: 2) } |
| 156 | editor.tap(); editor.typeText("Native text") |
| 157 | XCTAssertTrue((editor.value as? String)?.contains("Native text") == true) |
| 158 | let bold = app.buttons["Bold"].firstMatch |
| 159 | XCTAssertTrue(bold.exists) |
| 160 | bold.tap() |
| 161 | // The toolbar retains the editor's first responder. Tapping the centre |
| 162 | // of a whole-page text view here would deliberately reposition the caret. |
| 163 | editor.typeText(" bold") |
| 164 | let displayed = editor.value as? String ?? "<missing editor value>" |
| 165 | if !displayed.contains("bold") { |
| 166 | let diagnostic = XCTAttachment(string: "Synthetic editor value: \(String(reflecting: displayed))\n\(app.debugDescription)") |
| 167 | diagnostic.name = "Synthetic native input failure"; diagnostic.lifetime = .keepAlways; add(diagnostic) |
| 168 | let failedScreenshot = XCTAttachment(screenshot: app.screenshot()) |
| 169 | failedScreenshot.name = "Docs native input failure"; failedScreenshot.lifetime = .keepAlways; add(failedScreenshot) |
| 170 | } |
| 171 | XCTAssertTrue(displayed.contains("bold"), "Formatted input must remain visible: \(String(reflecting: displayed))") |
| 172 | struct Config: Decodable { struct Credential: Decodable { let name: String; let value: String }; let origin: String; let credential: Credential } |
| 173 | let config = try JSONDecoder().decode(Config.self, from: Data(contentsOf: URL.documentsDirectory.appendingPathComponent("review.json"))) |
| 174 | func fetch(_ path: String) async throws -> Data { |
| 175 | var request = URLRequest(url: URL(string: config.origin + path)!) |
| 176 | request.setValue("\(config.credential.name)=\(config.credential.value)", forHTTPHeaderField: "Cookie") |
| 177 | request.setValue("application/json", forHTTPHeaderField: "Accept") |
| 178 | let (data, response) = try await URLSession.shared.data(for: request) |
| 179 | XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200) |
| 180 | return data |
| 181 | } |
| 182 | struct Listed: Decodable { struct Item: Decodable { let sqid: String; let title: String }; let documents: [Item] } |
| 183 | let list = try JSONDecoder().decode(Listed.self, from: await fetch("/")) |
| 184 | let item = try XCTUnwrap(list.documents.first { $0.title == "Native editing verification" }) |
| 185 | var saved = "" |
| 186 | for _ in 0..<40 { |
| 187 | saved = String(decoding: try await fetch("/d/\(item.sqid)/export.md"), as: UTF8.self) |
| 188 | if saved.contains("Native text") && saved.contains("**") && saved.contains("bold") { break } |
| 189 | try await Task.sleep(for: .milliseconds(100)) |
| 190 | } |
| 191 | let persistedDiagnostic = XCTAttachment(string: "Synthetic persisted Markdown: \(String(reflecting: saved))\nSynthetic displayed value: \(String(reflecting: editor.value as? String ?? "<missing>"))") |
| 192 | persistedDiagnostic.name = "Synthetic native persistence"; persistedDiagnostic.lifetime = .keepAlways; add(persistedDiagnostic) |
| 193 | XCTAssertTrue(saved.contains("Native text"), "Native edits must reach the real CRDT store") |
| 194 | XCTAssertTrue(saved.contains("**"), "Bold must persist as Markdown formatting, not only visible text") |
| 195 | XCTAssertTrue(saved.contains("bold"), "Formatted typing must reach the real CRDT store") |
| 196 | // Canonical Markdown export includes the document title as its heading. |
| 197 | let titlePrefix = "# Native editing verification\n\n" |
| 198 | XCTAssertTrue(saved.hasPrefix(titlePrefix)) |
| 199 | let body = String(saved.dropFirst(titlePrefix.count)) |
| 200 | XCTAssertEqual(body.replacingOccurrences(of: "**", with: "").trimmingCharacters(in: .whitespacesAndNewlines), "Native text bold", "Formatting the insertion point must preserve every character and append at the retained caret") |
| 201 | let screenshot = XCTAttachment(screenshot: app.screenshot()) |
| 202 | screenshot.name = "Docs native editor"; screenshot.lifetime = .keepAlways; add(screenshot) |
| 203 | } |
| 204 | } |
| 205 | |