🐛 Report environment failures as compact audit events

Connectivity and gateway failures (network, offline, 502/503 and
nitrate configuration) are not application defects, but offline fell
through to :default and 502/503 rendered exception-page, so they
reached the internal error reports and alerts with the full payload
(stack plus the last events). They are now classified as environment
failures and reported as audit-only handled-exception events.

generate-report accepts an explicit :format, as keyword arguments or as
a trailing map. :compact keeps the context header plus type, code and
uri, and skips the stack, the ex-data dump (which may contain request
headers) and the last-events list. flash derives the payload format from
the cause, so environment failures get a compact report; the audit event
name stays the canonical one requested by the caller
(handled-exception/unhandled-exception) because external tooling filters
on those names. Environment fingerprints drop the stack frame, so
grouping does not depend on the internal call site.

submit-report now requires an exception cause: a report without one is
ignored instead of using a separate fallback fingerprint, so a single
fingerprint format governs every report.

:offline gets its own handler and both connectivity handlers show the
new errors.connection-error message instead of the generic toast.

Closes #11743

AI-assisted-by: deepseek-v4.1-flash
This commit is contained in:
Andrey Antukh 2026-09-16 08:22:49 +00:00
parent ee651b86d8
commit 95e551697f
7 changed files with 700 additions and 368 deletions

View File

@ -9,9 +9,10 @@
- The root app renders an exception page from `:exception` state before the normal error boundary. `rt/navigated` clears `:exception`. - The root app renders an exception page from `:exception` state before the normal error boundary. `rt/navigated` clears `:exception`.
- Frontend error handling treats stale cross-build JS chunk failures specially: messages containing `$cljs$cst$` or `$cljs$core$I` plus undefined/null/not-a-function signatures trigger throttled reload. - Frontend error handling treats stale cross-build JS chunk failures specially: messages containing `$cljs$cst$` or `$cljs$core$I` plus undefined/null/not-a-function signatures trigger throttled reload.
- Plugin-originated uncaught errors are identified through the plugin runtime hook and logged rather than turning into the global exception page. - Plugin-originated uncaught errors are identified through the plugin runtime hook and logged rather than turning into the global exception page.
- `app.main.errors/submit-report` is governed by a dedup governor: each report carries a fingerprint (`report-name|type|code|hint|first stack frame`; the report name is part of it so a handled report never coalesces with an unhandled/exception-page one), the first occurrence is always emitted, repeats within 2 minutes are counted and included in the next emitted report as `:occurrences`, and the fingerprint cache is bounded (first-inserted entry evicted, FIFO, via `:order` queue) so memory stays fixed. It applies to `handled-exception`, `unhandled-exception` and `exception-page`. - `app.main.errors/submit-report` is governed by a dedup governor: each report carries a fingerprint (`report-name|type|code|hint|first stack frame`; the report name is part of it so a handled report never coalesces with an unhandled/exception-page one), the first occurrence is always emitted, repeats within 2 minutes are counted and included in the next emitted report as `:occurrences`, and the fingerprint cache is bounded (first-inserted entry evicted, FIFO, via `:order` queue) so memory stays fixed. It applies to `handled-exception`, `unhandled-exception` and `exception-page`; a report without an exception cause is ignored and does not consume a reservation.
- `generate-report` is total: if formatting fails it returns a minimal fallback string instead of nil, so an already reserved emission is never dropped. - Errors caused by the environment (`environment-error-types`: `:network`, `:offline`, `:bad-gateway`, `:service-unavailable`, `:nitrate-unavailable`, `:nitrate-not-configured`) are not application defects: they are reported as audit-only `handled-exception` (never `unhandled-exception`/`exception-page`, so they skip internal reports and alerts), with a compact report and a fingerprint that drops the stack frame. `:offline` has its own handler and no longer falls through to `:default`; `:network`/`:offline` show the `errors.connection-error` toast.
- `flash` reserves the report before generating it, so suppressed occurrences do not pay the `generate-report` cost; the toast is unchanged. - `generate-report` accepts an explicit `{:format :compact|:full}` (default `:full`) chosen by its caller (`flash`, `exception-section*`): `:compact` keeps the context header plus type/code/uri, and skips the stack, the `ex-data` dump and the last-events list. It is total: if formatting fails it returns a minimal fallback string instead of nil, so an already reserved emission is never dropped.
- `flash` reserves the report before generating it, so suppressed occurrences do not pay the `generate-report` cost; the toast is unchanged. `flash` derives only the payload format from the cause (`environment-error?` → `:compact`); the audit event name is the canonical one requested by `:type` (`handled-exception`/`unhandled-exception`) and is never reclassified, because external tools filter on those names. `exception-section*` picks `handled-exception`/`exception-page` explicitly per cause; `flash-persistence` only adds the toast hint.
## Store and websocket ## Store and websocket

View File

@ -6,7 +6,7 @@ Frontend validation: CLJS + React/Rumext + RxJS/Potok; SCSS modules; shared CLJC
READ `mem:testing` FIRST — it defines the execution discipline (no piping, tee to file, preferred commands) that applies to all CLJS/JS test runs. READ `mem:testing` FIRST — it defines the execution discipline (no piping, tee to file, preferred commands) that applies to all CLJS/JS test runs.
Frontend unit tests live under `frontend/test/frontend_tests/` and use `cljs.test`. They should be deterministic, avoid DOM/UI integration where possible, and mock side effects such as RPC, storage, timers, or network access. Frontend unit tests live under `frontend/test/frontend_tests/` and use `cljs.test`. They should be deterministic, avoid DOM/UI integration where possible, and mock side effects such as RPC, storage, timers, or network access. Mock through `frontend-tests.helpers.mock`: prefer `mock/with-mocks` (installs with `set!`, so it survives async boundaries) over `with-redefs`. The `:esm` test build dispatches calls to multi-arity vars as `cljs$core$IFn$_invoke$arity$N`, so stub multi-arity vars with `mock/stub` (arities 0-6); for variadic call sites with more than 6 args use a plain variadic `fn` instead. A mock must not call the mocked var again (self-delegation inside a multi-arity function recurses). Async tests wrap the body in `t/async` and thread its `done` into `mock/with-mocks` as the outer callback; `done'` must be called exactly once (calling it twice only prints a warning; not calling it stalls the run and leaks the mocks).
From `frontend/`: From `frontend/`:
- Full unit test run (always builds, suppressed output): `pnpm run test:quiet`. - Full unit test run (always builds, suppressed output): `pnpm run test:quiet`.

View File

@ -127,35 +127,91 @@
;; Set the main potok error handler ;; Set the main potok error handler
(reset! st/on-error on-error) (reset! st/on-error on-error)
(defn generate-report ;; --- Environment failures
;;
;; Failures caused by the user's environment (connectivity, a degraded or
;; misconfigured service) are not application defects. They are reported to the
;; audit log as compact events and must never reach the internal error reports
;; (`unhandled-exception`/`exception-page`), which trigger alerts.
(def environment-error-types
"Error types produced by the environment rather than by an application
defect."
#{:network
:offline
:bad-gateway
:service-unavailable
:nitrate-unavailable
:nitrate-not-configured})
(defn environment-error?
[cause] [cause]
(contains? environment-error-types (:type (ex-data cause))))
(defn- report-context
"Common context header for every report format."
[cause]
(let [team-id (:current-team-id @st/state)
file-id (:current-file-id @st/state)
profile-id (:profile-id @st/state)
data (ex-data cause)]
(with-out-str
(println "Context:")
(println "--------------------")
(println "Timestamp:" (ct/format-inst (ct/now) :rfc1123))
(println "Hint: " (or (:hint data) (ex-message cause) "--"))
(println "Prof ID: " (str (or profile-id "--")))
(println "Team ID: " (str (or team-id "--")))
(when-let [file-id (or (:file-id data) file-id)]
(println "File ID: " (str file-id)))
(println "Version: " (:full cf/version))
(println "HREF: " (rt/get-current-href)))))
(defn- generate-full-report
"Complete report: context, formatted throwable (including `ex-data`) and the
last events."
[cause]
(with-out-str
(print (report-context cause))
(println)
(println
(ex/format-throwable cause))
(println)
(println "Last events:")
(println "--------------------")
(println (st/format-last-events))
(println)))
(defn- generate-compact-report
"Reduced report for environment failures: context plus the error type, code
and uri. It skips the stack trace, the `ex-data` dump (which may contain
request headers) and the last-events list."
[cause]
(let [data (ex-data cause)]
(with-out-str
(print (report-context cause))
(println)
(println "Error:")
(println "--------------------")
(println "Type: " (or (:type data) "--"))
(println "Code: " (or (:code data) "--"))
(when-let [uri (:uri data)]
(println "URI: " uri)))))
(defn generate-report
"Build the report string for `cause`.
`:format` selects the payload: `:full` (default) includes the formatted
throwable and the last events; `:compact` keeps only the context and the
error type/code/uri, for environment failures. The option is accepted both
as keyword arguments and as a trailing map."
[cause & {:keys [format] :or {format :full}}]
(try (try
(let [team-id (:current-team-id @st/state) (case format
file-id (:current-file-id @st/state) :compact (generate-compact-report cause)
profile-id (:profile-id @st/state) (generate-full-report cause))
data (ex-data cause)]
(with-out-str
(println "Context:")
(println "--------------------")
(println "Timestamp:" (ct/format-inst (ct/now) :rfc1123))
(println "Hint: " (or (:hint data) (ex-message cause) "--"))
(println "Prof ID: " (str (or profile-id "--")))
(println "Team ID: " (str (or team-id "--")))
(when-let [file-id (or (:file-id data) file-id)]
(println "File ID: " (str file-id)))
(println "Version: " (:full cf/version))
(println "HREF: " (rt/get-current-href))
(println)
(println
(ex/format-throwable cause))
(println)
(println "Last events:")
(println "--------------------")
(println (st/format-last-events))
(println)))
(catch :default err (catch :default err
(.error js/console "error on generating report" err) (.error js/console "error on generating report" err)
;; Keep this function total: `flash` reserves a report slot before ;; Keep this function total: `flash` reserves a report slot before
@ -212,23 +268,23 @@
The report name is part of the identity, so a `handled-exception` report The report name is part of the identity, so a `handled-exception` report
never coalesces with an `unhandled-exception`/`exception-page` report of never coalesces with an `unhandled-exception`/`exception-page` report of
the same cause (those two do reach the error reports and alerts)." the same cause (those two do reach the error reports and alerts).
Environment failures drop the stack frame: their internal call site is an
implementation detail, and keeping it would fragment the grouping."
[event-name cause] [event-name cause]
(let [data (ex-data cause) (let [data (ex-data cause)
ftype (or (:type data) :unknown) ftype (or (:type data) :unknown)
code (or (:code data) :unknown) code (or (:code data) :unknown)
hint (or (ex/get-hint cause) "") hint (or (ex/get-hint cause) "")
;; A JS stack string starts with "Error: <message>"; the first base (str (label event-name) "|" (label ftype) "|" (label code) "|"
;; actual frame is the second line. (str/prune hint 120))]
frame (or (some-> (.-stack cause) (str/lines) (second)) "")] (if (environment-error? cause)
(str (label event-name) "|" (label ftype) "|" (label code) "|" base
(str/prune hint 120) "|" (str/prune frame 120)))) (let [;; A JS stack string starts with "Error: <message>"; the first
;; actual frame is the second line.
(defn fallback-fingerprint frame (or (some-> (.-stack cause) (str/lines) (second)) "")]
"Fingerprint for reports submitted without a `cause` (e.g. the exception (str base "|" (str/prune frame 120))))))
page or a stalled save)."
[event-name hint]
(str (label event-name) "|" (str/prune (or hint "") 120)))
(defn- evict-oldest (defn- evict-oldest
"Drops the fingerprint inserted first. `:order` mirrors the insertion "Drops the fingerprint inserted first. `:order` mirrors the insertion
@ -297,15 +353,18 @@
(defn submit-report (defn submit-report
"Report the error report to the audit log subsystem, subject to the "Report the error report to the audit log subsystem, subject to the
report governor." report governor.
`cause` must be the exception the report describes: a report without a
cause is ignored (and does not consume a governor reservation), so every
report shares the same fingerprint format."
[& {:keys [event-name report hint cause] [& {:keys [event-name report hint cause]
:or {event-name "unhandled-exception"}}] :or {event-name "unhandled-exception"}}]
(when (and (not (str/empty? hint)) (when (and (ex/exception? cause)
(not (str/empty? hint))
(string? report) (string? report)
(string? event-name)) (string? event-name))
(let [state (reserve-report! (if (ex/exception? cause) (let [state (reserve-report! (error-fingerprint event-name cause)
(error-fingerprint event-name cause)
(fallback-fingerprint event-name hint))
(inst-ms (ct/now)))] (inst-ms (ct/now)))]
(when (::emit state) (when (::emit state)
(emit-report! event-name report hint (::occurrences state)))))) (emit-report! event-name report hint (::occurrences state))))))
@ -322,6 +381,11 @@
"Show error notification banner and emit error report. "Show error notification banner and emit error report.
A nil timeout keeps the notification visible until dismissed or replaced. A nil timeout keeps the notification visible until dismissed or replaced.
The payload format is derived from the cause: environment failures get a
compact report. The audit event name is the canonical one requested by
`:type` (`handled-exception`/`unhandled-exception`); it is an external
contract, so flash never reclassifies it.
The report is reserved before being generated, so repeated errors that The report is reserved before being generated, so repeated errors that
fall inside the governor window do not pay the report-building cost. fall inside the governor window do not pay the report-building cost.
@ -338,11 +402,12 @@
:handled "handled-exception" :handled "handled-exception"
:unhandled "unhandled-exception" :unhandled "unhandled-exception"
:silent nil)] :silent nil)]
(let [report-hint (ex/get-hint cause)] (let [format (if (environment-error? cause) :compact :full)
report-hint (ex/get-hint cause)]
(when (and (string? report-hint) (not (str/empty? report-hint))) (when (and (string? report-hint) (not (str/empty? report-hint)))
(let [state (reserve-report! (error-fingerprint event-name cause) (inst-ms (ct/now)))] (let [state (reserve-report! (error-fingerprint event-name cause) (inst-ms (ct/now)))]
(when (::emit state) (when (::emit state)
(let [generated (generate-report cause)] (let [generated (generate-report cause {:format format})]
(emit-report! event-name (emit-report! event-name
generated generated
report-hint report-hint
@ -360,14 +425,29 @@
(assoc :links [{:label (tr "labels.download" "report.txt") (assoc :links [{:label (tr "labels.download" "report.txt")
:callback (partial download-report! report)}]))))))) :callback (partial download-report! report)}])))))))
(defn- handle-connectivity-error
"Report a failure caused by the user's connectivity. These are audit-only
telemetry with a compact payload: they never reach the internal error
reports and a stack trace adds nothing for a network condition."
[error prefix]
(when-let [cause (::instance error)]
(ex/print-throwable cause :prefix prefix))
(flash :cause (::instance error)
:type :handled
:hint (tr "errors.connection-error")))
(defmethod ptk/handle-error :network (defmethod ptk/handle-error :network
[error] [error]
;; Transient network errors (e.g. lost connectivity, DNS failure) ;; Transient network errors (e.g. lost connectivity, DNS failure)
;; should not replace the entire page with an error screen. Show a ;; should not replace the entire page with an error screen. Show a
;; non-intrusive toast instead and let the user continue working. ;; non-intrusive toast instead and let the user continue working.
(when-let [cause (::instance error)] (handle-connectivity-error error "Network Error"))
(ex/print-throwable cause :prefix "Network Error"))
(flash :cause (::instance error) :type :handled)) (defmethod ptk/handle-error :offline
[error]
;; Status 0 (browser offline) must not fall through to `:default`:
;; that would report it as an unhandled application error.
(handle-connectivity-error error "Offline Error"))
(def ^:private delegated-persistence-types (def ^:private delegated-persistence-types
"Save failure causes routed to their own error handler: retaining the "Save failure causes routed to their own error handler: retaining the

View File

@ -551,19 +551,22 @@
(mf/defc exception-section* (mf/defc exception-section*
{::mf/private true} {::mf/private true}
[{:keys [data] :as props}] [{:keys [data] :as props}]
(let [type (get data :type) (let [type (get data :type)
cause (get data ::errors/instance) cause (get data ::errors/instance)
environment (errors/environment-error? cause)
report (mf/with-memo [cause] report (mf/with-memo [cause]
(when (ex/exception? cause) (when (ex/exception? cause)
(errors/generate-report cause))) (errors/generate-report cause {:format (if environment :compact :full)})))
props (mf/spread-props props {:report report})] props (mf/spread-props props {:report report})]
(mf/with-effect [report type cause] (mf/with-effect [report type cause]
(when (and (ex/exception? cause) (when (and (ex/exception? cause)
(not (contains? #{:not-found :authentication} type))) (not (contains? #{:not-found :authentication} type)))
(errors/submit-report :event-name "exception-page" ;; Environment pages are audit-only: they use the canonical
;; `handled-exception` event instead of `exception-page`.
(errors/submit-report :event-name (if environment "handled-exception" "exception-page")
:report report :report report
:hint (ex/get-hint cause) :hint (ex/get-hint cause)
:cause cause))) :cause cause)))

View File

@ -33,37 +33,43 @@
:undo-changes []})) :undo-changes []}))
(t/deftest queued-edits-save-during-temporary-read-only-mode (t/deftest queued-edits-save-during-temporary-read-only-mode
(doseq [read-only-event [(drw/context-lost) (t/async done
#(assoc-in % [:workspace-global :read-only?] true) (doseq [read-only-event [(drw/context-lost)
#(assoc % :workspace-global {:read-only? true #(assoc-in % [:workspace-global :read-only?] true)
:preview-id (uuid/next)})]] #(assoc % :workspace-global {:read-only? true
(let [file-id (uuid/next) :preview-id (uuid/next)})]]
response (rx/subject) (let [file-id (uuid/next)
errors (atom []) response (rx/subject)
store (ptk/store {:state {:permissions {:can-edit true} errors (atom [])
:files {file-id {:id file-id :revn 0}}} store (ptk/store {:state {:permissions {:can-edit true}
:on-error #(swap! errors conj %)})] :files {file-id {:id file-id :revn 0}}}
(with-redefs [rp/cmd! (mock/stub (fn [_ _] (rx/take 1 response)))] :on-error #(swap! errors conj %)})]
(try (mock/with-mocks
(ptk/emit! store (dps/initialize-persistence) {rp/cmd! (mock/stub (fn [_ _] (rx/take 1 response)))}
(local-commit file-id) (fn [section-done]
read-only-event (try
::dps/force-persist) (ptk/emit! store (dps/initialize-persistence)
(rx/push! response {:revn 1}) (local-commit file-id)
(t/is (= :saved (get-in @store [:persistence :status]))) read-only-event
(t/is (empty? (get-in @store [:persistence :queue]))) ::dps/force-persist)
(rx/push! response {:revn 1})
(t/is (= :saved (get-in @store [:persistence :status])))
(t/is (empty? (get-in @store [:persistence :queue])))
(ptk/emit! store (drw/context-restored) (ptk/emit! store (drw/context-restored)
#(assoc-in % [:workspace-global :read-only?] false) #(assoc-in % [:workspace-global :read-only?] false)
(local-commit file-id) (local-commit file-id)
::dps/force-persist) ::dps/force-persist)
(rx/push! response {:revn 2}) (rx/push! response {:revn 2})
(t/is (= :saved (get-in @store [:persistence :status]))) (t/is (= :saved (get-in @store [:persistence :status])))
(t/is (empty? (get-in @store [:persistence :queue]))) (t/is (empty? (get-in @store [:persistence :queue])))
(t/is (empty? @errors)) (t/is (empty? @errors))
(finally (finally
(rx/dispose! store) (rx/dispose! store)
(rx/end! response))))))) (rx/end! response)
(section-done))))
(fn []))))
(done)))
(t/deftest historical-preview-cannot-create-local-commits (t/deftest historical-preview-cannot-create-local-commits
(let [file-id (uuid/next) (let [file-id (uuid/next)
@ -78,128 +84,155 @@
(t/is (empty? @output)))) (t/is (empty? @output))))
(defn- with-watchdog (defn- with-watchdog
[f] "Async fixture for the persistence watchdog tests: mocks the clock, timers
and RPC transport, and runs `f` with persistence initialized.
Mocks are installed through `mock/with-mocks` and restored when the test
completes; `done'` chains to the `t/async` `done` and is always called
exactly once after teardown, so a failing body can neither leak the mocks
nor stall the test run."
[f done]
(let [clock (atom 0) (let [clock (atom 0)
ticks (rx/subject) ticks (rx/subject)
response (rx/subject) response (rx/subject)
reports (atom []) reports (atom [])
causes (atom []) causes (atom [])
render errors/generate-report
file-id (uuid/next) file-id (uuid/next)
store (ptk/store {:state {:current-file-id file-id store (ptk/store {:state {:current-file-id file-id
:permissions {:can-edit true} :permissions {:can-edit true}
:files {file-id {:id file-id :revn 0}}} :files {file-id {:id file-id :revn 0}}}
:on-error #(t/is false (str %))})] :on-error #(t/is false (str %))})]
(with-redefs [ct/now (mock/stub #(ct/inst @clock)) (mock/with-mocks
rx/interval (mock/stub (fn [_] ticks)) {ct/now (mock/stub #(ct/inst @clock))
rp/cmd! (mock/stub (fn [_ _] (rx/take 1 response))) rx/interval (mock/stub (fn [_] ticks))
st/state store rp/cmd! (mock/stub (fn [_ _] (rx/take 1 response)))
errors/generate-report (fn [cause] st/state store
(swap! causes conj cause) errors/generate-report (fn [cause & _]
(render cause)) (swap! causes conj cause)
errors/submit-report (fn [& params] "report")
(swap! reports conj (apply hash-map params)))] errors/submit-report (fn [& params]
(try (swap! reports conj (apply hash-map params)))}
(ptk/emit! store (dps/initialize-persistence)) (fn [done']
(f {:clock clock :ticks ticks :response response :causes causes (try
:reports reports :store store :file-id file-id}) (ptk/emit! store (dps/initialize-persistence))
(finally (f {:clock clock :ticks ticks :response response :causes causes
(rx/dispose! store) :reports reports :store store :file-id file-id})
(rx/end! ticks) (finally
(rx/end! response)))))) (rx/dispose! store)
(rx/end! ticks)
(rx/end! response)
(done'))))
done)))
(t/deftest stalled-request-is-reported-once-without-discarding-edits (t/deftest stalled-request-is-reported-once-without-discarding-edits
(with-watchdog (t/async done
(fn [{:keys [clock ticks reports causes store file-id]}] (with-watchdog
(ptk/emit! store (local-commit file-id) ::dps/force-persist) (fn [{:keys [clock ticks reports causes store file-id]}]
(reset! clock 300000) (ptk/emit! store (local-commit file-id) ::dps/force-persist)
(rx/push! ticks :tick) (reset! clock 300000)
(t/is (empty? @reports) "Five minutes must elapse before reporting") (rx/push! ticks :tick)
(t/is (empty? @reports) "Five minutes must elapse before reporting")
;; More local edits must not reset the stalled request's clock. ;; More local edits must not reset the stalled request's clock.
(reset! clock 300001) (reset! clock 300001)
(ptk/emit! store (drw/context-lost) (ptk/emit! store (drw/context-lost)
(local-commit file-id) ::dps/force-persist) (local-commit file-id) ::dps/force-persist)
(rx/push! ticks :tick) (rx/push! ticks :tick)
(t/is (= 1 (count @reports))) (t/is (= 1 (count @reports)))
(t/is (= "handled-exception" (:event-name (first @reports)))) (t/is (= "handled-exception" (:event-name (first @reports))))
(let [data (ex-data (first @causes))] (let [data (ex-data (first @causes))]
(t/is (= :saving-stalled (:code data))) (t/is (= :saving-stalled (:code data)))
(t/is (= file-id (:file-id data))) (t/is (= file-id (:file-id data)))
(t/is (true? (:render-context-lost? data)))) (t/is (true? (:render-context-lost? data))))
(reset! clock 900000) (reset! clock 900000)
(rx/push! ticks :tick) (rx/push! ticks :tick)
(t/is (= 1 (count @reports)) "Do not repeat a report for the same stall") (t/is (= 1 (count @reports)) "Do not repeat a report for the same stall")
(t/is (= :saving (get-in @store [:persistence :status]))) (t/is (= :saving (get-in @store [:persistence :status])))
(t/is (= 2 (count (get-in @store [:persistence :queue]))))))) (t/is (= 2 (count (get-in @store [:persistence :queue])))))
done)))
(t/deftest successful-saves-reset-the-stall-clock-and-allow-a-new-report (t/deftest successful-saves-reset-the-stall-clock-and-allow-a-new-report
(with-watchdog (t/async done
(fn [{:keys [clock ticks response reports store file-id]}] (with-watchdog
(ptk/emit! store (local-commit file-id) ::dps/force-persist (fn [{:keys [clock ticks response reports store file-id]}]
(local-commit file-id) ::dps/force-persist) (ptk/emit! store (local-commit file-id) ::dps/force-persist
(reset! clock 290000) (local-commit file-id) ::dps/force-persist)
(rx/push! response {:revn 1}) (reset! clock 290000)
(reset! clock 300001) (rx/push! response {:revn 1})
(rx/push! ticks :tick) (reset! clock 300001)
(t/is (empty? @reports) "The queue is making progress") (rx/push! ticks :tick)
(t/is (empty? @reports) "The queue is making progress")
(reset! clock 590001) (reset! clock 590001)
(rx/push! ticks :tick) (rx/push! ticks :tick)
(t/is (= 1 (count @reports)) "The second request has now stalled") (t/is (= 1 (count @reports)) "The second request has now stalled")
(rx/push! response {:revn 2}) (rx/push! response {:revn 2})
(t/is (= :saved (get-in @store [:persistence :status]))) (t/is (= :saved (get-in @store [:persistence :status])))
(reset! clock 1000000) (reset! clock 1000000)
(rx/push! ticks :tick) (rx/push! ticks :tick)
(t/is (= 1 (count @reports)) "A saved file must not be reported") (t/is (= 1 (count @reports)) "A saved file must not be reported")
(ptk/emit! store (local-commit file-id) ::dps/force-persist) (ptk/emit! store (local-commit file-id) ::dps/force-persist)
(reset! clock 1300001) (reset! clock 1300001)
(rx/push! ticks :tick) (rx/push! ticks :tick)
(t/is (= 2 (count @reports)) "A later stall gets its own report")))) (t/is (= 2 (count @reports)) "A later stall gets its own report"))
done)))
(t/deftest pending-edits-are-monitored-without-extending-the-deadline (t/deftest pending-edits-are-monitored-without-extending-the-deadline
(with-watchdog (t/async done
(fn [{:keys [clock ticks reports store]}] (with-watchdog
(rx/push! ticks :tick) (fn [{:keys [clock ticks reports store]}]
(t/is (empty? @reports) "An idle file must not be reported") (rx/push! ticks :tick)
(ptk/emit! store (#'dps/update-status :pending)) (t/is (empty? @reports) "An idle file must not be reported")
(reset! clock 300001) (ptk/emit! store (#'dps/update-status :pending))
(ptk/emit! store (#'dps/update-status :pending)) (reset! clock 300001)
(rx/push! ticks :tick) (ptk/emit! store (#'dps/update-status :pending))
(t/is (= 1 (count @reports))) (rx/push! ticks :tick)
(ptk/emit! store (#'dps/update-status :error)) (t/is (= 1 (count @reports)))
(reset! clock 900000) (ptk/emit! store (#'dps/update-status :error))
(rx/push! ticks :tick) (reset! clock 900000)
(t/is (= 1 (count @reports)) "Do not report an already failed save")))) (rx/push! ticks :tick)
(t/is (= 1 (count @reports)) "Do not report an already failed save"))
done)))
(t/deftest reinitializing-persistence-replaces-the-watchdog (t/deftest reinitializing-persistence-replaces-the-watchdog
(let [active-timers (atom 0) (t/async done
ticks (rx/subject) (let [active-timers (atom 0)
store (ptk/store {:state {} :on-error #(t/is false (str %))})] ticks (rx/subject)
(with-redefs [rx/interval (mock/stub store (ptk/store {:state {} :on-error #(t/is false (str %))})]
(fn [_] (mock/with-mocks
(rx/create {rx/interval (mock/stub
(fn [subscriber] (fn [_]
(swap! active-timers inc) (rx/create
(let [subscription (.subscribe ticks subscriber)] (fn [subscriber]
(fn [] (swap! active-timers inc)
(rx/dispose! subscription) (let [subscription (.subscribe ticks subscriber)]
(swap! active-timers dec)))))))] (fn []
(try (rx/dispose! subscription)
(ptk/emit! store (dps/initialize-persistence)) (swap! active-timers dec)))))))}
(t/is (= 1 @active-timers)) (fn [section-done]
(ptk/emit! store (dps/initialize-persistence)) (try
(t/is (= 1 @active-timers)) (ptk/emit! store (dps/initialize-persistence))
(finally (t/is (= 1 @active-timers))
(rx/dispose! store) (ptk/emit! store (dps/initialize-persistence))
(rx/end! ticks)))) (t/is (= 1 @active-timers))
(t/is (zero? @active-timers)))) (finally
(rx/dispose! store)
(rx/end! ticks)
(t/is (zero? @active-timers))
(section-done))))
done))))
(defn- with-persistence (defn- with-persistence
[f] "Async fixture for the persistence tests: mocks the transport and flash, and
runs `f` with persistence initialized.
Like `with-watchdog`, `done'` chains to the `t/async` `done` and is called
exactly once after teardown. Inside a `doseq`, pass a no-op completion
(`(fn [])`) and call the test's `done` once at the end."
[f done]
(let [file-id (uuid/next) (let [file-id (uuid/next)
response (rx/subject) response (rx/subject)
failures (atom []) failures (atom [])
@ -208,171 +241,214 @@
:permissions {:can-edit true} :permissions {:can-edit true}
:files {file-id {:id file-id :revn 0}}} :files {file-id {:id file-id :revn 0}}}
:on-error #(t/is false (str %))})] :on-error #(t/is false (str %))})]
(with-redefs [rp/cmd! (mock/stub (fn [cmd params] (mock/with-mocks
(swap! requests conj [cmd params]) {rp/cmd! (mock/stub (fn [cmd params]
(rx/take 1 response))) (swap! requests conj [cmd params])
errors/flash (fn [& {:keys [cause]}] (rx/take 1 response)))
(swap! failures conj cause))] errors/flash (fn [& {:keys [cause]}]
(try (swap! failures conj cause))}
(ptk/emit! store (dps/initialize-persistence)) (fn [done']
(f {:file-id file-id :response response :failures failures (try
:requests requests :store store}) (ptk/emit! store (dps/initialize-persistence))
(finally (f {:file-id file-id :response response :failures failures
(rx/dispose! store) :requests requests :store store})
(rx/end! response)))))) (finally
(rx/dispose! store)
(rx/end! response)
(done'))))
done)))
(t/deftest permission-loss-fails-without-discarding-queued-edits (t/deftest permission-loss-fails-without-discarding-queued-edits
(with-persistence (t/async done
(fn [{:keys [file-id requests failures store]}] (with-persistence
(ptk/emit! store (local-commit file-id) (fn [{:keys [file-id requests failures store]}]
#(assoc-in % [:permissions :can-edit] false) (ptk/emit! store (local-commit file-id)
::dps/force-persist) #(assoc-in % [:permissions :can-edit] false)
(t/is (= :error (get-in @store [:persistence :status]))) ::dps/force-persist)
(t/is (= 1 (count (get-in @store [:persistence :queue])))) (t/is (= :error (get-in @store [:persistence :status])))
(t/is (empty? @requests)) (t/is (= 1 (count (get-in @store [:persistence :queue]))))
(t/is (= 1 (count @failures))) (t/is (empty? @requests))
(ptk/emit! store (local-commit file-id) ::dps/force-persist (t/is (= 1 (count @failures)))
(#'dps/update-status :pending)) (ptk/emit! store (local-commit file-id) ::dps/force-persist
(t/is (= :error (get-in @store [:persistence :status]))) (#'dps/update-status :pending))
(t/is (= 2 (count (get-in @store [:persistence :queue]))))))) (t/is (= :error (get-in @store [:persistence :status])))
(t/is (= 2 (count (get-in @store [:persistence :queue])))))
done)))
(t/deftest failed-request-retains-the-queue-and-is-not-retried-on-initialization (t/deftest failed-request-retains-the-queue-and-is-not-retried-on-initialization
(with-persistence (t/async done
(fn [{:keys [file-id response requests store]}]
(ptk/emit! store (local-commit file-id) ::dps/force-persist
(local-commit file-id) ::dps/force-persist)
(.error response (ex-info "Connection lost" {:type :network}))
(ptk/emit! store (dps/initialize-persistence))
(t/is (= :error (get-in @store [:persistence :status])))
(t/is (= 2 (count (get-in @store [:persistence :queue]))))
(t/is (= 1 (count @requests))))))
(t/deftest save-failures-use-a-translated-warning-except-for-authentication
(doseq [cause-type [:network :offline :authentication]]
(with-persistence
(fn [{:keys [file-id response store]}]
(let [notifications (atom [])]
(with-redefs [errors/flash (fn [& params]
(swap! notifications conj (apply hash-map params)))
i18n/tr (mock/stub #(str "translated:" %))]
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
(.error response (ex-info "Raw transport details" {:type cause-type}))
(t/is (= :error (get-in @store [:persistence :status])))
(let [data (get-in @store [:persistence :error])]
(ptk/handle-error (assoc data ::errors/instance (ex-info "Save failed" data))))
(if (= cause-type :authentication)
(t/is (empty? @notifications))
(t/is (= ["translated:errors.save-failed"]
(mapv :hint @notifications))))))))))
(t/deftest missing-commit-is-an-error-instead-of-skipping-changes
(with-persistence
(fn [{:keys [requests store]}]
(let [id (uuid/next)]
(ptk/emit! store
#(assoc % :persistence {:queue (conj #queue [] id)
:index {} :run-id (uuid/next)
:status :saving})
(dps/initialize-persistence))
(t/is (= :error (get-in @store [:persistence :status])))
(t/is (= [id] (vec (get-in @store [:persistence :queue]))))
(t/is (empty? @requests))))))
(t/deftest initialization-recovers-an-unsent-commit-with-a-dangling-run-id
(with-persistence
(fn [{:keys [file-id response requests store]}]
(let [commit (assoc @(local-commit file-id) :changes [])
id (:id commit)]
(ptk/emit! store
#(assoc % :persistence {:queue (conj #queue [] id)
:index {id commit} :run-id (uuid/next)
:status :saving})
(dps/initialize-persistence))
(t/is (= 1 (count @requests)))
(rx/push! response {:revn 1})
(t/is (= :saved (get-in @store [:persistence :status])))
(t/is (empty? (get-in @store [:persistence :queue])))))))
(t/deftest an-active-request-is-never-sent-twice
(doseq [interrupt [[(dps/initialize-persistence)]
[(ptk/data-event ::dps/error)]]]
(with-persistence (with-persistence
(fn [{:keys [file-id response requests store]}] (fn [{:keys [file-id response requests store]}]
(apply ptk/emit! store (local-commit file-id) ::dps/force-persist interrupt) (ptk/emit! store (local-commit file-id) ::dps/force-persist
(local-commit file-id) ::dps/force-persist)
(.error response (ex-info "Connection lost" {:type :network}))
(ptk/emit! store (dps/initialize-persistence)) (ptk/emit! store (dps/initialize-persistence))
(t/is (= 1 (count @requests))) (t/is (= :error (get-in @store [:persistence :status])))
(rx/push! response {:revn 1}) (t/is (= 2 (count (get-in @store [:persistence :queue]))))
(t/is (= :saved (get-in @store [:persistence :status]))) (t/is (= 1 (count @requests))))
(t/is (empty? (get-in @store [:persistence :queue]))))))) done)))
(t/deftest save-failures-use-a-translated-warning-except-for-authentication
(t/async done
(doseq [cause-type [:network :offline :authentication]]
(with-persistence
(fn [{:keys [file-id response store]}]
(let [notifications (atom [])]
(mock/with-mocks
{errors/flash (fn [& params]
(swap! notifications conj (apply hash-map params)))
i18n/tr (mock/stub #(str "translated:" %))}
(fn [section-done]
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
(.error response (ex-info "Raw transport details" {:type cause-type}))
(t/is (= :error (get-in @store [:persistence :status])))
(let [data (get-in @store [:persistence :error])]
(ptk/handle-error (assoc data ::errors/instance (ex-info "Save failed" data))))
(if (= cause-type :authentication)
(t/is (empty? @notifications))
(t/is (= ["translated:errors.save-failed"]
(mapv :hint @notifications))))
(section-done))
(fn []))))
(fn [])))
(done)))
(t/deftest missing-commit-is-an-error-instead-of-skipping-changes
(t/async done
(with-persistence
(fn [{:keys [requests store]}]
(let [id (uuid/next)]
(ptk/emit! store
#(assoc % :persistence {:queue (conj #queue [] id)
:index {} :run-id (uuid/next)
:status :saving})
(dps/initialize-persistence))
(t/is (= :error (get-in @store [:persistence :status])))
(t/is (= [id] (vec (get-in @store [:persistence :queue]))))
(t/is (empty? @requests))))
done)))
(t/deftest initialization-recovers-an-unsent-commit-with-a-dangling-run-id
(t/async done
(with-persistence
(fn [{:keys [file-id response requests store]}]
(let [commit (assoc @(local-commit file-id) :changes [])
id (:id commit)]
(ptk/emit! store
#(assoc % :persistence {:queue (conj #queue [] id)
:index {id commit} :run-id (uuid/next)
:status :saving})
(dps/initialize-persistence))
(t/is (= 1 (count @requests)))
(rx/push! response {:revn 1})
(t/is (= :saved (get-in @store [:persistence :status])))
(t/is (empty? (get-in @store [:persistence :queue])))))
done)))
(t/deftest an-active-request-is-never-sent-twice
(t/async done
(doseq [interrupt [[(dps/initialize-persistence)]
[(ptk/data-event ::dps/error)]]]
(with-persistence
(fn [{:keys [file-id response requests store]}]
(apply ptk/emit! store (local-commit file-id) ::dps/force-persist interrupt)
(ptk/emit! store (dps/initialize-persistence))
(t/is (= 1 (count @requests)))
(rx/push! response {:revn 1})
(t/is (= :saved (get-in @store [:persistence :status])))
(t/is (empty? (get-in @store [:persistence :queue]))))
(fn [])))
(done)))
(t/deftest permission-restoration-resumes-only-unsent-edits (t/deftest permission-restoration-resumes-only-unsent-edits
(with-persistence (t/async done
(fn [{:keys [file-id response requests store]}] (with-persistence
(ptk/emit! store (local-commit file-id) ::dps/force-persist (fn [{:keys [file-id response requests store]}]
(local-commit file-id) ::dps/force-persist (ptk/emit! store (local-commit file-id) ::dps/force-persist
#(assoc-in % [:permissions :can-edit] false)) (local-commit file-id) ::dps/force-persist
(rx/push! response {:revn 1}) #(assoc-in % [:permissions :can-edit] false))
(t/is (= :error (get-in @store [:persistence :status]))) (rx/push! response {:revn 1})
(t/is (= 1 (count (get-in @store [:persistence :queue])))) (t/is (= :error (get-in @store [:persistence :status])))
(ptk/emit! store (t/is (= 1 (count (get-in @store [:persistence :queue]))))
#(assoc-in % [:permissions :can-edit] true) (ptk/emit! store
(ptk/data-event :app.main.data.common/change-team-role)) #(assoc-in % [:permissions :can-edit] true)
(t/is (= 2 (count @requests))) (ptk/data-event :app.main.data.common/change-team-role))
(rx/push! response {:revn 2}) (t/is (= 2 (count @requests)))
(t/is (= :saved (get-in @store [:persistence :status]))) (rx/push! response {:revn 2})
(t/is (empty? (get-in @store [:persistence :queue])))))) (t/is (= :saved (get-in @store [:persistence :status])))
(t/is (empty? (get-in @store [:persistence :queue]))))
done)))
(t/deftest recovery-keeps-an-acknowledgment-received-without-a-runner (t/deftest recovery-keeps-an-acknowledgment-received-without-a-runner
(with-persistence (t/async done
(fn [{:keys [file-id response requests store]}] (with-persistence
(ptk/emit! store (local-commit file-id) ::dps/force-persist (fn [{:keys [file-id response requests store]}]
(ptk/data-event ::dps/error)) (ptk/emit! store (local-commit file-id) ::dps/force-persist
(rx/push! response {:revn 1}) (ptk/data-event ::dps/error))
(t/is (= 1 (count (get-in @store [:persistence :queue])))) (rx/push! response {:revn 1})
(ptk/emit! store (dps/initialize-persistence)) (t/is (= 1 (count (get-in @store [:persistence :queue]))))
(t/is (= 1 (count @requests)) "The acknowledged changes must not be sent again") (ptk/emit! store (dps/initialize-persistence))
(t/is (= :saved (get-in @store [:persistence :status]))) (t/is (= 1 (count @requests)) "The acknowledged changes must not be sent again")
(t/is (empty? (get-in @store [:persistence :queue])))))) (t/is (= :saved (get-in @store [:persistence :status])))
(t/is (empty? (get-in @store [:persistence :queue]))))
done)))
(t/deftest recovery-reports-an-unknown-request-outcome-without-replaying-it (t/deftest recovery-reports-an-unknown-request-outcome-without-replaying-it
(with-persistence (t/async done
(fn [{:keys [file-id requests store]}] (with-persistence
(let [commit (assoc @(local-commit file-id) ::dps/request-id (uuid/next)) (fn [{:keys [file-id requests store]}]
id (:id commit)] (let [commit (assoc @(local-commit file-id) ::dps/request-id (uuid/next))
(ptk/emit! store id (:id commit)]
#(assoc % :persistence {:queue (conj #queue [] id) (ptk/emit! store
:index {id commit} :status :saving}) #(assoc % :persistence {:queue (conj #queue [] id)
(dps/initialize-persistence)) :index {id commit} :status :saving})
(t/is (= :error (get-in @store [:persistence :status]))) (dps/initialize-persistence))
(t/is (= :save-outcome-unknown (get-in @store [:persistence :error :code]))) (t/is (= :error (get-in @store [:persistence :status])))
(t/is (= [id] (vec (get-in @store [:persistence :queue])))) (t/is (= :save-outcome-unknown (get-in @store [:persistence :error :code])))
(t/is (empty? @requests)))))) (t/is (= [id] (vec (get-in @store [:persistence :queue]))))
(t/is (empty? @requests))))
done)))
(t/deftest initialization-flushes-buffered-edits-without-duplicating-them (t/deftest initialization-flushes-buffered-edits-without-duplicating-them
(with-persistence (t/async done
(fn [{:keys [file-id response requests store]}] (with-persistence
(ptk/emit! store (local-commit file-id) (fn [{:keys [file-id response requests store]}]
(dps/initialize-persistence) (ptk/emit! store (local-commit file-id)
::dps/force-persist) (dps/initialize-persistence)
(t/is (= 1 (count @requests))) ::dps/force-persist)
(t/is (= 1 (count (get-in @store [:persistence :queue])))) (t/is (= 1 (count @requests)))
(rx/push! response {:revn 1}) (t/is (= 1 (count (get-in @store [:persistence :queue]))))
(t/is (= :saved (get-in @store [:persistence :status])))))) (rx/push! response {:revn 1})
(t/is (= :saved (get-in @store [:persistence :status]))))
done)))
(t/deftest synchronous-save-results-do-not-leave-a-dangling-runner (t/deftest synchronous-save-results-do-not-leave-a-dangling-runner
(with-persistence (t/async done
(fn [{:keys [file-id store]}]
(with-redefs [rp/cmd! (mock/stub (fn [_ _] (rx/of {:revn 1})))]
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
(t/is (= :saved (get-in @store [:persistence :status])))
(t/is (empty? (get-in @store [:persistence :queue])))))))
(t/deftest empty-or-invalid-save-responses-preserve-the-queue-as-failed
(doseq [result [(rx/empty) (rx/of nil) (rx/of {:revn -1})]]
(with-persistence (with-persistence
(fn [{:keys [file-id store]}] (fn [{:keys [file-id store]}]
(with-redefs [rp/cmd! (mock/stub (fn [_ _] result))] (mock/with-mocks
(ptk/emit! store (local-commit file-id) ::dps/force-persist) {rp/cmd! (mock/stub (fn [_ _] (rx/of {:revn 1})))}
(t/is (= :error (get-in @store [:persistence :status]))) (fn [section-done]
(t/is (= :invalid-save-response (get-in @store [:persistence :error :code]))) (ptk/emit! store (local-commit file-id) ::dps/force-persist)
(t/is (= 1 (count (get-in @store [:persistence :queue]))))))))) (t/is (= :saved (get-in @store [:persistence :status])))
(t/is (empty? (get-in @store [:persistence :queue])))
(section-done))
(fn [])))
done)))
(t/deftest empty-or-invalid-save-responses-preserve-the-queue-as-failed
(t/async done
(doseq [result [(rx/empty) (rx/of nil) (rx/of {:revn -1})]]
(with-persistence
(fn [{:keys [file-id store]}]
(mock/with-mocks
{rp/cmd! (mock/stub (fn [_ _] result))}
(fn [section-done]
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
(t/is (= :error (get-in @store [:persistence :status])))
(t/is (= :invalid-save-response (get-in @store [:persistence :error :code])))
(t/is (= 1 (count (get-in @store [:persistence :queue]))))
(section-done))
(fn [])))
(fn [])))
(done)))

View File

@ -18,6 +18,7 @@
- delegated save failures – causes handled by the handler for their type" - delegated save failures – causes handled by the handler for their type"
(:require (:require
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.main.data.event :as ev]
[app.main.data.persistence :as dps] [app.main.data.persistence :as dps]
[app.main.data.workspace :as-alias dw] [app.main.data.workspace :as-alias dw]
[app.main.errors :as errors] [app.main.errors :as errors]
@ -155,13 +156,21 @@
(t/is (string? fingerprint)) (t/is (string? fingerprint))
(t/is (str/starts-with? fingerprint "handled-exception|unknown|unknown|")))) (t/is (str/starts-with? fingerprint "handled-exception|unknown|unknown|"))))
(t/deftest fallback-fingerprint-is-stable-and-discriminating (t/deftest environment-fingerprints-ignore-the-stack-frame
(t/is (= (errors/fallback-fingerprint "exception-page" "boom") (let [cause-a (doto (ex-info "http error" {:type :offline :hint "http error"})
(errors/fallback-fingerprint "exception-page" "boom"))) (unchecked-set "stack" "Error: http error\n at call-site-a (app.js:1)"))
(t/is (not= (errors/fallback-fingerprint "exception-page" "boom") cause-b (doto (ex-info "http error" {:type :offline :hint "http error"})
(errors/fallback-fingerprint "handled-exception" "boom"))) (unchecked-set "stack" "Error: http error\n at call-site-b (app.js:2)"))
(t/is (not= (errors/fallback-fingerprint "exception-page" "boom") defect-a (doto (ex-info "boom" {:type :internal :hint "boom"})
(errors/fallback-fingerprint "exception-page" "other")))) (unchecked-set "stack" "Error: boom\n at call-site-a (app.js:1)"))
defect-b (doto (ex-info "boom" {:type :internal :hint "boom"})
(unchecked-set "stack" "Error: boom\n at call-site-b (app.js:2)"))]
(t/testing "environment failures group across internal call sites"
(t/is (= (errors/error-fingerprint "handled-exception" cause-a)
(errors/error-fingerprint "handled-exception" cause-b))))
(t/testing "application defects keep the stack frame in their identity"
(t/is (not= (errors/error-fingerprint "handled-exception" defect-a)
(errors/error-fingerprint "handled-exception" defect-b))))))
(t/deftest governor-emits-first-occurrence-and-suppresses-repeats (t/deftest governor-emits-first-occurrence-and-suppresses-repeats
(let [d1 (errors/reserve-report* (errors/initial-report-state) "fp" 1000) (let [d1 (errors/reserve-report* (errors/initial-report-state) "fp" 1000)
@ -243,17 +252,29 @@
(let [events (capture-reports! (let [events (capture-reports!
(fn [] (fn []
(doseq [event-name ["handled-exception" "unhandled-exception" "exception-page"]] (doseq [event-name ["handled-exception" "unhandled-exception" "exception-page"]]
(errors/submit-report :event-name event-name :report "report" :hint event-name) (let [cause (error-cause :type :internal :hint event-name)]
(errors/submit-report :event-name event-name :report "report" :hint event-name))))] (errors/submit-report :event-name event-name
:report "report"
:hint event-name
:cause cause)
(errors/submit-report :event-name event-name
:report "report"
:hint event-name
:cause cause)))))]
(t/is (= 3 (count events))))) (t/is (= 3 (count events)))))
(t/deftest submit-report-without-cause-dedups-by-fallback-fingerprint (t/deftest submit-report-without-cause-is-ignored
(let [events (capture-reports! (let [events (capture-reports!
(fn [] (fn []
(errors/submit-report :event-name "exception-page" :report "report" :hint "boom") (errors/submit-report :event-name "exception-page" :report "report" :hint "boom")
(errors/submit-report :event-name "exception-page" :report "report" :hint "boom") (errors/submit-report :event-name "exception-page"
(errors/submit-report :event-name "exception-page" :report "report" :hint "other")))] :report "report"
(t/is (= 2 (count events))))) :hint "boom"
:cause (error-cause :type :internal :hint "boom"))))]
;; The cause-less call is ignored and must not consume the reservation
;; of the cause-based report.
(t/is (= 1 (count events)))
(t/is (= 1 (:occurrences (deref (first events)))))))
(t/deftest governor-bounds-an-incident-like-loop (t/deftest governor-bounds-an-incident-like-loop
(let [cause (error-cause :type :network :hint "unable to perform fetch operation") (let [cause (error-cause :type :network :hint "unable to perform fetch operation")
@ -268,29 +289,37 @@
(t/deftest flash-suppressed-occurrence-does-not-build-a-report (t/deftest flash-suppressed-occurrence-does-not-build-a-report
(let [generated (atom 0) (let [generated (atom 0)
cause (error-cause :type :network :hint "unable to perform fetch operation") cause (error-cause :type :internal :hint "unable to perform fetch operation")
events (atom [])] events (atom [])]
(with-redefs [errors/generate-report (fn [_] (swap! generated inc) "report") (mock/with-mocks
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) {st/format-last-events (mock/stub (fn [& _] (swap! generated inc) "report"))
rt/get-current-href (constantly "https://penpot.example.com/#/workspace") st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
tm/schedule mock/noop] rt/get-current-href (constantly "https://penpot.example.com/#/workspace")
(dotimes [_ 3] tm/schedule mock/noop}
(errors/flash :cause cause :type :handled)) (fn [done']
(t/is (= 1 (count @events))) (dotimes [_ 3]
(t/is (= 1 @generated))))) (errors/flash :cause cause :type :handled))
(t/is (= 1 (count @events)))
(t/is (= 1 @generated))
(done'))
(fn []))))
(t/deftest flash-bounds-an-incident-like-loop (t/deftest flash-bounds-an-incident-like-loop
(let [generated (atom 0) (let [generated (atom 0)
cause (error-cause :type :network :hint "unable to perform fetch operation") cause (error-cause :type :internal :hint "unable to perform fetch operation")
events (atom [])] events (atom [])]
(with-redefs [errors/generate-report (fn [_] (swap! generated inc) "report") (mock/with-mocks
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) {st/format-last-events (mock/stub (fn [& _] (swap! generated inc) "report"))
rt/get-current-href (constantly "https://penpot.example.com/#/workspace") st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
tm/schedule mock/noop] rt/get-current-href (constantly "https://penpot.example.com/#/workspace")
(dotimes [_ 10000] tm/schedule mock/noop}
(errors/flash :cause cause :type :handled)) (fn [done']
(t/is (= 1 (count @events))) (dotimes [_ 10000]
(t/is (= 1 @generated))))) (errors/flash :cause cause :type :handled))
(t/is (= 1 (count @events)))
(t/is (= 1 @generated))
(done'))
(fn []))))
(t/deftest generate-report-is-total-when-formatting-fails (t/deftest generate-report-is-total-when-formatting-fails
(with-redefs [st/format-last-events (mock/stub (fn [& _] (throw (ex-info "formatting failed" {}))))] (with-redefs [st/format-last-events (mock/stub (fn [& _] (throw (ex-info "formatting failed" {}))))]
@ -303,10 +332,149 @@
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
rt/get-current-href (constantly "https://penpot.example.com/#/workspace") rt/get-current-href (constantly "https://penpot.example.com/#/workspace")
tm/schedule mock/noop] tm/schedule mock/noop]
(errors/flash :cause (error-cause :type :network :hint "boom") :type :handled) (errors/flash :cause (error-cause :type :internal :hint "boom") :type :handled)
(t/is (= 1 (count @events))) (t/is (= 1 (count @events)))
(t/is (string? (:report (deref (first @events)))))))) (t/is (string? (:report (deref (first @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
: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 generate-report-compact-omits-stack-data-and-last-events
(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")}
(fn [done']
(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"))))
(done'))
(fn [])))
(t/deftest generate-report-defaults-to-the-full-format
(mock/with-mocks
{st/format-last-events (mock/stub (fn [& _] "(stub last events)"))
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")}
(fn [done']
(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)")))
(done'))
(fn [])))
(t/deftest connectivity-handlers-report-governed-compact-audit-events
(doseq [type [:network :offline]]
(errors/reset-report-governor!)
(let [events (atom [])
cause (ex-info "http error" {:type type :hint "http error"})]
(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" {}))))}
(fn [done']
(errors/on-error cause)
;; `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]))))
(done'))
(fn [])))))
(t/deftest flash-keeps-the-canonical-event-name-and-derives-the-format
(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 [])]
(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/noop
st/format-last-events (mock/stub (fn [& _] (throw (ex-info "must not be called" {}))))}
(fn [done']
;; The event name is the canonical one requested by the caller;
;; only the payload format is derived from the cause.
(errors/flash :cause cause :type type)
(t/is (= 1 (count @events)) (str "unexpected event count for " type))
(let [props (deref (first @events))
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"))))
(done'))
(fn []))))))
(t/deftest offline-loop-is-governed-and-never-unhandled
(let [events (atom [])
cause (ex-info "http error" {:type :offline :hint "http error"})]
(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/noop
st/format-last-events (mock/stub (fn [& _] (throw (ex-info "must not be called" {}))))}
(fn [done']
(dotimes [_ 10000]
(errors/on-error cause))
(t/is (= 1 (count @events)))
(t/is (= "handled-exception" (::ev/name (deref (first @events)))))
(done'))
(fn []))))
(t/deftest flash-persistence-uses-compact-reports-for-environment-failures
(let [events (atom [])]
(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/noop
st/format-last-events (mock/stub (fn [& _] (throw (ex-info "must not be called" {}))))}
(fn [done']
(errors/flash-persistence (ex-info "http error" {:type :offline
:hint "http error"
:headers {"x-session-id" "secret"}}))
(t/is (= 1 (count @events)))
(let [props (deref (first @events))]
(t/is (= "handled-exception" (::ev/name props)))
(t/is (not (str/includes? (:report props) "Last events:")))
(t/is (not (str/includes? (:report props) "secret"))))
(done'))
(fn []))))
(t/deftest exception-page-reports-dedup-by-cause (t/deftest exception-page-reports-dedup-by-cause
(let [cause-a (error-cause :type :internal :code :unable-to-process-repository-response :hint "boom") (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") cause-b (error-cause :type :internal :code :other :hint "other")

View File

@ -1674,6 +1674,10 @@ msgstr ""
msgid "errors.comment-error" msgid "errors.comment-error"
msgstr "There was an error with the comment" msgstr "There was an error with the comment"
#: src/app/main/errors.cljs:429
msgid "errors.connection-error"
msgstr "Cannot reach the server. Check your connection and try again."
#: src/app/main/errors.cljs:520 #: src/app/main/errors.cljs:520
msgid "errors.deprecated" msgid "errors.deprecated"
msgstr "" msgstr ""