Andrey Antukh 34b24a9d9d Retry transient saves with backoff and reconnect notice
Classify save failures as transient or terminal (`transient-error?`
over the repo retryable types plus `:invalid-save-response`).
Transient failures keep the head commit queued under a new `:retrying`
status and resend it with backoff (2s/8s/20s, then terminal):
stamp rotation reuses the same `:commit-id`, the in-flight guard
prevents double-sends, and episode tokens silence stale timers.
One tagged reconnect notice per episode (hidden on save and on
terminal failure, silent recovery) plus a `:retrying` save-indicator
state; the browser `online` event and new edits resume the episode.
Terminal failures keep the exact `:error` path. Covers tasks 4, 6
and 7 with 31 persistence tests; updates the persistence memory.

Relates to #11724

AI-assisted-by: muse-spark-1.3-contributor
2026-09-23 12:05:24 +02:00

93 lines
3.5 KiB
Clojure

;; 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 frontend-tests.helpers.async
"Async-first plumbing for ClojureScript tests.
Lets a test read sequentially while presupposing asynchronous APIs:
triggers run, effects are awaited as promises, and `done` always runs
at the end. Relies on two platform properties:
- `cljs.test` keeps its env in a `set!` var, so assertions inside
deferred ticks are still counted.
- RxJS delivers `observe-on :async` notifications in scheduling (FIFO)
order, so awaiting the last scheduled delivery observes every effect
scheduled before it."
(:require
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]))
(defn ->promise
"Coerces a single-value observable into a js/Promise that resolves
with its first value (or rejects on error)."
[ob]
(js/Promise. (fn [resolve reject] (rx/subs! resolve reject ob))))
(defn ^:async await-response
"Pushes `value` into the `response` subject and resolves once the last
recorded request completes, delivery included.
The promise subscribes BEFORE pushing: subscribing after the push
would never resolve. Request entries are maps holding the request
observable under `:req`."
[requests response value]
(let [p (->promise (:req (last @requests)))]
(rx/push! response value)
(await p)))
(defn settle
"Resolves on the next macrotask, letting scheduled deliveries land.
Await it before asserting absence: silence is only meaningful once the
queue had a chance to deliver."
[]
(js/Promise. (fn [resolve] (js/setTimeout resolve 0))))
(defn observe
"Subscribes to `ob` forcing asynchronous delivery through `observe-on
:async` — even when the source is synchronous — and returns a promise
resolving once the stream terminates.
Keyword args: `:on-next` per value, `:on-error` (handled errors resolve;
unhandled reject), `:on-complete` on clean termination, `:timeout-ms`
(default 2000). Asserts the stream is provided: resolving nil would let
a test pass without observing anything. Rejects on timeout instead of
hanging."
[ob & {:keys [on-next on-error on-complete timeout-ms]
:or {timeout-ms 2000}}]
(assert (some? ob) "observe requires an observable stream")
(js/Promise.
(fn [resolve reject]
(let [timer (js/setTimeout
#(reject (ex-info "Stream did not terminate in time" {}))
timeout-ms)
settle (fn [f v]
(js/clearTimeout timer)
(f v))]
(->> ob
(rx/observe-on :async)
(rx/subs!
(fn [v] (when on-next (on-next v)))
(fn [e]
(if on-error
(do (on-error e) (settle resolve nil))
(settle reject e)))
(fn []
(when on-complete (on-complete))
(settle resolve nil))))))))
(defn ^:async wait-for
"Resolves once `pred` holds, checking immediately and then once per
macrotask (bounded: records a failure instead of hanging). Await it
before asserting effects of triggers — it holds whether the effect
lands synchronously or not."
[pred msg & [{:keys [max-ticks] :or {max-ticks 50}}]]
(if (pred)
nil
(if (<= max-ticks 0)
(t/is false (str "Timed out waiting for: " msg))
(do (await (settle))
(await (wait-for pred msg {:max-ticks (dec max-ticks)}))))))