From 25515b19daf0d74769a6cf3557183d1e8976367b Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Fri, 16 Oct 2026 11:45:53 +0800 Subject: [PATCH 1/2] test(android): replay the round engine vectors through the sync adapter SyncCoordinator.runOnce now drives the engine through a RoundHost (the engine step, the clock, the kv store and the IO its commands ask for); DeviceRound is the device's. SyncRoundReplayTest plays each trace of round-engine-vectors.json as a scripted engine, fakes the IO from the trace's next event and checks every event the adapter answers with, the arguments each command ran with, the start facts, the crash-hold flag and the finish's result mapping. Co-Authored-By: Claude Opus 5.5 --- .../modules/update/SyncCoordinator.java | 219 +++++++-- .../modules/update/SyncRoundReplayTest.java | 417 ++++++++++++++++++ docs/NATIVE_CHECKUPDATE_DESIGN.md | 2 +- 3 files changed, 590 insertions(+), 48 deletions(-) create mode 100644 android/src/test/java/cn/reactnative/modules/update/SyncRoundReplayTest.java diff --git a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java index 30055911..b1948732 100644 --- a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java +++ b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java @@ -65,7 +65,7 @@ final class SyncCoordinator { // bumps it, and the late activation must lose to that reset exactly like // the round itself would. Written generation-first, read hash-first, so // a torn read can only see a stricter (newer) generation. - private static volatile String unactivatedHash; + static volatile String unactivatedHash; private static volatile long unactivatedGeneration; private static volatile UpdateContext sContext; private static volatile String sLaunchRolledBackVersion; @@ -75,7 +75,7 @@ final class SyncCoordinator { private static volatile String sJsCompletedConfig; // Published after the launch rollback snapshot, before host calls are accepted. private static volatile boolean nativeReady; - private static volatile BundlePreparationResult roundResult = + static volatile BundlePreparationResult roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "check_failed"); private static volatile long roundGeneration = -1; private static volatile long roundConfigGeneration = -1; @@ -246,7 +246,7 @@ private static boolean claimRound() { private static void runClaimedRound(long deadlineNanos) { try { - runOnce(sContext, sLaunchRolledBackVersion, deadlineNanos); + runOnce(new DeviceRound(sContext, sLaunchRolledBackVersion), deadlineNanos); } catch (Throwable e) { Log.w(Texts.LOG_TAG, "native sync failed: " + e); roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "internal_error"); @@ -328,27 +328,163 @@ static double engineMillis(long nanos) { return (nanos - CLOCK_ORIGIN_NANOS) / 1e6; } - private static long engineNanos(double millis) { + static long engineNanos(double millis) { return CLOCK_ORIGIN_NANOS + (long) (millis * 1e6); } + /** A monotonic clock in nanoseconds (System.nanoTime on the device). */ + interface Clock { + long nanoTime(); + } + + /** + * Everything a round does besides deciding: the engine step, the clock, + * the persisted state and the IO the engine's commands ask for. + * DeviceRound is the device's; the JVM tests replay the engine vectors + * through a scripted one (SyncRoundReplayTest). + */ + interface RoundHost extends DownloadStarter, Clock { + /** FlowBridge.stepRound; null when the engine is unavailable. */ + @Nullable + String stepRound(@Nullable String state, String event); + + /** Whether a crash is being held right now (crashHoldActive). */ + boolean crashHold(); + + long resetGeneration(); + + long configGeneration(); + + @Nullable + String getKv(String key); + + void setKv(String key, String value); + + void removeKv(String key); + + /** This launch as the shared core sees it (native_round.h). */ + JSONObject launchEnv() throws JSONException; + + /** The response body; null when the request failed. */ + @Nullable + String httpRequest(String url, @Nullable String postBody, long timeoutMs, long callTimeoutMs); + + /** Wall-clock seconds, stamped on each response. */ + double wallSeconds(); + + boolean hasCompletedVersion(String hash); + + /** UpdateContext.commitSyncResult: whether the commit landed. */ + boolean commitSyncResult( + long expectedGeneration, @Nullable String hash, @Nullable String hashInfoJson, + boolean activate, String responseCacheJson) throws Exception; + } + + /** The round on the device: the JNI engine, UpdateContext, OkHttp. */ + private static final class DeviceRound implements RoundHost { + private final UpdateContext context; + private final String launchRolledBackVersion; + + DeviceRound(UpdateContext context, String launchRolledBackVersion) { + this.context = context; + this.launchRolledBackVersion = launchRolledBackVersion; + } + + @Override + public long nanoTime() { + return System.nanoTime(); + } + + @Override + public String stepRound(String state, String event) { + return FlowBridge.stepRound(state, event); + } + + @Override + public boolean crashHold() { + return crashHoldActive; + } + + @Override + public long resetGeneration() { + return UpdateContext.getResetGeneration(); + } + + @Override + public long configGeneration() { + return UpdateContext.getNativeConfigGeneration(); + } + + @Override + public String getKv(String key) { + return context.getKv(key); + } + + @Override + public void setKv(String key, String value) { + context.setKv(key, value); + } + + @Override + public void removeKv(String key) { + context.removeKv(key); + } + + @Override + public JSONObject launchEnv() throws JSONException { + return SyncCoordinator.launchEnv(context, launchRolledBackVersion); + } + + @Override + public String httpRequest(String url, String postBody, long timeoutMs, long callTimeoutMs) { + return SyncCoordinator.httpRequest(url, postBody, timeoutMs, callTimeoutMs); + } + + @Override + public double wallSeconds() { + return System.currentTimeMillis() / 1000.0; + } + + @Override + public boolean hasCompletedVersion(String hash) { + return context.hasCompletedVersion(hash); + } + + @Override + public boolean commitSyncResult( + long expectedGeneration, String hash, String hashInfoJson, + boolean activate, String responseCacheJson) { + return context.commitSyncResult( + expectedGeneration, hash, hashInfoJson, activate, responseCacheJson); + } + + @Override + public DownloadTaskParams start( + String strategy, String url, String hash, String originHash, + UpdateContext.DownloadFileListener listener, long deadlineNanos) { + if ("diff".equals(strategy)) { + return context.downloadPatchFromPpk(url, hash, originHash, listener, deadlineNanos); + } + if ("pdiff".equals(strategy)) { + return context.downloadPatchFromApk(url, hash, listener, deadlineNanos); + } + return context.downloadFullUpdate(url, hash, listener, deadlineNanos); + } + } + /** * The round itself is the shared engine's (cpp/update_flow_core * round_engine.h, NATIVE_CHECKUPDATE_DESIGN §12): this drives it on the * calling thread and runs what it asks for — the breadcrumb, the launch * facts, the requests, the downloads and the commit. */ - private static void runOnce( - UpdateContext context, - String launchRolledBackVersion, - long deadlineNanos - ) throws JSONException { - final long resetGeneration = UpdateContext.getResetGeneration(); - final long configGeneration = UpdateContext.getNativeConfigGeneration(); + static void runOnce(RoundHost host, long deadlineNanos) throws JSONException { + final long resetGeneration = host.resetGeneration(); + final long configGeneration = host.configGeneration(); roundGeneration = resetGeneration; roundConfigGeneration = configGeneration; roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "check_failed"); - String configJson = context.getKv(KEY_CONFIG); + String configJson = host.getKv(KEY_CONFIG); roundConfigJson = configJson; JSONObject event = new JSONObject() @@ -362,9 +498,9 @@ private static void runOnce( boolean marked = false; try { while (true) { - event.put("now", engineMillis(System.nanoTime())) - .put("crashHold", crashHoldActive); - String step = FlowBridge.stepRound(state, event.toString()); + event.put("now", engineMillis(host.nanoTime())) + .put("crashHold", host.crashHold()); + String step = host.stepRound(state, event.toString()); if (step == null) { roundResult = BundlePreparationResult.of( BundlePreparationResult.FAILED, "internal_error"); @@ -380,14 +516,14 @@ private static void runOnce( if ("markIncomplete".equals(type)) { marked = true; try { - context.setKv(KEY_ROUND_INCOMPLETE, "1"); + host.setKv(KEY_ROUND_INCOMPLETE, "1"); } catch (IllegalStateException ignored) { } } else if ("finish".equals(type)) { finishRound(command, resetGeneration); return; } else { - event = perform(context, launchRolledBackVersion, command, configGeneration); + event = perform(host, command, configGeneration); } } } @@ -397,8 +533,8 @@ private static void runOnce( // without committing need the extra write. if (marked) { try { - if (context.getKv(KEY_ROUND_INCOMPLETE) != null) { - context.removeKv(KEY_ROUND_INCOMPLETE); + if (host.getKv(KEY_ROUND_INCOMPLETE) != null) { + host.removeKv(KEY_ROUND_INCOMPLETE); } } catch (IllegalStateException ignored) { } @@ -407,39 +543,35 @@ private static void runOnce( } /** Runs one engine command and returns the event that answers it. */ - private static JSONObject perform( - UpdateContext context, - String launchRolledBackVersion, - JSONObject command, - long configGeneration - ) throws JSONException { + private static JSONObject perform(RoundHost host, JSONObject command, long configGeneration) + throws JSONException { String type = command.getString("type"); switch (type) { case "collectEnv": return new JSONObject() .put("type", "env") - .put("env", launchEnv(context, launchRolledBackVersion)); + .put("env", host.launchEnv()); case "http": { boolean post = command.optBoolean("post"); - String text = httpRequest( + String text = host.httpRequest( command.getString("url"), post ? command.getString("body") : null, (long) command.getDouble("timeoutMs"), (long) command.getDouble("callTimeoutMs")); JSONObject event = new JSONObject().put("type", "http"); if (text != null) { - event.put("text", text).put("at", System.currentTimeMillis() / 1000.0); + event.put("text", text).put("at", host.wallSeconds()); } return event; } case "checkInstalled": return new JSONObject() .put("type", "installed") - .put("installed", context.hasCompletedVersion(command.getString("hash"))); + .put("installed", host.hasCompletedVersion(command.getString("hash"))); case "download": - return download(command, downloadStarter(context)); + return download(command, host, host); case "commit": - return commit(context, command, configGeneration); + return commit(host, command, configGeneration); default: // Unknown to this adapter: the engine finishes the round. return new JSONObject().put("type", type); @@ -473,20 +605,20 @@ private static JSONObject launchEnv(UpdateContext context, String launchRolledBa // commit (UpdateContext.commitSyncResult). A config change bumps the // reset generation too, so the commit refuses either; the config // generation tells the two apart. - private static JSONObject commit(UpdateContext context, JSONObject command, long configGeneration) + private static JSONObject commit(RoundHost host, JSONObject command, long configGeneration) throws JSONException { String hash = command.getString("hash"); String hashInfo = command.getString("hashInfo"); JSONObject event = new JSONObject().put("type", "committed"); try { - boolean committed = context.commitSyncResult( + boolean committed = host.commitSyncResult( (long) command.getDouble("resetGeneration"), hash.isEmpty() ? null : hash, hashInfo.isEmpty() ? null : hashInfo, command.getBoolean("activate"), command.getString("cache")); event.put("ok", committed); - if (!committed && UpdateContext.getNativeConfigGeneration() != configGeneration) { + if (!committed && host.configGeneration() != configGeneration) { event.put("reason", "config_changed"); } } catch (Exception e) { @@ -590,23 +722,16 @@ DownloadTaskParams start( UpdateContext.DownloadFileListener listener, long deadlineNanos); } - private static DownloadStarter downloadStarter(final UpdateContext context) { - return (strategy, url, hash, originHash, listener, deadlineNanos) -> { - if ("diff".equals(strategy)) { - return context.downloadPatchFromPpk(url, hash, originHash, listener, deadlineNanos); - } - if ("pdiff".equals(strategy)) { - return context.downloadPatchFromApk(url, hash, listener, deadlineNanos); - } - return context.downloadFullUpdate(url, hash, listener, deadlineNanos); - }; - } - /** * One download attempt the engine asked for, waited on up to its * deadline: {"type": "download", ok, code?, timedOut?}. */ static JSONObject download(JSONObject command, DownloadStarter starter) throws JSONException { + return download(command, starter, System::nanoTime); + } + + static JSONObject download(JSONObject command, DownloadStarter starter, Clock clock) + throws JSONException { final String strategy = command.getString("strategy"); final long deadlineNanos = engineNanos(command.getDouble("deadline")); final CountDownLatch latch = new CountDownLatch(1); @@ -633,7 +758,7 @@ public void onDownloadFailed(Throwable error) { JSONObject event = new JSONObject().put("type", "download"); boolean settled; try { - settled = latch.await(deadlineNanos - System.nanoTime(), TimeUnit.NANOSECONDS); + settled = latch.await(deadlineNanos - clock.nanoTime(), TimeUnit.NANOSECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); settled = false; diff --git a/android/src/test/java/cn/reactnative/modules/update/SyncRoundReplayTest.java b/android/src/test/java/cn/reactnative/modules/update/SyncRoundReplayTest.java new file mode 100644 index 00000000..4b672fd7 --- /dev/null +++ b/android/src/test/java/cn/reactnative/modules/update/SyncRoundReplayTest.java @@ -0,0 +1,417 @@ +package cn.reactnative.modules.update; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.File; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +// The Android adapter of the shared round engine (SyncCoordinator.runOnce), +// 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. +@RunWith(Parameterized.class) +public class SyncRoundReplayTest { + // What an adapter decides by itself, plus the clock it stamps (the fake + // clock reads the vector's). crashHold is compared separately: the + // vectors carry it once, Android repeats it on every later event. + private static final List ANSWER_FIELDS = Arrays.asList( + "type", "text", "installed", "ok", "code", "timedOut", "reason", "error", + "activated", "now"); + + private static JSONObject vectors; + + private static JSONObject vectors() throws IOException, JSONException { + if (vectors == null) { + String path = "cpp/update_flow_core/tests/round-engine-vectors.json"; + File dir = new File(System.getProperty("user.dir")).getAbsoluteFile(); + while (dir != null && !new File(dir, path).isFile()) { + dir = dir.getParentFile(); + } + if (dir == null) { + throw new IOException(path + " not found above " + System.getProperty("user.dir")); + } + vectors = new JSONObject(new String( + Files.readAllBytes(new File(dir, path).toPath()), StandardCharsets.UTF_8)); + } + return vectors; + } + + // A string "@name" anywhere in a round stands for fixtures[name]. + private static Object withFixtures(Object value, JSONObject fixtures) throws JSONException { + if (value instanceof String && ((String) value).startsWith("@")) { + return fixtures.get(((String) value).substring(1)); + } + if (value instanceof JSONArray) { + JSONArray array = (JSONArray) value; + JSONArray out = new JSONArray(); + for (int i = 0; i < array.length(); i++) { + out.put(withFixtures(array.get(i), fixtures)); + } + return out; + } + if (value instanceof JSONObject) { + JSONObject object = (JSONObject) value; + JSONObject out = new JSONObject(); + for (Iterator keys = object.keys(); keys.hasNext(); ) { + String key = keys.next(); + out.put(key, withFixtures(object.get(key), fixtures)); + } + return out; + } + return value; + } + + // Rounds whose events carry what Android never produces (a launch that + // waits on the round, an activation the commit dropped — both the tvOS + // purge restore's; a failed download without an error code) or that feed + // the engine protocol errors on purpose. + private static boolean replayable(String name, JSONArray steps) throws JSONException { + if (name.contains("internal_error")) { + return false; + } + for (int i = 0; i < steps.length(); i++) { + JSONObject event = steps.getJSONObject(i).getJSONObject("event"); + String type = event.optString("type"); + if ((i == 0) != "start".equals(type) + || event.optBoolean("forceActivate") + || event.has("activated") + || ("download".equals(type) && !event.optBoolean("ok") + && !event.optBoolean("timedOut") && !event.has("code"))) { + return false; + } + } + return true; + } + + @Parameterized.Parameters(name = "{0}") + public static Collection rounds() throws IOException, JSONException { + JSONObject file = vectors(); + JSONObject fixtures = file.getJSONObject("fixtures"); + JSONArray rounds = file.getJSONArray("rounds"); + List out = new ArrayList<>(); + for (int i = 0; i < rounds.length(); i++) { + JSONObject round = rounds.getJSONObject(i); + JSONArray steps = (JSONArray) withFixtures(round.getJSONArray("steps"), fixtures); + if (replayable(round.getString("name"), steps)) { + out.add(new Object[] {round.getString("name"), steps}); + } + } + return out; + } + + private final JSONArray steps; + + public SyncRoundReplayTest(String name, JSONArray steps) { + this.steps = steps; + } + + private static String canonical(Object value) { + if (value instanceof Number) { + return new BigDecimal(value.toString()).stripTrailingZeros().toPlainString(); + } + return String.valueOf(value); + } + + private static Map answer(JSONObject event) throws JSONException { + Map out = new TreeMap<>(); + for (String key : ANSWER_FIELDS) { + if (event.has(key)) { + out.put(key, canonical(event.get(key))); + } + } + // The engine reads ok as truthy: absent is false. + String type = event.optString("type"); + if ("download".equals(type) || "committed".equals(type)) { + out.put("ok", String.valueOf(event.optBoolean("ok"))); + } + return out; + } + + private interface JsonCall { + T call() throws JSONException; + } + + private static T unchecked(JsonCall call) { + try { + return call.call(); + } catch (JSONException e) { + throw new AssertionError(e); + } + } + + private static Throwable failureFor(String code) { + Throwable error; + if ("PATCH_FAILED".equals(code)) { + error = new PatchFailedException("hpatch failed", null); + } else if ("FILE_OPERATION_FAILED".equals(code)) { + error = new FileOperationException("disk full"); + } else { + error = new IOException("connection reset"); + } + assertEquals(code, ErrorCodes.forDownloadFailure(error)); + return error; + } + + /** Plays the vector's engine and fakes the IO from its next event. */ + private final class ScriptedRound implements SyncCoordinator.RoundHost { + final List sent = new ArrayList<>(); + final Map kv = new HashMap<>(); + final List io = new ArrayList<>(); + final List expectedIo = new ArrayList<>(); + final List unsettled = new ArrayList<>(); + int next; + + // The event that answers the command being run: the vector's next one. + JSONObject expected() { + return next < steps.length() + ? steps.optJSONObject(next).optJSONObject("event") + : new JSONObject(); + } + + JSONObject start() { + return steps.optJSONObject(0).optJSONObject("event"); + } + + @Override + public String stepRound(String state, String event) { + sent.add(unchecked(() -> new JSONObject(event))); + JSONArray commands = steps.optJSONObject(next++).optJSONArray("commands"); + for (int i = 0; i < commands.length(); i++) { + JSONObject command = commands.optJSONObject(i); + switch (command.optString("type")) { + case "http": + expectedIo.add("http " + command.optString("url") + " " + + (command.optBoolean("post") ? command.optString("body") : null) + " " + + canonical(command.opt("timeoutMs")) + " " + + canonical(command.opt("callTimeoutMs"))); + break; + case "download": + expectedIo.add("download " + command.optString("strategy") + " " + + command.optString("url") + " " + command.optString("hash") + " " + + command.optString("originHash", null) + " " + + SyncCoordinator.engineNanos(command.optDouble("deadline"))); + break; + case "commit": { + String hash = command.optString("hash"); + String hashInfo = command.optString("hashInfo"); + expectedIo.add("commit " + canonical(command.opt("resetGeneration")) + " " + + (hash.isEmpty() ? null : hash) + " " + + (hashInfo.isEmpty() ? null : hashInfo) + " " + + command.optBoolean("activate") + " " + command.optString("cache")); + break; + } + default: + break; + } + } + return unchecked(() -> new JSONObject() + .put("state", new JSONObject().put("step", next)) + .put("commands", commands).toString()); + } + + @Override + public long nanoTime() { + return SyncCoordinator.engineNanos(expected().optDouble("now", 0)); + } + + // A crash hold is sticky: it holds from the first event that carries it. + @Override + public boolean crashHold() { + for (int i = 0; i <= next && i < steps.length(); i++) { + if (steps.optJSONObject(i).optJSONObject("event").optBoolean("crashHold")) { + return true; + } + } + return false; + } + + @Override + public long resetGeneration() { + return start().optLong("resetGeneration"); + } + + // A commit lost to a config change: the config generation moved. + @Override + public long configGeneration() { + long generation = start().optLong("configGeneration"); + return "config_changed".equals(expected().optString("reason")) + ? generation + 1 : generation; + } + + @Override + public String getKv(String key) { + return kv.get(key); + } + + @Override + public void setKv(String key, String value) { + kv.put(key, value); + } + + @Override + public void removeKv(String key) { + kv.remove(key); + } + + @Override + public JSONObject launchEnv() throws JSONException { + return new JSONObject().put("packageVersion", "1.0.0"); + } + + @Override + public String httpRequest(String url, String postBody, long timeoutMs, long callTimeoutMs) { + io.add("http " + url + " " + postBody + " " + timeoutMs + " " + callTimeoutMs); + return expected().has("text") ? expected().optString("text") : null; + } + + @Override + public double wallSeconds() { + return 1700000000.9; + } + + @Override + public boolean hasCompletedVersion(String hash) { + return expected().optBoolean("installed"); + } + + @Override + public boolean commitSyncResult( + long expectedGeneration, String hash, String hashInfoJson, + boolean activate, String responseCacheJson) { + io.add("commit " + expectedGeneration + " " + hash + " " + hashInfoJson + " " + + activate + " " + responseCacheJson); + JSONObject event = expected(); + if (event.optBoolean("error")) { + throw new IllegalStateException("commit failed"); + } + return event.optBoolean("ok"); + } + + @Override + public DownloadTaskParams start( + String strategy, String url, String hash, String originHash, + UpdateContext.DownloadFileListener listener, long deadlineNanos) { + io.add("download " + strategy + " " + url + " " + hash + " " + originHash + " " + + deadlineNanos); + JSONObject event = expected(); + if (event.optBoolean("timedOut")) { + // Never settles; the clock already reads the deadline. + DownloadTaskParams task = new DownloadTaskParams(); + unsettled.add(task); + return task; + } + if (event.optBoolean("ok")) { + listener.onDownloadCompleted(new DownloadTaskParams()); + } else { + listener.onDownloadFailed(failureFor(event.optString("code"))); + } + return null; + } + } + + @Test + public void replaysTheRound() throws Exception { + ScriptedRound round = new ScriptedRound(); + JSONObject start = round.start(); + String config = start.getString("config"); + if (!config.isEmpty()) { + round.kv.put(SyncCoordinator.KEY_CONFIG, config); + } + double deadline = start.getDouble("deadline"); + + SyncCoordinator.runOnce(round, deadline == 0 ? 0 : SyncCoordinator.engineNanos(deadline)); + + List> expected = new ArrayList<>(); + List> actual = new ArrayList<>(); + boolean held = false; + for (int i = 0; i < steps.length(); i++) { + JSONObject event = steps.getJSONObject(i).getJSONObject("event"); + expected.add(answer(event)); + held |= event.optBoolean("crashHold"); + if (i < round.sent.size()) { + assertEquals("crashHold of event " + i, held, + round.sent.get(i).optBoolean("crashHold")); + } + } + for (JSONObject event : round.sent) { + actual.add(answer(event)); + } + assertEquals(expected, actual); + // Each command ran with the arguments it carried. + assertEquals(round.expectedIo, round.io); + // An attempt past its deadline is cancelled, not left queued. + for (DownloadTaskParams task : round.unsettled) { + assertTrue(task.isCancelled()); + } + + // The start carries the round's facts as the adapter sampled them. + JSONObject sentStart = round.sent.get(0); + assertEquals(config, sentStart.getString("config")); + assertEquals(start.getLong("resetGeneration"), sentStart.getLong("resetGeneration")); + assertEquals(start.getLong("configGeneration"), sentStart.getLong("configGeneration")); + assertEquals(canonical(start.get("deadline")), canonical(sentStart.get("deadline"))); + double sample = sentStart.getDouble("sample"); + assertTrue(sample >= 0 && sample < 1); + + JSONArray last = steps.getJSONObject(steps.length() - 1).getJSONArray("commands"); + JSONObject finish = null; + for (int i = 0; i < last.length(); i++) { + if ("finish".equals(last.getJSONObject(i).getString("type"))) { + finish = last.getJSONObject(i); + } + } + if (finish == null) { + fail("the round never finishes"); + } + BundlePreparationResult result = SyncCoordinator.roundResult; + String status = finish.getString("status"); + assertEquals(status, result.getStatus()); + assertEquals(finish.getString("reason"), result.getReason()); + if (BundlePreparationResult.DOWNLOADED.equals(status)) { + boolean activated = finish.getBoolean("activated"); + assertEquals(finish.getString("hash"), result.getHash()); + assertEquals(activated, result.isActivated()); + // Left for JS, it is the version a later crash hold activates. + if (activated) { + assertNull(SyncCoordinator.unactivatedHash); + } else { + assertEquals(finish.getString("hash"), SyncCoordinator.unactivatedHash); + } + } else { + assertEquals("", result.getHash()); + assertFalse(result.isActivated()); + } + // However the round ends, the breadcrumb does not survive it. + assertFalse(round.kv.containsKey(SyncCoordinator.KEY_ROUND_INCOMPLETE)); + } + + @Test + public void replaysMostOfTheVectors() throws Exception { + assertTrue(rounds().size() >= 25); + } +} diff --git a/docs/NATIVE_CHECKUPDATE_DESIGN.md b/docs/NATIVE_CHECKUPDATE_DESIGN.md index 6f5547ee..04c97146 100644 --- a/docs/NATIVE_CHECKUPDATE_DESIGN.md +++ b/docs/NATIVE_CHECKUPDATE_DESIGN.md @@ -649,7 +649,7 @@ 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 | +| 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 From a10f139a6426fc3bd3d3e23555cb1278f3459004 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Fri, 16 Oct 2026 11:56:35 +0800 Subject: [PATCH 2/2] docs: list the iOS round replay test next to the Android one Co-Authored-By: Claude Opus 5.5 --- docs/NATIVE_CHECKUPDATE_DESIGN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/NATIVE_CHECKUPDATE_DESIGN.md b/docs/NATIVE_CHECKUPDATE_DESIGN.md index 04c97146..23fcfb35 100644 --- a/docs/NATIVE_CHECKUPDATE_DESIGN.md +++ b/docs/NATIVE_CHECKUPDATE_DESIGN.md @@ -648,7 +648,7 @@ full 超时整体失败)、激活判定(`activate || crashHold || forceActiv | 端 | 驱动入口 | 绑定 | 适配器自身的测试 | | --- | --- | --- | --- | -| iOS | `RCTPushyOrchestrator.mm` `runOnce` → `perform:` / `download:` / `commit:` | 直接调 `flowcore::StepRound` | purge-restore 测试(提交与窗口)、e2e-ios native | +| 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` |