Compare commits

..
2 Commits
Author SHA1 Message Date
gronod e967502ea7 Merge pull request 'test(m10): wait for gamut layer enable; drive New via File menu on macOS 12' (#158) from fix/m10-ci-ui-macos12 into milestone/m10-studio
macOS CI / build-and-test (pull_request) Failing after 32m15s
macOS CI / package (pull_request) Skipped
2026-09-13 14:59:45 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> eb3ae08cf9 test(m10): wait for gamut layer enable; drive New via File menu on macOS 12
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>
2026-09-13 14:59:11 +01:00
2 changed files with 68 additions and 15 deletions
@@ -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")
}
@@ -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
}