@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.
@@ -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 and
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": blockId} | {"fill": {"blockId", "value"}} | {"select": {"blockId", "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": blockId} | {"text": {"blockId", "contains"}} | {"url": {"contains"}}}. Each step gets 5s; after an interaction the runner waits for the page\'s pending events and requests to settle.'
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
@@ -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 }`, blocks addressed by blockId) and assert what happens. 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.',
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
  '',
@@ -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 actOnBlock({ blockId, action }) {
71
+ async function actOnTarget({ target, action }) {
72
72
  try {
73
73
  await action();
74
74
  } catch (error) {
75
75
  const actual = cleanMessage(error);
76
- throw new JourneyStepError(`Block "${blockId}" was not actionable: ${actual}`, {
77
- expected: `block "${blockId}" to be actionable`,
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(block) {
103
- const control = block.locator(INTERACTIVE_CONTROL).first();
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 block;
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 blockId = step.click;
112
- await actOnBlock({
113
- blockId,
236
+ const target = normaliseTarget(step.click);
237
+ await actOnTarget({
238
+ target,
114
239
  action: async () => {
115
- const target = await resolveClickTarget(getBlock(page, blockId));
116
- await target.click({ timeout });
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 { blockId, value } = step.fill;
123
- await actOnBlock({
124
- blockId,
125
- action: () =>
126
- getBlock(page, blockId).locator('input, textarea').first().fill(String(value), { timeout }),
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 { blockId, value } = step.select;
264
+ const { value, ...target } = step.select;
144
265
  const text = String(value);
145
- const block = getBlock(page, blockId);
146
- const native = block.locator('select');
266
+ const scope = await resolveTarget({ page, target });
267
+ const native = scope.locator('select');
147
268
  if ((await native.count()) > 0) {
148
- await actOnBlock({
149
- blockId,
269
+ await actOnTarget({
270
+ target,
150
271
  action: () => native.first().selectOption({ label: text }, { timeout }),
151
272
  });
152
273
  return;
153
274
  }
154
- await actOnBlock({
155
- blockId,
275
+ await actOnTarget({
276
+ target,
156
277
  action: async () => {
157
- const target = await resolveClickTarget(block);
158
- await target.click({ timeout });
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 block "${blockId}".`,
292
+ `No option with text "${text}" appeared in the dropdown of ${description}.`,
171
293
  {
172
- expected: `option "${text}" in the dropdown of block "${blockId}"`,
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 blockId = params;
394
+ const target = normaliseTarget(params);
395
+ const description = describeTarget(target);
273
396
  try {
274
- await getBlock(page, blockId).waitFor({ state: 'visible', timeout });
397
+ const located = await resolveTarget({ page, target });
398
+ await located.waitFor({ state: 'visible', timeout });
275
399
  } catch (error) {
276
- throw new JourneyStepError(`Expected block "${blockId}" to be visible.`, {
277
- expected: `block "${blockId}" to be visible`,
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 { blockId, contains } = params;
419
+ const { contains, ...target } = params;
420
+ const description = describeTarget(target);
285
421
  let text;
286
422
  try {
287
- text = await getBlock(page, blockId).innerText({ timeout });
423
+ text = await readTargetText({ page, target, timeout });
288
424
  } catch (error) {
289
- throw new JourneyStepError(`Expected block "${blockId}" to contain text "${contains}".`, {
290
- expected: `block "${blockId}" text to contain "${contains}"`,
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 block "${blockId}" text to contain "${contains}" but found ${JSON.stringify(
297
- text
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
- innerText: jest.fn(async () => page.texts[selector] ?? ''),
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 undefined;
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
- if (!type.isString(value)) {
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 undefined;
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
- if (!type.isString(params)) {
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. Received 7/],
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
- [{ fill: { blockId: 1, value: 'x' } }, /Step "fill" requires a "blockId" string. Received 1/],
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
- [{ expect: { visible: { blockId: 'a' } } }, /Step "expect.visible" requires a blockId string/],
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-20260906143808",
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-20260906143808",
49
- "@lowdefy/api": "0.0.0-experimental-20260906143808",
50
- "@lowdefy/block-utils": "0.0.0-experimental-20260906143808",
51
- "@lowdefy/blocks-aggrid": "0.0.0-experimental-20260906143808",
52
- "@lowdefy/blocks-antd": "0.0.0-experimental-20260906143808",
53
- "@lowdefy/blocks-antd-x": "0.0.0-experimental-20260906143808",
54
- "@lowdefy/blocks-basic": "0.0.0-experimental-20260906143808",
55
- "@lowdefy/blocks-captcha": "0.0.0-experimental-20260906143808",
56
- "@lowdefy/blocks-echarts": "0.0.0-experimental-20260906143808",
57
- "@lowdefy/blocks-loaders": "0.0.0-experimental-20260906143808",
58
- "@lowdefy/blocks-markdown": "0.0.0-experimental-20260906143808",
59
- "@lowdefy/blocks-tiptap": "0.0.0-experimental-20260906143808",
60
- "@lowdefy/build": "0.0.0-experimental-20260906143808",
61
- "@lowdefy/client": "0.0.0-experimental-20260906143808",
62
- "@lowdefy/connection-axios-http": "0.0.0-experimental-20260906143808",
63
- "@lowdefy/connection-mongodb": "0.0.0-experimental-20260906143808",
64
- "@lowdefy/docs-content": "0.0.0-experimental-20260906143808",
65
- "@lowdefy/e2e-utils": "0.0.0-experimental-20260906143808",
66
- "@lowdefy/engine": "0.0.0-experimental-20260906143808",
67
- "@lowdefy/errors": "0.0.0-experimental-20260906143808",
68
- "@lowdefy/helpers": "0.0.0-experimental-20260906143808",
69
- "@lowdefy/layout": "0.0.0-experimental-20260906143808",
70
- "@lowdefy/logger": "0.0.0-experimental-20260906143808",
71
- "@lowdefy/node-utils": "0.0.0-experimental-20260906143808",
72
- "@lowdefy/operators-change-case": "0.0.0-experimental-20260906143808",
73
- "@lowdefy/operators-dayjs": "0.0.0-experimental-20260906143808",
74
- "@lowdefy/operators-diff": "0.0.0-experimental-20260906143808",
75
- "@lowdefy/operators-js": "0.0.0-experimental-20260906143808",
76
- "@lowdefy/operators-mql": "0.0.0-experimental-20260906143808",
77
- "@lowdefy/operators-nunjucks": "0.0.0-experimental-20260906143808",
78
- "@lowdefy/operators-uuid": "0.0.0-experimental-20260906143808",
79
- "@lowdefy/operators-yaml": "0.0.0-experimental-20260906143808",
80
- "@lowdefy/plugin-better-auth": "0.0.0-experimental-20260906143808",
81
- "@lowdefy/websockets-core": "0.0.0-experimental-20260906143808",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lowdefy/server-dev",
3
- "version": "0.0.0-experimental-20260906143808",
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-20260906143808",
55
- "@lowdefy/api": "0.0.0-experimental-20260906143808",
56
- "@lowdefy/block-utils": "0.0.0-experimental-20260906143808",
57
- "@lowdefy/blocks-aggrid": "0.0.0-experimental-20260906143808",
58
- "@lowdefy/blocks-antd": "0.0.0-experimental-20260906143808",
59
- "@lowdefy/blocks-antd-x": "0.0.0-experimental-20260906143808",
60
- "@lowdefy/blocks-basic": "0.0.0-experimental-20260906143808",
61
- "@lowdefy/blocks-captcha": "0.0.0-experimental-20260906143808",
62
- "@lowdefy/blocks-echarts": "0.0.0-experimental-20260906143808",
63
- "@lowdefy/blocks-loaders": "0.0.0-experimental-20260906143808",
64
- "@lowdefy/blocks-markdown": "0.0.0-experimental-20260906143808",
65
- "@lowdefy/blocks-tiptap": "0.0.0-experimental-20260906143808",
66
- "@lowdefy/build": "0.0.0-experimental-20260906143808",
67
- "@lowdefy/client": "0.0.0-experimental-20260906143808",
68
- "@lowdefy/connection-axios-http": "0.0.0-experimental-20260906143808",
69
- "@lowdefy/connection-mongodb": "0.0.0-experimental-20260906143808",
70
- "@lowdefy/docs-content": "0.0.0-experimental-20260906143808",
71
- "@lowdefy/e2e-utils": "0.0.0-experimental-20260906143808",
72
- "@lowdefy/engine": "0.0.0-experimental-20260906143808",
73
- "@lowdefy/errors": "0.0.0-experimental-20260906143808",
74
- "@lowdefy/helpers": "0.0.0-experimental-20260906143808",
75
- "@lowdefy/layout": "0.0.0-experimental-20260906143808",
76
- "@lowdefy/logger": "0.0.0-experimental-20260906143808",
77
- "@lowdefy/node-utils": "0.0.0-experimental-20260906143808",
78
- "@lowdefy/operators-change-case": "0.0.0-experimental-20260906143808",
79
- "@lowdefy/operators-dayjs": "0.0.0-experimental-20260906143808",
80
- "@lowdefy/operators-diff": "0.0.0-experimental-20260906143808",
81
- "@lowdefy/operators-js": "0.0.0-experimental-20260906143808",
82
- "@lowdefy/operators-mql": "0.0.0-experimental-20260906143808",
83
- "@lowdefy/operators-nunjucks": "0.0.0-experimental-20260906143808",
84
- "@lowdefy/operators-uuid": "0.0.0-experimental-20260906143808",
85
- "@lowdefy/operators-yaml": "0.0.0-experimental-20260906143808",
86
- "@lowdefy/plugin-better-auth": "0.0.0-experimental-20260906143808",
87
- "@lowdefy/websockets-core": "0.0.0-experimental-20260906143808",
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",