diff --git a/.agents/skills/create-commit/SKILL.md b/.agents/skills/create-commit/SKILL.md
index f0a612c17e..bf0667ca65 100644
--- a/.agents/skills/create-commit/SKILL.md
+++ b/.agents/skills/create-commit/SKILL.md
@@ -62,5 +62,6 @@ subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly.
- Do not pass `--author`. Author identity comes from the local git config.
- Do not amend a commit you did not create in this session, unless explicitly asked.
- Do not bypass pre-commit hooks (`--no-verify`) unless explicitly asked.
+- Never add a `Co-authored-by:` trailer. It is forbidden and fails `./scripts/check-commit`.
- Do not add untracked files that were not created in this session.
- Do not skip the `scripts/check-commit` verification step (Iron Rule 4).
diff --git a/.github/workflows/commit-checker.yml b/.github/workflows/commit-checker.yml
index 29d9c79d60..0de7fe8975 100644
--- a/.github/workflows/commit-checker.yml
+++ b/.github/workflows/commit-checker.yml
@@ -47,4 +47,45 @@ jobs:
# pattern: '^.+(Resolves|Fixes): \#[0-9]+$'
# error: 'You need at least one "Resolves|Fixes: #
At Penpot you can create as many teams as you need and be invited to teams owned by others. Learn how to manage them.
At the top left of the dashboard you can find the team selector.
+At the top left of the dashboard you can find the team selector. It shows the current team and, if the team belongs to an organization, the organization name.
+If you belong to an organization, the menu has two columns:
+Clicking an organization only shows its teams. You stay where you are until you pick a team. If you do not belong to any organization, the menu shows a single list of teams.
"Personal Projects" is the name of your personal space at Penpot. It is like any other team but in which no members can be invited so that you will always have your own private dashboard. Create or join other teams to collaborate with other Penpot users.
-

To create a new team go to the bottom of the team selector and press "+ Create new team". Then you will be asked to enter a team name and that's it. Once a new team is created you are able to invite new team members.
+To create a new team go to the bottom of the team selector and press "Create new team". If the menu shows organizations, the team is created in the organization you are looking at, or outside any organization if Other teams is selected. Then you will be asked to enter a team name and that's it. Once a new team is created you are able to invite new team members.

All members can leave the team anytime from the same menu.
-Only the team owner can delete a team. The option can be found at the team menu (the three dots at the right side). When deleting a team all projects and files belonging to it will be permanently deleted.
+Only the team owner can delete a team. The option can be found at the team menu (the three dots next to the team selector), together with members, invitations, settings and leaving the team. When deleting a team all projects and files belonging to it will be permanently deleted.

If you want to change your password to a new one, this can be done in the Password section.
+If you want to change your password to a new one, this can be done in the Password section. Penpot sends you an email each time your password is changed, set or reset.
Your session ends after a period without activity, and also after a maximum time since you logged in, even if you keep working. When that happens, Penpot asks you to log in again.
+After several failed login attempts in a short time, your account can be locked for some minutes. Penpot shows how long to wait. Resetting your password unlocks it.
+On self-hosted installations, the administrator sets these limits. See Session expiration and Account lockout in the Technical Guide.
+Penpot Design Tokens adhere to the Design Tokens Format Module and its definitions, a draft by the W3C DTCG. Penpot ensures compatibility across various disciplines, tools, and technologies by following the most standardized approach available for design tokens.
Tokens can be exported from Penpot or integrated into other tools directly, without conversion. Additionally, the knowledge gained from using Design Tokens in Penpot remains valuable, regardless of whether you continue using Penpot or a different tool or technology.
+Tokens live in a file. To share them between files, keep them in a shared library and set that library as the tokens source of the other files. See Use tokens from a shared library.
+You can create reusable and semantic tokens to be referenced in your designs at the Tokens panel. In this panel, you’ll find all the available types of tokens in Penpot arranged alphabetically, with existing tokens being shown at the top of the list.
You can also use a color token for the canvas background. With nothing selected, click the swatch in the Canvas background section of the Design tab, switch the color picker to Color tokens and pick a token. The background then follows the token, for example when you switch between a light and a dark theme. Use Detach token to keep the color as a plain value.
The stroke width token, also known as the border width token, defines the thickness of a stroke around a design element. It can be applied to boards, groups, rectangles and text elements.
+On rectangles and boards you can apply it to a single side. Right-click the token and choose All, Top, Right, Bottom or Left. Dimension tokens offer the same options under Stroke Width. You can also pick a token in each side field of the stroke per side controls.
+Tokens apply only to the first stroke of a layer. Applying a token to one side requires WebGL rendering.
You can export Tokens from Penpot and import them from your computer to a Penpot file. Tokens can be imported from the Tools option at the bottom of the Tokens tab.
-You can also import tokens from a connected shared library using the Libraries modal. That import uses the library's full token definition (sets and themes included) and replaces the tokens already in your file.
+To use the tokens of a shared library without copying them, set the library as the file's tokens source. See Use tokens from a shared library. Import is not available while a library is the tokens source.
The Import functionality allows you to upload and replace the global token set using a single file or a folder with multiple files in it.
These features support JSON files formatted according to specific guidelines and preserve the ability to undo changes if needed.
If a shared library you have connected includes design tokens, you can copy them into the current file from the Libraries modal.
-The Import tokens button appears only next to connected libraries that define tokens. Libraries without tokens do not show this control.
+A file can use the design tokens of a connected shared library instead of its own. This keeps one source of truth: tokens are defined once in the library, and every file that uses it gets the same tokens, sets and themes.
+A file uses one tokens source at a time: its own tokens or the tokens of one connected library.
Importing tokens from a library replaces all tokens, sets and themes in the current file. Export your tokens first (from the Tokens tab → Tools → Export) if you need to keep a backup.
-This is separate from connecting the library for components, colors and typographies. You can use shared assets without importing tokens, and import tokens only when you want the file's token setup to match the library.
-
-
The button only appears for libraries that have tokens of their own. A library that already uses tokens from another library cannot be a tokens source.
+You can also change the source from the top of the Tokens tab, which shows Source: followed by This file or the library name. In the Assets tab, a tokens icon marks the library that is the current source.
+ +Token changes in the library reach the files that use it right away. You do not need to accept them in Updates, which only lists components, colors and typographies. When sets or themes are added, removed or renamed in the library, Penpot shows a notice in the file.
+If a token applied in your file is renamed or deleted in the library, the layer keeps its value but the token appears as missing. Apply the new token again.
+ +Click Set as tokens source on the File library row. The file's own tokens were kept while the library was the source, so they come back. Tokens applied from the library appear as missing unless the file has tokens with the same names. Disconnecting a library that is the tokens source also switches the file back to its own tokens.
diff --git a/docs/user-guide/designing/color-stroke.njk b/docs/user-guide/designing/color-stroke.njk index bd1cab3000..de69f59b8f 100644 --- a/docs/user-guide/designing/color-stroke.njk +++ b/docs/user-guide/designing/color-stroke.njk @@ -122,6 +122,21 @@ desc: Style your designs with Penpot's options! Learn about color fills, gradien
Rectangles and boards can have a different stroke width on each side. Use it for dividers, tab underlines or accent borders without adding extra layers.
+
+When the sides have different widths, the main width field shows Mixed. Typing a value in it sets all four sides to that value. Color, position and style are shared by all sides.
+Each stroke of a layer has its own per-side widths. You can also edit them on a multiple selection of rectangles and boards.
+You can apply stroke width tokens to one side or to all of them. In the Inspect tab, per-side widths appear as CSS logical properties (border-block-start-width, border-inline-end-width, border-block-end-width and border-inline-start-width).
Stroke per side requires WebGL rendering. With the classic renderer the button is disabled and only the top width is drawn.
+A path is the underlying geometry: a sequence of points and segments (straight lines or Bezier curves). A stroke is the visible line drawn along that path, with properties such as width, color, opacity, dashes, caps, and joins.
Stroke to path turns the visible outline of a stroke into a separate, editable path. You can then reshape or style that outline independently of the original path. The new shape is usually closed. That matters for SVG, boolean operations, and editing the outline as geometry.
diff --git a/docs/user-guide/designing/layers.njk b/docs/user-guide/designing/layers.njk index b46bc11f90..9aca80ced0 100644 --- a/docs/user-guide/designing/layers.njk +++ b/docs/user-guide/designing/layers.njk @@ -22,7 +22,7 @@ are shown by default at the Curves (freehand)The curve tool allows a path to be created directly in a freehand mode. Select the curve tool from the free-draw flyout in the toolbar or press Shift/⇧ + C.
-The path created will contain a lot of points, but it is edited the same way as any other curve.
+When you release the mouse, Penpot turns your stroke into a few smooth curves. Sharp turns stay as corners, so a zigzag keeps its points. The result is a regular path that you can edit like any other.
A path is composed of two or more nodes and the line segments between them, which may also be curved. To draw a new path, select the path tool from the free-draw flyout in the toolbar or press P. Then you have two ways to create the path:
@@ -178,16 +178,37 @@ Select the curve tool from the free-draw flyout in the toolbar or press ShiTip: If you hold Shift/⇧ while adding nodes the angle between the current and the next will change in 45 degree increments.
+Tip: If you hold Shift/⇧ while adding nodes, the angle between the current node and the next one changes in 15 degree increments.
To edit a node double click on a path or select and press Enter. You can choose to edit individual nodes or create new ones. Press Esc to exit node edition.
+The path toolbar has two modes:
+Snap nodes (Ctrl/⌘ + ') is on by default, so new and moved nodes snap to the existing ones.
+ +To keep drawing from an existing node, select it in Move mode and switch to Draw mode, or click it in Draw mode. If the node is the end of an open path, the new segments extend it. Click the start node to close the shape. Hold Shift/⇧ to draw in 15 degree steps.
+ +These gestures work on nodes and segments while you edit a path:
+Delete or Backspace removes the selected nodes and keeps the path joined. With selected handles, it removes them and the curve at that node becomes straight. Shift/⇧ + Delete removes the selected nodes together with their segments.
+See all the path editor shortcuts.
Hold Ctrl/⌘ while you drag a resize handle to change the layer bounds without scaling the image. Shrink the layer to crop the image, or make it bigger to add empty space around it. The image is not distorted.
+This works with image layers and with any layer that has an image fill. A normal resize afterwards scales the frame and the image together.
+Background blur is not included in exported files yet (PNG, SVG, PDF, and others). The exporter still uses the legacy SVG renderer, so the effect only shows in the workspace and View mode. Export support is a current priority and will land in an upcoming release.
+Background blur is included in exports made with WebGL rendering enabled. In PDF files it is embedded as an image.
Press Enter with a text layer selected to start editing the text content. You can style parts of the text content as rich text.
+With WebGL rendering enabled, right-click while editing to open a menu with Cut, Copy, Paste and Select all. Your text selection is kept.
The comments menu lets you show all comments, only your comments or only your mentions. Hide resolved comments is a separate option. Penpot remembers it in your browser for all files, in the workspace and in View mode.
+At the top right of the comment popup you can find options to edit or delete comments.
diff --git a/docs/user-guide/export-import/exporting-layers.njk b/docs/user-guide/export-import/exporting-layers.njk index c8ad0988e9..6f1f5869c5 100644 --- a/docs/user-guide/export-import/exporting-layers.njk +++ b/docs/user-guide/export-import/exporting-layers.njk @@ -27,7 +27,7 @@ desc: Learn how to export layers in Penpot, the free, open-source design collaboThe options of an export:
Currently, private Penpot instances only require basic Docker knowledge.
You can run your own Penpot server following the instructions at the Technical Guide
+ +To see if your installation is up to date, open your profile menu in the dashboard and go to About Penpot → Check for updates. If a newer version exists, Penpot shows your installed version, the latest one, and the highlights of each release you are missing, with links to the full changelog and the release notes.
+The check runs from your browser and needs access to GitHub. It is hidden on installations configured as air-gapped.
diff --git a/docs/user-guide/first-steps/shortcuts.njk b/docs/user-guide/first-steps/shortcuts.njk index b06057a9b9..e4708c9800 100644 --- a/docs/user-guide/first-steps/shortcuts.njk +++ b/docs/user-guide/first-steps/shortcuts.njk @@ -10,7 +10,43 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interfaceHere you can find a list of all keyboard shortcuts that you can use in Penpot. Bear in mind that most of them are at the workspace, where they are more needed.
-You can also check the most updated list of shortcuts at the GitHub file.
+The tables below list the default shortcuts. You can change most of them to fit your keyboard layout or your habits. See Customize shortcuts.
+ +Go to Your account → Shortcuts to see every shortcut and change the ones you want. From the workspace, open the shortcuts panel (Help & info → Shortcuts in the main menu, or ?) and click Customize in Settings.
+Your shortcuts are saved in your account, so they follow you to any computer and browser. After a change, reload any other open Penpot tabs to apply it.
+ +Shortcuts are grouped into Workspace, Dashboard and Viewer. Use the tabs to see All of them, only your Custom ones, or the ones you Disabled.
+The search field matches both the action name and the keys. Type "group" to find the grouping actions, or "shift" to see every shortcut that uses Shift.
+ +
+If the keys are already used by another action in the same group, Penpot shows which one. When you save, the shortcut moves to the action you are editing, and the other action appears under Disabled.
+Each action takes one combination. If an action had two defaults (for example, two ways to redo), saving a custom one replaces both.
+From the same editor you can also disable a shortcut so that no keys trigger it, or reset it to its default value. Custom shortcuts are marked with a dot, and the main menu highlights the custom keys.
+Paste cannot be customized.
+ +Punctuation keys (such as -, [, / or ') are recorded by their physical position on the keyboard, so they work the same on AZERTY, QWERTZ and other layouts. Letters and numbers are recorded by the character they type. With Shift, the base key is recorded, not the shifted character.
+ +When you have custom shortcuts, a Reset all button appears next to the search field. It lists every shortcut that will go back to its default before you confirm.
+ +Use the Import/Export button at the bottom of the page to move your setup to another account or share it with your team.
+Importing replaces your custom shortcuts in each group included in the file (Workspace, Dashboard or Viewer). Custom shortcuts in those groups that are not in the file go back to their defaults. If an imported shortcut uses keys taken by a default one, the default one is disabled.
The Workspace is where the designs are actually created. More about the Workspace.
@@ -569,6 +605,7 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interfaceCopy, cut, paste, duplicate and select all also work on nodes while you edit a path. For mouse gestures, see Reshape with the mouse.
| ⇧+ | |||||
| Delete node | -Supr or Delete | -⌫ | +Delete node, segment or handle | +Delete or Backspace | +⌦ or ⌫ |
| Draw Path | +Delete node and segments | +ShiftDelete | +⇧⌫ | +||
| Draw nodes | P | P | ⌘J | ||
| Move Nodes | +Move nodes | M | M | Ctrl' | ⌘' | +
| Flip horizontally (selected nodes, or the whole path) | +ShiftH | +⇧H | +|||
| Flip vertically (selected nodes, or the whole path) | +ShiftV | +⇧V | +
Since Penpot 2.16, optional WebGL rendering (Beta) is available on design.penpot.app and self-hosted instances. It is off by default.
Turn it on from Your account → Settings or Workspace → Main menu → Preferences when you want a faster canvas for complex files.
-In this beta, WebGL applies to the design workspace canvas only. View mode and exports still use the legacy renderer, so some visual differences are expected. If the beta gets in your way, you can switch back to the legacy renderer at any time.
+With WebGL rendering enabled, the workspace canvas, View mode and exports (PNG, JPEG, WEBP, SVG and PDF) use the same engine, so exported files match what you see on the canvas. Text is edited with the new text editor, which also adds a right-click menu with Cut, Copy, Paste and Select all.
+Some features need WebGL rendering, such as stroke per side, background blur and stroke to path.
For what is in scope today, what we are prioritizing next, and known visual differences, read What's next for the Penpot WebGL renderer on the Penpot Community.
diff --git a/frontend/deps.edn b/frontend/deps.edn index b1293a652c..c5184e43ae 100644 --- a/frontend/deps.edn +++ b/frontend/deps.edn @@ -15,8 +15,8 @@ :git/url "https://github.com/funcool/tubax.git"} funcool/potok2 - {:git/tag "v2.2" - :git/sha "0f7e15a" + {:git/tag "v2.3" + :git/sha "bdebbdb" :git/url "https://github.com/funcool/potok.git" :exclusions [funcool/beicon2]} @@ -26,8 +26,8 @@ :git/url "https://github.com/funcool/beicon.git"} funcool/rumext - {:git/tag "v2.25" - :git/sha "27e5a1a" + {:git/tag "v2.26" + :git/sha "d726717" :git/url "https://github.com/funcool/rumext.git"} instaparse/instaparse {:mvn/version "1.5.0"} diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs index 06ffc849da..635572996f 100644 --- a/frontend/src/app/main.cljs +++ b/frontend/src/app/main.cljs @@ -17,7 +17,7 @@ [app.main.data.profile :as dp] [app.main.data.websocket :as ws] [app.main.data.workspace.mcp :as mcp] - [app.main.errors] + [app.main.errors :as errors] [app.main.features :as feat] [app.main.rasterizer :as thr] [app.main.store :as st] @@ -136,6 +136,8 @@ (i18n/init) (cur/init-styles) + (mf/set-update-scheduler {:on-trip errors/on-update-loop}) + (init-ui) (st/emit! (plugins/initialize) (initialize))))) diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 928722f889..d2c4976024 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -100,6 +100,40 @@ (assoc ::instance cause) (assoc ::trace (.-stack cause))))) +;; Events in flight when each exception was caught, as report lines +(defonce ^:private in-flight-by-cause (js/WeakMap.)) + +;; Events kept from each end of a long in-flight list +(def ^:private in-flight-edge-size 10) + +(defn- in-flight-lines + "Report lines for the in-flight `events`, outermost first; a long list + keeps only both ends." + [events] + (let [total (count events) + size in-flight-edge-size] + (if (<= total (* 2 size)) + (mapv ptk/repr-event events) + (-> [] + (into (map ptk/repr-event) (take size events)) + (conj (str "... " (- total (* 2 size)) " more ...")) + (into (map ptk/repr-event) (take-last size events)))))) + +(defn- record-in-flight! + "Keeps the events the store is processing for the report of `cause`. + Runs synchronously on the error path: when an event's `update`, `effect` + or synchronous `watch` throws, the last line is that event. When a + `watch` pipeline fails on a value from the store stream, the store has + already finished that value, so the lines show the events that emitted + it, not the event that owns the pipeline." + [cause] + (try + (when (ex/exception? cause) + (when-let [events (not-empty (ptk/in-flight-events st/state))] + (.set in-flight-by-cause cause (in-flight-lines events)))) + (catch :default _ + nil))) + (defn on-error "A general purpose error handler. @@ -114,6 +148,7 @@ (ex/print-throwable error)) (do (vreset! handling-error? true) + (record-in-flight! (if (map? error) (::instance error) error)) (try (if (map? error) (ptk/handle-error error) @@ -166,8 +201,9 @@ (println "HREF: " (rt/get-current-href))))) (defn- generate-full-report - "Complete report: context, formatted throwable (including `ex-data`) and the - last events." + "Complete report: context, formatted throwable (including `ex-data`), the + events in flight when the error was caught (when recorded) and the last + events." [cause] (with-out-str (print (report-context cause)) @@ -177,6 +213,12 @@ (ex/format-throwable cause)) (println) + (when-let [lines (.get in-flight-by-cause cause)] + (println "Events in flight when the error was caught (outermost first):") + (println "--------------------") + (run! println lines) + (println)) + (println "Last events:") (println "--------------------") (println (st/format-last-events)) @@ -202,9 +244,9 @@ "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." + throwable, the in-flight 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 (case format @@ -392,6 +434,24 @@ hint occurrences)))) +(defonce ^:private update-loop-warned-at + (volatile! 0)) + +(defn on-update-loop + "Handler for the rumext update scheduler trips: logs the throttled + update loop with the events being processed, at most once per + `report-window-ms`. Runs inside store watchers and effect bodies, so it + never throws." + [_] + (try + (let [now (inst-ms (ct/now))] + (when (>= (- now @update-loop-warned-at) report-window-ms) + (vreset! update-loop-warned-at now) + (.warn js/console "[update-scheduler] update loop throttled; in-flight events:" + (into-array (map ptk/repr-event (ptk/in-flight-events st/state)))))) + (catch :default _ + nil))) + (defn- download-report! [report event] (dom/prevent-default event) diff --git a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs index 6b91ba1984..ae8397383e 100644 --- a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs @@ -402,7 +402,7 @@ (defn inside-vbox [vbox objects frame-id] (let [frame (get objects frame-id)] - (and (some? frame) (gsh/overlaps? frame vbox)))) + (and (some? frame) (some? vbox) (gsh/overlaps? frame vbox)))) (defn setup-active-frames [objects hover-ids selected active-frames zoom transform vbox] diff --git a/frontend/test/frontend_tests/main_errors_test.cljs b/frontend/test/frontend_tests/main_errors_test.cljs index a964620c15..c622647f84 100644 --- a/frontend/test/frontend_tests/main_errors_test.cljs +++ b/frontend/test/frontend_tests/main_errors_test.cljs @@ -11,6 +11,10 @@ - stale-asset-error? – pure predicate - exception->error-data – pure transformer - on-error re-entrancy guard – prevents recursive invocations + - events in flight – the full report lists the events the + store was processing when it caught the exception; a failing stream + pipeline lists the emitters of the value, not its own event + - on-update-loop – never throws into the scheduler's caller - 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 @@ -368,6 +372,144 @@ ;; The guard must have allowed only the first invocation through. (t/is (= 1 @reentrant-call-count)))) +;; --------------------------------------------------------------------------- +;; Events in flight +;; +;; on-error records the events the store is processing when it catches an +;; exception; the full report lists them, outermost first. +;; --------------------------------------------------------------------------- + +(def ^:private in-flight-cause (atom nil)) + +(defmethod ptk/handle-error ::test-in-flight + [err] + (reset! in-flight-cause (::errors/instance err)) + nil) + +(def ^:private in-flight-heading + "Events in flight when the error was caught (outermost first):") + +(defn- in-flight-failure + [] + (ex-info "in-flight failure" {:type ::test-in-flight :hint "in-flight failure"})) + +(defn- recursive-event + [n] + (ptk/reify ::test-in-flight-recursive + ptk/WatchEvent + (watch [_ _ _] + (if (pos? n) + (rx/of (recursive-event (dec n))) + (throw (in-flight-failure)))))) + +(t/deftest ^:async report-lists-the-events-in-flight-of-a-failing-event + (let [store (ptk/store {:state {} :on-error errors/on-error})] + (try + (await + (mock/with-mocks* + {st/state store + st/format-last-events (mock/stub (fn [& _] "(stub last events)")) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace")} + (reset! in-flight-cause nil) + (st/emit! + (ptk/reify ::test-in-flight-outer + ptk/WatchEvent + (watch [_ _ _] + (rx/of (ptk/reify ::test-in-flight-inner + ptk/UpdateEvent + (update [_ _] (throw (in-flight-failure)))))))) + (await (async/wait-for #(some? @in-flight-cause) "the failure reaches the handler")) + (let [report (errors/generate-report @in-flight-cause) + outer (.indexOf report "typ:(:frontend-tests.main-errors-test/test-in-flight-outer)") + inner (.indexOf report "typ:(:frontend-tests.main-errors-test/test-in-flight-inner)")] + (t/is (str/includes? report in-flight-heading)) + (t/is (< -1 outer inner) "outer event listed before the failing one")))) + (finally + (rx/dispose! store))))) + +(t/deftest ^:async report-keeps-both-ends-of-a-long-in-flight-list + (let [store (ptk/store {:state {} :on-error errors/on-error})] + (try + (await + (mock/with-mocks* + {st/state store + st/format-last-events (mock/stub (fn [& _] "(stub last events)")) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace")} + (reset! in-flight-cause nil) + ;; 25 nested events: 10 from each end are kept + (st/emit! (recursive-event 24)) + (await (async/wait-for #(some? @in-flight-cause) "the failure reaches the handler")) + (let [report (errors/generate-report @in-flight-cause)] + (t/is (str/includes? report "... 5 more ...")) + (t/is (= 20 (count (re-seq #"test-in-flight-recursive" report))))))) + (finally + (rx/dispose! store))))) + +(defn- in-flight-section + "The in-flight section of `report`, or nil when absent." + [report] + (let [start (.indexOf report in-flight-heading)] + (when (<= 0 start) + (subs report start (.indexOf report "Last events:" start))))) + +(t/deftest ^:async stream-pipeline-failure-lists-the-emitters-of-the-value + (let [store (ptk/store {:state {} :on-error errors/on-error})] + (try + (await + (mock/with-mocks* + {st/state store + st/format-last-events (mock/stub (fn [& _] "(stub last events)")) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace")} + (reset! in-flight-cause nil) + ;; The listener's pipeline fails on the trigger that the emitter emits + (st/emit! + (ptk/reify ::test-in-flight-listener + ptk/WatchEvent + (watch [_ _ stream] + (->> stream + (rx/filter (ptk/type? ::test-in-flight-trigger)) + (rx/take 1) + (rx/map (fn [_] (throw (in-flight-failure))))))) + (ptk/reify ::test-in-flight-emitter + ptk/WatchEvent + (watch [_ _ _] + (rx/of (ptk/data-event ::test-in-flight-trigger {}))))) + (await (async/wait-for #(some? @in-flight-cause) "the failure reaches the handler")) + (let [section (in-flight-section (errors/generate-report @in-flight-cause))] + (t/is (str/includes? section "test-in-flight-emitter")) + (t/is (not (str/includes? section "test-in-flight-trigger"))) + (t/is (not (str/includes? section "test-in-flight-listener")))))) + (finally + (rx/dispose! store))))) + +(t/deftest ^:async delayed-watch-failure-has-no-in-flight-section + (let [store (ptk/store {:state {} :on-error errors/on-error})] + (try + (await + (mock/with-mocks* + {st/state store + st/format-last-events (mock/stub (fn [& _] "(stub last events)")) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace")} + (reset! in-flight-cause nil) + (st/emit! + (ptk/reify ::test-in-flight-delayed + ptk/WatchEvent + (watch [_ _ _] + (->> (rx/timer 0) + (rx/map (fn [_] (throw (in-flight-failure)))))))) + (await (async/wait-for #(some? @in-flight-cause) "the failure reaches the handler")) + (let [report (errors/generate-report @in-flight-cause)] + (t/is (not (str/includes? report in-flight-heading))) + (t/is (str/includes? report "Last events:"))))) + (finally + (rx/dispose! store))))) + +(t/deftest ^:async update-loop-handler-never-throws + (await + (mock/with-mocks* + {ptk/in-flight-events (fn [_] (throw (js/Error. "in-flight read failure")))} + (t/is (nil? (errors/on-update-loop {:rounds 26})))))) + ;; --------------------------------------------------------------------------- ;; Expired organization SSO session ;; diff --git a/scripts/check-commit b/scripts/check-commit index 3f2792552e..d0e9f59899 100755 --- a/scripts/check-commit +++ b/scripts/check-commit @@ -47,10 +47,12 @@ MERGE_PATTERN = re.compile(r"^(Merge|Revert|Reapply).+[^.]$") MAX_BODY_LINE = 76 TRAILER_PATTERN = re.compile( - r"^(Signed-off-by|Co-authored-by|Co-developed-by|Reviewed-by|" + r"^(Signed-off-by|Co-developed-by|Reviewed-by|" r"Acked-by|Tested-by|Reported-by|Suggested-by|AI-assisted-by):" ) +FORBIDDEN_TRAILER_PATTERN = re.compile(r"^\s*Co-authored-by\s*:", re.IGNORECASE) + URL_PATTERN = re.compile(r"https?://\S+") # ═══════════════════════════════════════════════════════════════════════════════ @@ -209,6 +211,25 @@ def check_signed_off_by(message): return True, None +def check_no_co_authored_by(message): + """Check that the forbidden Co-authored-by trailer is absent.""" + offenders = [ + (line_number, line) + for line_number, line in enumerate(message.split("\n"), start=1) + if FORBIDDEN_TRAILER_PATTERN.match(line) + ] + if not offenders: + return True, None + details = "\n".join( + f" line {line_number}: {line!r}" + for line_number, line in offenders + ) + return False, ( + "The 'Co-authored-by:' trailer is forbidden and must not appear " + "in the commit message. Offending line(s):\n" + details + ) + + # ═══════════════════════════════════════════════════════════════════════════════ def main(): @@ -234,6 +255,7 @@ def main(): ("Subject capitalized", check_subject_capitalized), ("Blank line after subject", check_body_blank_line), ("Body wrapped at 76 chars", check_body_line_length), + ("No Co-authored-by trailer", check_no_co_authored_by), ] all_ok = True diff --git a/scripts/test_check_commit.py b/scripts/test_check_commit.py index 4e4940429e..a3a7a1c2b9 100644 --- a/scripts/test_check_commit.py +++ b/scripts/test_check_commit.py @@ -106,6 +106,35 @@ class BodyLineLengthTests(unittest.TestCase): self.assertIn("line 4", error) +class ForbiddenTrailerTests(unittest.TestCase): + def assert_ok(self, message): + ok, error = check_commit.check_no_co_authored_by(message) + self.assertTrue(ok, error) + self.assertIsNone(error) + + def assert_fail(self, message): + ok, error = check_commit.check_no_co_authored_by(message) + self.assertFalse(ok) + self.assertIsNotNone(error) + return error + + def test_body_without_trailer_passes(self): + self.assert_ok(":bug: Fix crash\n\nBody.\n\nSigned-off-by: Someone