@qoretechnologies/reqraft 0.10.10 → 0.10.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/providers/StorageProvider.d.ts.map +1 -1
  2. package/dist/providers/StorageProvider.js +14 -4
  3. package/dist/providers/StorageProvider.js.map +1 -1
  4. package/package.json +1 -1
  5. package/src/components/dpqlEditor/DpqlEditor.stories.tsx +74 -2
  6. package/src/components/form/engine/FormEngine.stories.tsx +654 -32
  7. package/src/components/form/engine/FormEngineRemote.stories.tsx +48 -0
  8. package/src/components/form/engine/_structuredData/StructuredDataView.stories.tsx +8 -0
  9. package/src/components/form/engine/variants/FormEngineVariants.stories.tsx +40 -0
  10. package/src/components/form/expressions/ExpressionField.stories.tsx +74 -2
  11. package/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx +187 -0
  12. package/src/components/form/fields/Field.stories.tsx +40 -0
  13. package/src/components/form/fields/array/ArrayAutoField.stories.tsx +64 -0
  14. package/src/components/form/fields/auto/AutoFormField.stories.tsx +136 -0
  15. package/src/components/form/fields/binary/Binary.stories.tsx +32 -0
  16. package/src/components/form/fields/boolean/Boolean.stories.tsx +24 -0
  17. package/src/components/form/fields/byte-size/ByteSize.stories.tsx +24 -0
  18. package/src/components/form/fields/color/Color.stories.tsx +30 -0
  19. package/src/components/form/fields/cron/Cron.stories.tsx +8 -0
  20. package/src/components/form/fields/date/Date.stories.tsx +22 -0
  21. package/src/components/form/fields/file/File.stories.tsx +48 -0
  22. package/src/components/form/fields/long-string/LongString.stories.tsx +24 -0
  23. package/src/components/form/fields/markdown/Markdown.stories.tsx +8 -0
  24. package/src/components/form/fields/number/Number.stories.tsx +31 -0
  25. package/src/components/form/fields/object/Object.stories.tsx +88 -0
  26. package/src/components/form/fields/radio-group/RadioGroup.stories.tsx +24 -0
  27. package/src/components/form/fields/rich-text/RichText.stories.tsx +23 -0
  28. package/src/components/form/fields/schema-definition/SchemaDefinitionField.stories.tsx +72 -0
  29. package/src/components/form/fields/select/Select.stories.tsx +104 -0
  30. package/src/components/form/fields/string/String.stories.tsx +16 -0
  31. package/src/components/form/fields/template/TemplateField.stories.tsx +160 -0
  32. package/src/components/form/fields/url/Url.stories.tsx +24 -0
  33. package/src/components/log/Log.stories.tsx +96 -0
  34. package/src/components/menu/Menu.stories.tsx +54 -0
  35. package/src/components/qonsoleSmartInput/QonsoleSmartInput.stories.tsx +14 -0
  36. package/src/components/smartEditor/SmartEditor.stories.tsx +32 -0
  37. package/src/hooks/useFetch/useFetch.stories.tsx +30 -0
  38. package/src/hooks/useStorage/useStorage.stories.tsx +82 -0
  39. package/src/hooks/useWebSocket/useWebsocket.stories.tsx +127 -1
  40. package/src/providers/StorageProvider.tsx +12 -4
  41. package/src/stores/currentUser/currentUser.stories.tsx +17 -0
@@ -93,6 +93,14 @@ export default meta;
93
93
  export type Story = StoryObj<typeof meta>;
94
94
 
95
95
  export const Basic: Story = {
96
+ parameters: {
97
+ docs: {
98
+ description: {
99
+ story:
100
+ 'Renders ReqraftLog wired to a mock WebSocket that emits a stream of test log lines — the messages accumulate in the log panel as they arrive.',
101
+ },
102
+ },
103
+ },
96
104
  play: async () => {
97
105
  await testsWaitForText('This is a message with a link: https://www.qoretechnologies.com');
98
106
  },
@@ -103,6 +111,14 @@ export const Filterable: Story = {
103
111
  args: {
104
112
  filterable: true,
105
113
  },
114
+ parameters: {
115
+ docs: {
116
+ description: {
117
+ story:
118
+ 'Renders ReqraftLog with the filter input enabled. Typing "error" narrows the visible list to the single ERROR message.',
119
+ },
120
+ },
121
+ },
106
122
  play: async (args) => {
107
123
  await Basic.play(args);
108
124
  await fireEvent.change(document.querySelector('.reqore-input'), { target: { value: 'error' } });
@@ -115,6 +131,14 @@ export const WithTimestamps: Story = {
115
131
  args: {
116
132
  showTimestamps: true,
117
133
  },
134
+ parameters: {
135
+ docs: {
136
+ description: {
137
+ story:
138
+ 'Renders ReqraftLog with per-message timestamps enabled — each streamed line is prefixed with its arrival time.',
139
+ },
140
+ },
141
+ },
118
142
  };
119
143
 
120
144
  export const WithDefaultMessages: Story = {
@@ -126,6 +150,14 @@ export const WithDefaultMessages: Story = {
126
150
  { message: 'This is another default message', timestamp: '08:00:00.000' },
127
151
  ],
128
152
  },
153
+ parameters: {
154
+ docs: {
155
+ description: {
156
+ story:
157
+ 'Renders ReqraftLog seeded with two default messages — those lines appear before any messages arrive from the socket.',
158
+ },
159
+ },
160
+ },
129
161
  };
130
162
 
131
163
  export const WithCopyableMessages: Story = {
@@ -133,6 +165,14 @@ export const WithCopyableMessages: Story = {
133
165
  args: {
134
166
  allowMessageCopy: true,
135
167
  },
168
+ parameters: {
169
+ docs: {
170
+ description: {
171
+ story:
172
+ 'Renders ReqraftLog with allowMessageCopy — each message row exposes a copy action next to its content.',
173
+ },
174
+ },
175
+ },
136
176
  play: async (args) => {
137
177
  await Basic.play(args);
138
178
  },
@@ -144,6 +184,14 @@ export const WithDeletableMessages: Story = {
144
184
  allowMessageCopy: true,
145
185
  allowMessageDeletion: true,
146
186
  },
187
+ parameters: {
188
+ docs: {
189
+ description: {
190
+ story:
191
+ 'Renders ReqraftLog with copy and delete actions on every message. Clicking a message\'s delete action removes just that row from the log.',
192
+ },
193
+ },
194
+ },
147
195
  play: async (args) => {
148
196
  await Basic.play(args);
149
197
  await testsClickButton({ selector: '.reqraft-log-delete-message', nth: 4 });
@@ -155,6 +203,14 @@ export const Pause: Story = {
155
203
  args: {
156
204
  allowMessageDeletion: true,
157
205
  },
206
+ parameters: {
207
+ docs: {
208
+ description: {
209
+ story:
210
+ 'Renders ReqraftLog while messages stream in. Clicking the pause/resume action halts appending new messages until it is clicked again.',
211
+ },
212
+ },
213
+ },
158
214
  play: async () => {
159
215
  await testsWaitForText('This is another message');
160
216
  await testsClickButton({ selector: '.reqraft-log-pause-resume' });
@@ -166,6 +222,14 @@ export const Clear: Story = {
166
222
  args: {
167
223
  allowMessageDeletion: true,
168
224
  },
225
+ parameters: {
226
+ docs: {
227
+ description: {
228
+ story:
229
+ 'Renders ReqraftLog with messages streamed in. Clicking the clear action empties the log and the "No messages" empty state is shown.',
230
+ },
231
+ },
232
+ },
169
233
  play: async (args) => {
170
234
  await Basic.play(args);
171
235
  await testsClickButton({ selector: '.reqraft-log-clear' });
@@ -192,6 +256,14 @@ export const FormattedMessages: Story = {
192
256
  return { message, ...rest };
193
257
  },
194
258
  },
259
+ parameters: {
260
+ docs: {
261
+ description: {
262
+ story:
263
+ 'Renders ReqraftLog with a messageFormatter that assigns intents by content — WARNING lines colour warning, ERROR lines danger and ALL-CAPS lines info.',
264
+ },
265
+ },
266
+ },
195
267
  play: async (args) => {
196
268
  await Basic.play(args);
197
269
  },
@@ -203,6 +275,14 @@ export const CanSendMessages: Story = {
203
275
  canSendMessages: true,
204
276
  showTimestamps: true,
205
277
  },
278
+ parameters: {
279
+ docs: {
280
+ description: {
281
+ story:
282
+ 'Renders ReqraftLog with canSendMessages — an input at the bottom lets the operator send a message back through the socket alongside the incoming stream.',
283
+ },
284
+ },
285
+ },
206
286
  play: async (args) => {
207
287
  await Basic.play(args);
208
288
  },
@@ -214,6 +294,14 @@ export const WithAutoScroll: Story = {
214
294
  autoScroll: true,
215
295
  style: { height: '200px' },
216
296
  },
297
+ parameters: {
298
+ docs: {
299
+ description: {
300
+ story:
301
+ 'Renders ReqraftLog with autoScroll in a fixed 200px viewport — the log stays pinned to the newest message as new lines arrive.',
302
+ },
303
+ },
304
+ },
217
305
  play: async (args) => {
218
306
  await Basic.play(args);
219
307
  },
@@ -225,6 +313,14 @@ export const WithAutoScrollAndManualScroll: Story = {
225
313
  autoScroll: true,
226
314
  style: { height: '200px' },
227
315
  },
316
+ parameters: {
317
+ docs: {
318
+ description: {
319
+ story:
320
+ 'Renders ReqraftLog with autoScroll while the operator scrolls the panel manually — auto-scroll pauses so the operator can inspect older messages without being yanked back to the tail.',
321
+ },
322
+ },
323
+ },
228
324
  play: async (args) => {
229
325
  await WithAutoScroll.play(args);
230
326
  await sleep(3000);
@@ -27,6 +27,14 @@ export const Basic: Story = {
27
27
  args: {
28
28
  menu: typedMenu,
29
29
  },
30
+ parameters: {
31
+ docs: {
32
+ description: {
33
+ story:
34
+ 'Renders ReqraftMenu with the mock Qorus navigation — sections and items appear in the sidebar with no active path highlighted.',
35
+ },
36
+ },
37
+ },
30
38
  play: async () => {
31
39
  await testsWaitForText('Interfaces & More');
32
40
  },
@@ -36,6 +44,14 @@ export const ActivePath: Story = {
36
44
  path: '/Interfaces/mapper',
37
45
  menu: typedMenu,
38
46
  },
47
+ parameters: {
48
+ docs: {
49
+ description: {
50
+ story:
51
+ 'Renders ReqraftMenu with the /Interfaces/mapper path — the matching menu entry is highlighted as active.',
52
+ },
53
+ },
54
+ },
39
55
  play: async () => {
40
56
  await testsWaitForText('Interfaces & More');
41
57
  },
@@ -47,6 +63,14 @@ export const ActiveMenuItemIntent: Story = {
47
63
  menu: typedMenu,
48
64
  activeItemIntent: 'success',
49
65
  },
66
+ parameters: {
67
+ docs: {
68
+ description: {
69
+ story:
70
+ 'Renders ReqraftMenu with the /Interfaces/mapper path and a "success" activeItemIntent — the active entry uses the success intent for its highlight colour.',
71
+ },
72
+ },
73
+ },
50
74
  play: async () => {
51
75
  await testsWaitForText('Interfaces & More');
52
76
  },
@@ -57,6 +81,14 @@ export const WithDefaultQuery: Story = {
57
81
  menu: typedMenu,
58
82
  defaultQuery: 'mapper',
59
83
  },
84
+ parameters: {
85
+ docs: {
86
+ description: {
87
+ story:
88
+ 'Renders ReqraftMenu with a defaultQuery of "mapper" — the search input is pre-filled and the visible items are narrowed to those matching the query.',
89
+ },
90
+ },
91
+ },
60
92
  play: async () => {
61
93
  await testsWaitForText('Interfaces & More');
62
94
  await expect(document.querySelector('.reqore-input')).toHaveValue('mapper');
@@ -69,6 +101,14 @@ export const Filtered: Story = {
69
101
  menu: typedMenu,
70
102
  onQueryChange: fn(),
71
103
  },
104
+ parameters: {
105
+ docs: {
106
+ description: {
107
+ story:
108
+ 'Renders ReqraftMenu with an onQueryChange handler. Typing "step" into the search filter narrows the visible items to just the matching entries.',
109
+ },
110
+ },
111
+ },
72
112
  play: async () => {
73
113
  await testsWaitForText('Interfaces & More');
74
114
  await fireEvent.change(document.querySelector('.reqore-input'), { target: { value: 'step' } });
@@ -82,6 +122,12 @@ export const Filtered: Story = {
82
122
  export const WidthFromStorage: Story = {
83
123
  ...ActivePath,
84
124
  parameters: {
125
+ docs: {
126
+ description: {
127
+ story:
128
+ 'Renders ReqraftMenu backed by user storage that already holds a persisted width — the menu mounts at the stored width rather than the default.',
129
+ },
130
+ },
85
131
  mockData: [...storiesStorageMock],
86
132
  },
87
133
  };
@@ -93,6 +139,14 @@ export const WithCustomChildren: Story = {
93
139
  topChildren: <div style={{ padding: 10 }}>Top Custom Child</div>,
94
140
  bottomChildren: <div style={{ padding: 10 }}>Bottom Custom Child</div>,
95
141
  },
142
+ parameters: {
143
+ docs: {
144
+ description: {
145
+ story:
146
+ 'Renders ReqraftMenu with topChildren and bottomChildren slots — custom nodes appear above and below the menu list.',
147
+ },
148
+ },
149
+ },
96
150
  play: async () => {
97
151
  await testsWaitForText('Interfaces & More');
98
152
  await testsWaitForText('Top Custom Child');
@@ -398,6 +398,14 @@ export const BasicMock: Story = {
398
398
  args: {
399
399
  initialValue: '/list services ',
400
400
  },
401
+ parameters: {
402
+ docs: {
403
+ description: {
404
+ story:
405
+ 'Renders the QonsoleSmartInput against a mocked Qonsole LSP. Typing "-" after "/list services " opens the flag dropdown with --desc, --limit and --search; pressing Enter accepts the first item without producing a stray double dash.',
406
+ },
407
+ },
408
+ },
401
409
  async beforeEach() {
402
410
  const server = setupBasicQonsoleMockServer();
403
411
  return () => server.close();
@@ -509,6 +517,12 @@ export const LiveQonsoleWithContext: Story = {
509
517
  },
510
518
  parameters: {
511
519
  chromatic: { disable: true },
520
+ docs: {
521
+ description: {
522
+ story:
523
+ 'Renders the QonsoleSmartInput against the real Qonsole LSP with a pre-bound "/use services" context — predictive text is scoped to the services resource via the qonsole/setContext call.',
524
+ },
525
+ },
512
526
  },
513
527
  };
514
528
 
@@ -72,6 +72,14 @@ export const BasicMock: Story = {
72
72
  initialValue: '',
73
73
  triggerCharacters: new Set([' ', '.']),
74
74
  },
75
+ parameters: {
76
+ docs: {
77
+ description: {
78
+ story:
79
+ 'Renders the SmartEditor against a mocked LSP server. Typing the "." trigger character sends a completion request and the dropdown mounts with the apple and banana suggestions.',
80
+ },
81
+ },
82
+ },
75
83
  async beforeEach() {
76
84
  const lsp = createMockLspServer(MOCK_LSP_URL, {
77
85
  capabilities: {},
@@ -295,6 +303,14 @@ export const WithDiagnostics: Story = {
295
303
  languageId: 'demo',
296
304
  initialValue: 'list services in pricing',
297
305
  },
306
+ parameters: {
307
+ docs: {
308
+ description: {
309
+ story:
310
+ 'Renders the SmartEditor against a mocked LSP server that pushes diagnostics on didOpen — the text is decorated with wavy underlines and the stacked message panel below the editor lists both diagnostics.',
311
+ },
312
+ },
313
+ },
298
314
  async beforeEach() {
299
315
  const lsp = createMockLspServer(MOCK_LSP_URL, {
300
316
  capabilities: {},
@@ -342,6 +358,14 @@ export const ReadOnly: Story = {
342
358
  initialValue: 'frozen content',
343
359
  readOnly: true,
344
360
  },
361
+ parameters: {
362
+ docs: {
363
+ description: {
364
+ story:
365
+ 'Renders the SmartEditor in read-only mode over an initial value — the Slate editor mounts as contenteditable=false so the content shows but cannot be edited.',
366
+ },
367
+ },
368
+ },
345
369
  async beforeEach() {
346
370
  const lsp = createMockLspServer(MOCK_LSP_URL, { capabilities: {} });
347
371
  return () => lsp.close();
@@ -367,6 +391,14 @@ export const LoadingOverlay: Story = {
367
391
  languageId: 'demo',
368
392
  initialValue: 'visible under the overlay',
369
393
  },
394
+ parameters: {
395
+ docs: {
396
+ description: {
397
+ story:
398
+ 'Renders the SmartEditor against a mock LSP that never finishes initialize — the "Connecting to language server" overlay stays over the editor for the whole session.',
399
+ },
400
+ },
401
+ },
370
402
  async beforeEach() {
371
403
  const lsp = createMockLspServer(MOCK_LSP_URL, {
372
404
  capabilities: {},
@@ -35,6 +35,12 @@ export const Get: Story = {
35
35
  method: 'GET',
36
36
  },
37
37
  parameters: {
38
+ docs: {
39
+ description: {
40
+ story:
41
+ 'Renders a useFetch demo that issues a GET against /public/info on mount and displays the returned Qorus system info as a tree.',
42
+ },
43
+ },
38
44
  mockData: [
39
45
  {
40
46
  // An array of mock objects which will add in every story
@@ -66,6 +72,12 @@ export const Put: Story = {
66
72
  method: 'PUT',
67
73
  },
68
74
  parameters: {
75
+ docs: {
76
+ description: {
77
+ story:
78
+ 'Renders a useFetch demo that issues a PUT against /public/info on mount and displays the returned success status.',
79
+ },
80
+ },
69
81
  mockData: [
70
82
  {
71
83
  // An array of mock objects which will add in every story
@@ -88,6 +100,12 @@ export const Post: Story = {
88
100
  method: 'POST',
89
101
  },
90
102
  parameters: {
103
+ docs: {
104
+ description: {
105
+ story:
106
+ 'Renders a useFetch demo that issues a POST against /public/info on mount and displays the returned success status.',
107
+ },
108
+ },
91
109
  mockData: [
92
110
  {
93
111
  // An array of mock objects which will add in every story
@@ -110,6 +128,12 @@ export const Del: Story = {
110
128
  method: 'DELETE',
111
129
  },
112
130
  parameters: {
131
+ docs: {
132
+ description: {
133
+ story:
134
+ 'Renders a useFetch demo that issues a DELETE against /public/info on mount and displays the returned success status.',
135
+ },
136
+ },
113
137
  mockData: [
114
138
  {
115
139
  // An array of mock objects which will add in every story
@@ -132,6 +156,12 @@ export const Error409: Story = {
132
156
  method: 'POST',
133
157
  },
134
158
  parameters: {
159
+ docs: {
160
+ description: {
161
+ story:
162
+ 'Renders a useFetch demo that issues a POST returning a 409 error — the hook exposes errorData and the Qorus stack trace is rendered in place of the response.',
163
+ },
164
+ },
135
165
  mockData: [
136
166
  {
137
167
  // An array of mock objects which will add in every story
@@ -1,5 +1,6 @@
1
1
  import { ReqoreButton, ReqoreControlGroup, ReqoreP } from '@qoretechnologies/reqore';
2
2
  import { StoryObj } from '@storybook/react-vite';
3
+ import { useRef } from 'react';
3
4
  import { fireEvent, within } from 'storybook/test';
4
5
  import { storiesStorageMock } from '../../../__tests__/ mock';
5
6
  import { testsWaitForText } from '../../../__tests__/utils';
@@ -35,6 +36,12 @@ export const DefaultValue: Story = {
35
36
  method: 'GET',
36
37
  },
37
38
  parameters: {
39
+ docs: {
40
+ description: {
41
+ story:
42
+ 'Renders a useReqraftStorage demo where the server returns no stored value — the hook falls back to the default and displays it.',
43
+ },
44
+ },
38
45
  mockData: [
39
46
  {
40
47
  url: 'https://hq.qoretechnologies.com:8092/api/latest/users?action=current',
@@ -51,6 +58,12 @@ export const DefaultValue: Story = {
51
58
 
52
59
  export const StorageValue: Story = {
53
60
  parameters: {
61
+ docs: {
62
+ description: {
63
+ story:
64
+ 'Renders a useReqraftStorage demo with a value already persisted on the server — the hook loads it and displays it in place of the default.',
65
+ },
66
+ },
54
67
  mockData: [...storiesStorageMock],
55
68
  },
56
69
  play: async () => {
@@ -60,6 +73,15 @@ export const StorageValue: Story = {
60
73
 
61
74
  export const ValueCanBeUpdated: Story = {
62
75
  ...StorageValue,
76
+ parameters: {
77
+ ...StorageValue.parameters,
78
+ docs: {
79
+ description: {
80
+ story:
81
+ 'Renders the useReqraftStorage demo with a stored value. When "Update storage" is clicked, the setter writes a new value and the display refreshes to match.',
82
+ },
83
+ },
84
+ },
63
85
  play: async ({ canvasElement }) => {
64
86
  const canvas = within(canvasElement);
65
87
 
@@ -72,8 +94,68 @@ export const ValueCanBeUpdated: Story = {
72
94
  },
73
95
  };
74
96
 
97
+ /**
98
+ * Regression for the "stale updater clobbers other keys" bug: the storage
99
+ * updater must persist onto the LATEST storage blob, not the one captured in
100
+ * its closure. Long-lived callers (an imperative store subscription, a ref to
101
+ * the mount-time setter) hold an old updater; writing through it must not wipe
102
+ * keys other hooks wrote in the meantime.
103
+ */
104
+ export const StaleUpdaterDoesNotClobberOtherKeys: Story = {
105
+ args: { reqraftOptions: { waitForStorage: true } },
106
+ parameters: {
107
+ docs: {
108
+ description: {
109
+ story:
110
+ "Renders two useReqraftStorage keys and captures the mount-time setter for the first one (a deliberately STALE updater). After a second key is written, writing through the stale updater must keep the second key intact — proving storage writes merge onto the latest blob rather than a closure copy. This is the regression test for page-visit tracking wiping other stored settings.",
111
+ },
112
+ },
113
+ mockData: [...storiesStorageMock],
114
+ },
115
+ render: () => {
116
+ const [someVal, setSomeVal] = useReqraftStorage<string>('some-path', 'default');
117
+ const [keyB] = useReqraftStorage<string>('key-b', 'b-default');
118
+ const [, setKeyB] = useReqraftStorage<string>('key-b', 'b-default');
119
+ // Capture the mount-time setter — it stays stale once other keys change.
120
+ const staleSetSomeVal = useRef(setSomeVal);
121
+
122
+ return (
123
+ <ReqoreControlGroup vertical>
124
+ <ReqoreP>{`some-path: ${someVal}`}</ReqoreP>
125
+ <ReqoreP>{`key-b: ${keyB}`}</ReqoreP>
126
+ <ReqoreButton onClick={() => setKeyB('b-written')}>Write B</ReqoreButton>
127
+ <ReqoreButton onClick={() => staleSetSomeVal.current('a-written')}>
128
+ Write A (stale)
129
+ </ReqoreButton>
130
+ </ReqoreControlGroup>
131
+ );
132
+ },
133
+ play: async ({ canvasElement }) => {
134
+ const canvas = within(canvasElement);
135
+
136
+ await testsWaitForText('some-path: This is a storage value');
137
+ // Write a second key through its own (fresh) updater.
138
+ await fireEvent.click(canvas.getByText('Write B'));
139
+ await testsWaitForText('key-b: b-written');
140
+ // Write the first key through the STALE mount-time updater.
141
+ await fireEvent.click(canvas.getByText('Write A (stale)'));
142
+ await testsWaitForText('some-path: a-written');
143
+ // The stale write must NOT have wiped the second key.
144
+ await testsWaitForText('key-b: b-written');
145
+ },
146
+ };
147
+
75
148
  export const ValueCanBeRemoved: Story = {
76
149
  ...StorageValue,
150
+ parameters: {
151
+ ...StorageValue.parameters,
152
+ docs: {
153
+ description: {
154
+ story:
155
+ 'Renders the useReqraftStorage demo with a stored value. When "Remove value" is clicked, the remover clears storage and the hook falls back to the default value.',
156
+ },
157
+ },
158
+ },
77
159
  play: async ({ canvasElement }) => {
78
160
  const canvas = within(canvasElement);
79
161