diff --git a/frontend/performance/benches/render_wasm/errors.cljc b/frontend/performance/benches/render_wasm/errors.cljc index a9b7cf1d56..f933d207e0 100644 --- a/frontend/performance/benches/render_wasm/errors.cljc +++ b/frontend/performance/benches/render_wasm/errors.cljc @@ -48,6 +48,40 @@ #?(:cljs (do + (def ^:private schema:cause-value + [:or nil? boolean? keyword? number? string?]) + + (def schema:cause-detail + "One level of a portable exception description." + [:map {:closed true} + [:message string?] + [:fn {:optional true} schema:cause-value] + [:code {:optional true} schema:cause-value] + [:type {:optional true} schema:cause-value] + [:hint {:optional true} schema:cause-value]]) + + (def schema:diagnostic + "Failure location and message with optional cause and partial evidence." + [:map + [:phase string?] + [:message string?] + [:status {:optional true} [:enum "failed" "stale"]] + [:cause {:optional true} [:vector schema:cause-detail]] + [:partial {:optional true} map?] + [:reason {:optional true} :any] + [:deadline-exceeded {:optional true} boolean?]]) + + (defn project-diagnostic + "Returns the diagnostic fields of an execution envelope. + + Args: + evidence: Plain execution evidence. + + Returns: + Failure status, location, message and available diagnostic details." + [evidence] + (select-keys evidence [:status :phase :message :cause :partial :reason :deadline-exceeded])) + (def ^:private fatal-cause-depth 8) (def ^:private evidence-cause-depth 3) (def ^:private max-cause-chars 500) diff --git a/frontend/performance/benches/render_wasm/measurement.cljs b/frontend/performance/benches/render_wasm/measurement.cljs index b9fc292d33..a605ac2aa9 100644 --- a/frontend/performance/benches/render_wasm/measurement.cljs +++ b/frontend/performance/benches/render_wasm/measurement.cljs @@ -8,8 +8,15 @@ "Projects browser phase evidence into saved attempts." (:require [app.common.schema :as sm] + [benches.render-wasm.errors :as errors] [benches.render-wasm.result :as result])) +(def ^:private schema:response + [:multi {:dispatch :status} + ["ok" [:map [:status [:= "ok"]]]] + ["failed" (sm/merge errors/schema:diagnostic [:map [:status [:= "failed"]]])] + ["stale" [:map {:closed true} [:status [:= "stale"]]]]]) + (defn partial-phases "Returns the valid prefix of delivered phase history. @@ -33,6 +40,31 @@ out) out))) +(defn check-response + "Returns checked bridge evidence or an invalid-output failure. + + Stale responses retain their status and gain an ownership diagnostic. + Malformed responses retain their valid phase prefix and raw response. + + Args: + phase: The preparation or attempt receiving the response. + preparation-id: Expected owner of delivered phase blocks. + response: Decoded bridge envelope. + + Returns: + Execution evidence with a known status and diagnostic fields on failure." + [phase preparation-id response] + (if (sm/validate schema:response response) + (if (= "stale" (:status response)) + (assoc response :phase (name phase) :message "browser context was superseded") + response) + (assoc (errors/fail-data + (errors/evidence :invalid-output + (ex-info "invalid bridge response" + {:type ::invalid-response :partial {:response response}}))) + :phases (partial-phases (:phases response) preparation-id + (if (= :preparation phase) [:preparation] [:main :reset]))))) + (defn attempt "Returns an attempt with raw phase histories and available failure evidence. @@ -49,5 +81,5 @@ :outcome (if (= "ok" (:status evidence)) :completed :failed) :phases (or (:phases evidence) [])} (not= "ok" (:status evidence)) - (assoc :failure (select-keys evidence [:status :phase :message :cause])) + (assoc :failure (dissoc (errors/project-diagnostic evidence) :partial)) (:partial evidence) (assoc :partial (:partial evidence)))) diff --git a/frontend/performance/benches/render_wasm/node/runner/run.cljs b/frontend/performance/benches/render_wasm/node/runner/run.cljs index 116ff66247..6dca72948d 100644 --- a/frontend/performance/benches/render_wasm/node/runner/run.cljs +++ b/frontend/performance/benches/render_wasm/node/runner/run.cljs @@ -56,17 +56,6 @@ (contains? (ex-data cause) ::browser/deadline-exceeded) (assoc :deadline-exceeded (::browser/deadline-exceeded (ex-data cause))))) -(defn- failure-evidence - "Projects a bridge failure response into diagnostics evidence." - [evidence fallback-phase] - (if (map? evidence) - (cond-> (select-keys evidence [:status :phase :message :cause :partial :phases :reason :deadline-exceeded]) - (nil? (:status evidence)) (assoc :status "failed") - (nil? (:phase evidence)) (assoc :phase (name fallback-phase))) - {:status "failed" - :phase (name fallback-phase) - :message (str evidence)})) - (defn- interruption [signal] (when-let [name (process/signal-name signal)] @@ -142,7 +131,7 @@ (defn- record-failure [state case-id stage evidence] - (let [failure (cond-> (assoc (select-keys evidence [:status :phase :message :cause :partial :reason]) + (let [failure (cond-> (assoc (errors/project-diagnostic evidence) :stage stage) (some? case-id) (assoc :case-id case-id))] (reset! (:harness-failed? state) true) @@ -217,52 +206,56 @@ (defn- ^:async prepare-case [state case page deadline warmup?] (check-deadline deadline) - (let [build-id (:id (:selected-build state)) + (let [build-id (:id (:selected-build state)) preparation-id (str (random-uuid)) - response (attempt - (await ((get-in state [:services :prepare]) - page - (transit/encode-str - {:case case - :preparation-id preparation-id - :timeout-ms (max 1 (process/remaining deadline)) - :module-url (renderer-url build-id "render-wasm.js") - :wasm-url (renderer-url build-id "render-wasm.wasm")}) - {:deadline deadline :signal (:signal state) :phase :preparation}))) - accepted (when (and (not (failure? response)) (= "ok" (:status response))) - (attempt - (let [preparation (assoc (:preparation response) :warmup? warmup?)] - (when-not (and (= preparation-id (:preparation-id preparation)) - (= (:id case) (:case-id preparation)) - (not (:failure preparation))) - (throw (ex-info "preparation success response is inconsistent" {}))) - (record-preparation state preparation) - preparation)))] + response (attempt + (measurement/check-response + :preparation preparation-id + (await ((get-in state [:services :prepare]) + page + (transit/encode-str + {:case case + :preparation-id preparation-id + :timeout-ms (max 1 (process/remaining deadline)) + :module-url (renderer-url build-id "render-wasm.js") + :wasm-url (renderer-url build-id "render-wasm.wasm")}) + {:deadline deadline :signal (:signal state) :phase :preparation})))) + accepted (when (and (not (failure? response)) (= "ok" (:status response))) + (attempt + (let [preparation (assoc (:preparation response) :warmup? warmup?)] + (when-not (and (= preparation-id (:preparation-id preparation)) + (= (:id case) (:case-id preparation)) + (not (:failure preparation))) + (throw (ex-info "preparation success response is inconsistent" {}))) + (record-preparation state preparation) + preparation)))] (if (and accepted (not (failure? accepted))) {:preparation accepted} (let [failure (cond (failure? accepted) (assoc (cause-evidence accepted :invalid-output) :partial {:response response}) (failure? response) (cause-evidence response :preparation) - :else (failure-evidence response :preparation)) + :else response) phases (if (failure? accepted) (get-in response [:preparation :phases]) (:phases failure))] (record-preparation state {:preparation-id preparation-id :case-id (:id case) :warmup? warmup? :phases (measurement/partial-phases phases preparation-id [:preparation]) :diagnostics (or (get-in failure [:partial :setup]) {}) - :failure (dissoc failure :phases)}) + :failure (errors/project-diagnostic failure)}) {:failure failure :preparation-id preparation-id})))) (defn- ^:async run-attempt "Returns the bridge evidence of one attempt, or the failure." [state page preparation-id deadline] (attempt - (await ((get-in state [:services :run]) - page - (transit/encode-str - {:preparation-id preparation-id - :timeout-ms (max 1 (process/remaining deadline))}) - {:deadline deadline :signal (:signal state) :phase :attempt})))) + (measurement/check-response + :attempt preparation-id + (await ((get-in state [:services :run]) + page + (transit/encode-str + {:preparation-id preparation-id + :timeout-ms (max 1 (process/remaining deadline))}) + {:deadline deadline :signal (:signal state) :phase :attempt}))))) (defn- ^:async recycle-browser [state] diff --git a/frontend/performance/benches/render_wasm/result.cljs b/frontend/performance/benches/render_wasm/result.cljs index 09c68c4c24..249f223c08 100644 --- a/frontend/performance/benches/render_wasm/result.cljs +++ b/frontend/performance/benches/render_wasm/result.cljs @@ -10,6 +10,7 @@ [app.common.schema :as sm] [app.common.transit :as transit] [benches.render-wasm.codec :as codec] + [benches.render-wasm.errors :as errors] [benches.render-wasm.features.plan :as feature-plan] [benches.render-wasm.quantities :as quantities] [benches.render-wasm.report.analysis :as analysis])) @@ -46,7 +47,7 @@ [:effective-graphics {:optional true} map?] [:diagnostics map?] [:phases [:vector schema:phase]] - [:failure {:optional true} map?]]) + [:failure {:optional true} errors/schema:diagnostic]]) (def schema:attempt "One chronological execution; failure consumes a slot and may retain evidence." @@ -57,7 +58,7 @@ [:warmup? boolean?] [:outcome [:enum :completed :failed]] [:phases [:vector schema:phase]] - [:failure {:optional true} map?] + [:failure {:optional true} errors/schema:diagnostic] [:partial {:optional true} map?]]) (def schema:metadata @@ -70,16 +71,19 @@ [:environment {:optional true} [:map-of :string :string]]]] [:environment map?] [:scored? boolean?] - [:diagnostics {:optional true} map?]]) + [:diagnostics {:optional true} + [:map [:failures {:optional true} [:vector errors/schema:diagnostic]]]]]) (def schema:termination "A run's current state or final reason, with optional diagnostic details." - [:map [:reason keyword?]]) + [:map + [:reason keyword?] + [:failure {:optional true} errors/schema:diagnostic]]) (def schema:case-outcome [:map {:closed true} [:outcome [:enum :completed :failed :timed-out :interrupted]] - [:failure {:optional true} map?]]) + [:failure {:optional true} errors/schema:diagnostic]]) (def schema:run "schema:run stores saved case definitions, independent of any registry. @@ -194,7 +198,7 @@ [:run {:optional true} [:map [:run-id string?] [:record-path string?] [:log-path string?] [:exit-code {:optional true} :int]]] [:effective-features {:optional true} [:vector string?]] - [:failure {:optional true} :map] + [:failure {:optional true} errors/schema:diagnostic] [:reason {:optional true} string?]]]] [:edges [:vector @@ -202,7 +206,9 @@ [:from string?] [:to string?] [:feature string?] [:status [:enum :planned :completed :failed :unattempted]] [:path {:optional true} string?] - [:failure {:optional true} :map] + [:failure {:optional true} + [:or errors/schema:diagnostic + [:map {:closed true} [:unacknowledged-differences [:int {:min 1}]]]]] [:reason {:optional true} string?]]]] [:warnings [:vector string?]]]) diff --git a/frontend/test/performance_tests/render_wasm/feature_comparison_test.cljs b/frontend/test/performance_tests/render_wasm/feature_comparison_test.cljs index a1a46e3b09..ca2d851cd7 100644 --- a/frontend/test/performance_tests/render_wasm/feature_comparison_test.cljs +++ b/frontend/test/performance_tests/render_wasm/feature_comparison_test.cljs @@ -279,7 +279,7 @@ (mapv (fn [[index configuration]] (if (zero? index) (assoc (completed-configuration configuration) - :status :failed :failure {:phase :run} + :status :failed :failure {:phase "run" :message "run interrupted"} :reason "SIGINT") (assoc configuration :status :unattempted :reason "stopped after failure"))) @@ -288,7 +288,7 @@ (mapv (fn [[index edge]] (if (zero? index) (assoc edge :status :failed :path "/comparison.transit" - :failure {:message "partial"}) + :failure {:phase "comparison" :message "partial"}) (assoc edge :status :unattempted :reason "stopped after failure"))) (map-indexed vector (:edges record)))))) @@ -489,7 +489,7 @@ :warmup? false :outcome :failed :phases [] - :failure {:message "fixture failure"}}) + :failure {:phase "main" :message "fixture failure"}}) (let [prep (preparation case-id digest)] (-> run (result/record-preparation prep) @@ -503,7 +503,7 @@ run cases)) termination (case kind - :failed {:reason :failed :failure {:message "fixture failure"}} + :failed {:reason :failed :failure {:phase "main" :message "fixture failure"}} :zero-attempts {:reason :incomplete} :interrupted {:reason :interrupted :signal "SIGINT"} {:reason :completed}) diff --git a/frontend/test/performance_tests/render_wasm/lifecycle_test.cljs b/frontend/test/performance_tests/render_wasm/lifecycle_test.cljs index cb2ab2e3fd..410b5fc453 100644 --- a/frontend/test/performance_tests/render_wasm/lifecycle_test.cljs +++ b/frontend/test/performance_tests/render_wasm/lifecycle_test.cljs @@ -17,8 +17,13 @@ [benches.render-wasm.cases :as cases] [benches.render-wasm.declarations :as decl] [benches.render-wasm.errors :as errors] + [benches.render-wasm.measurement :as measurement] + [benches.render-wasm.report.format :as format] + [benches.render-wasm.report.summarize :as summary] + [benches.render-wasm.result :as result] [benches.render-wasm.runtime.camera :as camera] - [cljs.test :as t :include-macros true])) + [cljs.test :as t :include-macros true] + [clojure.string :as str])) ;; Forward declaration: entry-test helpers below build args against the ;; fake Emscripten factory URL defined alongside the fake-DOM harness. @@ -743,19 +748,49 @@ :preparation {:callback #(work :preparation %)} :reset #(work :reset %)} #(work :main %)) - (let [request (-> (transit/decode-str (scene-args 7 {})) - (assoc :preparation-id "phase-failure") - (transit/encode-str)) - prepared (transit/decode-str (await (browser/prepare-scene-request request))) - run (transit/encode-str {:preparation-id "phase-failure"}) - evidence (if (= failed-phase :preparation) - prepared - (transit/decode-str (await (browser/run-prepared run)))) - stopped (transit/decode-str (await (browser/run-prepared run))) - blocks (:phases evidence)] + (let [request (-> (transit/decode-str (scene-args 7 {})) + (assoc :preparation-id "phase-failure") + (transit/encode-str)) + prepared (transit/decode-str (await (browser/prepare-scene-request request))) + run (transit/encode-str {:preparation-id "phase-failure"}) + evidence (if (= failed-phase :preparation) + prepared + (transit/decode-str (await (browser/run-prepared run)))) + stopped (transit/decode-str (await (browser/run-prepared run))) + blocks (:phases evidence) + diagnostic (errors/project-diagnostic evidence) + preparation (if (= failed-phase :preparation) + {:preparation-id "phase-failure" :case-id :rects/lifecycle + :warmup? false :diagnostics (get-in evidence [:partial :setup]) + :phases blocks :failure diagnostic} + (assoc (:preparation prepared) :warmup? false)) + saved (-> (cond-> + (-> (result/create-run + {:run-id "phase-failure" :started-at "start" + :plan {:cases [(load-case)] :warmups 0 :repetitions 1} + :metadata {:git {:sha nil :dirty nil} :build {:functional {}} + :environment {} :scored? true}}) + (result/record-preparation preparation)) + (or (= context :fresh) (not= failed-phase :preparation)) + (result/record-attempt + (measurement/attempt :rects/lifecycle "phase-failure" false + (if (= failed-phase :preparation) + (dissoc evidence :phases) + evidence)))) + (result/record-case-outcome :rects/lifecycle + {:outcome :failed}) + (result/finish-run "end" {:reason :failed}) + result/encode result/decode) + stored (if (= failed-phase :preparation) + (first (:preparations saved)) + (first (:attempts saved)))] (t/is (= "failed" (:status evidence))) (t/is (= (name failed-phase) (:phase evidence))) (t/is (= "phase fixture failed" (:message evidence))) + (t/is (= (:cause evidence) (get-in stored [:failure :cause]))) + (t/is (= blocks (:phases stored))) + (t/is (= "phase-failure" (:preparation-id stored))) + (t/is (str/includes? (format/format-run (summary/summarize saved)) "phase fixture failed")) (t/is (= (case failed-phase :preparation [:preparation] :main [:preparation :main] diff --git a/frontend/test/performance_tests/render_wasm/report_test.cljs b/frontend/test/performance_tests/render_wasm/report_test.cljs index 6ca2469676..0482cca20b 100644 --- a/frontend/test/performance_tests/render_wasm/report_test.cljs +++ b/frontend/test/performance_tests/render_wasm/report_test.cljs @@ -54,7 +54,8 @@ run (measurement/attempt :saved/pan prep warmup? (cond-> {:status (if (= :completed outcome) "ok" "failed") :phases phases} - (= :failed outcome) (assoc :message "renderer failed"))))) + (= :failed outcome) (assoc :phase (name (:phase (last phases))) + :message "renderer failed"))))) (defn- sample-run [values] @@ -223,7 +224,7 @@ (let [run (-> (new-run 0 1 :reuse) (result/record-preparation (assoc (preparation :prep [(event :test/setup [9])]) - :failure {:message "initial Full failed"}))) + :failure {:phase "preparation" :message "initial Full failed"}))) report (first (:cases (summary/summarize run))) metric (get-in report [:metrics [:preparation :test/setup :command/elapsed]])] (t/is (= {:executions 1 :completed 1 :failed 0 :warmup 0} (get-in report [:phases :preparation]))) @@ -255,7 +256,7 @@ initial values) (result/record-attempt (measurement/attempt :saved/pan :prep false - {:status "failed" :message "render failed" + {:status "failed" :phase "main" :message "render failed" :cause [{:message "WASM trap"}] :partial {:clock js/NaN} :phases [(assoc (block :main :prep [(event :test/pan [99])]) @@ -294,7 +295,7 @@ (let [failed (-> (new-run 0 1 :reuse) (result/record-preparation {:preparation-id :prep :case-id :saved/pan :warmup? false :diagnostics {} :phases [] - :failure {:message "setup failed"}}) + :failure {:phase "preparation" :message "setup failed"}}) (result/finish-run "end" {:reason :preparation-error}))] (t/is (str/includes? (format/format-run (summary/summarize (result/decode (result/encode failed)))) "setup failed")))) @@ -409,7 +410,7 @@ (result/record-preparation (-> (preparation :prep [(event :test/setup [9])]) (dissoc :fingerprint :effective-graphics) - (assoc :failure {:message "setup failed"}))))] + (assoc :failure {:phase "preparation" :message "setup failed"}))))] (doseq [options [{} {:allow-scene-differences true}]] (t/is (= ::compare/incompatible (error-type #(compare/compare-runs run run options))))) (let [report (compare/compare-runs run run {:allow-all-differences true})] @@ -422,7 +423,7 @@ (doseq [bad [(assoc-in run [:attempts 0 :attempt-id] 3) (assoc-in run [:attempts 0 :phases] []) (assoc-in run [:preparations 0 :phases] []) - (assoc-in run [:preparations 0 :failure] {:message "setup failed"}) + (assoc-in run [:preparations 0 :failure] {:phase "preparation" :message "setup failed"}) (update-in run [:attempts 0] assoc :outcome :failed :phases [(assoc (block :main :prep []) :outcome :failed) (block :reset :prep [])]) @@ -434,6 +435,30 @@ (t/is (= ::result/invalid-record (error-type #(result/decode (transit/encode-str bad)))))))) +(t/deftest saved-failures-reject-malformed-diagnostics + (let [diagnostic {:phase "main" :message "renderer failed"} + failed-preparation (-> (new-run 0 1 :reuse) + (result/record-preparation (assoc (preparation :prep []) :failure diagnostic)) + (result/finish-run "end" {:reason :failed}) + result/encode result/decode) + failed-attempt (-> (new-run 0 1 :reuse) + (result/record-preparation (preparation :prep [])) + (add-attempt :prep false :failed [(assoc (block :main :prep []) :outcome :failed)]) + (result/record-case-outcome :saved/pan {:outcome :failed :failure diagnostic}) + (result/finish-run "end" {:reason :failed :failure diagnostic}) + (assoc-in [:metadata :diagnostics :failures] [diagnostic]) + result/encode result/decode)] + (doseq [[run path] [[failed-preparation [:preparations 0 :failure]] + [failed-attempt [:attempts 0 :failure]] + [failed-attempt [:case-outcomes :saved/pan :failure]] + [failed-attempt [:termination :failure]] + [failed-attempt [:metadata :diagnostics :failures 0]]] + invalid [{:phase 42 :message "bad location"} + {:phase "main" :message nil} + {:phase "main" :message "bad cause" :cause [42]}]] + (t/is (= ::result/invalid-record + (error-type #(result/decode (transit/encode-str (assoc-in run path invalid))))))))) + (t/deftest case-outcomes-validate-completion-and-close-the-recording-boundary (let [run (sample-run [1]) completed (result/record-case-outcome run :saved/pan {:outcome :completed}) diff --git a/frontend/test/performance_tests/render_wasm/run_test.cljs b/frontend/test/performance_tests/render_wasm/run_test.cljs index 7519d4b4b2..40b7ae8490 100644 --- a/frontend/test/performance_tests/render_wasm/run_test.cljs +++ b/frontend/test/performance_tests/render_wasm/run_test.cljs @@ -573,7 +573,8 @@ :invalid-preparation (update response :preparation dissoc :fingerprint) :contradictory-preparation (-> response - (assoc-in [:preparation :failure] {:message "callback failed"}) + (assoc-in [:preparation :failure] + {:phase "preparation" :message "callback failed"}) (assoc-in [:preparation :phases 0 :outcome] :failed)) response) response))) @@ -585,8 +586,14 @@ (if (= :case/a case-id) (case phase :main {:status "failed" :phase "main" :message "main failed" + :cause [{:message "WASM trap" :code "E_RENDER"}] + :reason :renderer-rejected :deadline-exceeded false + :partial {:completed-call 7} :phases [(assoc block :outcome :failed)]} :reset {:status "failed" :phase "reset" :message "reset failed" + :cause [{:message "WASM trap" :code "E_RENDER"}] + :reason :renderer-rejected :deadline-exceeded false + :partial {:completed-call 7} :phases [block (assoc block :phase :reset :outcome :failed)]} :invalid-main {:status "ok" :phases []} :invalid-reset {:status "ok" :phases [block {:phase :reset}]}) @@ -601,6 +608,11 @@ (t/is (= {:warmups 0 :repetitions (if (and prep-failed? (= :reuse context)) 2 1)} (get-in saved [:remaining :case/a]))) (t/is (= [:completed :completed] (mapv :outcome (attempts-for saved :case/b)))) + (when (#{:main :reset} phase) + (t/is (= {:cause [{:message "WASM trap" :code "E_RENDER"}] + :reason :renderer-rejected :deadline-exceeded false} + (select-keys (:failure (first attempts)) [:cause :reason :deadline-exceeded]))) + (t/is (= {:completed-call 7} (:partial (first attempts))))) (when (= :contradictory-preparation phase) (t/is (= "invalid-output" (get-in (first (preparations-for saved :case/a)) [:failure :phase])))) (when (= :invalid-reset phase) @@ -609,6 +621,65 @@ (when (#{:invalid-main :invalid-reset} phase) (t/is (= "invalid-output" (get-in (first attempts) [:failure :phase]))))))) +(t/deftest ^:async malformed-bridge-diagnostics-stop-the-case-with-delivered-evidence + (doseq [stage [:prepare :run] + evidence [nil "unexpected response" + {:status "unknown" :phase "main" :message "unexpected status"} + {:status "failed" :phase "main"} + {:status "failed" :phase "main" :message "bad cause" :cause [:invalid]}]] + (let [base (fake-browser {}) + service (get-in base [:service stage]) + delivered (atom nil) + fake (assoc-in base [:service stage] + (fn [page request bounds] + (let [response (service page request bounds) + params (transit/decode-str request) + case-id (if (= :prepare stage) + (get-in params [:case :id]) + (get-in @(:state base) [:preparations (:preparation-id params)]))] + (if (= :case/a case-id) + (let [phases (if (= :prepare stage) + (get-in response [:preparation :phases]) + (conj (:phases response) {:phase :reset})) + rejected (cond-> evidence + (map? evidence) (assoc :phases phases))] + (reset! delivered rejected) + (transit/decode-str (transit/encode-str rejected))) + response)))) + response (await (execute fake [(collected :case/a :reuse) (collected :case/b :reuse)] + (assoc (options fake (output-dir)) :repetitions 2))) + saved (saved-record response) + failed (if (= :prepare stage) + (first (preparations-for saved :case/a)) + (first (attempts-for saved :case/a)))] + (t/is (= 1 (:exit-code response))) + (t/is (= "invalid-output" (get-in failed [:failure :phase]))) + (t/is (= "invalid bridge response" (get-in failed [:failure :message]))) + (t/is (= @delivered (if (= :prepare stage) + (get-in failed [:failure :partial :response]) + (get-in failed [:partial :response])))) + (t/is (= (if (map? evidence) [(first (:phases @delivered))] []) (:phases failed))) + (t/is (= {:warmups 0 :repetitions (if (= :prepare stage) 2 1)} + (get-in saved [:remaining :case/a]))) + (t/is (= [:completed :completed] (mapv :outcome (attempts-for saved :case/b)))) + (t/is (str/includes? (:report response) "invalid bridge response"))))) + +(t/deftest ^:async stale-bridge-results-stop-the-case-with-ownership-diagnostics + (doseq [stage [:prepare :run]] + (let [base (fake-browser {}) + fake (assoc-in base [:service stage] (constantly {:status "stale"})) + response (await (execute fake [(collected :case/a :reuse)] + (assoc (options fake (output-dir)) :repetitions 2))) + saved (saved-record response) + failure (get-in saved [:case-outcomes :case/a :failure])] + (t/is (= 1 (:exit-code response))) + (t/is (= :failed (get-in saved [:case-outcomes :case/a :outcome]))) + (t/is (= "stale" (:status failure))) + (t/is (= (if (= :prepare stage) "preparation" "attempt") (:phase failure))) + (t/is (= "browser context was superseded" (:message failure))) + (t/is (= {:warmups 0 :repetitions (if (= :prepare stage) 2 1)} + (get-in saved [:remaining :case/a])))))) + (t/deftest ^:async runner-terminates-a-blocked-page-and-completes-the-next-case (let [dir (build-dir) owners (atom [])