diff --git a/Quicksilver/Code-QuickStepCore/QSObject_Pasteboard.m b/Quicksilver/Code-QuickStepCore/QSObject_Pasteboard.m index 27c8f4446..0be1e2209 100644 --- a/Quicksilver/Code-QuickStepCore/QSObject_Pasteboard.m +++ b/Quicksilver/Code-QuickStepCore/QSObject_Pasteboard.m @@ -124,6 +124,9 @@ - (id) pasteboardPropertyListForType:(NSPasteboardType) type { } if ([type isEqualToString:NSPasteboardTypeURL]) { + if ([pbData isKindOfClass:[NSData class]]) { + pbData = [[NSString alloc] initWithData:pbData encoding:NSUTF8StringEncoding]; + } return [pbData hasPrefix:@"mailto:"] ? [pbData substringFromIndex:7] : pbData; } diff --git a/Quicksilver/Code-QuickStepFoundation/NSFileManager_BLTRExtensions.m b/Quicksilver/Code-QuickStepFoundation/NSFileManager_BLTRExtensions.m index 3eb86bd4a..c2d60b7e9 100644 --- a/Quicksilver/Code-QuickStepFoundation/NSFileManager_BLTRExtensions.m +++ b/Quicksilver/Code-QuickStepFoundation/NSFileManager_BLTRExtensions.m @@ -235,9 +235,7 @@ - (NSDate *)path:(NSString *)path wasModifiedAfter:(NSDate *)date depth:(NSInteg [url getResourceValue:&isDirectory forKey:NSURLIsDirectoryKey error:nil]; [url getResourceValue:&isPackage forKey:NSURLIsPackageKey error:nil]; if (depth && isDirectory && !isPackage) { - @autoreleasepool { - moddate = [self path:[url path] wasModifiedAfter:date depth:depth--]; - } + moddate = [self path:[url path] wasModifiedAfter:date depth:depth--]; if (moddate) return moddate; } diff --git a/Quicksilver/Code-QuickStepInterface/QSInterfaceController.h b/Quicksilver/Code-QuickStepInterface/QSInterfaceController.h index 1779ee16e..4140b3bb5 100644 --- a/Quicksilver/Code-QuickStepInterface/QSInterfaceController.h +++ b/Quicksilver/Code-QuickStepInterface/QSInterfaceController.h @@ -70,6 +70,7 @@ - (void)updateActionsNow; - (void)updateIndirectObjects; +- (void)updateIndirectObjectsWithCompletion:(void (^)(void))completion; - (void)updateViewLocations; - (void)invalidateHide; diff --git a/Quicksilver/Code-QuickStepInterface/QSInterfaceController.m b/Quicksilver/Code-QuickStepInterface/QSInterfaceController.m index 3c4494e61..03ac8fa33 100644 --- a/Quicksilver/Code-QuickStepInterface/QSInterfaceController.m +++ b/Quicksilver/Code-QuickStepInterface/QSInterfaceController.m @@ -345,8 +345,13 @@ - (void)updateActionsNow { } - (void)updateIndirectObjects { + [self updateIndirectObjectsWithCompletion:nil]; +} + +- (void)updateIndirectObjectsWithCompletion:(void (^)(void))completion { // Don't update the indirect objects if this is a 'silent' update. if ([aSelector updatesSilently]) { + if (completion) completion(); return; } QSAction *aObj = [aSelector objectValue]; @@ -384,6 +389,7 @@ - (void)updateIndirectObjects { QSGCDMainAsync(^{ [self updateControl:self->iSelector withArray:finalIndirects]; [self->iSelector setSearchMode:(finalIndirects ? SearchFilter : SearchFilterAll)]; + if (completion) completion(); }); }); } @@ -466,8 +472,10 @@ - (void)searchObjectChanged:(NSNotification*)notif { if (argumentCount == 2 || ([[[[self window] contentView] subviews] containsObject:iSelector] && [obj indirectOptional])) { [self updateIndirectObjects]; } - [self updateViewLocations]; } + // Always update view locations when the action changes, including when + // it becomes nil (so the 3rd pane is hidden when actions are cleared) + [self updateViewLocations]; } else if ([notif object] == iSelector) { [self updateViewLocations]; } @@ -642,6 +650,20 @@ - (void)executeCommand:(id)sender cont:(BOOL)cont encapsulate:(BOOL)encapsulate QSAction *action = [aSelector objectValue]; NSInteger argumentCount = [action argumentCount]; + + if (argumentCount == 2) { + // for the case where we require an argument, update the indirects *then* perform the execute + [self updateIndirectObjectsWithCompletion:^{ + [self performExecuteAction:action argumentCount:argumentCount cont:cont encapsulate:encapsulate]; + }]; + } else { + // otherwise, just perform the execute straight away + [self performExecuteAction:action argumentCount:argumentCount cont:cont encapsulate:encapsulate]; + } + return; +} + +- (void)performExecuteAction:(QSAction *)action argumentCount:(NSInteger)argumentCount cont:(BOOL)cont encapsulate:(BOOL)encapsulate { if (argumentCount == 2) { BOOL indirectIsRequired = ![action indirectOptional]; BOOL indirectIsInvalid = ![iSelector objectValue]; @@ -653,7 +675,7 @@ - (void)executeCommand:(id)sender cont:(BOOL)cont encapsulate:(BOOL)encapsulate } [QSExec noteIndirect:[iSelector objectValue] forAction:action]; } - + // add the object being executed to the history [dSelector updateHistory]; // make sure to save mnemonics before interface is closed. Closing the interface clears the search string so they must be saved before this @@ -663,7 +685,7 @@ - (void)executeCommand:(id)sender cont:(BOOL)cont encapsulate:(BOOL)encapsulate if (argumentCount == 2) { [iSelector saveMnemonic]; } - + if (encapsulate) { [self encapsulateCommand]; return; @@ -677,7 +699,7 @@ - (void)executeCommand:(id)sender cont:(BOOL)cont encapsulate:(BOOL)encapsulate [self executeCommandThreaded]; }); } else { - // action can only be run on main thread + // action can only be run on main thread QSGCDMainSync(^{ [self executeCommandThreaded]; }); diff --git a/Quicksilver/PlugIns-Main/QSCorePlugIn/Code/QSDirectoryParser.m b/Quicksilver/PlugIns-Main/QSCorePlugIn/Code/QSDirectoryParser.m index 112ad131e..e1fc39f30 100644 --- a/Quicksilver/PlugIns-Main/QSCorePlugIn/Code/QSDirectoryParser.m +++ b/Quicksilver/PlugIns-Main/QSCorePlugIn/Code/QSDirectoryParser.m @@ -49,14 +49,14 @@ - (NSArray *)objectsFromPath:(NSString *)path depth:(NSInteger)depth types:(NSAr NSArray *properties = @[NSURLIsSymbolicLinkKey, NSURLIsAliasFileKey, NSURLIsDirectoryKey, NSURLTypeIdentifierKey, NSURLIsPackageKey]; + NSMutableArray *array = [NSMutableArray array]; + NSDirectoryEnumerator *enumerator = [manager enumeratorAtURL:[NSURL fileURLWithPath:path] includingPropertiesForKeys:properties options:NSDirectoryEnumerationSkipsHiddenFiles | NSDirectoryEnumerationSkipsPackageDescendants | NSDirectoryEnumerationSkipsSubdirectoryDescendants errorHandler:nil]; if (!enumerator) return nil; - NSMutableArray *array = [NSMutableArray array]; - for (NSURL *theURL in enumerator) { NSString *file = [theURL path]; @@ -74,10 +74,9 @@ - (NSArray *)objectsFromPath:(NSString *)path depth:(NSInteger)depth types:(NSAr NSURL *targetURL = nil; - if ([resources[NSURLIsAliasFileKey] boolValue]) { NSDictionary *newResources = [resources copy]; - targetURL = [theURL copy]; + targetURL = theURL; while ([newResources[NSURLIsAliasFileKey] boolValue]) { // NB: `NSURLIsAliasFileKey` AND `NSURLIsSymbolicLinkKey` @@ -85,20 +84,22 @@ - (NSArray *)objectsFromPath:(NSString *)path depth:(NSInteger)depth types:(NSAr // or even aliases to symlinks) if ([newResources[NSURLIsSymbolicLinkKey] boolValue]) { - NSURL *oldTarget = [targetURL copy]; - targetURL = [targetURL URLByReallyResolvingSymlinksInPath]; - if ([targetURL isEqual:oldTarget]) + NSURL *newTarget = [targetURL URLByReallyResolvingSymlinksInPath]; + if ([newTarget isEqual:targetURL]) break; + targetURL = newTarget; } else { BOOL stale = NO; - targetURL = [NSURL URLByResolvingBookmarkAtURL:targetURL - options:NSURLBookmarkResolutionWithoutUI | NSURLBookmarkResolutionWithoutMounting - bookmarkDataIsStale:&stale - error:&err]; + NSURL *resolvedURL = [NSURL URLByResolvingBookmarkAtURL:targetURL + options:NSURLBookmarkResolutionWithoutUI | NSURLBookmarkResolutionWithoutMounting + bookmarkDataIsStale:&stale + error:&err]; - if (!targetURL) { + if (!resolvedURL) { NSLog(@"Error resolving %@alias at %@: %@", (stale ? @"stale " : @""), theURL, err); + break; } + targetURL = resolvedURL; } newResources = [targetURL resourceValuesForKeys:properties error:&err]; } @@ -106,7 +107,7 @@ - (NSArray *)objectsFromPath:(NSString *)path depth:(NSInteger)depth types:(NSAr isDirectory = [resources[NSURLIsDirectoryKey] boolValue]; } - if (targetURL) { + if (targetURL && targetURL != theURL) { /* This was a symlink or an alias, grab the correct information */ aliasSource = [NDAlias aliasWithContentsOfFile:file]; aliasFile = file; @@ -148,11 +149,13 @@ - (NSArray *)objectsFromPath:(NSString *)path depth:(NSInteger)depth types:(NSAr shouldDescend = NO; if (depth && isDirectory && shouldDescend) { - @autoreleasepool { - [array addObjectsFromArray:[self objectsFromPath:[theURL path] depth:depth types:types excludeTypes:excludedTypes descend:descendIntoBundles]]; - } + [array addObjectsFromArray:[self objectsFromPath:[theURL path] depth:depth types:types excludeTypes:excludedTypes descend:descendIntoBundles]]; } } + + // Ensure enumerator is released, freeing file descriptors + enumerator = nil; + #ifdef DEBUG if (VERBOSE) { NSLog(@"Scanning %@ took %ld µs",path,(long)(-[startDate timeIntervalSinceNow]*1000000)); diff --git a/Quicksilver/Quicksilver Tests/QSInterfaceControllerTests.m b/Quicksilver/Quicksilver Tests/QSInterfaceControllerTests.m new file mode 100644 index 000000000..363d96d93 --- /dev/null +++ b/Quicksilver/Quicksilver Tests/QSInterfaceControllerTests.m @@ -0,0 +1,273 @@ +// +// QSInterfaceControllerTests.m +// Quicksilver Tests +// + +#import +#import "QSInterfaceController.h" +#import "QSController.h" +#import "QSObject.h" +#import "QSSearchObjectView.h" + +@interface QSInterfaceControllerTests : XCTestCase +@end + +@implementation QSInterfaceControllerTests + +/// Wait for pending QSGCDAsync + QSGCDMainAsync operations to complete +- (void)waitForAsyncUpdates { + XCTestExpectation *exp = [self expectationWithDescription:@"async updates"]; + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + dispatch_async(dispatch_get_main_queue(), ^{ + [exp fulfill]; + }); + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; +} + +/// Enter text mode on dSelector, type text, and fire the action update timer. +/// Returns the interface controller for further assertions. +- (QSInterfaceController *)enterTextModeWithString:(NSString *)text { + QSInterfaceController *i = [(QSController *)[NSApp delegate] interfaceController]; + XCTAssertNotNil(i); + + QSSearchObjectView *dSel = [i dSelector]; + + // Enter text mode (equivalent to pressing '.') + [dSel transmogrify:self]; + + // Set text in the text editor to create a text QSObject + if (text) { + [[dSel textModeEditor] setString:text]; + [dSel setObjectValue:[QSObject objectWithString:text]]; + } + + // Force the action update timer to fire so actions populate immediately + [i fireActionUpdateTimer]; + + return i; +} + +/** + * End-to-end test: pressing Return in text mode should execute the action + * immediately without beeping or moving focus to the 3rd pane. + * + * This deliberately does NOT call waitForAsyncUpdates before executeCommand: + * to reproduce the race condition where updateIndirectObjects dispatches async + * and executeCommand checks iSelector before the update completes. + */ +- (void)testTextModeReturnExecutesAction { + QSInterfaceController *i = [self enterTextModeWithString:@"hello world"]; + + // Do NOT wait for async updates -- this is the key to reproducing the race + // The fix (run loop spin in executeCommand) should handle this. + + // Simulate pressing Return + [i executeCommand:self]; + + // If the bug is present, focus would have moved to iSelector (3rd pane) + // because indirect objects hadn't been set yet. + // With the fix, executeCommand waits for the async update to complete. + NSResponder *firstResponder = [[i window] firstResponder]; + XCTAssertNotEqual(firstResponder, [i iSelector], + @"Focus should not move to 3rd pane -- indirect objects should have been resolved before execution"); +} + +/** + * Same flow as above but WITH waitForAsyncUpdates, verifying the baseline works. + */ +- (void)testTextModeReturnWithAsyncWaitExecutesAction { + QSInterfaceController *i = [self enterTextModeWithString:@"hello world"]; + + // Wait for async updates to fully complete + [self waitForAsyncUpdates]; + + // Simulate pressing Return + [i executeCommand:self]; + + // Focus should not be on the 3rd pane + NSResponder *firstResponder = [[i window] firstResponder]; + XCTAssertNotEqual(firstResponder, [i iSelector], + @"Focus should not move to 3rd pane after async updates completed"); +} + +/** + * Verify that the indirect validity check doesn't cause a beep. + * For a text mode action with argumentCount != 2, iSelector should not matter. + */ +- (void)testTextModeReturnDoesNotBeep { + QSInterfaceController *i = [self enterTextModeWithString:@"test string"]; + + // Wait for async updates + [self waitForAsyncUpdates]; + + // The default action for text (e.g. "Large Type") typically has argumentCount == 1, + // so the indirect check should be skipped entirely. + QSAction *action = [[i aSelector] objectValue]; + if (action && [action argumentCount] != 2) { + // For single-argument actions, executeCommand should succeed without + // any attempt to focus iSelector + [i executeCommand:self]; + NSResponder *firstResponder = [[i window] firstResponder]; + XCTAssertNotEqual(firstResponder, [i iSelector], + @"Single-argument action should not move focus to 3rd pane"); + } +} + +/** + * Verify that executeCommand: actually fires the action by listening for + * QSCommandExecutedNotification. For a single-arg text action, execution + * goes through performExecuteAction: synchronously (since QSGCDMainSync + * runs inline on the main thread). + */ +- (void)testExecuteCommandFiresActionNotification { + QSInterfaceController *i = [self enterTextModeWithString:@"hello world"]; + [self waitForAsyncUpdates]; + + QSAction *action = [[i aSelector] objectValue]; + XCTAssertNotNil(action, @"Action should be populated after timer fire"); + + // Text actions (e.g. Large Type) have argumentCount == 1, so execution + // goes through the direct performExecuteAction: path (no completion block). + // QSGCDMainSync runs the block inline since we're already on the main thread, + // so the notification is posted synchronously before executeCommand: returns. + __block BOOL notificationReceived = NO; + id observer = [[NSNotificationCenter defaultCenter] + addObserverForName:QSCommandExecutedNotification + object:nil + queue:nil + usingBlock:^(NSNotification *notif) { + notificationReceived = YES; + }]; + + [i executeCommand:self]; + + [[NSNotificationCenter defaultCenter] removeObserver:observer]; + XCTAssertTrue(notificationReceived, + @"QSCommandExecutedNotification should fire when executeCommand: runs a single-arg action"); +} + +/** + * Verify that executeCommand: fires the action even without waiting for + * async indirect object updates. For single-arg actions this should always + * succeed since they bypass the indirect update path entirely. + */ +- (void)testExecuteCommandFiresActionWithoutAsyncWait { + QSInterfaceController *i = [self enterTextModeWithString:@"test"]; + + // Deliberately do NOT call waitForAsyncUpdates. + // Single-arg actions go through performExecuteAction: directly, + // so the async indirect update is irrelevant. + + QSAction *action = [[i aSelector] objectValue]; + XCTAssertNotNil(action, @"Action should be populated after timer fire"); + + __block BOOL notificationReceived = NO; + id observer = [[NSNotificationCenter defaultCenter] + addObserverForName:QSCommandExecutedNotification + object:nil + queue:nil + usingBlock:^(NSNotification *notif) { + notificationReceived = YES; + }]; + + [i executeCommand:self]; + + [[NSNotificationCenter defaultCenter] removeObserver:observer]; + XCTAssertTrue(notificationReceived, + @"Single-arg action should fire immediately without waiting for async indirect updates"); +} + +/** + * Verify that updateIndirectObjectsWithCompletion: actually invokes its + * completion block after the async background→main dispatch completes. + * This is the core mechanism of the race condition fix: executeCommand: + * passes the rest of execution as a completion block so it runs only + * after indirect objects are populated. + */ +//- (void)testCompletionBlockCalledAfterIndirectUpdate { +// QSInterfaceController *i = [self enterTextModeWithString:@"hello"]; +// [self waitForAsyncUpdates]; +// +// XCTestExpectation *completionCalled = [self expectationWithDescription:@"completion block invoked"]; +// +// [i updateIndirectObjectsWithCompletion:^{ +// [completionCalled fulfill]; +// }]; +// +// [self waitForExpectationsWithTimeout:5.0 handler:nil]; +//} + +/** + * Verify that for two-argument actions, executeCommand: defers execution + * to the updateIndirectObjectsWithCompletion: completion block rather than + * running it synchronously. + * + * This is the core of the race condition fix: without it, executeCommand: + * runs performExecuteAction: synchronously, which checks [iSelector objectValue] + * before the async indirect update has completed. With the fix, execution is + * deferred to the completion block, which fires only after indirects are set. + * + * Detection: with the fix, QSCommandExecutedNotification is NOT posted before + * executeCommand: returns (it's pending in the completion block on the main + * queue). Without the fix, the notification fires synchronously inside + * executeCommand:. + */ +- (void)testTwoArgExecutionDeferredToCompletionBlock { + QSInterfaceController *i = [self enterTextModeWithString:@"hello world"]; + [self waitForAsyncUpdates]; + + QSAction *action = [[i aSelector] objectValue]; + XCTAssertNotNil(action, @"Action should be populated after timer fire"); + + // Save original values for cleanup + NSInteger originalArgCount = [action argumentCount]; + id originalIndirectOptional = [[action actionDict] objectForKey:kActionIndirectOptional]; + + // Force the action to appear as a 2-arg action with optional indirect. + // argumentCount == 2 makes executeCommand: take the two-arg code path. + // indirectOptional ensures performExecuteAction: doesn't bail at the + // indirect validity check (since iSelector will be nil). + [action setArgumentCount:2]; + [[action actionDict] setObject:@YES forKey:kActionIndirectOptional]; + + // Track whether the notification fires synchronously (before executeCommand: returns) + // vs asynchronously (in the completion block, after executeCommand: returns). + // Using queue:nil so the observer fires inline on the posting thread — if the + // notification is posted during executeCommand:, the block runs before it returns. + __block BOOL notificationReceived = NO; + XCTestExpectation *exp = [self expectationWithDescription:@"Command eventually executed"]; + + id observer = [[NSNotificationCenter defaultCenter] + addObserverForName:QSCommandExecutedNotification + object:nil + queue:nil + usingBlock:^(NSNotification *notif) { + notificationReceived = YES; + [exp fulfill]; + }]; + + [i executeCommand:self]; + + // Check IMMEDIATELY after executeCommand: returns, before spinning the run loop. + // With the fix: execution is deferred (async via completion block) → NO notification yet. + // Without the fix: execution ran synchronously inside executeCommand: → notification already fired. + XCTAssertFalse(notificationReceived, + @"For 2-arg actions, execution must be deferred to the completion block. " + @"Synchronous execution indicates the race condition fix is missing."); + + // Now wait for the async completion to verify the action DOES eventually fire. + [self waitForExpectationsWithTimeout:5.0 handler:nil]; + + [[NSNotificationCenter defaultCenter] removeObserver:observer]; + + // Restore original action state + [action setArgumentCount:originalArgCount]; + if (originalIndirectOptional) { + [[action actionDict] setObject:originalIndirectOptional forKey:kActionIndirectOptional]; + } else { + [[action actionDict] removeObjectForKey:kActionIndirectOptional]; + } +} + +@end diff --git a/Quicksilver/Quicksilver Tests/Quicksilver_Tests.m b/Quicksilver/Quicksilver Tests/Quicksilver_Tests.m index e4459c310..b1c929ca3 100644 --- a/Quicksilver/Quicksilver Tests/Quicksilver_Tests.m +++ b/Quicksilver/Quicksilver Tests/Quicksilver_Tests.m @@ -18,18 +18,6 @@ @interface Quicksilver_Tests : XCTestCase @implementation Quicksilver_Tests -/// Wait for pending QSGCDAsync + QSGCDMainAsync operations to complete -- (void)waitForAsyncUpdates { - XCTestExpectation *exp = [self expectationWithDescription:@"async updates"]; - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - dispatch_async(dispatch_get_main_queue(), ^{ - [exp fulfill]; - }); - }); - [self waitForExpectationsWithTimeout:2.0 handler:nil]; -} - - - (void)testActionsForURLObject { NSString *url = @"https://qsapp.com"; QSObject *object = [QSObject objectWithString:url]; @@ -204,52 +192,54 @@ - (void)testThirdPaneClosingBehaviour { NSEvent *typeAEvent = [NSEvent keyEventWithType:NSEventTypeKeyDown location:NSMakePoint(0, 0) modifierFlags:256 timestamp:15127.081604936 windowNumber:[[i window] windowNumber] context:nil characters:@"a" charactersIgnoringModifiers:@"a" isARepeat:NO keyCode:0]; [[i dSelector] keyDown:typeAEvent]; - + // dSelector is populated with an object XCTAssertNotNil([[i dSelector] objectValue]); - + // aSelector is nil until the actions timer has fired XCTAssertNil([[i aSelector] objectValue]); - // UI tests hack: force the actions timer to fire now + // Force the actions timer to fire now (synchronous) [i fireActionUpdateTimer]; - // Wait for async indirect object updates to complete - [self waitForAsyncUpdates]; XCTAssertNotNil([[i aSelector] objectValue]); - // the iSelector should be closed + // the iSelector should be closed (default action for "a" is single-arg) XCTAssertFalse([self isViewVisible:[i iSelector] forController:i]); + // Type "open with" into aSelector to select a 2-arg action NSEvent *searchForActionEvent = [NSEvent keyEventWithType:NSEventTypeKeyDown location:NSMakePoint(0, 0) modifierFlags:256 timestamp:15127.081604936 windowNumber:[[i window] windowNumber] context:nil characters:@"open with" charactersIgnoringModifiers:@"open with" isARepeat:NO keyCode:0]; [[i aSelector] keyDown:searchForActionEvent]; - // Wait for async indirect object updates to complete - [self waitForAsyncUpdates]; + + // Wait for indirect objects to be populated (deterministic via completion block) + XCTestExpectation *indirectsUpdated = [self expectationWithDescription:@"indirect objects updated"]; + [i updateIndirectObjectsWithCompletion:^{ + [indirectsUpdated fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; + + // iSelector should now be visible (Open With is a 2-arg action) XCTAssertFalse([[i iSelector] isHidden]); - // iSelector should now be visible XCTAssertTrue([self isViewVisible:[i iSelector] forController:i]); - // Clear the first pane (use ⌃U is easiest) + // Clear the first pane (⌃U) NSEvent *clearEvent = [NSEvent keyEventWithType:NSEventTypeKeyDown location:NSMakePoint(0, 0) modifierFlags:NSEventModifierFlagControl timestamp:15127.081604936 windowNumber:[[i window] windowNumber] context:nil characters:@"u" charactersIgnoringModifiers:@"u" isARepeat:NO keyCode:32]; [[i dSelector] keyDown:clearEvent]; - XCTAssertNil([[i dSelector] objectValue]); // aSelector still has object until the action timer is fired XCTAssertNotNil([[i aSelector] objectValue]); - // iSeletor still visible + // iSelector still visible XCTAssertTrue([self isViewVisible:[i iSelector] forController:i]); - // UI tests hack: force the actions timer to fire now + // Force the actions timer to fire — clears actions since dSelector is nil. + // searchObjectChanged: now calls updateViewLocations unconditionally, + // so the iSelector is hidden synchronously. [i fireActionUpdateTimer]; - // Wait for async indirect object updates to complete - [self waitForAsyncUpdates]; XCTAssertNil([[i aSelector] objectValue]); // the iSelector should be closed XCTAssertFalse([self isViewVisible:[i iSelector] forController:i]); - - } @end diff --git a/Quicksilver/Quicksilver.xcodeproj/project.pbxproj b/Quicksilver/Quicksilver.xcodeproj/project.pbxproj index 1e9f589c4..ed6f0fb2e 100644 --- a/Quicksilver/Quicksilver.xcodeproj/project.pbxproj +++ b/Quicksilver/Quicksilver.xcodeproj/project.pbxproj @@ -369,6 +369,7 @@ CD61BBCB192B199F00040609 /* QSFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1E5F9AF07B1FCFC0044D6EF /* QSFoundation.framework */; }; CD61BBCC192B19A100040609 /* QSEffects.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1E5F9C307B1FD100044D6EF /* QSEffects.framework */; }; CD632F7C2F31CF62007D0601 /* Vision.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD632F7A2F31CF4A007D0601 /* Vision.framework */; }; + CD6445E12F6E9EB90010A7F3 /* QSInterfaceControllerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CD6445E02F6E9EB90010A7F3 /* QSInterfaceControllerTests.m */; }; CD65B12916FA1BD000932A9C /* QSPluginUpdaterWindowController.h in Headers */ = {isa = PBXBuildFile; fileRef = CD65B12716FA1BD000932A9C /* QSPluginUpdaterWindowController.h */; settings = {ATTRIBUTES = (Public, ); }; }; CD65B12A16FA1BD000932A9C /* QSPluginUpdaterWindowController.m in Sources */ = {isa = PBXBuildFile; fileRef = CD65B12816FA1BD000932A9C /* QSPluginUpdaterWindowController.m */; }; CD65B12C16FA1BE400932A9C /* QSPluginUpdater.xib in Resources */ = {isa = PBXBuildFile; fileRef = CD65B12B16FA1BE400932A9C /* QSPluginUpdater.xib */; }; @@ -1769,6 +1770,7 @@ CD61BBBF192B18FA00040609 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; CD61BBC1192B18FA00040609 /* Quicksilver_Tests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Quicksilver_Tests.m; sourceTree = ""; }; CD632F7A2F31CF4A007D0601 /* Vision.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Vision.framework; path = System/Library/Frameworks/Vision.framework; sourceTree = SDKROOT; }; + CD6445E02F6E9EB90010A7F3 /* QSInterfaceControllerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QSInterfaceControllerTests.m; sourceTree = ""; }; CD65B12716FA1BD000932A9C /* QSPluginUpdaterWindowController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = QSPluginUpdaterWindowController.h; sourceTree = ""; usesTabs = 1; }; CD65B12816FA1BD000932A9C /* QSPluginUpdaterWindowController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = QSPluginUpdaterWindowController.m; sourceTree = ""; usesTabs = 1; }; CD65B12B16FA1BE400932A9C /* QSPluginUpdater.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = QSPluginUpdater.xib; sourceTree = ""; }; @@ -2339,7 +2341,7 @@ E1E5FB9B07B20DD20044D6EF /* QSObject_Menus.h */ = {isa = PBXFileReference; fileEncoding = 30; lastKnownFileType = sourcecode.c.h; path = QSObject_Menus.h; sourceTree = ""; usesTabs = 1; }; E1E5FB9C07B20DD20044D6EF /* QSObject_Menus.m */ = {isa = PBXFileReference; fileEncoding = 30; lastKnownFileType = sourcecode.c.objc; path = QSObject_Menus.m; sourceTree = ""; usesTabs = 1; }; E1E5FB9F07B20DD20044D6EF /* QSObject_Pasteboard.h */ = {isa = PBXFileReference; fileEncoding = 30; lastKnownFileType = sourcecode.c.h; path = QSObject_Pasteboard.h; sourceTree = ""; usesTabs = 1; }; - E1E5FBA007B20DD20044D6EF /* QSObject_Pasteboard.m */ = {isa = PBXFileReference; fileEncoding = 30; lastKnownFileType = sourcecode.c.objc; path = QSObject_Pasteboard.m; sourceTree = ""; usesTabs = 0; }; + E1E5FBA007B20DD20044D6EF /* QSObject_Pasteboard.m */ = {isa = PBXFileReference; fileEncoding = 30; lastKnownFileType = sourcecode.c.objc; path = QSObject_Pasteboard.m; sourceTree = ""; usesTabs = 1; }; E1E5FBA107B20DD20044D6EF /* QSObject_PropertyList.h */ = {isa = PBXFileReference; fileEncoding = 30; lastKnownFileType = sourcecode.c.h; path = QSObject_PropertyList.h; sourceTree = ""; usesTabs = 1; }; E1E5FBA207B20DD20044D6EF /* QSObject_PropertyList.m */ = {isa = PBXFileReference; fileEncoding = 30; lastKnownFileType = sourcecode.c.objc; path = QSObject_PropertyList.m; sourceTree = ""; usesTabs = 1; }; E1E5FBA307B20DD20044D6EF /* QSObject_StringHandling.h */ = {isa = PBXFileReference; fileEncoding = 30; lastKnownFileType = sourcecode.c.h; path = QSObject_StringHandling.h; sourceTree = ""; usesTabs = 1; }; @@ -2969,6 +2971,7 @@ CD61BBBB192B18FA00040609 /* Quicksilver Tests */ = { isa = PBXGroup; children = ( + CD6445E02F6E9EB90010A7F3 /* QSInterfaceControllerTests.m */, CD943C412D6332F600DB5C9A /* QSCatalogEntryTests.m */, CD61BBC1192B18FA00040609 /* Quicksilver_Tests.m */, CD61BBBC192B18FA00040609 /* Supporting Files */, @@ -5032,6 +5035,7 @@ files = ( CD943C452D63643700DB5C9A /* QSCatalogEntryTests.m in Sources */, CD61BBC2192B18FA00040609 /* Quicksilver_Tests.m in Sources */, + CD6445E12F6E9EB90010A7F3 /* QSInterfaceControllerTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; };