| 1 | import AppKit |
| 2 | import DocsKit |
| 3 | import RastrilloApple |
| 4 | import SwiftUI |
| 5 | |
| 6 | @MainActor private enum SmokeRun { static var started = false } |
| 7 | |
| 8 | @main struct DocumentWindowSmoke: App { |
| 9 | @State private var windows = DocsWindows() |
| 10 | var body: some Scene { |
| 11 | Window("Smoke Home", id: "home") { Launcher(windows: windows) } |
| 12 | WindowGroup("Smoke Document", id: "document", for: String.self) { _ in |
| 13 | DocsWorkspace(client: NativeHTTP(origin: URL(string: ProcessInfo.processInfo.environment["DOCS_SMOKE_ORIGIN"]!)!), |
| 14 | signOut: {}, documentID: "fixture", newDocument: ProcessInfo.processInfo.environment["DOCS_SMOKE_DRAFT"] == "1") |
| 15 | }.defaultSize(width: 1120, height: 820) |
| 16 | .restorationBehavior(.disabled) |
| 17 | .commands { DocsEditorCommands(); DocsWindowCommands(windows: windows) } |
| 18 | } |
| 19 | } |
| 20 | |
| 21 | private struct Launcher: View { |
| 22 | let windows: DocsWindows |
| 23 | @Environment(\.openWindow) private var openWindow |
| 24 | var body: some View { |
| 25 | Text("Synthetic document window smoke test").frame(width: 400, height: 200) |
| 26 | .task { |
| 27 | guard !SmokeRun.started else { return } |
| 28 | SmokeRun.started = true |
| 29 | NSApp.setActivationPolicy(.regular) |
| 30 | openWindow(id: "document", value: "fixture") |
| 31 | if ProcessInfo.processInfo.environment["DOCS_SMOKE_DRAFT"] == "1" { |
| 32 | let start = ContinuousClock.now |
| 33 | var draftEditor: NSTextView? |
| 34 | var draftWindow: NSWindow? |
| 35 | for _ in 0..<20 { |
| 36 | if let window = NSApp.windows.first(where: { $0.title == docsMacString("Untitled document") }), let editor = window.contentView.flatMap(findEditor) { |
| 37 | draftWindow = window; draftEditor = editor; break |
| 38 | } |
| 39 | try? await Task.sleep(for: .milliseconds(50)) |
| 40 | } |
| 41 | guard let window = draftWindow, let editor = draftEditor else { fatalError("Local editor waited for server creation") } |
| 42 | precondition(start.duration(to: .now) < .seconds(1)) |
| 43 | NSApp.activate(ignoringOtherApps: true) |
| 44 | window.makeKeyAndOrderFront(nil); window.makeFirstResponder(editor) |
| 45 | editor.insertText("Instant draft", replacementRange: NSRange(location: 0, length: 0)) |
| 46 | for _ in 0..<80 { |
| 47 | if window.title == "Window smoke fixture" { break } |
| 48 | try? await Task.sleep(for: .milliseconds(50)) |
| 49 | } |
| 50 | precondition(window.title == "Window smoke fixture") |
| 51 | precondition(window.contentView.flatMap(findEditor) === editor, "Server attachment replaced the editor") |
| 52 | precondition(editor.string.contains("Instant draft"), "Creation lost locally typed text") |
| 53 | print("PASS: draft editor appears before delayed creation and keeps its text view and typing") |
| 54 | await exercise(window, editor: editor) |
| 55 | return |
| 56 | } |
| 57 | for _ in 0..<100 { |
| 58 | if let window = NSApp.windows.first(where: { $0.title == "Window smoke fixture" }), |
| 59 | let editor = window.contentView.flatMap(findEditor), !editor.string.isEmpty { |
| 60 | await exercise(window, editor: editor) |
| 61 | return |
| 62 | } |
| 63 | try? await Task.sleep(for: .milliseconds(100)) |
| 64 | } |
| 65 | fatalError("Document editor did not load") |
| 66 | } |
| 67 | } |
| 68 | @MainActor private func findEditor(_ view: NSView) -> NSTextView? { |
| 69 | if let text = view as? NSTextView, text.accessibilityIdentifier() == "docs.editor" { return text } |
| 70 | return view.subviews.lazy.compactMap(findEditor).first |
| 71 | } |
| 72 | @MainActor private func menuItem(_ title: String, in menu: NSMenu) -> NSMenuItem? { |
| 73 | for item in menu.items { |
| 74 | if item.title == title { return item } |
| 75 | if let submenu = item.submenu, let match = menuItem(title, in: submenu) { return match } |
| 76 | } |
| 77 | return nil |
| 78 | } |
| 79 | @MainActor private func exercise(_ window: NSWindow, editor: NSTextView) async { |
| 80 | NSApp.activate(ignoringOtherApps: true) |
| 81 | window.makeKeyAndOrderFront(nil) |
| 82 | window.makeFirstResponder(editor) |
| 83 | editor.setSelectedRange(NSRange(location: 1, length: 2)) |
| 84 | try? await Task.sleep(for: .milliseconds(500)) |
| 85 | let original = editor.string |
| 86 | for step in 0..<8 { |
| 87 | try? await Task.sleep(for: .milliseconds(200)) |
| 88 | guard let menu = NSApp.mainMenu, let initial = menuItem(docsMacString("Document inspector"), in: menu), let submenu = initial.menu else { |
| 89 | fatalError("Document inspector command unavailable") |
| 90 | } |
| 91 | submenu.delegate?.menuNeedsUpdate?(submenu) |
| 92 | submenu.update() |
| 93 | guard let item = menuItem(docsMacString("Document inspector"), in: menu), item.isEnabled else { |
| 94 | fatalError("Inspector command disabled in document window") |
| 95 | } |
| 96 | let beforeWidth = editor.enclosingScrollView!.frame.width |
| 97 | item.menu?.performActionForItem(at: item.menu!.index(of: item)) |
| 98 | try? await Task.sleep(for: .milliseconds(200)) |
| 99 | precondition(abs(editor.enclosingScrollView!.frame.width - beforeWidth) > 200, "Inspector did not change editor space") |
| 100 | window.setContentSize(NSSize(width: step.isMultiple(of: 2) ? 900 : 1200, height: 760)) |
| 101 | try? await Task.sleep(for: .milliseconds(200)) |
| 102 | window.contentView?.layoutSubtreeIfNeeded() |
| 103 | precondition(window.contentView.flatMap(findEditor) === editor, "Inspector recreated editor") |
| 104 | precondition(editor.string == original && editor.selectedRange() == NSRange(location: 1, length: 2), "Panel lost content or selection") |
| 105 | } |
| 106 | // Save while the title field is still active, without submitting Return. |
| 107 | if let menu = NSApp.mainMenu, let rename = menuItem("Rename document", in: menu) { |
| 108 | rename.menu?.delegate?.menuNeedsUpdate?(rename.menu!) |
| 109 | rename.menu?.update() |
| 110 | guard let currentRename = menuItem("Rename document", in: menu), currentRename.isEnabled else { fatalError("Rename disabled in writable document") } |
| 111 | currentRename.menu?.performActionForItem(at: currentRename.menu!.index(of: currentRename)) |
| 112 | try? await Task.sleep(for: .milliseconds(200)) |
| 113 | func findTitle(_ view: NSView) -> NSTextField? { |
| 114 | if let field = view as? NSTextField, field.isEditable { return field } |
| 115 | return view.subviews.lazy.compactMap(findTitle).first |
| 116 | } |
| 117 | guard let root = window.contentView?.superview, let field = findTitle(root) else { fatalError("Editable header title missing") } |
| 118 | field.selectText(nil) |
| 119 | guard let input = field.currentEditor() as? NSTextView else { fatalError("Title has no field editor") } |
| 120 | input.selectAll(nil); input.insertText("Renamed from header", replacementRange: input.selectedRange()) |
| 121 | try? await Task.sleep(for: .milliseconds(200)) |
| 122 | guard let save = menuItem(docsMacString("Save"), in: menu) else { fatalError("Save unavailable") } |
| 123 | save.menu?.performActionForItem(at: save.menu!.index(of: save)) |
| 124 | try? await Task.sleep(for: .milliseconds(300)) |
| 125 | precondition(window.title == "Renamed from header", "Save lost the active title input") |
| 126 | print("PASS: Save commits active header title without Return") |
| 127 | } else { fatalError("Rename command missing") } |
| 128 | window.makeFirstResponder(editor) |
| 129 | editor.setSelectedRange(NSRange(location: editor.string.utf16.count - 1, length: 0)) |
| 130 | NSApp.activate(ignoringOtherApps: true) |
| 131 | window.makeKeyAndOrderFront(nil) |
| 132 | window.makeFirstResponder(editor) |
| 133 | editor.scrollRangeToVisible(editor.selectedRange()) |
| 134 | try? await Task.sleep(for: .milliseconds(100)) |
| 135 | func indicators(_ view: NSView) -> [NSView] { |
| 136 | (view is NSTextInsertionIndicator ? [view] : []) + view.subviews.flatMap(indicators) |
| 137 | } |
| 138 | func record(_ phase: String) { |
| 139 | let manager = editor.layoutManager! |
| 140 | manager.ensureLayout(for: editor.textContainer!) |
| 141 | let glyph = manager.glyphIndexForCharacter(at: min(editor.selectedRange().location, editor.string.utf16.count - 1)) |
| 142 | let line = manager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil) |
| 143 | let rect = editor.firstRect(forCharacterRange: editor.selectedRange(), actualRange: nil) |
| 144 | precondition(editor.textContainerOrigin == .zero, "Native caret origin drifted") |
| 145 | precondition(rect.height > 0, "Caret clipped outside editor bounds") |
| 146 | precondition(editor.bounds.maxY >= line.maxY, "Editor shrank below current line") |
| 147 | let visible = indicators(window.contentView!).filter { !$0.isHidden } |
| 148 | precondition(visible.count == 1, "Expected exactly one visible native insertion indicator; count=\(visible.count), phase=\(phase), editable=\(editor.isEditable), attached=\(editor.window === window), key=\(window.isKeyWindow), responder=\(String(describing: window.firstResponder))") |
| 149 | for indicator in visible { |
| 150 | let actual = indicator.convert(indicator.bounds, to: editor) |
| 151 | precondition(abs(actual.minY - line.minY) < 1, "Native insertion indicator disagrees with glyphs") |
| 152 | } |
| 153 | |
| 154 | } |
| 155 | for character in "\nCaret stability after Enter.\nSecond line.\nThird line." { |
| 156 | window.makeKeyAndOrderFront(nil) |
| 157 | window.makeFirstResponder(editor) |
| 158 | let before = editor.selectedRange().location |
| 159 | let typed = character == "\n" ? "\r" : String(character) |
| 160 | let event = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, context: nil, characters: typed, charactersIgnoringModifiers: typed, isARepeat: false, keyCode: character == "\n" ? 36 : 0)! |
| 161 | editor.keyDown(with: event) |
| 162 | record("immediate") |
| 163 | let immediate = editor.firstRect(forCharacterRange: editor.selectedRange(), actualRange: nil) |
| 164 | try? await Task.sleep(for: .milliseconds(100)) |
| 165 | precondition(editor.selectedRange().location == before + String(character).utf16.count, "Typing moved selection unexpectedly") |
| 166 | record("settled") |
| 167 | let settled = editor.firstRect(forCharacterRange: editor.selectedRange(), actualRange: nil) |
| 168 | precondition(abs(immediate.minY - settled.minY) < 1, "Caret jumped after publication") |
| 169 | } |
| 170 | if let path = ProcessInfo.processInfo.environment["DOCS_SMOKE_SCREENSHOT"], let view = window.contentView, |
| 171 | let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) { |
| 172 | view.cacheDisplay(in: view.bounds, to: bitmap) |
| 173 | try? bitmap.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: path)) |
| 174 | } |
| 175 | print("PASS: document loads, inspector toggles, window resizes, editor and selection persist, Enter and typing keep caret geometry stable") |
| 176 | NSApp.windows.first(where: { $0.title == "Smoke Home" })?.close() |
| 177 | window.makeKeyAndOrderFront(nil) |
| 178 | guard let menu = NSApp.mainMenu, let appMenu = menu.items.first?.submenu else { fatalError("App menu missing") } |
| 179 | appMenu.delegate?.menuNeedsUpdate?(appMenu) |
| 180 | appMenu.update() |
| 181 | guard let accounts = menuItem(docsMacString("Accounts"), in: appMenu), accounts.isEnabled, |
| 182 | let signOut = menuItem(docsMacString("Sign out"), in: appMenu), !signOut.isEnabled else { |
| 183 | fatalError("Account commands missing or unsafe in document window") |
| 184 | } |
| 185 | accounts.menu!.performActionForItem(at: accounts.menu!.index(of: accounts)) |
| 186 | try? await Task.sleep(for: .milliseconds(300)) |
| 187 | precondition(windows.accountsRequested, "Accounts action did not reach launcher") |
| 188 | precondition(NSApp.windows.contains { $0.title == "Smoke Home" && $0.isVisible }, "Accounts did not reopen Home") |
| 189 | print("PASS: account commands live in app menu and Accounts reopens closed Home") |
| 190 | NSApp.terminate(nil) |
| 191 | } |
| 192 | } |
| 193 | |