diff --git a/frontend/performance/benches/render_wasm/README.md b/frontend/performance/benches/render_wasm/README.md index d6b018494b..2b1feb0a6a 100644 --- a/frontend/performance/benches/render_wasm/README.md +++ b/frontend/performance/benches/render_wasm/README.md @@ -871,6 +871,22 @@ pnpm run render-bench -- compare \ --cases rects/load,paths/load --allow-scene-differences ``` +Merge an additional saved run into a base run: + +```bash +pnpm run render-bench -- merge \ + target/performance/render-wasm/runs//run.transit \ + target/performance/render-wasm/runs//run.transit \ + --output-dir target/performance/render-wasm/runs/ +``` + +The merged record gets a new run id and a fresh run home. Both inputs must be +completed and compatible: same cases, timeout policy, analysis, build facts, +runtime environment, and Git provenance. The additional run must use the same +warmup count. `merge` appends only the additional measured attempts; its warmup +attempts remain in the additional record. The command starts no build, server, +or browser. + Inspect and clean managed outputs: ```bash @@ -891,6 +907,7 @@ The commands write results to stdout: - `ab`: `Comparison `, `Output `, and final `Status `. - `compare`: the comparison report. +- `merge`: `Run ` and `Output `. - Help: usage text. `purge` produces no output on success. Progress and diagnostics go to stderr. The default shows build phases, case @@ -912,10 +929,11 @@ Each build writes `build.log`; each A/B comparison writes `run.transit`, and optional screenshots. Exit 0 means the command completed successfully: a ready build for `build`, -every allocated attempt for `run`, a valid comparison for `compare`, every -configuration and edge for `ab`, and a clean purge for `purge`. Exit 1 means a -failed preparation, a failed attempt, an incomplete run, an invalid selection, -an unacknowledged comparison difference, or a harness failure. Exits 130 and 143 +every allocated attempt for `run`, a valid comparison for `compare`, a +validated merged record for `merge`, every configuration and edge for `ab`, and +a clean purge for `purge`. Exit 1 means a failed preparation, a failed attempt, +an incomplete run, an invalid selection, incompatible merge inputs, an +unacknowledged comparison difference, or a harness failure. Exits 130 and 143 mean interruption by `SIGINT` and `SIGTERM` for `build`, `run` and `ab`. An interrupted command prints no error for the aborted work; it prints a failed write of its own record or log. diff --git a/frontend/performance/benches/render_wasm/node/merge.cljs b/frontend/performance/benches/render_wasm/node/merge.cljs new file mode 100644 index 0000000000..f5cffc015f --- /dev/null +++ b/frontend/performance/benches/render_wasm/node/merge.cljs @@ -0,0 +1,31 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns benches.render-wasm.node.merge + "Offline merge of two saved schema:run records." + (:require + ["fs" :as fs] + [benches.render-wasm.node.log :as log] + [benches.render-wasm.node.runs :as runs] + [benches.render-wasm.result :as result])) + +(defn execute + "Reads and merges two saved run files from the caller's cwd, then writes a + new run home. Inputs remain unchanged. Returns the merged record and home." + [root base-path additional-path {:keys [output-dir]}] + (let [base (runs/get-record base-path) + additional (runs/get-record additional-path) + run-id (str (random-uuid)) + merged (result/merge-runs base additional {:run-id run-id}) + home (runs/create-home {:root root + :run-id run-id + :output-dir output-dir})] + (runs/write-record! home merged) + (fs/appendFileSync (:log-path home) + (str "merge " (:run-id base) " + " (:run-id additional) "\n")) + (log/result (str "Run " run-id)) + (log/result (str "Output " (:dir home))) + {:run merged :home home})) diff --git a/frontend/performance/benches/render_wasm/node/runner.cljs b/frontend/performance/benches/render_wasm/node/runner.cljs index 39fd43eff3..128a2db07e 100644 --- a/frontend/performance/benches/render_wasm/node/runner.cljs +++ b/frontend/performance/benches/render_wasm/node/runner.cljs @@ -11,8 +11,10 @@ [app.common.transit :as transit] [benches.render-wasm.errors :as errors :refer [attempt failure?]] [benches.render-wasm.feature-plan :as feature-plan] + [benches.render-wasm.node.artifacts :as artifacts] [benches.render-wasm.node.compare :as compare] [benches.render-wasm.node.log :as log] + [benches.render-wasm.node.merge :as run-merge] [benches.render-wasm.node.options :as options] [clojure.string :as str] [clojure.tools.cli :refer [parse-opts]])) @@ -98,7 +100,8 @@ " [--confirm] [--dry-run] [run and comparison options]\n" " runner compare BASELINE CANDIDATE [--cases A,B]\n" " [--allow-build-differences] [--allow-scene-differences]\n" - " [--allow-all-differences]\n\n" + " [--allow-all-differences]\n" + " runner merge BASE ADDITIONAL [--output-dir DIR]\n\n" "build prints Build and Output on stdout. Exit 0 means\n" "ready; errors retain the build.transit and build.log for that ID.\n" "run prints Run , Output and Report on stdout.\n" @@ -111,7 +114,10 @@ "to require hardware WebGL2 in preparation metadata. ab --dry-run skips it.\n" "compare reads saved schema:run records and starts no build, host or browser.\n" "Build and scene permissions combine; operation and runtime checks stay strict.\n" - "All-differences permits every compatibility difference. Reports retain them.\n\n" + "All-differences permits every compatibility difference. Reports retain them.\n" + "merge reads two saved schema:run records and writes a merged record in a fresh\n" + "run home. It appends the additional run's measured attempts, keeps the base\n" + "warmups, and requires compatible completed runs. It starts no build or browser.\n\n" "ab uses every on/off configuration with Cargo defaults disabled; declared\n" "defaults must be empty. --max-features defaults to 3 (range 1..6).\n" "Use --confirm above eight configurations or with an empty filter.\n" @@ -227,7 +233,15 @@ (do (when purge? (invalid-args "purge category flags require purge")) (when build? (invalid-args "build options require build")) - (when run? (invalid-args "run options require run")))))) + (when run? (invalid-args "run options require run"))) + + "merge" + (do + (when purge? (invalid-args "purge category flags require purge")) + (when build? (invalid-args "build options require build")) + (when compare? (invalid-args "compare options require compare")) + (when (some-flags? flags (disj run-flags "--output-dir")) + (invalid-args "run options require run")))))) (defn- check-arguments! [command arguments] @@ -237,6 +251,10 @@ (when-not (= 2 (count extra)) (invalid-args "compare requires BASELINE and CANDIDATE paths")) + "merge" + (when-not (= 2 (count extra)) + (invalid-args "merge requires BASE and ADDITIONAL paths")) + (when (seq extra) (invalid-args (str command " takes no positional arguments")))))) @@ -254,8 +272,8 @@ (invalid-args "--quiet and --verbose are mutually exclusive")) (when-not (:help options) (when-not (and (= 1 (count (take 1 arguments))) - (contains? #{"build" "purge" "run" "ab" "compare"} command)) - (invalid-args "expected build, purge, run, ab or compare; try --help")) + (contains? #{"build" "purge" "run" "ab" "compare" "merge"} command)) + (invalid-args "expected build, purge, run, ab, compare or merge; try --help")) (check-command-flags! command flags) (check-arguments! command arguments) (when (and (= command "purge") @@ -290,6 +308,12 @@ :allow-scene-differences :allow-all-differences])))) + (= command "merge") + (run-merge/execute (artifacts/frontend-root) + (second arguments) + (nth arguments 2) + (select-keys options [:output-dir])) + :else (let [^js commands (js/require (path/join js/__dirname "commands.cjs"))] (set! (.-exitCode js/process) diff --git a/frontend/performance/benches/render_wasm/result.cljs b/frontend/performance/benches/render_wasm/result.cljs index c6423af73a..fbc1756406 100644 --- a/frontend/performance/benches/render_wasm/result.cljs +++ b/frontend/performance/benches/render_wasm/result.cljs @@ -243,6 +243,23 @@ (when-not valid? (throw (ex-info message (assoc data :type ::invalid-record))))) +(defn- incompatible! + "Rejects runs whose recorded evidence cannot be merged." + [message data] + (throw (ex-info message (assoc data :type ::incompatible-runs)))) + +(defn- require-compatible! + "Requires equal values at `path`, naming the disagreeing field." + [base additional path label] + (let [base-value (get-in base path) + additional-value (get-in additional path)] + (when-not (= base-value additional-value) + (incompatible! (str "cannot merge runs with different " label) + {:field label + :path path + :base base-value + :additional additional-value})))) + (defn invalid-reason "Classifies one raw measurement without changing its value. Nil denotes an explicit missing value; an absent metric has no entry. @@ -382,6 +399,88 @@ (check-completion run termination) (assoc run :ended-at ended-at :termination termination)) +(defn merge-runs + "Returns a new validated run that appends `additional`'s measured attempts to + `base`. + + Both runs must be completed and compatible. The merged plan keeps `base`'s + warmups and adds `additional`'s measured repetitions. Warmup attempts + and preparations used only by them are excluded from `additional`. + + The caller supplies the merged run id and may supply its end time." + [base additional {:keys [run-id ended-at]}] + (let [base (check-run base) + additional (check-run additional)] + (require-valid! (and (string? run-id) (seq run-id)) + "merged run needs a run id" {}) + (doseq [[side run] [[:base base] [:additional additional]]] + (when-not (= :completed (get-in run [:termination :reason])) + (incompatible! "only completed runs can be merged" + {:side side + :run-id (:run-id run) + :reason (get-in run [:termination :reason])}))) + (when-not (and (string? (:ended-at base)) + (string? (:ended-at additional))) + (incompatible! "completed runs need end times" + {:base (:ended-at base) + :additional (:ended-at additional)})) + (doseq [[path label] [[[:plan :cases] "case definitions"] + [[:plan :timeout-policy] "timeout policy"] + [[:plan :warmups] "warmup count"] + [[:analysis] "analysis settings"] + [[:metadata :git] "git provenance"] + [[:metadata :build] "build facts"] + [[:metadata :environment] "runtime environment"] + [[:metadata :scored?] "scored flag"]]] + (require-compatible! base additional path label)) + (let [base-attempts (:attempts base) + additional-measured (filterv #(not (:warmup? %)) (:attempts additional)) + base-preparations (:preparations base) + base-preparation-ids (into #{} (map :preparation-id) base-preparations) + used-preparation-ids (into #{} (keep :preparation-id) additional-measured) + additional-preparations (filterv #(contains? used-preparation-ids + (:preparation-id %)) + (:preparations additional))] + (when-let [duplicate (some #(contains? base-preparation-ids (:preparation-id %)) + additional-preparations)] + (incompatible! "cannot merge runs with duplicate preparation ids" + {:preparation-id (:preparation-id duplicate)})) + (let [offset (count base-attempts) + attempts (into (vec base-attempts) + (map-indexed (fn [index attempt] + (assoc attempt :ordinal (+ offset index))) + additional-measured)) + plan (assoc (:plan base) + :repetitions (+ (get-in base [:plan :repetitions]) + (get-in additional [:plan :repetitions]))) + remaining (reduce consume-slot (initial-remaining plan) attempts) + ended-at (or ended-at + (let [base-end (:ended-at base) + additional-end (:ended-at additional)] + (if (neg? (compare base-end additional-end)) + additional-end + base-end))) + metadata (update (:metadata base) :diagnostics + (fn [diagnostics] + (assoc (or diagnostics {}) + :merge + {:base-run-id (:run-id base) + :additional-run-id (:run-id additional) + :additional-warmups-excluded + (count (filter :warmup? (:attempts additional))) + :merged-at ended-at})))] + (check-run {:type :run-record + :run-id run-id + :started-at (:started-at base) + :ended-at ended-at + :plan plan + :metadata metadata + :analysis (:analysis base) + :preparations (into (vec base-preparations) additional-preparations) + :attempts attempts + :remaining remaining + :termination {:reason :completed}}))))) + (defn- check-feature-configuration "Requires evidence that matches each saved configuration's progress." [{:keys [status build run effective-features failure reason]}] diff --git a/frontend/test/performance_tests/render_wasm/cli_integration.cjs b/frontend/test/performance_tests/render_wasm/cli_integration.cjs index a341f8c15d..8560577b8b 100644 --- a/frontend/test/performance_tests/render_wasm/cli_integration.cjs +++ b/frontend/test/performance_tests/render_wasm/cli_integration.cjs @@ -319,6 +319,9 @@ function runFixtureText({ fingerprint = "a".repeat(64), operation = "load", operationArgs = { distance: 10 }, + warmups = 0, + repetitions = 1, + gitSha = runId, }) { const k = transit.keyword; const map = (pairs) => transit.map(pairs); @@ -348,22 +351,29 @@ function runFixtureText({ k("diagnostics"), map([]), ]); - const attempt = map([ - k("ordinal"), - 0, - k("case-id"), - k(caseId), - k("preparation-id"), - `${runId}-preparation`, - k("warmup?"), - false, - k("outcome"), - k("completed"), - k("metrics"), - map([k("elapsed"), metric]), - k("trace"), - map([]), - ]); + const makeAttempt = (ordinal, warmup) => + map([ + k("ordinal"), + ordinal, + k("case-id"), + k(caseId), + k("preparation-id"), + `${runId}-preparation`, + k("warmup?"), + warmup, + k("outcome"), + k("completed"), + k("metrics"), + map([k("elapsed"), metric]), + k("trace"), + map([]), + ]); + const attempts = [ + ...Array.from({ length: warmups }, (_, index) => makeAttempt(index, true)), + ...Array.from({ length: repetitions }, (_, index) => + makeAttempt(warmups + index, false), + ), + ]; const record = map([ k("type"), k("run-record"), @@ -401,16 +411,16 @@ function runFixtureText({ ]), ], k("warmups"), - 0, + warmups, k("repetitions"), - 1, + repetitions, k("timeout-policy"), map([k("attempt-ms"), 30000]), ]), k("metadata"), map([ k("git"), - map([k("sha"), runId, k("dirty"), false]), + map([k("sha"), gitSha, k("dirty"), false]), k("build"), map([ k("functional"), @@ -443,7 +453,7 @@ function runFixtureText({ k("preparations"), [preparation], k("attempts"), - [attempt], + attempts, k("remaining"), map([k(caseId), map([k("warmups"), 0, k("repetitions"), 0])]), k("termination"), @@ -1252,10 +1262,14 @@ test( const help = await runCli(fixture, ["--help"]); assert.equal(help.exitCode, 0, help.stderr); assert.match(help.stdout, /runner compare/); + assert.match(help.stdout, /runner merge/); const abHelp = await runCli(fixture, ["ab", "--help"]); assert.equal(abHelp.exitCode, 0, abHelp.stderr); assert.match(abHelp.stdout, /runner ab --features/); assert.match(abHelp.stdout, /--only-comparisons/); + const mergeHelp = await runCli(fixture, ["merge", "--help"]); + assert.equal(mergeHelp.exitCode, 0, mergeHelp.stderr); + assert.match(mergeHelp.stdout, /runner merge BASE ADDITIONAL/); for (const args of [["ab"], ["ab", "--dry-run"]]) { const invalid = await runCli(fixture, args); assert.equal(invalid.exitCode, 1, invalid.stderr); @@ -1298,6 +1312,7 @@ test( assert.equal(result.exitCode, 0); assert.match(result.stdout, /runner run/); assert.match(result.stdout, /runner compare/); + assert.match(result.stdout, /runner merge/); assert.match(result.stdout, /--cases/); assert.match(result.stdout, /--allow-build-differences/); assert.match(result.stdout, /--allow-scene-differences/); @@ -1557,3 +1572,162 @@ test("compare rejects --force without an alias", testOptions, async (t) => { assert.deepEqual(tree(fixture.managed), original); } }); + +test( + "merge writes a validated run home with appended measured attempts", + testOptions, + async (t) => { + const fixture = createFrontend(t); + const base = path.join(fixture.managed, "base.transit"); + const additional = path.join(fixture.managed, "additional.transit"); + const baseText = runFixtureText({ + runId: "base", + caseId: "case/load", + value: 10, + warmups: 1, + repetitions: 2, + gitSha: "same-sha", + }); + const additionalText = runFixtureText({ + runId: "additional", + caseId: "case/load", + value: 12, + warmups: 1, + repetitions: 3, + gitSha: "same-sha", + }); + fs.writeFileSync(base, baseText); + fs.writeFileSync(additional, additionalText); + const output = path.join(fixture.managed, "merged-run"); + + const result = await runCli(fixture, [ + "merge", + base, + additional, + "--output-dir", + output, + ]); + + assert.equal(result.exitCode, 0, result.stderr); + const runId = result.stdout.match(/Run ([0-9a-f-]{36})/)[1]; + assert.ok(fs.existsSync(path.join(output, "run.transit"))); + assert.equal(fs.readFileSync(base, "utf8"), baseText); + assert.equal(fs.readFileSync(additional, "utf8"), additionalText); + + const k = transit.keyword; + const merged = readManifest(path.join(output, "run.transit")); + const plan = merged.get(k("plan")); + const attempts = merged.get(k("attempts")); + const remaining = merged.get(k("remaining")).get(k("case/load")); + const mergeInfo = merged + .get(k("metadata")) + .get(k("diagnostics")) + .get(k("merge")); + + assert.equal(merged.get(k("run-id")), runId); + assert.equal(plan.get(k("warmups")), 1); + assert.equal(plan.get(k("repetitions")), 5); + assert.deepEqual( + attempts.map((attempt) => attempt.get(k("ordinal"))), + [0, 1, 2, 3, 4, 5], + ); + assert.deepEqual( + attempts.map((attempt) => attempt.get(k("warmup?"))), + [true, false, false, false, false, false], + ); + assert.equal(remaining.get(k("warmups")), 0); + assert.equal(remaining.get(k("repetitions")), 0); + assert.equal(mergeInfo.get(k("base-run-id")), "base"); + assert.equal(mergeInfo.get(k("additional-run-id")), "additional"); + assert.equal(mergeInfo.get(k("additional-warmups-excluded")), 1); + assert.match( + fs.readFileSync(path.join(output, "run.log"), "utf8"), + /merge base \+ additional/, + ); + }, +); + +test( + "merge rejects incompatible runs before creating output", + testOptions, + async (t) => { + const fixture = createFrontend(t); + const base = path.join(fixture.managed, "base.transit"); + const additional = path.join(fixture.managed, "additional.transit"); + fs.writeFileSync( + base, + runFixtureText({ + runId: "base", + caseId: "case/load", + value: 10, + gitSha: "sha-a", + }), + ); + fs.writeFileSync( + additional, + runFixtureText({ + runId: "additional", + caseId: "case/load", + value: 12, + gitSha: "sha-b", + }), + ); + const output = path.join(fixture.managed, "merged-run"); + const original = tree(fixture.managed); + + const result = await runCli(fixture, [ + "merge", + base, + additional, + "--output-dir", + output, + ]); + + assert.equal(result.exitCode, 1); + assert.match(result.stderr, /different git provenance/); + assert.equal(fs.existsSync(output), false); + assert.deepEqual(tree(fixture.managed), original); + }, +); + +test("merge refuses an existing output directory", testOptions, async (t) => { + const fixture = createFrontend(t); + const base = path.join(fixture.managed, "base.transit"); + const additional = path.join(fixture.managed, "additional.transit"); + fs.writeFileSync( + base, + runFixtureText({ + runId: "base", + caseId: "case/load", + value: 10, + gitSha: "same-sha", + }), + ); + fs.writeFileSync( + additional, + runFixtureText({ + runId: "additional", + caseId: "case/load", + value: 12, + gitSha: "same-sha", + }), + ); + const output = path.join(fixture.managed, "existing-run"); + fs.mkdirSync(output); + fs.writeFileSync(path.join(output, "keep.txt"), "keep\n"); + + const result = await runCli(fixture, [ + "merge", + base, + additional, + "--output-dir", + output, + ]); + + assert.equal(result.exitCode, 1); + assert.match(result.stderr, /already exists/); + assert.equal( + fs.readFileSync(path.join(output, "keep.txt"), "utf8"), + "keep\n", + ); +}); diff --git a/frontend/test/performance_tests/render_wasm/merge_test.cljs b/frontend/test/performance_tests/render_wasm/merge_test.cljs new file mode 100644 index 0000000000..3e1f97ad8a --- /dev/null +++ b/frontend/test/performance_tests/render_wasm/merge_test.cljs @@ -0,0 +1,202 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns performance-tests.render-wasm.merge-test + (:require + [benches.render-wasm.result :as result] + [cljs.test :as t :include-macros true])) + +(defn- metric + [value] + (result/metric {:value value + :unit :ms + :semantics :elapsed + :basis {:kind :attempt-total} + :zero-policy :positive})) + +(defn- case-definition + [case-id] + {:id case-id + :scene :case + :scene-description "merge scene" + :scene-seed 7 + :params {:count 10} + :view {:scale 1 :x 0 :y 0} + :context :fresh + :completion :render-full + :batch-size 1 + :preparation {}}) + +(defn- preparation + [preparation-id case-id] + {:preparation-id preparation-id + :case-id case-id + :fingerprint {:algorithm :sha-256 + :digest (apply str (repeat 64 "a"))} + :effective-graphics {:webgl2 true} + :diagnostics {}}) + +(defn- attempt + [case-id preparation-id warmup?] + {:case-id case-id + :preparation-id preparation-id + :warmup? warmup? + :outcome :completed + :metrics {:elapsed (metric 1.0)} + :trace {}}) + +(defn- make-run + [{:keys [run-id cases warmups repetitions git-sha build environment analysis + timeout-ms started-at ended-at termination preparations] + :or {git-sha "same-sha" + build {:mode :release} + environment {:node "v24"} + timeout-ms 30000 + started-at "2026-10-26T00:00:00.000Z" + ended-at "2026-10-26T00:01:00.000Z" + termination {:reason :completed}}}] + (let [run (result/create-run + {:run-id run-id + :started-at started-at + :plan {:cases (mapv case-definition cases) + :warmups warmups + :repetitions repetitions + :timeout-policy {:attempt-ms timeout-ms}} + :metadata {:git {:sha git-sha :dirty false} + :build {:functional build} + :environment environment + :scored? true} + :analysis analysis}) + run (reduce + (fn [run case-id] + (let [options (get preparations case-id) + warmup-id (or (:warmup options) + (str run-id "-" (name case-id))) + measured-id (or (:measured options) warmup-id)] + (-> run + (result/record-preparation (preparation warmup-id case-id)) + (cond-> (not= warmup-id measured-id) + (result/record-preparation (preparation measured-id case-id))) + (as-> run + (reduce (fn [run _] + (result/record-attempt + run (attempt case-id warmup-id true))) + run (range warmups)) + (reduce (fn [run _] + (result/record-attempt + run (attempt case-id measured-id false))) + run (range repetitions)))))) + run cases)] + (result/finish-run run ended-at termination))) + +(t/deftest merges-measured-attempts-and-updates-the-plan + (let [base (make-run {:run-id "base" + :cases [:case/load :case/pan] + :warmups 1 + :repetitions 2 + :started-at "2026-10-26T00:00:00.000Z" + :ended-at "2026-10-26T00:01:00.000Z"}) + additional (make-run {:run-id "additional" + :cases [:case/load :case/pan] + :warmups 1 + :repetitions 3 + :started-at "2026-10-26T00:02:00.000Z" + :ended-at "2026-10-26T00:03:00.000Z"}) + merged (result/merge-runs base additional {:run-id "merged"})] + (t/is (= "merged" (:run-id merged))) + (t/is (= 1 (get-in merged [:plan :warmups]))) + (t/is (= 5 (get-in merged [:plan :repetitions]))) + (t/is (= 12 (count (:attempts merged)))) + (t/is (= (range 12) (map :ordinal (:attempts merged)))) + (t/is (= [true false false true false false false false false false false false] + (map :warmup? (:attempts merged)))) + (t/is (every? #(every? zero? (vals %)) (vals (:remaining merged)))) + (t/is (= "2026-10-26T00:03:00.000Z" (:ended-at merged))) + (t/is (= 2 (get-in merged [:metadata :diagnostics :merge + :additional-warmups-excluded]))) + (t/is (= merged (result/check-run merged))) + (t/is (= (:attempts merged) + (:attempts (result/decode (result/encode merged))))))) + +(t/deftest excludes-additional-warmup-only-preparations + (let [base (make-run {:run-id "base" + :cases [:case/load] + :warmups 1 + :repetitions 1}) + additional (make-run {:run-id "additional" + :cases [:case/load] + :warmups 1 + :repetitions 1 + :preparations + {:case/load {:warmup "warm-prep" + :measured "measured-prep"}}}) + merged (result/merge-runs base additional {:run-id "merged"}) + preparation-ids (map :preparation-id (:preparations merged))] + (t/is (some #{"base-load"} preparation-ids)) + (t/is (some #{"measured-prep"} preparation-ids)) + (t/is (not (some #{"warm-prep"} preparation-ids))) + (t/is (= 3 (count (:attempts merged)))) + (t/is (= "measured-prep" (:preparation-id (last (:attempts merged))))))) + +(t/deftest rejects-incompatible-runs + (let [base (make-run {:run-id "base" + :cases [:case/load] + :warmups 1 + :repetitions 1})] + (doseq [[label options message] + [["case" {:cases [:case/pan]} #"different case definitions"] + ["timeout" {:timeout-ms 1000} #"different timeout policy"] + ["warmups" {:warmups 0} #"different warmup count"] + ["analysis" {:analysis {:draws 1000}} #"different analysis settings"] + ["git" {:git-sha "other"} #"different git provenance"] + ["build" {:build {:mode :debug}} #"different build facts"] + ["environment" {:environment {:node "v25"}} + #"different runtime environment"]]] + (t/testing label + (let [additional (make-run (merge {:run-id "additional" + :cases [:case/load] + :warmups 1 + :repetitions 1} + options))] + (t/is (thrown-with-msg? js/Error message + (result/merge-runs base additional + {:run-id "merged"})))))))) + +(t/deftest rejects-non-completed-runs + (let [completed (make-run {:run-id "base" + :cases [:case/load] + :warmups 1 + :repetitions 1}) + interrupted (make-run {:run-id "additional" + :cases [:case/load] + :warmups 1 + :repetitions 1 + :termination {:reason :interrupted}})] + (t/is (thrown-with-msg? js/Error #"only completed runs can be merged" + (result/merge-runs completed interrupted + {:run-id "merged"}))) + (t/is (thrown-with-msg? js/Error #"only completed runs can be merged" + (result/merge-runs interrupted completed + {:run-id "merged"}))))) + +(t/deftest rejects-duplicate-preparation-ids + (let [base (make-run {:run-id "base" + :cases [:case/load] + :warmups 1 + :repetitions 1 + :preparations + {:case/load {:warmup "shared" + :measured "shared"}}}) + additional (make-run {:run-id "additional" + :cases [:case/load] + :warmups 1 + :repetitions 1 + :preparations + {:case/load {:warmup "shared" + :measured "shared"}}})] + (t/is (thrown-with-msg? js/Error #"duplicate preparation ids" + (result/merge-runs base additional + {:run-id "merged"}))))) diff --git a/frontend/test/performance_tests/render_wasm/runner.cljs b/frontend/test/performance_tests/render_wasm/runner.cljs index 1eb166bcaa..39ccaacb3d 100644 --- a/frontend/test/performance_tests/render_wasm/runner.cljs +++ b/frontend/test/performance_tests/render_wasm/runner.cljs @@ -24,6 +24,7 @@ [performance-tests.render-wasm.geometry-scenes-test] [performance-tests.render-wasm.lifecycle-test] [performance-tests.render-wasm.log-test] + [performance-tests.render-wasm.merge-test] [performance-tests.render-wasm.outcome-test] [performance-tests.render-wasm.process-test] [performance-tests.render-wasm.protocol-test] @@ -60,6 +61,7 @@ 'performance-tests.render-wasm.geometry-scenes-test 'performance-tests.render-wasm.lifecycle-test 'performance-tests.render-wasm.log-test + 'performance-tests.render-wasm.merge-test 'performance-tests.render-wasm.outcome-test 'performance-tests.render-wasm.process-test 'performance-tests.render-wasm.protocol-test diff --git a/frontend/test/performance_tests/render_wasm/runner_test.cljs b/frontend/test/performance_tests/render_wasm/runner_test.cljs index 16196bf7a8..56d36a2fbf 100644 --- a/frontend/test/performance_tests/render_wasm/runner_test.cljs +++ b/frontend/test/performance_tests/render_wasm/runner_test.cljs @@ -13,6 +13,7 @@ (t/deftest output-options-apply-to-every-command (doseq [args [["build"] ["run"] ["purge"] ["compare" "base" "candidate"] + ["merge" "base" "additional"] ["ab" "--features" "a" "--filter" "rects/load"]]] (t/is (= :info (options/console-level (:options (runner/parse-args args))))) (t/is (= :auto (options/color-mode (:options (runner/parse-args args))))) @@ -81,7 +82,7 @@ (t/deftest help-needs-no-command-and-other-command-lines-must-be-valid (doseq [args [["--help"] ["-h"] ["build" "--help"] ["run" "--help"] - ["compare" "--help"]]] + ["compare" "--help"] ["merge" "--help"]]] (let [{:keys [options summary]} (runner/parse-args args) help (runner/usage summary)] (t/is (true? (:help options))) @@ -89,13 +90,14 @@ (t/is (str/includes? help "runner purge")) (t/is (str/includes? help "runner run")) (t/is (str/includes? help "runner compare")) + (t/is (str/includes? help "runner merge")) (t/is (str/includes? help "--build-timeout-ms")) (t/is (str/includes? help "--cases")) (t/is (str/includes? help "--allow-build-differences")) (t/is (str/includes? help "--allow-scene-differences")) (t/is (str/includes? help "--allow-all-differences")) (t/is (not (str/includes? help "--force"))))) - (t/is (thrown-with-msg? js/Error #"expected build, purge, run, ab or compare" + (t/is (thrown-with-msg? js/Error #"expected build, purge, run, ab, compare or merge" (runner/parse-args []))) (doseq [args [["unknown"] ["build" "purge"] ["build" "--unknown"] ["run" "extra"] ["compare" "only-one"]]] @@ -167,6 +169,27 @@ (t/is (true? (:allow-scene-differences options))) (t/is (not (:allow-all-differences options))))) +(t/deftest merge-takes-two-paths-and-only-output-dir + (let [{:keys [command arguments options]} + (runner/parse-args ["merge" "base.transit" "additional.transit" + "--output-dir" "merged-home"])] + (t/is (= "merge" command)) + (t/is (= ["merge" "base.transit" "additional.transit"] arguments)) + (t/is (= "merged-home" (:output-dir options)))) + (doseq [args [["merge"] ["merge" "only-base"] + ["merge" "base" "additional" "extra"]]] + (t/is (thrown? js/Error (runner/parse-args args)))) + (doseq [flags [["--build" "id"] + ["--filter" "rects"] + ["--seed" "1"] + ["--require-gpu"] + ["--features" "functional"] + ["--cases" "a,b"] + ["--allow-build-differences"] + ["--only-builds"] + ["--dry-run"]]] + (t/is (thrown? js/Error (runner/parse-args (into ["merge" "base" "additional"] flags)))))) + (t/deftest force-is-rejected-without-an-alias (doseq [args [["compare" "a.transit" "b.transit" "--force"] ["compare" "a.transit" "b.transit" "--force" "--allow-all-differences"] @@ -182,7 +205,12 @@ [["build" "--build" "retained"] #"run options require run"] [["build" "--only-runs"] #"purge category flags require purge"] [["purge" "--seed" "1"] #"run options require run"] - [["purge" "--cases" "a,b"] #"compare options require compare"]]] + [["purge" "--cases" "a,b"] #"compare options require compare"] + [["merge" "a" "b" "--seed" "1"] #"run options require run"] + [["merge" "a" "b" "--features" "functional"] #"build options require build"] + [["merge" "a" "b" "--cases" "a,b"] #"compare options require compare"] + [["merge" "a" "b" "--only-builds"] #"purge category flags require purge"] + [["merge" "a" "b" "--dry-run"] #"ab planning options require ab"]]] (t/is (thrown-with-msg? js/Error message (runner/parse-args args)))) (doseq [command ["build" "purge" "run"] flag ["--allow-build-differences" "--allow-scene-differences"