@lowdefy/server-dev 0.0.0-experimental-20260906143808 → 0.0.0-experimental-20260908103554
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.
- package/lib/client/auth/AuthConfigured.jsx +7 -2
- package/lib/client/auth/AuthNotConfigured.js +2 -0
- package/lib/docs/createDocsMcpServer.js +2 -2
- package/lib/docs/getOverview.js +1 -1
- package/lib/docs/runJourney.js +179 -45
- package/lib/docs/runJourney.test.mjs +367 -1
- package/lib/docs/validateJourneySteps.js +90 -19
- package/lib/docs/validateJourneySteps.test.mjs +83 -3
- package/package.json +35 -35
- package/package.original.json +35 -35
|
@@ -18,6 +18,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
|
|
18
18
|
import { createAuthClient } from 'better-auth/react';
|
|
19
19
|
import {
|
|
20
20
|
adminClient,
|
|
21
|
+
emailOTPClient,
|
|
21
22
|
magicLinkClient,
|
|
22
23
|
organizationClient,
|
|
23
24
|
phoneNumberClient,
|
|
@@ -44,6 +45,7 @@ const authClient = createAuthClient({
|
|
|
44
45
|
baseURL: `${window.location.origin}${lowdefyConfig.basePath ?? ''}/api/auth`,
|
|
45
46
|
plugins: [
|
|
46
47
|
adminClient(),
|
|
48
|
+
emailOTPClient(),
|
|
47
49
|
magicLinkClient(),
|
|
48
50
|
oauthProviderClient(),
|
|
49
51
|
organizationClient(),
|
|
@@ -57,8 +59,8 @@ const authClient = createAuthClient({
|
|
|
57
59
|
// signal-driven refetch on the calls that would otherwise fire one, so
|
|
58
60
|
// nothing competes with (and aborts) the refetch UpdateSession awaits.
|
|
59
61
|
// Only calls that mutate an existing session are suppressed - calls that
|
|
60
|
-
// can establish one (the sign-ins, and the two-factor, phone-number
|
|
61
|
-
// passkey verifies) keep their signal: its refetch is what makes the store
|
|
62
|
+
// can establish one (the sign-ins, and the two-factor, phone-number,
|
|
63
|
+
// email-otp and passkey verifies) keep their signal: its refetch is what makes the store
|
|
62
64
|
// notice a login when no navigation follows.
|
|
63
65
|
// disableSignal silences all atomListeners for the call, including the
|
|
64
66
|
// organization plugin's org atoms - none are exposed to app config, so a
|
|
@@ -193,6 +195,9 @@ function AuthConfigured({ authConfig, children, serverUser }) {
|
|
|
193
195
|
// oauth_query contract as oauth2Consent: the signed query rides from
|
|
194
196
|
// window.location.search, so the call must run before any navigation.
|
|
195
197
|
oauth2Continue: (params) => authClient.oauth2.continue(params),
|
|
198
|
+
// The one-time email code: send, then sign in with it.
|
|
199
|
+
emailOtpSend: (params) => authClient.emailOtp.sendVerificationOtp(params),
|
|
200
|
+
emailOtpVerify: (params) => authClient.signIn.emailOtp(params),
|
|
196
201
|
phoneNumberRequestPasswordReset: (params) =>
|
|
197
202
|
authClient.phoneNumber.requestPasswordReset(params),
|
|
198
203
|
phoneNumberResetPassword: (params) => authClient.phoneNumber.resetPassword(params),
|
|
@@ -39,6 +39,8 @@ function AuthNotConfigured({ authConfig, children, user = null }) {
|
|
|
39
39
|
listOrganizations: authNotConfigured,
|
|
40
40
|
oauth2Consent: authNotConfigured,
|
|
41
41
|
oauth2Continue: authNotConfigured,
|
|
42
|
+
emailOtpSend: authNotConfigured,
|
|
43
|
+
emailOtpVerify: authNotConfigured,
|
|
42
44
|
phoneNumberRequestPasswordReset: authNotConfigured,
|
|
43
45
|
phoneNumberResetPassword: authNotConfigured,
|
|
44
46
|
phoneNumberSendOtp: authNotConfigured,
|
|
@@ -445,13 +445,13 @@ function createDocsMcpServer({ origin, honoContext } = {}) {
|
|
|
445
445
|
'lowdefy_run_journey',
|
|
446
446
|
{
|
|
447
447
|
description:
|
|
448
|
-
'Drive a page of the running dev server headless through declarative steps and assert what happens — the way to verify behaviour (a form submits, a modal opens, a filter works), not just layout. Blocks are addressed by blockId. A step that fails stops the journey and is returned as data (passed: false, failure with index/step/expected/actual/message, later steps "skipped") — never as a tool error. Always returns the final page state and any screenshots taken (as images after the JSON text).',
|
|
448
|
+
'Drive a page of the running dev server headless through declarative steps and assert what happens — the way to verify behaviour (a form submits, a modal opens, a filter works), not just layout. Blocks are addressed by blockId; a target object narrows to a grid row/cell ({"blockId": "grid", "row": 1, "column": "actions"}), to the control with exactly some text ({"blockId": "grid", "row": 1, "text": "Edit"}), or reaches portal-rendered controls page-wide by text alone ({"text": "OK"} for a confirm dialog or modal footer button, a menu item). A step that fails stops the journey and is returned as data (passed: false, failure with index/step/expected/actual/message, later steps "skipped") — never as a tool error. Always returns the final page state and any screenshots taken (as images after the JSON text).',
|
|
449
449
|
inputSchema: {
|
|
450
450
|
pageId: z.string().describe('The page id to open.'),
|
|
451
451
|
steps: z
|
|
452
452
|
.array(z.record(z.any()))
|
|
453
453
|
.describe(
|
|
454
|
-
'Ordered steps, one key each: {"click":
|
|
454
|
+
'Ordered steps, one key each: {"click": target} | {"fill": {...target, "value"}} | {"select": {...target, "value"}} (option by exact text) | {"press": "Enter" | "Mod+k"} (Mod is Meta/Control per platform) | {"wait": {"ms": n} | {"request": requestId} | {"state": path}} | {"screenshot": name?} | {"expect": {"state": {"path", "equals"}} | {"visible": target} | {"text": {...target, "contains"}} | {"url": {"contains"}}}. A target is a blockId string, or an object of {"blockId", "row" (zero-based grid row as displayed), "column" (grid col-id), "text" (exact text of the interactive control to use), "nth" (zero-based pick among several matches)}; "text" without "blockId" searches the whole page, which is how confirm dialog / modal footer buttons and dropdown menu items are reached. fill, select and expect.text need a blockId. Each step gets 5s; after an interaction the runner waits for the page\'s pending events and requests to settle.'
|
|
455
455
|
),
|
|
456
456
|
user: userSchema,
|
|
457
457
|
urlQuery: z
|
package/lib/docs/getOverview.js
CHANGED
|
@@ -71,7 +71,7 @@ function getOverview() {
|
|
|
71
71
|
' Or subscribe instead of polling: `GET /lowdefy-docs/events` streams `build`, `client_error`, `server_error` and `restart` events over SSE (MCP clients receive the same as `notifications/message` from logger `lowdefy`).',
|
|
72
72
|
'2. **Verify the page**: `GET /lowdefy-docs/page-config/{pageId}` — or `lowdefy_get_page_config`. The fully built page config, or its structured build errors.',
|
|
73
73
|
'3. **See it rendered**: `GET /lowdefy-docs/screenshot/{pageId}` — or `lowdefy_screenshot_page`. PNG of the page in headless Chromium.',
|
|
74
|
-
'4. **Verify behaviour**: `POST /lowdefy-docs/journey` `{pageId, steps, user, urlQuery}` — or `lowdefy_run_journey`. Drive the page headless with declarative steps (`click`, `fill`, `select`, `press`, `wait`, `screenshot`, `expect: { state | visible | text | url }
|
|
74
|
+
'4. **Verify behaviour**: `POST /lowdefy-docs/journey` `{pageId, steps, user, urlQuery}` — or `lowdefy_run_journey`. Drive the page headless with declarative steps (`click`, `fill`, `select`, `press`, `wait`, `screenshot`, `expect: { state | visible | text | url }`) and assert what happens. Targets are a blockId, or `{ blockId, row, column, text, nth }` to reach the button in a grid cell, a control by its exact text, or — with `text` alone — a confirm dialog / modal footer button rendered outside every block. A failing step stops the journey and returns `passed: false` with `failure: { index, step, expected, actual, message }` and the final page `state` — data, not an error.',
|
|
75
75
|
'5. **Locate config**: `GET /lowdefy-docs/find/{id}?pageId=...` — or `lowdefy_find_config`. Which yaml file defines a page/block/request id.',
|
|
76
76
|
'6. **Scaffold**: `lowdefy_scaffold_page` creates a canonical new page file (then register it in lowdefy.yaml).',
|
|
77
77
|
'',
|
package/lib/docs/runJourney.js
CHANGED
|
@@ -68,18 +68,23 @@ function cleanMessage(error) {
|
|
|
68
68
|
// Wraps a Playwright interaction so a locator that never became actionable
|
|
69
69
|
// (missing block, hidden, disabled, detached) reads as "expected the block
|
|
70
70
|
// to be actionable, actual: <Playwright's message>" instead of a bare error.
|
|
71
|
-
async function
|
|
71
|
+
async function actOnTarget({ target, action }) {
|
|
72
72
|
try {
|
|
73
73
|
await action();
|
|
74
74
|
} catch (error) {
|
|
75
75
|
const actual = cleanMessage(error);
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
const description = describeTarget(target);
|
|
77
|
+
throw new JourneyStepError(`${capitalise(description)} was not actionable: ${actual}`, {
|
|
78
|
+
expected: `${description} to be actionable`,
|
|
78
79
|
actual,
|
|
79
80
|
});
|
|
80
81
|
}
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
function capitalise(text) {
|
|
85
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
86
|
+
}
|
|
87
|
+
|
|
83
88
|
// The #bl-<id> wrapper spans the full row while the control inside it (an
|
|
84
89
|
// antd button, a link, a checkbox) is usually narrower, so a click at the
|
|
85
90
|
// wrapper's centre can land beside the control. The block's own e2e helpers
|
|
@@ -99,40 +104,156 @@ const INTERACTIVE_CONTROL = [
|
|
|
99
104
|
'[role="menuitem"]',
|
|
100
105
|
].join(', ');
|
|
101
106
|
|
|
102
|
-
async function resolveClickTarget(
|
|
103
|
-
const control =
|
|
107
|
+
async function resolveClickTarget(scope) {
|
|
108
|
+
const control = scope.locator(INTERACTIVE_CONTROL).first();
|
|
104
109
|
if ((await control.count()) > 0) {
|
|
105
110
|
return control;
|
|
106
111
|
}
|
|
107
|
-
return
|
|
112
|
+
return scope;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Exact match on a control's or option's text: a regex anchored at both ends,
|
|
116
|
+
// so "Cat" does not pick "Category".
|
|
117
|
+
function exactText(value) {
|
|
118
|
+
return new RegExp(`^\\s*${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// A step's target is a blockId string or an object narrowing the search (see
|
|
122
|
+
// validateJourneySteps). The string form is the object form with only blockId.
|
|
123
|
+
function normaliseTarget(target) {
|
|
124
|
+
if (type.isString(target)) {
|
|
125
|
+
return { blockId: target };
|
|
126
|
+
}
|
|
127
|
+
return target;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Reads back the way an author thinks of it — `block "grid" row 1 control
|
|
131
|
+
// "Edit"` — for the expected/actual pair of a failed step.
|
|
132
|
+
function describeTarget(target) {
|
|
133
|
+
const parts = [];
|
|
134
|
+
if (!type.isUndefined(target.blockId)) {
|
|
135
|
+
parts.push(`block "${target.blockId}"`);
|
|
136
|
+
}
|
|
137
|
+
if (!type.isUndefined(target.row)) {
|
|
138
|
+
parts.push(`row ${target.row}`);
|
|
139
|
+
}
|
|
140
|
+
if (!type.isUndefined(target.column)) {
|
|
141
|
+
parts.push(`column "${target.column}"`);
|
|
142
|
+
}
|
|
143
|
+
if (!type.isUndefined(target.text)) {
|
|
144
|
+
parts.push(`control "${target.text}"`);
|
|
145
|
+
}
|
|
146
|
+
if (!type.isUndefined(target.nth)) {
|
|
147
|
+
parts.push(`nth ${target.nth}`);
|
|
148
|
+
}
|
|
149
|
+
return parts.join(' ');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function attributeValue(value) {
|
|
153
|
+
return `"${String(value).replace(/["\\]/g, '\\$&')}"`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The element the target's scope keys name: the block wrapper, narrowed to a
|
|
157
|
+
// grid row (`.ag-row[row-index]`, the row as displayed, which ag-grid renders
|
|
158
|
+
// once per pinned/centre column container) and to a cell (`.ag-cell[col-id]`,
|
|
159
|
+
// the column's field or colId). Undefined for a page-wide `text` target,
|
|
160
|
+
// which has no scope narrower than the page.
|
|
161
|
+
function resolveScope({ page, target }) {
|
|
162
|
+
if (type.isUndefined(target.blockId)) {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
let scope = getBlock(page, target.blockId);
|
|
166
|
+
if (!type.isUndefined(target.row)) {
|
|
167
|
+
scope = scope.locator(`.ag-row[row-index=${attributeValue(target.row)}]`);
|
|
168
|
+
}
|
|
169
|
+
if (!type.isUndefined(target.column)) {
|
|
170
|
+
scope = scope.locator(`.ag-cell[col-id=${attributeValue(target.column)}]`);
|
|
171
|
+
}
|
|
172
|
+
return scope;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Portal layers, front-most first. A control found by text alone is looked
|
|
176
|
+
// for in the front-most open layer before the page, the way a person reads a
|
|
177
|
+
// screen: an open dropdown menu covers a dialog, a dialog covers the page. A
|
|
178
|
+
// confirm dialog's "Delete" is then found over the grid's "Delete" cell
|
|
179
|
+
// buttons behind its mask, without the author counting buttons.
|
|
180
|
+
const LAYERS = ['[role="menu"]', '[role="dialog"]'];
|
|
181
|
+
|
|
182
|
+
function controlsWithText({ root, text, nth }) {
|
|
183
|
+
return root
|
|
184
|
+
.locator(INTERACTIVE_CONTROL)
|
|
185
|
+
.filter({ hasText: exactText(text) })
|
|
186
|
+
.filter({ visible: true })
|
|
187
|
+
.nth(nth ?? 0);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function resolvePageWideText({ page, target }) {
|
|
191
|
+
for (const layer of LAYERS) {
|
|
192
|
+
const open = page.locator(layer).filter({ visible: true });
|
|
193
|
+
if ((await open.count()) > 0) {
|
|
194
|
+
const inLayer = controlsWithText({ root: open.last(), text: target.text });
|
|
195
|
+
if ((await inLayer.count()) > 0) {
|
|
196
|
+
return controlsWithText({ root: open.last(), text: target.text, nth: target.nth });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return controlsWithText({ root: page, text: target.text, nth: target.nth });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// The element a step acts on or asserts about. With `text` it is the visible
|
|
204
|
+
// interactive control with exactly that text (a cell button, a confirm
|
|
205
|
+
// dialog's OK, a menu item) inside the scope, or in the front-most open layer
|
|
206
|
+
// of the page when there is no blockId — portal-rendered controls live
|
|
207
|
+
// outside every block. With `nth` alone it is the nth interactive control in
|
|
208
|
+
// the scope. Otherwise it is the scope itself, and a click resolves its inner
|
|
209
|
+
// control the way a plain blockId click does.
|
|
210
|
+
async function resolveTarget({ page, target }) {
|
|
211
|
+
const scope = resolveScope({ page, target });
|
|
212
|
+
if (!type.isUndefined(target.text)) {
|
|
213
|
+
if (type.isUndefined(scope)) {
|
|
214
|
+
return resolvePageWideText({ page, target });
|
|
215
|
+
}
|
|
216
|
+
return controlsWithText({ root: scope, text: target.text, nth: target.nth });
|
|
217
|
+
}
|
|
218
|
+
if (!type.isUndefined(target.nth)) {
|
|
219
|
+
return scope.locator(INTERACTIVE_CONTROL).nth(target.nth);
|
|
220
|
+
}
|
|
221
|
+
return scope;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// A target that names a control (`text`, `nth`) is clicked as is; a target that
|
|
225
|
+
// names a container (block, row, cell) is clicked on the first control inside
|
|
226
|
+
// it, or on itself when it has none.
|
|
227
|
+
async function resolveClickLocator({ page, target }) {
|
|
228
|
+
const located = await resolveTarget({ page, target });
|
|
229
|
+
if (!type.isUndefined(target.text) || !type.isUndefined(target.nth)) {
|
|
230
|
+
return located;
|
|
231
|
+
}
|
|
232
|
+
return resolveClickTarget(located);
|
|
108
233
|
}
|
|
109
234
|
|
|
110
235
|
async function runClick({ page, step, timeout }) {
|
|
111
|
-
const
|
|
112
|
-
await
|
|
113
|
-
|
|
236
|
+
const target = normaliseTarget(step.click);
|
|
237
|
+
await actOnTarget({
|
|
238
|
+
target,
|
|
114
239
|
action: async () => {
|
|
115
|
-
const
|
|
116
|
-
await
|
|
240
|
+
const locator = await resolveClickLocator({ page, target });
|
|
241
|
+
await locator.click({ timeout });
|
|
117
242
|
},
|
|
118
243
|
});
|
|
119
244
|
}
|
|
120
245
|
|
|
121
246
|
async function runFill({ page, step, timeout }) {
|
|
122
|
-
const {
|
|
123
|
-
await
|
|
124
|
-
|
|
125
|
-
action: () =>
|
|
126
|
-
|
|
247
|
+
const { value, ...target } = step.fill;
|
|
248
|
+
await actOnTarget({
|
|
249
|
+
target,
|
|
250
|
+
action: async () => {
|
|
251
|
+
const scope = await resolveTarget({ page, target });
|
|
252
|
+
await scope.locator('input, textarea').first().fill(String(value), { timeout });
|
|
253
|
+
},
|
|
127
254
|
});
|
|
128
255
|
}
|
|
129
256
|
|
|
130
|
-
// Exact match on the option's text: a regex anchored at both ends, so "Cat"
|
|
131
|
-
// does not pick "Category".
|
|
132
|
-
function exactText(value) {
|
|
133
|
-
return new RegExp(`^\\s*${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
257
|
// A native <select> inside the block is preferred when present (Playwright's
|
|
137
258
|
// selectOption is exact and needs no open dropdown). Otherwise the block is
|
|
138
259
|
// clicked to open its dropdown and the option with exactly `value` as text is
|
|
@@ -140,22 +261,22 @@ function exactText(value) {
|
|
|
140
261
|
// page-wide, restricted to visible ones so the hidden accessibility list is
|
|
141
262
|
// never matched.
|
|
142
263
|
async function runSelect({ page, step, timeout }) {
|
|
143
|
-
const {
|
|
264
|
+
const { value, ...target } = step.select;
|
|
144
265
|
const text = String(value);
|
|
145
|
-
const
|
|
146
|
-
const native =
|
|
266
|
+
const scope = await resolveTarget({ page, target });
|
|
267
|
+
const native = scope.locator('select');
|
|
147
268
|
if ((await native.count()) > 0) {
|
|
148
|
-
await
|
|
149
|
-
|
|
269
|
+
await actOnTarget({
|
|
270
|
+
target,
|
|
150
271
|
action: () => native.first().selectOption({ label: text }, { timeout }),
|
|
151
272
|
});
|
|
152
273
|
return;
|
|
153
274
|
}
|
|
154
|
-
await
|
|
155
|
-
|
|
275
|
+
await actOnTarget({
|
|
276
|
+
target,
|
|
156
277
|
action: async () => {
|
|
157
|
-
const
|
|
158
|
-
await
|
|
278
|
+
const locator = await resolveClickLocator({ page, target });
|
|
279
|
+
await locator.click({ timeout });
|
|
159
280
|
},
|
|
160
281
|
});
|
|
161
282
|
const option = page
|
|
@@ -166,10 +287,11 @@ async function runSelect({ page, step, timeout }) {
|
|
|
166
287
|
try {
|
|
167
288
|
await option.click({ timeout });
|
|
168
289
|
} catch (error) {
|
|
290
|
+
const description = describeTarget(target);
|
|
169
291
|
throw new JourneyStepError(
|
|
170
|
-
`No option with text "${text}" appeared in the dropdown of
|
|
292
|
+
`No option with text "${text}" appeared in the dropdown of ${description}.`,
|
|
171
293
|
{
|
|
172
|
-
expected: `option "${text}" in the dropdown of
|
|
294
|
+
expected: `option "${text}" in the dropdown of ${description}`,
|
|
173
295
|
actual: cleanMessage(error),
|
|
174
296
|
}
|
|
175
297
|
);
|
|
@@ -269,34 +391,46 @@ async function expectState({ page, params }) {
|
|
|
269
391
|
}
|
|
270
392
|
|
|
271
393
|
async function expectVisible({ page, params, timeout }) {
|
|
272
|
-
const
|
|
394
|
+
const target = normaliseTarget(params);
|
|
395
|
+
const description = describeTarget(target);
|
|
273
396
|
try {
|
|
274
|
-
await
|
|
397
|
+
const located = await resolveTarget({ page, target });
|
|
398
|
+
await located.waitFor({ state: 'visible', timeout });
|
|
275
399
|
} catch (error) {
|
|
276
|
-
throw new JourneyStepError(`Expected
|
|
277
|
-
expected:
|
|
400
|
+
throw new JourneyStepError(`Expected ${description} to be visible.`, {
|
|
401
|
+
expected: `${description} to be visible`,
|
|
278
402
|
actual: cleanMessage(error),
|
|
279
403
|
});
|
|
280
404
|
}
|
|
281
405
|
}
|
|
282
406
|
|
|
407
|
+
// Waits for the target to be in the page, then reads the text of every element
|
|
408
|
+
// it matches. A grid row is rendered once per column container (pinned left,
|
|
409
|
+
// centre, pinned right), so a row target legitimately matches more than one
|
|
410
|
+
// element; joining them reads the whole row.
|
|
411
|
+
async function readTargetText({ page, target, timeout }) {
|
|
412
|
+
const located = await resolveTarget({ page, target });
|
|
413
|
+
await located.first().waitFor({ state: 'attached', timeout });
|
|
414
|
+
const texts = await located.allInnerTexts();
|
|
415
|
+
return texts.join('\n');
|
|
416
|
+
}
|
|
417
|
+
|
|
283
418
|
async function expectText({ page, params, timeout }) {
|
|
284
|
-
const {
|
|
419
|
+
const { contains, ...target } = params;
|
|
420
|
+
const description = describeTarget(target);
|
|
285
421
|
let text;
|
|
286
422
|
try {
|
|
287
|
-
text = await
|
|
423
|
+
text = await readTargetText({ page, target, timeout });
|
|
288
424
|
} catch (error) {
|
|
289
|
-
throw new JourneyStepError(`Expected
|
|
290
|
-
expected:
|
|
425
|
+
throw new JourneyStepError(`Expected ${description} to contain text "${contains}".`, {
|
|
426
|
+
expected: `${description} text to contain "${contains}"`,
|
|
291
427
|
actual: cleanMessage(error),
|
|
292
428
|
});
|
|
293
429
|
}
|
|
294
430
|
if (!text.includes(contains)) {
|
|
295
431
|
throw new JourneyStepError(
|
|
296
|
-
`Expected
|
|
297
|
-
|
|
298
|
-
)}.`,
|
|
299
|
-
{ expected: `block "${blockId}" text to contain "${contains}"`, actual: text }
|
|
432
|
+
`Expected ${description} text to contain "${contains}" but found ${JSON.stringify(text)}.`,
|
|
433
|
+
{ expected: `${description} text to contain "${contains}"`, actual: text }
|
|
300
434
|
);
|
|
301
435
|
}
|
|
302
436
|
}
|
|
@@ -69,6 +69,8 @@ function createLocator({ selector, page }) {
|
|
|
69
69
|
}),
|
|
70
70
|
locator: jest.fn((child) => createLocator({ selector: `${selector} ${child}`, page })),
|
|
71
71
|
first: jest.fn(() => locator),
|
|
72
|
+
last: jest.fn(() => locator),
|
|
73
|
+
nth: jest.fn(() => locator),
|
|
72
74
|
filter: jest.fn(() => locator),
|
|
73
75
|
count: jest.fn(async () => 0),
|
|
74
76
|
selectOption: jest.fn(),
|
|
@@ -77,7 +79,7 @@ function createLocator({ selector, page }) {
|
|
|
77
79
|
throw new Error(`locator.waitFor: Timeout 5000ms exceeded.`);
|
|
78
80
|
}
|
|
79
81
|
}),
|
|
80
|
-
|
|
82
|
+
allInnerTexts: jest.fn(async () => [page.texts[selector] ?? '']),
|
|
81
83
|
};
|
|
82
84
|
return locator;
|
|
83
85
|
}
|
|
@@ -254,6 +256,370 @@ test('runJourney clicks the interactive control inside a block when there is one
|
|
|
254
256
|
]);
|
|
255
257
|
});
|
|
256
258
|
|
|
259
|
+
const CONTROLS = `button, [role="button"], a[href], input:not([type="hidden"]), textarea, select, [role="switch"], [role="checkbox"], [role="radio"], [role="tab"], [role="menuitem"]`;
|
|
260
|
+
|
|
261
|
+
// Records every filter and nth applied to any locator, however deeply chained,
|
|
262
|
+
// so a test can assert on the text and visibility filters a target resolves to.
|
|
263
|
+
function trackFilters(page) {
|
|
264
|
+
const filters = [];
|
|
265
|
+
function instrument(locator) {
|
|
266
|
+
locator.filter.mockImplementation((filter) => {
|
|
267
|
+
filters.push({ selector: locator.selector, filter });
|
|
268
|
+
return locator;
|
|
269
|
+
});
|
|
270
|
+
locator.nth.mockImplementation((index) => {
|
|
271
|
+
page.nths.push({ selector: locator.selector, index });
|
|
272
|
+
return locator;
|
|
273
|
+
});
|
|
274
|
+
locator.locator.mockImplementation((child) =>
|
|
275
|
+
instrument(createLocator({ selector: `${locator.selector} ${child}`, page }))
|
|
276
|
+
);
|
|
277
|
+
return locator;
|
|
278
|
+
}
|
|
279
|
+
page.locator.mockImplementation((selector) => instrument(createLocator({ selector, page })));
|
|
280
|
+
return filters;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
test('runJourney clicks a cell button by row and exact text inside a grid block', async () => {
|
|
284
|
+
const page = createPage();
|
|
285
|
+
page.nths = [];
|
|
286
|
+
openWith(page);
|
|
287
|
+
const filters = trackFilters(page);
|
|
288
|
+
|
|
289
|
+
const result = await runJourney({
|
|
290
|
+
origin,
|
|
291
|
+
pageId: 'controls',
|
|
292
|
+
steps: [{ click: { blockId: 'grid', row: 1, text: 'Edit' } }],
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
expect(result.passed).toBe(true);
|
|
296
|
+
expect(page.clicks).toEqual([`#bl-grid .ag-row[row-index="1"] ${CONTROLS}`]);
|
|
297
|
+
expect(filters).toHaveLength(2);
|
|
298
|
+
expect(filters[0].filter.hasText.test('Edit')).toBe(true);
|
|
299
|
+
expect(filters[0].filter.hasText.test(' Edit ')).toBe(true);
|
|
300
|
+
expect(filters[0].filter.hasText.test('Edit row')).toBe(false);
|
|
301
|
+
expect(filters[1].filter).toEqual({ visible: true });
|
|
302
|
+
expect(page.nths).toEqual([
|
|
303
|
+
{ selector: `#bl-grid .ag-row[row-index="1"] ${CONTROLS}`, index: 0 },
|
|
304
|
+
]);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('runJourney clicks the control inside a grid cell addressed by row and column', async () => {
|
|
308
|
+
const page = createPage();
|
|
309
|
+
openWith(page);
|
|
310
|
+
page.locator.mockImplementation((selector) => {
|
|
311
|
+
const locator = createLocator({ selector, page });
|
|
312
|
+
locator.locator.mockImplementation((child) => {
|
|
313
|
+
const inner = createLocator({ selector: `${selector} ${child}`, page });
|
|
314
|
+
inner.locator.mockImplementation((grandchild) => {
|
|
315
|
+
const cell = createLocator({ selector: `${inner.selector} ${grandchild}`, page });
|
|
316
|
+
cell.locator.mockImplementation((control) => {
|
|
317
|
+
const found = createLocator({ selector: `${cell.selector} ${control}`, page });
|
|
318
|
+
found.count.mockResolvedValue(1);
|
|
319
|
+
return found;
|
|
320
|
+
});
|
|
321
|
+
return cell;
|
|
322
|
+
});
|
|
323
|
+
return inner;
|
|
324
|
+
});
|
|
325
|
+
return locator;
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const result = await runJourney({
|
|
329
|
+
origin,
|
|
330
|
+
pageId: 'controls',
|
|
331
|
+
steps: [{ click: { blockId: 'grid', row: 0, column: 'actions' } }],
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
expect(result.passed).toBe(true);
|
|
335
|
+
expect(page.clicks).toEqual([
|
|
336
|
+
`#bl-grid .ag-row[row-index="0"] .ag-cell[col-id="actions"] ${CONTROLS}`,
|
|
337
|
+
]);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('runJourney clicks the nth control in a grid cell when several have no text', async () => {
|
|
341
|
+
const page = createPage();
|
|
342
|
+
page.nths = [];
|
|
343
|
+
openWith(page);
|
|
344
|
+
trackFilters(page);
|
|
345
|
+
|
|
346
|
+
const result = await runJourney({
|
|
347
|
+
origin,
|
|
348
|
+
pageId: 'controls',
|
|
349
|
+
steps: [{ click: { blockId: 'grid', row: 0, column: 'actions', nth: 1 } }],
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
expect(result.passed).toBe(true);
|
|
353
|
+
const selector = `#bl-grid .ag-row[row-index="0"] .ag-cell[col-id="actions"] ${CONTROLS}`;
|
|
354
|
+
expect(page.clicks).toEqual([selector]);
|
|
355
|
+
expect(page.nths).toEqual([{ selector, index: 1 }]);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test('runJourney clicks a page-wide control by text when the target has no blockId', async () => {
|
|
359
|
+
const page = createPage();
|
|
360
|
+
page.nths = [];
|
|
361
|
+
openWith(page);
|
|
362
|
+
const filters = trackFilters(page);
|
|
363
|
+
|
|
364
|
+
const result = await runJourney({
|
|
365
|
+
origin,
|
|
366
|
+
pageId: 'controls',
|
|
367
|
+
steps: [{ click: { text: 'OK' } }, { click: { text: 'Delete', nth: 1 } }],
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
expect(result.passed).toBe(true);
|
|
371
|
+
expect(page.clicks).toEqual([CONTROLS, CONTROLS]);
|
|
372
|
+
// No menu or dialog is open (every count is 0), so only the page-wide
|
|
373
|
+
// search filters apply, once per step.
|
|
374
|
+
const pageWide = filters.filter((f) => f.selector === CONTROLS);
|
|
375
|
+
expect(pageWide.map((f) => f.filter)).toEqual([
|
|
376
|
+
{ hasText: expect.any(RegExp) },
|
|
377
|
+
{ visible: true },
|
|
378
|
+
{ hasText: expect.any(RegExp) },
|
|
379
|
+
{ visible: true },
|
|
380
|
+
]);
|
|
381
|
+
expect(pageWide[0].filter.hasText.test('OK')).toBe(true);
|
|
382
|
+
expect(pageWide[0].filter.hasText.test('OKAY')).toBe(false);
|
|
383
|
+
expect(page.nths).toEqual([
|
|
384
|
+
{ selector: CONTROLS, index: 0 },
|
|
385
|
+
{ selector: CONTROLS, index: 1 },
|
|
386
|
+
]);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test('runJourney finds a page-wide text target in the open dialog before the page', async () => {
|
|
390
|
+
const page = createPage();
|
|
391
|
+
page.nths = [];
|
|
392
|
+
openWith(page);
|
|
393
|
+
trackFilters(page);
|
|
394
|
+
// A dialog is open and it holds a control with the text; the grid behind it
|
|
395
|
+
// holds one too, but the dialog wins.
|
|
396
|
+
page.locator.mockImplementation((selector) => {
|
|
397
|
+
const locator = createLocator({ selector, page });
|
|
398
|
+
if (selector === '[role="dialog"]') {
|
|
399
|
+
locator.count.mockResolvedValue(1);
|
|
400
|
+
locator.last.mockImplementation(() => locator);
|
|
401
|
+
locator.locator.mockImplementation((child) => {
|
|
402
|
+
const inner = createLocator({ selector: `${selector} ${child}`, page });
|
|
403
|
+
inner.count.mockResolvedValue(1);
|
|
404
|
+
inner.nth.mockImplementation((index) => {
|
|
405
|
+
page.nths.push({ selector: inner.selector, index });
|
|
406
|
+
return inner;
|
|
407
|
+
});
|
|
408
|
+
return inner;
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
return locator;
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
const result = await runJourney({
|
|
415
|
+
origin,
|
|
416
|
+
pageId: 'controls',
|
|
417
|
+
steps: [{ click: { text: 'Delete' } }],
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
expect(result.passed).toBe(true);
|
|
421
|
+
expect(page.clicks).toEqual([`[role="dialog"] ${CONTROLS}`]);
|
|
422
|
+
expect(page.nths).toEqual([
|
|
423
|
+
{ selector: `[role="dialog"] ${CONTROLS}`, index: 0 },
|
|
424
|
+
{ selector: `[role="dialog"] ${CONTROLS}`, index: 0 },
|
|
425
|
+
]);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test('runJourney prefers an open menu over an open dialog for a page-wide text target', async () => {
|
|
429
|
+
const page = createPage();
|
|
430
|
+
openWith(page);
|
|
431
|
+
page.locator.mockImplementation((selector) => {
|
|
432
|
+
const locator = createLocator({ selector, page });
|
|
433
|
+
if (selector === '[role="menu"]' || selector === '[role="dialog"]') {
|
|
434
|
+
locator.count.mockResolvedValue(1);
|
|
435
|
+
locator.last.mockImplementation(() => locator);
|
|
436
|
+
locator.locator.mockImplementation((child) => {
|
|
437
|
+
const inner = createLocator({ selector: `${selector} ${child}`, page });
|
|
438
|
+
inner.count.mockResolvedValue(1);
|
|
439
|
+
return inner;
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
return locator;
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const result = await runJourney({
|
|
446
|
+
origin,
|
|
447
|
+
pageId: 'controls',
|
|
448
|
+
steps: [{ click: { text: 'Archive' } }],
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
expect(result.passed).toBe(true);
|
|
452
|
+
expect(page.clicks).toEqual([`[role="menu"] ${CONTROLS}`]);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test('runJourney falls through to the page when the open dialog has no control with the text', async () => {
|
|
456
|
+
const page = createPage();
|
|
457
|
+
openWith(page);
|
|
458
|
+
page.locator.mockImplementation((selector) => {
|
|
459
|
+
const locator = createLocator({ selector, page });
|
|
460
|
+
if (selector === '[role="dialog"]') {
|
|
461
|
+
locator.count.mockResolvedValue(1);
|
|
462
|
+
locator.last.mockImplementation(() => locator);
|
|
463
|
+
}
|
|
464
|
+
return locator;
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const result = await runJourney({
|
|
468
|
+
origin,
|
|
469
|
+
pageId: 'controls',
|
|
470
|
+
steps: [{ click: { text: 'Refresh' } }],
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
expect(result.passed).toBe(true);
|
|
474
|
+
expect(page.clicks).toEqual([CONTROLS]);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test('runJourney escapes quotes in a column id', async () => {
|
|
478
|
+
const page = createPage();
|
|
479
|
+
openWith(page);
|
|
480
|
+
|
|
481
|
+
await runJourney({
|
|
482
|
+
origin,
|
|
483
|
+
pageId: 'controls',
|
|
484
|
+
steps: [{ click: { blockId: 'grid', row: 0, column: 'a"b' } }],
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
expect(page.clicks).toEqual(['#bl-grid .ag-row[row-index="0"] .ag-cell[col-id="a\\"b"]']);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test('runJourney describes a failed target by block, row, column and text', async () => {
|
|
491
|
+
const page = createPage();
|
|
492
|
+
openWith(page);
|
|
493
|
+
page.locator.mockImplementation((selector) => {
|
|
494
|
+
const locator = createLocator({ selector, page });
|
|
495
|
+
const fail = async () => {
|
|
496
|
+
throw new Error('locator.click: Timeout 5000ms exceeded.');
|
|
497
|
+
};
|
|
498
|
+
locator.click.mockImplementation(fail);
|
|
499
|
+
locator.locator.mockImplementation((child) => {
|
|
500
|
+
const inner = createLocator({ selector: `${selector} ${child}`, page });
|
|
501
|
+
inner.click.mockImplementation(fail);
|
|
502
|
+
inner.locator.mockImplementation((grandchild) => {
|
|
503
|
+
const deep = createLocator({ selector: `${inner.selector} ${grandchild}`, page });
|
|
504
|
+
deep.click.mockImplementation(fail);
|
|
505
|
+
deep.locator.mockImplementation(() => deep);
|
|
506
|
+
return deep;
|
|
507
|
+
});
|
|
508
|
+
return inner;
|
|
509
|
+
});
|
|
510
|
+
return locator;
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
const result = await runJourney({
|
|
514
|
+
origin,
|
|
515
|
+
pageId: 'controls',
|
|
516
|
+
steps: [{ click: { blockId: 'grid', row: 2, column: 'actions', text: 'Edit', nth: 1 } }],
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
expect(result.passed).toBe(false);
|
|
520
|
+
expect(result.failure.expected).toEqual(
|
|
521
|
+
'block "grid" row 2 column "actions" control "Edit" nth 1 to be actionable'
|
|
522
|
+
);
|
|
523
|
+
expect(result.failure.message).toMatch(
|
|
524
|
+
/^Block "grid" row 2 column "actions" control "Edit" nth 1 was not actionable: locator.click/
|
|
525
|
+
);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test('runJourney reports a page-wide control that never appears', async () => {
|
|
529
|
+
const page = createPage();
|
|
530
|
+
openWith(page);
|
|
531
|
+
page.locator.mockImplementation((selector) => {
|
|
532
|
+
const locator = createLocator({ selector, page });
|
|
533
|
+
locator.click.mockImplementation(async () => {
|
|
534
|
+
throw new Error('locator.click: Timeout 5000ms exceeded.');
|
|
535
|
+
});
|
|
536
|
+
return locator;
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
const result = await runJourney({
|
|
540
|
+
origin,
|
|
541
|
+
pageId: 'controls',
|
|
542
|
+
steps: [{ click: { text: 'Confirm' } }],
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
expect(result.passed).toBe(false);
|
|
546
|
+
expect(result.failure.expected).toEqual('control "Confirm" to be actionable');
|
|
547
|
+
expect(result.failure.message).toMatch(/^Control "Confirm" was not actionable/);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test('runJourney fills and selects inside a grid cell addressed by row and column', async () => {
|
|
551
|
+
const page = createPage();
|
|
552
|
+
openWith(page);
|
|
553
|
+
|
|
554
|
+
const result = await runJourney({
|
|
555
|
+
origin,
|
|
556
|
+
pageId: 'controls',
|
|
557
|
+
steps: [
|
|
558
|
+
{ fill: { blockId: 'grid', row: 1, column: 'name', value: 'Ada' } },
|
|
559
|
+
{ select: { blockId: 'grid', row: 1, column: 'status', value: 'Open' } },
|
|
560
|
+
],
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
expect(result.passed).toBe(true);
|
|
564
|
+
expect(page.fills).toEqual([
|
|
565
|
+
{
|
|
566
|
+
selector: '#bl-grid .ag-row[row-index="1"] .ag-cell[col-id="name"] input, textarea',
|
|
567
|
+
value: 'Ada',
|
|
568
|
+
},
|
|
569
|
+
]);
|
|
570
|
+
expect(page.clicks).toEqual([
|
|
571
|
+
'#bl-grid .ag-row[row-index="1"] .ag-cell[col-id="status"]',
|
|
572
|
+
'.ant-select-item-option, [role="option"]',
|
|
573
|
+
]);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test('runJourney checks visibility and text of a grid row and cell', async () => {
|
|
577
|
+
const page = createPage();
|
|
578
|
+
page.texts['#bl-grid .ag-row[row-index="0"] .ag-cell[col-id="title"]'] = 'Access reviews';
|
|
579
|
+
page.texts['#bl-grid .ag-row[row-index="0"]'] = 'Access reviews open Edit';
|
|
580
|
+
openWith(page);
|
|
581
|
+
|
|
582
|
+
const result = await runJourney({
|
|
583
|
+
origin,
|
|
584
|
+
pageId: 'controls',
|
|
585
|
+
steps: [
|
|
586
|
+
{ expect: { visible: { blockId: 'grid', row: 0 } } },
|
|
587
|
+
{ expect: { text: { blockId: 'grid', row: 0, column: 'title', contains: 'Access' } } },
|
|
588
|
+
{ expect: { text: { blockId: 'grid', row: 0, contains: 'open' } } },
|
|
589
|
+
{ expect: { text: { blockId: 'grid', row: 0, column: 'title', contains: 'Closed' } } },
|
|
590
|
+
],
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
expect(result.steps.map((step) => step.status)).toEqual(['ok', 'ok', 'ok', 'failed']);
|
|
594
|
+
expect(result.failure.expected).toEqual(
|
|
595
|
+
'block "grid" row 0 column "title" text to contain "Closed"'
|
|
596
|
+
);
|
|
597
|
+
expect(result.failure.actual).toEqual('Access reviews');
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test('runJourney joins the text of every element a row target matches', async () => {
|
|
601
|
+
const page = createPage();
|
|
602
|
+
openWith(page);
|
|
603
|
+
page.locator.mockImplementation((selector) => {
|
|
604
|
+
const locator = createLocator({ selector, page });
|
|
605
|
+
locator.locator.mockImplementation((child) => {
|
|
606
|
+
const inner = createLocator({ selector: `${selector} ${child}`, page });
|
|
607
|
+
// ag-grid renders the row once per column container.
|
|
608
|
+
inner.allInnerTexts.mockResolvedValue(['Access reviews', 'Edit Delete']);
|
|
609
|
+
return inner;
|
|
610
|
+
});
|
|
611
|
+
return locator;
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const result = await runJourney({
|
|
615
|
+
origin,
|
|
616
|
+
pageId: 'controls',
|
|
617
|
+
steps: [{ expect: { text: { blockId: 'grid', row: 0, contains: 'Delete' } } }],
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
expect(result.passed).toBe(true);
|
|
621
|
+
});
|
|
622
|
+
|
|
257
623
|
test('runJourney reports a missing expect.state value as actual null', async () => {
|
|
258
624
|
const page = createPage({ window: createLowdefyWindow({ state: {} }) });
|
|
259
625
|
openWith(page);
|
|
@@ -34,17 +34,93 @@ function getStepKey(step) {
|
|
|
34
34
|
return keys[0];
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// A step's target is a blockId string or an object that narrows the search:
|
|
38
|
+
// `blockId` scopes to the block's #bl- wrapper, `row` and `column` to a grid
|
|
39
|
+
// row (zero-based, as displayed) and cell (by col-id) inside it, `text` to the
|
|
40
|
+
// interactive control with exactly that text, `nth` picks among several
|
|
41
|
+
// matches. `text` alone, with no blockId, searches the whole page — that is
|
|
42
|
+
// how portal-rendered controls (confirm dialog buttons, modal footers,
|
|
43
|
+
// dropdown menu items) are reached, since they render outside every block.
|
|
44
|
+
const TARGET_KEYS = ['blockId', 'text', 'row', 'column', 'nth'];
|
|
45
|
+
|
|
46
|
+
function isIndex(value) {
|
|
47
|
+
return type.isInt(value) && value >= 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Validates the target keys of a step object; `extraKeys` are the step's own
|
|
51
|
+
// keys (`value`, `contains`) that may sit beside them. Returns an error
|
|
52
|
+
// message, or undefined when the target is well-formed.
|
|
53
|
+
function validateTargetObject({ key, params, extraKeys = [], requireBlockId = false }) {
|
|
54
|
+
const allowed = [...TARGET_KEYS, ...extraKeys];
|
|
55
|
+
const unknown = Object.keys(params).filter((k) => !allowed.includes(k));
|
|
56
|
+
if (unknown.length > 0) {
|
|
57
|
+
return `Step "${key}" has unknown key${unknown.length > 1 ? 's' : ''} ${unknown
|
|
58
|
+
.map((k) => `"${k}"`)
|
|
59
|
+
.join(', ')}. Keys are: ${allowed.join(', ')}.`;
|
|
60
|
+
}
|
|
61
|
+
if (!type.isUndefined(params.blockId) && !type.isString(params.blockId)) {
|
|
62
|
+
return `Step "${key}" requires "blockId" to be a string. Received ${describe(params.blockId)}.`;
|
|
63
|
+
}
|
|
64
|
+
if (!type.isUndefined(params.text) && !type.isString(params.text)) {
|
|
65
|
+
return `Step "${key}" requires "text" to be a string. Received ${describe(params.text)}.`;
|
|
66
|
+
}
|
|
67
|
+
if (requireBlockId && type.isUndefined(params.blockId)) {
|
|
68
|
+
return `Step "${key}" requires a "blockId" string. Received ${describe(params)}.`;
|
|
69
|
+
}
|
|
70
|
+
if (type.isUndefined(params.blockId) && type.isUndefined(params.text)) {
|
|
71
|
+
return `Step "${key}" requires a "blockId" or a "text" to target. Received ${describe(
|
|
72
|
+
params
|
|
73
|
+
)}.`;
|
|
74
|
+
}
|
|
75
|
+
if (!type.isUndefined(params.row) && !isIndex(params.row)) {
|
|
76
|
+
return `Step "${key}" requires "row" to be a zero-based row index. Received ${describe(
|
|
77
|
+
params.row
|
|
78
|
+
)}.`;
|
|
79
|
+
}
|
|
80
|
+
if (!type.isUndefined(params.column) && !type.isString(params.column)) {
|
|
81
|
+
return `Step "${key}" requires "column" to be a column id string. Received ${describe(
|
|
82
|
+
params.column
|
|
83
|
+
)}.`;
|
|
84
|
+
}
|
|
85
|
+
if (
|
|
86
|
+
(!type.isUndefined(params.row) || !type.isUndefined(params.column)) &&
|
|
87
|
+
type.isUndefined(params.blockId)
|
|
88
|
+
) {
|
|
89
|
+
return `Step "${key}" requires a "blockId" (the grid block) when "row" or "column" is given. Received ${describe(
|
|
90
|
+
params
|
|
91
|
+
)}.`;
|
|
92
|
+
}
|
|
93
|
+
if (!type.isUndefined(params.nth) && !isIndex(params.nth)) {
|
|
94
|
+
return `Step "${key}" requires "nth" to be a zero-based index. Received ${describe(
|
|
95
|
+
params.nth
|
|
96
|
+
)}.`;
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// click and expect.visible take a blockId string or a target object.
|
|
102
|
+
function validateTarget({ key, params }) {
|
|
103
|
+
if (type.isString(params)) {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
if (!type.isObject(params)) {
|
|
107
|
+
return `Step "${key}" requires a blockId string or a target object { blockId, text, row, column, nth }. Received ${describe(
|
|
108
|
+
params
|
|
109
|
+
)}.`;
|
|
110
|
+
}
|
|
111
|
+
return validateTargetObject({ key, params });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// fill and select take the target keys beside `value`; the block is required
|
|
115
|
+
// because a value is typed into a block's input, never a page-wide control.
|
|
37
116
|
function validateBlockValue({ key, params }) {
|
|
38
117
|
if (!type.isObject(params)) {
|
|
39
118
|
return `Step "${key}" requires { blockId, value }. Received ${describe(params)}.`;
|
|
40
119
|
}
|
|
41
|
-
if (!type.isString(params.blockId)) {
|
|
42
|
-
return `Step "${key}" requires a "blockId" string. Received ${describe(params.blockId)}.`;
|
|
43
|
-
}
|
|
44
120
|
if (type.isUndefined(params.value)) {
|
|
45
121
|
return `Step "${key}" requires a "value". Received ${describe(params)}.`;
|
|
46
122
|
}
|
|
47
|
-
return
|
|
123
|
+
return validateTargetObject({ key, params, extraKeys: ['value'], requireBlockId: true });
|
|
48
124
|
}
|
|
49
125
|
|
|
50
126
|
function validateWait(params) {
|
|
@@ -88,19 +164,17 @@ function validateExpect(params) {
|
|
|
88
164
|
}
|
|
89
165
|
return undefined;
|
|
90
166
|
case 'visible':
|
|
91
|
-
|
|
92
|
-
return `Step "expect.visible" requires a blockId string. Received ${describe(value)}.`;
|
|
93
|
-
}
|
|
94
|
-
return undefined;
|
|
167
|
+
return validateTarget({ key: 'expect.visible', params: value });
|
|
95
168
|
case 'text':
|
|
96
|
-
if (
|
|
97
|
-
!type.isObject(value) ||
|
|
98
|
-
!type.isString(value.blockId) ||
|
|
99
|
-
!type.isString(value.contains)
|
|
100
|
-
) {
|
|
169
|
+
if (!type.isObject(value) || !type.isString(value.contains)) {
|
|
101
170
|
return `Step "expect.text" requires { blockId, contains }. Received ${describe(value)}.`;
|
|
102
171
|
}
|
|
103
|
-
return
|
|
172
|
+
return validateTargetObject({
|
|
173
|
+
key: 'expect.text',
|
|
174
|
+
params: value,
|
|
175
|
+
extraKeys: ['contains'],
|
|
176
|
+
requireBlockId: true,
|
|
177
|
+
});
|
|
104
178
|
case 'url':
|
|
105
179
|
if (!type.isObject(value) || !type.isString(value.contains)) {
|
|
106
180
|
return `Step "expect.url" requires { contains }. Received ${describe(value)}.`;
|
|
@@ -125,10 +199,7 @@ function validateStep(step) {
|
|
|
125
199
|
const params = step[key];
|
|
126
200
|
switch (key) {
|
|
127
201
|
case 'click':
|
|
128
|
-
|
|
129
|
-
return `Step "click" requires a blockId string. Received ${describe(params)}.`;
|
|
130
|
-
}
|
|
131
|
-
return undefined;
|
|
202
|
+
return validateTarget({ key: 'click', params });
|
|
132
203
|
case 'fill':
|
|
133
204
|
case 'select':
|
|
134
205
|
return validateBlockValue({ key, params });
|
|
@@ -169,5 +240,5 @@ function validateJourneySteps({ steps }) {
|
|
|
169
240
|
return {};
|
|
170
241
|
}
|
|
171
242
|
|
|
172
|
-
export { STEP_KEYS, getStepKey };
|
|
243
|
+
export { STEP_KEYS, TARGET_KEYS, getStepKey };
|
|
173
244
|
export default validateJourneySteps;
|
|
@@ -40,6 +40,27 @@ test('validateJourneySteps accepts every step of the grammar', () => {
|
|
|
40
40
|
expect(result).toEqual({});
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
test('validateJourneySteps accepts target objects on click, fill, select, expect.visible and expect.text', () => {
|
|
44
|
+
const result = validateJourneySteps({
|
|
45
|
+
steps: [
|
|
46
|
+
{ click: { blockId: 'submit' } },
|
|
47
|
+
{ click: { blockId: 'grid', row: 1, text: 'Edit' } },
|
|
48
|
+
{ click: { blockId: 'grid', row: 0, column: 'actions' } },
|
|
49
|
+
{ click: { blockId: 'grid', row: 0, column: 'actions', nth: 1 } },
|
|
50
|
+
{ click: { blockId: 'tabs', text: 'Settings' } },
|
|
51
|
+
{ click: { text: 'OK' } },
|
|
52
|
+
{ click: { text: 'Delete', nth: 0 } },
|
|
53
|
+
{ fill: { blockId: 'grid', row: 2, column: 'name', value: 'Ada' } },
|
|
54
|
+
{ select: { blockId: 'grid', row: 2, column: 'status', value: 'Open' } },
|
|
55
|
+
{ expect: { visible: { blockId: 'grid', row: 3 } } },
|
|
56
|
+
{ expect: { visible: { text: 'Are you sure?' } } },
|
|
57
|
+
{ expect: { text: { blockId: 'grid', row: 0, column: 'title', contains: 'Access' } } },
|
|
58
|
+
{ expect: { text: { blockId: 'grid', row: 0, contains: 'Access' } } },
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
expect(result).toEqual({});
|
|
62
|
+
});
|
|
63
|
+
|
|
43
64
|
test('validateJourneySteps rejects steps that are not an array', () => {
|
|
44
65
|
expect(validateJourneySteps({ steps: undefined }).error).toMatch(
|
|
45
66
|
/requires "steps" to be an array. Received undefined/
|
|
@@ -68,9 +89,53 @@ test('validateJourneySteps rejects a step that is not an object', () => {
|
|
|
68
89
|
});
|
|
69
90
|
|
|
70
91
|
test.each([
|
|
71
|
-
[{ click: 7 }, /Step "click" requires a blockId string
|
|
92
|
+
[{ click: 7 }, /Step "click" requires a blockId string or a target object .*Received 7/],
|
|
93
|
+
[{ click: {} }, /Step "click" requires a "blockId" or a "text" to target/],
|
|
94
|
+
[{ click: { blockId: 7 } }, /Step "click" requires "blockId" to be a string. Received 7/],
|
|
95
|
+
[{ click: { text: 7 } }, /Step "click" requires "text" to be a string. Received 7/],
|
|
96
|
+
[
|
|
97
|
+
{ click: { blockId: 'grid', row: -1 } },
|
|
98
|
+
/Step "click" requires "row" to be a zero-based row index. Received -1/,
|
|
99
|
+
],
|
|
100
|
+
[
|
|
101
|
+
{ click: { blockId: 'grid', row: '0' } },
|
|
102
|
+
/Step "click" requires "row" to be a zero-based row index. Received "0"/,
|
|
103
|
+
],
|
|
104
|
+
[
|
|
105
|
+
{ click: { blockId: 'grid', column: 0 } },
|
|
106
|
+
/Step "click" requires "column" to be a column id string. Received 0/,
|
|
107
|
+
],
|
|
108
|
+
[
|
|
109
|
+
{ click: { text: 'Edit', row: 0 } },
|
|
110
|
+
/Step "click" requires a "blockId" \(the grid block\) when "row" or "column" is given/,
|
|
111
|
+
],
|
|
112
|
+
[
|
|
113
|
+
{ click: { text: 'Edit', column: 'actions' } },
|
|
114
|
+
/Step "click" requires a "blockId" \(the grid block\) when "row" or "column" is given/,
|
|
115
|
+
],
|
|
116
|
+
[
|
|
117
|
+
{ click: { text: 'Edit', nth: 1.5 } },
|
|
118
|
+
/Step "click" requires "nth" to be a zero-based index. Received 1.5/,
|
|
119
|
+
],
|
|
120
|
+
[
|
|
121
|
+
{ click: { blockId: 'grid', colum: 'actions' } },
|
|
122
|
+
/Step "click" has unknown key "colum". Keys are: blockId, text, row, column, nth/,
|
|
123
|
+
],
|
|
124
|
+
[
|
|
125
|
+
{ click: { blockId: 'grid', colum: 'a', rows: 1 } },
|
|
126
|
+
/Step "click" has unknown keys "colum", "rows"/,
|
|
127
|
+
],
|
|
72
128
|
[{ fill: 'name' }, /Step "fill" requires \{ blockId, value \}/],
|
|
73
|
-
[
|
|
129
|
+
[
|
|
130
|
+
{ fill: { blockId: 1, value: 'x' } },
|
|
131
|
+
/Step "fill" requires "blockId" to be a string. Received 1/,
|
|
132
|
+
],
|
|
133
|
+
[{ fill: { value: 'x' } }, /Step "fill" requires a "blockId" string/],
|
|
134
|
+
[{ fill: { text: 'Name', value: 'x' } }, /Step "fill" requires a "blockId" string/],
|
|
135
|
+
[
|
|
136
|
+
{ fill: { blockId: 'grid', row: 0, column: 'name', value: 'x', selector: 'input' } },
|
|
137
|
+
/Step "fill" has unknown key "selector". Keys are: blockId, text, row, column, nth, value/,
|
|
138
|
+
],
|
|
74
139
|
[{ fill: { blockId: 'name' } }, /Step "fill" requires a "value"/],
|
|
75
140
|
[{ select: { value: 'x' } }, /Step "select" requires a "blockId" string/],
|
|
76
141
|
[{ press: ['Enter'] }, /Step "press" requires a key string/],
|
|
@@ -93,8 +158,23 @@ test.each([
|
|
|
93
158
|
],
|
|
94
159
|
[{ expect: { state: { path: 'a' } } }, /Step "expect.state" requires \{ path, equals \}/],
|
|
95
160
|
[{ expect: { state: 'a' } }, /Step "expect.state" requires \{ path, equals \}/],
|
|
96
|
-
[
|
|
161
|
+
[
|
|
162
|
+
{ expect: { visible: 7 } },
|
|
163
|
+
/Step "expect.visible" requires a blockId string or a target object/,
|
|
164
|
+
],
|
|
165
|
+
[
|
|
166
|
+
{ expect: { visible: { row: 0 } } },
|
|
167
|
+
/Step "expect.visible" requires a "blockId" or a "text" to target/,
|
|
168
|
+
],
|
|
97
169
|
[{ expect: { text: { blockId: 'a' } } }, /Step "expect.text" requires \{ blockId, contains \}/],
|
|
170
|
+
[
|
|
171
|
+
{ expect: { text: { text: 'OK', contains: 'OK' } } },
|
|
172
|
+
/Step "expect.text" requires a "blockId" string/,
|
|
173
|
+
],
|
|
174
|
+
[
|
|
175
|
+
{ expect: { text: { blockId: 'grid', row: 0, contains: 'a', equals: 'a' } } },
|
|
176
|
+
/Step "expect.text" has unknown key "equals". Keys are: blockId, text, row, column, nth, contains/,
|
|
177
|
+
],
|
|
98
178
|
[{ expect: { url: '/detail' } }, /Step "expect.url" requires \{ contains \}/],
|
|
99
179
|
])('validateJourneySteps rejects malformed step %j', (step, expected) => {
|
|
100
180
|
const result = validateJourneySteps({ steps: [step] });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lowdefy/server-dev",
|
|
3
|
-
"version": "0.0.0-experimental-
|
|
3
|
+
"version": "0.0.0-experimental-20260908103554",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "",
|
|
6
6
|
"homepage": "https://lowdefy.com",
|
|
@@ -45,40 +45,40 @@
|
|
|
45
45
|
"@hono/mcp": "0.3.0",
|
|
46
46
|
"@hono/node-server": "2.0.4",
|
|
47
47
|
"@hono/vite-dev-server": "0.26.0",
|
|
48
|
-
"@lowdefy/actions-core": "0.0.0-experimental-
|
|
49
|
-
"@lowdefy/api": "0.0.0-experimental-
|
|
50
|
-
"@lowdefy/block-utils": "0.0.0-experimental-
|
|
51
|
-
"@lowdefy/blocks-aggrid": "0.0.0-experimental-
|
|
52
|
-
"@lowdefy/blocks-antd": "0.0.0-experimental-
|
|
53
|
-
"@lowdefy/blocks-antd-x": "0.0.0-experimental-
|
|
54
|
-
"@lowdefy/blocks-basic": "0.0.0-experimental-
|
|
55
|
-
"@lowdefy/blocks-captcha": "0.0.0-experimental-
|
|
56
|
-
"@lowdefy/blocks-echarts": "0.0.0-experimental-
|
|
57
|
-
"@lowdefy/blocks-loaders": "0.0.0-experimental-
|
|
58
|
-
"@lowdefy/blocks-markdown": "0.0.0-experimental-
|
|
59
|
-
"@lowdefy/blocks-tiptap": "0.0.0-experimental-
|
|
60
|
-
"@lowdefy/build": "0.0.0-experimental-
|
|
61
|
-
"@lowdefy/client": "0.0.0-experimental-
|
|
62
|
-
"@lowdefy/connection-axios-http": "0.0.0-experimental-
|
|
63
|
-
"@lowdefy/connection-mongodb": "0.0.0-experimental-
|
|
64
|
-
"@lowdefy/docs-content": "0.0.0-experimental-
|
|
65
|
-
"@lowdefy/e2e-utils": "0.0.0-experimental-
|
|
66
|
-
"@lowdefy/engine": "0.0.0-experimental-
|
|
67
|
-
"@lowdefy/errors": "0.0.0-experimental-
|
|
68
|
-
"@lowdefy/helpers": "0.0.0-experimental-
|
|
69
|
-
"@lowdefy/layout": "0.0.0-experimental-
|
|
70
|
-
"@lowdefy/logger": "0.0.0-experimental-
|
|
71
|
-
"@lowdefy/node-utils": "0.0.0-experimental-
|
|
72
|
-
"@lowdefy/operators-change-case": "0.0.0-experimental-
|
|
73
|
-
"@lowdefy/operators-dayjs": "0.0.0-experimental-
|
|
74
|
-
"@lowdefy/operators-diff": "0.0.0-experimental-
|
|
75
|
-
"@lowdefy/operators-js": "0.0.0-experimental-
|
|
76
|
-
"@lowdefy/operators-mql": "0.0.0-experimental-
|
|
77
|
-
"@lowdefy/operators-nunjucks": "0.0.0-experimental-
|
|
78
|
-
"@lowdefy/operators-uuid": "0.0.0-experimental-
|
|
79
|
-
"@lowdefy/operators-yaml": "0.0.0-experimental-
|
|
80
|
-
"@lowdefy/plugin-better-auth": "0.0.0-experimental-
|
|
81
|
-
"@lowdefy/websockets-core": "0.0.0-experimental-
|
|
48
|
+
"@lowdefy/actions-core": "0.0.0-experimental-20260908103554",
|
|
49
|
+
"@lowdefy/api": "0.0.0-experimental-20260908103554",
|
|
50
|
+
"@lowdefy/block-utils": "0.0.0-experimental-20260908103554",
|
|
51
|
+
"@lowdefy/blocks-aggrid": "0.0.0-experimental-20260908103554",
|
|
52
|
+
"@lowdefy/blocks-antd": "0.0.0-experimental-20260908103554",
|
|
53
|
+
"@lowdefy/blocks-antd-x": "0.0.0-experimental-20260908103554",
|
|
54
|
+
"@lowdefy/blocks-basic": "0.0.0-experimental-20260908103554",
|
|
55
|
+
"@lowdefy/blocks-captcha": "0.0.0-experimental-20260908103554",
|
|
56
|
+
"@lowdefy/blocks-echarts": "0.0.0-experimental-20260908103554",
|
|
57
|
+
"@lowdefy/blocks-loaders": "0.0.0-experimental-20260908103554",
|
|
58
|
+
"@lowdefy/blocks-markdown": "0.0.0-experimental-20260908103554",
|
|
59
|
+
"@lowdefy/blocks-tiptap": "0.0.0-experimental-20260908103554",
|
|
60
|
+
"@lowdefy/build": "0.0.0-experimental-20260908103554",
|
|
61
|
+
"@lowdefy/client": "0.0.0-experimental-20260908103554",
|
|
62
|
+
"@lowdefy/connection-axios-http": "0.0.0-experimental-20260908103554",
|
|
63
|
+
"@lowdefy/connection-mongodb": "0.0.0-experimental-20260908103554",
|
|
64
|
+
"@lowdefy/docs-content": "0.0.0-experimental-20260908103554",
|
|
65
|
+
"@lowdefy/e2e-utils": "0.0.0-experimental-20260908103554",
|
|
66
|
+
"@lowdefy/engine": "0.0.0-experimental-20260908103554",
|
|
67
|
+
"@lowdefy/errors": "0.0.0-experimental-20260908103554",
|
|
68
|
+
"@lowdefy/helpers": "0.0.0-experimental-20260908103554",
|
|
69
|
+
"@lowdefy/layout": "0.0.0-experimental-20260908103554",
|
|
70
|
+
"@lowdefy/logger": "0.0.0-experimental-20260908103554",
|
|
71
|
+
"@lowdefy/node-utils": "0.0.0-experimental-20260908103554",
|
|
72
|
+
"@lowdefy/operators-change-case": "0.0.0-experimental-20260908103554",
|
|
73
|
+
"@lowdefy/operators-dayjs": "0.0.0-experimental-20260908103554",
|
|
74
|
+
"@lowdefy/operators-diff": "0.0.0-experimental-20260908103554",
|
|
75
|
+
"@lowdefy/operators-js": "0.0.0-experimental-20260908103554",
|
|
76
|
+
"@lowdefy/operators-mql": "0.0.0-experimental-20260908103554",
|
|
77
|
+
"@lowdefy/operators-nunjucks": "0.0.0-experimental-20260908103554",
|
|
78
|
+
"@lowdefy/operators-uuid": "0.0.0-experimental-20260908103554",
|
|
79
|
+
"@lowdefy/operators-yaml": "0.0.0-experimental-20260908103554",
|
|
80
|
+
"@lowdefy/plugin-better-auth": "0.0.0-experimental-20260908103554",
|
|
81
|
+
"@lowdefy/websockets-core": "0.0.0-experimental-20260908103554",
|
|
82
82
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
83
83
|
"@tailwindcss/postcss": "4.3.1",
|
|
84
84
|
"@vitejs/plugin-react": "6.0.2",
|
package/package.original.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lowdefy/server-dev",
|
|
3
|
-
"version": "0.0.0-experimental-
|
|
3
|
+
"version": "0.0.0-experimental-20260908103554",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "",
|
|
6
6
|
"homepage": "https://lowdefy.com",
|
|
@@ -51,40 +51,40 @@
|
|
|
51
51
|
"@hono/mcp": "0.3.0",
|
|
52
52
|
"@hono/node-server": "2.0.4",
|
|
53
53
|
"@hono/vite-dev-server": "0.26.0",
|
|
54
|
-
"@lowdefy/actions-core": "0.0.0-experimental-
|
|
55
|
-
"@lowdefy/api": "0.0.0-experimental-
|
|
56
|
-
"@lowdefy/block-utils": "0.0.0-experimental-
|
|
57
|
-
"@lowdefy/blocks-aggrid": "0.0.0-experimental-
|
|
58
|
-
"@lowdefy/blocks-antd": "0.0.0-experimental-
|
|
59
|
-
"@lowdefy/blocks-antd-x": "0.0.0-experimental-
|
|
60
|
-
"@lowdefy/blocks-basic": "0.0.0-experimental-
|
|
61
|
-
"@lowdefy/blocks-captcha": "0.0.0-experimental-
|
|
62
|
-
"@lowdefy/blocks-echarts": "0.0.0-experimental-
|
|
63
|
-
"@lowdefy/blocks-loaders": "0.0.0-experimental-
|
|
64
|
-
"@lowdefy/blocks-markdown": "0.0.0-experimental-
|
|
65
|
-
"@lowdefy/blocks-tiptap": "0.0.0-experimental-
|
|
66
|
-
"@lowdefy/build": "0.0.0-experimental-
|
|
67
|
-
"@lowdefy/client": "0.0.0-experimental-
|
|
68
|
-
"@lowdefy/connection-axios-http": "0.0.0-experimental-
|
|
69
|
-
"@lowdefy/connection-mongodb": "0.0.0-experimental-
|
|
70
|
-
"@lowdefy/docs-content": "0.0.0-experimental-
|
|
71
|
-
"@lowdefy/e2e-utils": "0.0.0-experimental-
|
|
72
|
-
"@lowdefy/engine": "0.0.0-experimental-
|
|
73
|
-
"@lowdefy/errors": "0.0.0-experimental-
|
|
74
|
-
"@lowdefy/helpers": "0.0.0-experimental-
|
|
75
|
-
"@lowdefy/layout": "0.0.0-experimental-
|
|
76
|
-
"@lowdefy/logger": "0.0.0-experimental-
|
|
77
|
-
"@lowdefy/node-utils": "0.0.0-experimental-
|
|
78
|
-
"@lowdefy/operators-change-case": "0.0.0-experimental-
|
|
79
|
-
"@lowdefy/operators-dayjs": "0.0.0-experimental-
|
|
80
|
-
"@lowdefy/operators-diff": "0.0.0-experimental-
|
|
81
|
-
"@lowdefy/operators-js": "0.0.0-experimental-
|
|
82
|
-
"@lowdefy/operators-mql": "0.0.0-experimental-
|
|
83
|
-
"@lowdefy/operators-nunjucks": "0.0.0-experimental-
|
|
84
|
-
"@lowdefy/operators-uuid": "0.0.0-experimental-
|
|
85
|
-
"@lowdefy/operators-yaml": "0.0.0-experimental-
|
|
86
|
-
"@lowdefy/plugin-better-auth": "0.0.0-experimental-
|
|
87
|
-
"@lowdefy/websockets-core": "0.0.0-experimental-
|
|
54
|
+
"@lowdefy/actions-core": "0.0.0-experimental-20260908103554",
|
|
55
|
+
"@lowdefy/api": "0.0.0-experimental-20260908103554",
|
|
56
|
+
"@lowdefy/block-utils": "0.0.0-experimental-20260908103554",
|
|
57
|
+
"@lowdefy/blocks-aggrid": "0.0.0-experimental-20260908103554",
|
|
58
|
+
"@lowdefy/blocks-antd": "0.0.0-experimental-20260908103554",
|
|
59
|
+
"@lowdefy/blocks-antd-x": "0.0.0-experimental-20260908103554",
|
|
60
|
+
"@lowdefy/blocks-basic": "0.0.0-experimental-20260908103554",
|
|
61
|
+
"@lowdefy/blocks-captcha": "0.0.0-experimental-20260908103554",
|
|
62
|
+
"@lowdefy/blocks-echarts": "0.0.0-experimental-20260908103554",
|
|
63
|
+
"@lowdefy/blocks-loaders": "0.0.0-experimental-20260908103554",
|
|
64
|
+
"@lowdefy/blocks-markdown": "0.0.0-experimental-20260908103554",
|
|
65
|
+
"@lowdefy/blocks-tiptap": "0.0.0-experimental-20260908103554",
|
|
66
|
+
"@lowdefy/build": "0.0.0-experimental-20260908103554",
|
|
67
|
+
"@lowdefy/client": "0.0.0-experimental-20260908103554",
|
|
68
|
+
"@lowdefy/connection-axios-http": "0.0.0-experimental-20260908103554",
|
|
69
|
+
"@lowdefy/connection-mongodb": "0.0.0-experimental-20260908103554",
|
|
70
|
+
"@lowdefy/docs-content": "0.0.0-experimental-20260908103554",
|
|
71
|
+
"@lowdefy/e2e-utils": "0.0.0-experimental-20260908103554",
|
|
72
|
+
"@lowdefy/engine": "0.0.0-experimental-20260908103554",
|
|
73
|
+
"@lowdefy/errors": "0.0.0-experimental-20260908103554",
|
|
74
|
+
"@lowdefy/helpers": "0.0.0-experimental-20260908103554",
|
|
75
|
+
"@lowdefy/layout": "0.0.0-experimental-20260908103554",
|
|
76
|
+
"@lowdefy/logger": "0.0.0-experimental-20260908103554",
|
|
77
|
+
"@lowdefy/node-utils": "0.0.0-experimental-20260908103554",
|
|
78
|
+
"@lowdefy/operators-change-case": "0.0.0-experimental-20260908103554",
|
|
79
|
+
"@lowdefy/operators-dayjs": "0.0.0-experimental-20260908103554",
|
|
80
|
+
"@lowdefy/operators-diff": "0.0.0-experimental-20260908103554",
|
|
81
|
+
"@lowdefy/operators-js": "0.0.0-experimental-20260908103554",
|
|
82
|
+
"@lowdefy/operators-mql": "0.0.0-experimental-20260908103554",
|
|
83
|
+
"@lowdefy/operators-nunjucks": "0.0.0-experimental-20260908103554",
|
|
84
|
+
"@lowdefy/operators-uuid": "0.0.0-experimental-20260908103554",
|
|
85
|
+
"@lowdefy/operators-yaml": "0.0.0-experimental-20260908103554",
|
|
86
|
+
"@lowdefy/plugin-better-auth": "0.0.0-experimental-20260908103554",
|
|
87
|
+
"@lowdefy/websockets-core": "0.0.0-experimental-20260908103554",
|
|
88
88
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
89
89
|
"@tailwindcss/postcss": "4.3.1",
|
|
90
90
|
"@vitejs/plugin-react": "6.0.2",
|