From 760113a22179dab9821be08fbbb1df818cb43620 Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Fri, 27 Feb 2026 10:18:02 +0100 Subject: [PATCH 1/4] Screenshot test Screenshot test More Undefined symbols for architecture arm64:'OBJC_CLASS' Upload screenshots Manually adjust extract command Code format CI fails on python errors during screenshot tests Make it use for XCode 26.2 Show errors again debug Again --- .github/workflows/BuildPR.yml | 24 ++- Classes/Controllers/ApplicationController.m | 22 +++ GitX.xcodeproj/project.pbxproj | 94 ++++++++++ .../xcshareddata/xcschemes/GitX.xcscheme | 57 +++++- GitXUITests/GitXScreenshotTests.m | 124 +++++++++++++ scripts/extract_screenshots.py | 170 ++++++++++++++++++ 6 files changed, 489 insertions(+), 2 deletions(-) create mode 100644 GitXUITests/GitXScreenshotTests.m create mode 100755 scripts/extract_screenshots.py diff --git a/.github/workflows/BuildPR.yml b/.github/workflows/BuildPR.yml index 9b235e188..2c2c28656 100644 --- a/.github/workflows/BuildPR.yml +++ b/.github/workflows/BuildPR.yml @@ -82,6 +82,28 @@ jobs: brew unlink openssl@3 - name: Build project run: xcodebuild -workspace GitX.xcworkspace -scheme GitX -archivePath ./GitX archive ARCHS="${{ matrix.abi }}" PRODUCT_BUNDLE_IDENTIFIER=net.phere.GitX + - name: Run tests + run: | + xcodebuild test \ + -workspace GitX.xcworkspace \ + -scheme GitX \ + -destination "platform=macOS,arch=${{ matrix.abi }}" \ + ARCHS="${{ matrix.abi }}" \ + CODE_SIGN_IDENTITY="-" \ + -resultBundlePath TestResults-${{ matrix.abi }}.xcresult + - name: Extract screenshots from test results + if: always() + run: | + python3 scripts/extract_screenshots.py \ + TestResults-${{ matrix.abi }}.xcresult \ + screenshots-${{ matrix.abi }} + ls -la screenshots-${{ matrix.abi }} + - name: Upload screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: Screenshots-${{ matrix.abi }} + path: screenshots-${{ matrix.abi }} - name: Prepare artifact if: ${{ env.variableSet != '' }} env: @@ -150,7 +172,7 @@ jobs: run: | security delete-keychain $RUNNER_TEMP/app-signing.keychain-db rm ~/Library/MobileDevice/Provisioning\ Profiles/build_pp.provisionprofile - + release: if: startsWith(github.event.ref, 'refs/tags/') needs: build-gitx diff --git a/Classes/Controllers/ApplicationController.m b/Classes/Controllers/ApplicationController.m index 2894b2c90..5286372a0 100644 --- a/Classes/Controllers/ApplicationController.m +++ b/Classes/Controllers/ApplicationController.m @@ -95,6 +95,10 @@ - (BOOL)applicationShouldOpenUntitledFile:(NSApplication *)sender { if (!started || [[[NSDocumentController sharedDocumentController] documents] count]) return NO; + // Suppress the recents dialog during UI tests so the test-repo window + // opens cleanly without a competing sheet/panel. + if ([[[NSProcessInfo processInfo] environment] objectForKey:@"GITX_UITEST_REPO"]) + return NO; return YES; } @@ -121,6 +125,24 @@ - (void)applicationDidFinishLaunching:(NSNotification *)notification [NSApp registerObserverForAppearanceChanges:self]; [self registerServices]; started = YES; + + // UI-test hook: open a repo path passed via environment variable so that + // XCUITests always get a document window without relying on recents or + // Launch Services registration. + NSString *uitestRepo = [[[NSProcessInfo processInfo] environment] objectForKey:@"GITX_UITEST_REPO"]; + if (uitestRepo.length > 0) { + NSURL *repoURL = [NSURL fileURLWithPath:uitestRepo]; + PBRepositoryDocumentController *controller = [PBRepositoryDocumentController sharedDocumentController]; + // Defer to the next run-loop iteration so the app is fully initialised. + dispatch_async(dispatch_get_main_queue(), ^{ + [controller openDocumentWithContentsOfURL:repoURL + display:YES + completionHandler:^(NSDocument *document, BOOL documentWasAlreadyOpen, NSError *error) { + if (!document) + NSLog(@"[UITest] Failed to open repo %@: %@", uitestRepo, error); + }]; + }); + } } - (void)windowWillClose:sender diff --git a/GitX.xcodeproj/project.pbxproj b/GitX.xcodeproj/project.pbxproj index f6539e1b8..6bfecfd25 100644 --- a/GitX.xcodeproj/project.pbxproj +++ b/GitX.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + CC0000010000001 /* GitXScreenshotTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CC0000020000002 /* GitXScreenshotTests.m */; }; 0A6858C711F7EA8A00AC2BE4 /* CoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0A6858C611F7EA8A00AC2BE4 /* CoreServices.framework */; }; 2682AABB1929140E00271A4D /* GTOID+JavaScript.m in Sources */ = {isa = PBXBuildFile; fileRef = 2682AABA1929140E00271A4D /* GTOID+JavaScript.m */; }; 2DE9B60629ABEB450097873A /* GitX.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 2DE9B60529ABEB450097873A /* GitX.xcconfig */; }; @@ -414,6 +415,13 @@ remoteGlobalIDString = 721C24451CB753E6005440CB; remoteInfo = "Installer Progress"; }; + CC000007B000007 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8D1107260486CEB800E47090; + remoteInfo = GitX; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -432,6 +440,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + CC0000020000002 /* GitXScreenshotTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GitXScreenshotTests.m; sourceTree = ""; }; + CC0000030000003 /* GitXUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GitXUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 0A6858C611F7EA8A00AC2BE4 /* CoreServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreServices.framework; path = System/Library/Frameworks/CoreServices.framework; sourceTree = SDKROOT; }; 1058C7A1FEA54F0111CA2CBB /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = /System/Library/Frameworks/Cocoa.framework; sourceTree = ""; }; 2682AAB91929140E00271A4D /* GTOID+JavaScript.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "GTOID+JavaScript.h"; sourceTree = ""; }; @@ -745,6 +755,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CC0000060000006 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -754,10 +771,20 @@ 8D1107320486CEB800E47090 /* GitX.app */, 913D5E490E55644600CECEA2 /* gitx */, 551BF111112F371800265053 /* gitx_askpasswd */, + CC0000030000003 /* GitXUITests.xctest */, ); name = Products; sourceTree = ""; }; + CC0000040000004 /* GitXUITests */ = { + isa = PBXGroup; + children = ( + CC0000020000002 /* GitXScreenshotTests.m */, + ); + name = GitXUITests; + path = GitXUITests; + sourceTree = ""; + }; 29B97314FDCFA39411CA2CEA /* GitTest */ = { isa = PBXGroup; children = ( @@ -767,6 +794,7 @@ 4A5D762114A9A9CC00DF6C68 /* Classes */, 4A5D773E14A9AB3A00DF6C68 /* External Sources */, 4A5D757C14A9A90500DF6C68 /* Resources */, + CC0000040000004 /* GitXUITests */, 37A5656C1EAD077400CA0332 /* cli tool */, 37A5656D1EAD07C200CA0332 /* gitx_askpasswd */, 29B97323FDCFA39411CA2CEA /* Frameworks */, @@ -1268,6 +1296,23 @@ productReference = 913D5E490E55644600CECEA2 /* gitx */; productType = "com.apple.product-type.tool"; }; + CC000008A000008 /* GitXUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = CC000009A000009 /* Build configuration list for PBXNativeTarget "GitXUITests" */; + buildPhases = ( + CC0000050000005 /* Sources */, + CC0000060000006 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + CC000007A000007 /* PBXTargetDependency */, + ); + name = GitXUITests; + productName = GitXUITests; + productReference = CC0000030000003 /* GitXUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -1309,6 +1354,7 @@ 913D5E480E55644600CECEA2 /* cli tool */, 551BF110112F371800265053 /* gitx_askpasswd */, 4D3F252B18C37D2E000922D9 /* Generate Scripting Header */, + CC000008A000008 /* GitXUITests */, ); }; /* End PBXProject section */ @@ -1715,6 +1761,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CC0000050000005 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CC0000010000001 /* GitXScreenshotTests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -1758,6 +1812,11 @@ target = 551BF110112F371800265053 /* gitx_askpasswd */; targetProxy = 551BF174112F3F3500265053 /* PBXContainerItemProxy */; }; + CC000007A000007 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8D1107260486CEB800E47090 /* GitX */; + targetProxy = CC000007B000007 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -2065,9 +2124,44 @@ }; name = Release; }; + CC00000AA00000A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "-"; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + PRODUCT_BUNDLE_IDENTIFIER = net.phere.GitXUITests; + PRODUCT_NAME = GitXUITests; + SDKROOT = macosx; + TEST_TARGET_NAME = GitX; + }; + name = Debug; + }; + CC00000BA00000B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "-"; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + PRODUCT_BUNDLE_IDENTIFIER = net.phere.GitXUITests; + PRODUCT_NAME = GitXUITests; + SDKROOT = macosx; + TEST_TARGET_NAME = GitX; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + CC000009A000009 /* Build configuration list for PBXNativeTarget "GitXUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CC00000AA00000A /* Debug */, + CC00000BA00000B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 26FC0A840875C7B200E6366F /* Build configuration list for PBXNativeTarget "GitX" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/GitX.xcodeproj/xcshareddata/xcschemes/GitX.xcscheme b/GitX.xcodeproj/xcshareddata/xcschemes/GitX.xcscheme index 4cd51555a..9ca8d9b71 100644 --- a/GitX.xcodeproj/xcshareddata/xcschemes/GitX.xcscheme +++ b/GitX.xcodeproj/xcshareddata/xcschemes/GitX.xcscheme @@ -20,13 +20,41 @@ ReferencedContainer = "container:GitX.xcodeproj"> + + + + + + + + + shouldUseLaunchSchemeArgsEnv = "NO"> + + + + + + + + + + + + + +@interface GitXScreenshotTests : XCTestCase +@property (nonatomic, strong) XCUIApplication *app; +@end + +@implementation GitXScreenshotTests + +- (void)setUp { + [super setUp]; + self.continueAfterFailure = NO; + self.app = [[XCUIApplication alloc] init]; + + // GITX_UITEST_REPO is set to $(SRCROOT) in the scheme's TestAction + // environment variables, so it is always available both locally and on CI. + NSString *repoPath = [[[NSProcessInfo processInfo] environment] objectForKey:@"GITX_UITEST_REPO"]; + + if (!repoPath) { + // Fallback: a fixture repo bundled with the test target + NSBundle *bundle = [NSBundle bundleForClass:[self class]]; + NSURL *bundledRepo = [bundle URLForResource:@"testrepo" withExtension:nil]; + if (bundledRepo && [[NSFileManager defaultManager] fileExistsAtPath:bundledRepo.path]) { + repoPath = bundledRepo.path; + } + } + + NSLog(@"[GitXScreenshotTests] repoPath = %@", repoPath ?: @"(none)"); + + if (repoPath) { + // Passed to the app via applicationDidFinishLaunching: which opens + // the repo directly, giving the test a reliable document window. + self.app.launchEnvironment = @{@"GITX_UITEST_REPO": repoPath}; + } + + [self.app launch]; +} + +- (void)tearDown { + [self.app terminate]; + [super tearDown]; +} + +// MARK: - Helpers + +- (BOOL)waitForWindow { + XCUIElement *window = self.app.windows.firstMatch; + if ([window waitForExistenceWithTimeout:20]) { + return YES; + } + // Activate the app and give it one more chance — it may have launched + // but not yet brought its window to the front. + [self.app activate]; + return [self.app.windows.firstMatch waitForExistenceWithTimeout:10]; +} + +- (void)saveScreenshotNamed:(NSString *)name { + XCUIScreenshot *screenshot = [[XCUIScreen mainScreen] screenshot]; + XCTAttachment *attachment = [XCTAttachment attachmentWithScreenshot:screenshot]; + attachment.name = name; + attachment.lifetime = XCTAttachmentLifetimeKeepAlways; + [self addAttachment:attachment]; +} + +- (void)saveWindowScreenshotNamed:(NSString *)name { + XCUIElement *window = self.app.windows.firstMatch; + if (!window.exists) { + [self saveScreenshotNamed:name]; // fall back to full screen + return; + } + XCUIScreenshot *screenshot = [window screenshot]; + XCTAttachment *attachment = [XCTAttachment attachmentWithScreenshot:screenshot]; + attachment.name = name; + attachment.lifetime = XCTAttachmentLifetimeKeepAlways; + [self addAttachment:attachment]; +} + +// MARK: - Tests + +- (void)testMainWindowExists { + XCTAssertTrue([self waitForWindow], + @"Main window should appear within 30 seconds"); + [self saveWindowScreenshotNamed:@"main-window"]; +} + +- (void)testHistoryTabScreenshot { + if (![self waitForWindow]) { return; } + [self saveWindowScreenshotNamed:@"history-view"]; +} + +- (void)testStagingTabScreenshot { + if (![self waitForWindow]) { return; } + + // Click the Stage tab / toolbar button if present + XCUIElement *stageButton = self.app.toolbars.buttons[@"Stage"]; + if (!stageButton.exists) { + // Try as a tab or segmented control + stageButton = [self.app.windows.firstMatch.buttons elementMatchingType:XCUIElementTypeButton + identifier:@"Stage"]; + } + if (stageButton.exists) { + [stageButton click]; + [NSThread sleepForTimeInterval:0.5]; + } + + [self saveWindowScreenshotNamed:@"staging-view"]; +} + +- (void)testFullScreenScreenshot { + // Capture the entire screen — useful for catching system-level visual regressions + [NSThread sleepForTimeInterval:1.0]; // let the app settle + [self saveScreenshotNamed:@"full-screen"]; +} + +@end + diff --git a/scripts/extract_screenshots.py b/scripts/extract_screenshots.py new file mode 100755 index 000000000..b4fc4e2cc --- /dev/null +++ b/scripts/extract_screenshots.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Extract PNG screenshots from an .xcresult bundle into a flat output directory.""" + +import json +import os +import plistlib +import shutil +import subprocess +import sys + + +def run(cmd): + return subprocess.run(cmd, capture_output=True) + + +def get_object(bundle, ref_id=None): + """Fetch a JSON object from the bundle, optionally by ref ID.""" + cmd = ['xcrun', 'xcresulttool', 'get', 'object', '--legacy', + '--path', bundle, '--format', 'json'] + if ref_id: + cmd += ['--id', ref_id] + result = run(cmd) + if result.returncode != 0: + return None + return json.loads(result.stdout) + + +def find_attachments_recursive(bundle, node, visited=None): + """Walk the object graph following Reference IDs to find ActionTestAttachment nodes.""" + if visited is None: + visited = set() + if isinstance(node, dict): + type_name = node.get('_type', {}).get('_name', '') + if type_name == 'ActionTestAttachment': + payload = node.get('payloadRef', {}) + if isinstance(payload, dict): + ref = payload.get('id', payload.get('_value', '')) + if isinstance(ref, dict): + ref = ref.get('_value', '') + else: + ref = '' + name = node.get('name', 'screenshot') + if isinstance(name, dict): + name = name.get('_value', 'screenshot') + uti = node.get('uniformTypeIdentifier', '') + if isinstance(uti, dict): + uti = uti.get('_value', '') + if ref and ('png' in uti.lower() or 'image' in uti.lower()): + yield ref, name + elif type_name == 'Reference': + ref_id = node.get('id', '') + if isinstance(ref_id, dict): + ref_id = ref_id.get('_value', '') + if ref_id and ref_id not in visited: + visited.add(ref_id) + child = get_object(bundle, ref_id) + if child: + yield from find_attachments_recursive(bundle, child, visited) + else: + for v in node.values(): + yield from find_attachments_recursive(bundle, v, visited) + elif isinstance(node, list): + for v in node: + yield from find_attachments_recursive(bundle, v, visited) + + +def export_via_xcresulttool(bundle, out_dir): + """Xcode 16+: walk the object graph following References to find all attachments.""" + root = get_object(bundle) + if root is None: + print("xcresulttool get object --legacy failed", file=sys.stderr) + return False + + print("Used: xcresulttool get object --legacy (recursive)") + count = 0 + for i, (ref, name) in enumerate(find_attachments_recursive(bundle, root)): + safe = ''.join(c if c.isalnum() or c in '-_' else '_' for c in name) + dest = os.path.join(out_dir, f'{i:02d}-{safe}.png') + r = run(['xcrun', 'xcresulttool', 'export', 'object', '--legacy', + '--path', bundle, '--id', ref, + '--output-path', dest, '--type', 'file']) + if r.returncode != 0: + print(f"Warning: failed to export '{name}' (rc={r.returncode}): " + f"{r.stderr.decode().strip()}", file=sys.stderr) + else: + print(f"Exported: {dest}") + count += 1 + return count > 0 + + +def export_via_get_legacy(bundle, out_dir): + """Older Xcode: use 'xcresulttool get --legacy' + raw format per attachment.""" + result = run(['xcrun', 'xcresulttool', 'get', '--legacy', + '--path', bundle, '--format', 'json']) + if result.returncode != 0: + print(f"xcresulttool get --legacy failed (rc={result.returncode}): " + f"{result.stderr.decode().strip()}", file=sys.stderr) + return False + + print("Used: xcresulttool get --legacy") + data = json.loads(result.stdout) + count = 0 + for i, (ref, name) in enumerate(find_attachments_recursive(bundle, data)): + safe = ''.join(c if c.isalnum() or c in '-_' else '_' for c in name) + dest = os.path.join(out_dir, f'{i:02d}-{safe}.png') + r = run(['xcrun', 'xcresulttool', 'get', '--legacy', + '--path', bundle, '--id', ref, '--format', 'raw']) + if r.returncode != 0: + print(f"Warning: failed to export '{name}': {r.stderr.decode().strip()}", file=sys.stderr) + else: + with open(dest, 'wb') as f: + f.write(r.stdout) + print(f"Exported: {dest}") + count += 1 + return count > 0 + + +def export_via_filesystem(bundle, out_dir): + """Last resort: copy raw attachment files from inside the .xcresult bundle.""" + print("Falling back to filesystem scan of .xcresult bundle...") + attachments_dir = os.path.join(bundle, 'Attachments') + search_root = attachments_dir if os.path.isdir(attachments_dir) else bundle + count = 0 + for root, _, files in os.walk(search_root): + for fname in files: + src = os.path.join(root, fname) + if os.path.getsize(src) == 0: + continue + _, ext = os.path.splitext(fname) + dest_name = fname if ext else fname + '.png' + dest = os.path.join(out_dir, dest_name) + base, dext = os.path.splitext(dest) + n = 1 + while os.path.exists(dest): + dest = f"{base}_{n}{dext}" + n += 1 + shutil.copy2(src, dest) + print(f"Exported: {dest}") + count += 1 + return count + + +def main(): + if len(sys.argv) != 3: + print(f"Usage: {sys.argv[0]} ") + sys.exit(1) + + bundle, out_dir = sys.argv[1], sys.argv[2] + + if not os.path.exists(bundle): + print(f"Error: bundle not found: {bundle}", file=sys.stderr) + sys.exit(1) + + os.makedirs(out_dir, exist_ok=True) + + if not export_via_xcresulttool(bundle, out_dir): + if not export_via_get_legacy(bundle, out_dir): + export_via_filesystem(bundle, out_dir) + + count = sum(1 for f in os.listdir(out_dir) + if os.path.isfile(os.path.join(out_dir, f))) + if count == 0: + print("No screenshots found in result bundle.") + else: + print(f"Done: {count} screenshot(s) exported to {out_dir}") + + +if __name__ == '__main__': + main() + From a3d1f7d32a75b523e8572a2fed0c07084f731a0a Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Sun, 1 Mar 2026 08:18:53 +0100 Subject: [PATCH 2/4] Checkout specific commit to have same screenshots --- .github/workflows/BuildPR.yml | 12 +++++++++--- Classes/Controllers/ApplicationController.m | 3 ++- GitX.xcconfig | 4 ++++ GitX.xcodeproj/xcshareddata/xcschemes/GitX.xcscheme | 2 +- GitXUITests/GitXScreenshotTests.m | 8 +++++--- 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/.github/workflows/BuildPR.yml b/.github/workflows/BuildPR.yml index 2c2c28656..05b26a05d 100644 --- a/.github/workflows/BuildPR.yml +++ b/.github/workflows/BuildPR.yml @@ -80,8 +80,10 @@ jobs: - name: Unlink openssl 3 run: | brew unlink openssl@3 - - name: Build project - run: xcodebuild -workspace GitX.xcworkspace -scheme GitX -archivePath ./GitX archive ARCHS="${{ matrix.abi }}" PRODUCT_BUNDLE_IDENTIFIER=net.phere.GitX + - name: Checkout fixed repo snapshot for screenshots + run: | + git clone --no-checkout "$GITHUB_WORKSPACE" /tmp/gitx-screenshot-repo + git -C /tmp/gitx-screenshot-repo checkout 52ff1051d0c8e5cf3ee5 - name: Run tests run: | xcodebuild test \ @@ -90,6 +92,7 @@ jobs: -destination "platform=macOS,arch=${{ matrix.abi }}" \ ARCHS="${{ matrix.abi }}" \ CODE_SIGN_IDENTITY="-" \ + GITX_SCREENSHOT_REPO=/tmp/gitx-screenshot-repo \ -resultBundlePath TestResults-${{ matrix.abi }}.xcresult - name: Extract screenshots from test results if: always() @@ -104,6 +107,9 @@ jobs: with: name: Screenshots-${{ matrix.abi }} path: screenshots-${{ matrix.abi }} + + - name: Build project + run: xcodebuild -workspace GitX.xcworkspace -scheme GitX -archivePath ./GitX archive ARCHS="${{ matrix.abi }}" PRODUCT_BUNDLE_IDENTIFIER=net.phere.GitX - name: Prepare artifact if: ${{ env.variableSet != '' }} env: @@ -172,7 +178,7 @@ jobs: run: | security delete-keychain $RUNNER_TEMP/app-signing.keychain-db rm ~/Library/MobileDevice/Provisioning\ Profiles/build_pp.provisionprofile - + release: if: startsWith(github.event.ref, 'refs/tags/') needs: build-gitx diff --git a/Classes/Controllers/ApplicationController.m b/Classes/Controllers/ApplicationController.m index 5286372a0..968a5a76a 100644 --- a/Classes/Controllers/ApplicationController.m +++ b/Classes/Controllers/ApplicationController.m @@ -129,7 +129,8 @@ - (void)applicationDidFinishLaunching:(NSNotification *)notification // UI-test hook: open a repo path passed via environment variable so that // XCUITests always get a document window without relying on recents or // Launch Services registration. - NSString *uitestRepo = [[[NSProcessInfo processInfo] environment] objectForKey:@"GITX_UITEST_REPO"]; + NSDictionary *env = [[NSProcessInfo processInfo] environment]; + NSString *uitestRepo = env[@"GITX_UITEST_REPO"]; if (uitestRepo.length > 0) { NSURL *repoURL = [NSURL fileURLWithPath:uitestRepo]; PBRepositoryDocumentController *controller = [PBRepositoryDocumentController sharedDocumentController]; diff --git a/GitX.xcconfig b/GitX.xcconfig index 2607f8f25..0f6ab82f3 100644 --- a/GitX.xcconfig +++ b/GitX.xcconfig @@ -3,4 +3,8 @@ CODE_SIGN_IDENTITY = - +// Default repo for UI screenshot tests. Override on CI via: +// xcodebuild ... GITX_SCREENSHOT_REPO=/tmp/gitx-screenshot-repo +GITX_SCREENSHOT_REPO = $(SRCROOT) + #include? "Dev.xcconfig" diff --git a/GitX.xcodeproj/xcshareddata/xcschemes/GitX.xcscheme b/GitX.xcodeproj/xcshareddata/xcschemes/GitX.xcscheme index 9ca8d9b71..22ff93592 100644 --- a/GitX.xcodeproj/xcshareddata/xcschemes/GitX.xcscheme +++ b/GitX.xcodeproj/xcshareddata/xcschemes/GitX.xcscheme @@ -67,7 +67,7 @@ diff --git a/GitXUITests/GitXScreenshotTests.m b/GitXUITests/GitXScreenshotTests.m index aafc83b34..4ff4c30af 100644 --- a/GitXUITests/GitXScreenshotTests.m +++ b/GitXUITests/GitXScreenshotTests.m @@ -20,9 +20,11 @@ - (void)setUp { self.continueAfterFailure = NO; self.app = [[XCUIApplication alloc] init]; - // GITX_UITEST_REPO is set to $(SRCROOT) in the scheme's TestAction - // environment variables, so it is always available both locally and on CI. - NSString *repoPath = [[[NSProcessInfo processInfo] environment] objectForKey:@"GITX_UITEST_REPO"]; + // GITX_UITEST_REPO is set by the scheme to $(GITX_SCREENSHOT_REPO). + // Locally this expands to $(SRCROOT). On CI, xcodebuild overrides + // GITX_SCREENSHOT_REPO=/tmp/gitx-screenshot-repo (the fixed commit checkout). + NSDictionary *env = [[NSProcessInfo processInfo] environment]; + NSString *repoPath = env[@"GITX_UITEST_REPO"]; if (!repoPath) { // Fallback: a fixture repo bundled with the test target From 5bc54e19e1d804d173a50f82912e21074ba3e475 Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Sun, 1 Mar 2026 08:52:46 +0100 Subject: [PATCH 3/4] Remove full screen test --- GitXUITests/GitXScreenshotTests.m | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/GitXUITests/GitXScreenshotTests.m b/GitXUITests/GitXScreenshotTests.m index 4ff4c30af..38d632d11 100644 --- a/GitXUITests/GitXScreenshotTests.m +++ b/GitXUITests/GitXScreenshotTests.m @@ -116,11 +116,11 @@ - (void)testStagingTabScreenshot { [self saveWindowScreenshotNamed:@"staging-view"]; } -- (void)testFullScreenScreenshot { - // Capture the entire screen — useful for catching system-level visual regressions - [NSThread sleepForTimeInterval:1.0]; // let the app settle - [self saveScreenshotNamed:@"full-screen"]; -} +// - (void)testFullScreenScreenshot { +// // Capture the entire screen — useful for catching system-level visual regressions +// [NSThread sleepForTimeInterval:1.0]; // let the app settle +// [self saveScreenshotNamed:@"full-screen"]; +// } @end From ffc132bfbed743ee3826683746cd182dab7b3607 Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Sun, 1 Mar 2026 08:27:35 +0100 Subject: [PATCH 4/4] commit-context-menu --- GitXUITests/GitXScreenshotTests.m | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/GitXUITests/GitXScreenshotTests.m b/GitXUITests/GitXScreenshotTests.m index 38d632d11..7379b21fd 100644 --- a/GitXUITests/GitXScreenshotTests.m +++ b/GitXUITests/GitXScreenshotTests.m @@ -122,5 +122,42 @@ - (void)testStagingTabScreenshot { // [self saveScreenshotNamed:@"full-screen"]; // } +- (void)testCommitContextMenuScreenshot { + if (![self waitForWindow]) { return; } + + // The commit list is a table — find the first (most recent) commit row + XCUIElement *window = self.app.windows.firstMatch; + XCUIElement *table = window.tables.firstMatch; + if (![table waitForExistenceWithTimeout:10]) { + NSLog(@"[GitXScreenshotTests] Commit table not found, skipping context menu screenshot"); + return; + } + + // Let the history list fully load + [NSThread sleepForTimeInterval:1.0]; + + XCUIElement *firstRow = [table.tableRows elementBoundByIndex:0]; + if (!firstRow.exists) { + NSLog(@"[GitXScreenshotTests] No commit rows found, skipping context menu screenshot"); + return; + } + + // Right-click to open the context menu + [firstRow rightClick]; + + // Wait for the menu to appear + XCUIElement *menu = self.app.menus.firstMatch; + if (![menu waitForExistenceWithTimeout:5]) { + NSLog(@"[GitXScreenshotTests] Context menu did not appear"); + return; + } + + [NSThread sleepForTimeInterval:0.3]; // let the menu fully render + [self saveWindowScreenshotNamed:@"commit-context-menu"]; + + // Dismiss the menu + [window typeKey:XCUIKeyboardKeyEscape modifierFlags:0]; +} + @end