instrument-ios.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. """Add measurement-only hooks to disposable git-archive copies, never a checkout.
  2. Usage: python3 instrument-ios.py /tmp/bot-latency-acceptance [baseline|optimized]
  3. """
  4. from pathlib import Path
  5. import sys
  6. root = Path(sys.argv[1] if len(sys.argv) > 1 else '/tmp/bot-latency-acceptance')
  7. modes = [sys.argv[2]] if len(sys.argv) > 2 else ['baseline', 'optimized']
  8. helper = r'''
  9. @MainActor enum LatencyProbe {
  10. private static var opened = false
  11. static func mark(_ name: String, _ detail: String = "") {
  12. guard let run = ProcessInfo.processInfo.environment["LATENCY_RUN"] else { return }
  13. let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("latency-\(run).jsonl")
  14. if !opened { FileManager.default.createFile(atPath: url.path, contents: Data()); opened = true }
  15. let row: [String: Any] = ["time": Date().timeIntervalSince1970, "name": name, "detail": detail]
  16. guard var data = try? JSONSerialization.data(withJSONObject: row) else { return }; data.append(10)
  17. if let handle = try? FileHandle(forWritingTo: url) { try? handle.seekToEnd(); try? handle.write(contentsOf: data); try? handle.close() }
  18. }
  19. }
  20. #if DEBUG
  21. @MainActor private struct LatencyFixtureView: View {
  22. @State private var client: HermesRESTClient?
  23. @State private var error = "连接隔离测试服务"
  24. var body: some View {
  25. Group {
  26. if let client { WorkspaceChatView(client: client, accountID: nil, accountName: "隔离测试", onOpenDrawer: {}) }
  27. else { Text(error) }
  28. }.task {
  29. do {
  30. let config = try ServerConfiguration(urlString: ProcessInfo.processInfo.environment["LATENCY_URL"]!)
  31. let network = HermesNetworkSession.isolated()
  32. var request = URLRequest(url: config.baseURL.appendingPathComponent("auth/password-login"))
  33. request.httpMethod = "POST"; request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  34. request.httpBody = Data(#"{"username":"fixture","password":"fixture-pass","provider":"basic"}"#.utf8)
  35. let (_, response) = try await network.session.data(for: request)
  36. guard (response as? HTTPURLResponse)?.statusCode == 200 else { error = "夹具登录失败"; return }
  37. let rest = HermesRESTClient(session: network.session, cookieStorage: network.cookieStorage)
  38. await rest.configure(server: config, authenticationTransport: .cookie); client = rest
  39. } catch { self.error = error.localizedDescription }
  40. }
  41. }
  42. }
  43. #endif
  44. '''
  45. ui_test = r'''
  46. extension LaunchSmokeTests {
  47. func testLatencyCurrent() async throws {
  48. continueAfterFailure = false
  49. let product = "MODE-ios", nonce = String(UUID().uuidString.prefix(8))
  50. for (kind, count) in [("short", 6), ("long", 1)] {
  51. let group = "\(product)-\(kind)"
  52. _ = try await URLSession.shared.data(from: URL(string: "http://127.0.0.1:19359/scenario?delay=160&run=\(group)-setup")!)
  53. let app = XCUIApplication()
  54. app.launchEnvironment["LATENCY_URL"] = "http://127.0.0.1:PORT"
  55. app.launchEnvironment["LATENCY_RUN"] = group
  56. app.launchArguments = ["-AppleLanguages", "(zh-Hans)", "-AppleLocale", "zh_CN"]
  57. app.launch()
  58. let chat = app.buttons.matching(NSPredicate(format: "label CONTAINS %@", "LatencyBot")).firstMatch
  59. XCTAssertTrue(chat.waitForExistence(timeout: 20)); chat.tap()
  60. let input = app.descendants(matching: .any)["workspace.message-input"].firstMatch
  61. XCTAssertTrue(input.waitForExistence(timeout: 10))
  62. for n in 1...count {
  63. let run = "\(group)-\(n)", marker = "\(kind)-\(group)-\(n)-\(nonce)"
  64. _ = try await URLSession.shared.data(from: URL(string: "http://127.0.0.1:19359/scenario?delay=160&run=\(run)")!)
  65. input.tap(); input.typeText("[latency:\(marker)]"); app.buttons["workspace.send"].tap()
  66. let reply = app.staticTexts.matching(NSPredicate(format: "label CONTAINS %@", "END-\(marker)")).firstMatch
  67. XCTAssertTrue(reply.waitForExistence(timeout: 30))
  68. if n == count { let a = XCTAttachment(screenshot: app.screenshot()); a.name = run; a.lifetime = .keepAlways; add(a) }
  69. try await Task.sleep(for: .milliseconds(700))
  70. }
  71. app.terminate()
  72. }
  73. }
  74. }
  75. '''
  76. for mode in modes:
  77. assert mode in ('baseline', 'optimized')
  78. repo = root / f'ios-{mode}'
  79. assert not (repo / '.git').exists(), 'Use a disposable source copy, not a git checkout'
  80. app = repo / 'YaoYaoAI/App/YaoYaoAIApp.swift'
  81. text = app.read_text()
  82. assert 'enum LatencyProbe' not in text, 'Copy is already instrumented'
  83. needle = 'if let scenario = UITestScenario.current {'
  84. assert needle in text
  85. app.write_text(text.replace(needle, 'if ProcessInfo.processInfo.environment["LATENCY_URL"] != nil { LatencyFixtureView() } else '+needle, 1) + helper)
  86. model = repo / 'YaoYaoAI/Features/WorkspaceChat/WorkspaceChatModel.swift'
  87. text = model.read_text(); start = text.index(' func send() async {')
  88. part = text[start:].replace(' busy = true', ' LatencyProbe.mark("send_start")\n busy = true', 1)
  89. part = part.replace(' defer { busy = false }', ' defer { busy = false; LatencyProbe.mark("send_ready") }', 1)
  90. lines = part.splitlines(); index = next(i for i, line in enumerate(lines) if 'client.post("/api/app/conversations/\\(c.id)/messages"' in line)
  91. lines.insert(index + 1, ' LatencyProbe.mark("post_ack")')
  92. text = text[:start] + '\n'.join(lines) + '\n'
  93. lines = text.splitlines(); index = next(i for i, line in enumerate(lines) if line.startswith(' private func present('))
  94. lines.insert(index, ' @ObservationIgnored private var latencyCompletedIDs: Set<String> = []')
  95. lines.insert(index + 2, ' if let m = detail.messages.last, m.role == "assistant", m.status == "complete", latencyCompletedIDs.insert(m.id).inserted { LatencyProbe.mark("completed_text", m.content) }')
  96. model.write_text('\n'.join(lines) + '\n')
  97. view = repo / 'YaoYaoAI/Features/WorkspaceChat/WorkspaceTranscriptView.swift'
  98. text = view.read_text(); needle = '.onChange(of: messages.last?.content.count) { _, _ in'
  99. assert needle in text
  100. view.write_text(text.replace(needle, needle + '\n LatencyProbe.mark("view_text", "\\(messages.last?.role ?? "")|\\(messages.last?.status ?? "")|\\(messages.last?.content.count ?? 0)")', 1))
  101. tests = repo / 'YaoYaoAIUITests/LaunchSmokeTests.swift'
  102. tests.write_text(tests.read_text() + ui_test.replace('MODE', mode).replace('PORT', '19371' if mode == 'baseline' else '19372'))