mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
When the scheduler throttles an update loop, it only logs a console warning, so a loop that no longer crashes leaves no trace. Send an error report too, with the events in flight and the last events, at most once per report window. Relates to #12580 AI-assisted-by: claude-opus-5-5
1119 lines
54 KiB
Clojure
1119 lines
54 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.main-errors-test
|
||
"Unit tests for app.main.errors.
|
||
|
||
Tests cover:
|
||
- stale-asset-error? – pure predicate
|
||
- exception->error-data – pure transformer
|
||
- on-error re-entrancy guard – prevents recursive invocations
|
||
- events in flight – the full report lists the events the
|
||
store was processing when it caught the exception; a failing stream
|
||
pipeline lists the emitters of the value, not its own event
|
||
- on-update-loop – never throws into the scheduler's caller;
|
||
reports the throttled loop with the events in flight
|
||
- flash schedules async report and toast – neither the report nor
|
||
ntf/show is emitted synchronously
|
||
- organization SSO recovery – expired SSO sessions go back to the provider
|
||
- invalid-sso-config handler – requires :organization-id to promote to :sso-error
|
||
- save failure notification – sticky toast carrying the error report
|
||
- delegated save failures – causes handled by the handler for their type"
|
||
(:require
|
||
[app.common.uuid :as uuid]
|
||
[app.main.data.event :as ev]
|
||
[app.main.data.persistence :as dps]
|
||
[app.main.data.workspace :as-alias dw]
|
||
[app.main.errors :as errors]
|
||
[app.main.refs :as refs]
|
||
[app.main.repo :as rp]
|
||
[app.main.router :as rt]
|
||
[app.main.store :as st]
|
||
[app.util.dom :as dom]
|
||
[app.util.i18n :as i18n]
|
||
[app.util.timers :as tm]
|
||
[app.util.webapi :as wapi]
|
||
[beicon.v2.core :as rx]
|
||
[cljs.test :as t :include-macros true]
|
||
[cuerdas.core :as str]
|
||
[frontend-tests.helpers.async :as async]
|
||
[frontend-tests.helpers.mock :as mock]
|
||
[potok.v2.core :as ptk]))
|
||
|
||
;; ---------------------------------------------------------------------------
|
||
;; stale-asset-error?
|
||
;; ---------------------------------------------------------------------------
|
||
|
||
(t/deftest stale-asset-error-nil
|
||
(t/testing "nil cause returns nil/falsy"
|
||
(t/is (not (errors/stale-asset-error? nil)))))
|
||
|
||
(t/deftest stale-asset-error-keyword-cst-undefined
|
||
(t/testing "error with $cljs$cst$ and 'is undefined' is recognised"
|
||
(let [err (js/Error. "foo$cljs$cst$bar is undefined")]
|
||
(t/is (true? (boolean (errors/stale-asset-error? err)))))))
|
||
|
||
(t/deftest stale-asset-error-keyword-cst-null
|
||
(t/testing "error with $cljs$cst$ and 'is null' is recognised"
|
||
(let [err (js/Error. "foo$cljs$cst$bar is null")]
|
||
(t/is (true? (boolean (errors/stale-asset-error? err)))))))
|
||
|
||
(t/deftest stale-asset-error-protocol-dispatch-undefined
|
||
(t/testing "error with $cljs$core$I and 'Cannot read properties of undefined' is recognised"
|
||
(let [err (js/Error. "Cannot read properties of undefined (reading '$cljs$core$IFn$_invoke$arity$1$')")]
|
||
(t/is (true? (boolean (errors/stale-asset-error? err)))))))
|
||
|
||
(t/deftest stale-asset-error-not-a-function
|
||
(t/testing "error with $cljs$cst$ and 'is not a function' is recognised"
|
||
(let [err (js/Error. "foo$cljs$cst$bar is not a function")]
|
||
(t/is (true? (boolean (errors/stale-asset-error? err)))))))
|
||
|
||
(t/deftest stale-asset-error-unrelated-message
|
||
(t/testing "ordinary error without stale-asset signature is NOT recognised"
|
||
(let [err (js/Error. "Cannot read properties of undefined (reading 'foo')")]
|
||
(t/is (not (errors/stale-asset-error? err))))))
|
||
|
||
(t/deftest stale-asset-error-only-cst-no-undefined
|
||
(t/testing "error with $cljs$cst$ but no undefined/null/not-a-function keyword is not recognised"
|
||
(let [err (js/Error. "foo$cljs$cst$bar exploded")]
|
||
(t/is (not (errors/stale-asset-error? err))))))
|
||
|
||
;; ---------------------------------------------------------------------------
|
||
;; exception->error-data
|
||
;; ---------------------------------------------------------------------------
|
||
|
||
(t/deftest exception->error-data-plain-error
|
||
(t/testing "plain JS Error is converted to a data map with :hint and ::instance"
|
||
(let [err (js/Error. "something went wrong")
|
||
data (errors/exception->error-data err)]
|
||
(t/is (= "something went wrong" (:hint data)))
|
||
(t/is (identical? err (::errors/instance data))))))
|
||
|
||
(t/deftest exception->error-data-ex-info
|
||
(t/testing "ex-info error preserves existing :hint and attaches ::instance"
|
||
(let [err (ex-info "original" {:hint "my-hint" :type :network})
|
||
data (errors/exception->error-data err)]
|
||
(t/is (= "my-hint" (:hint data)))
|
||
(t/is (= :network (:type data)))
|
||
(t/is (identical? err (::errors/instance data))))))
|
||
|
||
(t/deftest exception->error-data-ex-info-no-hint
|
||
(t/testing "ex-info without :hint falls back to ex-message"
|
||
(let [err (ex-info "fallback message" {:type :validation})
|
||
data (errors/exception->error-data err)]
|
||
(t/is (= "fallback message" (:hint data))))))
|
||
|
||
;; Shared report-test helpers (copied from the governor suite so each
|
||
;; namespace stays self-contained).
|
||
(t/use-fixtures :each {:before #(errors/reset-report-governor!)})
|
||
|
||
(defn- error-cause
|
||
[& {:keys [type code hint]}]
|
||
(ex-info (or hint "boom")
|
||
(cond-> {}
|
||
(some? type) (assoc :type type)
|
||
(some? code) (assoc :code code)
|
||
(some? hint) (assoc :hint hint))))
|
||
|
||
(defn- capture-reports!
|
||
"Run `f` capturing the events emitted through `st/emit!`."
|
||
[f]
|
||
(let [events (atom [])]
|
||
(with-redefs [st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
|
||
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")]
|
||
(f)
|
||
@events)))
|
||
|
||
(defn- report-events
|
||
"The audit events (report emissions) out of everything collected through
|
||
the `st/emit!` double. Toasts are ungoverned by design — one per `flash`
|
||
call — so only the events typed as audit events count for emission
|
||
bounds. Discriminates by `ptk/type`, which is total (never throws),
|
||
because toasts are not derefable."
|
||
[events]
|
||
(filter #(= ::ev/event (ptk/type %)) events))
|
||
|
||
(defn- ^:async report-names
|
||
"Drives `flash-persistence` with an `ex-data` map of `cause-data` and
|
||
returns the audit event names it emitted, in order.
|
||
|
||
The report generator is stubbed and the scheduler runs its callback
|
||
inline, so the whole flash body completes before returning. The emitter
|
||
is the only observable."
|
||
[cause-data]
|
||
(let [events (atom [])
|
||
cause (ex-info "Save failed" (assoc cause-data :hint "Save failed"))]
|
||
;; The governor is global and remembers fingerprints for two minutes, so
|
||
;; a second identical report would be suppressed. Clear it per case.
|
||
(errors/reset-report-governor!)
|
||
(await
|
||
(mock/with-mocks*
|
||
{errors/generate-report (fn [_ & _] "generated error report")
|
||
st/emit! (mock/stub (fn [& emitted]
|
||
(swap! events into emitted)))
|
||
;; `schedule` is multi-arity, so the mock needs every arity the
|
||
;; flash body may call it with.
|
||
tm/schedule (mock/stub (fn
|
||
([] nil)
|
||
([callback] (callback))
|
||
([_ms callback] (callback))))}
|
||
(errors/flash-persistence cause)))
|
||
(mapv #(-> % deref ::ev/name) (report-events @events))))
|
||
|
||
;; ---------------------------------------------------------------------------
|
||
;; Environment failures
|
||
;;
|
||
;; Connectivity and service failures are not application defects: they are
|
||
;; audit-only telemetry with a compact report and a dedicated toast.
|
||
;; ---------------------------------------------------------------------------
|
||
|
||
(t/deftest environment-error-classification
|
||
(t/testing "environment failures are recognised"
|
||
(doseq [type [:network :offline :bad-gateway :service-unavailable
|
||
:gateway-error :nitrate-unavailable :nitrate-not-configured]]
|
||
(t/is (true? (errors/environment-error? (error-cause :type type)))
|
||
(str "expected environment error: " type))))
|
||
(t/testing "application defects are not environment failures"
|
||
(doseq [type [:internal :assertion :persistence :validation :authentication]]
|
||
(t/is (false? (errors/environment-error? (error-cause :type type)))
|
||
(str "did not expect environment error: " type))))
|
||
(t/testing "causes without ex-data are not environment failures"
|
||
(t/is (false? (errors/environment-error? (js/Error. "plain failure"))))
|
||
(t/is (false? (errors/environment-error? nil)))))
|
||
|
||
(t/deftest ^:async generate-report-compact-omits-stack-data-and-last-events
|
||
(await
|
||
(mock/with-mocks*
|
||
{st/format-last-events (mock/stub (fn [& _] (throw (ex-info "must not be called" {}))))
|
||
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")}
|
||
(let [cause (ex-info "http error" {:type :offline
|
||
:hint "http error"
|
||
:uri "/api/rpc/command/update-file"
|
||
:headers {"x-session-id" "secret"}})
|
||
report (errors/generate-report cause {:format :compact})]
|
||
(t/is (string? report))
|
||
(t/is (str/includes? report "Hint:"))
|
||
(t/is (str/includes? report "http error"))
|
||
(t/is (str/includes? report ":offline"))
|
||
(t/is (str/includes? report "/api/rpc/command/update-file"))
|
||
(t/is (not (str/includes? report "Last events:")))
|
||
(t/is (not (str/includes? report "Data:")))
|
||
(t/is (not (str/includes? report "====")))
|
||
(t/is (not (str/includes? report "secret")))
|
||
;; Trailing settle: the body is synchronous, but `with-mocks*`
|
||
;; evaluates to a promise, so the body must settle one.
|
||
(await (async/settle))))))
|
||
|
||
(t/deftest ^:async generate-report-defaults-to-the-full-format
|
||
(await
|
||
(mock/with-mocks*
|
||
{st/format-last-events (mock/stub (fn [& _] "(stub last events)"))
|
||
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")}
|
||
(let [report (errors/generate-report (error-cause :type :internal :hint "boom"))]
|
||
(t/is (str/includes? report "Last events:"))
|
||
(t/is (str/includes? report "(stub last events)"))
|
||
;; Trailing settle: the body is synchronous, but `with-mocks*`
|
||
;; evaluates to a promise, so the body must settle one.
|
||
(await (async/settle))))))
|
||
|
||
(t/deftest ^:async generate-report-bounds-a-huge-exception-message
|
||
;; A message can embed a whole payload (a serialized file) and reach
|
||
;; megabytes. The report must keep a bounded head in both the context and
|
||
;; the trace, so the audit event built from it can never bloat.
|
||
(await
|
||
(mock/with-mocks*
|
||
{st/format-last-events (mock/stub (fn [& _] "(stub last events)"))
|
||
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")}
|
||
(let [huge (apply str (repeat 200000 "z"))
|
||
report (errors/generate-report (js/Error. huge))]
|
||
(t/is (string? report))
|
||
(t/is (< (count report) 50000))
|
||
(t/is (not (str/includes? report (subs huge 0 1500))))
|
||
;; Trailing settle: the body is synchronous, but `with-mocks*`
|
||
;; evaluates to a promise, so the body must settle one.
|
||
(await (async/settle))))))
|
||
|
||
(t/deftest ^:async connectivity-handlers-report-governed-compact-audit-events
|
||
;; Scenario: a network failure and an offline status through the global
|
||
;; handler. Each is reported as a governed compact audit event plus its
|
||
;; connection toast. Proves: per type, report first and toast right after,
|
||
;; with no stack in the report and the dedicated toast message.
|
||
(doseq [type [:network :offline :gateway-error]]
|
||
(errors/reset-report-governor!)
|
||
(let [events (atom [])
|
||
cause (ex-info "http error" {:type type :hint "http error"})]
|
||
(await
|
||
(mock/with-mocks*
|
||
{st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
|
||
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")
|
||
tm/schedule (mock/stub (fn [f] (f)))
|
||
st/format-last-events (mock/stub (fn [& _] (throw (ex-info "must not be called" {}))))}
|
||
(errors/on-error cause)
|
||
(await (async/settle))
|
||
;; `flash` emits the report first and schedules the toast right after.
|
||
(t/is (= 2 (count @events)) (str "unexpected event count for " type))
|
||
(let [report-event (first @events)
|
||
toast-event (second @events)
|
||
props (deref report-event)]
|
||
(t/is (= "handled-exception" (::ev/name props)))
|
||
(t/is (not (str/includes? (:report props) "Last events:")))
|
||
(t/is (= (i18n/tr "errors.connection-error")
|
||
(get-in (ptk/update toast-event {}) [:notification :content])))))))))
|
||
|
||
(t/deftest ^:async flash-keeps-the-canonical-event-name-and-derives-the-format
|
||
;; Scenario: an environment failure flashed as `:handled` and as
|
||
;; `:unhandled`. The audit event name is the canonical one requested by
|
||
;; the caller while only the payload format derives from the cause
|
||
;; (compact, with no stack and no header dump). Proves: per type, one
|
||
;; compact audit event under the requested name, with the `flash`
|
||
;; awaited to completion.
|
||
(let [cause (ex-info "http error" {:type :network
|
||
:hint "http error"
|
||
:headers {"x-session-id" "secret"}})]
|
||
(doseq [[type event-name] [[:handled "handled-exception"]
|
||
[:unhandled "unhandled-exception"]]]
|
||
(errors/reset-report-governor!)
|
||
(let [events (atom [])]
|
||
(await
|
||
(mock/with-mocks*
|
||
{st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
|
||
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")
|
||
tm/schedule (mock/stub (fn [f] (mock/asap f)))
|
||
st/format-last-events (mock/stub (fn [& _] (throw (ex-info "must not be called" {}))))}
|
||
;; The event name is the canonical one requested by the caller;
|
||
;; only the payload format is derived from the cause.
|
||
(await (errors/flash :cause cause :type type))
|
||
(let [reports (report-events @events)]
|
||
(t/is (= 1 (count reports)) (str "unexpected event count for " type))
|
||
(let [props (deref (first reports))
|
||
report (:report props)]
|
||
(t/is (= event-name (::ev/name props)) (str "event name for " type))
|
||
(t/is (not (str/includes? report "Last events:")))
|
||
(t/is (not (str/includes? report "secret")))))))))))
|
||
|
||
(t/deftest ^:async offline-loop-is-governed-and-never-unhandled
|
||
;; Scenario: 10 000 offline errors through the global handler, replaying
|
||
;; a connectivity outage. Every occurrence is handled (never unhandled)
|
||
;; and the governor emits only the first. Proves: one `handled-exception`
|
||
;; audit event for the whole loop, observed after timers drain.
|
||
(let [events (atom [])
|
||
cause (ex-info "http error" {:type :offline :hint "http error"})]
|
||
(await
|
||
(mock/with-mocks*
|
||
{st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
|
||
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")
|
||
tm/schedule (mock/stub (fn [f] (mock/asap f)))
|
||
st/format-last-events (mock/stub (fn [& _] (throw (ex-info "must not be called" {}))))}
|
||
(dotimes [_ 10000]
|
||
(errors/on-error cause))
|
||
(await (async/settle))
|
||
(t/is (= 1 (count (report-events @events))))
|
||
(t/is (= "handled-exception" (::ev/name (deref (first (report-events @events))))))))))
|
||
|
||
(t/deftest ^:async flash-persistence-uses-compact-reports-for-environment-failures
|
||
;; Scenario: a save failure caused by the environment. `flash-persistence`
|
||
;; delegates to `flash`, so the audit event carries a compact report
|
||
;; (context only: no stack, no header dump). Proves: one
|
||
;; `handled-exception` with a compact payload, observed after timers
|
||
;; drain.
|
||
(let [events (atom [])]
|
||
(await
|
||
(mock/with-mocks*
|
||
{st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
|
||
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")
|
||
tm/schedule (mock/stub (fn [f] (mock/asap f)))
|
||
st/format-last-events (mock/stub (fn [& _] (throw (ex-info "must not be called" {}))))}
|
||
(errors/flash-persistence (ex-info "http error" {:type :offline
|
||
:hint "http error"
|
||
:headers {"x-session-id" "secret"}}))
|
||
(await (async/settle))
|
||
(let [reports (report-events @events)]
|
||
(t/is (= 1 (count reports)))
|
||
(let [props (deref (first reports))]
|
||
(t/is (= "handled-exception" (::ev/name props)))
|
||
(t/is (not (str/includes? (:report props) "Last events:")))
|
||
(t/is (not (str/includes? (:report props) "secret")))))))))
|
||
|
||
(t/deftest exception-page-reports-dedup-by-cause
|
||
(let [cause-a (error-cause :type :internal :code :unable-to-process-repository-response :hint "boom")
|
||
cause-b (error-cause :type :internal :code :other :hint "other")
|
||
events (capture-reports!
|
||
(fn []
|
||
(errors/submit-report :event-name "exception-page"
|
||
:report "report" :hint "boom" :cause cause-a)
|
||
(errors/submit-report :event-name "exception-page"
|
||
:report "report" :hint "different hint" :cause cause-a)
|
||
(errors/submit-report :event-name "exception-page"
|
||
:report "report" :hint "other" :cause cause-b)))]
|
||
(t/is (= 2 (count events)))))
|
||
|
||
(t/deftest reports-of-the-same-cause-under-different-names-do-not-coalesce
|
||
(let [cause (error-cause :type :internal :hint "boom")
|
||
events (capture-reports!
|
||
(fn []
|
||
(errors/submit-report :event-name "handled-exception"
|
||
:report "report" :hint "boom" :cause cause)
|
||
(errors/submit-report :event-name "unhandled-exception"
|
||
:report "report" :hint "boom" :cause cause)
|
||
(errors/submit-report :event-name "exception-page"
|
||
:report "report" :hint "boom" :cause cause)))]
|
||
(t/is (= 3 (count events)))))
|
||
|
||
;; ---------------------------------------------------------------------------
|
||
;; on-error dispatches to ptk/handle-error
|
||
;;
|
||
;; We use a dedicated test-only error type so we can add/remove a
|
||
;; defmethod without touching the real handlers.
|
||
;; ---------------------------------------------------------------------------
|
||
|
||
(def ^:private test-handled (atom nil))
|
||
|
||
(defmethod ptk/handle-error ::test-dispatch
|
||
[err]
|
||
(reset! test-handled err))
|
||
|
||
(t/deftest on-error-dispatches-map-error
|
||
(t/testing "on-error dispatches a map error to ptk/handle-error using its :type"
|
||
(reset! test-handled nil)
|
||
(errors/on-error {:type ::test-dispatch :hint "hello"})
|
||
(t/is (= ::test-dispatch (:type @test-handled)))
|
||
(t/is (= "hello" (:hint @test-handled)))))
|
||
|
||
(t/deftest on-error-wraps-exception-then-dispatches
|
||
(t/testing "on-error wraps a JS Error into error-data before dispatching"
|
||
(reset! test-handled nil)
|
||
(let [err (ex-info "wrapped" {:type ::test-dispatch})]
|
||
(errors/on-error err)
|
||
(t/is (= ::test-dispatch (:type @test-handled)))
|
||
(t/is (identical? err (::errors/instance @test-handled))))))
|
||
|
||
;; ---------------------------------------------------------------------------
|
||
;; on-error re-entrancy guard
|
||
;;
|
||
;; The guard is implemented via the `handling-error?` volatile inside
|
||
;; app.main.errors. We can verify its effect by registering a
|
||
;; handle-error method that itself calls on-error and checking that
|
||
;; only one invocation gets through.
|
||
;; ---------------------------------------------------------------------------
|
||
|
||
(def ^:private reentrant-call-count (atom 0))
|
||
|
||
(defmethod ptk/handle-error ::test-reentrant
|
||
[_err]
|
||
(swap! reentrant-call-count inc)
|
||
;; Simulate a secondary error inside the error handler
|
||
;; (e.g. the notification emit itself throws).
|
||
;; Without the re-entrancy guard this would recurse indefinitely.
|
||
(when (= 1 @reentrant-call-count)
|
||
(errors/on-error (ex-info "test" {:type ::test-reentrant :hint "secondary"}))))
|
||
|
||
(t/deftest on-error-reentrancy-guard-prevents-recursion
|
||
(t/testing "a second on-error call while handling an error is suppressed by the guard"
|
||
(reset! reentrant-call-count 0)
|
||
(errors/on-error (ex-info "test" {:type ::test-reentrant :hint "first"}))
|
||
;; The guard must have allowed only the first invocation through.
|
||
(t/is (= 1 @reentrant-call-count))))
|
||
|
||
;; ---------------------------------------------------------------------------
|
||
;; Events in flight
|
||
;;
|
||
;; on-error records the events the store is processing when it catches an
|
||
;; exception; the full report lists them, outermost first.
|
||
;; ---------------------------------------------------------------------------
|
||
|
||
(def ^:private in-flight-cause (atom nil))
|
||
|
||
(defmethod ptk/handle-error ::test-in-flight
|
||
[err]
|
||
(reset! in-flight-cause (::errors/instance err))
|
||
nil)
|
||
|
||
(def ^:private in-flight-heading
|
||
"Events in flight when the error was caught (outermost first):")
|
||
|
||
(defn- in-flight-failure
|
||
[]
|
||
(ex-info "in-flight failure" {:type ::test-in-flight :hint "in-flight failure"}))
|
||
|
||
(defn- recursive-event
|
||
[n]
|
||
(ptk/reify ::test-in-flight-recursive
|
||
ptk/WatchEvent
|
||
(watch [_ _ _]
|
||
(if (pos? n)
|
||
(rx/of (recursive-event (dec n)))
|
||
(throw (in-flight-failure))))))
|
||
|
||
(t/deftest ^:async report-lists-the-events-in-flight-of-a-failing-event
|
||
(let [store (ptk/store {:state {} :on-error errors/on-error})]
|
||
(try
|
||
(await
|
||
(mock/with-mocks*
|
||
{st/state store
|
||
st/format-last-events (mock/stub (fn [& _] "(stub last events)"))
|
||
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")}
|
||
(reset! in-flight-cause nil)
|
||
(st/emit!
|
||
(ptk/reify ::test-in-flight-outer
|
||
ptk/WatchEvent
|
||
(watch [_ _ _]
|
||
(rx/of (ptk/reify ::test-in-flight-inner
|
||
ptk/UpdateEvent
|
||
(update [_ _] (throw (in-flight-failure))))))))
|
||
(await (async/wait-for #(some? @in-flight-cause) "the failure reaches the handler"))
|
||
(let [report (errors/generate-report @in-flight-cause)
|
||
outer (.indexOf report "typ:(:frontend-tests.main-errors-test/test-in-flight-outer)")
|
||
inner (.indexOf report "typ:(:frontend-tests.main-errors-test/test-in-flight-inner)")]
|
||
(t/is (str/includes? report in-flight-heading))
|
||
(t/is (< -1 outer inner) "outer event listed before the failing one"))))
|
||
(finally
|
||
(rx/dispose! store)))))
|
||
|
||
(t/deftest ^:async report-keeps-both-ends-of-a-long-in-flight-list
|
||
(let [store (ptk/store {:state {} :on-error errors/on-error})]
|
||
(try
|
||
(await
|
||
(mock/with-mocks*
|
||
{st/state store
|
||
st/format-last-events (mock/stub (fn [& _] "(stub last events)"))
|
||
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")}
|
||
(reset! in-flight-cause nil)
|
||
;; 25 nested events: 10 from each end are kept
|
||
(st/emit! (recursive-event 24))
|
||
(await (async/wait-for #(some? @in-flight-cause) "the failure reaches the handler"))
|
||
(let [report (errors/generate-report @in-flight-cause)]
|
||
(t/is (str/includes? report "... 5 more ..."))
|
||
(t/is (= 20 (count (re-seq #"test-in-flight-recursive" report)))))))
|
||
(finally
|
||
(rx/dispose! store)))))
|
||
|
||
(defn- in-flight-section
|
||
"The in-flight section of `report`, or nil when absent."
|
||
[report]
|
||
(let [start (.indexOf report in-flight-heading)]
|
||
(when (<= 0 start)
|
||
(subs report start (.indexOf report "Last events:" start)))))
|
||
|
||
(t/deftest ^:async stream-pipeline-failure-lists-the-emitters-of-the-value
|
||
(let [store (ptk/store {:state {} :on-error errors/on-error})]
|
||
(try
|
||
(await
|
||
(mock/with-mocks*
|
||
{st/state store
|
||
st/format-last-events (mock/stub (fn [& _] "(stub last events)"))
|
||
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")}
|
||
(reset! in-flight-cause nil)
|
||
;; The listener's pipeline fails on the trigger that the emitter emits
|
||
(st/emit!
|
||
(ptk/reify ::test-in-flight-listener
|
||
ptk/WatchEvent
|
||
(watch [_ _ stream]
|
||
(->> stream
|
||
(rx/filter (ptk/type? ::test-in-flight-trigger))
|
||
(rx/take 1)
|
||
(rx/map (fn [_] (throw (in-flight-failure)))))))
|
||
(ptk/reify ::test-in-flight-emitter
|
||
ptk/WatchEvent
|
||
(watch [_ _ _]
|
||
(rx/of (ptk/data-event ::test-in-flight-trigger {})))))
|
||
(await (async/wait-for #(some? @in-flight-cause) "the failure reaches the handler"))
|
||
(let [section (in-flight-section (errors/generate-report @in-flight-cause))]
|
||
(t/is (str/includes? section "test-in-flight-emitter"))
|
||
(t/is (not (str/includes? section "test-in-flight-trigger")))
|
||
(t/is (not (str/includes? section "test-in-flight-listener"))))))
|
||
(finally
|
||
(rx/dispose! store)))))
|
||
|
||
(t/deftest ^:async delayed-watch-failure-has-no-in-flight-section
|
||
(let [store (ptk/store {:state {} :on-error errors/on-error})]
|
||
(try
|
||
(await
|
||
(mock/with-mocks*
|
||
{st/state store
|
||
st/format-last-events (mock/stub (fn [& _] "(stub last events)"))
|
||
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")}
|
||
(reset! in-flight-cause nil)
|
||
(st/emit!
|
||
(ptk/reify ::test-in-flight-delayed
|
||
ptk/WatchEvent
|
||
(watch [_ _ _]
|
||
(->> (rx/timer 0)
|
||
(rx/map (fn [_] (throw (in-flight-failure))))))))
|
||
(await (async/wait-for #(some? @in-flight-cause) "the failure reaches the handler"))
|
||
(let [report (errors/generate-report @in-flight-cause)]
|
||
(t/is (not (str/includes? report in-flight-heading)))
|
||
(t/is (str/includes? report "Last events:")))))
|
||
(finally
|
||
(rx/dispose! store)))))
|
||
|
||
(t/deftest ^:async update-loop-handler-never-throws
|
||
(await
|
||
(mock/with-mocks*
|
||
{ptk/in-flight-events (fn [_] (throw (js/Error. "in-flight read failure")))}
|
||
(t/is (nil? (errors/on-update-loop {:rounds 26}))))))
|
||
|
||
(t/deftest ^:async update-loop-is-reported-with-the-events-in-flight
|
||
(errors/reset-report-governor!)
|
||
(vreset! @#'errors/update-loop-warned-at 0)
|
||
(let [events (atom [])
|
||
loop-ev (ptk/reify ::test-update-loop-source)]
|
||
(await
|
||
(mock/with-mocks*
|
||
{st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
|
||
ptk/in-flight-events (fn [_] [loop-ev])
|
||
st/format-last-events (mock/stub (fn [& _] "(stub last events)"))
|
||
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")
|
||
tm/schedule (mock/stub (fn [f] (f)))}
|
||
(errors/on-update-loop {:rounds 26})
|
||
;; A second trip within the window is not reported again
|
||
(errors/on-update-loop {:rounds 26})
|
||
(t/is (= 1 (count @events)))
|
||
(let [props (deref (first @events))]
|
||
(t/is (= "handled-exception" (::ev/name props)))
|
||
(t/is (= "update loop throttled" (:hint props)))
|
||
(t/is (str/includes? (in-flight-section (:report props))
|
||
"test-update-loop-source")))))))
|
||
|
||
;; ---------------------------------------------------------------------------
|
||
;; Expired organization SSO session
|
||
;;
|
||
;; The backend rejects SSO-guarded requests with an :authentication error
|
||
;; coded :nitrate-sso-required once the organization SSO session lapses.
|
||
;; The user must be sent back through the identity provider instead of
|
||
;; being told they have no access to the file.
|
||
;; ---------------------------------------------------------------------------
|
||
|
||
(def ^:private workspace-href
|
||
"https://penpot.example.com/#/workspace?team-id=b8f8bb52-8b70-8144-8004-4a5085f0bdc9")
|
||
|
||
(def ^:private organization-id "d1a4c0f2-2f36-8114-8006-1b0e6d9d0c11")
|
||
|
||
(defn- sso-required-error
|
||
[]
|
||
{:type :authentication
|
||
:code :nitrate-sso-required
|
||
:organization-id organization-id
|
||
:team-id "b8f8bb52-8b70-8144-8004-4a5085f0bdc9"})
|
||
|
||
(t/deftest ^:async expired-organization-sso-navigates-to-identity-provider
|
||
(t/testing "the browser is sent to the identity provider instead of an error page"
|
||
(let [events (atom [])]
|
||
(await
|
||
(mock/with-mocks*
|
||
{rp/cmd! (mock/stub
|
||
(fn [_command _params]
|
||
(rx/of {:authorized false
|
||
:redirect-uri "https://idp.example.com/authorize"})))
|
||
rt/get-current-href (constantly workspace-href)
|
||
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))}
|
||
(errors/on-error (sso-required-error))
|
||
(await (async/settle))
|
||
(t/is (= [::rt/nav-raw] (mapv ptk/type @events))))))))
|
||
|
||
(t/deftest ^:async expired-organization-sso-comes-back-to-the-current-location
|
||
(t/testing "the SSO check asks the provider to return the user where they were"
|
||
(let [rpc-calls (atom [])]
|
||
(await
|
||
(mock/with-mocks*
|
||
{rp/cmd! (mock/stub
|
||
(fn [command params]
|
||
(swap! rpc-calls conj {:command command :params params})
|
||
(rx/of {:authorized false
|
||
:redirect-uri "https://idp.example.com/authorize"})))
|
||
rt/get-current-href (constantly workspace-href)
|
||
st/emit! mock/noop}
|
||
(errors/on-error (sso-required-error))
|
||
(await (async/settle))
|
||
(t/is (= [{:command :check-nitrate-sso
|
||
:params {:team-id "b8f8bb52-8b70-8144-8004-4a5085f0bdc9"
|
||
:organization-id organization-id
|
||
:url workspace-href}}]
|
||
@rpc-calls)))))))
|
||
|
||
(t/deftest ^:async already-satisfied-organization-sso-retries-the-location
|
||
(t/testing "a session renewed meanwhile (e.g. in another tab) reloads instead of erroring"
|
||
(let [events (atom [])]
|
||
(await
|
||
(mock/with-mocks*
|
||
{rp/cmd! (mock/stub
|
||
(fn [_command _params]
|
||
(rx/of {:authorized true :reason :sso-satisfied})))
|
||
rt/get-current-href (constantly workspace-href)
|
||
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))}
|
||
(errors/on-error (sso-required-error))
|
||
(await (async/settle))
|
||
(t/is (= [::rt/reload] (mapv ptk/type @events))))))))
|
||
|
||
(t/deftest ^:async organization-sso-without-usable-provider-shows-the-sso-error-dialog
|
||
(t/testing "SSO is required but there is nowhere to go: offer a retry, not a permission error"
|
||
(let [assigned* (atom nil)]
|
||
(await
|
||
(mock/with-mocks*
|
||
{rp/cmd! (mock/stub
|
||
(fn [_command _params]
|
||
(rx/of {:authorized false :redirect-uri nil})))
|
||
rt/get-current-href (constantly workspace-href)
|
||
rt/assign-exception (fn [error]
|
||
(reset! assigned* error)
|
||
(ptk/data-event ::assigned error))}
|
||
(errors/on-error (sso-required-error))
|
||
(await (async/settle))
|
||
(t/is (= :sso-error (:type @assigned*)))
|
||
(t/is (= organization-id (:organization-id @assigned*)))
|
||
(t/is (true? (:is-workspace @assigned*))))))))
|
||
|
||
(t/deftest ^:async organization-sso-without-team-access-reports-a-permission-failure
|
||
(t/testing "a user who cannot reach the team keeps getting the authentication error"
|
||
(let [assigned* (atom nil)]
|
||
(await
|
||
(mock/with-mocks*
|
||
{rp/cmd! (mock/stub
|
||
(fn [_command _params]
|
||
(rx/of {:authorized true :reason :no-team-access})))
|
||
rt/get-current-href (constantly workspace-href)
|
||
rt/assign-exception (fn [error]
|
||
(reset! assigned* error)
|
||
(ptk/data-event ::assigned error))}
|
||
(errors/on-error (sso-required-error))
|
||
(await (async/settle))
|
||
(t/is (= :authentication (:type @assigned*)))
|
||
(t/is (= :nitrate-sso-required (:code @assigned*))))))))
|
||
|
||
(t/deftest ^:async organization-sso-does-not-retry-on-an-unexplained-authorization
|
||
(t/testing "reloading on an answer we don't understand would spin on the same rejection"
|
||
(let [events (atom [])]
|
||
(await
|
||
(mock/with-mocks*
|
||
{rp/cmd! (mock/stub (fn [_command _params] (rx/of {:authorized true})))
|
||
rt/get-current-href (constantly workspace-href)
|
||
rt/assign-exception (fn [error] (ptk/data-event ::assigned error))
|
||
st/async-emit! (fn [& emitted] (swap! events into emitted))}
|
||
(errors/on-error (sso-required-error))
|
||
(await (async/settle))
|
||
(t/is (= [::assigned] (mapv ptk/type @events))))))))
|
||
|
||
(t/deftest ^:async organization-sso-error-without-context-is-reported-as-it-arrives
|
||
(t/testing "with no organization and no team there is nothing to check"
|
||
(let [rpc-calls (atom 0)
|
||
assigned* (atom nil)]
|
||
(await
|
||
(mock/with-mocks*
|
||
{rp/cmd! (mock/stub (fn [_command _params]
|
||
(swap! rpc-calls inc)
|
||
(rx/empty)))
|
||
rt/get-current-href (constantly workspace-href)
|
||
rt/assign-exception (fn [error]
|
||
(reset! assigned* error)
|
||
(ptk/data-event ::assigned error))}
|
||
(errors/on-error {:type :authentication
|
||
:code :nitrate-sso-required})
|
||
(await (async/settle))
|
||
(t/is (zero? @rpc-calls))
|
||
(t/is (= :nitrate-sso-required (:code @assigned*))))))))
|
||
|
||
(t/deftest ^:async a-resultless-organization-sso-check-does-not-wedge-later-rejections
|
||
(t/testing "the one-in-flight guard is released even when no answer arrives"
|
||
(let [rpc-calls (atom 0)]
|
||
(await
|
||
(mock/with-mocks*
|
||
{rp/cmd! (mock/stub (fn [_command _params]
|
||
(swap! rpc-calls inc)
|
||
(rx/empty)))
|
||
rt/get-current-href (constantly workspace-href)
|
||
st/emit! mock/noop}
|
||
(errors/on-error (sso-required-error))
|
||
(errors/on-error (sso-required-error))
|
||
(await (async/settle))
|
||
(t/is (= 2 @rpc-calls)))))))
|
||
|
||
;; A failing check must stay a failing check: the generic handling turns it
|
||
;; into a toast, whereas swallowing it would show a permission error for
|
||
;; what may be a momentary network blip. The mocked RPC fails on a later
|
||
;; tick, like a real request, so the handler is not inside on-error's
|
||
;; re-entrancy guard when the failure arrives.
|
||
|
||
(def ^:private check-failures (atom []))
|
||
|
||
(defmethod ptk/handle-error ::test-check-failure
|
||
[error]
|
||
(swap! check-failures conj error))
|
||
|
||
(t/deftest ^:async failing-organization-sso-check-is-not-reported-as-missing-access
|
||
(t/testing "the SSO check fails like a real request (later tick) and the failure stays a failure"
|
||
(reset! check-failures [])
|
||
(let [assigned* (atom nil)]
|
||
(await
|
||
(mock/with-mocks*
|
||
{rp/cmd!
|
||
(mock/stub
|
||
(fn [_command _params]
|
||
(->> (rx/timer 0)
|
||
(rx/mapcat (fn [_]
|
||
(rx/throw (ex-info "boom" {:type ::test-check-failure})))))))
|
||
|
||
rt/get-current-href
|
||
(constantly workspace-href)
|
||
|
||
rt/assign-exception
|
||
(fn [error]
|
||
(reset! assigned* error)
|
||
(ptk/data-event ::assigned error))}
|
||
(errors/on-error (sso-required-error))
|
||
(await (async/wait-for #(= 1 (count @check-failures))
|
||
"sso check failure observed"))
|
||
(t/is (= [::test-check-failure] (mapv :type @check-failures)))
|
||
(t/is (nil? @assigned*)))))))
|
||
|
||
;; ---------------------------------------------------------------------------
|
||
;; :validation / :invalid-sso-config
|
||
;;
|
||
;; The SSO error page needs an organization-id to retry meaningfully. Promote
|
||
;; to :sso-error only when that id is present; otherwise keep :validation so
|
||
;; we do not surface a broken SSO dialog for a future code path that omits it.
|
||
;; ---------------------------------------------------------------------------
|
||
|
||
(defn- capture-async-exception
|
||
"Invoke `ptk/handle-error` while capturing the error map passed to
|
||
`rt/assign-exception` via `st/async-emit!`.
|
||
|
||
`st/async-emit!` is variadic (`[& params]`); the mock must be too,
|
||
otherwise CLJS looks up `IFn$_invoke$arity$variadic` and throws."
|
||
[error]
|
||
(let [captured (atom nil)]
|
||
(with-redefs [st/async-emit! (fn [& events]
|
||
(reset! captured (first events)))
|
||
rt/assign-exception (fn [err] err)]
|
||
(ptk/handle-error error)
|
||
@captured)))
|
||
|
||
(t/deftest invalid-sso-config-with-organization-id-promotes-to-sso-error
|
||
(t/testing "invalid-sso-config with :organization-id is shown as :sso-error"
|
||
(let [org-id #uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||
assigned (capture-async-exception
|
||
{:type :validation
|
||
:code :invalid-sso-config
|
||
:organization-id org-id
|
||
:hint "missing issuer"})]
|
||
(t/is (= :sso-error (:type assigned)))
|
||
(t/is (= org-id (:organization-id assigned)))
|
||
(t/is (= :invalid-sso-config (:code assigned))))))
|
||
|
||
(t/deftest invalid-sso-config-without-organization-id-keeps-validation
|
||
(t/testing "invalid-sso-config without :organization-id must not become :sso-error"
|
||
(let [assigned (capture-async-exception
|
||
{:type :validation
|
||
:code :invalid-sso-config
|
||
:hint "missing issuer"})]
|
||
(t/is (= :validation (:type assigned)))
|
||
(t/is (nil? (:organization-id assigned)))
|
||
(t/is (= :invalid-sso-config (:code assigned))))))
|
||
|
||
(t/deftest persistence-notifications-do-not-expire-but-other-flashes-do
|
||
(doseq [[notify timeout] [[#(errors/flash :hint "Ordinary error") 5000]
|
||
[#(errors/flash :hint "Custom error" :timeout 1000) 1000]
|
||
[#(errors/flash-persistence nil) nil]]]
|
||
(let [scheduled (atom [])
|
||
events (atom [])]
|
||
(with-redefs [tm/schedule (mock/stub #(swap! scheduled conj %))
|
||
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))]
|
||
(notify)
|
||
(t/is (empty? @events) "Keep notification delivery asynchronous")
|
||
(doseq [callback @scheduled] (callback))
|
||
(t/is (= 1 (count @events)))
|
||
(let [state (ptk/update (first @events) {})]
|
||
(t/is (= timeout (get-in state [:notification :timeout])))
|
||
(t/is (= :visible (get-in state [:notification :status]))))))))
|
||
|
||
(t/deftest ^:async persistence-notifications-include-an-error-report-download
|
||
(let [scheduled (atom [])
|
||
idle-callbacks (atom [])
|
||
events (atom [])
|
||
downloads (atom [])
|
||
revoked (atom [])
|
||
report "generated error report"
|
||
cause (ex-info "Save failed" {:type :validation})]
|
||
(await
|
||
(mock/with-mocks*
|
||
{dom/prevent-default (fn [_])
|
||
dom/trigger-download-uri (fn [& params]
|
||
(swap! downloads conj params))
|
||
errors/generate-report (fn [_ & _] report)
|
||
errors/submit-report (fn [& _])
|
||
;; `tr` is called with one and with two arguments, and its
|
||
;; two-argument arity is variadic: the stub exposes both
|
||
;; shapes so the compiled static calls resolve.
|
||
i18n/tr (fn ([key] (str key ":"))
|
||
([key & args]
|
||
(str key ":" (first args))))
|
||
st/emit! (mock/stub (fn [& emitted]
|
||
(swap! events into emitted)))
|
||
tm/schedule (mock/stub (fn [callback]
|
||
(swap! scheduled conj callback)))
|
||
tm/schedule-on-idle (mock/stub (fn [callback]
|
||
(swap! idle-callbacks conj callback)))
|
||
wapi/create-blob (mock/stub (fn [content media-type]
|
||
{:content content :media-type media-type}))
|
||
wapi/create-uri (fn [_] "blob:report")
|
||
wapi/revoke-uri (fn [uri]
|
||
(swap! revoked conj uri))}
|
||
(errors/flash-persistence cause)
|
||
(doseq [callback @scheduled] (callback))
|
||
(await (async/settle))
|
||
;; The report is emitted first and the toast right after, so the
|
||
;; toast carrying the download link is the last event collected.
|
||
(let [state (ptk/update (last @events) {})
|
||
download (get-in state [:notification :links 0])]
|
||
(t/is (= "labels.download:report.txt" (:label download)))
|
||
((:callback download) nil)
|
||
(t/is (= [["report" "text/plain" "blob:report"]] @downloads))
|
||
(doseq [callback @idle-callbacks] (callback))
|
||
(t/is (= ["blob:report"] @revoked))
|
||
;; Autosave is stopped for the whole session, so the toast also
|
||
;; offers the only action that restarts it without the user having
|
||
;; to know they could leave the file and come back.
|
||
(let [reload (get-in state [:notification :links 1])]
|
||
(t/is (= "labels.reload-page:" (:label reload))
|
||
"the `tr` stub renders a one-argument key with a trailing colon")
|
||
(t/is (fn? (:callback reload)) "the reload link has a callback")))))))
|
||
|
||
;; Scenario: a save fails terminally. The toast offers the report download
|
||
;; and a reload. Proves: the recovery action reaches the user.
|
||
;;
|
||
;; The reload callback itself is not invoked here: `js/location` does not
|
||
;; exist under the test runner, so the link is verified by its label and by
|
||
;; being a callable, not by calling it.
|
||
(t/deftest ^:async save-failure-toast-offers-a-reload-action
|
||
(let [events (atom [])]
|
||
(await
|
||
(mock/with-mocks*
|
||
{errors/generate-report (fn [_ & _] "generated error report")
|
||
st/emit! (mock/stub (fn [& emitted]
|
||
(swap! events into emitted)))
|
||
tm/schedule (mock/stub (fn [callback] (callback)))}
|
||
(errors/flash-persistence (ex-info "Save failed"
|
||
{:type :validation :hint "Save failed"}))
|
||
(await (async/settle))
|
||
;; The report is emitted first and the toast right after, so the toast
|
||
;; is the last event collected.
|
||
(let [state (ptk/update (last @events) {})
|
||
links (get-in state [:notification :links])]
|
||
;; `tr` is not stubbed here, and the test runner does not load the
|
||
;; catalogue, so a key comes back verbatim without its arguments.
|
||
(t/is (= ["labels.download" "labels.reload-page"] (mapv :label links))
|
||
"the toast carries the report and a reload, download first")
|
||
(t/is (every? #(fn? (:callback %)) links)
|
||
"both actions are callable")
|
||
(t/is (nil? (get-in state [:notification :timeout]))
|
||
"the toast stays until the user acts on it"))))))
|
||
|
||
;; Scenario: a save fails on the transport and the queue keeps trying on
|
||
;; the slow cycle. The toast says the changes are kept and offers no reload,
|
||
;; which would lose them. Proves: the retrying variant tells the truth.
|
||
(t/deftest ^:async retrying-save-failure-toast-offers-no-reload
|
||
(let [events (atom [])]
|
||
(await
|
||
(mock/with-mocks*
|
||
{errors/generate-report (fn [_ & _] "generated error report")
|
||
st/emit! (mock/stub (fn [& emitted]
|
||
(swap! events into emitted)))
|
||
tm/schedule (mock/stub (fn [callback] (callback)))}
|
||
(errors/flash-persistence (ex-info "Save failed"
|
||
{:type :gateway-error :hint "Save failed"})
|
||
:retrying true)
|
||
(await (async/settle))
|
||
(let [state (ptk/update (last @events) {})]
|
||
(t/is (= "errors.save-still-retrying" (get-in state [:notification :content])))
|
||
(t/is (= ["labels.download"] (mapv :label (get-in state [:notification :links])))
|
||
"the toast carries the report and no reload")
|
||
(t/is (= errors/persistence-failed-tag (get-in state [:notification :tag]))
|
||
"a save that lands can hide it")
|
||
(t/is (nil? (get-in state [:notification :timeout]))))))))
|
||
|
||
(t/deftest ^:async persistence-waiters-do-not-report-an-already-handled-failure
|
||
(let [reports (atom [])
|
||
rejected (atom [])
|
||
pstate (atom {:status :saving})
|
||
store (ptk/store {:state {} :on-error errors/on-error})
|
||
cause (ex-info "Save failed" {:type :network})]
|
||
(await
|
||
(mock/with-mocks*
|
||
{refs/persistence pstate
|
||
st/emit! (mock/stub (fn [& emitted]
|
||
(swap! reports into
|
||
(report-events emitted))))
|
||
;; Run the scheduled `flash` body synchronously: the whole flow
|
||
;; under test is synchronous except for the deferral.
|
||
tm/schedule (mock/stub (fn [f] (f)))}
|
||
(try
|
||
(ptk/emit! store (#'dps/persistence-failed (uuid/next) cause))
|
||
(reset! pstate (:persistence @store))
|
||
(dotimes [_ 2]
|
||
(->> (dps/wait-persisted-or-error)
|
||
(rx/subs! (fn [_] (t/is false "A failed save must still reject"))
|
||
(fn [error]
|
||
(swap! rejected conj error)
|
||
(errors/on-error error)))))
|
||
(await (async/settle))
|
||
(t/is (= 2 (count @rejected)))
|
||
(t/is (= 1 (count @reports)))
|
||
;; A standalone timeout and a later save failure are new incidents.
|
||
;; The later failure carries a distinct signature: repeating the same
|
||
;; one inside the governor window is coalesced by design.
|
||
(errors/on-error (ex-info "Save timed out" {:type :persistence :code :save-timeout}))
|
||
(await (async/settle))
|
||
(t/is (= 2 (count @reports)))
|
||
(ptk/emit! store (#'dps/persistence-failed (uuid/next)
|
||
(ex-info "Save failed again" {:type :network})))
|
||
(await (async/settle))
|
||
(t/is (= 3 (count @reports)))
|
||
(finally
|
||
(rx/dispose! store)))))))
|
||
|
||
;; ---------------------------------------------------------------------------
|
||
;; Save failures that belong to their own handler
|
||
;;
|
||
;; Some causes are not resolved by retaining the changes: the session has to
|
||
;; be renewed, the file is gone, or a different version was restored. Each
|
||
;; one is handled by the error handler for its own type.
|
||
;; ---------------------------------------------------------------------------
|
||
|
||
(t/deftest expired-session-during-save-goes-to-the-authentication-handler
|
||
(t/testing "a lost session is handled as an authentication error, not notified"
|
||
(let [assigned (atom nil)
|
||
scheduled (atom [])]
|
||
(with-redefs [rt/get-current-href (constantly workspace-href)
|
||
rt/assign-exception (fn [error] error)
|
||
st/async-emit! (fn [& events] (reset! assigned (first events)))
|
||
tm/schedule (mock/stub (fn [callback]
|
||
(swap! scheduled conj callback)))]
|
||
(errors/flash-persistence (ex-info "Session expired" {:type :authentication}))
|
||
(t/is (= :authentication (:type @assigned)))
|
||
(t/is (empty? @scheduled) "An expired session shows no save notification")))))
|
||
|
||
(t/deftest ^:async expired-organization-sso-during-save-renews-the-session
|
||
(t/testing "an SSO-guarded save failure goes back through the identity provider"
|
||
(let [events (atom [])]
|
||
(await
|
||
(mock/with-mocks*
|
||
{rp/cmd! (mock/stub
|
||
(fn [_command _params]
|
||
(rx/of {:authorized false
|
||
:redirect-uri "https://idp.example.com/authorize"})))
|
||
rt/get-current-href (constantly workspace-href)
|
||
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))}
|
||
(errors/flash-persistence (ex-info "SSO required" (sso-required-error)))
|
||
(await (async/settle))
|
||
(t/is (= [::rt/nav-raw] (mapv ptk/type @events))))))))
|
||
|
||
(t/deftest a-deleted-file-during-save-shows-the-exception-page
|
||
(t/testing "a file that no longer exists shows its page, not the save notification"
|
||
(let [events (atom [])
|
||
scheduled (atom [])]
|
||
(with-redefs [rt/assign-exception (fn [error] error)
|
||
st/emit! (mock/stub (fn [& emitted]
|
||
(swap! events into emitted)))
|
||
tm/schedule (mock/stub (fn [callback]
|
||
(swap! scheduled conj callback)))]
|
||
(errors/flash-persistence (ex-info "File not found" {:type :not-found}))
|
||
(doseq [callback @scheduled] (callback))
|
||
(t/is (= [:not-found] (mapv :type @events)))))))
|
||
|
||
(t/deftest a-restored-version-during-save-reloads-the-file
|
||
(t/testing "a version restored elsewhere reloads the file instead of notifying"
|
||
(let [events (atom [])
|
||
scheduled (atom [])]
|
||
(with-redefs [st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
|
||
tm/schedule (mock/stub (fn [callback]
|
||
(swap! scheduled conj callback)))]
|
||
(errors/flash-persistence (ex-info "A different version has been restored"
|
||
{:type :validation :code :vern-conflict}))
|
||
(t/is (= [::dw/reload-current-file] (mapv ptk/type @events)))
|
||
(t/is (empty? @scheduled) "A restored version shows no save notification")))))
|
||
|
||
(t/deftest ^:async save-failure-report-type-follows-the-cause
|
||
;; The user still gets the same sticky toast and report link; only the audit
|
||
;; event name changes, and that is what decides whether the incident reaches
|
||
;; the internal reports and the alerts.
|
||
(t/testing "a validation failure is a defect, so it reports unhandled"
|
||
(t/is (= ["unhandled-exception"]
|
||
(await (report-names {:type :validation :code :invalid-data})))))
|
||
|
||
(t/testing "the wrapped shape reads the cause, not the wrapper"
|
||
;; This is the shape that actually reaches flash-persistence:
|
||
;; persistence-failed sets :type :persistence and keeps the real type
|
||
;; under :cause-type. Reading :type alone would match the wrapper and
|
||
;; leave the cause as handled.
|
||
(t/is (= ["unhandled-exception"]
|
||
(await (report-names {:type :persistence
|
||
:cause-type :validation
|
||
:code :invalid-data})))))
|
||
|
||
(t/testing "environment failures stay handled after the retry budget"
|
||
;; They land here once the backoff is spent, and the user's connection is
|
||
;; not our bug, so they must not alert.
|
||
(doseq [cause-type [:network :offline :bad-gateway :service-unavailable
|
||
:gateway-error]]
|
||
(t/is (= ["handled-exception"]
|
||
(await (report-names {:type :persistence
|
||
:cause-type cause-type})))
|
||
(str cause-type " is an environment failure, not a defect"))))
|
||
|
||
(t/testing "responses the backend never wrote stay handled"
|
||
(doseq [cause-type [:rate-limit :unexpected-response]]
|
||
(t/is (= ["handled-exception"]
|
||
(await (report-names {:type :persistence
|
||
:cause-type cause-type})))
|
||
(str cause-type " comes from a proxy, not from our code"))))
|
||
|
||
(t/testing "an unclassified failure keeps the handled path"
|
||
(t/is (= ["handled-exception"]
|
||
(await (report-names {:type :persistence
|
||
:cause-type :unknown-thing})))))
|
||
|
||
(t/testing "a bare :type still classifies, without a wrapper"
|
||
(t/is (= ["unhandled-exception"]
|
||
(await (report-names {:type :validation})))))
|
||
|
||
(t/testing "a server-side or internal defect reports unhandled"
|
||
(doseq [cause-type [:server-error :internal]]
|
||
(t/is (= ["unhandled-exception"]
|
||
(await (report-names {:type :persistence
|
||
:cause-type cause-type})))
|
||
(str cause-type " is our bug")))))
|
||
|
||
;; ---------------------------------------------------------------------------
|
||
;; transport errors
|
||
;; ---------------------------------------------------------------------------
|
||
|
||
(t/deftest ^:async transport-errors-are-handled-without-replacing-the-page
|
||
;; Scenario: a rate limit and an error page the backend did not write,
|
||
;; through the global handler. Proves: neither replaces the page, each is
|
||
;; a handled report followed by one toast.
|
||
(doseq [type [:rate-limit :unexpected-response]]
|
||
(errors/reset-report-governor!)
|
||
(let [events (atom [])
|
||
assigned (atom [])
|
||
cause (ex-info "http error"
|
||
{:type type
|
||
:hint "http error"
|
||
:status 429
|
||
:uri "https://design.penpot.app/api/main/methods/get-teams"})]
|
||
(await
|
||
(mock/with-mocks*
|
||
{st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
|
||
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")
|
||
rt/assign-exception (fn [error] (swap! assigned conj error) error)
|
||
tm/schedule (mock/stub (fn [f] (f)))
|
||
st/format-last-events (mock/stub (fn [& _] "(stub last events)"))}
|
||
(errors/on-error cause)
|
||
(await (async/settle))
|
||
(t/is (empty? @assigned)
|
||
(str type " must not replace the page with an error screen"))
|
||
(t/is (= ["handled-exception"]
|
||
(map (comp ::ev/name deref) (report-events @events)))
|
||
(str type " is reported as handled, not unhandled"))
|
||
(t/is (= 2 (count @events)) (str type " must show one notification")))))))
|