Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16df18127c | ||
|
|
78baa49e61 | ||
|
|
04a563c54a | ||
|
|
2cbd43470d | ||
|
|
14f9e6f78d | ||
|
|
f16825be72 | ||
|
|
9fcd21dc2d | ||
|
|
f5c57649e1 | ||
|
|
e967502ea7 | ||
|
|
eb3ae08cf9 | ||
|
|
3d8e70be62 | ||
|
|
f43c275152 | ||
|
|
48d2816c73 | ||
|
|
b494b4fffd | ||
|
|
c39afe0dfd |
@@ -30,19 +30,25 @@ jobs:
|
||||
fi
|
||||
echo "$line"
|
||||
|
||||
# Tag pushes whose name contains "prerelease" skip the test build and both
|
||||
# test legs: they exist to package a build already validated elsewhere.
|
||||
# The job still succeeds quickly so `package`'s `needs:` stays satisfied.
|
||||
# Homebrew's xcodegen formula requires Xcode 15.3, which cannot be
|
||||
# installed on macOS 12 (#109). The script installs a pinned
|
||||
# prebuilt release instead.
|
||||
- name: Ensure host tools
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: scripts/ensure-host-tools.sh
|
||||
|
||||
- name: Generate Xcode project
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: xcodegen generate --spec project.yml
|
||||
|
||||
# Tests only ever run on the runner's own architecture; build
|
||||
# just that slice. Packaging (scripts/package-release.sh) still
|
||||
# produces the universal Release binary.
|
||||
- name: Build for testing (host arch)
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: |
|
||||
xcodebuild build-for-testing \
|
||||
-scheme ICCery \
|
||||
@@ -60,6 +66,7 @@ jobs:
|
||||
# launch (run 31992) — so sign every embedded copy once the build is
|
||||
# done (embed steps run after any build script phase) (#119).
|
||||
- name: Sign package product frameworks
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: |
|
||||
find "$DERIVED/Build/Products/Debug" -depth -name '*_PackageProduct.framework' -print0 \
|
||||
| while IFS= read -r -d '' fw; do
|
||||
@@ -68,6 +75,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Test unit (ICCeryCoreTests)
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: |
|
||||
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
||||
if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then
|
||||
@@ -90,6 +98,7 @@ jobs:
|
||||
# tag packaging can proceed. Real XCTest assertion failures
|
||||
# still fail the job.
|
||||
- name: Test UI (ICCeryUITests)
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: |
|
||||
set -o pipefail
|
||||
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
||||
@@ -154,7 +163,7 @@ jobs:
|
||||
# xcresult on failure — upload it so UI failures can be triaged
|
||||
# without access to the runner (#126).
|
||||
- name: Prepare Node CA bundle (failure path)
|
||||
if: failure()
|
||||
if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: |
|
||||
NODE_CA_FILE="/tmp/macos-ca-bundle.pem"
|
||||
security find-certificate -a -p \
|
||||
@@ -166,7 +175,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload UI test xcresult
|
||||
if: failure()
|
||||
if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
uses: actions/upload-artifact@v3
|
||||
env:
|
||||
NODE_EXTRA_CA_CERTS: /tmp/macos-ca-bundle.pem
|
||||
|
||||
@@ -8,7 +8,7 @@ All measurement, chart generation, and profile mathematics live in the [Gronod A
|
||||
|---|---|
|
||||
| Product | ICCery v2 for macOS |
|
||||
| Bundle | `com.gronod.iccery2` |
|
||||
| Floor | macOS 14 Sonoma, universal `arm64` + `x86_64` |
|
||||
| Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` |
|
||||
| Default branch | `develop` |
|
||||
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` |
|
||||
| M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` |
|
||||
|
||||
@@ -97,6 +97,12 @@ enum UITestHooks {
|
||||
static var projectSaveURL: URL? { url("ICCERY_TEST_PROJECT_SAVE") }
|
||||
/// Relocate-folder result when a project's `cwd` is missing (#149).
|
||||
static var projectRelocateURL: URL? { url("ICCERY_TEST_PROJECT_RELOCATE") }
|
||||
/// Forces the gamut sheet into its no-Metal fallback even on a GPU
|
||||
/// host (#147). Set per-test only — never in a default launch env,
|
||||
/// or CI's future GPU run would skip SceneKit too.
|
||||
static var skipSceneKit: Bool {
|
||||
isEnabled && env["ICCERY_TEST_SKIP_SCENEKIT"] == "1"
|
||||
}
|
||||
|
||||
// MARK: - Print panel / CUPS stubs (issue 13/17)
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ struct GamutView: View {
|
||||
@StateObject private var viewModel: GamutViewModel
|
||||
@State private var pause: () -> Void = {}
|
||||
@FocusState private var isFocused: Bool
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Binding var showingAllHelp: Bool
|
||||
|
||||
init(
|
||||
@@ -94,6 +95,8 @@ struct GamutView: View {
|
||||
Divider().overlay(Theme.border)
|
||||
statusLine
|
||||
inspectPanel
|
||||
Divider().overlay(Theme.border)
|
||||
footer
|
||||
}
|
||||
.frame(minWidth: 720, minHeight: 520)
|
||||
.background(Theme.background)
|
||||
@@ -142,8 +145,12 @@ struct GamutView: View {
|
||||
Text(layer?.displayName ?? fallback)
|
||||
}
|
||||
.toggleStyle(.checkbox)
|
||||
.disabled(layer == nil || viewModel.viewerUnavailable)
|
||||
.disabled(layer == nil)
|
||||
.help(layer.map { $0.sourceURL.lastPathComponent } ?? "No profile .gam loaded")
|
||||
// macOS 12 puts the identifier on the Toggle's container, an
|
||||
// element that never reports isEnabled — combine so the a11y
|
||||
// leaf is the checkbox itself.
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityIdentifier("gamutLayer-\(id)")
|
||||
}
|
||||
|
||||
@@ -318,6 +325,21 @@ struct GamutView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Footer
|
||||
|
||||
/// Always-visible Close (#147) — the fallback banner keeps it
|
||||
/// reachable and Escape works via `.cancelAction` without SceneKit.
|
||||
private var footer: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Close") { dismiss() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.accessibilityIdentifier("btnCloseGamut")
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// MARK: - TIFF sample sheet
|
||||
|
||||
private var tiffPreviewSheet: some View {
|
||||
@@ -377,7 +399,9 @@ private struct GamutSceneView: NSViewRepresentable {
|
||||
context.coordinator.installKeyMonitor()
|
||||
context.coordinator.installClickGesture()
|
||||
|
||||
// No GPU → the docs/18 fallback; never respawn the view in a loop.
|
||||
// Safety net only — the primary no-Metal check is
|
||||
// `GamutSceneAvailability.isAvailable`, evaluated before this
|
||||
// view is mounted. Never respawn the view in a loop.
|
||||
if MTLCreateSystemDefaultDevice() == nil {
|
||||
DispatchQueue.main.async { onUnavailable() }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import ICCeryCore
|
||||
import Metal
|
||||
import simd
|
||||
|
||||
/// Whether the SceneKit gamut scene can render on this host (#147).
|
||||
///
|
||||
/// Checked **before** `GamutSceneView` is mounted — constructing an
|
||||
/// `SCNView` on a Metal-less machine can wedge the main thread, which
|
||||
/// also stalls app quit behind the open sheet.
|
||||
enum GamutSceneAvailability {
|
||||
static var isAvailable: Bool {
|
||||
if UITestHooks.skipSceneKit { return false }
|
||||
return MTLCreateSystemDefaultDevice() != nil
|
||||
}
|
||||
}
|
||||
|
||||
/// View model for the native SceneKit gamut viewer (issues #28, #147).
|
||||
///
|
||||
/// Loads the bundled `sRGB.gam` reference immediately, the workflow's own
|
||||
@@ -73,6 +86,8 @@ final class GamutViewModel: ObservableObject {
|
||||
init(environment: AppEnvironment, profileGamURL: URL? = nil) {
|
||||
self.environment = environment
|
||||
self.profileGamURL = profileGamURL
|
||||
// Never let the view mount an SCNView without Metal (#147).
|
||||
viewerUnavailable = !GamutSceneAvailability.isAvailable
|
||||
loadTask = Task { await load() }
|
||||
}
|
||||
|
||||
|
||||
@@ -46,9 +46,11 @@ struct SaveMediaRecipeDialog: View {
|
||||
|| media.captureColourSpaceMismatch
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Save Media Recipe").font(.title3).foregroundStyle(Theme.text)
|
||||
// Swift 5.7 (Xcode 14.2 CI runner) caps a ViewBuilder body at 10
|
||||
// children (#146); Group blocks are layout-transparent, so field
|
||||
// order and every docs/21 id are unchanged.
|
||||
private var fields: some View {
|
||||
Group {
|
||||
TextField("Name", text: $media.saveMediaName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityIdentifier("saveMediaName")
|
||||
@@ -61,7 +63,11 @@ struct SaveMediaRecipeDialog: View {
|
||||
TextField("Ink set", text: $media.saveMediaInk)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityIdentifier("saveMediaInk")
|
||||
}
|
||||
}
|
||||
|
||||
private var readOnlyRows: some View {
|
||||
Group {
|
||||
captureRow("Printer", value: printerCaption,
|
||||
identifier: "saveMediaPrinter")
|
||||
captureRow("Preset",
|
||||
@@ -74,6 +80,14 @@ struct SaveMediaRecipeDialog: View {
|
||||
value: workflow.profile.calibrationFile.isEmpty
|
||||
? "None" : workflow.profile.calibrationFile,
|
||||
identifier: "saveMediaCal")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Save Media Recipe").font(.title3).foregroundStyle(Theme.text)
|
||||
fields
|
||||
readOnlyRows
|
||||
Toggle("Apply calibration to profile",
|
||||
isOn: $media.saveMediaApplyCal)
|
||||
.disabled(!media.calApplyable)
|
||||
|
||||
@@ -37,6 +37,21 @@ struct SidebarView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
presetBlock
|
||||
mediaBlock
|
||||
studioButtons
|
||||
stepperAndProject
|
||||
}
|
||||
.frame(width: Theme.Metrics.sidebarWidth)
|
||||
.background(Theme.panel)
|
||||
}
|
||||
|
||||
// Swift 5.7 (Xcode 14.2 CI runner) caps a ViewBuilder body at 10
|
||||
// children (#146); these Group blocks are layout-transparent, so
|
||||
// visual order, ids and the 270 pt column are unchanged.
|
||||
private var header: some View {
|
||||
Group {
|
||||
HStack {
|
||||
Image("ICCery-logo")
|
||||
.resizable()
|
||||
@@ -65,7 +80,11 @@ struct SidebarView: View {
|
||||
.padding(12)
|
||||
|
||||
Divider().overlay(Theme.border)
|
||||
}
|
||||
}
|
||||
|
||||
private var presetBlock: some View {
|
||||
Group {
|
||||
// Preset select (`#presetSelect`) — issue #11. Selection
|
||||
// applies the preset immediately; names render via Text only.
|
||||
Picker("Preset", selection: Binding(
|
||||
@@ -97,7 +116,11 @@ struct SidebarView: View {
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private var mediaBlock: some View {
|
||||
Group {
|
||||
// Media library (`#mediaSelect`) — issue #146. Selection
|
||||
// applies the recipe immediately, like presets; names render
|
||||
// via Text only (#114). Never reuses `presetSelect` (#137).
|
||||
@@ -147,7 +170,11 @@ struct SidebarView: View {
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private var studioButtons: some View {
|
||||
Group {
|
||||
// Calibrate Printer (`#btnCalibratePrinter`).
|
||||
Button(action: { model.enterCalibration() }) {
|
||||
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
|
||||
@@ -187,7 +214,11 @@ struct SidebarView: View {
|
||||
showing: $showingAllHelp)
|
||||
.accessibilityIdentifier("btnSpotRead")
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
}
|
||||
|
||||
private var stepperAndProject: some View {
|
||||
Group {
|
||||
Divider().overlay(Theme.border)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
@@ -214,8 +245,6 @@ struct SidebarView: View {
|
||||
ProjectChip(project: project, showingAllHelp: $showingAllHelp)
|
||||
.padding(8)
|
||||
}
|
||||
.frame(width: Theme.Metrics.sidebarWidth)
|
||||
.background(Theme.panel)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Metal
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
@testable import ICCery
|
||||
@@ -36,6 +37,15 @@ final class GamutViewModelTests: XCTestCase {
|
||||
XCTAssertTrue(vm.status.contains("faces"), "status: \(vm.status)")
|
||||
}
|
||||
|
||||
/// #147 — `viewerUnavailable` is decided before any `SCNView` is
|
||||
/// mounted: it must exactly mirror Metal presence on this host.
|
||||
func testViewerUnavailableMirrorsMetalAvailability() async throws {
|
||||
let vm = try makeViewModel()
|
||||
XCTAssertEqual(
|
||||
vm.viewerUnavailable,
|
||||
MTLCreateSystemDefaultDevice() == nil)
|
||||
}
|
||||
|
||||
func testMissingCompareGamLeavesSRGBAndSetsNotice() async throws {
|
||||
let vm = try makeViewModel()
|
||||
await vm.awaitInitialLoad()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Metal
|
||||
import XCTest
|
||||
|
||||
/// Milestone 10 — Issue #147 gamut compare chrome tests.
|
||||
@@ -9,6 +10,11 @@ import XCTest
|
||||
@MainActor
|
||||
final class Milestone10GamutCompareUITests: XCTestCase {
|
||||
|
||||
/// Metal on the test host — the app under test runs on the same
|
||||
/// machine, so this predicts whether the sheet mounts SceneKit.
|
||||
/// GPU-less runners still get the banner/Close assertions (#147).
|
||||
private var hasGPU: Bool { MTLCreateSystemDefaultDevice() != nil }
|
||||
|
||||
private var app: XCUIApplication!
|
||||
private var testRoot: URL!
|
||||
private var binDir: URL!
|
||||
@@ -46,6 +52,10 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
// Never leave the gamut sheet up for `terminate()` (#147).
|
||||
if app != nil, element("btnCloseGamut").exists {
|
||||
element("btnCloseGamut").click()
|
||||
}
|
||||
app?.terminate()
|
||||
app = nil
|
||||
if let testRoot {
|
||||
@@ -75,6 +85,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) {
|
||||
@@ -88,18 +114,41 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
||||
_ = waitFor("gamutView")
|
||||
}
|
||||
|
||||
/// Inverse of `waitFor` — polls until the element leaves the tree.
|
||||
private func waitForGone(_ id: String, timeout: TimeInterval = 10) {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if !element(id).exists { return }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
XCTAssertFalse(element(id).exists, "Expected element \(id) to disappear")
|
||||
}
|
||||
|
||||
/// `btnCloseGamut` dismisses the sheet so `tearDown`'s `terminate()`
|
||||
/// is not stuck behind a key sheet (#147). No-op when already closed.
|
||||
private func closeGamutSheet() {
|
||||
let close = element("btnCloseGamut")
|
||||
guard close.waitForExistence(timeout: 5) else { return }
|
||||
close.click()
|
||||
waitForGone("gamutView")
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
let compare = waitFor("gamutLayer-compare")
|
||||
XCTAssertTrue(compare.exists)
|
||||
XCTAssertFalse(compare.isEnabled, "Compare toggle must be disabled before a load")
|
||||
|
||||
let status = waitFor("gamutStatusText")
|
||||
let statusValue = status.value as? String ?? ""
|
||||
XCTAssertTrue(statusValue.contains("sRGB"), "Status should list the sRGB layer, got: \(statusValue)")
|
||||
closeGamutSheet()
|
||||
}
|
||||
|
||||
func testAddCompareButtonExists() throws {
|
||||
@@ -113,6 +162,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
||||
let status = waitFor("gamutStatusText")
|
||||
let value = status.value as? String ?? ""
|
||||
XCTAssertTrue(value.contains("sRGB"), "Status should keep the sRGB clause, got: \(value)")
|
||||
closeGamutSheet()
|
||||
}
|
||||
|
||||
func testCompareGamLoadEnablesToggle() throws {
|
||||
@@ -124,8 +174,10 @@ 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")
|
||||
XCTAssertEqual(compare.value as? Int, 1, "Compare layer should be on after load")
|
||||
|
||||
let status = waitFor("gamutStatusText")
|
||||
let value = status.value as? String ?? ""
|
||||
@@ -133,6 +185,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
||||
|
||||
let remove = waitFor("btnGamutRemoveCompare")
|
||||
XCTAssertTrue(remove.isEnabled)
|
||||
closeGamutSheet()
|
||||
}
|
||||
|
||||
func testOpenProfileRunsIccgamutForCompare() throws {
|
||||
@@ -146,8 +199,15 @@ 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")
|
||||
XCTAssertEqual(compare.value as? Int, 1, "Compare layer should be on after iccgamut")
|
||||
|
||||
// The compare slot's display name is the .gam stem ("myprinter").
|
||||
let status = waitFor("gamutStatusText")
|
||||
let statusValue = status.value as? String ?? ""
|
||||
XCTAssertTrue(statusValue.contains("myprinter"), "Status should list the compare layer, got: \(statusValue)")
|
||||
closeGamutSheet()
|
||||
}
|
||||
|
||||
func testInspectPanelIdleStableHeight() throws {
|
||||
@@ -157,6 +217,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
||||
XCTAssertTrue(panel.exists)
|
||||
XCTAssertTrue(element("gamutInspectIdle").exists)
|
||||
XCTAssertTrue(element("gamutStatusText").exists)
|
||||
closeGamutSheet()
|
||||
}
|
||||
|
||||
func testManualLabInspectShowsContainment() throws {
|
||||
@@ -176,11 +237,55 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
||||
XCTAssertTrue(value.contains("in"), "Lab(50,0,0) should be inside sRGB, got: \(value)")
|
||||
XCTAssertTrue(element("gamutInspectL").exists)
|
||||
XCTAssertTrue(element("gamutInspectSwatch").exists)
|
||||
closeGamutSheet()
|
||||
}
|
||||
|
||||
func testResetIdentifierUnchanged() throws {
|
||||
openGamutSheet()
|
||||
let reset = waitFor("btnResetGamutCamera")
|
||||
XCTAssertTrue(reset.isEnabled)
|
||||
closeGamutSheet()
|
||||
}
|
||||
|
||||
/// `btnCloseGamut` is always enabled — including on the fallback
|
||||
/// banner — and dismisses the sheet (#147).
|
||||
func testCloseButtonDismissesSheet() throws {
|
||||
openGamutSheet()
|
||||
|
||||
let close = waitFor("btnCloseGamut")
|
||||
XCTAssertTrue(close.isEnabled)
|
||||
close.click()
|
||||
waitForGone("gamutView")
|
||||
}
|
||||
|
||||
/// The fallback banner exists exactly when the host lacks Metal —
|
||||
/// no `SCNView` is mounted on a GPU-less runner, and none may be
|
||||
/// reported unavailable on a GPU host.
|
||||
func testFallbackBannerMatchesGPUAvailability() throws {
|
||||
openGamutSheet()
|
||||
|
||||
if hasGPU {
|
||||
XCTAssertFalse(
|
||||
element("gamutViewerUnavailable").exists,
|
||||
"GPU host must mount the SceneKit view, not the fallback")
|
||||
} else {
|
||||
_ = waitFor("gamutViewerUnavailable")
|
||||
}
|
||||
closeGamutSheet()
|
||||
}
|
||||
|
||||
/// `ICCERY_TEST_SKIP_SCENEKIT=1` forces the fallback even on a GPU
|
||||
/// host — banner plus a working Close, no `SCNView` mounted (#147).
|
||||
/// The env is set for this test only; the default launch env must
|
||||
/// not carry it, or CI's future GPU run would skip SceneKit too.
|
||||
func testForcedSceneKitSkipShowsBannerAndClose() throws {
|
||||
app.launchEnvironment["ICCERY_TEST_SKIP_SCENEKIT"] = "1"
|
||||
openGamutSheet()
|
||||
|
||||
_ = waitFor("gamutViewerUnavailable")
|
||||
let close = waitFor("btnCloseGamut")
|
||||
XCTAssertTrue(close.isEnabled)
|
||||
close.click()
|
||||
waitForGone("gamutView")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -85,6 +85,31 @@ final class Milestone2UITests: XCTestCase {
|
||||
return el
|
||||
}
|
||||
|
||||
/// Exists **and** `isEnabled` — guards clicks against buttons that
|
||||
/// appear a beat before their `.disabled` condition clears.
|
||||
private func waitUntilEnabled(_ id: String, timeout: TimeInterval = 10) -> 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
|
||||
}
|
||||
|
||||
/// Non-asserting existence poll for the retry-or-fail pattern.
|
||||
private func existsAfter(_ id: String, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if element(id).exists { return true }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
return element(id).exists
|
||||
}
|
||||
|
||||
/// Assert an element stays absent after a short dwell — unlike
|
||||
/// `waitForExistence`, which always burns its full timeout on the
|
||||
/// negative path.
|
||||
@@ -181,8 +206,15 @@ final class Milestone2UITests: XCTestCase {
|
||||
XCTAssertTrue(element("tiffDpi").exists)
|
||||
XCTAssertTrue(element("targetLabelPreview").exists)
|
||||
|
||||
app.buttons["btnCreateLayout"].click()
|
||||
XCTAssertTrue(waitFor("galleryPage-0", timeout: 20).exists)
|
||||
// On the slow macOS 12 runner a synthesized click can land
|
||||
// while the button is still rebuilding — retry once if the
|
||||
// gallery never materialises, then allow a generous window
|
||||
// for the fixture printtarg + PNG render.
|
||||
waitUntilEnabled("btnCreateLayout").click()
|
||||
if !existsAfter("galleryPage-0", timeout: 15) {
|
||||
waitUntilEnabled("btnCreateLayout").click()
|
||||
}
|
||||
XCTAssertTrue(waitFor("galleryPage-0", timeout: 30).exists)
|
||||
XCTAssertTrue(FileManager.default.fileExists(
|
||||
atPath: workDir.appendingPathComponent("mytarget.ti2").path))
|
||||
|
||||
|
||||
@@ -113,6 +113,59 @@ final class Milestone3UITests: XCTestCase {
|
||||
return recordedLpArgv()
|
||||
}
|
||||
|
||||
/// Drags `#galleryPage-0`'s TIFF upward so `identifier`'s button
|
||||
/// moves up, clear of the Dock collision zone at the window's
|
||||
/// bottom edge (#132).
|
||||
///
|
||||
/// macOS overlay scrollbars are not in the AX tree — never use
|
||||
/// `app.scrollBars` — and a synthesized scroll wheel is inert on
|
||||
/// this LazyVGrid, so the scroll is a real drag on the gallery
|
||||
/// cell's content. A stale/off-screen AX frame resolves to a screen
|
||||
/// point that can be a Dock icon — a coordinate click there once
|
||||
/// opened Calendar instead of Print. Callers must click only when
|
||||
/// the returned element `isHittable`; never coordinate-click a
|
||||
/// stale frame.
|
||||
@discardableResult
|
||||
private func scrollStage2UntilHittable(
|
||||
_ identifier: String,
|
||||
timeout: TimeInterval = 20
|
||||
) -> XCUIElement {
|
||||
var button = app.buttons[identifier]
|
||||
let cell = app.descendants(matching: .any)["galleryPage-0"].firstMatch
|
||||
XCTAssertTrue(cell.waitForExistence(timeout: 10), "galleryPage-0")
|
||||
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let windowBottom = app.windows.firstMatch.frame.maxY
|
||||
if button.exists, button.isHittable,
|
||||
button.frame.maxY < windowBottom - 80 {
|
||||
return button
|
||||
}
|
||||
// Grab the upper half of the cell (the TIFF, not the Print
|
||||
// button / Dock) and drag toward the top of the window.
|
||||
// Mouse moves UP ⇒ gallery content moves UP ⇒ Print leaves
|
||||
// the Dock zone.
|
||||
if cell.isHittable {
|
||||
let start = cell.coordinate(withNormalizedOffset:
|
||||
CGVector(dx: 0.5, dy: 0.25))
|
||||
let end = start.withOffset(CGVector(dx: 0, dy: -280))
|
||||
start.press(forDuration: 0.15, thenDragTo: end)
|
||||
} else {
|
||||
// Cell not hit-testable: drag the stage-2 content
|
||||
// directly — still content, still never scrollBars.
|
||||
let scrollView = app.scrollViews["stage-2"]
|
||||
scrollView.coordinate(withNormalizedOffset:
|
||||
CGVector(dx: 0.5, dy: 0.55))
|
||||
.press(forDuration: 0.15, thenDragTo:
|
||||
scrollView.coordinate(withNormalizedOffset:
|
||||
CGVector(dx: 0.5, dy: 0.15)))
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
|
||||
button = app.buttons[identifier]
|
||||
}
|
||||
return button
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
/// Panel appears after the manifest; refresh populates the printer
|
||||
@@ -199,36 +252,16 @@ final class Milestone3UITests: XCTestCase {
|
||||
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
|
||||
|
||||
// The gallery cell's Print button sits at the window's bottom
|
||||
// edge where synthesized scroll-wheel events are inert on the
|
||||
// LazyVGrid (#132). Drag the NSScrollView's vertical AXScrollBar
|
||||
// thumb instead — a real scroll that re-renders the cell onscreen.
|
||||
var printPage = app.buttons["btnPrintPage-0"]
|
||||
let scrollDeadline = Date().addingTimeInterval(15)
|
||||
while !printPage.isHittable, Date() < scrollDeadline {
|
||||
let scroller = app.scrollBars.allElementsBoundByIndex
|
||||
.first { $0.frame.height > $0.frame.width }
|
||||
if let scroller {
|
||||
scroller.coordinate(withNormalizedOffset:
|
||||
CGVector(dx: 0.5, dy: 0.1))
|
||||
.press(forDuration: 0.1, thenDragTo:
|
||||
scroller.coordinate(withNormalizedOffset:
|
||||
CGVector(dx: 0.5, dy: 0.6)))
|
||||
} else {
|
||||
app.scrollViews["stage-2"].scroll(byDeltaX: 0, deltaY: -1)
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||
printPage = app.buttons["btnPrintPage-0"]
|
||||
}
|
||||
if printPage.isHittable {
|
||||
printPage.click()
|
||||
} else {
|
||||
// LazyVGrid cells can report a stale a11y frame — click the
|
||||
// point directly; the lp argv assert below still verifies.
|
||||
// edge; scroll until it is genuinely hittable (#132). Never
|
||||
// coordinate-click a stale frame — that point can be the Dock.
|
||||
let printPage = scrollStage2UntilHittable("btnPrintPage-0")
|
||||
guard printPage.isHittable else {
|
||||
print("AXTREE-BEGIN frame=\(printPage.frame)\n" +
|
||||
"\(app.debugDescription)\nAXTREE-END")
|
||||
printPage.coordinate(withNormalizedOffset:
|
||||
CGVector(dx: 0.5, dy: 0.5)).click()
|
||||
XCTFail("btnPrintPage-0 never became hittable; frame=\(printPage.frame)")
|
||||
return
|
||||
}
|
||||
printPage.click()
|
||||
let argv = waitForLpLine()
|
||||
XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv)
|
||||
XCTAssertTrue(argv.contains("page1.tif"), argv)
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import Foundation
|
||||
import Metal
|
||||
import XCTest
|
||||
|
||||
/// Milestone 6 — Issue #28 native SceneKit gamut viewer acceptance tests.
|
||||
@MainActor
|
||||
final class Milestone6GamutUITests: XCTestCase {
|
||||
|
||||
/// Metal on the test host — the app under test runs on the same
|
||||
/// machine, so this predicts whether the sheet mounts SceneKit.
|
||||
private var hasGPU: Bool { MTLCreateSystemDefaultDevice() != nil }
|
||||
|
||||
private var app: XCUIApplication!
|
||||
private var testRoot: URL!
|
||||
private var binDir: URL!
|
||||
@@ -64,6 +69,10 @@ final class Milestone6GamutUITests: XCTestCase {
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
// Never leave the gamut sheet up for `terminate()` (#147).
|
||||
if app != nil, element("btnCloseGamut").exists {
|
||||
element("btnCloseGamut").click()
|
||||
}
|
||||
app?.terminate()
|
||||
app = nil
|
||||
if let testRoot {
|
||||
@@ -90,10 +99,29 @@ final class Milestone6GamutUITests: XCTestCase {
|
||||
return el
|
||||
}
|
||||
|
||||
/// Inverse of `waitFor` — polls until the element leaves the tree.
|
||||
private func waitForGone(_ id: String, timeout: TimeInterval = 10) {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if !element(id).exists { return }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
XCTAssertFalse(element(id).exists, "Expected element \(id) to disappear")
|
||||
}
|
||||
|
||||
/// `btnCloseGamut` dismisses the sheet so `tearDown`'s `terminate()`
|
||||
/// is not stuck behind a key sheet (#147). No-op when already closed.
|
||||
private func closeGamutSheet() {
|
||||
let close = element("btnCloseGamut")
|
||||
guard close.waitForExistence(timeout: 5) else { return }
|
||||
close.click()
|
||||
waitForGone("gamutView")
|
||||
}
|
||||
|
||||
/// Build and verify the mock profile, then open the native gamut viewer.
|
||||
/// The viewer should load both the reference sRGB mesh and the profile
|
||||
/// gamut copied from that reference.
|
||||
func testViewGamutOpensSceneKitSheet() throws {
|
||||
private func openGamutSheet() {
|
||||
app.launch()
|
||||
if !app.wait(for: .runningForeground, timeout: 10) {
|
||||
app.activate()
|
||||
@@ -105,6 +133,12 @@ final class Milestone6GamutUITests: XCTestCase {
|
||||
|
||||
waitFor("btnViewGamut").click()
|
||||
|
||||
_ = waitFor("gamutView")
|
||||
}
|
||||
|
||||
func testViewGamutOpensSceneKitSheet() throws {
|
||||
openGamutSheet()
|
||||
|
||||
let gamutView = waitFor("gamutView")
|
||||
XCTAssertTrue(gamutView.exists)
|
||||
|
||||
@@ -112,9 +146,33 @@ final class Milestone6GamutUITests: XCTestCase {
|
||||
let value = status.value as? String ?? ""
|
||||
XCTAssertTrue(value.contains("faces"), "Gamut status should report mesh faces, got: \(value)")
|
||||
|
||||
// The fallback banner appears exactly when the host lacks Metal
|
||||
// — no SCNView is constructed without a GPU (#147).
|
||||
if hasGPU {
|
||||
XCTAssertFalse(
|
||||
element("gamutViewerUnavailable").exists,
|
||||
"GPU host must mount the SceneKit view, not the fallback")
|
||||
} else {
|
||||
_ = waitFor("gamutViewerUnavailable")
|
||||
}
|
||||
|
||||
// The reset button demonstrates that the viewer is interactive.
|
||||
let reset = waitFor("btnResetGamutCamera")
|
||||
XCTAssertTrue(reset.isEnabled)
|
||||
|
||||
closeGamutSheet()
|
||||
}
|
||||
|
||||
/// Clicking Reset drives the live `SCNView` — runs only on Metal
|
||||
/// hosts, skipped on GPU-less runners so the same suite exercises
|
||||
/// 3D once CI has a GPU (#147).
|
||||
func testResetCameraInteractsWithScene() throws {
|
||||
guard hasGPU else { throw XCTSkip("No Metal") }
|
||||
openGamutSheet()
|
||||
|
||||
let reset = waitFor("btnResetGamutCamera")
|
||||
reset.click()
|
||||
|
||||
closeGamutSheet()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ Keyboard: **R** resets gamut camera when Stage 5 is visible. Bind to a focusable
|
||||
| Profile install collision | `profileInstallCollisionDialog` | `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn` |
|
||||
| Spot read | `spotReadView` | `btnSpotDetectInstruments`, `spotDetectError`, `spotInstrumentSelect`, `spotDefaultMissing`, `spotSetDefault`, `spotXYHint`, `spotPrompt`, `spotLastError`, `spotLogContainer`, `spotLog`, `btnSpotStart`, `btnSpotCalibrate`, `btnSpotTrigger`, `btnSpotStop`, `spotLastSample`, `spotLastEmpty`, `spotLabL`, `spotLabA`, `spotLabB`, `spotXYZ`, `spotSwatch`, `spotDeltaE`, `spotLastInstrument`, `spotLabImplausible`, `spotHistoryTable`, `spotHistoryEmpty`, `spotHistoryRow-{uuid}`, `btnSpotCopyLab`, `btnSpotExportCsv`, `spotSidecarMissing`, `btnCloseSpotRead` |
|
||||
| Project relocate | `projectRelocateSheet` | `btnProjectRelocate`, `btnProjectRelocateCancel` |
|
||||
| Gamut viewer | `gamutView` | `gamutLayer-sRGB`, `gamutLayer-profile`, `gamutLayer-compare`, `btnGamutAddCompare`, `btnGamutOpenGam`, `btnGamutOpenProfile`, `btnGamutRemoveCompare`, `btnGamutSampleTiff`, `btnResetGamutCamera`, `gamutStatusText`, `gamutNoticeText`, `gamutInspectPanel`, `gamutInspectIdle`, `gamutInspectL`, `gamutInspectA`, `gamutInspectB`, `gamutInspect-sRGB`, `gamutInspect-profile`, `gamutInspect-compare`, `gamutInspectSwatch`, `gamutInspectApprox`, `gamutLabEntryL`, `gamutLabEntryA`, `gamutLabEntryB`, `btnGamutInspectLab`, `gamutTiffPreview`, `btnCloseGamutTiffPreview`, `gamutViewerUnavailable` |
|
||||
| Gamut viewer | `gamutView` | `gamutLayer-sRGB`, `gamutLayer-profile`, `gamutLayer-compare`, `btnGamutAddCompare`, `btnGamutOpenGam`, `btnGamutOpenProfile`, `btnGamutRemoveCompare`, `btnGamutSampleTiff`, `btnResetGamutCamera`, `gamutStatusText`, `gamutNoticeText`, `gamutInspectPanel`, `gamutInspectIdle`, `gamutInspectL`, `gamutInspectA`, `gamutInspectB`, `gamutInspect-sRGB`, `gamutInspect-profile`, `gamutInspect-compare`, `gamutInspectSwatch`, `gamutInspectApprox`, `gamutLabEntryL`, `gamutLabEntryA`, `gamutLabEntryB`, `btnGamutInspectLab`, `gamutTiffPreview`, `btnCloseGamutTiffPreview`, `gamutViewerUnavailable`, `btnCloseGamut` |
|
||||
|
||||
## Dialogs must go through host APIs
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Persisted at `{app_data}/settings.json` via `load_settings` / `save_settings`. I
|
||||
| Field | Default | Notes |
|
||||
|-------|---------|-------|
|
||||
| `argyll_binary_dir` | `null` | Overrides bundled sidecars. `resolve_binary` checks this first. |
|
||||
| `default_instrument` | `null` | **Stored but not applied to argv.** Stage 2 `#instrumentSelect` is the source of truth. Do not start honouring this without an explicit product decision. |
|
||||
| `default_instrument` | `null` | Stored; seeds the Spot Read instrument picker when that instrument is present (#148). **Never** written into `printtarg -i` or `targen` argv. Stage 2 `#instrumentSelect` remains the printtarg instrument. |
|
||||
| `log_level` | `null` | `error` / `warn` / `info` / `debug` / `trace`. `null` → Debug in debug builds, Info in release. Applied at startup **and** on save (#158). |
|
||||
| `delta_e_good_max` | `2.0` | Stage 3 swatch traffic-light "Good". Must be ≥ 0. |
|
||||
| `delta_e_warning_max` | `5.0` | Stage 3 "Warning" band. Must be **strictly greater** than good. |
|
||||
|
||||
@@ -30,6 +30,11 @@ if [ -z "$TAG" ] || [ "$TAG" = "${GITHUB_REF:-}" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$TAG" in
|
||||
*prerelease*) PRERELEASE=true ;;
|
||||
*) PRERELEASE=false ;;
|
||||
esac
|
||||
|
||||
DMG="${1:-}"
|
||||
if [ -z "$DMG" ]; then
|
||||
DMG="$(ls -1 ICCery-*.dmg 2>/dev/null | head -n 1 || true)"
|
||||
@@ -49,12 +54,12 @@ STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \
|
||||
"$API/repos/$REPO/releases/tags/$TAG" || true)"
|
||||
|
||||
if [ "$STATUS" = "404" ]; then
|
||||
echo "==> Creating release $TAG"
|
||||
echo "==> Creating release $TAG (prerelease=$PRERELEASE)"
|
||||
STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST "$API/repos/$REPO/releases" \
|
||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"prerelease\":true,\"target_commitish\":\"${GITHUB_SHA:-}\"}")"
|
||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"prerelease\":$PRERELEASE,\"target_commitish\":\"${GITHUB_SHA:-}\"}")"
|
||||
fi
|
||||
if [ "$STATUS" != "200" ] && [ "$STATUS" != "201" ]; then
|
||||
echo "error: could not load/create release $TAG (HTTP $STATUS)" >&2
|
||||
|
||||
Reference in New Issue
Block a user