From eb3ae08cf95c4ec4a542138ba3282e3abef79152 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sun, 13 Sep 2026 14:59:11 +0100 Subject: [PATCH] test(m10): wait for gamut layer enable; drive New via File menu on macOS 12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Actions run 33718 (Xcode 14.2, macOS 12) failed four UI tests that pass locally. Gamut layer toggles render as disabled placeholders until the async layer load lands, so waitFor(id) won the race on the placeholder — added waitUntilEnabled and gate on isEnabled. The project New test's ⌘N may not reach the CommandGroup and alert buttons often lack identifiers on macOS 12 — drive New through the File menu "New Project" item and confirm the alert by button title with id fallback. Refs #147 #149 #146 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../Milestone10GamutCompareUITests.swift | 24 ++++++-- .../Milestone10ProjectUITests.swift | 59 +++++++++++++++---- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/Tests/ICCeryUITests/Milestone10GamutCompareUITests.swift b/Tests/ICCeryUITests/Milestone10GamutCompareUITests.swift index da7f2f9..631e2fd 100644 --- a/Tests/ICCeryUITests/Milestone10GamutCompareUITests.swift +++ b/Tests/ICCeryUITests/Milestone10GamutCompareUITests.swift @@ -75,6 +75,22 @@ final class Milestone10GamutCompareUITests: XCTestCase { return el } + /// Exists **and** `isEnabled`. Layer toggles render as disabled + /// placeholders until the async layer load lands — on the macOS 12 + /// runner `waitFor` alone wins the race against `parse`. + private func waitUntilEnabled(_ id: String, timeout: TimeInterval = 15) -> XCUIElement { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let el = element(id) + if el.exists && el.isEnabled { return el } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + let el = element(id) + XCTAssertTrue( + el.exists && el.isEnabled, "Expected enabled element \(id)") + return el + } + private func launchApp() { app.launch() if !app.wait(for: .runningForeground, timeout: 10) { @@ -91,9 +107,8 @@ final class Milestone10GamutCompareUITests: XCTestCase { func testLayerTogglesExistWithSRGB() throws { openGamutSheet() - let srgb = waitFor("gamutLayer-sRGB") + let srgb = waitUntilEnabled("gamutLayer-sRGB") XCTAssertTrue(srgb.exists) - XCTAssertTrue(srgb.isEnabled) // NSButton checkbox value is 1 when checked. XCTAssertEqual(srgb.value as? Int, 1, "sRGB layer should be on") @@ -124,7 +139,8 @@ final class Milestone10GamutCompareUITests: XCTestCase { waitFor("btnGamutAddCompare").click() waitFor("btnGamutOpenGam").click() - let compare = waitFor("gamutLayer-compare") + // The pre-load placeholder also exists — wait for enabled. + let compare = waitUntilEnabled("gamutLayer-compare") XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after load") let status = waitFor("gamutStatusText") @@ -146,7 +162,7 @@ final class Milestone10GamutCompareUITests: XCTestCase { waitFor("btnGamutAddCompare").click() waitFor("btnGamutOpenProfile").click() - let compare = waitFor("gamutLayer-compare") + let compare = waitUntilEnabled("gamutLayer-compare") XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after iccgamut") } diff --git a/Tests/ICCeryUITests/Milestone10ProjectUITests.swift b/Tests/ICCeryUITests/Milestone10ProjectUITests.swift index 55a3249..25db5f9 100644 --- a/Tests/ICCeryUITests/Milestone10ProjectUITests.swift +++ b/Tests/ICCeryUITests/Milestone10ProjectUITests.swift @@ -3,8 +3,9 @@ import XCTest /// Milestone 10 UI tests — issue #149 project file. Panels are never /// real: `ICCERY_TEST_PROJECT_OPEN` / `ICCERY_TEST_PROJECT_SAVE` /// inject fixture paths through `UITestHooks`. Menu commands are driven -/// by their keyboard shortcuts (⌘N) or the sidebar chip so the tests do -/// not depend on menu AX exposure (R19). All queries by identifier. +/// through the File menu when it is in the AX tree, else by their +/// keyboard shortcuts (⌘N) — the tests do not depend on menu AX +/// exposure (R19). All queries by identifier. @MainActor final class Milestone10ProjectUITests: XCTestCase { @@ -77,6 +78,39 @@ final class Milestone10ProjectUITests: XCTestCase { return (el.value as? String) ?? el.label } + /// Alert/sheet button by visible title, falling back to the a11y + /// id; nil when neither matches. macOS 12 SwiftUI alerts often + /// drop `accessibilityIdentifier` on their buttons, so the title + /// is the reliable handle there. + private func alertButton(title: String, id: String) -> XCUIElement? { + let inDialog = app.dialogs.firstMatch.buttons[title].firstMatch + if inDialog.exists { return inDialog } + let inSheet = app.sheets.firstMatch.buttons[title].firstMatch + if inSheet.exists { return inSheet } + let byId = element(id) + return byId.exists ? byId : nil + } + + /// Fires File ▸ New Project via the menu when it is in the AX + /// tree, else ⌘N. On macOS 12 `typeKey` may not reach the + /// `CommandGroup`, and menu item ids are unreliable — the menu + /// item is matched by its "New Project" label first. + private func triggerNewProject() { + let fileMenu = app.menuBarItems["File"] + if fileMenu.waitForExistence(timeout: 5) { + fileMenu.click() + let byTitle = app.menuItems["New Project"].firstMatch + let byId = app.menuItems["menuProjectNew"].firstMatch + let item = byTitle.exists ? byTitle : byId + if item.waitForExistence(timeout: 5) { + item.click() + return + } + app.typeKey(XCUIKeyboardKey.escape, modifierFlags: []) + } + app.typeKey("n", modifierFlags: .command) + } + /// Writes a `.icceryproj` fixture under `testRoot` and points the /// open-picker hook at it. private func stageProjectFixture( @@ -133,23 +167,26 @@ final class Milestone10ProjectUITests: XCTestCase { XCTAssertTrue(basenameField.waitForExistence(timeout: 10)) XCTAssertEqual(basenameField.value as? String, "ui149job") - // ⌘N fires the File-menu New command even when the menu is not - // in the AX tree. Mock CUPS may have enumerated a queue that the - // fixture does not record, making the session dirty — in that - // case the dirty alert gates New first. - app.typeKey("n", modifierFlags: .command) + // File ▸ New Project when the menu is in the AX tree, else + // ⌘N. Mock CUPS may have enumerated a queue that the fixture + // does not record, making the session dirty — in that case + // the dirty alert gates New first. macOS 12 alerts often lack + // button identifiers, so confirm by title with id fallback. + triggerNewProject() let deadline = Date().addingTimeInterval(10) var confirmed = false while Date() < deadline { // Dirty sessions show the dirty alert first; discarding it // runs the New reset directly (no second confirm). - if element("btnProjectDirtyDiscard").exists { - element("btnProjectDirtyDiscard").click() + if let discard = alertButton( + title: "Don't Save", id: "btnProjectDirtyDiscard") { + discard.click() confirmed = true break } - if element("btnProjectNewConfirm").exists { - element("btnProjectNewConfirm").click() + if let start = alertButton( + title: "Start", id: "btnProjectNewConfirm") { + start.click() confirmed = true break } -- 2.39.5