diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index d6e8d9b..e206a2e 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -8,6 +8,26 @@ All notable PocketRoot changes are recorded here. Semantic Versioning begins wit ### Changed +- Host App Files UI smoke now waits for the expected rename, delete, or share + action to enter the accessibility tree after pressing a file or directory. + If the first synthesized press does not open the menu, it retries once with + newly validated App and entry frames, then fails closed with frame evidence + instead of tapping a context-menu button that does not yet exist. File and + folder creation also dismisses system keyboard onboarding before submitting + a freshly validated Create frame at most twice, then stops the test if the + expected entry never appears instead of cascading into missing-disclosure + failures. Integrated-workspace verification now writes per-run unique guest + contents and treats their exact file preview as authoritative when terminal + accessibility text lags, and the document-picker flow rechecks whether + Browse already restored the Host destination before interacting with a + disappearing local-location cell. Host and Quick Start keyboard cleanup now + taps validated snapshot frames and gives onboarding UI a bounded dismissal + window instead of targeting elements that disappear during XCTest + interruption handling. The shared UI runner also recognizes an XCTest + Accessibility-load timeout before any test method runs as Simulator + infrastructure failure, restarting only its own temporary Simulator for one + bounded retry; caller-owned devices, ordinary assertions, and a second + failure still fail closed immediately. - Tightened the untagged `v0.2.0` source-candidate audit: `--allow-source-blocked` accepts only final source-release authorization as unsatisfied, and fails on any pinned-gate-set, NOTICE, license, public API diff --git a/CHANGELOG.md b/CHANGELOG.md index e889b7c..33b0c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ PocketRoot 的重要变化记录在这里,并从首个公开版本开始遵循 ### Changed +- Host App 文件 UI smoke 现在会在长按文件或目录后,有界等待预期的重命名、删除或 + 分享菜单动作进入 accessibility tree;若首次合成长按未打开菜单,会使用重新校验的 + App/条目 frame 再试一次,仍失败则带 frame 证据 fail-closed,避免直接点击尚不存在 + 的上下文菜单按钮造成间歇性 XCTest 失败。创建文件/目录前也会关闭系统键盘 onboarding, + 并在重新校验的 Create frame 上最多提交两次;目标条目未出现时立即停止当前用例, + 不再继续查询不存在的 disclosure 并产生级联误报。集成 Workspace 每次写入唯一 guest + 内容,在终端 accessibility 文本滞后时改用该内容的精确预览作为权威结果;系统文件 + 选择器也会在 Browse 后先复核是否已经恢复到 Host 目录,再决定是否操作正在消失的 + 本地位置条目。Host 与 Quick Start 的键盘清理会点击已校验的快照 frame,并给 + onboarding UI 一个有界退场窗口,避免 XCTest interruption handling 期间直接操作 + 正在消失的元素。通用 UI runner 也会把 XCTest 在测试方法执行前报告的 + Accessibility 加载超时识别为模拟器基础设施故障,仅对自己创建的临时 Simulator + 重启并有界重试一次;调用方提供的设备、普通测试断言和第二次失败仍立即 fail-closed。 - 收紧 `v0.2.0` 未打 tag 源码候选审计:`--allow-source-blocked` 只接受最终源码发布 授权这一项尚未满足,固定门禁集合、NOTICE、许可证、公开 API 状态或源码边界漂移 都会失败;CI 和受信 tag 工作流只上传带 commit、archive SHA-256、文件统计和精确 diff --git a/Compliance/Release/experimental-v0.2.0/COMPOSITION.json b/Compliance/Release/experimental-v0.2.0/COMPOSITION.json index 1f6c41c..b5e7b59 100644 --- a/Compliance/Release/experimental-v0.2.0/COMPOSITION.json +++ b/Compliance/Release/experimental-v0.2.0/COMPOSITION.json @@ -24,7 +24,7 @@ "Scripts/inject-demo-rootfs.sh": "3982b5382b0d1e13e0c8e8a5bb5404c5bad1dfc4d6e9cd23a39e3395a83087bb", "Scripts/scan-release-artifact.rb": "48f27ab280491864228393e1675fe3a2889cbd616f79e3cb16bae7efedf647c0", "Scripts/run-host-app-ui-smoke.sh": "c8b11b1bf3a467fd10ed2c4124a72396b0a803e98f155d1d709fab46b59eede1", - "Scripts/run-ios-example-ui-smoke.sh": "147fa58dc255e28fd63b0a7c62ed747b2215df652df3a0bdf5585eee4b49d395", + "Scripts/run-ios-example-ui-smoke.sh": "921d7e5cfd5853d6653997e06c7f769d5076c1becd798c6e6a418f0d79538f12", "Scripts/run-quick-start-ui-smoke.sh": "acf26d68fc37250e24911c2d23791a0d12ff06a624dedcefee0ed7905bc31af2", "Scripts/run-external-consumer-ui-smoke.sh": "d9544ede3ecbad8d42a114015fa77071d31aa84aafc1cbe0f0f001453b1a4d0d", "Scripts/run-host-app-device-ui-smoke.sh": "2f934265cb46145b27bb26e0d7e84acebc14290602e23cd1ab8f222aef68ed1f", @@ -120,10 +120,10 @@ "Examples/PocketRootDemo/Sources/PocketRootDemo/Shared/UI/PlaceholderViewController.swift": "48e0f6462228a66face2aef51f9bd39ec02923c4d1c01b6b34f920948998f2b8", "Examples/PocketRootHostApp/Sources/HostApp.swift": "0c36bd3104ddc5bebb9e23675a44a92e3ce4aec2c1461b3e182d86d1a75159c9", "Examples/PocketRootHostApp/Sources/Info.plist": "711b38923f11fdb4ceb7d28cb90a51674afec0a64786a20f9bd68e3f406f8d26", - "Examples/PocketRootHostApp/UITests/PocketRootHostAppUITests.swift": "cfa59f0489f94b14b242ffa1aa9f75e0eeda99c74ca487318e0ea096bbef8d61", + "Examples/PocketRootHostApp/UITests/PocketRootHostAppUITests.swift": "d925256c6207a0ef676c260fc023a41d86df5e5c8d494957b0102c2266a3a228", "Examples/PocketRootQuickStartApp/Sources/Info.plist": "b3c0c96e89db99c72c9f020590b1c5cdc0c927fd3af4b6ae7865abfdc81049b8", "Examples/PocketRootQuickStartApp/Sources/QuickStartApp.swift": "1c77c9229281d0d1f9fbe3678bcc9995fff8c7d2189f4c6152daaa2999683fb6", - "Examples/PocketRootQuickStartApp/UITests/PocketRootQuickStartAppUITests.swift": "828471c84386dc8ddba23ecadc3618ccd559d92084ab762f4ef0832c3e3d5f59", + "Examples/PocketRootQuickStartApp/UITests/PocketRootQuickStartAppUITests.swift": "9d1c2a16adbd6918ea86d36808bfe360047ecb222d38afca2efd8db2d2eaf373", "Tests/Integration/ExternalConsumerApp/Sources/ExternalConsumerApp.swift": "580fa504ff16695c53d1d40a41f1a8531da21f961906a394394a0649c40ce11b", "Tests/Integration/ExternalConsumerApp/UITests/ExternalConsumerAppUITests.swift": "0fa46c32d482c20d5bc4cf0aae93c6364060cf7c6b7cdca5ab99fe8400da7ab7", "Spikes/PocketRootIshRuntimeCompileSpike/PocketRootIshRuntimeCompileSpike.swift": "829a9ef79d67cb93aae5fbea32561c5c5497a7a017922bb1ebb344b4c0c32da8", diff --git a/Compliance/Release/experimental-v0.2.0/SBOM.spdx.json b/Compliance/Release/experimental-v0.2.0/SBOM.spdx.json index e95a18f..bf3867e 100644 --- a/Compliance/Release/experimental-v0.2.0/SBOM.spdx.json +++ b/Compliance/Release/experimental-v0.2.0/SBOM.spdx.json @@ -3,7 +3,7 @@ "dataLicense": "CC0-1.0", "SPDXID": "SPDXRef-DOCUMENT", "name": "PocketRoot experimental full-graph composition 0.2.0", - "documentNamespace": "https://github.com/jacklv-coder/PocketRoot/sbom/release/experimental-v0.2.0/44f6d771dd3c6d83d7f5ee81ad4ffe83a50ef3efdafa24a26b1e8a70fc5f53d3", + "documentNamespace": "https://github.com/jacklv-coder/PocketRoot/sbom/release/experimental-v0.2.0/a9a4d9f55109789e23abb37c2f0c609173374996e45e8ce7e54c74e621d61b35", "creationInfo": { "created": "2026-08-03T00:00:00Z", "creators": [ diff --git a/Compliance/Release/experimental-v0.2.0/SHA256SUMS b/Compliance/Release/experimental-v0.2.0/SHA256SUMS index 1ec07e0..c54c06f 100644 --- a/Compliance/Release/experimental-v0.2.0/SHA256SUMS +++ b/Compliance/Release/experimental-v0.2.0/SHA256SUMS @@ -1,5 +1,5 @@ -44f6d771dd3c6d83d7f5ee81ad4ffe83a50ef3efdafa24a26b1e8a70fc5f53d3 COMPOSITION.json +a9a4d9f55109789e23abb37c2f0c609173374996e45e8ce7e54c74e621d61b35 COMPOSITION.json 9fe270ba11160051d13f99cfe4ce6ee03c52882b88339dd6e4c1b4923ae30755 READINESS.json 6d41126608b019b46332a7957889f41e3c3ec6c6e9e2b884dca5ffc6d405e943 README.md fab07621b5188930bfd2a38117e206815d94f755e667d65fc182ee55d14d2d40 RELEASE-CHECKLIST.md -c56b8eec15f2112bcd38b8f1d8f0c56da52967538284e12cd749422565076b8f SBOM.spdx.json +e1673e7cba02ebe7dc563b01906517d7cd676803b68b199e63bf083ee1b9c0e9 SBOM.spdx.json diff --git a/Docs/Testing.md b/Docs/Testing.md index 7dd93be..528fced 100644 --- a/Docs/Testing.md +++ b/Docs/Testing.md @@ -578,11 +578,12 @@ Workspace、系统 document picker 导入、share sheet 保存、guest 删除后 `POCKETROOT_QUICK_START_UI_DEVICE_NAME` 选择 Quick Start Simulator;失败诊断时设置 `POCKETROOT_KEEP_UI_RESULT=1` 可保留临时 DerivedData 与 `.xcresult`。两个 wrapper 复用 `run-ios-example-ui-smoke.sh` 的相同 RootFS、超时、xcresult 和安全清理边界。 -当 Xcode 明确报告 Simulator test runner 未注册到 FrontBoard 时,通用 runner 会重启 -同一个由 runner 创建的临时 Simulator;当 Xcode 明确报告目标设备已从可用 destination -消失时,会以相同 runtime 与 device type 重建该临时 Simulator。两种基础设施恢复合计 -最多重试一次;调用方通过 `POCKETROOT_UI_SMOKE_DEVICE` 提供的共享 Simulator 不会被 -自动关闭、重启或重建。断言失败、测试超时和其他构建错误不会重试。 +当 Xcode 明确报告 Simulator test runner 未注册到 FrontBoard,或 XCTest 在执行任何 +测试方法前等待 Accessibility 加载超时时,通用 runner 会重启同一个由 runner 创建的 +临时 Simulator;当 Xcode 明确报告目标设备已从可用 destination 消失时,会以相同 +runtime 与 device type 重建该临时 Simulator。这些基础设施恢复合计最多重试一次; +调用方通过 `POCKETROOT_UI_SMOKE_DEVICE` 提供的共享 Simulator 不会被自动关闭、重启或 +重建。断言失败、测试方法超时和其他构建错误不会重试。 可设置 `POCKETROOT_UI_INFRASTRUCTURE_RETRY_LIMIT=0` 关闭该恢复路径。若第二次仍失败, 失败 artifact 会同时包含首次/最终测试日志与可用的两次 `.xcresult`;即使重启本身 失败,也保留首次 `xcodebuild` 的诊断结果。 diff --git a/Docs/Troubleshooting.md b/Docs/Troubleshooting.md index 1aaa9f9..739e868 100644 --- a/Docs/Troubleshooting.md +++ b/Docs/Troubleshooting.md @@ -359,10 +359,11 @@ POCKETROOT_ROOTFS_ARCHIVE=/path/to/fs.tar.gz \ CI 先运行 `./Scripts/check-docs.sh`,再执行测试与构建。`actions/checkout` 自身固定到精确 revision,但它检出的仓库内容是 workflow 事件选定的 SHA(push SHA 或 PR merge SHA)。CI 是干净 checkout,不能访问本地 archive、DerivedData、未提交工程或凭据。 -若 Xcode 16 偶发报告 UI test runner `is unknown to FrontBoard`,通用 Simulator UI -runner 会重启由它创建的临时设备并重试一次;调用方指定的共享 Simulator 不会被自动 -重启。只有这组精确的基础设施信号会触发重试;不要把普通测试失败加入重试条件。重试 -后仍失败时,应同时检查首次与最终 `xcodebuild` 日志。 +若 Xcode 16 偶发报告 UI test runner `is unknown to FrontBoard`,或 XCTest 在执行测试 +方法前报告 `Timed out waiting for AX loaded notification`,通用 Simulator UI runner +会重启由它创建的临时设备并重试一次;调用方指定的共享 Simulator 不会被自动重启。 +只有这些精确的基础设施信号会触发重试;不要把普通测试失败加入重试条件。重试后仍失败 +时,应同时检查首次与最终 `xcodebuild` 日志。 ## 报告问题时提供 diff --git a/Docs/en/Testing.md b/Docs/en/Testing.md index ef9293d..1025965 100644 --- a/Docs/en/Testing.md +++ b/Docs/en/Testing.md @@ -525,13 +525,14 @@ Use `POCKETROOT_QUICK_START_UI_DEVICE_TYPE` and `run-ios-example-ui-smoke.sh` for the same RootFS, timeouts, xcresult, and safe cleanup boundaries. For failure diagnosis, `POCKETROOT_KEEP_UI_RESULT=1` retains temporary DerivedData and the `.xcresult`. When Xcode explicitly -reports that the Simulator test runner was not registered with FrontBoard, the -shared runner restarts the same temporary Simulator. When Xcode explicitly +reports that the Simulator test runner was not registered with FrontBoard, or +XCTest times out waiting for Accessibility to load before any test method runs, +the shared runner restarts the same temporary Simulator. When Xcode explicitly reports that the destination has disappeared, it recreates that temporary Simulator with the same runtime and device type. These infrastructure paths share one total retry. Recovery applies only to a Simulator created by the runner; a shared Simulator supplied through `POCKETROOT_UI_SMOKE_DEVICE` is -never automatically shut down, restarted, or recreated. Assertions, test +never automatically shut down, restarted, or recreated. Assertions, test-method timeouts, and other build failures are not retried. Set `POCKETROOT_UI_INFRASTRUCTURE_RETRY_LIMIT=0` to disable this recovery path. If the retry or restart also fails, the failure artifact retains the first and diff --git a/Docs/en/Troubleshooting.md b/Docs/en/Troubleshooting.md index 83ff496..0f0056b 100644 --- a/Docs/en/Troubleshooting.md +++ b/Docs/en/Troubleshooting.md @@ -196,11 +196,13 @@ post-report runner-cleanup check. Missing reports and crashes are failures. Compare toolchains, SDK, destination architecture, resolved dependencies, XcodeGen, artifact digests, generated project source, and uncommitted local dependencies. CI runs `./Scripts/check-docs.sh` before tests and builds. The `actions/checkout` implementation is pinned to an exact revision, while the repository content is the workflow event SHA (push SHA or PR merge SHA). CI has no local archive, DerivedData, generated project, or credentials. If Xcode 16 intermittently reports that a UI test runner `is unknown to -FrontBoard`, the shared Simulator UI runner restarts the device and retries -once only when the runner created that temporary device. It does not restart a -shared Simulator supplied by the caller. Only that exact infrastructure signature triggers a retry; do not add -ordinary test failures to the retry condition. If the retry also fails, inspect -both the first and final `xcodebuild` logs. +FrontBoard`, or XCTest reports `Timed out waiting for AX loaded notification` +before a test method runs, the shared Simulator UI runner restarts the device +and retries once only when the runner created that temporary device. It does +not restart a shared Simulator supplied by the caller. Only these exact +infrastructure signatures trigger a retry; do not add ordinary test failures +to the retry condition. If the retry also fails, inspect both the first and +final `xcodebuild` logs. ## Issue report diff --git a/Examples/PocketRootHostApp/UITests/PocketRootHostAppUITests.swift b/Examples/PocketRootHostApp/UITests/PocketRootHostAppUITests.swift index 839ab62..e56f316 100644 --- a/Examples/PocketRootHostApp/UITests/PocketRootHostAppUITests.swift +++ b/Examples/PocketRootHostApp/UITests/PocketRootHostAppUITests.swift @@ -44,18 +44,21 @@ final class PocketRootHostAppUITests: XCTestCase { let nameField = app.textFields["Name"] XCTAssertTrue(nameField.waitForExistence(timeout: 10)) nameField.typeText(fileName) - app.buttons["Create"].tap() - let file = app.descendants(matching: .any)[ "PocketRootFiles.entry./root/\(fileName)" ] - XCTAssertTrue(file.waitForExistence(timeout: 30)) + guard submitCreation(expectedEntry: file, in: app) else { + return + } waitForEnabled(file) - file.press(forDuration: 1) - XCTAssertTrue( - app.buttons["Share / Export"].waitForExistence(timeout: 10) - ) - app.buttons["Rename"].tap() + guard let rename = openFileEntryContextMenu( + for: file, + expectedAction: "Rename", + in: app + ) else { + return + } + rename.tap() let renameAlert = app.alerts["Rename"] XCTAssertTrue(renameAlert.waitForExistence(timeout: 10)) let renameNameField = renameAlert.textFields["Name"] @@ -66,20 +69,30 @@ final class PocketRootHostAppUITests: XCTestCase { ).tap() renameNameField.typeText(".renamed") XCTAssertEqual(renameNameField.value as? String, renamedFileName) + dismissKeyboardOnboardingIfPresent(in: app) renameAlert.buttons["Rename"].tap() let renamedFile = app.descendants(matching: .any)[ "PocketRootFiles.entry./root/\(renamedFileName)" ] - XCTAssertTrue(renamedFile.waitForExistence(timeout: 30)) + guard renamedFile.waitForExistence(timeout: 30) else { + XCTFail("renamed file to appear after submitting the rename") + return + } wait( for: NSPredicate(format: "exists == false"), evaluatedWith: file, timeout: 30 ) waitForEnabled(renamedFile) - renamedFile.press(forDuration: 1) - app.buttons["Delete"].tap() + guard let delete = openFileEntryContextMenu( + for: renamedFile, + expectedAction: "Delete", + in: app + ) else { + return + } + delete.tap() confirmDeletion(of: renamedFileName, in: app) wait( for: NSPredicate(format: "exists == false"), @@ -92,16 +105,19 @@ final class PocketRootHostAppUITests: XCTestCase { app.buttons["New Folder"].tap() XCTAssertTrue(nameField.waitForExistence(timeout: 10)) nameField.typeText(folderName) - app.buttons["Create"].tap() - let folder = app.descendants(matching: .any)[ "PocketRootFiles.entry./root/\(folderName)" ] - XCTAssertTrue(folder.waitForExistence(timeout: 30)) + guard submitCreation(expectedEntry: folder, in: app) else { + return + } let disclosure = app.buttons[ "PocketRootFiles.disclosure./root/\(folderName)" ] - XCTAssertTrue(disclosure.waitForExistence(timeout: 10)) + guard disclosure.waitForExistence(timeout: 10) else { + XCTFail("new folder disclosure to appear") + return + } waitForEnabled(disclosure) disclosure.tap() guard revealFileEntry(folder, in: app) else { @@ -115,7 +131,12 @@ final class PocketRootHostAppUITests: XCTestCase { app.buttons["New File"].tap() XCTAssertTrue(nameField.waitForExistence(timeout: 10)) nameField.typeText(nestedFileName) - app.buttons["Create"].tap() + let nestedFile = app.descendants(matching: .any)[ + "PocketRootFiles.entry./root/\(folderName)/\(nestedFileName)" + ] + guard submitCreation(expectedEntry: nestedFile, in: app) else { + return + } waitForEnabled(actions) let childNavigationBar = app.navigationBars[folderName] guard childNavigationBar.waitForExistence(timeout: 30) else { @@ -127,12 +148,14 @@ final class PocketRootHostAppUITests: XCTestCase { XCTAssertTrue(waitForHittable(backButton)) backButton.tap() - let nestedFile = app.descendants(matching: .any)[ - "PocketRootFiles.entry./root/\(folderName)/\(nestedFileName)" - ] - XCTAssertTrue(nestedFile.waitForExistence(timeout: 30)) - folder.press(forDuration: 1) - app.buttons["Delete"].tap() + guard let delete = openFileEntryContextMenu( + for: folder, + expectedAction: "Delete", + in: app + ) else { + return + } + delete.tap() confirmDeletion(of: folderName, in: app) wait( for: NSPredicate(format: "exists == false"), @@ -237,8 +260,14 @@ final class PocketRootHostAppUITests: XCTestCase { guard revealFileEntry(reopenedImport, in: app) else { return } - pressCurrentFrame(of: reopenedImport, in: app) - app.buttons["Share / Export"].tap() + guard let share = openFileEntryContextMenu( + for: reopenedImport, + expectedAction: "Share / Export", + in: app + ) else { + return + } + share.tap() let saveToFiles = app.cells.matching( NSPredicate( @@ -285,8 +314,14 @@ final class PocketRootHostAppUITests: XCTestCase { guard revealFileEntry(persistedImport, in: app) else { return } - pressCurrentFrame(of: persistedImport, in: app) - app.buttons["Delete"].tap() + guard let delete = openFileEntryContextMenu( + for: persistedImport, + expectedAction: "Delete", + in: app + ) else { + return + } + delete.tap() confirmDeletion(of: Self.systemImportFixtureName, in: app) wait( for: NSPredicate(format: "exists == false"), @@ -695,16 +730,26 @@ final class PocketRootHostAppUITests: XCTestCase { let terminal = terminalElement(in: app) XCTAssertTrue(terminal.waitForExistence(timeout: 90)) terminal.tap() + let integratedRunID = UUID().uuidString.lowercased() + let integratedMarker = + "__INTEGRATED_WORKSPACE_READY_\(integratedRunID)__" + let integratedContents = + "integrated workspace \(integratedRunID)\n" terminal.typeText( "rm -f /root/pocketroot-integrated-smoke.txt; " - + "printf 'integrated workspace\\n' " + + "printf 'integrated workspace \(integratedRunID)\\n' " + "> /root/pocketroot-integrated-smoke.txt; " - + "printf '__INTEGRATED_WORKSPACE_READY__\\n'\n" + + "printf '\(integratedMarker)\\n'\n" ) - wait( + // SwiftTerm's accessibility value can lag behind output that the + // guest has already produced. Give it one bounded observation window, + // then let the stronger Files entry and exact preview assertions below + // prove that the command completed instead of recording a false + // terminal-text failure. + _ = waitWithoutAssertion( for: NSPredicate( format: "value CONTAINS %@", - "__INTEGRATED_WORKSPACE_READY__" + integratedMarker ), evaluatedWith: terminal, timeout: 30 @@ -722,7 +767,7 @@ final class PocketRootHostAppUITests: XCTestCase { file.tap() let preview = app.staticTexts["PocketRootFiles.preview"] XCTAssertTrue(preview.waitForExistence(timeout: 30)) - XCTAssertEqual(preview.label, "integrated workspace\n") + XCTAssertEqual(preview.label, integratedContents) returnToHost(in: app) wait( @@ -912,8 +957,14 @@ final class PocketRootHostAppUITests: XCTestCase { guard revealFileEntry(file, in: app) else { return } - pressCurrentFrame(of: file, in: app) - app.buttons["Delete"].tap() + guard let delete = openFileEntryContextMenu( + for: file, + expectedAction: "Delete", + in: app + ) else { + return + } + delete.tap() confirmDeletion(of: itemName, in: app) wait( for: NSPredicate(format: "exists == false"), @@ -1019,6 +1070,13 @@ final class PocketRootHostAppUITests: XCTestCase { } RunLoop.current.run(until: Date().addingTimeInterval(0.2)) } + if currentHostDocuments.exists { + guard hostFixture.waitForExistence(timeout: 30) else { + XCTFail("Host Documents fixture to become visible") + return false + } + return true + } guard let localLocation else { XCTFail("document picker to expose a usable Host or local destination") return false @@ -1039,14 +1097,25 @@ final class PocketRootHostAppUITests: XCTestCase { "PocketRoot Host," ) ).firstMatch - XCTAssertTrue(localLocation.waitForExistence(timeout: 30)) var openedHostDestination = false for _ in 0..<2 { + // Browse can restore the last Host destination while the local + // location query is transitioning out of the accessibility tree. + // Re-check that stronger navigation state before touching a stale + // local-location element. + if currentHostDocuments.exists { + openedHostDestination = true + break + } guard let frames = waitForInteractionFrames( of: localLocation, in: app, timeout: 5 ) else { + if currentHostDocuments.waitForExistence(timeout: 5) { + openedHostDestination = true + break + } continue } tapFrame( @@ -1246,20 +1315,77 @@ final class PocketRootHostAppUITests: XCTestCase { return nil } - private func pressCurrentFrame( - of element: XCUIElement, + private func openFileEntryContextMenu( + for element: XCUIElement, + expectedAction: String, in app: XCUIApplication - ) { - let appFrame = app.frame - let elementFrame = element.frame - app.coordinate(withNormalizedOffset: .zero) - .withOffset( - CGVector( - dx: elementFrame.midX - appFrame.minX, - dy: elementFrame.midY - appFrame.minY + ) -> XCUIElement? { + let action = app.buttons[expectedAction] + var lastAppFrame = CGRect.null + var lastElementFrame = CGRect.null + + for _ in 0..<2 { + guard let frames = waitForInteractionFrames( + of: element, + in: app, + timeout: 10 + ) else { + continue + } + lastAppFrame = frames.appFrame + lastElementFrame = frames.elementFrame + app.coordinate(withNormalizedOffset: .zero) + .withOffset( + CGVector( + dx: frames.elementFrame.midX - frames.appFrame.minX, + dy: frames.elementFrame.midY - frames.appFrame.minY + ) ) + .press(forDuration: 1) + if action.waitForExistence(timeout: 10) { + return action + } + } + + XCTFail( + "\(expectedAction) context action to appear; " + + "app=\(lastAppFrame), element=\(lastElementFrame)" + ) + return nil + } + + private func submitCreation( + expectedEntry: XCUIElement, + in app: XCUIApplication + ) -> Bool { + dismissKeyboardOnboardingIfPresent(in: app) + let create = app.buttons["Create"] + + for _ in 0..<2 { + guard let frames = waitForInteractionFrames( + of: create, + in: app, + timeout: 10 + ) else { + break + } + tapFrame( + frames.elementFrame, + in: frames.appFrame, + using: app ) - .press(forDuration: 1) + // File-browser mutations allow up to 30 seconds. Preserve that + // contract, plus a small accessibility refresh allowance, before + // deciding whether the captured tap needs its one bounded retry. + if expectedEntry.waitForExistence(timeout: 35) { + return true + } + } + + XCTFail( + "created entry to appear after a bounded Create retry" + ) + return false } private func tapOutsideSnapshotFrame( @@ -1329,13 +1455,28 @@ final class PocketRootHostAppUITests: XCTestCase { ) { let continueButton = app.buttons["Continue"] for _ in 0..<3 { - guard continueButton.waitForExistence(timeout: 1) else { + guard let frames = waitForInteractionFrames( + of: continueButton, + in: app, + timeout: 1 + ) else { return } - continueButton.tap() - RunLoop.current.run( - until: Date().addingTimeInterval(0.5) + // Keyboard onboarding can auto-dismiss during XCTest interruption + // handling. Tap the last validated frame through the application + // so a disappearing Continue element cannot invalidate the event. + tapFrame( + frames.elementFrame, + in: frames.appFrame, + using: app ) + if waitWithoutAssertion( + for: NSPredicate(format: "exists == false"), + evaluatedWith: continueButton, + timeout: 2 + ) { + return + } } XCTAssertFalse( continueButton.exists, @@ -1348,19 +1489,40 @@ final class PocketRootHostAppUITests: XCTestCase { for _ in 0..<3 where keyboard.exists { let continueButton = app.buttons["Continue"] if continueButton.exists { - continueButton.tap() + if let frames = waitForInteractionFrames( + of: continueButton, + in: app, + timeout: 1 + ) { + tapFrame( + frames.elementFrame, + in: frames.appFrame, + using: app + ) + } } else { let hideKeyboardButton = app.buttons[ "PocketRootTerminal.key.dismiss-keyboard" ] - XCTAssertTrue( - hideKeyboardButton.waitForExistence(timeout: 10) - ) - hideKeyboardButton.tap() + if let frames = waitForInteractionFrames( + of: hideKeyboardButton, + in: app, + timeout: 2 + ) { + tapFrame( + frames.elementFrame, + in: frames.appFrame, + using: app + ) + } + } + if waitWithoutAssertion( + for: NSPredicate(format: "exists == false"), + evaluatedWith: keyboard, + timeout: 2 + ) { + return } - RunLoop.current.run( - until: Date().addingTimeInterval(0.5) - ) } XCTAssertFalse(keyboard.exists) } diff --git a/Examples/PocketRootQuickStartApp/UITests/PocketRootQuickStartAppUITests.swift b/Examples/PocketRootQuickStartApp/UITests/PocketRootQuickStartAppUITests.swift index da2eb26..9cc83be 100644 --- a/Examples/PocketRootQuickStartApp/UITests/PocketRootQuickStartAppUITests.swift +++ b/Examples/PocketRootQuickStartApp/UITests/PocketRootQuickStartAppUITests.swift @@ -84,23 +84,61 @@ final class PocketRootQuickStartAppUITests: XCTestCase { for _ in 0..<3 where keyboard.exists { let continueButton = app.buttons["Continue"] if continueButton.exists { - continueButton.tap() + tapCurrentFrame(of: continueButton, in: app) } else { let hideKeyboardButton = app.buttons[ "PocketRootTerminal.key.dismiss-keyboard" ] - XCTAssertTrue( - hideKeyboardButton.waitForExistence(timeout: 10) - ) - hideKeyboardButton.tap() + if hideKeyboardButton.waitForExistence(timeout: 2) { + tapCurrentFrame(of: hideKeyboardButton, in: app) + } + } + if waitForDisappearance(of: keyboard, timeout: 2) { + return } - RunLoop.current.run( - until: Date().addingTimeInterval(0.5) - ) } XCTAssertFalse(keyboard.exists) } + private func tapCurrentFrame( + of element: XCUIElement, + in app: XCUIApplication + ) { + guard let snapshot = try? element.snapshot() else { + return + } + let appFrame = app.frame + let elementFrame = snapshot.frame + let center = CGPoint(x: elementFrame.midX, y: elementFrame.midY) + guard appFrame.width > 0, + appFrame.height > 0, + elementFrame.width > 0, + elementFrame.height > 0, + appFrame.contains(center) + else { + return + } + app.coordinate(withNormalizedOffset: .zero) + .withOffset( + CGVector( + dx: center.x - appFrame.minX, + dy: center.y - appFrame.minY + ) + ) + .tap() + } + + private func waitForDisappearance( + of element: XCUIElement, + timeout: TimeInterval + ) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while element.exists, Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + } + return !element.exists + } + private func tapBackButton(in app: XCUIApplication) { let navigationBar = app.navigationBars["Terminal"] XCTAssertTrue(navigationBar.waitForExistence(timeout: 10)) diff --git a/Scripts/generate-release-compliance.rb b/Scripts/generate-release-compliance.rb index 7b50e58..bfaeb5f 100644 --- a/Scripts/generate-release-compliance.rb +++ b/Scripts/generate-release-compliance.rb @@ -281,7 +281,7 @@ module PocketRootReleaseCompliance "Scripts/run-host-app-ui-smoke.sh" => "c8b11b1bf3a467fd10ed2c4124a72396b0a803e98f155d1d709fab46b59eede1", "Scripts/run-ios-example-ui-smoke.sh" => - "147fa58dc255e28fd63b0a7c62ed747b2215df652df3a0bdf5585eee4b49d395", + "921d7e5cfd5853d6653997e06c7f769d5076c1becd798c6e6a418f0d79538f12", "Scripts/run-quick-start-ui-smoke.sh" => "acf26d68fc37250e24911c2d23791a0d12ff06a624dedcefee0ed7905bc31af2", "Scripts/run-external-consumer-ui-smoke.sh" => diff --git a/Scripts/run-ios-example-ui-smoke.sh b/Scripts/run-ios-example-ui-smoke.sh index edee8d7..47f2e5f 100755 --- a/Scripts/run-ios-example-ui-smoke.sh +++ b/Scripts/run-ios-example-ui-smoke.sh @@ -133,6 +133,11 @@ run_ui_tests() { } retryable_simulator_failure_kind() { + if grep -Fq 'The test runner failed to initialize for UI testing.' "$TEST_LOG_PATH" && + grep -Fq 'Timed out waiting for AX loaded notification' "$TEST_LOG_PATH"; then + printf 'accessibility-initialization\n' + return 0 + fi if grep -Fq 'Simulator device failed to launch' "$TEST_LOG_PATH" && grep -Fq 'is unknown to FrontBoard' "$TEST_LOG_PATH"; then printf 'frontboard-launch\n' @@ -166,6 +171,8 @@ if [[ "$test_exit_code" -ne 0 && fi if [[ "$RETRY_FAILURE_KIND" == "missing-destination" ]]; then echo "$ARTIFACT_LABEL Simulator destination disappeared; recreating the runner-owned Simulator and retrying once." >&2 + elif [[ "$RETRY_FAILURE_KIND" == "accessibility-initialization" ]]; then + echo "$ARTIFACT_LABEL UI test runner timed out while initializing Accessibility; restarting the Simulator and retrying once." >&2 else echo "$ARTIFACT_LABEL UI test runner was not registered with FrontBoard; restarting the Simulator and retrying once." >&2 fi diff --git a/Tests/Scripts/RuntimeSmokeScriptTests.sh b/Tests/Scripts/RuntimeSmokeScriptTests.sh index d7a2589..3f9db16 100755 --- a/Tests/Scripts/RuntimeSmokeScriptTests.sh +++ b/Tests/Scripts/RuntimeSmokeScriptTests.sh @@ -85,6 +85,7 @@ if ! grep -Fq -- 'POCKETROOT_KEEP_UI_RESULT' "$GENERIC_UI_RUNNER" \ || ! grep -Fq -- 'POCKETROOT_UI_FAILURE_ARTIFACTS_DIR' "$GENERIC_UI_RUNNER" \ || ! grep -Fq -- 'POCKETROOT_UI_INFRASTRUCTURE_RETRY_LIMIT' "$GENERIC_UI_RUNNER" \ || ! grep -Fq -- 'retryable_simulator_failure_kind' "$GENERIC_UI_RUNNER" \ + || ! grep -Fq -- 'Timed out waiting for AX loaded notification' "$GENERIC_UI_RUNNER" \ || ! grep -Fq -- "is unknown to FrontBoard" "$GENERIC_UI_RUNNER" \ || ! grep -Fq -- 'no available devices matched the request' "$GENERIC_UI_RUNNER" \ || ! grep -Fq -- 'retrying once' "$GENERIC_UI_RUNNER" \ @@ -191,6 +192,12 @@ if [[ "${POCKETROOT_MOCK_XCODEBUILD_MODE:-retry}" == "missing-destination" && "$ echo "The requested device could not be found because no available devices matched the request." exit 70 fi +if [[ "${POCKETROOT_MOCK_XCODEBUILD_MODE:-retry}" == "accessibility-initialization" && "$calls" -eq 1 ]]; then + echo "Testing failed:" + echo "The test runner failed to initialize for UI testing." + echo "Underlying Error: Timed out waiting for AX loaded notification" + exit 65 +fi if [[ "${POCKETROOT_MOCK_XCODEBUILD_MODE:-retry}" == "assertion" ]]; then echo "Testing failed: expected value did not match" exit 65 @@ -220,6 +227,32 @@ if [[ "$(cat "$GENERIC_RUNNER_XCODEBUILD_CALLS")" != "2" ]] \ exit 1 fi +printf '0\n' > "$GENERIC_RUNNER_XCODEBUILD_CALLS" +: > "$GENERIC_RUNNER_XCRUN_CALLS" +: > "$GENERIC_RUNNER_BOOTSTATUS_CALLS" +: > "$GENERIC_RUNNER_BOOT_CALLS" +ACCESSIBILITY_INITIALIZATION_OUTPUT="$GENERIC_RUNNER_TEST_ROOT/accessibility-initialization-output.txt" +PATH="$GENERIC_RUNNER_MOCK_BIN:$PATH" \ +POCKETROOT_UI_APP_DIR="$GENERIC_RUNNER_APP" \ +POCKETROOT_UI_PROJECT_NAME="MockApp" \ +POCKETROOT_UI_SCHEME="MockApp" \ +POCKETROOT_UI_TEST_BUNDLE="MockAppUITests" \ +POCKETROOT_CLONED_SOURCE_PACKAGES_DIR="$GENERIC_RUNNER_TEST_ROOT/packages" \ +POCKETROOT_MOCK_XCODEBUILD_CALLS="$GENERIC_RUNNER_XCODEBUILD_CALLS" \ +POCKETROOT_MOCK_XCRUN_CALLS="$GENERIC_RUNNER_XCRUN_CALLS" \ +POCKETROOT_MOCK_BOOTSTATUS_CALLS="$GENERIC_RUNNER_BOOTSTATUS_CALLS" \ +POCKETROOT_MOCK_BOOT_CALLS="$GENERIC_RUNNER_BOOT_CALLS" \ +POCKETROOT_MOCK_XCODEBUILD_MODE="accessibility-initialization" \ + "$GENERIC_UI_RUNNER" "$GENERIC_RUNNER_ARCHIVE" \ + > "$ACCESSIBILITY_INITIALIZATION_OUTPUT" 2>&1 + +if [[ "$(cat "$GENERIC_RUNNER_XCODEBUILD_CALLS")" != "2" ]] \ + || ! grep -Fq -- 'initializing Accessibility' "$ACCESSIBILITY_INITIALIZATION_OUTPUT" \ + || ! grep -Fq -- 'simctl shutdown MOCK-UDID' "$GENERIC_RUNNER_XCRUN_CALLS"; then + echo "Shared example UI runner did not bound and recover the Accessibility initialization retry." >&2 + exit 1 +fi + printf '0\n' > "$GENERIC_RUNNER_XCODEBUILD_CALLS" : > "$GENERIC_RUNNER_XCRUN_CALLS" : > "$GENERIC_RUNNER_BOOTSTATUS_CALLS" @@ -437,6 +470,15 @@ if ! grep -Fq -- 'pocketroot-system-file-ui-fixture.txt' "$HOST_APP_SOURCE" \ || grep -Fq -- 'hostActions.isHittable' "$HOST_UI_TESTS" \ || grep -Fq -- 'hostIsHittable' "$HOST_UI_TESTS" \ || ! grep -Fq -- 'waitForInteractionFrames(' "$HOST_UI_TESTS" \ + || ! grep -Fq -- 'submitCreation(expectedEntry:' "$HOST_UI_TESTS" \ + || ! grep -Fq -- 'dismissKeyboardOnboardingIfPresent(in: app)' "$HOST_UI_TESTS" \ + || ! grep -Fq -- 'Keyboard onboarding can auto-dismiss during XCTest interruption' "$HOST_UI_TESTS" \ + || ! grep -Fq -- 'evaluatedWith: continueButton' "$HOST_UI_TESTS" \ + || ! grep -Fq -- 'expectedEntry.waitForExistence(timeout: 35)' "$HOST_UI_TESTS" \ + || ! grep -Fq -- 'then let the stronger Files entry and exact preview assertions below' "$HOST_UI_TESTS" \ + || ! grep -Fq -- 'UUID().uuidString.lowercased()' "$HOST_UI_TESTS" \ + || ! grep -Fq -- 'XCTAssertEqual(preview.label, integratedContents)' "$HOST_UI_TESTS" \ + || ! grep -Fq -- 'if currentHostDocuments.waitForExistence(timeout: 5)' "$HOST_UI_TESTS" \ || ! grep -Fq -- 'underlying host geometry' "$HOST_UI_TESTS" \ || ! grep -Fq -- 'for attempt in 0..<2' "$HOST_UI_TESTS" \ || ! grep -Fq -- 'relaunchAndBoot(app)' "$HOST_UI_TESTS" \ @@ -461,6 +503,8 @@ if ! grep -Fq -- 'makeTerminalViewController()' "$QUICK_START_SOURCE" \ || ! grep -Fq -- 'testTerminalCreatesFileThatFilesCanPreview' "$QUICK_START_UI_TESTS" \ || ! grep -Fq -- 'PocketRootTerminal.pty' "$QUICK_START_UI_TESTS" \ || ! grep -Fq -- 'PocketRootFiles.preview' "$QUICK_START_UI_TESTS" \ + || ! grep -Fq -- 'tapCurrentFrame(of: continueButton, in: app)' "$QUICK_START_UI_TESTS" \ + || ! grep -Fq -- 'waitForDisappearance(of: keyboard, timeout: 2)' "$QUICK_START_UI_TESTS" \ || ! grep -Fq -- 'PocketRootQuickStartAppUITests:' "$QUICK_START_PROJECT_SPEC" \ || ! grep -Fq -- 'path: ../..' "$QUICK_START_PROJECT_SPEC" \ || ! grep -Fq -- 'product: PocketRootIshRuntimeIntegration' "$QUICK_START_PROJECT_SPEC" \