Skip to content

Commit 91cadfe

Browse files
committed
test(ios): isolate defaults collaborator and narrow warning errors
Keep ordinary compiler warnings visible without a blanket -Werror for extracted product code. Retain objc-method-access as a targeted test-host contract check. Replace the NSUserDefaults subclass with an NSObject-backed in-memory collaborator and substitute the defaults type only in the test translation unit. Unsupported typed selectors fail compilation; dynamic sends fail fast instead of reaching inherited real preferences or being swallowed by product exception handlers. Add instance-isolation, warning-tolerance, and unsupported read/write compile/runtime probes. Keep the five ordering cases and three regression mutants unchanged. Document the harness contracts; no production changes.
1 parent 680214c commit 91cadfe

3 files changed

Lines changed: 157 additions & 13 deletions

File tree

‎scripts/test-ios-purge-restore.sh‎

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ if [ "${SANITIZE:-0}" = "1" ]; then
1616
SANITIZE_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g"
1717
fi
1818

19+
# This is an orchestration test, not a stricter product warning gate. Keep
20+
# warnings visible without promoting all of them to errors. Unknown Objective-C
21+
# selectors are the one intentional error: they indicate an incomplete test seam.
22+
WARNING_FLAGS="-Wall -Wextra -Wno-unused-parameter -Werror=objc-method-access"
23+
1924
build() {
2025
variant="$1"
2126
destination="$BUILD_DIR/$variant"
@@ -27,8 +32,7 @@ build() {
2732
fi
2833
# Compile the real production bodies and state_core; only their external I/O
2934
# collaborators live in the test host. No RN, CocoaPods, simulator or network.
30-
xcrun clang++ -std=c++17 -fobjc-arc -fblocks -Wall -Wextra -Werror \
31-
-Wno-unused-parameter $SANITIZE_FLAGS \
35+
xcrun clang++ -std=c++17 -fobjc-arc -fblocks $WARNING_FLAGS $SANITIZE_FLAGS \
3236
-I"$ROOT_DIR" -I"$destination" \
3337
"$TEST_DIR/purge_restore_test.mm" "$ROOT_DIR/cpp/patch_core/state_core.cpp" \
3438
-framework Foundation -o "$destination/purge_restore_test"
@@ -40,6 +44,51 @@ build baseline
4044
# Negative controls execute only generated test copies. Every original bug must
4145
# fail its named assertion, not merely fail to compile or time out.
4246
if [ "${VERIFY_REGRESSIONS:-0}" = "1" ]; then
47+
# Compile the same translation unit with narrowly selected contract probes.
48+
# Unsupported selectors must fail for that selector, while a benign warning
49+
# must compile. No probe modifies product code or the generated baseline.
50+
compile_probe() {
51+
xcrun clang++ -std=c++17 -fobjc-arc -fblocks $WARNING_FLAGS \
52+
-I"$ROOT_DIR" -I"$BUILD_DIR/baseline" -fsyntax-only -D"$1" \
53+
"$TEST_DIR/purge_restore_test.mm"
54+
}
55+
logfile="$BUILD_DIR/baseline/warning-probe.log"
56+
if ! compile_probe TEST_HARMLESS_WARNING_PROBE >"$logfile" 2>&1; then
57+
cat "$logfile" >&2
58+
exit 1
59+
fi
60+
grep -F 'warning: PUSHY_TEST_HARMLESS_WARNING' "$logfile"
61+
echo "[PASS] ordinary compiler warnings are non-fatal"
62+
63+
for access in read write; do
64+
case "$access" in
65+
read) probe=TEST_DEFAULTS_READ_PROBE; selector='boolForKey:' ;;
66+
write) probe=TEST_DEFAULTS_WRITE_PROBE; selector='setBool:forKey:' ;;
67+
esac
68+
logfile="$BUILD_DIR/baseline/defaults-$access-compile.log"
69+
if compile_probe "$probe" >"$logfile" 2>&1; then
70+
echo "ERROR: unsupported defaults $access unexpectedly compiled" >&2
71+
exit 1
72+
fi
73+
if ! grep -F 'error:' "$logfile" | grep -F "no visible @interface for 'TestDefaults'" | grep -F "'$selector'"; then
74+
cat "$logfile" >&2
75+
echo "ERROR: defaults $access failed compilation for an unexpected reason" >&2
76+
exit 1
77+
fi
78+
testcase="unsupported_defaults_$access"
79+
logfile="$BUILD_DIR/baseline/defaults-$access-runtime.log"
80+
if "$BUILD_DIR/baseline/purge_restore_test" "$testcase" >"$logfile" 2>&1; then
81+
echo "ERROR: unsupported defaults $access unexpectedly succeeded" >&2
82+
exit 1
83+
fi
84+
if ! grep -Fx "[FAIL] $testcase: unsupported TestDefaults selector: $selector" "$logfile"; then
85+
cat "$logfile" >&2
86+
echo "ERROR: defaults $access failed at runtime for an unexpected reason" >&2
87+
exit 1
88+
fi
89+
echo "[PASS] unsupported defaults $access rejected at compile time and runtime"
90+
done
91+
4392
for pair in late-activation:late_commit skip-reresolve:commit_before_signal ignore-reset:reset_wins; do
4493
variant="${pair%:*}"
4594
testcase="${pair#*:}"

‎scripts/tests/ios-purge-restore/README.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,30 @@ post-reset filesystem cleanup. Bundle-existence checks use real temporary files.
4444
The production state lock is real; every defaults mutation asserts ownership of
4545
that same `os_unfair_lock`.
4646

47+
## Harness contracts
48+
49+
This executable is not a stricter warning gate for the extracted product code.
50+
`-Wall -Wextra` keeps warnings visible, without a blanket `-Werror`. The only
51+
explicit warning promoted to an error is `objc-method-access`: an undeclared
52+
selector means the test host no longer models a collaborator it needs.
53+
54+
`TestDefaults` inherits from `NSObject`, **not** `NSUserDefaults`. Its six explicit
55+
accessors use a private, per-instance dictionary. A test-only type substitution,
56+
after importing Foundation, makes the extracted `NSUserDefaults *` declarations
57+
refer to this narrow interface. A newly used accessor such as `setBool:forKey:`
58+
or `boolForKey:` must be implemented deliberately; it cannot silently inherit a
59+
path into the test process's real preferences. Dynamic calls that erase the
60+
static type hit a fatal `doesNotRecognizeSelector:` backstop, which production
61+
exception handlers cannot swallow.
62+
63+
Every test process runs `defaults_isolation` before the ordering cases, verifying
64+
fresh-instance isolation, the supported accessors and snapshot independence.
65+
With `VERIFY_REGRESSIONS=1`, compile probes prove that an ordinary warning remains
66+
non-fatal and both unsupported typed selectors are rejected. Separate subprocess
67+
probes send the same messages through `id` and require the exact fail-fast runtime
68+
diagnostic. No probe instantiates a real `NSUserDefaults` object or writes a real
69+
preferences domain.
70+
4771
## Orderings
4872

4973
| Test | Enforced ordering | Assertions |

‎scripts/tests/ios-purge-restore/purge_restore_test.mm‎

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,19 @@ static void Await(dispatch_semaphore_t gate, const char *description) {
6262
static int64_t waitBudget;
6363
static NSString *root;
6464

65-
// In-memory NSUserDefaults collaborator. Every production defaults mutation must
66-
// still hold the REAL production os_unfair_lock. Pausing one write inside that
67-
// lock lets the opposing operation start without relying on OS scheduling.
68-
@interface TestDefaults : NSUserDefaults
69-
@property(nonatomic, strong) NSMutableDictionary<NSString *, id> *values;
65+
// In-memory collaborator, deliberately NOT an NSUserDefaults subclass: inherited
66+
// accessors could otherwise reach real preferences without ObserveWrite(). Only
67+
// explicitly modelled selectors are supported. Every mutation still holds the
68+
// REAL production lock; pausing one write fixes the competing operation's order.
69+
@interface TestDefaults : NSObject {
70+
NSMutableDictionary<NSString *, id> *_values;
71+
}
72+
- (void)setObject:(id)value forKey:(NSString *)key;
73+
- (void)removeObjectForKey:(NSString *)key;
74+
- (id)objectForKey:(NSString *)key;
75+
- (NSString *)stringForKey:(NSString *)key;
76+
- (NSDictionary *)dictionaryForKey:(NSString *)key;
77+
- (NSDictionary<NSString *, id> *)dictionaryRepresentation;
7078
@end
7179

7280
static void ObserveWrite(void) {
@@ -83,20 +91,46 @@ - (instancetype)init {
8391
if (self) { _values = [NSMutableDictionary new]; }
8492
return self;
8593
}
86-
- (void)setObject:(id)value forKey:(NSString *)key { ObserveWrite(); self.values[key] = value; }
87-
- (void)removeObjectForKey:(NSString *)key { ObserveWrite(); [self.values removeObjectForKey:key]; }
88-
- (id)objectForKey:(NSString *)key { return self.values[key]; }
94+
- (void)setObject:(id)value forKey:(NSString *)key { ObserveWrite(); _values[key] = value; }
95+
- (void)removeObjectForKey:(NSString *)key { ObserveWrite(); [_values removeObjectForKey:key]; }
96+
- (id)objectForKey:(NSString *)key { return _values[key]; }
8997
- (NSString *)stringForKey:(NSString *)key {
90-
id value = self.values[key];
98+
id value = _values[key];
9199
return [value isKindOfClass:NSString.class] ? value : nil;
92100
}
93101
- (NSDictionary *)dictionaryForKey:(NSString *)key {
94-
id value = self.values[key];
102+
id value = _values[key];
95103
return [value isKindOfClass:NSDictionary.class] ? value : nil;
96104
}
97-
- (NSDictionary<NSString *, id> *)dictionaryRepresentation { return [self.values copy]; }
105+
- (NSDictionary<NSString *, id> *)dictionaryRepresentation { return [_values copy]; }
106+
- (void)doesNotRecognizeSelector:(SEL)selector {
107+
// Fail even if product code catches Objective-C exceptions. Dynamic sends
108+
// must not fall through to a real defaults domain or turn into a passed test.
109+
std::string message = "unsupported TestDefaults selector: ";
110+
message += NSStringFromSelector(selector).UTF8String;
111+
Expect(false, message.c_str());
112+
}
98113
@end
99114

115+
// Type substitution is confined to this test translation unit, AFTER Foundation
116+
// is imported. The extracted bodies stay unchanged, but a newly used defaults
117+
// selector must be explicitly implemented here rather than inherited silently.
118+
#define NSUserDefaults TestDefaults
119+
120+
// Compile-only contract probes, enabled individually by the runner. Ordinary
121+
// warnings must remain non-fatal; unmodelled selectors must fail compilation.
122+
#if defined(TEST_DEFAULTS_READ_PROBE)
123+
static BOOL DefaultsReadProbe(NSUserDefaults *defaults) {
124+
return [defaults boolForKey:@"probe"];
125+
}
126+
#elif defined(TEST_DEFAULTS_WRITE_PROBE)
127+
static void DefaultsWriteProbe(NSUserDefaults *defaults) {
128+
[defaults setBool:YES forKey:@"probe"];
129+
}
130+
#elif defined(TEST_HARMLESS_WARNING_PROBE)
131+
#warning PUSHY_TEST_HARMLESS_WARNING
132+
#endif
133+
100134
static TestDefaults *testDefaults;
101135
static NSUserDefaults *PushyDefaults(void) { return testDefaults; }
102136
static NSTimeInterval PushyMonotonicNow(void) { return 100; }
@@ -413,8 +447,45 @@ static void CompleteInTime(void) {
413447
Finish();
414448
}
415449

450+
// This contract test deliberately does not call SetUp: a new collaborator must
451+
// start empty on its own, not only because the fixture reset some known keys.
452+
static void DefaultsIsolation(void) {
453+
TestDefaults *first = [TestDefaults new];
454+
TestDefaults *second = [TestDefaults new];
455+
Expect(![first isKindOfClass:NSClassFromString(@"NSUserDefaults")],
456+
"test defaults must not inherit real preferences storage");
457+
PushyWithStateLock(^{
458+
[first setObject:@"value" forKey:@"key"];
459+
[first setObject:@{@"nested": @YES} forKey:@"dictionary"];
460+
Expect([[first stringForKey:@"key"] isEqual:@"value"], "string accessor uses memory");
461+
Expect([[first dictionaryForKey:@"dictionary"][@"nested"] boolValue],
462+
"dictionary accessor uses memory");
463+
NSDictionary *snapshot = [first dictionaryRepresentation];
464+
[first removeObjectForKey:@"key"];
465+
Expect([first objectForKey:@"key"] == nil, "remove accessor uses memory");
466+
Expect([snapshot[@"key"] isEqual:@"value"], "dictionary snapshot is independent");
467+
Expect([second dictionaryRepresentation].count == 0, "instances must not share defaults");
468+
});
469+
}
470+
416471
int main(int argc, char **argv) {
417472
@autoreleasepool {
473+
// These intentional failures run in separate processes. Erasing the
474+
// static type also checks the runtime backstop, not just the compiler.
475+
if (argc > 1 && (std::string(argv[1]) == "unsupported_defaults_read" ||
476+
std::string(argv[1]) == "unsupported_defaults_write")) {
477+
testName = argv[1];
478+
id defaults = [TestDefaults new];
479+
if (std::string(argv[1]) == "unsupported_defaults_read") {
480+
(void)[defaults boolForKey:@"probe"];
481+
} else {
482+
PushyWithStateLock(^{ [defaults setBool:YES forKey:@"probe"]; });
483+
}
484+
Expect(false, "unsupported defaults call unexpectedly returned");
485+
}
486+
testName = "defaults_isolation";
487+
DefaultsIsolation();
488+
std::printf("[PASS] defaults_isolation\n");
418489
struct Case { const char *name; void (*run)(); };
419490
const Case cases[] = {
420491
{"late_commit", LateCommit},

0 commit comments

Comments
 (0)