test(m10): wait for gamut layer enable; drive New via File menu on macOS 12 #158

Merged
gronod merged 1 commits from fix/m10-ci-ui-macos12 into milestone/m10-studio 2026-09-13 14:59:47 +01:00
2 changed files with 68 additions and 15 deletions
@@ -75,6 +75,22 @@ final class Milestone10GamutCompareUITests: XCTestCase {
return el 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() { private func launchApp() {
app.launch() app.launch()
if !app.wait(for: .runningForeground, timeout: 10) { if !app.wait(for: .runningForeground, timeout: 10) {
@@ -91,9 +107,8 @@ final class Milestone10GamutCompareUITests: XCTestCase {
func testLayerTogglesExistWithSRGB() throws { func testLayerTogglesExistWithSRGB() throws {
openGamutSheet() openGamutSheet()
let srgb = waitFor("gamutLayer-sRGB") let srgb = waitUntilEnabled("gamutLayer-sRGB")
XCTAssertTrue(srgb.exists) XCTAssertTrue(srgb.exists)
XCTAssertTrue(srgb.isEnabled)
// NSButton checkbox value is 1 when checked. // NSButton checkbox value is 1 when checked.
XCTAssertEqual(srgb.value as? Int, 1, "sRGB layer should be on") XCTAssertEqual(srgb.value as? Int, 1, "sRGB layer should be on")
@@ -124,7 +139,8 @@ final class Milestone10GamutCompareUITests: XCTestCase {
waitFor("btnGamutAddCompare").click() waitFor("btnGamutAddCompare").click()
waitFor("btnGamutOpenGam").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") XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after load")
let status = waitFor("gamutStatusText") let status = waitFor("gamutStatusText")
@@ -146,7 +162,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
waitFor("btnGamutAddCompare").click() waitFor("btnGamutAddCompare").click()
waitFor("btnGamutOpenProfile").click() waitFor("btnGamutOpenProfile").click()
let compare = waitFor("gamutLayer-compare") let compare = waitUntilEnabled("gamutLayer-compare")
XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after iccgamut") XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after iccgamut")
} }
@@ -3,8 +3,9 @@ import XCTest
/// Milestone 10 UI tests issue #149 project file. Panels are never /// Milestone 10 UI tests issue #149 project file. Panels are never
/// real: `ICCERY_TEST_PROJECT_OPEN` / `ICCERY_TEST_PROJECT_SAVE` /// real: `ICCERY_TEST_PROJECT_OPEN` / `ICCERY_TEST_PROJECT_SAVE`
/// inject fixture paths through `UITestHooks`. Menu commands are driven /// inject fixture paths through `UITestHooks`. Menu commands are driven
/// by their keyboard shortcuts (N) or the sidebar chip so the tests do /// through the File menu when it is in the AX tree, else by their
/// not depend on menu AX exposure (R19). All queries by identifier. /// keyboard shortcuts (N) the tests do not depend on menu AX
/// exposure (R19). All queries by identifier.
@MainActor @MainActor
final class Milestone10ProjectUITests: XCTestCase { final class Milestone10ProjectUITests: XCTestCase {
@@ -77,6 +78,39 @@ final class Milestone10ProjectUITests: XCTestCase {
return (el.value as? String) ?? el.label 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 /// Writes a `.icceryproj` fixture under `testRoot` and points the
/// open-picker hook at it. /// open-picker hook at it.
private func stageProjectFixture( private func stageProjectFixture(
@@ -133,23 +167,26 @@ final class Milestone10ProjectUITests: XCTestCase {
XCTAssertTrue(basenameField.waitForExistence(timeout: 10)) XCTAssertTrue(basenameField.waitForExistence(timeout: 10))
XCTAssertEqual(basenameField.value as? String, "ui149job") XCTAssertEqual(basenameField.value as? String, "ui149job")
// N fires the File-menu New command even when the menu is not // File New Project when the menu is in the AX tree, else
// in the AX tree. Mock CUPS may have enumerated a queue that the // N. Mock CUPS may have enumerated a queue that the fixture
// fixture does not record, making the session dirty in that // does not record, making the session dirty in that case
// case the dirty alert gates New first. // the dirty alert gates New first. macOS 12 alerts often lack
app.typeKey("n", modifierFlags: .command) // button identifiers, so confirm by title with id fallback.
triggerNewProject()
let deadline = Date().addingTimeInterval(10) let deadline = Date().addingTimeInterval(10)
var confirmed = false var confirmed = false
while Date() < deadline { while Date() < deadline {
// Dirty sessions show the dirty alert first; discarding it // Dirty sessions show the dirty alert first; discarding it
// runs the New reset directly (no second confirm). // runs the New reset directly (no second confirm).
if element("btnProjectDirtyDiscard").exists { if let discard = alertButton(
element("btnProjectDirtyDiscard").click() title: "Don't Save", id: "btnProjectDirtyDiscard") {
discard.click()
confirmed = true confirmed = true
break break
} }
if element("btnProjectNewConfirm").exists { if let start = alertButton(
element("btnProjectNewConfirm").click() title: "Start", id: "btnProjectNewConfirm") {
start.click()
confirmed = true confirmed = true
break break
} }