mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 14:00:37 +00:00
* ✨ Add async test helpers and the with-mocks* macro Backport of the test scaffolding that the persistence retry work needs, taken from staging. Purely additive: no existing helper changes behavior, so the suite result is unchanged (740 tests, 0 failures). - frontend-tests.helpers.async: ->promise, await-response, settle, observe and wait-for. wait-for checks immediately and then polls one macrotask at a time, bounded, so it fails instead of hanging. - mock/with-mocks*: installs mocks with set! and evaluates its body in a generated ^:async fn, so the test reads sequentially and there is no done to thread. Evaluates to a promise. - mock/asap and mock/run-mocked: run-mocked awaits the body and restores the originals always, in that order, reporting a rejection or a non-promise return as an :error. The matching change that made with-mocks defer its body via asap is NOT backported: it would force all 58 existing uses across 14 files into done-chained contexts, which is a separate refactor. AI-assisted-by: space-bunny-free * ✨ Add an error report governor with compact payloads One browser session could emit an unbounded number of identical error reports, and a dropped connection was reported as an application defect with the full payload. Both are fixed here, in one place, because the persistence retry work that follows depends on the new submit-report contract. - A report governor in app.main.errors. Each report carries a fingerprint (report-name|type|code|hint|first stack frame); the first occurrence is always emitted, repeats inside a 2 minute window are counted, and the next emitted report carries them as :occurrences. The cache is bounded by evicting the oldest entry, so memory stays fixed. - flash reserves the report before generating it, so a suppressed occurrence never pays the report-building cost. Its whole body now runs inside one ts/schedule callback, a reporting or notification failure is logged instead of propagating, and it returns a total promise so tests can await it. - generate-report accepts :format :compact|:full. The compact payload keeps the context header plus type, code and uri, and skips the stack, the ex-data dump (it can carry request headers) and the last-events list. - environment-error-types is derived from rp/retryable-types, so the transport classification has a single source of truth. Environment failures are audit-only handled-exceptions and never reach the exception page or the alerts. :offline gets its own handler instead of falling through to :default. :occurrences travels inside the audit event :props, which the backend schema accepts as a free-form map, so no backend change is needed. The dedup governor test file is new: 7 tests over the fingerprint, the pure reservation decision, and the flash pipeline. Relates to #11726, #11743 AI-assisted-by: space-bunny-free * ♻️ Answer commit refusals with an empty stream commit-changes used to return nil when it refused to build a commit, which forced every caller that wanted to observe the outcome to special-case nil. It now returns an empty stream, so a refusal and an empty result look the same and callers can subscribe uniformly. Only the refusal branch changes: the accepted branch already answered with (rx/of commit). Potok treats a nil watch result as "nothing", so both spellings were already correct at the store level. The 57 call sites that wrap this event in rx/of are unaffected. They hold the ::commit-changes event itself, not the value its watch returns, so they keep processing exactly as before. AI-assisted-by: space-bunny-free * ✨ Retry transient saves with backoff and a reconnect notice Any failed update-file landed on the same path: the queue halted in :error, the retained changes were flushed away, and the user was told to reload. A dropped connection and a revoked permission are not the same thing, so they now take different paths. transient-error? splits them, checking :type and :cause-type because persistence-failed wraps the original cause. Transient failures enter a :retrying episode: the head commit stays queued and resends with backoff at 2s, 8s and 20s, then falls through to today's terminal path. Everything else is terminal and keeps the exact :error path. - Resends rotate the ::request-id stamp only once the old request has left active-requests, carry the same :commit-id, and never double send while one request is in flight. - Retry timers carry an episode token; a timer whose episode was superseded stays silent instead of sending or failing. - One tagged reconnect notice per episode, hidden by tag on save and on terminal failure. Recovery is silent. - The browser online event re-enters the runner, but only for a live :retrying episode, guarded by exists? js/window. - wait-persisted-or-error waits through :retrying instead of cutting the wait short. The episode keeps its :run-id, so a new local edit only joins the queue. Re-entering the runner on every edit would bypass the backoff and spend one attempt per batched commit. The retry test namespace is new: 13 tests over the classification, the backoff schedule, stamp rotation, stale tokens and the notice lifecycle. Relates to #11724 AI-assisted-by: space-bunny-free * 🐛 Fix stalled saves that never answer and edits lost on leave Two holes the retry work did not close. An update-file request that never gets an answer kept its attempt stamp forever, so the queue could not move and the user saw a saving state that never resolved. The request now has a 7 minute timeout that fails it as a transient network error, which hands it to the retry path instead of blocking. The timeout is longer than the 5 minute stall threshold on purpose: the stalled report must fire before the retry, not instead of it. The leave-page warning also had gaps. It now tracks unsaved edits directly, through sync-unload-guard, which holds :persistence in st/ongoing-tasks while edits sit in the buffer or the queue. Edits kept in the queue after a failed save keep the guard on too, so closing the tab warns instead of dropping them. store.cljs guards its ongoing-tasks watcher with exists? js/window, because the new tests exercise it without a window. Four new tests: three for the guard across buffering, an in-flight save and a retained edit, and one for a hung request that must resend and save. Closes #12223 AI-assisted-by: space-bunny-free * 🐛 Skip the save when a missing font cannot be remapped fix-deleted-fonts emitted text and typography changes even when no replacement font existed, so opening a page that uses a deleted custom font rewrote identical content and cleared position-data on every visit. Every visit became a save with nothing to save. Both loops now compare the remapped value with the original and emit nothing when they match. The remapped path is unchanged. Two new tests cover the no-replacement case and the remapped case. Closes #12151 AI-assisted-by: space-bunny-free * ✨ Add a debug helper that simulates an intermediary response A save that never reaches the backend is hard to reproduce: you need a proxy, a captive portal or a CDN in the way. This wraps fetch and answers the named API method with a status and a body that carries no Penpot error code, leaving every other request alone. Without a duration the simulation is spent after answering one call. With one it answers every matching call for that long, which is how to watch the save retry, warn, keep retrying and finally recover. debug.simulateHttpError('update-file') debug.simulateHttpError('get-comment-threads', 403) debug.simulateHttpError('update-file', 524, 120) debug.clearHttpErrorSimulation() Dev-only and self-contained: no production path reads it. AI-assisted-by: space-bunny-free * 🌐 Add the save retry and connection error strings Three strings the new persistence and error paths need: - errors.save-retrying, shown once per retry episode while the queue resends a queued save after a connection loss. - workspace.header.retrying, the save indicator state for an episode in progress. - errors.connection-error, shown by the network and offline handlers, which no longer fall through to the generic message. Added to en.po only. The translations script also normalizes every other locale and drops 439 strings that were already stale before this work; that belongs in its own maintenance pass, not in a backport. AI-assisted-by: space-bunny-free * 🐛 Report save failures as unhandled when they are our defect flash-persistence reported every failure it did not delegate as handled-exception, including the ones that are plainly our fault. A validation failure means the client sent something the server refused, yet it landed in the audit log with a name that skips the internal reports and the alerts. Nobody was paged. The report type now follows the cause. :validation, :server-error and :internal report as unhandled-exception, so they alert. The rest are unchanged. Two exclusions are deliberate: - Environment failures stay handled. They reach this point after the retry budget is spent, and the user's connection is not our bug. - :assertion stays handled, because its own handler already reports it that way and the two paths should not disagree. The cause type is read from :cause-type first, since persistence-failed wraps the original under :type :persistence. Reading :type alone would match the wrapper every time and change nothing. The user still gets the same sticky toast and report link; only the audit event name moves. The test that asserted a validation failure is "still notified" now asserts which event name it gets, across the bare and wrapped shapes, the environment types that must stay handled, and an unclassified one. AI-assisted-by: space-bunny-free * 🐛 Unblock persistence when opening a file after a terminal failure A terminal save failure set the state to :error and left the queue alone. recover-persistence only resumed for :save-permission-denied, so for every other terminal code both of its branches were false and it emitted nothing. Leaving and reopening the same file therefore changed nothing: the editor stayed halted until a reload, which rebuilds the whole store and loses the queue anyway. The path had two jobs under one name. initialize-persistence fires when the workspace view mounts and tears down the previous subscription; recover-persistence fires when a file is opened or the role changes. They are now separate: initialization picks up a queue left behind by a previous session, and deliberately does not lift a halt, so undeliverable edits are released when the user acts rather than when the view mounts. Recovery lifts the halt by releasing what can never be saved. release-failed-file drops every queued commit of the file that failed, then resume-persistence clears the error and settles an empty queue to :saved. Retaining them until that point is deliberate: it is what keeps wait-persisted-or-error rejecting, which is what stops workspace/versions.cljs from building a snapshot out of unsaved edits. Releasing at failure time would make waiters resolve instead. Permission loss is treated as permanent too, which the previous exception got wrong. The backend raises :not-found for a missing edit permission, so a resend does not succeed — it routes the user to the exception page. The old branch made that reachable: it resumed the save whenever the cached can-edit flag said yes, and the server disagreed. The only case where the flag can return to true is a team switch, where the queue is gone anyway, so nothing real is lost by releasing. Because the queue is shared, dropping the failed file is also what lets the other files' pending edits drain. One file that cannot be saved no longer blocks everything else. Tests: the failure retains the queue and notifies once, recovery releases it without replaying, a failed file does not block a second one, permission loss is released rather than resumed, and initialization leaves a halt alone. AI-assisted-by: space-bunny-free * ✨ Offer a reload on the save failure toast A terminal save failure stops autosave for the whole session, but the toast asked the user to reload without offering the reload. It listed five implied actions, none of them reachable from the banner, and it never said that the changes could not be saved. flash now takes a :links vector of actions, appended after the download link that :report-link? produces, so a caller can add recovery actions without reaching into the report handling. flash-persistence passes a reload built from labels.reload-page and location.reload, the pattern already used elsewhere in the app for a full restart. The message now says what happened, what is lost, and what to do about it: autosave has stopped for this session, the changes since the last successful save cannot be saved, and either reloading or leaving the file and opening it again restarts it. Naming both matters, because the toast links are easy to miss. Saying the edits are lost is not optional: recovery releases them, so without it the user finds out when they come back to the file. Translated in en.po and es.po only. The translations script would also normalize all 40 locales and drop entries that were already stale, which is a maintenance pass of its own. The reload callback cannot be invoked under the test runner, since js/location does not exist there, so it is covered by its label and by being callable. Whether the reload works is a manual check. AI-assisted-by: space-bunny-free * 🐛 Classify the responses the backend never wrote A reply written by a proxy instead of the backend carries no Penpot error code, so handle-response raised it as :internal. A 504 on a save skipped the retry cycle and stopped autosave at once, and because :internal reports as unhandled, it raised an alert. In a standard deployment this is how a slow save ends: nginx answers 504 after 300s, before the client's 7 minute timeout fires. The status now decides the type: - 504 and the Cloudflare 520-524 family raise :gateway-error, which joins retryable-types. Saves retry with backoff like 502 and 503, GETs retry, and a save that spends its retries reports as handled, because the report rules derive from the same set. - 429 raises :rate-limit, and any other error status :unexpected-response. Neither retries; both show a toast instead of the error page. - :internal is kept for a response below 400 that does not decode. Bodies we cannot read are cut to 500 characters, since proxy error pages run to tens of kilobytes. Tests: classification and retries per status, the transport handlers keep the page, a 504 and a 524 built by the real handle-response go through the save path and land on a retry, and the new types stay handled once the retries are spent. AI-assisted-by: claude-opus-5-5 * ✨ Keep retrying failed saves on a slow cycle Once the three backoff retries were spent, a save that failed on the connection stopped autosave like any terminal failure, and opening a file again dropped its edits. A connection that comes back after a minute lost the work all the same. A transport failure that outlasts the backoff now arms a slow cycle: one silent resend every 30s, until a save lands or 24h have passed since the failure started. The cycle stops when persistence restarts, the queue resumes another way, or the workspace closes. A save that keeps failing slowly is not resent for a day. A gateway that answers 504 after its own timeout may leave the backend applying the save, so each resend can cost the whole save again, and the failing file holds back every other file's queued edits. An attempt that fails after 90s or more counts as a slow failure; at three, the slow cycle stops sending the commit and the save fails as before. Fast failures, such as being offline, never count. - The first failure shows a toast that says the changes are kept and keep being sent, with no reload link, since a reload loses them. A save that lands hides it. - Opening a file sends a queue still inside its retry window once more instead of releasing it. Failures outside the window, and every non-transport failure, are still released. - A freshly loaded file gets back the queued edits of its version, so reopening it shows the changes that are still waiting to be saved. An edit that no longer applies to the loaded data leaves the file as the server sent it, so the file still opens; the edits stay queued. - A commit whose earlier request is no longer in flight is simply sent again under the same commit id: changes are idempotent, so there is no unknown-outcome guard any more. A request still in flight still blocks a parallel send. flash takes a :tag so the save failure toast can be hidden by tag. Tests: the spent backoff arms the slow cycle, a 504 arms it, a terminal failure does not, the cycle stops when the window closes or at the slow failure limit, recovery resends instead of releasing, reopening restores only the loaded file's edits and survives one that no longer applies, and the retrying toast offers no reload. * 🐛 Turn off the leave-page warning when failed edits are released The leave-page warning only followed the queue on appends and discards. Opening a file again releases the edits of a file that failed for good, but that release changed the queue without either event, so the warning stayed on with nothing left to save. Leaving the page asked about unsaved changes until the next save went through. The warning now also follows the release. Tests: releasing the last failed edit turns the warning off. * 🐛 Stop retrying failed saves outside the workspace Leaving the workspace was meant to stop the slow retry cycle, but only a cycle already armed stopped. A backoff timer kept firing after the user left, and a backoff spent outside the workspace armed a slow cycle that kept sending for up to a day from the dashboard. Those sends check the edit permission of whatever team is open, so a switch to a team where the user only views failed the queued edits for good, and the next file they opened dropped them. Leaving the workspace now suspends retries. A pending backoff timer goes stale, and while suspended a transport failure schedules no backoff, a spent backoff arms no slow cycle and the online event sends nothing. The edits stay queued and a request in flight still lands. Opening a file lifts the suspension and resends a retrying save whose backoff was cut short; a role change does not lift it. Tests: leaving stops a pending backoff, a failure after leaving schedules no retry and ignores the online event, a backoff spent outside the workspace arms no slow cycle, and opening a file saves the held edit in each case. --------- Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
937 lines
46 KiB
Clojure
937 lines
46 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 INC Sucursal en España 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
|
||
- 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 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))))
|
||
|
||
;; ---------------------------------------------------------------------------
|
||
;; 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")))))))
|