diff --git a/docs/NATIVE_CHECKUPDATE_DESIGN.md b/docs/NATIVE_CHECKUPDATE_DESIGN.md index 6f5547ee..23fcfb35 100644 --- a/docs/NATIVE_CHECKUPDATE_DESIGN.md +++ b/docs/NATIVE_CHECKUPDATE_DESIGN.md @@ -648,8 +648,8 @@ full 超时整体失败)、激活判定(`activate || crashHold || forceActiv | 端 | 驱动入口 | 绑定 | 适配器自身的测试 | | --- | --- | --- | --- | -| iOS | `RCTPushyOrchestrator.mm` `runOnce` → `perform:` / `download:` / `commit:` | 直接调 `flowcore::StepRound` | purge-restore 测试(提交与窗口)、e2e-ios native | -| Android | `SyncCoordinator.java` `runOnce` → `perform` / `download` / `commit` | `FlowBridge.stepRound`(JNI,`.so` 已重编) | `SyncCoordinatorTest`(单次下载的回报)、e2e-android | +| iOS | `RCTPushyOrchestrator.mm` `runOnce` → `perform:` / `download:` / `commit:` | 直接调 `flowcore::StepRound` | purge-restore 测试(提交与窗口;`round_replay_test` 以轨迹向量回放适配器)、e2e-ios native | +| Android | `SyncCoordinator.java` `runOnce` → `perform` / `download` / `commit` | `FlowBridge.stepRound`(JNI,`.so` 已重编) | `SyncRoundReplayTest`(经 `RoundHost` 注入,以轨迹向量回放适配器;JVM 单测仅本地跑)、`SyncCoordinatorTest`(单次下载的回报)、e2e-android | | 鸿蒙 | `SyncCoordinator.ts` `runOnce` → `perform` / `download` / `commit` | `NativePatchCore.stepRound`(NAPI,同步) | `harmonySyncRound.test.ts`(以轨迹向量回放适配器)、`nativeHostApi.test.ts` | 三端适配器都在提交被拒时按配置代数回报 `reason: "config_changed"`(Android/鸿蒙换配置时同时递增 reset diff --git a/scripts/test-ios-purge-restore.sh b/scripts/test-ios-purge-restore.sh index b6b32925..a0885066 100755 --- a/scripts/test-ios-purge-restore.sh +++ b/scripts/test-ios-purge-restore.sh @@ -42,10 +42,21 @@ build() { "$ROOT_DIR"/cpp/update_flow_core/*.cpp \ "$ROOT_DIR/ios/RCTPushy/RCTPushyConfiguration.mm" \ -framework Foundation -o "$destination/purge_restore_test" + # The round adapter against the engine vectors: the extracted runOnce and + # command methods, a scripted engine instead of round_engine.cpp. + xcrun clang++ -std=c++17 -fobjc-arc -fblocks $WARNING_FLAGS $SANITIZE_FLAGS \ + -I"$ROOT_DIR" -I"$destination" \ + "$TEST_DIR/round_replay_test.mm" "$ROOT_DIR/cpp/update_flow_core/flow_json.cpp" \ + -framework Foundation -o "$destination/round_replay_test" } +VECTORS="$ROOT_DIR/cpp/update_flow_core/tests/round-engine-vectors.json" + build baseline "$BUILD_DIR/baseline/purge_restore_test" "$@" +if [ "$#" -eq 0 ]; then + "$BUILD_DIR/baseline/round_replay_test" "$VECTORS" +fi # Negative controls execute only generated test copies. Every original bug must # fail its named assertion, not merely fail to compile or time out. @@ -121,4 +132,21 @@ if [ "${VERIFY_REGRESSIONS:-0}" = "1" ]; then fi echo "[PASS] negative control: $variant is detected by $testcase" done + + # The round adapter: a lost commit reported as a reset instead of a config + # change must fail that trace. + variant=drop-config-changed + round="a config change during the download wins the commit" + build "$variant" + logfile="$BUILD_DIR/$variant/result.log" + if "$BUILD_DIR/$variant/round_replay_test" "$VECTORS" "$round" >"$logfile" 2>&1; then + echo "ERROR: the round replay did not detect $variant" >&2 + exit 1 + fi + if ! grep -F "[FAIL] $round: event 5: 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 the round replay" fi diff --git a/scripts/tests/ios-purge-restore/README.md b/scripts/tests/ios-purge-restore/README.md index ed09803d..0984120c 100644 --- a/scripts/tests/ios-purge-restore/README.md +++ b/scripts/tests/ios-purge-restore/README.md @@ -105,9 +105,35 @@ injected, so the tests do not spend 12 seconds per case. They still assert that production requests exactly the 12-second budget minus the time already spent since process start. +## Round adapter replay + +`round_replay_test.mm` is a second executable over the same extractor. It +compiles the unchanged `RCTPushyOrchestrator` round adapter (`runOnce`, +`perform:`, `download:`, `commit:`, `finishRound:`, `PushyWaitSeconds` and the +globals they use, written to `round*.inc`) against a scripted engine: for each +round in `cpp/update_flow_core/tests/round-engine-vectors.json` the host's +`flowcore::StepRound` returns the vector's commands, and the IO they ask for +(`PushyHttpRequest`, the install check, `+[RCTPushyInstaller fetch:...]`, +`commitRoundWithGeneration:`, the monotonic clock) is faked from the vector's +next event. It checks every event the adapter answers with (type, text, +installed, ok, code, timedOut, reason, error, activated, now; ok and code as +the engine reads them), the sticky crashHold/forceActivate flags, the arguments +each command ran with, the start facts, the host result, the remembered +unactivated hash and the breadcrumb. Every round except the protocol-error ones +replays, crash hold, forceActivate, deadlines and timed-out downloads included +(a timed-out attempt never settles; the fake clock already reads its +deadline). Same idea as Harmony's `src/__tests__/harmonySyncRound.test.ts` and +Android's `SyncRoundReplayTest`. + +```sh +# One round: +.tmp/ios-purge-restore-tests/baseline/round_replay_test \ + cpp/update_flow_core/tests/round-engine-vectors.json "a reset during the download wins the commit" +``` + ## Negative controls -`VERIFY_REGRESSIONS=1` additionally compiles six generated-only mutants: +`VERIFY_REGRESSIONS=1` additionally compiles seven 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. @@ -120,6 +146,9 @@ since process start. out-of-lock verification and the under-lock re-check): `round_commit_verifies` must fail. +- Report a commit lost to a config change without `reason: "config_changed"` + (round adapter): the config-change round of `round_replay_test` 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. diff --git a/scripts/tests/ios-purge-restore/extract.py b/scripts/tests/ios-purge-restore/extract.py index d6b6cfa8..fde7d4bb 100644 --- a/scripts/tests/ios-purge-restore/extract.py +++ b/scripts/tests/ios-purge-restore/extract.py @@ -108,6 +108,20 @@ def declaration(source: str, name: str) -> tuple[int, str]: 'PushyIsCurrentOrLastLocked', 'PushyVerifyActivation', 'PushyVerifiedActivationUnchangedLocked', ] +# The round adapter (round_replay_test.mm): RCTPushyOrchestrator's runOnce and +# the methods that run the engine's commands, compiled in their own host +# against a scripted engine. Their globals and pure helpers come along; the +# IO collaborators are the host's. +ROUND_GLOBALS = [ + 'keyNativeConfig', 'keyNativeCheckIncomplete', 'pushyResetGeneration', + 'pushyNativeConfigGeneration', 'pushyCrashHoldActive', 'pushyPurgeRestoreActive', + 'pushyHostRoundResult', 'pushyHostRoundConfig', 'pushyHostRoundGeneration', + 'pushyHostRoundConfigGeneration', 'pushyUnactivatedHash', 'pushyUnactivatedGeneration', + 'pushyUnactivatedConfigGeneration', +] +ROUND_HELPERS = ['PushyToStdString', 'PushyFromStdString', 'PushyJsonDictionary', + 'PushyHostResult', 'PushyWaitSeconds'] +ROUND_METHODS = ['runOnce', 'perform', 'download', 'commit', 'finishRound'] # Emitted ahead of HELPERS, which use them. STRUCTS: list[str] = ['PushyVerifiedActivation'] # The completion record core switchVersion:error: calls. Copied (with #line) @@ -115,8 +129,10 @@ def declaration(source: str, name: str) -> tuple[int, str]: # test script compiles the copy instead of the checkout's file. CORE_SOURCE = Path('cpp/patch_core/install_record.cpp') CORE_MUTATIONS = ('trust-digestless', 'grandfather-recorded') +# Mutate the round adapter (round.inc) instead of the purge-restore methods. +ROUND_MUTATIONS = ('drop-config-changed',) MUTATIONS = ('late-activation', 'skip-reresolve', 'ignore-reset', - 'skip-commit-verify') + CORE_MUTATIONS + 'skip-commit-verify') + CORE_MUTATIONS + ROUND_MUTATIONS def mutate(text: str, mutation: str) -> str: @@ -134,6 +150,10 @@ def mutate(text: str, mutation: str) -> str: # Grandfather current/last versions even when they carry a record. 'grandfather-recorded': ( r'if \(grandfathered && state == Record::State::kAbsent\)', 'if (grandfathered)'), + # Report a commit lost to a config change as an ordinary reset. + 'drop-config-changed': ( + r'event\.Set\("reason", flowjson::Value::String\("config_changed"\)\);', + '(void)configGeneration;'), # Activate from the round commit without the activation gate (both # the out-of-lock verification and the under-lock re-check). 'skip-commit-verify': [ @@ -183,7 +203,11 @@ def generate(source_dir: Path, output: Path, mutation: str | None = None) -> Non switch_version = locate(sources, method, 'switchVersion') core_path = (source_dir / '..' / '..' / CORE_SOURCE).resolve() core = core_path.read_text(encoding='utf-8') - if mutation in CORE_MUTATIONS: + round_methods = [locate(sources, method, name) for name in ROUND_METHODS] + if mutation in ROUND_MUTATIONS: + round_methods = [(path, line, mutate(text, mutation) if name == 'commit' else text) + for name, (path, line, text) in zip(ROUND_METHODS, round_methods)] + elif mutation in CORE_MUTATIONS: core = mutate(core, mutation) elif mutation == 'skip-reresolve': restore = (restore[0], restore[1], mutate(restore[2], mutation)) @@ -199,6 +223,9 @@ def generate(source_dir: Path, output: Path, mutation: str | None = None) -> Non # The launch wait helper precedes restorePurgedLaunch so the host's # dispatch seams apply to it as well. 'orchestrator.inc': [locate(sources, function, 'PushyWaitSeconds'), restore, commit], + 'round_globals.inc': [locate(sources, declaration, name) for name in ROUND_GLOBALS], + 'round_helpers.inc': [locate(sources, function, name) for name in ROUND_HELPERS], + 'round.inc': round_methods, } output.mkdir(parents=True, exist_ok=True) for name, snippets in sections.items(): diff --git a/scripts/tests/ios-purge-restore/round_replay_test.mm b/scripts/tests/ios-purge-restore/round_replay_test.mm new file mode 100644 index 00000000..ba6e4b24 --- /dev/null +++ b/scripts/tests/ios-purge-restore/round_replay_test.mm @@ -0,0 +1,493 @@ +// Foundation-only host for the UNMODIFIED round adapter extracted at build time +// (RCTPushyOrchestrator runOnce / perform: / download: / commit: / finishRound:), +// run against the rounds in cpp/update_flow_core/tests/round-engine-vectors.json: +// the vector's commands stand in for the engine, and the IO those commands ask +// for is faked from the vector's next event. The adapter must answer each +// command with the event the vector expects, run each command with the +// arguments it carries, and map the finish to its result. Same idea as +// Harmony's src/__tests__/harmonySyncRound.test.ts and Android's +// SyncRoundReplayTest; see README.md. +#import +#include +#include +#include +#include +#include +#include +#include +#include +#include "cpp/update_flow_core/flow_json.h" +#include "cpp/update_flow_core/round_engine.h" +#include "ios/RCTPushy/RCTPushyCommon.h" + +static void TestLog(NSString *, ...) {} +#define RCTLogInfo TestLog +#define RCTLogWarn TestLog + +// Pod-internal linkage (RCTPushyCommon.h); one translation unit here. +#undef PUSHY_HIDDEN +#define PUSHY_HIDDEN + +// RCTPushyInternal.h's, which pulls in React Native. +typedef NS_ENUM(NSInteger, PushyType) { + PushyTypeFullDownload = 1, + PushyTypePatchFromPackage = 2, + PushyTypePatchFromPpk = 3, +}; + +// The three accessors the round uses, over a dictionary (never a real +// preferences domain). +@interface TestDefaults : NSObject { + NSMutableDictionary *_values; +} +- (void)setObject:(id)value forKey:(NSString *)key; +- (void)removeObjectForKey:(NSString *)key; +- (id)objectForKey:(NSString *)key; +- (NSString *)stringForKey:(NSString *)key; +@end + +@implementation TestDefaults +- (instancetype)init { + self = [super init]; + if (self) { _values = [NSMutableDictionary new]; } + return self; +} +- (void)setObject:(id)value forKey:(NSString *)key { _values[key] = value; } +- (void)removeObjectForKey:(NSString *)key { [_values removeObjectForKey:key]; } +- (id)objectForKey:(NSString *)key { return _values[key]; } +- (NSString *)stringForKey:(NSString *)key { + id value = _values[key]; + return [value isKindOfClass:NSString.class] ? value : nil; +} +@end + +#define NSUserDefaults TestDefaults + +#include "round_globals.inc" +NSDictionary *PushyHostResult(NSString *status, NSString *reason, + NSString *hash = nil, BOOL activated = NO); +#include "round_helpers.inc" + +using flowjson::Value; + +// The round being replayed: each step's event and the commands answering it. +static std::vector events, commandLists; +static size_t next; +static std::vector sent; +static std::vector io, expectedIo; + +// The event that answers the command being run: the vector's next one. +static const Value &Expected() { + static const Value empty = Value::Object(); + return next < events.size() ? events[next] : empty; +} + +// crashHold / forceActivate are sticky: they hold from the first event that +// carries them. +static bool Sticky(const char *flag, size_t upto) { + for (size_t i = 0; i <= upto && i < events.size(); ++i) { + if (events[i].Get(flag).Truthy()) { + return true; + } + } + return false; +} + +static std::string Text(NSString *value) { + return value == nil ? "nil" : std::string(value.UTF8String); +} + +static std::string Num(double value) { + char buffer[64]; + std::snprintf(buffer, sizeof buffer, "%.6f", value); + return buffer; +} + +// Key-order-independent text of a JSON object. +static std::string SortedJson(NSDictionary *object) { + if (object == nil) { + return "nil"; + } + NSData *data = [NSJSONSerialization dataWithJSONObject:object + options:NSJSONWritingSortedKeys + error:nil]; + return std::string((const char *)data.bytes, data.length); +} + +static TestDefaults *testDefaults; +static NSUserDefaults *PushyDefaults(void) { return testDefaults; } +// The vector's clock, in seconds like the system uptime. +PUSHY_HIDDEN NSTimeInterval PushyMonotonicNow(void) { + return Expected().Get("now").AsNumber() / 1000; +} +static NSString *PushyOptionUrlKey(void) { return @"updateUrl"; } +static NSString *PushyVersionDir(NSString *hash) { + return [@"/versions" stringByAppendingPathComponent:hash]; +} +static BOOL PushyHasCompletedVersionAtPath(NSString *versionDir, NSString *hash) { + io.push_back("installed " + Text(versionDir) + " " + Text(hash)); + return Expected().Get("installed").Truthy(); +} +static NSString *PushyHttpRequest(NSString *url, NSString *method, NSString *body, + NSTimeInterval timeout, NSTimeInterval callTimeout) { + io.push_back("http " + Text(url) + " " + Text(method) + " " + Text(body) + " " + + Num(timeout) + " " + Num(callTimeout)); + const Value &text = Expected().Get("text"); + return text.IsString() ? @(text.AsString().c_str()) : nil; +} + +@interface RCTPushyInstaller : NSObject ++ (void)fetch:(PushyType)type + options:(NSDictionary *)options + progress:(void (^)(long long received, long long total))progress + callback:(void (^)(NSError *error))callback; +@end + +@implementation RCTPushyInstaller +// Settles synchronously, or never: a timed-out attempt's clock already reads +// its deadline. ++ (void)fetch:(PushyType)type + options:(NSDictionary *)options + progress:(void (^)(long long received, long long total))progress + callback:(void (^)(NSError *error))callback { + io.push_back("download " + std::to_string((long)type) + " " + + Text(options[PushyOptionUrlKey()]) + " " + Text(options[@"hash"]) + " " + + Text(options[@"originHash"]) + " " + + Num([options[@"deadlineUptime"] doubleValue])); + const Value &event = Expected(); + if (event.Get("timedOut").Truthy()) { + return; + } + if (event.Get("ok").Truthy()) { + callback(nil); + return; + } + const Value &code = event.Get("code"); + callback([NSError errorWithDomain:@"test" code:1 userInfo:code.IsString() + ? @{RCTPushyErrorCodeKey: @(code.AsString().c_str())} : @{}]); +} +@end + +@interface RCTPushyOrchestrator : NSObject ++ (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)deadlineUptime; +@end + +@implementation RCTPushyOrchestrator ++ (flowjson::Value)launchEnv:(NSString *)launchRolledBackVersion { + Value env = Value::Object(); + env.Set("packageVersion", Value::String("1.0.0")); + return env; +} + +// The commit's outcome as the vector reports it: a lost commit moved the +// generation it names, a failed one is the activation gate's verdict. ++ (BOOL)commitRoundWithGeneration:(uint64_t)generation + configGeneration:(uint64_t)configGeneration + hashInfo:(NSDictionary *)hashInfo + activate:(NSString *)hashToActivate + cache:(NSString *)cache + activated:(BOOL *)activatedOut + verifyError:(NSError **)verifyErrorOut { + io.push_back("commit " + std::to_string(generation) + " " + + std::to_string(configGeneration) + " " + Text(hashInfo[@"hash"]) + " " + + SortedJson(hashInfo[@"info"]) + " " + Text(hashToActivate) + " " + Text(cache)); + const Value &event = Expected(); + *activatedOut = NO; + if (event.Get("error").Truthy()) { + *verifyErrorOut = [NSError errorWithDomain:@"test" code:1 userInfo:nil]; + return NO; + } + if (!event.Get("ok").Truthy()) { + if (event.Get("reason").AsString() == "config_changed") { + pushyNativeConfigGeneration.fetch_add(1); + } else { + pushyResetGeneration.fetch_add(1); + } + return NO; + } + const Value &activated = event.Get("activated"); + *activatedOut = activated.IsBool() ? activated.AsBool() : hashToActivate != nil; + return YES; +} + +#include "round.inc" +@end + +// The scripted engine: the vector's next commands, whatever the state. +Value flowcore::StepRound(const Value &, const Value &event) { + sent.push_back(event); + Value result = Value::Object(); + result.Set("state", Value::Object()); + if (next >= commandLists.size()) { + Value finish = Value::Object(); + finish.Set("type", Value::String("finish")); + finish.Set("status", Value::String("failed")); + finish.Set("reason", Value::String("unexpected_event")); + Value commands = Value::Array(); + commands.Push(finish); + result.Set("commands", commands); + return result; + } + const Value &commands = commandLists[next]; + for (const Value &command : commands.elements()) { + const std::string &type = command.Get("type").AsString(); + const auto text = [&command](const char *key) { + const Value &value = command.Get(key); + return value.IsString() && !value.AsString().empty() ? value.AsString() + : std::string("nil"); + }; + if (type == "http") { + const bool post = command.Get("post").Truthy(); + expectedIo.push_back("http " + text("url") + " " + (post ? "POST" : "GET") + " " + + (post ? text("body") : "nil") + " " + + Num(command.Get("timeoutMs").AsNumber() / 1000) + " " + + Num(command.Get("callTimeoutMs").AsNumber() / 1000)); + } else if (type == "checkInstalled") { + expectedIo.push_back("installed /versions/" + text("hash") + " " + text("hash")); + } else if (type == "download") { + const std::string &strategy = command.Get("strategy").AsString(); + expectedIo.push_back( + "download " + std::string(strategy == "diff" ? "3" : strategy == "pdiff" ? "2" : "1") + + " " + text("url") + " " + text("hash") + " " + text("originHash") + " " + + Num(command.Get("deadline").AsNumber() / 1000)); + } else if (type == "commit") { + const std::string hash = text("hash"); + NSDictionary *info = nil; + if (hash != "nil") { + NSData *data = [@(command.Get("hashInfo").AsString().c_str()) + dataUsingEncoding:NSUTF8StringEncoding]; + info = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] ?: @{}; + } + expectedIo.push_back( + "commit " + std::to_string((uint64_t)command.Get("resetGeneration").AsNumber()) + + " " + std::to_string((uint64_t)events[0].Get("configGeneration").AsNumber()) + + " " + hash + " " + SortedJson(info) + " " + + (command.Get("activate").Truthy() ? hash : "nil") + " " + text("cache")); + } + } + result.Set("commands", commands); + ++next; + pushyCrashHoldActive.store(Sticky("crashHold", next)); + return result; +} + +static std::string Show(const Value &value) { + if (value.IsString()) { + return value.AsString(); + } + if (value.IsBool()) { + return value.AsBool() ? "true" : "false"; + } + if (value.IsNumber()) { + return Num(value.AsNumber()); + } + return flowjson::Stringify(value); +} + +// What an adapter decides by itself, plus the clock it stamps (the host's +// clock reads the vector's). activated, crashHold and forceActivate are +// compared separately. +static std::map Answer(const Value &event) { + std::map out; + for (const char *key : {"type", "text", "installed", "ok", "code", "timedOut", "reason", + "error", "now"}) { + if (!event.Get(key).IsUndefined()) { + out[key] = Show(event.Get(key)); + } + } + const std::string &type = event.Get("type").AsString(); + // The engine reads ok as truthy and code as a string: absent is false + // and "". + if (type == "download" || type == "committed") { + out["ok"] = event.Get("ok").Truthy() ? "true" : "false"; + } + if (out.count("code") && out["code"].empty()) { + out.erase("code"); + } + return out; +} + +static std::string Describe(const std::map &answer) { + std::string text = "{"; + for (const auto &entry : answer) { + text += entry.first + "=" + entry.second + ", "; + } + return text + "}"; +} + +static Value WithFixtures(const Value &value, const Value &fixtures) { + if (value.IsString() && !value.AsString().empty() && value.AsString()[0] == '@') { + return fixtures.Get(value.AsString().substr(1)); + } + if (value.IsArray()) { + Value out = Value::Array(); + for (const Value &element : value.elements()) { + out.Push(WithFixtures(element, fixtures)); + } + return out; + } + if (value.IsObject()) { + Value out = Value::Object(); + for (const auto &member : value.members()) { + out.Set(member.first, WithFixtures(member.second, fixtures)); + } + return out; + } + return value; +} + +// Rounds that feed the engine protocol errors on purpose; everything else +// (crash hold, forceActivate, deadlines, timeouts) is iOS behaviour. +static bool Replayable(const std::string &name, const Value &steps) { + if (name.find("internal_error") != std::string::npos) { + return false; + } + for (size_t i = 0; i < steps.Size(); ++i) { + if ((i == 0) != (steps.At(i).Get("event").Get("type").AsString() == "start")) { + return false; + } + } + return true; +} + +static std::vector failures; +static std::string roundName; +static void Expect(bool condition, const std::string &message) { + if (!condition) { + failures.push_back(roundName + ": " + message); + std::fprintf(stderr, "[FAIL] %s: %s\n", roundName.c_str(), message.c_str()); + } +} + +static void Replay(const Value &steps) { + events.clear(); + commandLists.clear(); + sent.clear(); + io.clear(); + expectedIo.clear(); + next = 0; + for (const Value &step : steps.elements()) { + events.push_back(step.Get("event")); + commandLists.push_back(step.Get("commands")); + } + const Value &start = events[0]; + testDefaults = [TestDefaults new]; + const std::string &config = start.Get("config").AsString(); + if (!config.empty()) { + [testDefaults setObject:@(config.c_str()) forKey:keyNativeConfig]; + } + pushyResetGeneration.store((uint64_t)start.Get("resetGeneration").AsNumber()); + pushyNativeConfigGeneration.store((uint64_t)start.Get("configGeneration").AsNumber()); + pushyCrashHoldActive.store(Sticky("crashHold", 0)); + pushyPurgeRestoreActive.store(start.Get("forceActivate").Truthy()); + pushyUnactivatedHash = nil; + pushyHostRoundResult = nil; + + [RCTPushyOrchestrator runOnce:nil deadline:start.Get("deadline").AsNumber() / 1000]; + + Expect(sent.size() == events.size(), "the adapter sent " + std::to_string(sent.size()) + + " events, the vector has " + std::to_string(events.size())); + bool activate = false; + for (size_t i = 0; i < events.size() && i < sent.size(); ++i) { + const std::string at = "event " + std::to_string(i); + const auto expected = Answer(events[i]); + const auto actual = Answer(sent[i]); + Expect(expected == actual, at + ": expected " + Describe(expected) + " but was " + + Describe(actual)); + for (const char *flag : {"crashHold", "forceActivate"}) { + Expect(sent[i].Get(flag).Truthy() == Sticky(flag, i), at + ": " + flag); + } + // A landed commit reports what it really activated; absent in the + // vector means as asked. + if (events[i].Get("type").AsString() == "committed" && events[i].Get("ok").Truthy()) { + const Value &activated = events[i].Get("activated"); + Expect(sent[i].Get("activated").Truthy() == + (activated.IsBool() ? activated.AsBool() : activate), at + ": activated"); + } + for (const Value &command : commandLists[i].elements()) { + if (command.Get("type").AsString() == "commit") { + activate = command.Get("activate").Truthy(); + } + } + } + // Each command ran with the arguments it carried. + Expect(io == expectedIo, "IO differs from the commands"); + for (size_t i = 0; i < io.size() || i < expectedIo.size(); ++i) { + const std::string a = i < io.size() ? io[i] : "(none)"; + const std::string e = i < expectedIo.size() ? expectedIo[i] : "(none)"; + if (a != e) { + std::fprintf(stderr, " io %zu\n expected %s\n actual %s\n", i, e.c_str(), a.c_str()); + break; + } + } + + // The start carries the round's facts as the adapter sampled them. + if (!sent.empty()) { + const Value &sentStart = sent[0]; + Expect(sentStart.Get("config").AsString() == config, "start config"); + for (const char *key : {"resetGeneration", "configGeneration", "deadline"}) { + Expect(Show(sentStart.Get(key)) == Show(start.Get(key)), std::string("start ") + key); + } + const double sample = sentStart.Get("sample").AsNumber(); + Expect(sample >= 0 && sample < 1, "start sample"); + } + + Value finish; + for (const Value &command : commandLists.back().elements()) { + if (command.Get("type").AsString() == "finish") { + finish = command; + } + } + Expect(finish.IsObject(), "the round never finishes"); + const std::string &status = finish.Get("status").AsString(); + const bool downloaded = status == "downloaded"; + NSString *hash = downloaded ? @(finish.Get("hash").AsString().c_str()) : nil; + const bool activated = downloaded && finish.Get("activated").Truthy(); + NSDictionary *expected = PushyHostResult(@(status.c_str()), + @(finish.Get("reason").AsString().c_str()), hash, activated); + Expect([pushyHostRoundResult isEqual:expected], + "result " + Text(pushyHostRoundResult.description) + " instead of " + + Text(expected.description)); + // Left for JS, it is the version a later crash hold activates. + Expect(downloaded && !activated ? [pushyUnactivatedHash isEqualToString:hash] + : pushyUnactivatedHash == nil, "unactivated hash"); + // However the round ends, the breadcrumb does not survive it. + Expect([testDefaults objectForKey:keyNativeCheckIncomplete] == nil, "breadcrumb survived"); +} + +int main(int argc, const char **argv) { + @autoreleasepool { + if (argc < 2) { + std::fprintf(stderr, "usage: %s round-engine-vectors.json [round name]\n", argv[0]); + return 2; + } + std::ifstream file(argv[1]); + std::stringstream text; + text << file.rdbuf(); + bool ok = false; + const Value vectors = flowjson::Parse(text.str(), &ok); + if (!ok) { + std::fprintf(stderr, "[FAIL] cannot parse %s\n", argv[1]); + return 1; + } + const Value &fixtures = vectors.Get("fixtures"); + int replayed = 0; + for (const Value &round : vectors.Get("rounds").elements()) { + roundName = round.Get("name").AsString(); + const Value steps = WithFixtures(round.Get("steps"), fixtures); + if (!Replayable(roundName, steps) || (argc > 2 && roundName != argv[2])) { + continue; + } + Replay(steps); + ++replayed; + } + roundName = "round replay"; + Expect(replayed >= (argc > 2 ? 1 : 30), + "only " + std::to_string(replayed) + " rounds replayed"); + if (!failures.empty()) { + return 1; + } + std::printf("[PASS] round replay: %d rounds\n", replayed); + } + return 0; +} diff --git a/scripts/tests/ios-purge-restore/test_extract.py b/scripts/tests/ios-purge-restore/test_extract.py index 0bc2a6d8..c5dba656 100644 --- a/scripts/tests/ios-purge-restore/test_extract.py +++ b/scripts/tests/ios-purge-restore/test_extract.py @@ -72,6 +72,10 @@ def test_mutations_fail_on_source_drift(self): self.assertEqual( mutate('if (grandfathered && state == Record::State::kAbsent) {', 'grandfather-recorded'), 'if (grandfathered) {') + self.assertEqual( + mutate('event.Set("reason", flowjson::Value::String("config_changed"));', + 'drop-config-changed'), + '(void)configGeneration;') for source in ('', 'activation = nil; activation = nil;'): with self.assertRaises(ValueError): mutate(source, 'late-activation')