diff --git a/Mirror/iOS/ProwlMirror-iOS/ContentView.swift b/Mirror/iOS/ProwlMirror-iOS/ContentView.swift index 978db87b..0e68cbb4 100644 --- a/Mirror/iOS/ProwlMirror-iOS/ContentView.swift +++ b/Mirror/iOS/ProwlMirror-iOS/ContentView.swift @@ -279,16 +279,26 @@ private struct AddConnectionView: View { } } +/// The row tops and the visible top of the live reader. It is not observable: it changes on every +/// scroll, and no view shows it. +private final class MirrorLiveReadingLayout { + var rowTops: [MirrorDocument.Row.ID: CGFloat] = [:] + var visibleTop: CGFloat = 0 +} + private struct MirrorReadingView: View { @Bindable var session: MirrorSession @State private var showsConnectionEditor = false - @State private var position: ScrollPosition - @State private var observedInitialPosition = false + @State private var position = ScrollPosition(edge: .top) + @State private var layout = MirrorLiveReadingLayout() + /// The anchor to restore when the live reader appears. While it is set, scrolling does not + /// replace the stored anchor. + @State private var restoring: MirrorReadingAnchor? @State private var isEditing = false init(session: MirrorSession) { self.session = session - _position = State(initialValue: ScrollPosition(y: session.liveReadingOffset)) + _restoring = State(initialValue: session.liveReadingAnchor) } var body: some View { @@ -331,37 +341,42 @@ private struct MirrorReadingView: View { ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 12) { - MirrorDocumentView(text: session.text) + MirrorDocumentView(text: session.text, onRowTop: rowTopChanged) .frame(maxWidth: .infinity, alignment: .leading) .accessibilityIdentifier("mirror-live-text") Color.clear.frame(height: 1).id("latest") } .padding() + .coordinateSpace(MirrorDocumentView.rowSpace) } .scrollPosition($position) .accessibilityIdentifier("mirror-live-scroll") - .onDisappear { observedInitialPosition = false } + .onDisappear { restoring = session.liveReadingAnchor } .onAppear { if session.followsLatest { + restoring = nil position.scrollTo(edge: .bottom) - } else { - position.scrollTo(y: session.liveReadingOffset) + } else if let restoring { + restore(restoring, with: proxy) } } .onScrollGeometryChange(for: CGFloat.self) { geometry in max(0, geometry.contentOffset.y + geometry.contentInsets.top) - } action: { _, offset in - if observedInitialPosition { session.liveReadingOffset = offset } - observedInitialPosition = true + } action: { _, top in + layout.visibleTop = top + storeAnchor() } .onScrollPhaseChange { _, phase in - if phase == .interacting { session.followsLatest = false } + guard phase == .interacting else { return } + session.followsLatest = false + restoring = nil } .onChange(of: session.revision) { _, _ in if session.followsLatest { proxy.scrollTo("latest", anchor: .bottom) } } .onChange(of: session.completedScroll) { _, completion in guard completion != nil else { return } + restoring = nil position.scrollTo(edge: .top) } .safeAreaInset(edge: .top) { @@ -470,6 +485,38 @@ private struct MirrorReadingView: View { } } } + + private func restore(_ anchor: MirrorReadingAnchor, with proxy: ScrollViewProxy) { + if let top = layout.rowTops[anchor.row] { + restoring = nil + position.scrollTo(y: top + anchor.offset) + } else if MirrorDocument(session.text).rows.contains(where: { $0.id == anchor.row }) { + // Lay out the anchor row first. `rowTopChanged` then applies the offset in the row. + proxy.scrollTo(anchor.row, anchor: .top) + } else { + restoring = nil + position.scrollTo(edge: .bottom) + } + } + + private func rowTopChanged(_ row: MirrorDocument.Row.ID, _ top: CGFloat?) { + layout.rowTops[row] = top + guard let top else { return } + if let anchor = restoring { + guard anchor.row == row else { return } + restoring = nil + position.scrollTo(y: top + anchor.offset) + } else { + storeAnchor() + } + } + + private func storeAnchor() { + guard restoring == nil, + let anchor = MirrorReadingAnchor(top: layout.visibleTop, rowTops: layout.rowTops) + else { return } + session.liveReadingAnchor = anchor + } } private struct MirrorConnectionEditor: View { @@ -551,18 +598,21 @@ private struct MirrorHistoryView: View { .font(.caption).foregroundStyle(.secondary) ScrollViewReader { proxy in ScrollView { - LazyVStack(alignment: .leading) { + VStack(alignment: .leading) { Button("Load Earlier 200 Lines") { session.loadHistory() } .disabled( session.historyOffset == 0 || session.isLoadingHistory || session.status != .live) - ForEach( - session.historyOffset..<(session.historyOffset + session.historyLines.count), - id: \.self - ) { index in - let line = session.historyLines[index - session.historyOffset] - Text(line.isEmpty ? " " : line).textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .id(index) + // iOS 27 sends no taps to a button in the same stack as selectable text rows. + LazyVStack(alignment: .leading) { + ForEach( + session.historyOffset..<(session.historyOffset + session.historyLines.count), + id: \.self + ) { index in + let line = session.historyLines[index - session.historyOffset] + Text(line.isEmpty ? " " : line).textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .id(index) + } } } } diff --git a/Mirror/iOS/ProwlMirror-iOS/Features/Reading/MirrorDocument.swift b/Mirror/iOS/ProwlMirror-iOS/Features/Reading/MirrorDocument.swift index f6d25fc7..49d93017 100644 --- a/Mirror/iOS/ProwlMirror-iOS/Features/Reading/MirrorDocument.swift +++ b/Mirror/iOS/ProwlMirror-iOS/Features/Reading/MirrorDocument.swift @@ -19,8 +19,34 @@ nonisolated struct MirrorDocument { } } + /// One item of the reading layout and a scroll anchor: a code or table block, a text block + /// that fits in one layout chunk, or one chunk of a longer text block. + struct Row: Identifiable { + struct ID: Hashable, Comparable { + let block: Int + let chunk: Int + + static func < (lhs: ID, rhs: ID) -> Bool { (lhs.block, lhs.chunk) < (rhs.block, rhs.chunk) } + } + + let id: ID + let block: Block + /// The chunk to show, or nil to show the whole block. + let chunk: MirrorTextLayout.Chunk? + } + let blocks: [Block] + var rows: [Row] { + blocks.flatMap { block -> [Row] in + let whole = [Row(id: .init(block: block.id, chunk: 0), block: block, chunk: nil)] + guard case .text(_, let content) = block else { return whole } + let chunks = MirrorTextLayout.chunks(content) + guard chunks.count > 1 else { return whole } + return chunks.map { Row(id: .init(block: block.id, chunk: $0.id), block: block, chunk: $0) } + } + } + init(_ text: String) { let lines = text.components(separatedBy: "\n") var result: [Block] = [] diff --git a/Mirror/iOS/ProwlMirror-iOS/Features/Reading/MirrorDocumentView.swift b/Mirror/iOS/ProwlMirror-iOS/Features/Reading/MirrorDocumentView.swift index ed424776..351cea6a 100644 --- a/Mirror/iOS/ProwlMirror-iOS/Features/Reading/MirrorDocumentView.swift +++ b/Mirror/iOS/ProwlMirror-iOS/Features/Reading/MirrorDocumentView.swift @@ -1,36 +1,29 @@ import SwiftUI struct MirrorDocumentView: View { + /// The coordinate space of the row tops. Apply it to the scroll content. + nonisolated static var rowSpace: NamedCoordinateSpace { .named("MirrorDocumentView.rows") } + let text: String + /// Receives the top of each row in `rowSpace` when the row is laid out, and nil when it leaves + /// the layout. + var onRowTop: ((MirrorDocument.Row.ID, CGFloat?) -> Void)? @State private var expanded: MirrorDocument.Block? var body: some View { - LazyVStack(alignment: .leading, spacing: 12) { - ForEach(MirrorDocument(text).blocks) { block in - switch block { - case .text(_, let content): - textBlock(content) - case .code(_, let language, let code): - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(language.isEmpty ? "Code" : language).font(.caption).foregroundStyle(.secondary) - Spacer() - Button("Expand") { expanded = block } - } - Text(code).font(.body.monospaced()).lineLimit(6).textSelection(.enabled) - } - .padding().background(.quaternary, in: RoundedRectangle(cornerRadius: 12)) - case .table(_, _, let rows): - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Table · \(rows.count - 1) rows").font(.caption).foregroundStyle(.secondary) - Spacer() - Button("Expand") { expanded = block } - } - ScrollView(.horizontal) { table(Array(rows.prefix(5))) } + // One flat lazy stack: each chunk of a long text block is a row, so a reading position can + // anchor to the text near the top of the visible area. + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(MirrorDocument(text).rows) { row in + rowContent(row) + .padding(.top, row.id.chunk == 0 && row.id.block > 0 ? 12 : 0) + .id(row.id) + .onGeometryChange(for: CGFloat.self) { + $0.frame(in: Self.rowSpace).minY + } action: { top in + onRowTop?(row.id, top) } - .padding().background(.quaternary, in: RoundedRectangle(cornerRadius: 12)) - } + .onDisappear { onRowTop?(row.id, nil) } } } .sheet(item: $expanded) { block in @@ -45,31 +38,50 @@ struct MirrorDocumentView: View { } } - private func renderInline(_ text: String) -> AttributedString { - (try? AttributedString( - markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) - ?? AttributedString(text) - } - @ViewBuilder - private func textBlock(_ content: String) -> some View { - let chunks = MirrorTextLayout.chunks(content) - if chunks.count == 1 { - Text(renderInline(content)).textSelection(.enabled) - } else { - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(chunks) { chunk in - Text(verbatim: chunk.display) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) + private func rowContent(_ row: MirrorDocument.Row) -> some View { + let block = row.block + switch block { + case .text(_, let content): + if let chunk = row.chunk { + Text(verbatim: chunk.display) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .contextMenu { + Button("Copy Full Text Block") { UIPasteboard.general.string = content } + } + } else { + Text(renderInline(content)).textSelection(.enabled) + } + case .code(_, let language, let code): + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(language.isEmpty ? "Code" : language).font(.caption).foregroundStyle(.secondary) + Spacer() + Button("Expand") { expanded = block } } + Text(code).font(.body.monospaced()).lineLimit(6).textSelection(.enabled) } - .contextMenu { - Button("Copy Full Text Block") { UIPasteboard.general.string = content } + .padding().background(.quaternary, in: RoundedRectangle(cornerRadius: 12)) + case .table(_, _, let rows): + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Table · \(rows.count - 1) rows").font(.caption).foregroundStyle(.secondary) + Spacer() + Button("Expand") { expanded = block } + } + ScrollView(.horizontal) { table(Array(rows.prefix(5))) } } + .padding().background(.quaternary, in: RoundedRectangle(cornerRadius: 12)) } } + private func renderInline(_ text: String) -> AttributedString { + (try? AttributedString( + markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) + ?? AttributedString(text) + } + private func table(_ rows: [[String]]) -> some View { Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 10) { ForEach(Array(rows.enumerated()), id: \.offset) { index, cells in diff --git a/Mirror/iOS/ProwlMirror-iOS/Features/Reading/MirrorReadingAnchor.swift b/Mirror/iOS/ProwlMirror-iOS/Features/Reading/MirrorReadingAnchor.swift new file mode 100644 index 00000000..5cc48bd6 --- /dev/null +++ b/Mirror/iOS/ProwlMirror-iOS/Features/Reading/MirrorReadingAnchor.swift @@ -0,0 +1,22 @@ +import CoreGraphics + +/// A live reading position: a document row and the distance from the top of that row to the +/// top of the visible area. When a pane switch creates the reading view again, `LazyVStack` +/// estimates the heights of the rows that it has not laid out, so an absolute scroll offset +/// does not show the same text again. +nonisolated struct MirrorReadingAnchor: Equatable { + let row: MirrorDocument.Row.ID + let offset: CGFloat +} + +extension MirrorReadingAnchor { + /// Makes the anchor for the visible top `top` from the content tops of the rows that are laid + /// out: the last row that starts at or above `top`, else the first row below it. + nonisolated init?(top: CGFloat, rowTops: [MirrorDocument.Row.ID: CGFloat]) { + let rows = rowTops.map { ($0.value, $0.key) } + guard let (rowTop, row) = rows.filter({ $0.0 <= top }).max(by: <) ?? rows.min(by: <) else { + return nil + } + self.init(row: row, offset: top - rowTop) + } +} diff --git a/Mirror/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift b/Mirror/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift index e22343f8..240f251b 100644 --- a/Mirror/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift +++ b/Mirror/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift @@ -89,7 +89,9 @@ final class MirrorSession: Identifiable { private(set) var historyTruncated = false private(set) var isLoadingHistory = false var showsHistory = false - var liveReadingOffset: CGFloat = 0 + /// The reading view writes it on every scroll and reads it only when it appears, so a change + /// must not update views. + @ObservationIgnored var liveReadingAnchor: MirrorReadingAnchor? var historyReadingOffset: CGFloat = 0 @ObservationIgnored private var historyID: UUID? @ObservationIgnored private var historyBytes = 0 @@ -242,7 +244,7 @@ final class MirrorSession: Identifiable { text = "" revision = 0 updatedAt = nil - liveReadingOffset = 0 + liveReadingAnchor = nil historyReadingOffset = 0 historyID = nil historyLines = [] diff --git a/Mirror/iOS/ProwlMirror-iOSTests/MirrorDocumentTests.swift b/Mirror/iOS/ProwlMirror-iOSTests/MirrorDocumentTests.swift index 04c45eec..952e60fb 100644 --- a/Mirror/iOS/ProwlMirror-iOSTests/MirrorDocumentTests.swift +++ b/Mirror/iOS/ProwlMirror-iOSTests/MirrorDocumentTests.swift @@ -22,4 +22,18 @@ struct MirrorDocumentTests { #expect(MirrorDocument("").blocks == [.text(0, "")]) #expect(MirrorDocument("done").blocks == [.text(0, "done")]) } + + @Test func rowsKeepShortBlocksWholeAndSplitLongTextIntoChunks() { + let long = (1...40).map { "line \($0)" }.joined(separator: "\n") + let document = MirrorDocument("intro\n```\ncode\n```\n" + long) + #expect( + document.rows.map(\.id) == [ + .init(block: 0, chunk: 0), .init(block: 1, chunk: 0), .init(block: 2, chunk: 0), + .init(block: 2, chunk: 1), + ]) + #expect(document.rows[0].chunk == nil) + #expect(document.rows[1].chunk == nil) + #expect(document.rows[2...].map { $0.chunk?.raw ?? "" }.joined() == long) + #expect(document.rows[2...].allSatisfy { $0.block == document.blocks[2] }) + } } diff --git a/Mirror/iOS/ProwlMirror-iOSTests/MirrorReadingAnchorTests.swift b/Mirror/iOS/ProwlMirror-iOSTests/MirrorReadingAnchorTests.swift new file mode 100644 index 00000000..ea3e0505 --- /dev/null +++ b/Mirror/iOS/ProwlMirror-iOSTests/MirrorReadingAnchorTests.swift @@ -0,0 +1,35 @@ +import CoreGraphics +import Testing + +@testable import ProwlMirror_iOS + +struct MirrorReadingAnchorTests { + private let rowTops: [MirrorDocument.Row.ID: CGFloat] = [ + .init(block: 0, chunk: 0): 16, .init(block: 1, chunk: 0): 120, .init(block: 1, chunk: 1): 400, + ] + + @Test func anchorIsTheLastRowThatStartsAtOrAboveTheVisibleTop() { + #expect( + MirrorReadingAnchor(top: 250, rowTops: rowTops) + == .init(row: .init(block: 1, chunk: 0), offset: 130)) + #expect( + MirrorReadingAnchor(top: 400, rowTops: rowTops) + == .init(row: .init(block: 1, chunk: 1), offset: 0)) + } + + @Test func anchorUsesTheFirstRowWhenNoRowStartsAboveTheVisibleTop() { + #expect( + MirrorReadingAnchor(top: 0, rowTops: rowTops) + == .init(row: .init(block: 0, chunk: 0), offset: -16)) + #expect(MirrorReadingAnchor(top: 0, rowTops: [:]) == nil) + } + + @Test func rowsWithTheSameTopUseTheLaterRow() { + let empty: [MirrorDocument.Row.ID: CGFloat] = [ + .init(block: 0, chunk: 0): 16, .init(block: 1, chunk: 0): 16, .init(block: 2, chunk: 0): 16, + ] + #expect( + MirrorReadingAnchor(top: 20, rowTops: empty) + == .init(row: .init(block: 2, chunk: 0), offset: 4)) + } +} diff --git a/Mirror/iOS/ProwlMirror-iOSTests/MirrorSessionTests.swift b/Mirror/iOS/ProwlMirror-iOSTests/MirrorSessionTests.swift index 0aae1344..f9cbbca5 100644 --- a/Mirror/iOS/ProwlMirror-iOSTests/MirrorSessionTests.swift +++ b/Mirror/iOS/ProwlMirror-iOSTests/MirrorSessionTests.swift @@ -92,7 +92,7 @@ struct MirrorSessionTests { .textFrame(.init(sequence: 2, text: "new live", subscriptionID: lease))) #expect(session.historyLines == ["first", "last"]) #expect(session.text == "new live") - session.liveReadingOffset = 300 + session.liveReadingAnchor = .init(row: .init(block: 0, chunk: 0), offset: 300) session.historyReadingOffset = 200 session.updateConnection( .init( @@ -100,7 +100,7 @@ struct MirrorSessionTests { pairingKey: String(repeating: "a", count: 64))) #expect(session.historyLines.isEmpty) #expect(!session.showsHistory) - #expect(session.liveReadingOffset == 0) + #expect(session.liveReadingAnchor == nil) #expect(session.historyReadingOffset == 0) } -- 2.51.2 From 5e6bbd5914c614fce0ed38ff33efac0a38e6f718 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 4 Oct 2026 01:25:02 +0900 Subject: [PATCH 02/11] Fix two iOS mirror compiler warnings - Discard the result of resignFirstResponder() explicitly. - Make the test UIKey on the main actor and inject it into the test UIPress. UIPress is not isolated to the main actor, so its key getter cannot make a UIKey. --- .../Features/Composer/MirrorComposer.swift | 2 +- .../ProwlMirror-iOSTests/MirrorComposerTests.swift | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Mirror/iOS/ProwlMirror-iOS/Features/Composer/MirrorComposer.swift b/Mirror/iOS/ProwlMirror-iOS/Features/Composer/MirrorComposer.swift index 9ad50244..e42e45f8 100644 --- a/Mirror/iOS/ProwlMirror-iOS/Features/Composer/MirrorComposer.swift +++ b/Mirror/iOS/ProwlMirror-iOS/Features/Composer/MirrorComposer.swift @@ -29,7 +29,7 @@ struct MirrorComposer: UIViewRepresentable { } view.canSubmit = session.canSubmit if !view.canSubmit { view.gesture.cancel() } - if !isEditing, view.isFirstResponder { view.resignFirstResponder() } + if !isEditing, view.isFirstResponder { _ = view.resignFirstResponder() } } func sizeThatFits(_ proposal: ProposedViewSize, uiView: ComposerTextView, context: Context) diff --git a/Mirror/iOS/ProwlMirror-iOSTests/MirrorComposerTests.swift b/Mirror/iOS/ProwlMirror-iOSTests/MirrorComposerTests.swift index 4aac713e..718bd44d 100644 --- a/Mirror/iOS/ProwlMirror-iOSTests/MirrorComposerTests.swift +++ b/Mirror/iOS/ProwlMirror-iOSTests/MirrorComposerTests.swift @@ -13,7 +13,7 @@ struct MirrorComposerTests { view.selectedRange = NSRange(location: 8, length: 0) var sends = 0 view.onSubmit = { sends += 1 } - let press = ReturnPress() + let press = ReturnPress(key: ReturnKey()) let event = KeyEvent() view.pressesBegan([press], with: event) #expect(view.text == "message\n\n") @@ -57,8 +57,15 @@ struct MirrorComposerTests { override var characters: String { "\r" } override var charactersIgnoringModifiers: String { "\r" } } + /// `UIPress` is not isolated to the main actor, but `UIKey` is. The test makes the key on the + /// main actor, and `key` only returns it. private final class ReturnPress: UIPress { - override var key: UIKey? { ReturnKey() } + private let returnKey: UIKey + init(key: UIKey) { + returnKey = key + super.init() + } + override var key: UIKey? { returnKey } } private final class KeyEvent: UIPressesEvent { var time: TimeInterval = 0 -- 2.51.2 From 0d34b600b50b1042f81dfaacc14fec3c7dc517fe Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 4 Oct 2026 01:25:03 +0900 Subject: [PATCH 03/11] Show iOS mirror test progress and support Python 3.9 - Add the test-progress heartbeat to the iOS mirror test targets and keep the raw xcodebuild log next to the result bundle. xcsift reports only at the end, so a long CI run showed no output for 20 minutes. - Upload the result bundle and the log also when the job is cancelled, for example at its timeout. - select_ios_simulator.py: evaluate annotations lazily. The Python 3.9 that macOS includes failed on `X | None`. --- .github/workflows/test.yml | 9 ++++++--- Makefile | 7 ++++++- scripts/select_ios_simulator.py | 3 +++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 306e7f23..82f2a0f8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -148,12 +148,15 @@ jobs: mise install tuist github:ldomaradzki/xcsift - name: Test iOS mirror run: make test-mirror-ios-${{ matrix.form-factor }} - - name: Upload xcresult bundle (on failure) - if: failure() + - name: Upload xcresult bundle and log (on failure) + # A job that reaches its timeout is cancelled, and its partial results are still useful. + if: failure() || cancelled() uses: actions/upload-artifact@v4 with: name: ios-mirror-${{ matrix.form-factor }}-xcresult-${{ github.run_id }}-${{ github.run_attempt }} - path: build/test-results/mirror-ios-${{ matrix.form-factor }}.xcresult + path: | + build/test-results/mirror-ios-${{ matrix.form-factor }}.xcresult + build/test-results/mirror-ios-${{ matrix.form-factor }}.xcresult.log if-no-files-found: ignore retention-days: 7 diff --git a/Makefile b/Makefile index ba118046..133e9c9b 100644 --- a/Makefile +++ b/Makefile @@ -426,6 +426,8 @@ ensure-mirror-ios-project: # Run iOS mirror tests on one simulator form factor. # $(1): result bundle name, $(2): simulator name pattern, $(3): test selection arguments +# The UI tests take minutes and xcsift reports only at the end, so a heartbeat on stderr shows +# progress, and the raw log stays next to the result bundle. define run_mirror_ios_tests @set -euo pipefail; \ result_bundle="$(CURRENT_MAKEFILE_DIR)/build/test-results/$(1).xcresult"; \ @@ -434,7 +436,10 @@ destination="$$(python3 "$(CURRENT_MAKEFILE_DIR)/scripts/select_ios_simulator.py rm -rf "$$result_bundle"; \ xcodebuild test -project "$(IOS_MIRROR_PROJECT)" -scheme "$(IOS_MIRROR_SCHEME)" \ -destination "$$destination" -resultBundlePath "$$result_bundle" -parallel-testing-enabled NO \ - $(3) 2>&1 | mise exec -- xcsift -w --format toon + $(3) 2>&1 | tee "$$result_bundle.log" \ + | tee >(PROWL_TEST_PROGRESS_LABEL=$(1) PROWL_TEST_PROGRESS_INTERVAL=5 \ + awk -f "$(CURRENT_MAKEFILE_DIR)/scripts/test-progress.awk" >&2) \ + | mise exec -- xcsift -w --format toon endef test-mirror-ios: test-mirror-ios-ipad test-mirror-ios-iphone # Run iOS mirror unit/UI tests on their matching simulator form factors diff --git a/scripts/select_ios_simulator.py b/scripts/select_ios_simulator.py index 53070469..69e5c395 100644 --- a/scripts/select_ios_simulator.py +++ b/scripts/select_ios_simulator.py @@ -5,6 +5,9 @@ Simulator model names change with each Xcode release, so the caller gives a regu expression for the full name (for example `iPhone [0-9]+ Pro`). The newest matching model on the newest runtime that the selected Xcode supports is used. """ +# macOS includes Python 3.9, which cannot evaluate `X | None` annotations at run time. +from __future__ import annotations + import json import re import subprocess -- 2.51.2 From 888b85ccad9660b492d5bb1dd49a3ead080174f8 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 4 Oct 2026 02:01:53 +0900 Subject: [PATCH 04/11] Run the iOS mirror UI tests on iPad and remove a timing race - Move the iPhone initial split-view layout decision into ContentView.initialLayout(for:) and test it with a unit test. The UI test for it could run only on iPhone and is removed. The other UI tests that ran on iPhone also pass on iPad. - The fixture Host confirmed a held scroll after 2 seconds, and on a slow runner the test checked the loading state after that. Now the fixture holds each scroll until the test posts a Darwin notification (--mirror-ui-scroll-hold-fixture). MirrorSession takes the scroll confirmation timeout as a parameter, and the fixture makes it longer. --- Mirror/iOS/ProwlMirror-iOS/ContentView.swift | 16 ++++++-- .../Features/Sessions/MirrorSession.swift | 6 ++- .../Infrastructure/MirrorUIFixture.swift | 39 +++++++++++++++---- .../MirrorInitialLayoutTests.swift | 21 ++++++++++ .../ProwlMirror_iOSUITests.swift | 29 +++++++------- 5 files changed, 84 insertions(+), 27 deletions(-) create mode 100644 Mirror/iOS/ProwlMirror-iOSTests/MirrorInitialLayoutTests.swift diff --git a/Mirror/iOS/ProwlMirror-iOS/ContentView.swift b/Mirror/iOS/ProwlMirror-iOS/ContentView.swift index 0e68cbb4..5d6f3c7e 100644 --- a/Mirror/iOS/ProwlMirror-iOS/ContentView.swift +++ b/Mirror/iOS/ProwlMirror-iOS/ContentView.swift @@ -6,13 +6,21 @@ struct ContentView: View { @State private var sessions: [MirrorSession] = [] @State private var selectedID: UUID? @State private var showsConnection = false - @State private var columns: NavigationSplitViewVisibility = - UIDevice.current.userInterfaceIdiom == .phone ? .detailOnly : .all - @State private var compactColumn: NavigationSplitViewColumn = - UIDevice.current.userInterfaceIdiom == .phone ? .detail : .sidebar + @State private var columns: NavigationSplitViewVisibility + @State private var compactColumn: NavigationSplitViewColumn @State private var wasBackgrounded = false + /// iPhone starts on the detail column with the session list hidden; larger devices show both. + static func initialLayout(for idiom: UIUserInterfaceIdiom) -> ( + columns: NavigationSplitViewVisibility, compactColumn: NavigationSplitViewColumn + ) { + idiom == .phone ? (.detailOnly, .detail) : (.all, .sidebar) + } + init() { + let layout = Self.initialLayout(for: UIDevice.current.userInterfaceIdiom) + _columns = State(initialValue: layout.columns) + _compactColumn = State(initialValue: layout.compactColumn) #if DEBUG if CommandLine.arguments.contains("--mirror-ui-fixture") { let multiple = CommandLine.arguments.contains("--mirror-ui-multiple-fixtures") diff --git a/Mirror/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift b/Mirror/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift index 240f251b..82abee95 100644 --- a/Mirror/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift +++ b/Mirror/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift @@ -50,6 +50,7 @@ final class MirrorSession: Identifiable { @ObservationIgnored private var pendingCommand: PendingCommand? @ObservationIgnored private var commandTimeout: Task? @ObservationIgnored private let clock: any Clock + @ObservationIgnored private let scrollConfirmationTimeout: Duration private struct PendingCommand { let id: UUID @@ -141,12 +142,14 @@ final class MirrorSession: Identifiable { init( configuration: MirrorSavedConnection, clock: any Clock = ContinuousClock(), + scrollConfirmationTimeout: Duration = .seconds(5), makeTransport: @escaping (MirrorSavedConnection) throws -> any MirrorTransport = { configuration in MirrorRemoteConnection(configuration: configuration) } ) { self.clock = clock + self.scrollConfirmationTimeout = scrollConfirmationTimeout self.configuration = configuration self.makeTransport = makeTransport } @@ -441,8 +444,9 @@ final class MirrorSession: Identifiable { scrollError = nil followsLatest = false let clock = clock + let timeout = scrollConfirmationTimeout scrollTimeout = Task { [weak self] in - do { try await clock.sleep(for: .seconds(5)) } catch { return } + do { try await clock.sleep(for: timeout) } catch { return } guard self?.pendingScroll?.id == request.id else { return } self?.clearScroll() self?.scrollError = String( diff --git a/Mirror/iOS/ProwlMirror-iOS/Infrastructure/MirrorUIFixture.swift b/Mirror/iOS/ProwlMirror-iOS/Infrastructure/MirrorUIFixture.swift index 99801e8b..172be77b 100644 --- a/Mirror/iOS/ProwlMirror-iOS/Infrastructure/MirrorUIFixture.swift +++ b/Mirror/iOS/ProwlMirror-iOS/Infrastructure/MirrorUIFixture.swift @@ -28,11 +28,19 @@ return try JSONDecoder().decode(MirrorJSON.self, from: Data(payload.utf8)) } + /// With `--mirror-ui-scroll-hold-fixture`, the fixture Host confirms a scroll only when the UI + /// test posts this Darwin notification, so the test can check the loading state at any speed. + static let releaseScrollNotification = "com.awhisper.ProwlMirror-iOS.ui-fixture.release-scroll" + private static var holdsScroll: Bool { + CommandLine.arguments.contains("--mirror-ui-scroll-hold-fixture") + } + static func session(name: String = "UI Fixture", longOutput: Bool = false) -> MirrorSession { let channel = Channel(name: name, longOutput: longOutput) let session = MirrorSession( configuration: .init( address: "127.0.0.1", port: 7880, pairingKey: ""), + scrollConfirmationTimeout: holdsScroll ? .seconds(120) : .seconds(5), makeTransport: { _ in channel }) session.connect() session.select(channel.pane) @@ -50,6 +58,20 @@ pane = MirrorPaneDescriptor( id: UUID(), title: "\(name) · Codex", directory: "/fixture", busy: false, projectName: name, subtitle: "Codex · main") + if MirrorUIFixture.holdsScroll { + CFNotificationCenterAddObserver( + CFNotificationCenterGetDarwinNotifyCenter(), Unmanaged.passUnretained(self).toOpaque(), + { _, observer, _, _, _ in + guard let observer else { return } + let channel = Unmanaged.fromOpaque(observer).takeUnretainedValue() + Task { @MainActor in channel.releaseHeldScrolls() } + }, + MirrorUIFixture.releaseScrollNotification as CFString, nil, .deliverImmediately) + } + } + deinit { + CFNotificationCenterRemoveEveryObserver( + CFNotificationCenterGetDarwinNotifyCenter(), Unmanaged.passUnretained(self).toOpaque()) } private let lease = UUID() private let run = UUID() @@ -57,12 +79,12 @@ text: (1...401).map { "Retained line \($0)" }.joined(separator: "\n"), truncated: true) private var sequence: UInt64 = 0 private var scrollPage = 0 - private var scrollTask: Task? + private var heldScrolls: [MirrorMessage] = [] private var includesScrollState = false func start() { onReady?() } func close(_ reason: String?) { - scrollTask?.cancel() + heldScrolls = [] onClose?(reason) } @@ -148,16 +170,19 @@ } private func scroll(_ message: MirrorMessage) { - if CommandLine.arguments.contains("--mirror-ui-scroll-delay-fixture") { - scrollTask = Task { [weak self] in - do { try await ContinuousClock().sleep(for: .seconds(2)) } catch { return } - self?.completeScroll(message) - } + if MirrorUIFixture.holdsScroll { + heldScrolls.append(message) } else { completeScroll(message) } } + private func releaseHeldScrolls() { + let scrolls = heldScrolls + heldScrolls = [] + scrolls.forEach(completeScroll) + } + private func completeScroll(_ message: MirrorMessage) { guard let id = message.scrollRequestID, let direction = message.scrollDirection else { return } scrollPage += direction == .upward ? -1 : 1 diff --git a/Mirror/iOS/ProwlMirror-iOSTests/MirrorInitialLayoutTests.swift b/Mirror/iOS/ProwlMirror-iOSTests/MirrorInitialLayoutTests.swift new file mode 100644 index 00000000..cb146531 --- /dev/null +++ b/Mirror/iOS/ProwlMirror-iOSTests/MirrorInitialLayoutTests.swift @@ -0,0 +1,21 @@ +import SwiftUI +import Testing +import UIKit + +@testable import ProwlMirror_iOS + +@MainActor +struct MirrorInitialLayoutTests { + @Test func phoneStartsOnTheDetailColumnWithTheSessionListHidden() { + let layout = ContentView.initialLayout(for: .phone) + #expect(layout.columns == .detailOnly) + #expect(layout.compactColumn == .detail) + } + + @Test(arguments: [UIUserInterfaceIdiom.pad, .mac]) + func largerDevicesShowTheSessionList(_ idiom: UIUserInterfaceIdiom) { + let layout = ContentView.initialLayout(for: idiom) + #expect(layout.columns == .all) + #expect(layout.compactColumn == .sidebar) + } +} diff --git a/Mirror/iOS/ProwlMirror-iOSUITests/ProwlMirror_iOSUITests.swift b/Mirror/iOS/ProwlMirror-iOSUITests/ProwlMirror_iOSUITests.swift index 621e3722..0ba1d166 100644 --- a/Mirror/iOS/ProwlMirror-iOSUITests/ProwlMirror_iOSUITests.swift +++ b/Mirror/iOS/ProwlMirror-iOSUITests/ProwlMirror_iOSUITests.swift @@ -121,7 +121,7 @@ final class ProwlMirror_iOSUITests: XCTestCase { func testRemoteScrollShowsLoadingAndKeepsHistoryIndependent() { let app = XCUIApplication() app.launchArguments = [ - "--mirror-ui-fixture", "--mirror-ui-scroll-fixture", "--mirror-ui-scroll-delay-fixture", + "--mirror-ui-fixture", "--mirror-ui-scroll-fixture", "--mirror-ui-scroll-hold-fixture", ] app.launch() XCUIDevice.shared.orientation = .portrait @@ -137,13 +137,17 @@ final class ProwlMirror_iOSUITests: XCTestCase { attachment.name = "Remote scroll loading" attachment.lifetime = .keepAlways add(attachment) + releaseHeldScrolls() let olderPage = app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "Remote page -1\n")).firstMatch XCTAssertTrue(olderPage.waitForExistence(timeout: 8)) XCTAssertFalse(progress.exists) scrollDown.tap() + XCTAssertTrue(progress.waitForExistence(timeout: 5)) app.buttons["History"].tap() XCTAssertTrue(app.staticTexts["Loaded lines 202–401"].waitForExistence(timeout: 5)) XCTAssertFalse(scrollUp.exists) + // Host confirms the downward scroll while History is open. + releaseHeldScrolls() app.buttons["Load Earlier 200 Lines"].tap() XCTAssertTrue(app.staticTexts["Loaded lines 2–401"].waitForExistence(timeout: 5)) app.buttons["Live Output"].tap() @@ -249,20 +253,6 @@ final class ProwlMirror_iOSUITests: XCTestCase { XCTAssertTrue(app.staticTexts["created-mirror"].waitForExistence(timeout: 5)) } - @MainActor - func testPhoneStartsInDetailAndCanAddConnection() throws { - try XCTSkipUnless(UIDevice.current.userInterfaceIdiom == .phone, "Phone navigation coverage") - let app = XCUIApplication() - app.launch() - XCUIDevice.shared.orientation = .portrait - XCTAssertTrue(app.staticTexts["Choose a Remote Pane"].waitForExistence(timeout: 10)) - XCTAssertFalse(app.staticTexts["Connect to Prowl"].isHittable) - app.buttons["Add Remote Pane"].tap() - XCTAssertTrue(app.textFields["host-address"].waitForExistence(timeout: 5)) - app.buttons["Cancel"].tap() - XCTAssertTrue(app.staticTexts["Choose a Remote Pane"].waitForExistence(timeout: 5)) - } - @MainActor func testSidebarClosesMirrorsWithOneClick() { let app = XCUIApplication() @@ -535,4 +525,13 @@ final class ProwlMirror_iOSUITests: XCTestCase { app.buttons["Cancel"].tap() XCTAssertTrue(app.buttons["Add Remote Pane"].firstMatch.waitForExistence(timeout: 5)) } + + /// Makes the fixture Host confirm the scrolls that `--mirror-ui-scroll-hold-fixture` holds. + /// The name matches `MirrorUIFixture.releaseScrollNotification`. + private func releaseHeldScrolls() { + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName("com.awhisper.ProwlMirror-iOS.ui-fixture.release-scroll" as CFString), nil, nil, + true) + } } -- 2.51.2 From 71d842cbb9c446839c23db37c40bf079c7dc774b Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 4 Oct 2026 02:01:54 +0900 Subject: [PATCH 05/11] Run the mirror clients in CI only when they change - Move the iOS and Android mirror jobs to their own workflows. They run for changes to their client (and Mirror/Shared for iOS) and for v* release tags. The Mac app workflow does not run for changes to only the mirror clients. - CI runs only the iOS mirror unit tests (make test-mirror-ios-unit). The UI suite takes minutes on a hosted runner and runs locally with make test-mirror-ios, on one iPad simulator. - Turn off simulator diagnostics collection for the mirror tests. It waited 10 minutes for the simulator after a passing run. --- .github/workflows/mirror-android.yml | 41 +++++++++++++++++ .github/workflows/mirror-ios.yml | 61 ++++++++++++++++++++++++ .github/workflows/test.yml | 69 ++++------------------------ Makefile | 40 ++++++---------- Mirror/iOS/README.md | 14 +++--- scripts/test_developer_onboarding.py | 4 +- 6 files changed, 135 insertions(+), 94 deletions(-) create mode 100644 .github/workflows/mirror-android.yml create mode 100644 .github/workflows/mirror-ios.yml diff --git a/.github/workflows/mirror-android.yml b/.github/workflows/mirror-android.yml new file mode 100644 index 00000000..bfe6d520 --- /dev/null +++ b/.github/workflows/mirror-android.yml @@ -0,0 +1,41 @@ +name: mirror-android + +# The Android mirror client needs only a minimal check, so CI runs it only when the client +# changes and for release tags (GitHub does not apply path filters to tags). +on: + push: + branches: + - main + tags: + - 'v*' + paths: + - Mirror/Android/** + - .github/workflows/mirror-android.yml + pull_request: + paths: + - Mirror/Android/** + - .github/workflows/mirror-android.yml + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + mirror-android: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: '17' + cache: gradle + - uses: android-actions/setup-android@v4 + - name: Install Android SDK 34 + run: sdkmanager 'platforms;android-34' 'build-tools;34.0.0' + - name: Build and test Android mirror + working-directory: Mirror/Android + run: ./gradlew --no-daemon :app:assembleDebug :app:testDebugUnitTest :app:lintDebug diff --git a/.github/workflows/mirror-ios.yml b/.github/workflows/mirror-ios.yml new file mode 100644 index 00000000..f7e74df5 --- /dev/null +++ b/.github/workflows/mirror-ios.yml @@ -0,0 +1,61 @@ +name: mirror-ios + +# The iOS mirror client needs only a minimal check, so CI runs its unit tests only when the +# client changes and for release tags (GitHub does not apply path filters to tags). The UI +# suite runs locally with `make test-mirror-ios`. +on: + push: + branches: + - main + tags: + - 'v*' + paths: + - Mirror/iOS/** + - Mirror/Shared/** + - scripts/select_ios_simulator.py + - .github/workflows/mirror-ios.yml + pull_request: + paths: + - Mirror/iOS/** + - Mirror/Shared/** + - scripts/select_ios_simulator.py + - .github/workflows/mirror-ios.yml + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # The project needs no submodule, GhosttyKit, CLI, or Swift package, so this job does not use + # setup-macos. It also must not save the setup-macos caches, or it would take the cache keys + # of the Mac app job. + mirror-ios: + runs-on: macos-26 + timeout-minutes: 20 + permissions: + contents: read + env: + MISE_HTTP_TIMEOUT: 120 + MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Install only the tools that the iOS mirror targets use. + MISE_EXEC_AUTO_INSTALL: false + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + steps: + - uses: actions/checkout@v6 + - name: Install Tuist and xcsift + run: | + brew install mise + mise install tuist github:ldomaradzki/xcsift + - name: Test iOS mirror + run: make test-mirror-ios-unit + - name: Upload xcresult bundle and log (on failure) + # A job that reaches its timeout is cancelled, and its partial results are still useful. + if: failure() || cancelled() + uses: actions/upload-artifact@v4 + with: + name: mirror-ios-xcresult-${{ github.run_id }}-${{ github.run_attempt }} + path: | + build/test-results/mirror-ios-unit.xcresult + build/test-results/mirror-ios-unit.xcresult.log + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 82f2a0f8..f092f403 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,10 +1,20 @@ name: test +# The Mac app. The mirror clients have their own workflows, and a change to only those +# clients does not run this one. on: push: branches: - main + paths-ignore: + - Mirror/iOS/** + - Mirror/Android/** + - .github/workflows/mirror-*.yml pull_request: + paths-ignore: + - Mirror/iOS/** + - Mirror/Android/** + - .github/workflows/mirror-*.yml concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -119,62 +129,3 @@ jobs: path: build/test-results/**/*.xcresult if-no-files-found: ignore retention-days: 7 - - # Runs in parallel with `build`, one job for each simulator form factor. The iOS mirror - # project needs no submodule, GhosttyKit, CLI, or Swift package, so this job does not use - # setup-macos. It also must not save the setup-macos caches: this job ends first and would - # take the cache keys of `build`. - ios-mirror: - name: ios-mirror (${{ matrix.form-factor }}) - runs-on: macos-26 - timeout-minutes: 30 - permissions: - contents: read - strategy: - fail-fast: false - matrix: - form-factor: [ipad, iphone] - env: - MISE_HTTP_TIMEOUT: 120 - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Install only the tools that the iOS mirror targets use. - MISE_EXEC_AUTO_INSTALL: false - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - steps: - - uses: actions/checkout@v6 - - name: Install Tuist and xcsift - run: | - brew install mise - mise install tuist github:ldomaradzki/xcsift - - name: Test iOS mirror - run: make test-mirror-ios-${{ matrix.form-factor }} - - name: Upload xcresult bundle and log (on failure) - # A job that reaches its timeout is cancelled, and its partial results are still useful. - if: failure() || cancelled() - uses: actions/upload-artifact@v4 - with: - name: ios-mirror-${{ matrix.form-factor }}-xcresult-${{ github.run_id }}-${{ github.run_attempt }} - path: | - build/test-results/mirror-ios-${{ matrix.form-factor }}.xcresult - build/test-results/mirror-ios-${{ matrix.form-factor }}.xcresult.log - if-no-files-found: ignore - retention-days: 7 - - android-mirror: - runs-on: ubuntu-latest - timeout-minutes: 20 - permissions: - contents: read - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-java@v6 - with: - distribution: temurin - java-version: '17' - cache: gradle - - uses: android-actions/setup-android@v4 - - name: Install Android SDK 34 - run: sdkmanager 'platforms;android-34' 'build-tools;34.0.0' - - name: Build and test Android mirror - working-directory: Mirror/Android - run: ./gradlew --no-daemon :app:assembleDebug :app:testDebugUnitTest :app:lintDebug diff --git a/Makefile b/Makefile index 133e9c9b..580d48c4 100644 --- a/Makefile +++ b/Makefile @@ -26,18 +26,9 @@ APP_SCHEME := Prowl TEST_TARGET := ProwlTests IOS_MIRROR_PROJECT := $(CURRENT_MAKEFILE_DIR)/Mirror/iOS/ProwlMirror-iOS.xcodeproj IOS_MIRROR_SCHEME := ProwlMirror-iOS -# Regular expressions for the full simulator name. Each Xcode release renames the models, +# Regular expression for the full simulator name. Each Xcode release renames the models, # so scripts/select_ios_simulator.py uses the newest match on the newest supported runtime. -IOS_MIRROR_IPAD_SIMULATOR_PATTERN ?= iPad Pro 11-inch \(M[0-9]+\) -IOS_MIRROR_IPHONE_SIMULATOR_PATTERN ?= iPhone [0-9]+ Pro -IOS_MIRROR_IPHONE_ONLY_TESTS := \ - ProwlMirror-iOSUITests/ProwlMirror_iOSUITests/testClearingBothPairingHalvesAllowsCredentialReconnect \ - ProwlMirror-iOSUITests/ProwlMirror_iOSUITests/testHostBoundariesDisableOnlyTheReachedDirection \ - ProwlMirror-iOSUITests/ProwlMirror_iOSUITests/testRemoteScrollAlwaysRevealsTheTopWhileInteriorDragStaysLocal \ - ProwlMirror-iOSUITests/ProwlMirror_iOSUITests/testRemoteScrollButtonsLeaveLocalGesturesAndHistoryUnchanged \ - ProwlMirror-iOSUITests/ProwlMirror_iOSUITests/testRemoteScrollShowsLoadingAndKeepsHistoryIndependent \ - ProwlMirror-iOSUITests/ProwlMirror_iOSUITests/testSelectingTextDoesNotScrollTheHost \ - ProwlMirror-iOSUITests/ProwlMirror_iOSUITests/testPhoneStartsInDetailAndCanAddConnection +IOS_MIRROR_SIMULATOR_PATTERN ?= iPad Pro 11-inch \(M[0-9]+\) XCODE_CONFIG_DIR := $(APP_DIR)/Config VERSION_XCCONFIG := $(XCODE_CONFIG_DIR)/Version.xcconfig TUIST_STAMP := $(CURRENT_MAKEFILE_DIR)/.tuist_generated_stamp @@ -100,7 +91,7 @@ TEST_SIGNING_ARGS := CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_ endif .DEFAULT_GOAL := help -.PHONY: build-ghostty-xcframework ensure-ghostty sync-ghostty _record-ghostty-hash generate ensure-project ensure-mirror-ios-project build-app build-cli build-cli-release embed-cli-debug embed-cli embed-docs embed-skills run-app install-dev-build install-release archive export-archive format format-changed format-lint lint check test test-app test-mirror-ios test-mirror-ios-ipad test-mirror-ios-iphone test-scripts test-cli-smoke test-cli-unit test-cli-integration benchmark-build bump-version log-stream agent-versions +.PHONY: build-ghostty-xcframework ensure-ghostty sync-ghostty _record-ghostty-hash generate ensure-project ensure-mirror-ios-project build-app build-cli build-cli-release embed-cli-debug embed-cli embed-docs embed-skills run-app install-dev-build install-release archive export-archive format format-changed format-lint lint check test test-app test-mirror-ios test-mirror-ios-unit test-scripts test-cli-smoke test-cli-unit test-cli-integration benchmark-build bump-version log-stream agent-versions .PHONY: test-agent-contracts _test-agent-contract-codex _test-agent-contract-export help: # Display this help. @@ -424,34 +415,31 @@ test: ensure-ghostty embed-cli-debug embed-docs embed-skills test-app ensure-mirror-ios-project: @cd "$(CURRENT_MAKEFILE_DIR)" && mise exec -- tuist generate --path Mirror/iOS --no-open -# Run iOS mirror tests on one simulator form factor. -# $(1): result bundle name, $(2): simulator name pattern, $(3): test selection arguments +# Run iOS mirror tests on the iPad simulator. +# $(1): result bundle name, $(2): test selection arguments # The UI tests take minutes and xcsift reports only at the end, so a heartbeat on stderr shows -# progress, and the raw log stays next to the result bundle. +# progress, and the raw log stays next to the result bundle. The result bundle keeps failures and +# screenshots; the simulator diagnostics collection can wait 10 minutes for a response, so it is off. define run_mirror_ios_tests @set -euo pipefail; \ result_bundle="$(CURRENT_MAKEFILE_DIR)/build/test-results/$(1).xcresult"; \ mkdir -p "$${result_bundle%/*}"; \ -destination="$$(python3 "$(CURRENT_MAKEFILE_DIR)/scripts/select_ios_simulator.py" "$(2)")"; \ +destination="$$(python3 "$(CURRENT_MAKEFILE_DIR)/scripts/select_ios_simulator.py" "$(IOS_MIRROR_SIMULATOR_PATTERN)")"; \ rm -rf "$$result_bundle"; \ xcodebuild test -project "$(IOS_MIRROR_PROJECT)" -scheme "$(IOS_MIRROR_SCHEME)" \ -destination "$$destination" -resultBundlePath "$$result_bundle" -parallel-testing-enabled NO \ - $(3) 2>&1 | tee "$$result_bundle.log" \ + -collect-test-diagnostics never \ + $(2) 2>&1 | tee "$$result_bundle.log" \ | tee >(PROWL_TEST_PROGRESS_LABEL=$(1) PROWL_TEST_PROGRESS_INTERVAL=5 \ awk -f "$(CURRENT_MAKEFILE_DIR)/scripts/test-progress.awk" >&2) \ | mise exec -- xcsift -w --format toon endef -test-mirror-ios: test-mirror-ios-ipad test-mirror-ios-iphone # Run iOS mirror unit/UI tests on their matching simulator form factors +test-mirror-ios: ensure-mirror-ios-project # Run the iOS mirror unit and UI tests + $(call run_mirror_ios_tests,mirror-ios,-only-testing:ProwlMirror-iOSTests -only-testing:ProwlMirror-iOSUITests) -test-mirror-ios-ipad: ensure-mirror-ios-project # Run the iOS mirror unit tests and the iPad UI tests - $(call run_mirror_ios_tests,mirror-ios-ipad,$(IOS_MIRROR_IPAD_SIMULATOR_PATTERN),\ - -only-testing:ProwlMirror-iOSTests -only-testing:ProwlMirror-iOSUITests \ - $(foreach test,$(IOS_MIRROR_IPHONE_ONLY_TESTS),-skip-testing:$(test))) - -test-mirror-ios-iphone: ensure-mirror-ios-project # Run the iOS mirror UI tests that need an iPhone - $(call run_mirror_ios_tests,mirror-ios-iphone,$(IOS_MIRROR_IPHONE_SIMULATOR_PATTERN),\ - $(foreach test,$(IOS_MIRROR_IPHONE_ONLY_TESTS),-only-testing:$(test))) +test-mirror-ios-unit: ensure-mirror-ios-project # Run the iOS mirror unit tests (CI runs only these) + $(call run_mirror_ios_tests,mirror-ios-unit,-only-testing:ProwlMirror-iOSTests) test-scripts: # Run tests for the repository's scripts @python3 -m unittest discover -s "$(CURRENT_MAKEFILE_DIR)/scripts" -p 'test_*.py' diff --git a/Mirror/iOS/README.md b/Mirror/iOS/README.md index 672c84a3..88daae7d 100644 --- a/Mirror/iOS/README.md +++ b/Mirror/iOS/README.md @@ -99,19 +99,19 @@ command correlation/routing, reading and keyboard behavior. The Host's real App target covers pairing, revocation, terminal capture and input protection; there is no copied Host implementation in this client's tests. -Run the iOS mirror unit and UI suites on their matching simulator form factors: +Run the iOS mirror unit and UI suites on an iPad simulator: ```bash make test-mirror-ios ``` The target generates only this project, so it does not need the Mac app's build inputs. -It runs the split-pane and composer tests on iPad (`make test-mirror-ios-ipad`), then the -remote-scroll, pairing, and phone-navigation tests on iPhone (`make test-mirror-ios-iphone`). -The UI suites take several minutes. It uses the newest iPad Pro 11-inch and iPhone Pro -simulators on the newest runtime that the selected Xcode supports. To use other models, -set `IOS_MIRROR_IPAD_SIMULATOR_PATTERN` or `IOS_MIRROR_IPHONE_SIMULATOR_PATTERN` to a -regular expression for the full simulator name. +The UI suite takes several minutes. `make test-mirror-ios-unit` runs only the unit tests. +CI runs only the unit tests, and only when a pull request changes `Mirror/iOS/` or +`Mirror/Shared/`, and for release tags. Run the UI suite locally before you merge changes to +this client. The target uses the newest iPad Pro 11-inch simulator on the newest runtime that +the selected Xcode supports. To use another model, set `IOS_MIRROR_SIMULATOR_PATTERN` to a +regular expression for the full simulator name. The UI tests also pass on iPhone. DEBUG-only `--mirror-ui-fixture` and `--mirror-ui-launch-fixture` supply deterministic UI data without connecting to Host or starting an Agent. Component and socket tests diff --git a/scripts/test_developer_onboarding.py b/scripts/test_developer_onboarding.py index 6cc4b255..a0b442f6 100644 --- a/scripts/test_developer_onboarding.py +++ b/scripts/test_developer_onboarding.py @@ -25,10 +25,10 @@ class DeveloperOnboardingTests(unittest.TestCase): self.assertIn(expected, readme) self.assertNotIn("feat/mobile-mirror", readme) - def test_ios_readme_has_a_repeatable_cross_device_test_command(self): + def test_ios_readme_has_a_repeatable_test_command(self): readme = (ROOT / "Mirror/iOS/README.md").read_text() self.assertIn("make test-mirror-ios", readme) - self.assertIn("matching simulator form factors", readme) + self.assertIn("CI runs only the unit tests", readme) def test_android_readme_accepts_android_studios_bundled_jbr(self): readme = (ROOT / "Mirror/Android/README.md").read_text() -- 2.51.2 From 6c7e07c99570e7015719ae534516f9908fdc76ba Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 4 Oct 2026 02:01:55 +0900 Subject: [PATCH 06/11] Fix the CI compilation cache and save large caches only from main - The compilation cache step cached ~/Library/Developer/Xcode/ DerivedData/CompilationCache.noindex, but with -derivedDataPath Xcode keeps the CAS in build/ci-derived-data. The step never restored or saved anything, so each miss of the App incremental cache compiled everything (about 10 minutes). Remove it. When the incremental cache misses, restore the newest App build state for the same Xcode and keep only its CAS, so that the clean build replays compiler outputs. - Restore caches with actions/cache/restore and save them at the end of the job with the new save-macos-caches action. Only main saves the large SwiftPM, CLI and App entries: pull request entries filled the 10 GB quota and evicted the entries of main. Pull requests still save the small Mise and GhosttyKit entries. --- .github/actions/save-macos-caches/action.yml | 44 +++++++++++++ .github/actions/setup-macos/action.yml | 66 ++++++++++++++------ .github/workflows/test.yml | 1 + 3 files changed, 92 insertions(+), 19 deletions(-) create mode 100644 .github/actions/save-macos-caches/action.yml diff --git a/.github/actions/save-macos-caches/action.yml b/.github/actions/save-macos-caches/action.yml new file mode 100644 index 00000000..ea38f45b --- /dev/null +++ b/.github/actions/save-macos-caches/action.yml @@ -0,0 +1,44 @@ +name: Save macOS build caches +description: Save the caches that setup-macos restored without an exact match. Run it at the end of the job, after the build input times are saved. + +# The repository has a 10 GB cache quota, and the large entries (SwiftPM, CLI and App build +# state) change with every source change. Only main saves them, so that pull requests do not +# evict the entries of main, which all pull requests restore. The small Mise and GhosttyKit +# entries change rarely, and a GhosttyKit miss is slow to rebuild, so pull requests save them. +runs: + using: composite + steps: + - if: env.PROWL_MISE_CACHE_HIT != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/.local/share/mise + ~/.cache/mise + key: ${{ env.PROWL_MISE_CACHE_KEY }} + - if: env.PROWL_GHOSTTY_CACHE_HIT != 'true' + uses: actions/cache/save@v4 + with: + path: | + App/Frameworks/GhosttyKit.xcframework + App/Resources/ghostty + App/Resources/terminfo + .ghostty_hash + .ghostty_build_stamp + key: ${{ env.PROWL_GHOSTTY_CACHE_KEY }} + - if: env.PROWL_SPM_CACHE_HIT != 'true' && github.ref == 'refs/heads/main' + uses: actions/cache/save@v4 + with: + path: ~/Library/Caches/prowl-spm-cache/SourcePackages + key: ${{ env.PROWL_SPM_CACHE_KEY }} + - if: env.PROWL_CLI_CACHE_HIT != 'true' && github.ref == 'refs/heads/main' + uses: actions/cache/save@v4 + with: + path: | + CLI/.build + Mirror/Relay/.build + key: ${{ env.PROWL_CLI_CACHE_KEY }} + - if: env.PROWL_APP_CACHE_HIT != 'true' && github.ref == 'refs/heads/main' + uses: actions/cache/save@v4 + with: + path: ${{ env.PROWL_DERIVED_DATA_PATH }} + key: ${{ env.PROWL_APP_CACHE_KEY }} diff --git a/.github/actions/setup-macos/action.yml b/.github/actions/setup-macos/action.yml index a095f577..0474118a 100644 --- a/.github/actions/setup-macos/action.yml +++ b/.github/actions/setup-macos/action.yml @@ -1,11 +1,12 @@ name: Setup macOS build deps -description: Install + cache mise tools and GhosttyKit build outputs +description: Install mise tools and restore the build caches. Run save-macos-caches at the end of the job to save them. runs: using: composite steps: - name: Mise cache - uses: actions/cache@v4 + id: mise_cache + uses: actions/cache/restore@v4 with: path: | ~/.local/share/mise @@ -31,7 +32,8 @@ runs: echo "Xcode cache identity: $XCODE_CACHE_ID" - name: CLI SwiftPM build cache - uses: actions/cache@v4 + id: cli_cache + uses: actions/cache/restore@v4 with: path: | CLI/.build @@ -53,7 +55,7 @@ runs: printf '%s\n' "GHOSTTY_SHA=$GHOSTTY_SHA" >> "$GITHUB_ENV" - name: Ghostty cache id: ghostty_cache - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: | App/Frameworks/GhosttyKit.xcframework @@ -76,26 +78,14 @@ runs: touch .ghostty_build_stamp - name: SPM cache - uses: actions/cache@v4 + id: spm_cache + uses: actions/cache/restore@v4 with: path: ~/Library/Caches/prowl-spm-cache/SourcePackages key: ${{ runner.os }}-${{ runner.arch }}-spm-v1-${{ hashFiles('.package.resolved') }} restore-keys: | ${{ runner.os }}-${{ runner.arch }}-spm-v1- - - name: Xcode compilation cache - uses: actions/cache@v4 - with: - path: ~/Library/Developer/Xcode/DerivedData/CompilationCache.noindex - # GitHub cache entries are immutable. A source-state suffix guarantees that a - # successful changed build saves newly compiled objects; restore-keys select the - # newest compatible CAS. Never restore across Xcode builds because compiler cache - # keys and storage formats are toolchain-specific. - key: ${{ runner.os }}-${{ runner.arch }}-xcode-compilation-cache-v3-${{ env.XCODE_CACHE_ID }}-${{ hashFiles('.package.resolved', 'Tuist.swift', 'Workspace.swift', 'App/Project.swift', 'App/Config/**', 'mise.toml', 'Makefile', '.github/actions/setup-macos/action.yml', '.github/workflows/test.yml') }}-${{ hashFiles('App/**/*.swift', 'Shared/Sources/**/*.swift', 'Mirror/Shared/*.swift', 'Mirror/Relay/Sources/**/*.swift') }} - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-xcode-compilation-cache-v3-${{ env.XCODE_CACHE_ID }}-${{ hashFiles('.package.resolved', 'Tuist.swift', 'Workspace.swift', 'App/Project.swift', 'App/Config/**', 'mise.toml', 'Makefile', '.github/actions/setup-macos/action.yml', '.github/workflows/test.yml') }}- - ${{ runner.os }}-${{ runner.arch }}-xcode-compilation-cache-v3-${{ env.XCODE_CACHE_ID }}- - - name: App incremental cache identity shell: bash run: | @@ -105,7 +95,8 @@ runs: APP_WORKSPACE_ID="$(pwd -P | shasum -a 256 | cut -d ' ' -f 1)" printf '%s\n' "APP_INPUT_PATHS_ID=$APP_INPUT_PATHS_ID" "APP_WORKSPACE_ID=$APP_WORKSPACE_ID" >> "$GITHUB_ENV" - name: App incremental build cache - uses: actions/cache@v4 + id: app_cache + uses: actions/cache/restore@v4 with: path: ${{ env.PROWL_DERIVED_DATA_PATH }} # Incremental state needs a stricter boundary than the content-addressed cache. @@ -113,6 +104,43 @@ runs: key: ${{ runner.os }}-${{ runner.arch }}-app-incremental-v2-${{ env.XCODE_CACHE_ID }}-${{ env.APP_WORKSPACE_ID }}-${{ env.GHOSTTY_SHA }}-${{ env.APP_INPUT_PATHS_ID }}-${{ hashFiles('CLI/Package.swift', 'CLI/Package.resolved', 'Shared/Package.swift', 'Mirror/Relay/Package.swift', '.package.resolved', 'Tuist.swift', 'Workspace.swift', 'App/Project.swift', 'App/Config/**', 'mise.toml', 'Makefile', '.github/actions/setup-macos/action.yml', '.github/workflows/test.yml', 'scripts/ci-source-mtimes.py') }}-${{ hashFiles('App/**/*.swift', 'Shared/Sources/**/*.swift', 'Mirror/Shared/*.swift', 'Mirror/Relay/Sources/**/*.swift') }} restore-keys: | ${{ runner.os }}-${{ runner.arch }}-app-incremental-v2-${{ env.XCODE_CACHE_ID }}-${{ env.APP_WORKSPACE_ID }}-${{ env.GHOSTTY_SHA }}-${{ env.APP_INPUT_PATHS_ID }}-${{ hashFiles('CLI/Package.swift', 'CLI/Package.resolved', 'Shared/Package.swift', 'Mirror/Relay/Package.swift', '.package.resolved', 'Tuist.swift', 'Workspace.swift', 'App/Project.swift', 'App/Config/**', 'mise.toml', 'Makefile', '.github/actions/setup-macos/action.yml', '.github/workflows/test.yml', 'scripts/ci-source-mtimes.py') }}- + # Xcode keeps the compilation cache (CAS) in the DerivedData folder, so the App incremental + # cache carries it. When no compatible incremental state exists (changed file roster or build + # configuration), keep only the CAS of the newest App build for this Xcode. The build is then + # clean, but it replays compiler outputs from the content-addressed cache. + - name: Compilation cache of an earlier App build + id: app_cas + if: steps.app_cache.outputs.cache-matched-key == '' + uses: actions/cache/restore@v4 + with: + path: ${{ env.PROWL_DERIVED_DATA_PATH }} + key: ${{ runner.os }}-${{ runner.arch }}-app-incremental-v2-${{ env.XCODE_CACHE_ID }}-${{ env.APP_WORKSPACE_ID }}- + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-app-incremental-v2-${{ env.XCODE_CACHE_ID }}-${{ env.APP_WORKSPACE_ID }}- + - name: Keep only the compilation cache + if: steps.app_cas.outputs.cache-matched-key != '' + shell: bash + run: | + set -euo pipefail + find "$PROWL_DERIVED_DATA_PATH" -mindepth 1 -maxdepth 1 ! -name CompilationCache.noindex -exec rm -rf {} + + du -sh "$PROWL_DERIVED_DATA_PATH/CompilationCache.noindex" 2>/dev/null || echo "No compilation cache in the restored state" - name: Restore unchanged App source times shell: bash run: python3 scripts/ci-source-mtimes.py restore --scope app --manifest "$PROWL_DERIVED_DATA_PATH/source-times.json" + + - name: Cache keys for save-macos-caches + shell: bash + run: | + set -euo pipefail + { + echo "PROWL_MISE_CACHE_KEY=${{ steps.mise_cache.outputs.cache-primary-key }}" + echo "PROWL_MISE_CACHE_HIT=${{ steps.mise_cache.outputs.cache-hit }}" + echo "PROWL_CLI_CACHE_KEY=${{ steps.cli_cache.outputs.cache-primary-key }}" + echo "PROWL_CLI_CACHE_HIT=${{ steps.cli_cache.outputs.cache-hit }}" + echo "PROWL_GHOSTTY_CACHE_KEY=${{ steps.ghostty_cache.outputs.cache-primary-key }}" + echo "PROWL_GHOSTTY_CACHE_HIT=${{ steps.ghostty_cache.outputs.cache-hit }}" + echo "PROWL_SPM_CACHE_KEY=${{ steps.spm_cache.outputs.cache-primary-key }}" + echo "PROWL_SPM_CACHE_HIT=${{ steps.spm_cache.outputs.cache-hit }}" + echo "PROWL_APP_CACHE_KEY=${{ steps.app_cache.outputs.cache-primary-key }}" + echo "PROWL_APP_CACHE_HIT=${{ steps.app_cache.outputs.cache-hit }}" + } >> "$GITHUB_ENV" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f092f403..1339abd4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -110,6 +110,7 @@ jobs: run: | python3 scripts/ci-source-mtimes.py save python3 scripts/ci-source-mtimes.py save --scope app --manifest "$PROWL_DERIVED_DATA_PATH/source-times.json" + - uses: ./.github/actions/save-macos-caches - name: Upload build and test logs if: always() uses: actions/upload-artifact@v4 -- 2.51.2 From aa418b91bec412a8e6c3c867e11f8e8d8ff4864f Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 4 Oct 2026 02:15:34 +0900 Subject: [PATCH 07/11] Show when the Mac app tests start in the CI summary After an incremental build, the first xcodebuild test pass on CI waits 1.5 to 2 minutes before its first test suite runs; later passes start in seconds. make test-app now exports each pass's test action log, and the CI summary shows when the test runner launch and the first test suite start, so that the cause of the wait can be found and watched. --- .github/workflows/test.yml | 3 ++- Makefile | 4 +++ scripts/summarize-xcode-build.py | 38 +++++++++++++++++++++++++-- scripts/test_summarize_xcode_build.py | 28 ++++++++++++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1339abd4..a38566d7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -95,7 +95,7 @@ jobs: printf '## Build and test wall time\n\n| Task | Seconds | Exit status |\n| --- | ---: | ---: |\n' cat build/ci-logs/*.timing printf '\nTasks run in parallel; their times must not be added.\n\n' - python3 scripts/summarize-xcode-build.py build/test-results/*.build.json + python3 scripts/summarize-xcode-build.py build/test-results/*.build.json build/test-results/*.action.json } >> "$GITHUB_STEP_SUMMARY" exit "$failed" - name: Collect mirror diagnostics @@ -120,6 +120,7 @@ jobs: build/ci-logs/ build/test-results/*.log build/test-results/*.build.json + build/test-results/*.action.json if-no-files-found: ignore retention-days: 7 - name: Upload xcresult bundle (on failure) diff --git a/Makefile b/Makefile index 580d48c4..3d53f032 100644 --- a/Makefile +++ b/Makefile @@ -467,6 +467,10 @@ test-app: ensure-ghostty ensure-project # Run app/unit tests via xcodebuild xcrun xcresulttool get log --path "$$result_bundle" --type build --compact > "$$result_bundle.build.json" \ || echo "warning: could not export Xcode build metrics" >&2; \ fi; \ + if [ -d "$$result_bundle" ]; then \ + xcrun xcresulttool get log --path "$$result_bundle" --type action --compact > "$$result_bundle.action.json" \ + || echo "warning: could not export the Xcode test action log" >&2; \ + fi; \ if [ "$$xcodebuild_status" -ne 0 ]; then \ bash "$(CURRENT_MAKEFILE_DIR)/scripts/print-xcresult-failures.sh" "$$result_bundle" || true; \ return "$$xcodebuild_status"; \ diff --git a/scripts/summarize-xcode-build.py b/scripts/summarize-xcode-build.py index 6f9643cb..2eb64e26 100644 --- a/scripts/summarize-xcode-build.py +++ b/scripts/summarize-xcode-build.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Summarize xcresulttool build logs without adding overlapping task times.""" +"""Summarize xcresulttool build logs without adding overlapping task times, and test action logs.""" import json from pathlib import Path @@ -26,11 +26,45 @@ def summarize(log, name): return "\n".join(lines) + "\n" +def sections(node): + for child in node.get("subsections", []): + yield child + yield from sections(child) + + +def summarize_test_launch(log, name): + """Show when the test runner launch and the first test suite start in a test action log. + + A large gap between them is time before any test runs (for example a slow first launch of + the test host), which the build log and the test results do not show. + """ + start = log.get("startTime", 0) + + def first(prefix): + times = [ + section["startTime"] for section in sections(log) + if section.get("title", "").startswith(prefix) and "startTime" in section + ] + return min(times) - start if times else None + + lines = [f"### {name}", "", f"Test action: **{log.get('duration', 0):.1f} s**", ""] + for label, offset in ( + ("Test runner launch", first("Launching ")), + ("First test suite", first("Run test suite ")), + ): + lines.append(f"- {label}: not recorded" if offset is None else f"- {label} starts at **{offset:.1f} s**") + return "\n".join(lines) + "\n" + + if __name__ == "__main__": for argument in sys.argv[1:]: path = Path(argument) try: - print(summarize(json.loads(path.read_text()), path.name)) + log = json.loads(path.read_text()) + if path.name.endswith(".action.json"): + print(summarize_test_launch(log, path.name)) + else: + print(summarize(log, path.name)) except (OSError, ValueError, KeyError, TypeError) as error: # Build diagnostics must not replace the build or test exit status. print(f"Build metrics unavailable for {path.name}: {error}", file=sys.stderr) diff --git a/scripts/test_summarize_xcode_build.py b/scripts/test_summarize_xcode_build.py index 11cc5abc..014e807f 100644 --- a/scripts/test_summarize_xcode_build.py +++ b/scripts/test_summarize_xcode_build.py @@ -38,6 +38,34 @@ class BuildSummaryTests(unittest.TestCase): self.assertIn("Cache counters unavailable", result) self.assertNotIn("0%", result) + def test_reports_when_the_test_runner_and_the_first_suite_start(self): + log = { + "startTime": 100, + "duration": 200, + "subsections": [ + {"title": "Launch actions", "startTime": 100, "subsections": [ + {"title": "Launch AppTests", "startTime": 140, "subsections": [ + {"title": "Launching AppTests", "startTime": 140}, + ]}, + ]}, + {"title": "Test target AppTests", "startTime": 140, "subsections": [ + {"title": "App (42)", "startTime": 140, "subsections": [ + {"title": "Run test suite B", "startTime": 231}, + {"title": "Run test suite A", "startTime": 230}, + ]}, + ]}, + ], + } + result = module.summarize_test_launch(log, "test.action.json") + self.assertIn("Test action: **200.0 s**", result) + self.assertIn("Test runner launch starts at **40.0 s**", result) + self.assertIn("First test suite starts at **130.0 s**", result) + + def test_missing_launch_sections_remain_unknown(self): + result = module.summarize_test_launch({"startTime": 0, "duration": 5}, "test.action.json") + self.assertIn("Test runner launch: not recorded", result) + self.assertIn("First test suite: not recorded", result) + if __name__ == "__main__": unittest.main() -- 2.51.2 From c196d9b2df102bb22d4a87d29bdb65a536e456b1 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 4 Oct 2026 02:26:28 +0900 Subject: [PATCH 08/11] Run lint and the localization check beside the CI build They need no build, so they run in the parallel step with the script tests (the new checks task) instead of before the build. --- .github/workflows/test.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a38566d7..27277327 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,8 +38,6 @@ jobs: with: submodules: recursive - uses: ./.github/actions/setup-macos - - run: make lint - - run: make check-localization - name: Stage debug app resources run: make embed-cli-debug embed-docs embed-skills - name: Generate workspace @@ -84,13 +82,14 @@ jobs: # SwiftPM serializes access to .build; keep its checks in one branch. run_task cli-tests make test-cli-smoke test-cli-unit test-cli-integration & pid_cli=$! - run_task scripts make test-scripts & - pid_scripts=$! + # Lint and the localization check need no build, so they run beside it. + run_task checks make lint check-localization test-scripts & + pid_checks=$! failed=0 wait "$pid_app" || failed=1 wait "$pid_cli" || failed=1 - wait "$pid_scripts" || failed=1 + wait "$pid_checks" || failed=1 { printf '## Build and test wall time\n\n| Task | Seconds | Exit status |\n| --- | ---: | ---: |\n' cat build/ci-logs/*.timing -- 2.51.2 From e3b29191b551b277325bb221ab3e695be635b673 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 4 Oct 2026 02:26:30 +0900 Subject: [PATCH 09/11] Record the CI cache fixes in docs-ai 016 Add 016.008: the dead CAS cache path, the CAS fallback, the cache save scope, the mirror client workflows and the measured job times. Correct the CAS note in 016.007. The test action log shows that the apparent wait before the first test suite was output buffering. --- .../016-dev-build-and-ci-workflow/000-plan.md | 4 + ...7-app-incremental-and-module-boundaries.md | 4 +- .../008-cas-fallback-and-cache-scope.md | 79 +++++++++++++++++++ docs-ai/README.md | 2 +- 4 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 docs-ai/016-dev-build-and-ci-workflow/008-cas-fallback-and-cache-scope.md diff --git a/docs-ai/016-dev-build-and-ci-workflow/000-plan.md b/docs-ai/016-dev-build-and-ci-workflow/000-plan.md index bd274dea..2e0c4728 100644 --- a/docs-ai/016-dev-build-and-ci-workflow/000-plan.md +++ b/docs-ai/016-dev-build-and-ci-workflow/000-plan.md @@ -98,3 +98,7 @@ Anchor wave, all landed 2026-04-04/05: [006-ci-source-cache-reuse.md](006-ci-source-cache-reuse.md) - Updated 2026-09-06: App incremental-state reuse and measured module extraction — see [007-app-incremental-and-module-boundaries.md](007-app-incremental-and-module-boundaries.md) +- Updated 2026-10-04: the CAS cache step used a path that never existed after 007; restore the + CAS from the newest App incremental entry on a miss, save large caches only from main, and + run the mirror client jobs only when they change (corrects 007's CAS note) — see + [008-cas-fallback-and-cache-scope.md](008-cas-fallback-and-cache-scope.md) diff --git a/docs-ai/016-dev-build-and-ci-workflow/007-app-incremental-and-module-boundaries.md b/docs-ai/016-dev-build-and-ci-workflow/007-app-incremental-and-module-boundaries.md index 36b2827c..bf00fef6 100644 --- a/docs-ai/016-dev-build-and-ci-workflow/007-app-incremental-and-module-boundaries.md +++ b/docs-ai/016-dev-build-and-ci-workflow/007-app-incremental-and-module-boundaries.md @@ -36,7 +36,9 @@ App state is cached at a fixed CI DerivedData path. Its restore prefix includes Xcode build, Ghostty revision, project/package/build configuration and tracked input paths. Added/deleted source or resource paths therefore start fresh instead of retaining stale products. CLI-owned Shared timestamps are excluded from App timestamp restoration. Content -changes retain normal compiler/resource invalidation; the existing CAS remains available. +changes retain normal compiler/resource invalidation. (Correction, 2026-10-04: the separate +CAS cache step did not remain available. With the fixed DerivedData path the CAS moved into it, +so that step cached a path that never existed; see [008](008-cas-fallback-and-cache-scope.md).) ## Verification diff --git a/docs-ai/016-dev-build-and-ci-workflow/008-cas-fallback-and-cache-scope.md b/docs-ai/016-dev-build-and-ci-workflow/008-cas-fallback-and-cache-scope.md new file mode 100644 index 00000000..692c5ed4 --- /dev/null +++ b/docs-ai/016-dev-build-and-ci-workflow/008-cas-fallback-and-cache-scope.md @@ -0,0 +1,79 @@ +# 016.008 — CAS Fallback, Cache Save Scope and Mirror Jobs + +## Context + +In #851, a pull request that changed the Makefile and the test workflow spent about 10 minutes +compiling the App (2,000+ compile steps) in the `build` job; the same job with a restored App +incremental cache compiles in under 2 minutes. Three problems caused this or made it likely: + +- **The CAS cache was never used.** The `xcode-compilation-cache` step from + [005](005-build-test-time-optimization.md) cached + `~/Library/Developer/Xcode/DerivedData/CompilationCache.noindex`. Since + [007](007-app-incremental-and-module-boundaries.md) the App builds with + `-derivedDataPath build/ci-derived-data`, and Xcode then keeps the CAS at + `/CompilationCache.noindex` (`COMPILATION_CACHE_CAS_PATH`). The cached + path never existed: every run logged "Cache not found", and no run saved an entry. A miss of + the strict App incremental key (changed build configuration, Makefile, workflow or tracked + file roster) therefore built without any compiler cache. +- **Pull requests evicted the entries of main.** Every pull request run that changed sources + saved a new App incremental entry (about 930 MB) and CLI entry (about 670 MB). The repository + used 10.7 GB of its 10 GB quota, and least-recently-used eviction removes the main entries + that every pull request restores. Saving also added up to 53 s to pull request runs. +- **Mirror client jobs ran for every change.** The iOS mirror UI suite took 11–20 minutes on + hosted runners for each pull request, including pull requests that changed only the Mac app. + +## Change + +- `.github/actions/setup-macos/action.yml` restores caches with `actions/cache/restore`. + The dead CAS step is removed. When the strict App incremental key finds nothing, the action + restores the newest App incremental entry for the same Xcode build and workspace, and keeps + only its `CompilationCache.noindex`. The build is clean (no stale products from a changed + file roster or configuration) but replays compiler outputs from the content-addressed cache. + This reuses existing entries and needs no more cache space. +- `.github/actions/save-macos-caches/action.yml` saves the caches at the end of the job, after + the build input times. Only `main` saves the SwiftPM, CLI and App entries. Pull requests + still save the Mise and GhosttyKit entries, which are small and change rarely, because a + GhosttyKit miss is slow to rebuild. +- The mirror clients have their own workflows, `.github/workflows/mirror-ios.yml` and + `.github/workflows/mirror-android.yml`. They run for changes to their client (and + `Mirror/Shared/` for iOS) and for `v*` tags; GitHub does not apply path filters to tags. + CI runs only the iOS mirror unit tests (`make test-mirror-ios-unit`); the UI suite runs + locally with `make test-mirror-ios`. The Mac app workflow ignores changes to only the + mirror clients. +- `make test-app` exports each pass's test action log (`*.action.json`), and the CI summary + shows when the test runner launch and the first test suite start. +- `make lint` and `make check-localization` run in the parallel step beside the build, in the + `checks` task with the script tests, not as steps before it. + +## Test start time + +The test-progress heartbeat suggested that the first `xcodebuild test` pass waited 1.5–2 +minutes after the build before its first suite. The test action log shows that this is +output buffering: xcodebuild writes test results to the pipe in bursts. In the measured run +the build ended at 96 s, the test runner launched at 98.8 s and the first suite started at +105.3 s; the tests then ran for about 85 s. Read test timing from the action-log summary, not +from heartbeat timestamps. + +## Measurements (#851, hosted `macos-26` runner) + +Both runs missed the strict App incremental key because the pull request changes the +Makefile and the workflows. + +| Run | Compile | "Build app and run tests" step | `build` job | +| --- | ---: | ---: | ---: | +| Before (no CAS) | about 10 min | 782 s | 16 min 28 s | +| After (CAS from the newest main entry, 1.3 GB) | about 2 min 20 s | 322 s | 8 min 46 s | +| After, second run (516 Swift cache hits, 30 misses) | 96 s build | 267 s | 7 min 33 s | + +Hosted runner speed varies about 2x between runs, so these are single samples, not medians. +In the second run, restoring caches took 98 s (SwiftPM 34 s, App state 25 s, CLI 15 s), the +main test pass about 90 s, and the three isolated `test-without-building` passes about 60 s. + +## Open questions + +- The SwiftPM cache (1.46 GB, mostly repository clones and the Sentry binary artifact) is the + slowest restore. Removing the clones may make SwiftPM fetch them again; not measured. + +## Refs + +PR #851. diff --git a/docs-ai/README.md b/docs-ai/README.md index 11aca663..d76d41f1 100644 --- a/docs-ai/README.md +++ b/docs-ai/README.md @@ -98,7 +98,7 @@ keep the old paths. Use this table to find a file that an old entry names: | 013 | [prowl-cli](013-prowl-cli/000-plan.md) | 2026-03-30 | Contract-first `prowl` CLI: socket service, v1 commands, hardening, agents | | 014 | [terminal-layout-persistence](014-terminal-layout-persistence/000-plan.md) | 2026-03-31 | Layout snapshot save/restore; font-size persistence; launch races | | 015 | [repositories-feature-refactor](015-repositories-feature-refactor/000-plan.md) | 2026-04-03 | TCA decomposition of RepositoriesFeature and later code-health splits | -| 016 | [dev-build-and-ci-workflow](016-dev-build-and-ci-workflow/000-plan.md) | 2026-04-04 | Build/test tooling, CI parallelism, Debug identity, incremental caching, Shared module boundary | +| 016 | [dev-build-and-ci-workflow](016-dev-build-and-ci-workflow/000-plan.md) | 2026-04-04 | Build/test tooling, CI parallelism, Debug identity, incremental caching, Shared module boundary, CAS fallback and cache save scope | | 017 | [upstream-sync-process](017-upstream-sync-process/000-plan.md) | 2026-04-08 | Upstream review discipline, baselines, batch decisions | | 018 | [archived-worktrees](018-archived-worktrees/000-plan.md) | 2026-04-09 | Archived worktree discoverability and auto-delete | | 019 | [worktree-creation-and-lifecycle](019-worktree-creation-and-lifecycle/000-plan.md) | 2026-04-12 | Creation/merge flows, safe deletion, Add-to-Prowl redesign | -- 2.51.2 From ec0dc683a79f701e55ff6b0c3e8ffb9e5490d3ae Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 4 Oct 2026 09:22:09 +0900 Subject: [PATCH 10/11] Run the mirror checks on the release event, not on tag pushes Release tags are lightweight. git push --follow-tags does not push them, and gh release create makes them on GitHub, which sends no tag push event. The v2026.9.29 release shows only the main push and the release event. --- .github/workflows/mirror-android.yml | 9 ++++++--- .github/workflows/mirror-ios.yml | 11 +++++++---- Mirror/iOS/README.md | 2 +- .../008-cas-fallback-and-cache-scope.md | 7 +++++-- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/.github/workflows/mirror-android.yml b/.github/workflows/mirror-android.yml index bfe6d520..2d88f909 100644 --- a/.github/workflows/mirror-android.yml +++ b/.github/workflows/mirror-android.yml @@ -1,13 +1,11 @@ name: mirror-android # The Android mirror client needs only a minimal check, so CI runs it only when the client -# changes and for release tags (GitHub does not apply path filters to tags). +# changes and for each published release. on: push: branches: - main - tags: - - 'v*' paths: - Mirror/Android/** - .github/workflows/mirror-android.yml @@ -15,6 +13,11 @@ on: paths: - Mirror/Android/** - .github/workflows/mirror-android.yml + # Release tags are lightweight, so `git push --follow-tags` does not push them and + # `gh release create` makes them on GitHub. That sends no tag push event. + release: + types: + - published concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/mirror-ios.yml b/.github/workflows/mirror-ios.yml index f7e74df5..08e6d2e0 100644 --- a/.github/workflows/mirror-ios.yml +++ b/.github/workflows/mirror-ios.yml @@ -1,14 +1,12 @@ name: mirror-ios # The iOS mirror client needs only a minimal check, so CI runs its unit tests only when the -# client changes and for release tags (GitHub does not apply path filters to tags). The UI -# suite runs locally with `make test-mirror-ios`. +# client changes and for each published release. The UI suite runs locally with +# `make test-mirror-ios`. on: push: branches: - main - tags: - - 'v*' paths: - Mirror/iOS/** - Mirror/Shared/** @@ -20,6 +18,11 @@ on: - Mirror/Shared/** - scripts/select_ios_simulator.py - .github/workflows/mirror-ios.yml + # Release tags are lightweight, so `git push --follow-tags` does not push them and + # `gh release create` makes them on GitHub. That sends no tag push event. + release: + types: + - published concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/Mirror/iOS/README.md b/Mirror/iOS/README.md index 88daae7d..1a563922 100644 --- a/Mirror/iOS/README.md +++ b/Mirror/iOS/README.md @@ -108,7 +108,7 @@ make test-mirror-ios The target generates only this project, so it does not need the Mac app's build inputs. The UI suite takes several minutes. `make test-mirror-ios-unit` runs only the unit tests. CI runs only the unit tests, and only when a pull request changes `Mirror/iOS/` or -`Mirror/Shared/`, and for release tags. Run the UI suite locally before you merge changes to +`Mirror/Shared/`, and for each published release. Run the UI suite locally before you merge changes to this client. The target uses the newest iPad Pro 11-inch simulator on the newest runtime that the selected Xcode supports. To use another model, set `IOS_MIRROR_SIMULATOR_PATTERN` to a regular expression for the full simulator name. The UI tests also pass on iPhone. diff --git a/docs-ai/016-dev-build-and-ci-workflow/008-cas-fallback-and-cache-scope.md b/docs-ai/016-dev-build-and-ci-workflow/008-cas-fallback-and-cache-scope.md index 692c5ed4..ad20c14b 100644 --- a/docs-ai/016-dev-build-and-ci-workflow/008-cas-fallback-and-cache-scope.md +++ b/docs-ai/016-dev-build-and-ci-workflow/008-cas-fallback-and-cache-scope.md @@ -36,8 +36,11 @@ incremental cache compiles in under 2 minutes. Three problems caused this or mad GhosttyKit miss is slow to rebuild. - The mirror clients have their own workflows, `.github/workflows/mirror-ios.yml` and `.github/workflows/mirror-android.yml`. They run for changes to their client (and - `Mirror/Shared/` for iOS) and for `v*` tags; GitHub does not apply path filters to tags. - CI runs only the iOS mirror unit tests (`make test-mirror-ios-unit`); the UI suite runs + `Mirror/Shared/` for iOS) and for each published release. They use the `release` event, + not a `v*` tag push: `scripts/release.sh` makes lightweight tags, which + `git push --follow-tags` does not push, and `gh release create` then makes the tag on + GitHub. The v2026.9.29 release recorded no tag push event. The release check runs after + publication, so it reports problems but does not block the Mac app release. CI runs only the iOS mirror unit tests (`make test-mirror-ios-unit`); the UI suite runs locally with `make test-mirror-ios`. The Mac app workflow ignores changes to only the mirror clients. - `make test-app` exports each pass's test action log (`*.action.json`), and the CI summary -- 2.51.2 From ef8aa826e8853deaee44e8dba90eb38f97e463b5 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 4 Oct 2026 10:27:15 +0900 Subject: [PATCH 11/11] Add make test-all and make test-mirror-android make test runs only the Mac app tests, but AGENTS.md described it as all tests. test-all runs the script, CLI, Mac app, iOS mirror (unit and UI) and Android mirror suites one after another, so that make -j cannot start two xcodebuild runs in the same checkout. --- AGENTS.md | 3 ++- Makefile | 16 +++++++++++++++- Mirror/Android/README.md | 2 ++ README.md | 1 + 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b52670c3..3fb42fe8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,8 @@ make format # Run full-tree swift-format cleanup make lint # Run swiftlint only make check # Run changed-file format, swift-format lint, swiftlint, and the string catalog check make audit-localization # Release check of the string catalog and unlocalized UI copy (see the sync-l10n skill) -make test # Run all tests +make test # Run the Mac app tests +make test-all # Run every test suite: scripts, CLI, Mac app, iOS mirror (unit and UI), Android mirror make benchmark-build # Benchmark CI-like clean/warm-CAS build and test time make bench # Run performance benchmarks with -O; append absolute medians to ~/Library/Logs/Prowl/measurements/bench/ make measure-cpu # Steady-state CPU + per-symbol attribution of the running Prowl Debug app diff --git a/Makefile b/Makefile index 3d53f032..b11c084d 100644 --- a/Makefile +++ b/Makefile @@ -91,7 +91,7 @@ TEST_SIGNING_ARGS := CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_ endif .DEFAULT_GOAL := help -.PHONY: build-ghostty-xcframework ensure-ghostty sync-ghostty _record-ghostty-hash generate ensure-project ensure-mirror-ios-project build-app build-cli build-cli-release embed-cli-debug embed-cli embed-docs embed-skills run-app install-dev-build install-release archive export-archive format format-changed format-lint lint check test test-app test-mirror-ios test-mirror-ios-unit test-scripts test-cli-smoke test-cli-unit test-cli-integration benchmark-build bump-version log-stream agent-versions +.PHONY: build-ghostty-xcframework ensure-ghostty sync-ghostty _record-ghostty-hash generate ensure-project ensure-mirror-ios-project build-app build-cli build-cli-release embed-cli-debug embed-cli embed-docs embed-skills run-app install-dev-build install-release archive export-archive format format-changed format-lint lint check test test-all test-app test-mirror-ios test-mirror-ios-unit test-mirror-android test-scripts test-cli-smoke test-cli-unit test-cli-integration benchmark-build bump-version log-stream agent-versions .PHONY: test-agent-contracts _test-agent-contract-codex _test-agent-contract-export help: # Display this help. @@ -410,6 +410,17 @@ export-archive: # Export xarchive test: ensure-ghostty embed-cli-debug embed-docs embed-skills test-app +# Recipe lines, not prerequisites, so that `make -j` cannot start two xcodebuild runs: a second +# `xcodebuild test` in the same checkout can hang. The quick suites run first. +test-all: # Run every test suite: scripts, CLI, Mac app, iOS mirror (unit and UI), Android mirror + $(MAKE) test-scripts + $(MAKE) test-cli-unit + $(MAKE) test-cli-smoke + $(MAKE) test-cli-integration + $(MAKE) test + $(MAKE) test-mirror-ios + $(MAKE) test-mirror-android + # Internal: generate only the iOS mirror project. It has no package and does not need the # Mac app's build inputs (GhosttyKit, CLI, bundled resources), so the CI job can skip them. ensure-mirror-ios-project: @@ -441,6 +452,9 @@ test-mirror-ios: ensure-mirror-ios-project # Run the iOS mirror unit and UI test test-mirror-ios-unit: ensure-mirror-ios-project # Run the iOS mirror unit tests (CI runs only these) $(call run_mirror_ios_tests,mirror-ios-unit,-only-testing:ProwlMirror-iOSTests) +test-mirror-android: # Build, unit test and lint the Android mirror (needs JDK 17 and Android SDK 34) + cd "$(CURRENT_MAKEFILE_DIR)/Mirror/Android" && ./gradlew :app:assembleDebug :app:testDebugUnitTest :app:lintDebug + test-scripts: # Run tests for the repository's scripts @python3 -m unittest discover -s "$(CURRENT_MAKEFILE_DIR)/scripts" -p 'test_*.py' @bash "$(CURRENT_MAKEFILE_DIR)/scripts/test-performance-measurement-scripts.sh" diff --git a/Mirror/Android/README.md b/Mirror/Android/README.md index b7a846b8..855a5e79 100644 --- a/Mirror/Android/README.md +++ b/Mirror/Android/README.md @@ -64,6 +64,8 @@ Android Studio's bundled JBR or JDK 17 with an installed Android SDK 34. Set ./gradlew :app:assembleDebug :app:testDebugUnitTest :app:lintDebug +From the repository root, `make test-mirror-android` runs the same tasks. + APK: `app/build/outputs/apk/debug/app-debug.apk`. With an available emulator/device: diff --git a/README.md b/README.md index e3f7d585..17113188 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ make format-changed # Format changed Swift files only make format # Full-tree Swift format cleanup make lint # SwiftLint only make test # Run app/unit tests +make test-all # Run every test suite, including the CLI and both mirror clients make log-stream # Stream app logs (subsystem: com.onevcat.prowl) ```