@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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
39
|
+
"@lowdefy/helpers": "5.4.0",
|
|
40
40
|
"js-yaml": "4.1.0",
|
|
41
41
|
"prompts": "2.4.2"
|
|
42
42
|
},
|