Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,15 @@ jobs:
sudo apt-get install -y clang
FUZZ=1 ./scripts/test-update-flow-core.sh
FUZZ=1 ./scripts/test-patch-core.sh

ios-purge-restore-test:
name: Native purge-restore ordering
runs-on: macos-latest
timeout-minutes: 5

steps:
- uses: actions/checkout@v7
# Foundation-only executable: exercises the production tvOS Release
# methods without RN, CocoaPods, a simulator, or real network delays.
- name: Run deterministic native ordering tests and negative controls
run: SANITIZE=1 VERIFY_REGRESSIONS=1 bash scripts/test-ios-purge-restore.sh
113 changes: 113 additions & 0 deletions scripts/test-ios-purge-restore.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#!/bin/sh
set -eu

ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
TEST_DIR="$ROOT_DIR/scripts/tests/ios-purge-restore"
BUILD_DIR="$ROOT_DIR/.tmp/ios-purge-restore-tests"

python3 -m unittest discover -s "$TEST_DIR" -p test_extract.py
if [ "$(uname -s)" != "Darwin" ]; then
echo "Native purge-restore tests require macOS Foundation and Xcode command-line tools." >&2
exit 1
fi

SANITIZE_FLAGS=""
if [ "${SANITIZE:-0}" = "1" ]; then
SANITIZE_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g"
fi

# This is an orchestration test, not a stricter product warning gate. Keep
# warnings visible without promoting all of them to errors. Unknown Objective-C
# selectors are the one intentional error: they indicate an incomplete test seam.
WARNING_FLAGS="-Wall -Wextra -Wno-unused-parameter -Werror=objc-method-access"

build() {
variant="$1"
destination="$BUILD_DIR/$variant"
mkdir -p "$destination"
if [ "$variant" = baseline ]; then
python3 "$TEST_DIR/extract.py" "$ROOT_DIR/ios/RCTPushy/RCTPushy.mm" "$destination"
else
python3 "$TEST_DIR/extract.py" "$ROOT_DIR/ios/RCTPushy/RCTPushy.mm" "$destination" --mutation "$variant"
fi
# Compile the real production bodies and state_core; only their external I/O
# collaborators live in the test host. No RN, CocoaPods, simulator or network.
xcrun clang++ -std=c++17 -fobjc-arc -fblocks $WARNING_FLAGS $SANITIZE_FLAGS \
-I"$ROOT_DIR" -I"$destination" \
"$TEST_DIR/purge_restore_test.mm" "$ROOT_DIR/cpp/patch_core/state_core.cpp" \
-framework Foundation -o "$destination/purge_restore_test"
}

build baseline
"$BUILD_DIR/baseline/purge_restore_test" "$@"

# Negative controls execute only generated test copies. Every original bug must
# fail its named assertion, not merely fail to compile or time out.
if [ "${VERIFY_REGRESSIONS:-0}" = "1" ]; then
# Compile the same translation unit with narrowly selected contract probes.
# Unsupported selectors must fail for that selector, while a benign warning
# must compile. No probe modifies product code or the generated baseline.
compile_probe() {
xcrun clang++ -std=c++17 -fobjc-arc -fblocks $WARNING_FLAGS \
-I"$ROOT_DIR" -I"$BUILD_DIR/baseline" -fsyntax-only -D"$1" \
"$TEST_DIR/purge_restore_test.mm"
}
logfile="$BUILD_DIR/baseline/warning-probe.log"
if ! compile_probe TEST_HARMLESS_WARNING_PROBE >"$logfile" 2>&1; then
cat "$logfile" >&2
exit 1
fi
grep -F 'warning: PUSHY_TEST_HARMLESS_WARNING' "$logfile"
echo "[PASS] ordinary compiler warnings are non-fatal"

for access in read write; do
case "$access" in
read) probe=TEST_DEFAULTS_READ_PROBE; selector='boolForKey:' ;;
write) probe=TEST_DEFAULTS_WRITE_PROBE; selector='setBool:forKey:' ;;
esac
logfile="$BUILD_DIR/baseline/defaults-$access-compile.log"
if compile_probe "$probe" >"$logfile" 2>&1; then
echo "ERROR: unsupported defaults $access unexpectedly compiled" >&2
exit 1
fi
if ! grep -F 'error:' "$logfile" | grep -F "no visible @interface for 'TestDefaults'" | grep -F "'$selector'"; then
cat "$logfile" >&2
echo "ERROR: defaults $access failed compilation for an unexpected reason" >&2
exit 1
fi
testcase="unsupported_defaults_$access"
logfile="$BUILD_DIR/baseline/defaults-$access-runtime.log"
if "$BUILD_DIR/baseline/purge_restore_test" "$testcase" >"$logfile" 2>&1; then
echo "ERROR: unsupported defaults $access unexpectedly succeeded" >&2
exit 1
fi
if ! grep -Fx "[FAIL] $testcase: unsupported TestDefaults selector: $selector" "$logfile"; then
cat "$logfile" >&2
echo "ERROR: defaults $access failed at runtime for an unexpected reason" >&2
exit 1
fi
echo "[PASS] unsupported defaults $access rejected at compile time and runtime"
done

for pair in late-activation:late_commit skip-reresolve:commit_before_signal ignore-reset:reset_wins; do
variant="${pair%:*}"
testcase="${pair#*:}"
build "$variant"
logfile="$BUILD_DIR/$variant/result.log"
if "$BUILD_DIR/$variant/purge_restore_test" "$testcase" >"$logfile" 2>&1; then
echo "ERROR: $testcase did not detect $variant" >&2
exit 1
fi
case "$variant" in
late-activation) expected="late round may persist its response but must not activate" ;;
skip-reresolve) expected="must re-resolve B even when done signal is late" ;;
ignore-reset) expected="stale generation must reject ALL commit writes" ;;
esac
if ! grep -F "[FAIL] $testcase: $expected" "$logfile"; then
cat "$logfile" >&2
echo "ERROR: $variant failed for an unexpected reason" >&2
exit 1
fi
echo "[PASS] negative control: $variant is detected by $testcase"
done
fi
106 changes: 106 additions & 0 deletions scripts/tests/ios-purge-restore/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Deterministic native purge-restore ordering tests

Follow-up to #646. These tests exercise the tvOS Release branch of the real
`RCTPushy.mm` startup/commit/reset code in a macOS Foundation-only executable.
There are no production code changes, React Native mocks to install, CocoaPods,
simulator builds, network requests, or sleeps.

## Run

On macOS with Xcode command-line tools and Python 3.10+:

```sh
bash scripts/test-ios-purge-restore.sh
SANITIZE=1 VERIFY_REGRESSIONS=1 bash scripts/test-ios-purge-restore.sh
# Run just one ordering:
bash scripts/test-ios-purge-restore.sh reset_wins
```

The `test` workflow runs the sanitized suite and negative controls on macOS.
The extractor's own tests also run independently on Linux/macOS:

```sh
python3 -m unittest discover -s scripts/tests/ios-purge-restore -p test_extract.py
```

## Production code, not a second state machine

`extract.py` reads the checkout's `ios/RCTPushy/RCTPushy.mm` on every build. It
copies the exact definitions (with `#line` locations) of `bundleURL`,
`resolveLaunchBundleURL`, `restorePurgedLaunch`, `commitRoundWithGeneration`,
`resetToPackagedBundle`, their state/defaults helpers, and the relevant globals
into build-only `.inc` files. These are compiled unchanged with the actual
`cpp/patch_core/state_core.cpp`.

Extraction ignores braces in comments and literals, skips forward declarations,
and rejects missing or duplicate definitions. No generated implementation is
checked in. A source change that no longer fits the test host fails extraction
or compilation instead of silently testing a stale copy.

The host replaces only collaborators: React Native export/logging plumbing,
application paths/configuration, defaults storage, network round delivery,
cold-start scheduling, the launch wait's return value/completion delivery, and
post-reset filesystem cleanup. Bundle-existence checks use real temporary files.
The production state lock is real; every defaults mutation asserts ownership of
that same `os_unfair_lock`.

## Harness contracts

This executable is not a stricter warning gate for the extracted product code.
`-Wall -Wextra` keeps warnings visible, without a blanket `-Werror`. The only
explicit warning promoted to an error is `objc-method-access`: an undeclared
selector means the test host no longer models a collaborator it needs.

`TestDefaults` inherits from `NSObject`, **not** `NSUserDefaults`. Its six explicit
accessors use a private, per-instance dictionary. A test-only type substitution,
after importing Foundation, makes the extracted `NSUserDefaults *` declarations
refer to this narrow interface. A newly used accessor such as `setBool:forKey:`
or `boolForKey:` must be implemented deliberately; it cannot silently inherit a
path into the test process's real preferences. Dynamic calls that erase the
static type hit a fatal `doesNotRecognizeSelector:` backstop, which production
exception handlers cannot swallow.

Every test process runs `defaults_isolation` before the ordering cases, verifying
fresh-instance isolation, the supported accessors and snapshot independence.
With `VERIFY_REGRESSIONS=1`, compile probes prove that an ordinary warning remains
non-fatal and both unsupported typed selectors are rejected. Separate subprocess
probes send the same messages through `id` and require the exact fail-fast runtime
diagnostic. No probe instantiates a real `NSUserDefaults` object or writes a real
preferences domain.

## Orderings

| Test | Enforced ordering | Assertions |
| --- | --- | --- |
| `late_commit` | Request captures generation; wait times out and closes window; only then release response/commit | Packaged bundle stays selected; round may cache its response/metadata for JS but cannot activate B or add `purgeRestore` |
| `commit_before_signal` | Commit activates B; completion signal is held; wait reports timeout; launch re-resolves; release signal | B actually launches; state/URL/running identity agree; first-load protection is armed and `purgeRestore` is recorded |
| `reset_wins` | Pause reset inside the real state lock; start stale commit; release reset | Old generation rejects **all** commit writes: no current/last version, version metadata or response cache; install UUID survives |
| `commit_wins` | Pause commit inside the real state lock; start reset; release commit | Earlier commit succeeds, then reset clears its state, metadata and cache; a delayed done signal cannot restore it |
| `complete_in_time` | Commit and done signal both arrive before wait returns | Normal successful restore still launches B with first-load protection |

A real GCD worker captures the request generation and blocks at a controlled
response boundary. Semaphores establish each ordering. Reset races are exercised
in both legal lock orders, with one operation paused while it owns the lock and
the competing operation started on another thread. Five-second waits are only
fail-fast deadlock guards, never timing assumptions. The launch wait itself is
injected, so the tests do not spend 12 seconds per case. They still assert that
production requests a 12-second (not 13-second) wait budget.

## Negative controls

`VERIFY_REGRESSIONS=1` additionally compiles three generated-only mutants:

- Remove the closed-window activation veto: `late_commit` must fail.
- Return `NO` after timeout instead of re-resolving: `commit_before_signal` must fail.
- Remove the reset-generation guard: `reset_wins` must fail.

Each mutant must compile and fail its specific state assertion; unrelated
compiler errors, crashes or deadlock-guard failures do not count as detection.
The mutants never modify the checkout's production source.

## Scope

These are native orchestration regression tests, not tvOS device E2E tests. They
do not validate NSURLSession idle-timeout behavior, actual cache purging, update
download/unzip/diff pipelines, React Native bridge creation, or physical-device
watchdog limits. Existing E2E tests and device validation remain complementary.
142 changes: 142 additions & 0 deletions scripts/tests/ios-purge-restore/extract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Compile production Objective-C++ methods in a Foundation-only test host.

No generated source is checked in. Bodies, signatures and source locations come
from RCTPushy.mm on every run. Extraction fails on missing/ambiguous definitions;
strings/comments cannot affect brace matching. Only the test host supplies I/O.
"""

import argparse
import json
from pathlib import Path
import re


TOKENS = re.compile(
r'//[^\n]*|/\*[\s\S]*?\*/|'
r'(?:u8|u|U|L)?R"(?P<delimiter>[^\s()\\]{0,16})\([\s\S]*?\)(?P=delimiter)"|'
r'"(?:\\[\s\S]|[^"\\])*"|\'(?:\\[\s\S]|[^\'\\])*\''
)


def mask_literals(source: str) -> str:
"""Preserve offsets/newlines, hiding comments and string/character literals."""
return TOKENS.sub(lambda m: re.sub(r'[^\n]', ' ', m.group()), source)


def definition(source: str, pattern: str, label: str) -> tuple[int, str]:
masked = mask_literals(source)
definitions = []
for match in re.finditer(pattern, masked, re.MULTILINE):
opening = masked.find('{', match.end())
semicolon = masked.find(';', match.end())
if opening < 0 or 0 <= semicolon < opening:
continue # An interface declaration or a forward declaration.
depth = 1
cursor = opening + 1
while depth and cursor < len(masked):
depth += (masked[cursor] == '{') - (masked[cursor] == '}')
cursor += 1
if depth:
raise ValueError(f'{label}: unterminated definition')
definitions.append((source.count('\n', 0, match.start()) + 1,
source[match.start():cursor]))
if len(definitions) != 1:
raise ValueError(f'{label}: expected one definition, found {len(definitions)}')
return definitions[0]


def function(source: str, name: str) -> tuple[int, str]:
return definition(source, rf'^static\b[^\n;]*?\b{re.escape(name)}\s*\(', name)


def method(source: str, name: str) -> tuple[int, str]:
return definition(source, rf'^\+\s*\([^\n)]*\)\s*{re.escape(name)}\b', name)


def exported_method(source: str, name: str) -> tuple[int, str]:
return definition(source, rf'^RCT_EXPORT_METHOD\(\s*{re.escape(name)}\s*:', name)


def declaration(source: str, name: str) -> tuple[int, str]:
matches = list(re.finditer(rf'^static\b[^\n;]*\b{re.escape(name)}\b[^\n;]*;',
mask_literals(source), re.MULTILINE))
if len(matches) != 1:
raise ValueError(f'{name}: expected one static declaration, found {len(matches)}')
match = matches[0]
return (source.count('\n', 0, match.start()) + 1,
source[match.start():match.end()])


GLOBALS = [
'keyPushyInfo', 'paramPackageVersion', 'paramBuildTime',
'legacyParamPackageVersion', 'legacyParamBuildTime', 'paramLastVersion',
'paramCurrentVersion', 'paramIsFirstTime', 'paramIsFirstLoadOk', 'keyUuid',
'keyHashInfo', 'keyFirstLoadMarked', 'keyRolledBackMarked',
'KeyPackageUpdatedMarked', 'keyNativeCheckCache', 'BUNDLE_FILE_NAME',
'pushyStateLock', 'ignoreRollback', 'pushyIsUsingBundleUrl',
'pushyResetGeneration', 'pushyLaunchVersion', 'pushyCrashRescueActive',
'pushyPurgeRestoreActive', 'pushyPurgeRestoreWindowOpen',
'pushyHostRoundResult', 'kPushyPurgeRestoreBudget',
]
HELPERS = [
'PushyWithStateLock', 'PushyToStdString', 'PushyFromStdString',
'PushySetNullableString', 'PushyHashInfoKey', 'PushyBinaryIdentityValue',
'PushyStateFromDefaults', 'PushyApplyStateToDefaults', 'PushySwitchVersionLocked',
]
MUTATIONS = ('late-activation', 'skip-reresolve', 'ignore-reset')


def mutate(text: str, mutation: str) -> str:
"""Negative controls: prove each regression is detected, never ship a mutant."""
patterns = {
'late-activation': (r'\bactivation = nil;', '(void)activation;'),
'skip-reresolve': (r'\breturn YES;', 'return !timedOut;'),
'ignore-reset': (
r'if \(pushyResetGeneration\.load\(\) != generation\)\s*\{\s*return;\s*\}',
'(void)generation;'),
}
pattern, replacement = patterns[mutation]
mutated, count = re.subn(pattern, replacement, text)
if count != 1:
raise ValueError(f'{mutation}: expected one mutation site, found {count}')
return mutated


def generate(source_path: Path, output: Path, mutation: str | None = None) -> None:
source = source_path.read_text(encoding='utf-8')
restore = method(source, 'restorePurgedLaunch')
commit = method(source, 'commitRoundWithGeneration')
if mutation == 'skip-reresolve':
restore = (restore[0], mutate(restore[1], mutation))
elif mutation:
commit = (commit[0], mutate(commit[1], mutation))
sections = {
'globals.inc': [declaration(source, name) for name in GLOBALS],
'helpers.inc': [function(source, name) for name in HELPERS],
'pushy.inc': [method(source, 'bundleURL'), method(source, 'resolveLaunchBundleURL'),
exported_method(source, 'resetToPackagedBundle')],
'orchestrator.inc': [restore, commit],
}
output.mkdir(parents=True, exist_ok=True)
filename = json.dumps(str(source_path.resolve()))
for name, snippets in sections.items():
output.joinpath(name).write_text(''.join(
f'#line {line} {filename}\n{text}\n\n' for line, text in snippets
), encoding='utf-8')


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('source', type=Path)
parser.add_argument('output', type=Path)
parser.add_argument('--mutation', choices=MUTATIONS)
args = parser.parse_args()
try:
generate(args.source, args.output, args.mutation)
except (OSError, ValueError) as error:
parser.exit(1, f'Native test extraction failed: {error}\n')


if __name__ == '__main__':
main()
Loading
Loading