diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f2aa4f7..c334034 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,4 +1,4 @@ -name: Unit Tests +name: Tests on: pull_request: @@ -10,7 +10,7 @@ permissions: contents: read jobs: - test: + unit-tests: runs-on: macos-26 steps: - uses: actions/checkout@v4 @@ -23,3 +23,17 @@ jobs: -destination "platform=macOS" \ CODE_SIGN_IDENTITY="-" \ CODE_SIGNING_REQUIRED=NO + + ui-tests: + runs-on: macos-26 + steps: + - uses: actions/checkout@v4 + + - name: Run UI tests + run: | + xcodebuild test \ + -project "PPPC Utility.xcodeproj" \ + -scheme "PPPC Utility UI Tests" \ + -destination "platform=macOS" \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGNING_REQUIRED=NO diff --git a/CLAUDE.md b/CLAUDE.md index afe70f8..0ab1aee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,14 @@ - Beyond 3 params: create separate tests with some values hard-coded - Use `deinit` as teardown for repeated cleanup across tests in a suite. Use `class` for suites that need `deinit`; use `struct` otherwise. +## UI Testing Conventions + +- UI tests use XCTest (XCUITest), not Swift Testing — the UI test target uses Swift 5 with minimal concurrency checking +- Prefer **multiple assertions per test** to minimize app launches. Each test method relaunches the app, which is expensive. Group related checks (e.g., verify all buttons exist in one test) rather than one assertion per test. +- Do not use `// when` / `// then` comment blocks in UI tests — they add noise without clarity in assertion-heavy tests +- Use accessibility identifiers set in `setupAccessibilityIdentifiers()` to locate UI elements +- The `-UITestMode` launch argument triggers test-specific setup (e.g., loading a test profile) + ## Git - Do not stage or commit changes in terminal sessions diff --git a/PPPC Utility UI Tests.xctestplan b/PPPC Utility UI Tests.xctestplan new file mode 100644 index 0000000..4e9a536 --- /dev/null +++ b/PPPC Utility UI Tests.xctestplan @@ -0,0 +1,29 @@ +{ + "configurations" : [ + { + "id" : "C4300CCF-D433-4368-B895-3721F691DA4D", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "codeCoverage" : true, + "targetForVariableExpansion" : { + "containerPath" : "container:PPPC Utility.xcodeproj", + "identifier" : "6EC409D9214D65BC00BE4F17", + "name" : "PPPC Utility" + } + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:PPPC Utility.xcodeproj", + "identifier" : "AA0001000000000000000001", + "name" : "PPPC UtilityUITests" + } + } + ], + "version" : 1 +} diff --git a/PPPC Utility.xcodeproj/project.pbxproj b/PPPC Utility.xcodeproj/project.pbxproj index d2c1e4c..ce1dfa7 100644 --- a/PPPC Utility.xcodeproj/project.pbxproj +++ b/PPPC Utility.xcodeproj/project.pbxproj @@ -67,6 +67,8 @@ C0EE9A7F2863BDE300738B6B /* JamfProAPITypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0EE9A7E2863BDE300738B6B /* JamfProAPITypes.swift */; }; C0EE9A812863BE2B00738B6B /* NetworkAuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0EE9A802863BE2B00738B6B /* NetworkAuthManager.swift */; }; D023DD16033D452488B41741 /* ArrayExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5B34EC5FC52B5B4C9D0258 /* ArrayExtensionTests.swift */; }; +AA000E00000000000000000E /* AppLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000B00000000000000000B /* AppLaunchTests.swift */; }; +AA001200000000000000001C /* TestTCCUnsignedProfile.mobileconfig in Resources */ = {isa = PBXBuildFile; fileRef = AA001100000000000000001B /* TestTCCUnsignedProfile.mobileconfig */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -77,6 +79,13 @@ remoteGlobalIDString = 6EC409D9214D65BC00BE4F17; remoteInfo = "PPPC Utility"; }; +AA0006000000000000000006 /* PBXContainerItemProxy */ = { +isa = PBXContainerItemProxy; +containerPortal = 6EC409D2214D65BC00BE4F17 /* Project object */; +proxyType = 1; +remoteGlobalIDString = 6EC409D9214D65BC00BE4F17; +remoteInfo = "PPPC Utility"; +}; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ @@ -147,6 +156,11 @@ DAC55385D89BDBD447395AF1 /* TCCPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCCPolicyTests.swift; sourceTree = ""; }; F3DBEDB941B7AEAE7BFF2776 /* MockURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLProtocol.swift; sourceTree = ""; }; F82A8F992F85984800A72E5C /* PPPC Utility.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "PPPC Utility.xctestplan"; sourceTree = ""; }; + AA002100000000000000002B /* PPPC Utility UI Tests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "PPPC Utility UI Tests.xctestplan"; sourceTree = ""; }; +AA000B00000000000000000B /* AppLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLaunchTests.swift; sourceTree = ""; }; +AA000D00000000000000000D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +AA0005000000000000000005 /* PPPC UtilityUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "PPPC UtilityUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; +AA001100000000000000001B /* TestTCCUnsignedProfile.mobileconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = TestTCCUnsignedProfile.mobileconfig; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -165,6 +179,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; +AA0003000000000000000003 /* Frameworks */ = { +isa = PBXFrameworksBuildPhase; +buildActionMask = 2147483647; +files = ( +); +runOnlyForDeploymentPostprocessing = 0; +}; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -269,6 +290,7 @@ isa = PBXGroup; children = ( F82A8F992F85984800A72E5C /* PPPC Utility.xctestplan */, + AA002100000000000000002B /* PPPC Utility UI Tests.xctestplan */, 6E95730721553B650002C30B /* LICENSE */, 97227C6726248CD7000F26C1 /* CHANGELOG.md */, 6E5D5A1521541B8F00B43312 /* README.md */, @@ -276,6 +298,7 @@ 5F95AE1423158EF0002E0A22 /* External */, 6EC409F6214D975A00BE4F17 /* Resources */, 5F95AE1C2315A6AD002E0A22 /* PPPC UtilityTests */, + AA001000000000000000001A /* PPPC UtilityUITests */, 6EC409DB214D65BC00BE4F17 /* Products */, ); sourceTree = ""; @@ -285,6 +308,7 @@ children = ( 6EC409DA214D65BC00BE4F17 /* PPPC Utility.app */, 5F95AE1B2315A6AD002E0A22 /* PPPC UtilityTests.xctest */, + AA0005000000000000000005 /* PPPC UtilityUITests.xctest */, ); name = Products; sourceTree = ""; @@ -313,6 +337,7 @@ 6EC409E6214D65BD00BE4F17 /* Info.plist */, C0E0384127A30D1D00A23FA2 /* PPPCServices.json */, 6E957309215557870002C30B /* PPPC Utility.entitlements */, + AA001100000000000000001B /* TestTCCUnsignedProfile.mobileconfig */, ); path = Resources; sourceTree = ""; @@ -378,6 +403,15 @@ path = NetworkingTests; sourceTree = ""; }; +AA001000000000000000001A /* PPPC UtilityUITests */ = { +isa = PBXGroup; +children = ( +AA000B00000000000000000B /* AppLaunchTests.swift */, +AA000D00000000000000000D /* Info.plist */, +); +path = "PPPC UtilityUITests"; +sourceTree = ""; +}; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -419,6 +453,24 @@ productReference = 6EC409DA214D65BC00BE4F17 /* PPPC Utility.app */; productType = "com.apple.product-type.application"; }; +AA0001000000000000000001 /* PPPC UtilityUITests */ = { +isa = PBXNativeTarget; +buildConfigurationList = AA0008000000000000000008 /* Build configuration list for PBXNativeTarget "PPPC UtilityUITests" */; +buildPhases = ( +AA0002000000000000000002 /* Sources */, +AA0003000000000000000003 /* Frameworks */, +AA0004000000000000000004 /* Resources */, +); +buildRules = ( +); +dependencies = ( +AA0007000000000000000007 /* PBXTargetDependency */, +); +name = "PPPC UtilityUITests"; +productName = "PPPC UtilityUITests"; +productReference = AA0005000000000000000005 /* PPPC UtilityUITests.xctest */; +productType = "com.apple.product-type.bundle.ui-testing"; +}; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -434,6 +486,10 @@ LastSwiftMigration = 1020; TestTargetID = 6EC409D9214D65BC00BE4F17; }; + AA0001000000000000000001 = { + CreatedOnToolsVersion = 16.0; + TestTargetID = 6EC409D9214D65BC00BE4F17; + }; 6EC409D9214D65BC00BE4F17 = { CreatedOnToolsVersion = 10.0; LastSwiftMigration = 1100; @@ -466,6 +522,7 @@ targets = ( 6EC409D9214D65BC00BE4F17 /* PPPC Utility */, 5F95AE1A2315A6AD002E0A22 /* PPPC UtilityTests */, + AA0001000000000000000001 /* PPPC UtilityUITests */, ); }; /* End PBXProject section */ @@ -491,9 +548,17 @@ 6EC409E2214D65BD00BE4F17 /* Assets.xcassets in Resources */, C0E0384227A30D1D00A23FA2 /* PPPCServices.json in Resources */, 6EC409E5214D65BD00BE4F17 /* Main.storyboard in Resources */, + AA001200000000000000001C /* TestTCCUnsignedProfile.mobileconfig in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; +AA0004000000000000000004 /* Resources */ = { +isa = PBXResourcesBuildPhase; +buildActionMask = 2147483647; +files = ( +); +runOnlyForDeploymentPostprocessing = 0; +}; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -561,6 +626,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; +AA0002000000000000000002 /* Sources */ = { +isa = PBXSourcesBuildPhase; +buildActionMask = 2147483647; +files = ( +AA000E00000000000000000E /* AppLaunchTests.swift in Sources */, +); +runOnlyForDeploymentPostprocessing = 0; +}; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -569,6 +642,11 @@ target = 6EC409D9214D65BC00BE4F17 /* PPPC Utility */; targetProxy = 5F95AE202315A6AD002E0A22 /* PBXContainerItemProxy */; }; +AA0007000000000000000007 /* PBXTargetDependency */ = { +isa = PBXTargetDependency; +target = 6EC409D9214D65BC00BE4F17 /* PPPC Utility */; +targetProxy = AA0006000000000000000006 /* PBXContainerItemProxy */; +}; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -809,6 +887,55 @@ }; name = Release; }; +AA0009000000000000000009 /* Debug */ = { +isa = XCBuildConfiguration; +buildSettings = { +CODE_SIGN_IDENTITY = "Apple Development"; +"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; +CODE_SIGN_STYLE = Manual; +COMBINE_HIDPI_IMAGES = YES; +DEVELOPMENT_TEAM = ""; +INFOPLIST_FILE = "PPPC UtilityUITests/Info.plist"; +LD_RUNPATH_SEARCH_PATHS = ( +"$(inherited)", +"@executable_path/../Frameworks", +"@loader_path/../Frameworks", +); +PRODUCT_BUNDLE_IDENTIFIER = "com.jamf.PPPC-UtilityUITests"; +PRODUCT_NAME = "$(TARGET_NAME)"; +PROVISIONING_PROFILE_SPECIFIER = ""; +SWIFT_APPROACHABLE_CONCURRENCY = YES; +SWIFT_OPTIMIZATION_LEVEL = "-Onone"; +SWIFT_STRICT_CONCURRENCY = minimal; +SWIFT_VERSION = 5.0; +TEST_TARGET_NAME = "PPPC Utility"; +}; +name = Debug; +}; +AA000A00000000000000000A /* Release */ = { +isa = XCBuildConfiguration; +buildSettings = { +CODE_SIGN_IDENTITY = "Apple Development"; +"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; +CODE_SIGN_STYLE = Manual; +COMBINE_HIDPI_IMAGES = YES; +DEVELOPMENT_TEAM = ""; +INFOPLIST_FILE = "PPPC UtilityUITests/Info.plist"; +LD_RUNPATH_SEARCH_PATHS = ( +"$(inherited)", +"@executable_path/../Frameworks", +"@loader_path/../Frameworks", +); +PRODUCT_BUNDLE_IDENTIFIER = "com.jamf.PPPC-UtilityUITests"; +PRODUCT_NAME = "$(TARGET_NAME)"; +PROVISIONING_PROFILE_SPECIFIER = ""; +SWIFT_APPROACHABLE_CONCURRENCY = YES; +SWIFT_STRICT_CONCURRENCY = minimal; +SWIFT_VERSION = 5.0; +TEST_TARGET_NAME = "PPPC Utility"; +}; +name = Release; +}; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -839,6 +966,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; +AA0008000000000000000008 /* Build configuration list for PBXNativeTarget "PPPC UtilityUITests" */ = { +isa = XCConfigurationList; +buildConfigurations = ( +AA0009000000000000000009 /* Debug */, +AA000A00000000000000000A /* Release */, +); +defaultConfigurationIsVisible = 0; +defaultConfigurationName = Release; +}; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/PPPC Utility.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/PPPC Utility.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 90ca261..08fbe69 100644 --- a/PPPC Utility.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/PPPC Utility.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "a1bc04ca45476ec303ba87ffcb6060d93e81550701340ec8355671215edfb5d9", "pins" : [ { "identity" : "haversack", @@ -19,5 +20,5 @@ } } ], - "version" : 2 + "version" : 3 } diff --git a/PPPC Utility.xcodeproj/xcshareddata/xcschemes/PPPC Utility UI Tests.xcscheme b/PPPC Utility.xcodeproj/xcshareddata/xcschemes/PPPC Utility UI Tests.xcscheme new file mode 100644 index 0000000..3770336 --- /dev/null +++ b/PPPC Utility.xcodeproj/xcshareddata/xcschemes/PPPC Utility UI Tests.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PPPC UtilityUITests/AppLaunchTests.swift b/PPPC UtilityUITests/AppLaunchTests.swift new file mode 100644 index 0000000..6c736e1 --- /dev/null +++ b/PPPC UtilityUITests/AppLaunchTests.swift @@ -0,0 +1,82 @@ +// +// AppLaunchTests.swift +// PPPC UtilityUITests +// +// MIT License +// +// Copyright (c) 2018 Jamf Software +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import XCTest + +final class AppLaunchTests: XCTestCase { + + private var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + app.launch() + } + + override func tearDownWithError() throws { + app.terminate() + app = nil + } + + func testMainWindowAndTables() { + let window = app.windows.firstMatch + XCTAssertTrue(window.exists, "Main window should exist after launch") + XCTAssertTrue(window.isHittable, "Main window should be hittable") + + let executablesTable = app.tables["ExecutablesTable"] + XCTAssertTrue(executablesTable.waitForExistence(timeout: 5), "Executables table should exist") + + let appleEventsTable = app.tables["AppleEventsTable"] + XCTAssertTrue(appleEventsTable.waitForExistence(timeout: 5), "Apple Events table should exist") + } + + func testToolbarButtons() { + let saveButton = app.buttons["SaveButton"] + XCTAssertTrue(saveButton.waitForExistence(timeout: 5), "Save button should exist") + XCTAssertFalse(saveButton.isEnabled, "Save button should be disabled with no executables") + + let uploadButton = app.buttons["UploadButton"] + XCTAssertTrue(uploadButton.waitForExistence(timeout: 5), "Upload button should exist") + XCTAssertFalse(uploadButton.isEnabled, "Upload button should be disabled with no executables") + + let addExecutableButton = app.buttons["AddExecutableButton"] + XCTAssertTrue(addExecutableButton.waitForExistence(timeout: 5), "Add executable button should exist") + XCTAssertTrue(addExecutableButton.isEnabled, "Add executable button should always be enabled") + + let removeExecutableButton = app.buttons["RemoveExecutableButton"] + XCTAssertTrue(removeExecutableButton.waitForExistence(timeout: 5), "Remove executable button should exist") + XCTAssertFalse(removeExecutableButton.isEnabled, "Remove executable button should be disabled with no selection") + + let addAppleEventButton = app.buttons["AddAppleEventButton"] + XCTAssertTrue(addAppleEventButton.waitForExistence(timeout: 5), "Add Apple Event button should exist") + XCTAssertFalse(addAppleEventButton.isEnabled, "Add Apple Event button should be disabled with no executable selected") + + let removeAppleEventButton = app.buttons["RemoveAppleEventButton"] + XCTAssertTrue(removeAppleEventButton.waitForExistence(timeout: 5), "Remove Apple Event button should exist") + XCTAssertFalse(removeAppleEventButton.isEnabled, "Remove Apple Event button should be disabled with no selection") + } +} diff --git a/PPPC UtilityUITests/Info.plist b/PPPC UtilityUITests/Info.plist new file mode 100644 index 0000000..64d65ca --- /dev/null +++ b/PPPC UtilityUITests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/Resources/Base.lproj/Main.storyboard b/Resources/Base.lproj/Main.storyboard index 5856c46..501e3e0 100644 --- a/Resources/Base.lproj/Main.storyboard +++ b/Resources/Base.lproj/Main.storyboard @@ -2459,6 +2459,7 @@ + diff --git a/Resources/TestTCCUnsignedProfile.mobileconfig b/Resources/TestTCCUnsignedProfile.mobileconfig new file mode 100644 index 0000000..9092b0c --- /dev/null +++ b/Resources/TestTCCUnsignedProfile.mobileconfig @@ -0,0 +1,575 @@ + + + + + PayloadContent + + + PayloadDescription + Test Unsigned Profile + PayloadDisplayName + TestUnsignedProfile + PayloadIdentifier + 3A4EDE0C-A189-4372-953F-304ECA0B6489 + PayloadOrganization + Jamf + PayloadType + com.apple.TCC.configuration-profile-policy + PayloadUUID + 7CAF0BD6-D1DB-486E-9388-CC7F688C51E7 + PayloadVersion + 1 + Services + + Accessibility + + + Allowed + + CodeRequirement + identifier "io.brackets.appshell" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = JQ525L2MZD + Comment + + Identifier + io.brackets.appshell + IdentifierType + bundleID + + + Allowed + + CodeRequirement + identifier "com.jamfsoftware.JamfAdmin" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "483DWKW443" + Comment + + Identifier + com.jamfsoftware.JamfAdmin + IdentifierType + bundleID + + + AddressBook + + + Allowed + + CodeRequirement + identifier "io.brackets.appshell" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = JQ525L2MZD + Comment + + Identifier + io.brackets.appshell + IdentifierType + bundleID + + + Allowed + + CodeRequirement + identifier "com.jamfsoftware.JamfAdmin" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "483DWKW443" + Comment + + Identifier + com.jamfsoftware.JamfAdmin + IdentifierType + bundleID + + + AppleEvents + + + AEReceiverCodeRequirement + identifier "com.apple.finder" and anchor apple + AEReceiverIdentifier + com.apple.finder + AEReceiverIdentifierType + bundleID + Allowed + + CodeRequirement + identifier "io.brackets.appshell" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = JQ525L2MZD + Comment + + Identifier + io.brackets.appshell + IdentifierType + bundleID + + + AEReceiverCodeRequirement + identifier "com.apple.systemuiserver" and anchor apple + AEReceiverIdentifier + com.apple.systemuiserver + AEReceiverIdentifierType + bundleID + Allowed + + CodeRequirement + identifier "io.brackets.appshell" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = JQ525L2MZD + Comment + + Identifier + io.brackets.appshell + IdentifierType + bundleID + + + AEReceiverCodeRequirement + identifier "com.apple.finder" and anchor apple + AEReceiverIdentifier + com.apple.finder + AEReceiverIdentifierType + bundleID + Allowed + + CodeRequirement + identifier "com.jamfsoftware.JamfAdmin" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "483DWKW443" + Comment + + Identifier + com.jamfsoftware.JamfAdmin + IdentifierType + bundleID + + + AEReceiverCodeRequirement + identifier "io.brackets.appshell" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = JQ525L2MZD + AEReceiverIdentifier + io.brackets.appshell + AEReceiverIdentifierType + bundleID + Allowed + + CodeRequirement + identifier "com.jamfsoftware.JamfAdmin" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "483DWKW443" + Comment + + Identifier + com.jamfsoftware.JamfAdmin + IdentifierType + bundleID + + + Calendar + + + Allowed + + CodeRequirement + identifier "io.brackets.appshell" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = JQ525L2MZD + Comment + + Identifier + io.brackets.appshell + IdentifierType + bundleID + + + Allowed + + CodeRequirement + identifier "com.jamfsoftware.JamfAdmin" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "483DWKW443" + Comment + + Identifier + com.jamfsoftware.JamfAdmin + IdentifierType + bundleID + + + Camera + + + Allowed + + CodeRequirement + identifier "io.brackets.appshell" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = JQ525L2MZD + Comment + + Identifier + io.brackets.appshell + IdentifierType + bundleID + + + Allowed + + CodeRequirement + identifier "com.jamfsoftware.JamfAdmin" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "483DWKW443" + Comment + + Identifier + com.jamfsoftware.JamfAdmin + IdentifierType + bundleID + + + FileProviderPresence + + + Allowed + + CodeRequirement + identifier "io.brackets.appshell" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = JQ525L2MZD + Comment + + Identifier + io.brackets.appshell + IdentifierType + bundleID + + + Allowed + + CodeRequirement + identifier "com.jamfsoftware.JamfAdmin" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "483DWKW443" + Comment + + Identifier + com.jamfsoftware.JamfAdmin + IdentifierType + bundleID + + + ListenEvent + + + Allowed + + CodeRequirement + identifier "io.brackets.appshell" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = JQ525L2MZD + Comment + + Identifier + io.brackets.appshell + IdentifierType + bundleID + + + Allowed + + CodeRequirement + identifier "com.jamfsoftware.JamfAdmin" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "483DWKW443" + Comment + + Identifier + com.jamfsoftware.JamfAdmin + IdentifierType + bundleID + + + MediaLibrary + + + Allowed + + CodeRequirement + identifier "io.brackets.appshell" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = JQ525L2MZD + Comment + + Identifier + io.brackets.appshell + IdentifierType + bundleID + + + Allowed + + CodeRequirement + identifier "com.jamfsoftware.JamfAdmin" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "483DWKW443" + Comment + + Identifier + com.jamfsoftware.JamfAdmin + IdentifierType + bundleID + + + Microphone + + + Allowed + + CodeRequirement + identifier "io.brackets.appshell" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = JQ525L2MZD + Comment + + Identifier + io.brackets.appshell + IdentifierType + bundleID + + + Allowed + + CodeRequirement + identifier "com.jamfsoftware.JamfAdmin" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "483DWKW443" + Comment + + Identifier + com.jamfsoftware.JamfAdmin + IdentifierType + bundleID + + + Photos + + + Allowed + + CodeRequirement + identifier "io.brackets.appshell" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = JQ525L2MZD + Comment + + Identifier + io.brackets.appshell + IdentifierType + bundleID + + + Allowed + + CodeRequirement + identifier "com.jamfsoftware.JamfAdmin" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "483DWKW443" + Comment + + Identifier + com.jamfsoftware.JamfAdmin + IdentifierType + bundleID + + + PostEvent + + + Allowed + + CodeRequirement + identifier "io.brackets.appshell" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = JQ525L2MZD + Comment + + Identifier + io.brackets.appshell + IdentifierType + bundleID + + + Allowed + + CodeRequirement + identifier "com.jamfsoftware.JamfAdmin" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "483DWKW443" + Comment + + Identifier + com.jamfsoftware.JamfAdmin + IdentifierType + bundleID + + + Reminders + + + Allowed + + CodeRequirement + identifier "io.brackets.appshell" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = JQ525L2MZD + Comment + + Identifier + io.brackets.appshell + IdentifierType + bundleID + + + Allowed + + CodeRequirement + identifier "com.jamfsoftware.JamfAdmin" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "483DWKW443" + Comment + + Identifier + com.jamfsoftware.JamfAdmin + IdentifierType + bundleID + + + ScreenCapture + + + Allowed + + CodeRequirement + identifier "io.brackets.appshell" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = JQ525L2MZD + Comment + + Identifier + io.brackets.appshell + IdentifierType + bundleID + + + SpeechRecognition + + + Allowed + + CodeRequirement + identifier "io.brackets.appshell" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = JQ525L2MZD + Comment + + Identifier + io.brackets.appshell + IdentifierType + bundleID + + + SystemPolicyAllFiles + + + Allowed + + CodeRequirement + identifier "io.brackets.appshell" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = JQ525L2MZD + Comment + + Identifier + io.brackets.appshell + IdentifierType + bundleID + + + Allowed + + CodeRequirement + identifier "com.jamfsoftware.JamfAdmin" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "483DWKW443" + Comment + + Identifier + com.jamfsoftware.JamfAdmin + IdentifierType + bundleID + + + SystemPolicyDesktopFolder + + + Allowed + + CodeRequirement + identifier "io.brackets.appshell" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = JQ525L2MZD + Comment + + Identifier + io.brackets.appshell + IdentifierType + bundleID + + + SystemPolicyDocumentsFolder + + + Allowed + + CodeRequirement + identifier "io.brackets.appshell" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = JQ525L2MZD + Comment + + Identifier + io.brackets.appshell + IdentifierType + bundleID + + + SystemPolicyDownloadsFolder + + + Allowed + + CodeRequirement + identifier "io.brackets.appshell" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = JQ525L2MZD + Comment + + Identifier + io.brackets.appshell + IdentifierType + bundleID + + + SystemPolicyNetworkVolumes + + + Allowed + + CodeRequirement + identifier "io.brackets.appshell" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = JQ525L2MZD + Comment + + Identifier + io.brackets.appshell + IdentifierType + bundleID + + + SystemPolicyRemovableVolumes + + + Allowed + + CodeRequirement + identifier "io.brackets.appshell" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = JQ525L2MZD + Comment + + Identifier + io.brackets.appshell + IdentifierType + bundleID + + + SystemPolicySysAdminFiles + + + Allowed + + CodeRequirement + identifier "io.brackets.appshell" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = JQ525L2MZD + Comment + + Identifier + io.brackets.appshell + IdentifierType + bundleID + + + Allowed + + CodeRequirement + identifier "com.jamfsoftware.JamfAdmin" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "483DWKW443" + Comment + + Identifier + com.jamfsoftware.JamfAdmin + IdentifierType + bundleID + + + + + + PayloadDescription + Test Unsigned Profile + PayloadDisplayName + TestUnsignedProfile + PayloadIdentifier + 3A4EDE0C-A189-4372-953F-304ECA0B6489 + PayloadOrganization + Jamf + PayloadType + com.apple.TCC.configuration-profile-policy + PayloadUUID + 3FF5028C-CAA0-4B2F-A7BF-2A3539074424 + PayloadVersion + 1 + PayloadScope + system + + diff --git a/Source/View Controllers/TCCProfileViewController.swift b/Source/View Controllers/TCCProfileViewController.swift index 4a3c6a8..ee8ec58 100644 --- a/Source/View Controllers/TCCProfileViewController.swift +++ b/Source/View Controllers/TCCProfileViewController.swift @@ -128,6 +128,7 @@ class TCCProfileViewController: NSViewController { @IBOutlet weak var saveButton: NSButton! @IBOutlet weak var uploadButton: NSButton! + @IBOutlet weak var addExecutableButton: NSButton! @IBOutlet weak var addAppleEventButton: NSButton! @IBOutlet weak var removeAppleEventButton: NSButton! @IBOutlet weak var removeExecutableButton: NSButton! @@ -284,6 +285,9 @@ class TCCProfileViewController: NSViewController { executablesTable.dataSource = self appleEventsTable.registerForDraggedTypes([.fileURL]) appleEventsTable.dataSource = self + + setupAccessibilityIdentifiers() + loadUITestProfileIfNeeded() } @IBAction func showHelpMessage(_ sender: InfoButton) { @@ -399,6 +403,53 @@ class TCCProfileViewController: NSViewController { } return foundRule == nil } + + private func setupAccessibilityIdentifiers() { + executablesTable.setAccessibilityIdentifier("ExecutablesTable") + appleEventsTable.setAccessibilityIdentifier("AppleEventsTable") + saveButton.setAccessibilityIdentifier("SaveButton") + uploadButton.setAccessibilityIdentifier("UploadButton") + addExecutableButton.setAccessibilityIdentifier("AddExecutableButton") + addAppleEventButton.setAccessibilityIdentifier("AddAppleEventButton") + removeAppleEventButton.setAccessibilityIdentifier("RemoveAppleEventButton") + removeExecutableButton.setAccessibilityIdentifier("RemoveExecutableButton") + } + + private func loadUITestProfileIfNeeded() { + guard ProcessInfo.processInfo.arguments.contains("-UITestMode") else { return } + guard let profileURL = Bundle.main.url(forResource: "TestTCCUnsignedProfile", withExtension: "mobileconfig") else { + logger.error("UITestMode: Failed to find bundled test profile") + return + } + let importer = TCCProfileImporter() + Task { + do { + let tccProfile = try importer.decodeTCCProfile(data: Data(contentsOf: profileURL)) + await model.importProfile(tccProfile: tccProfile) + } catch { + logger.error("UITestMode: Failed to load test profile: \(error.localizedDescription)") + } + } + } + + #if DEBUG + /// Simulates a drop of the given file URL onto the executables table. + /// Used by UI tests to add executables without automating Finder. + func simulateDropOnExecutablesTable(fileURL: URL) -> Bool { + do { + let executable = try model.loadExecutable(url: fileURL) + guard shouldExecutableBeAdded(executable), executablesAC.canInsert else { + return false + } + let insertIndex = (executablesAC.arrangedObjects as? [Any])?.count ?? 0 + executablesAC.insert(executable, atArrangedObjectIndex: insertIndex) + return true + } catch { + logger.error("simulateDropOnExecutablesTable failed: \(error)") + return false + } + } + #endif } extension TCCProfileViewController: NSTableViewDataSource { diff --git a/docs/plans/ui-testing.md b/docs/plans/ui-testing.md new file mode 100644 index 0000000..1c7fd09 --- /dev/null +++ b/docs/plans/ui-testing.md @@ -0,0 +1,172 @@ +# UI Testing — Phased Plan + +## Context + +PPPC Utility has 98 unit tests at ~45% production code coverage, but zero UI test coverage. The app has four main UI surfaces: + +| Surface | Framework | Lines | Role | +|---------|-----------|-------|------| +| `TCCProfileViewController` | AppKit (Storyboard) | 447 | Main window — executable table, policy popups, Apple Events table | +| `SaveViewController` | AppKit (Storyboard) | 171 | Modal sheet — profile name, signing identity, export | +| `OpenViewController` | AppKit (Storyboard) | 91 | Modal sheet — Apple Event destination picker | +| `UploadInfoView` | SwiftUI | 365 | Sheet — Jamf Pro connection, payload config, upload | + +No UI test target exists yet. This plan adds one incrementally. + +### Tools + +- **XCUITest** for interaction tests (launch, tap, type, assert element state) + +### Conventions + +- Prefer **multiple assertions per test** to minimize app launches — each test method relaunches the app, which is expensive +- Do not use `// when` / `// then` comment blocks in UI tests — they add noise in assertion-heavy tests +- The UI test target uses **Swift 5 with minimal concurrency checking** (XCTestCase lifecycle methods are nonisolated, incompatible with MainActor default isolation) +- No `SWIFT_DEFAULT_ACTOR_ISOLATION` on the UI test target + +### Test data strategy + +`NSOpenPanel` and Finder drag-and-drop can't be reliably automated by XCUITest (the panel runs in a separate system process). Two complementary approaches: + +1. **Launch argument (`-UITestMode`)** — app checks for the flag at startup and preloads a bundled test profile into `Model.shared`. Most tests use this for a deterministic starting state. +2. **Programmatic drop simulation** — a test hook calls the table's `performDragOperation` with a pasteboard containing a known app URL (e.g., Books.app). Tests the drop handler logic without automating Finder. + +### Network stubbing strategy + +Upload/connection tests use **scoped `URLSession` injection**. When the app detects `-UITestStubNetwork`, it creates an ephemeral `URLSession` wired to `MockURLProtocol` and passes it to `UploadManager(session:)`. Only upload-related calls are intercepted; `URLSession.shared` remains untouched. + +--- + +## Quality gates (apply to every phase) + +1. All existing unit tests still pass after each phase. +2. No new compiler warnings. +3. UI tests pass in a clean `xcodebuild test` run. + +--- + +## Phase 1 — Infrastructure & App Launch ✅ + +**Goal:** Create the UI test target, prove the app launches and the main window is testable. + +### Work + +| Item | Detail | +|------|--------| +| Add `PPPC UtilityUITests` target | Xcode UI Testing Bundle targeting `PPPC Utility.app` | +| Add accessibility identifiers to `TCCProfileViewController` | Executables table, Apple Events table, Save button, Upload button, add/remove buttons | +| Add `-UITestMode` launch argument support | Check flag in `TCCProfileViewController.viewDidLoad`; when set, load a bundled test `.mobileconfig` into `Model.shared` | +| Add programmatic drop hook | `#if DEBUG` method that synthesizes a drop operation on the executables table from a file URL | +| `AppLaunchTests.swift` | Verify app launches, main window exists, tables visible, all key buttons present, and correct buttons disabled at empty startup (Save, Upload, Remove Executable, Add Apple Event, Remove Apple Event should be disabled; Add Executable should be enabled) | + +### Verification + +``` +xcodebuild test -project "PPPC Utility.xcodeproj" -scheme "PPPC Utility" -destination "platform=macOS" -only-testing:"PPPC UtilityUITests" +``` + +--- + +## Phase 2 — Profile Building Interactions + +**Goal:** Test the core workflow — executables in the table, policy popup changes, drop handler. + +### Accessibility identifiers needed + +- Each of the ~20 policy popup buttons (e.g., `Policy.addressBookPopUp`) +- Executable detail labels (name, identifier, code requirement) +- Add/remove executable buttons + +### New test files + +| File | What it tests | +|------|---------------| +| `ExecutableManagementTests.swift` | Launch with `-UITestMode` → verify preloaded executable appears in table; select executable → verify detail labels populated; remove executable → verify table row gone | +| `DropHandlerTests.swift` | Programmatic drop of Books.app URL onto executables table → verify new row appears with correct name/identifier | +| `PolicySelectionTests.swift` | Select executable → change a policy popup → verify popup value persists after re-selecting the executable; verify initial state is "-" for all popups | + +--- + +## Phase 3 — Profile Import & Save Sheet + +**Goal:** Test importing an existing profile and the save sheet flow. + +### Accessibility identifiers needed + +- `SaveViewController`: payload name field, organization field, signing identity popup, save button + +### New test files + +| File | What it tests | +|------|---------------| +| `ProfileImportTests.swift` | Launch with `-UITestMode` (preloaded profile) → verify executables table populated with expected rows; verify policy values match imported profile | +| `SaveSheetTests.swift` | Trigger save sheet → verify fields present (payload name, org, signing identity); verify save button disabled when required fields empty; verify save button enabled when fields filled | + +### Test data + +- Bundle a sample `.mobileconfig` in the UI test target's resources. + +--- + +## Phase 4 — Apple Events + +**Goal:** Test Apple Event rule creation using the preloaded destination list. + +### Accessibility identifiers needed + +- `OpenViewController`: choices table (no browse button testing needed) +- Apple Events table, add/remove Apple Event buttons + +### New test files + +| File | What it tests | +|------|---------------| +| `AppleEventTests.swift` | Select executable → click Add Apple Event → OpenViewController appears → select destination from preloaded choices table → rule appears in Apple Events table; remove rule → table row gone | + +--- + +## Phase 5 — Upload Sheet (SwiftUI) + +**Goal:** Test the UploadInfoView form validation and field interactions. Network calls stubbed via scoped `URLSession` injection. + +### Accessibility identifiers needed + +- All SwiftUI fields and buttons in `UploadInfoView` (use `.accessibilityIdentifier()` modifier) + +### Production change + +- Add `-UITestStubNetwork` launch argument check where `UploadManager` is instantiated +- When set, create an ephemeral `URLSession` wired to `MockURLProtocol` and pass to `UploadManager(session:)` + +### New test files + +| File | What it tests | +|------|---------------| +| `UploadSheetTests.swift` | Open upload sheet → verify all fields present; verify "Check connection" button disabled with empty URL; enter URL + credentials → button enabled; verify auth type picker switches between Basic Auth and Client Credentials fields; verify "Use Site" toggle shows/hides site fields | + +--- + +## Files touched across all phases + +``` +PPPC Utility.xcodeproj/project.pbxproj (Phase 1 — add UI test target) +Source/View Controllers/TCCProfileViewController.swift (Phase 1-4 — accessibility identifiers, test harness) +Source/View Controllers/SaveViewController.swift (Phase 3 — accessibility identifiers) +Source/View Controllers/OpenViewController.swift (Phase 4 — accessibility identifiers) +Source/SwiftUI/UploadInfoView.swift (Phase 5 — accessibility identifiers) +PPPC UtilityUITests/ (Phase 1-5 — all new test files) +``` + +--- + +## CI considerations + +- macOS UI tests require a GUI session (not headless). GitHub Actions macOS runners support this. + +## What this plan does NOT cover + +- Automating `NSOpenPanel` file selection (bypassed via launch argument) +- Drag-and-drop from Finder (bypassed via programmatic drop simulation) +- Dark mode visual regression (can be added later if needed) +- Real keychain / signing identity interactions +- Real Jamf Pro server upload (network stubbed via scoped injection)