mirror of
https://github.com/penpot/penpot.git
synced 2026-09-24 21:06:14 +00:00
🐛 Fix silent fail in plugin API interactions (#11781)
This commit is contained in:
parent
31b73460c3
commit
efb10c82a8
@ -210,7 +210,7 @@
|
|||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ _ _]
|
(watch [_ _ _]
|
||||||
(let [interactions (ctsi/update-interaction (:interactions shape) index update-fn)
|
(let [interactions (ctsi/update-interaction (:interactions shape) index update-fn)
|
||||||
interaction (nth interactions index)]
|
interaction (get interactions index)]
|
||||||
(rx/of
|
(rx/of
|
||||||
(dwsh/update-shapes
|
(dwsh/update-shapes
|
||||||
[(:id shape)]
|
[(:id shape)]
|
||||||
|
|||||||
@ -105,7 +105,8 @@
|
|||||||
:get #(-> % u/proxy->interaction :event-type format/format-key)
|
:get #(-> % u/proxy->interaction :event-type format/format-key)
|
||||||
:set
|
:set
|
||||||
(fn [_ value]
|
(fn [_ value]
|
||||||
(let [value (parser/parse-keyword value)]
|
(let [value (parser/parse-keyword value)
|
||||||
|
index (locate-index)]
|
||||||
(cond
|
(cond
|
||||||
(not (contains? ctsi/event-types value))
|
(not (contains? ctsi/event-types value))
|
||||||
(u/not-valid plugin-id :trigger value)
|
(u/not-valid plugin-id :trigger value)
|
||||||
@ -113,11 +114,14 @@
|
|||||||
(not (r/check-permission plugin-id "content:write"))
|
(not (r/check-permission plugin-id "content:write"))
|
||||||
(u/not-valid plugin-id :trigger "Plugin doesn't have 'content:write' permission")
|
(u/not-valid plugin-id :trigger "Plugin doesn't have 'content:write' permission")
|
||||||
|
|
||||||
|
(nil? index)
|
||||||
|
(u/not-valid plugin-id :trigger "The interaction is not part of the shape anymore")
|
||||||
|
|
||||||
:else
|
:else
|
||||||
(do
|
(do
|
||||||
(st/emit! (dwi/update-interaction
|
(st/emit! (dwi/update-interaction
|
||||||
(u/locate-shape file-id page-id shape-id)
|
(u/locate-shape file-id page-id shape-id)
|
||||||
(locate-index)
|
index
|
||||||
#(assoc % :event-type value)
|
#(assoc % :event-type value)
|
||||||
{:page-id page-id}))
|
{:page-id page-id}))
|
||||||
(swap! current assoc :event-type value)))))}
|
(swap! current assoc :event-type value)))))}
|
||||||
@ -127,21 +131,25 @@
|
|||||||
:get #(-> % u/proxy->interaction :delay)
|
:get #(-> % u/proxy->interaction :delay)
|
||||||
:set
|
:set
|
||||||
(fn [_ value]
|
(fn [_ value]
|
||||||
(cond
|
(let [index (locate-index)]
|
||||||
(or (not (sm/valid-safe-int? value)) (neg? value))
|
(cond
|
||||||
(u/not-valid plugin-id :delay value)
|
(or (not (sm/valid-safe-int? value)) (neg? value))
|
||||||
|
(u/not-valid plugin-id :delay value)
|
||||||
|
|
||||||
(not (r/check-permission plugin-id "content:write"))
|
(not (r/check-permission plugin-id "content:write"))
|
||||||
(u/not-valid plugin-id :delay "Plugin doesn't have 'content:write' permission")
|
(u/not-valid plugin-id :delay "Plugin doesn't have 'content:write' permission")
|
||||||
|
|
||||||
:else
|
(nil? index)
|
||||||
(do
|
(u/not-valid plugin-id :delay "The interaction is not part of the shape anymore")
|
||||||
(st/emit! (dwi/update-interaction
|
|
||||||
(u/locate-shape file-id page-id shape-id)
|
:else
|
||||||
(locate-index)
|
(do
|
||||||
#(assoc % :delay value)
|
(st/emit! (dwi/update-interaction
|
||||||
{:page-id page-id}))
|
(u/locate-shape file-id page-id shape-id)
|
||||||
(swap! current assoc :delay value))))}
|
index
|
||||||
|
#(assoc % :delay value)
|
||||||
|
{:page-id page-id}))
|
||||||
|
(swap! current assoc :delay value)))))}
|
||||||
|
|
||||||
:action
|
:action
|
||||||
{:this true
|
{:this true
|
||||||
@ -149,21 +157,27 @@
|
|||||||
:set
|
:set
|
||||||
(fn [self value]
|
(fn [self value]
|
||||||
(let [params (parser/parse-action value)
|
(let [params (parser/parse-action value)
|
||||||
|
index (locate-index)
|
||||||
interaction
|
interaction
|
||||||
(-> (u/proxy->interaction self)
|
(-> (u/proxy->interaction self)
|
||||||
(d/patch-object params))]
|
(d/patch-object params))]
|
||||||
(cond
|
(cond
|
||||||
(not (sm/validate ctsi/schema:interaction interaction))
|
|
||||||
(u/not-valid plugin-id :action interaction)
|
|
||||||
|
|
||||||
(not (r/check-permission plugin-id "content:write"))
|
(not (r/check-permission plugin-id "content:write"))
|
||||||
(u/not-valid plugin-id :action "Plugin doesn't have 'content:write' permission")
|
(u/not-valid plugin-id :action "Plugin doesn't have 'content:write' permission")
|
||||||
|
|
||||||
|
;; Precedes the schema check, which sees only the partial map that
|
||||||
|
;; patching a missing interaction produces.
|
||||||
|
(nil? index)
|
||||||
|
(u/not-valid plugin-id :action "The interaction is not part of the shape anymore")
|
||||||
|
|
||||||
|
(not (sm/validate ctsi/schema:interaction interaction))
|
||||||
|
(u/not-valid plugin-id :action interaction)
|
||||||
|
|
||||||
:else
|
:else
|
||||||
(do
|
(do
|
||||||
(st/emit! (dwi/update-interaction
|
(st/emit! (dwi/update-interaction
|
||||||
(u/locate-shape file-id page-id shape-id)
|
(u/locate-shape file-id page-id shape-id)
|
||||||
(locate-index)
|
index
|
||||||
#(d/patch-object % params)
|
#(d/patch-object % params)
|
||||||
{:page-id page-id}))
|
{:page-id page-id}))
|
||||||
(reset! current interaction)))))}
|
(reset! current interaction)))))}
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
- **plugins-runtime**: An interaction obtained from `Shape.interactions` now keeps addressing that interaction instead of the position it held when the array was read. Removing every interaction of a shape from a single read removes all of them rather than leaving some behind, and writing through a held interaction after an earlier one is removed no longer lands on a different interaction.
|
- **plugins-runtime**: An interaction obtained from `Shape.interactions` now keeps addressing that interaction instead of the position it held when the array was read. Removing every interaction of a shape from a single read removes all of them rather than leaving some behind, and writing through a held interaction after an earlier one is removed no longer lands on a different interaction.
|
||||||
- **plugins-runtime**: `Shape.removeInteraction()` now rejects an interaction belonging to a different shape with a validation error, instead of removing whichever interaction sat at the same position on the target shape.
|
- **plugins-runtime**: `Shape.removeInteraction()` now rejects an interaction belonging to a different shape with a validation error, instead of removing whichever interaction sat at the same position on the target shape.
|
||||||
|
- **plugins-runtime**: Writing `trigger`, `delay` or `action` on an interaction the shape no longer has now raises a validation error. The write used to be sent to the workspace with no position to apply it at, where it failed out of the plugin's reach: nothing was written and nothing was reported.
|
||||||
- **plugins-runtime**: `Library.createComponent()` now rejects invalid input (an empty shape list, or a shape inside a component copy) with a validation error instead of returning a component proxy pointing at nothing.
|
- **plugins-runtime**: `Library.createComponent()` now rejects invalid input (an empty shape list, or a shape inside a component copy) with a validation error instead of returning a component proxy pointing at nothing.
|
||||||
- **plugins-runtime**: Setting an individual padding/margin side (`leftPadding`, `topMargin`, …) now re-derives the padding/margin type, switching to `multiple` when the four sides stop being symmetric (so the value is actually painted) and back to `simple` once top/bottom and left/right are mirrored again.
|
- **plugins-runtime**: Setting an individual padding/margin side (`leftPadding`, `topMargin`, …) now re-derives the padding/margin type, switching to `multiple` when the four sides stop being symmetric (so the value is actually painted) and back to `simple` once top/bottom and left/right are mirrored again.
|
||||||
|
|
||||||
|
|||||||
@ -69,6 +69,27 @@ E2E_LOGIN_EMAIL=… E2E_LOGIN_PASSWORD=… \
|
|||||||
- `PRINT_UNCOVERED=1` dumps the uncovered targets per interface; `PRINT_STATIC=1`
|
- `PRINT_UNCOVERED=1` dumps the uncovered targets per interface; `PRINT_STATIC=1`
|
||||||
dumps the statically-covered ones (see [Coverage](#how-coverage-works-and-how-to-write-tests-that-move-it)).
|
dumps the statically-covered ones (see [Coverage](#how-coverage-works-and-how-to-write-tests-that-move-it)).
|
||||||
|
|
||||||
|
### Errors the app reports on its own
|
||||||
|
|
||||||
|
An API call can leave the plugin happy and still break Penpot: an exception
|
||||||
|
raised inside an event handler is caught by the store, which reports it and
|
||||||
|
carries on, so nothing is thrown back across the sandbox. The CI runner watches
|
||||||
|
the page console for the prefixes Penpot's error handler prints (`Internal
|
||||||
|
Error`, `Unexpected Error`, `Assertion Error`, `Uncaught Exception`, `Uncaught
|
||||||
|
Rejection`) and for uncaught page errors, and fails the test that was running.
|
||||||
|
|
||||||
|
Two consequences when writing a test for such a case:
|
||||||
|
|
||||||
|
- Await the API (`await ctx.penpot.waitForLayoutUpdate()`) after the operation,
|
||||||
|
so the message reaches the console before the test ends and is attributed to
|
||||||
|
it rather than to the next one.
|
||||||
|
- This runs in the CI runner only. The plugin UI cannot read the page console,
|
||||||
|
so the same test shows green there — check it with `test:ci` or
|
||||||
|
`test:ci:mocked`.
|
||||||
|
|
||||||
|
`Plugin Error` and `Network Error` are not watched: tests provoke both on
|
||||||
|
purpose.
|
||||||
|
|
||||||
CI entry points reuse the exact same test files (`src/ci/headless.ts` discovers
|
CI entry points reuse the exact same test files (`src/ci/headless.ts` discovers
|
||||||
them the same way the plugin does).
|
them the same way the plugin does).
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,13 @@ const frontendDir = resolve(repoRoot, 'frontend');
|
|||||||
const staticRoot = resolve(frontendDir, 'resources/public');
|
const staticRoot = resolve(frontendDir, 'resources/public');
|
||||||
const e2eDataDir = resolve(frontendDir, 'playwright/data');
|
const e2eDataDir = resolve(frontendDir, 'playwright/data');
|
||||||
|
|
||||||
|
// Console prefixes Penpot's error handler prints for failures the app did not
|
||||||
|
// expect (`frontend/src/app/main/errors.cljs`). The store swallows these, so
|
||||||
|
// the console is the only place a test can observe them. "Plugin Error" and
|
||||||
|
// "Network Error" are left out: tests provoke both on purpose.
|
||||||
|
const APP_ERROR_RE =
|
||||||
|
/^(Internal Error|Unexpected Error|Assertion Error|Uncaught Exception|Uncaught Rejection):/;
|
||||||
|
|
||||||
const MOCKED = !!process.env['MOCK_BACKEND'];
|
const MOCKED = !!process.env['MOCK_BACKEND'];
|
||||||
const MOCK_BASE_URL = 'http://localhost:3000';
|
const MOCK_BASE_URL = 'http://localhost:3000';
|
||||||
const apiUrl = MOCKED
|
const apiUrl = MOCKED
|
||||||
@ -393,13 +400,36 @@ async function main() {
|
|||||||
let fatal: string | null = null;
|
let fatal: string | null = null;
|
||||||
|
|
||||||
console.log('\nRunning tests:');
|
console.log('\nRunning tests:');
|
||||||
|
// Errors the app reported since the previous test result.
|
||||||
|
let appErrors: string[] = [];
|
||||||
|
const takeAppErrors = (): string => {
|
||||||
|
const detail = appErrors.join('; ');
|
||||||
|
appErrors = [];
|
||||||
|
return detail;
|
||||||
|
};
|
||||||
|
|
||||||
const done = new Promise<void>((resolvePromise) => {
|
const done = new Promise<void>((resolvePromise) => {
|
||||||
|
page.on('pageerror', (err) => {
|
||||||
|
appErrors.push(`Uncaught ${err.message}`);
|
||||||
|
});
|
||||||
page.on('console', (msg) => {
|
page.on('console', (msg) => {
|
||||||
const text = msg.text();
|
const text = msg.text();
|
||||||
|
if (APP_ERROR_RE.test(text)) {
|
||||||
|
appErrors.push(text.split('\n')[0]!.trim());
|
||||||
|
}
|
||||||
if (text.startsWith('__TEST_RESULT__ ')) {
|
if (text.startsWith('__TEST_RESULT__ ')) {
|
||||||
const result: TestResult = JSON.parse(
|
const result: TestResult = JSON.parse(
|
||||||
text.slice('__TEST_RESULT__ '.length),
|
text.slice('__TEST_RESULT__ '.length),
|
||||||
);
|
);
|
||||||
|
// Errors buffered so far belong to the test this result closes.
|
||||||
|
const reported = takeAppErrors();
|
||||||
|
if (reported) {
|
||||||
|
result.error =
|
||||||
|
result.status === 'fail' && result.error
|
||||||
|
? `${result.error} — Penpot also reported: ${reported}`
|
||||||
|
: `Penpot reported an error during the test: ${reported}`;
|
||||||
|
result.status = 'fail';
|
||||||
|
}
|
||||||
results.push(result);
|
results.push(result);
|
||||||
// Print each result as it streams in so the run shows live progress
|
// Print each result as it streams in so the run shows live progress
|
||||||
// instead of staying silent until it finishes.
|
// instead of staying silent until it finishes.
|
||||||
|
|||||||
@ -413,6 +413,28 @@ describe('Interactions', () => {
|
|||||||
expect(r.interactions.map((i) => i.delay)).toEqual([null, 500]);
|
expect(r.interactions.map((i) => i.delay)).toEqual([null, 500]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A proxy whose own interaction is gone resolves to no position, so the write
|
||||||
|
// has nothing to address and the API rejects it.
|
||||||
|
test('a write through a removed interaction is rejected', async (ctx) => {
|
||||||
|
const r = rect(ctx);
|
||||||
|
r.addInteraction('click', {
|
||||||
|
type: 'open-url',
|
||||||
|
url: 'https://example.com',
|
||||||
|
});
|
||||||
|
await ctx.penpot.waitForLayoutUpdate();
|
||||||
|
const [only] = r.interactions;
|
||||||
|
|
||||||
|
only.remove();
|
||||||
|
await ctx.penpot.waitForLayoutUpdate();
|
||||||
|
expect(r.interactions).toHaveLength(0);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
only.delay = 500;
|
||||||
|
}).toThrow('The interaction is not part of the shape anymore');
|
||||||
|
await ctx.penpot.waitForLayoutUpdate();
|
||||||
|
expect(r.interactions).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('interaction trigger can be changed', (ctx) => {
|
test('interaction trigger can be changed', (ctx) => {
|
||||||
const dest = board(ctx);
|
const dest = board(ctx);
|
||||||
const r = rect(ctx);
|
const r = rect(ctx);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user