@lowdefy/e2e-utils 5.2.0 → 5.4.0

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/README.md CHANGED
@@ -89,6 +89,44 @@ test('validates required fields', async ({ ldf }) => {
89
89
  });
90
90
  ```
91
91
 
92
+ ### Testing List Children (`ldf.list()` / `ldf.block()` with indexed ids)
93
+
94
+ List blocks render their slot children with runtime-prefixed ids
95
+ (`legal_rows.0.toggle`, `legal_rows.1.toggle`, ...). You can pass these indexed ids
96
+ straight into `ldf.block()` — the manifest stores `legal_rows.$.toggle` and the runtime
97
+ resolves the numeric segment automatically:
98
+
99
+ ```javascript
100
+ await ldf.block('legal_rows.0.toggle').do.toggle();
101
+ await ldf.block('legal_rows.0.toggle').expect.checked();
102
+ ```
103
+
104
+ For ergonomic row addressing, use `ldf.list(listId)`:
105
+
106
+ ```javascript
107
+ // Number of rows in state
108
+ const count = await ldf.list('legal_rows').count();
109
+
110
+ // Positional access (synchronous)
111
+ await ldf.list('legal_rows').row(0).block('toggle').do.toggle();
112
+
113
+ // By key (async — reads list state to find the index)
114
+ const row = await ldf.list('legal_rows').rowBy('_id', 'bbbee');
115
+ await row.block('toggle').do.toggle();
116
+ await row.block('toggle').expect.checked();
117
+
118
+ // By predicate (async)
119
+ const archived = await ldf.list('legal_rows').rowWhere((it) => it.archived === true);
120
+ await archived.block('restore_btn').do.click();
121
+ ```
122
+
123
+ Nested lists work the same way — every level of nesting adds another numeric
124
+ segment, all resolved through one templated lookup:
125
+
126
+ ```javascript
127
+ await ldf.block('outer.0.inner.2.button').do.click();
128
+ ```
129
+
92
130
  ### State (`ldf.state()`)
93
131
 
94
132
  ```javascript
@@ -22,6 +22,7 @@ import { expectUrl, expectUrlQuery, setUrlQuery } from '../core/url.js';
22
22
  import { setUserCookie, clearUserCookie } from '../core/userCookie.js';
23
23
  import { get, type } from '@lowdefy/helpers';
24
24
  import createBlockMethodProxy from './createBlockMethodProxy.js';
25
+ import resolveTemplateId from './resolveTemplateId.js';
25
26
  function createPageManager({ page, manifest, helperRegistry, mockManager }) {
26
27
  let currentBlockMap = null;
27
28
  let currentPageId = null;
@@ -30,6 +31,81 @@ function createPageManager({ page, manifest, helperRegistry, mockManager }) {
30
31
  throw new Error('Call goto() before accessing blocks');
31
32
  }
32
33
  }
34
+ // Block locator - ldf.block('id').do.*/expect.*/locator()/state()/validation()
35
+ // List children: pass the concrete runtime blockId (e.g. `legal_rows.0.toggle`) and we
36
+ // fall back to the templated entry (`legal_rows.$.toggle`) for the type/helper. The
37
+ // concrete blockId still flows into each helper's locator(page, blockId) so the DOM
38
+ // selector targets the right row.
39
+ function block(blockId) {
40
+ ensurePageLoaded();
41
+ let blockInfo = currentBlockMap[blockId];
42
+ if (!blockInfo) {
43
+ const templateId = resolveTemplateId(blockId);
44
+ if (templateId !== blockId) {
45
+ blockInfo = currentBlockMap[templateId];
46
+ }
47
+ }
48
+ if (!blockInfo) {
49
+ const available = Object.keys(currentBlockMap).join(', ');
50
+ throw new Error(`Block "${blockId}" not found on page. Available blocks: ${available || '(none)'}`);
51
+ }
52
+ return {
53
+ do: createBlockMethodProxy({
54
+ page,
55
+ blockId,
56
+ blockInfo,
57
+ helperRegistry,
58
+ mode: 'do'
59
+ }),
60
+ expect: createBlockMethodProxy({
61
+ page,
62
+ blockId,
63
+ blockInfo,
64
+ helperRegistry,
65
+ mode: 'expect'
66
+ }),
67
+ locator: ()=>getBlock(page, blockId),
68
+ state: ()=>getBlockState(page, {
69
+ blockId
70
+ }),
71
+ validation: ()=>getValidation(page, blockId)
72
+ };
73
+ }
74
+ // List locator - ldf.list('id') for ergonomic addressing of list children.
75
+ // .count() → number of items currently in state
76
+ // .row(i).block('childId') → synchronous, positional access
77
+ // .rowBy('_id', 'bbbee') → async; resolves to a row whose block(...) is sync
78
+ // .rowWhere(item => ...) → async; same shape as rowBy
79
+ // All paths delegate to block(`${listId}.${index}.${childId}`), so the templated
80
+ // manifest lookup above takes over from there.
81
+ function list(listId) {
82
+ ensurePageLoaded();
83
+ const readItems = async ()=>{
84
+ const state = await getState(page);
85
+ const items = get(state, listId);
86
+ if (!Array.isArray(items)) {
87
+ throw new Error(`ldf.list("${listId}"): state value is not an array (got ${items === undefined ? 'undefined' : typeof items}). Make sure a Request has populated the list state before reading rows.`);
88
+ }
89
+ return items;
90
+ };
91
+ const rowAt = (index)=>({
92
+ block: (childId)=>block(`${listId}.${index}.${childId}`)
93
+ });
94
+ const findRow = async (predicate, describe)=>{
95
+ const items = await readItems();
96
+ const index = items.findIndex(predicate);
97
+ if (index < 0) {
98
+ throw new Error(`ldf.list("${listId}"): no row matched ${describe}.`);
99
+ }
100
+ return rowAt(index);
101
+ };
102
+ return {
103
+ count: async ()=>(await readItems()).length,
104
+ row: (index)=>rowAt(index),
105
+ rowBy: (key, value)=>findRow((item)=>item != null && item[key] === value, `${key}=${JSON.stringify(value)}`),
106
+ rowWhere: (predicate)=>findRow(predicate, 'predicate')
107
+ };
108
+ }
33
109
  return {
34
110
  // Raw Playwright page
35
111
  page,
@@ -60,36 +136,8 @@ function createPageManager({ page, manifest, helperRegistry, mockManager }) {
60
136
  get pageId () {
61
137
  return currentPageId;
62
138
  },
63
- // Block locator - ldf.block('id').do.*/expect.*/locator()/state()/validation()
64
- block (blockId) {
65
- ensurePageLoaded();
66
- const blockInfo = currentBlockMap[blockId];
67
- if (!blockInfo) {
68
- const available = Object.keys(currentBlockMap).join(', ');
69
- throw new Error(`Block "${blockId}" not found on page. Available blocks: ${available || '(none)'}`);
70
- }
71
- return {
72
- do: createBlockMethodProxy({
73
- page,
74
- blockId,
75
- blockInfo,
76
- helperRegistry,
77
- mode: 'do'
78
- }),
79
- expect: createBlockMethodProxy({
80
- page,
81
- blockId,
82
- blockInfo,
83
- helperRegistry,
84
- mode: 'expect'
85
- }),
86
- locator: ()=>getBlock(page, blockId),
87
- state: ()=>getBlockState(page, {
88
- blockId
89
- }),
90
- validation: ()=>getValidation(page, blockId)
91
- };
92
- },
139
+ block,
140
+ list,
93
141
  // Request locator - ldf.request('id').expect.*/response()/state()
94
142
  request (requestId) {
95
143
  return {
@@ -0,0 +1,21 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ // Convert a runtime blockId (e.g. `legal_rows.0.toggle`) into the template form
16
+ // stored in the e2e manifest (`legal_rows.$.toggle`) by replacing each integer-only
17
+ // dot-separated segment with `$`. Mixed segments like `step_1d` are left as-is.
18
+ function resolveTemplateId(blockId) {
19
+ return blockId.split('.').map((segment)=>/^\d+$/.test(segment) ? '$' : segment).join('.');
20
+ }
21
+ export default resolveTemplateId;
@@ -18,34 +18,46 @@
18
18
  if (container['~arr']) return container['~arr'];
19
19
  return [];
20
20
  }
21
- function extractBlockMap({ pageConfig, typesBlocks }) {
21
+ function extractBlockMap({ pageConfig, typesBlocks, blockMetas = {} }) {
22
22
  const blockMap = {};
23
- function traverse(obj) {
23
+ function traverse(obj, prefix) {
24
24
  if (!obj || typeof obj !== 'object') return;
25
- // If this object has blockId and type, record it
25
+ let nextPrefix = prefix;
26
+ // If this object has blockId and type, record it under prefix + blockId.
27
+ // List-category blocks (category: 'list' on their meta) iterate their slot children
28
+ // at runtime under {blockId}.{index}.{childId}, so we record children under the
29
+ // template id `{blockId}.$.{childId}` and downstream resolves numeric runtime
30
+ // segments back to `$` for lookup.
26
31
  if (obj.blockId && obj.type) {
27
32
  const packageName = typesBlocks[obj.type]?.package;
28
33
  if (packageName) {
29
- blockMap[obj.blockId] = {
34
+ blockMap[`${prefix}${obj.blockId}`] = {
30
35
  type: obj.type,
31
36
  helper: `${packageName}/e2e`
32
37
  };
33
38
  }
39
+ if (blockMetas[obj.type]?.category === 'list') {
40
+ nextPrefix = `${prefix}${obj.blockId}.$.`;
41
+ }
42
+ }
43
+ // Traverse slots - slot.blocks is { "~arr": [...blocks...] }
44
+ if (obj.slots) {
45
+ Object.values(obj.slots).forEach((slot)=>{
46
+ getBlocksArray(slot.blocks).forEach((block)=>traverse(block, nextPrefix));
47
+ });
34
48
  }
35
49
  // Traverse areas - area.blocks is { "~arr": [...blocks...] }
36
50
  if (obj.areas) {
37
51
  Object.values(obj.areas).forEach((area)=>{
38
- const blocks = getBlocksArray(area.blocks);
39
- blocks.forEach((block)=>traverse(block));
52
+ getBlocksArray(area.blocks).forEach((block)=>traverse(block, nextPrefix));
40
53
  });
41
54
  }
42
55
  // Traverse direct blocks array (may also be { "~arr": [...] })
43
56
  if (obj.blocks) {
44
- const blocks = getBlocksArray(obj.blocks);
45
- blocks.forEach((block)=>traverse(block));
57
+ getBlocksArray(obj.blocks).forEach((block)=>traverse(block, nextPrefix));
46
58
  }
47
59
  }
48
- traverse(pageConfig);
60
+ traverse(pageConfig, '');
49
61
  return blockMap;
50
62
  }
51
63
  export default extractBlockMap;
@@ -21,6 +21,11 @@ function generateManifest({ buildDir = '.lowdefy' }) {
21
21
  throw new Error(`Build artifacts not found at ${buildDir}. Run 'lowdefy build' first.`);
22
22
  }
23
23
  const types = JSON.parse(fs.readFileSync(typesPath, 'utf-8'));
24
+ // blockMetas tells us which block types iterate their slot children (category === 'list').
25
+ // Missing file is non-fatal: older builds keep working, list children just don't get the
26
+ // .$. prefix applied.
27
+ const blockMetasPath = path.join(buildDir, 'plugins/blockMetas.json');
28
+ const blockMetas = fs.existsSync(blockMetasPath) ? JSON.parse(fs.readFileSync(blockMetasPath, 'utf-8')) : {};
24
29
  const pagesDir = path.join(buildDir, 'pages');
25
30
  const manifest = {
26
31
  pages: {}
@@ -42,7 +47,8 @@ function generateManifest({ buildDir = '.lowdefy' }) {
42
47
  const pageConfig = JSON.parse(fs.readFileSync(pageConfigPath, 'utf-8'));
43
48
  manifest.pages[pageId] = extractBlockMap({
44
49
  pageConfig,
45
- typesBlocks: types.blocks ?? {}
50
+ typesBlocks: types.blocks ?? {},
51
+ blockMetas
46
52
  });
47
53
  }
48
54
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lowdefy/e2e-utils",
3
- "version": "5.2.0",
3
+ "version": "5.4.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Lowdefy E2E Testing Utilities for Playwright",
6
6
  "homepage": "https://lowdefy.com",
@@ -36,7 +36,7 @@
36
36
  "dist/*"
37
37
  ],
38
38
  "dependencies": {
39
- "@lowdefy/helpers": "5.2.0",
39
+ "@lowdefy/helpers": "5.4.0",
40
40
  "js-yaml": "4.1.0",
41
41
  "prompts": "2.4.2"
42
42
  },