@lowdefy/e2e-utils 6.1.0 → 7.0.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 +24 -0
- package/dist/config.js +42 -9
- package/dist/core/instanceKey.js +32 -0
- package/dist/core/navigation.js +28 -11
- package/dist/core/readBasePath.js +25 -0
- package/dist/core/requests.js +6 -7
- package/dist/core/state.js +7 -11
- package/dist/core/url.js +14 -10
- package/dist/core/validation.js +9 -8
- package/dist/core/verifyServer.js +49 -0
- package/dist/globalSetup.js +24 -0
- package/dist/index.js +3 -0
- package/dist/init/index.js +1 -1
- package/dist/init/templates/README.md.template +4 -2
- package/dist/proxy/createPageManager.js +17 -5
- package/dist/startServer.js +203 -0
- package/dist/targets/index.js +17 -0
- package/dist/targets/resolveTargetInDocument.js +118 -0
- package/dist/targets/targetFixtures.js +380 -0
- package/dist/testPrep/generateManifest.js +13 -1
- package/package.json +12 -3
|
@@ -0,0 +1,203 @@
|
|
|
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
|
+
*/ import { spawn } from 'child_process';
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
import isPortAvailable from '@lowdefy/node-utils/isPortAvailable.js';
|
|
19
|
+
const READY_POLL_MS = 500;
|
|
20
|
+
function resolveLowdefyCommand({ appDir }) {
|
|
21
|
+
const binName = process.platform === 'win32' ? 'lowdefy.cmd' : 'lowdefy';
|
|
22
|
+
const localBin = path.join(appDir, 'node_modules', '.bin', binName);
|
|
23
|
+
if (fs.existsSync(localBin)) {
|
|
24
|
+
return {
|
|
25
|
+
command: localBin,
|
|
26
|
+
prefix: []
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
command: process.platform === 'win32' ? 'npx.cmd' : 'npx',
|
|
31
|
+
prefix: [
|
|
32
|
+
'lowdefy'
|
|
33
|
+
]
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function hasExited(child) {
|
|
37
|
+
return child.exitCode !== null || child.signalCode !== null;
|
|
38
|
+
}
|
|
39
|
+
function waitForExit({ child, timeoutMs }) {
|
|
40
|
+
if (hasExited(child)) {
|
|
41
|
+
return Promise.resolve(true);
|
|
42
|
+
}
|
|
43
|
+
return new Promise((resolve)=>{
|
|
44
|
+
const timer = setTimeout(()=>resolve(false), timeoutMs);
|
|
45
|
+
child.once('exit', ()=>{
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
resolve(true);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function runBuild({ appDir, lowdefy, env }) {
|
|
52
|
+
return new Promise((resolve, reject)=>{
|
|
53
|
+
const build = spawn(lowdefy.command, [
|
|
54
|
+
...lowdefy.prefix,
|
|
55
|
+
'build',
|
|
56
|
+
'--server',
|
|
57
|
+
'e2e'
|
|
58
|
+
], {
|
|
59
|
+
cwd: appDir,
|
|
60
|
+
env,
|
|
61
|
+
stdio: 'inherit',
|
|
62
|
+
// https://nodejs.org/en/blog/vulnerability/april-2024-security-releases-2#command-injection-via-args-parameter-of-child_processspawn-without-shell-option-enabled-on-windows-cve-2024-27980---high
|
|
63
|
+
shell: process.platform === 'win32'
|
|
64
|
+
});
|
|
65
|
+
build.on('error', reject);
|
|
66
|
+
build.on('exit', (code)=>{
|
|
67
|
+
if (code !== 0) {
|
|
68
|
+
reject(new Error(`lowdefy build exited with code ${code}.`));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
resolve();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
function signalGroup({ child, signal }) {
|
|
76
|
+
try {
|
|
77
|
+
if (process.platform === 'win32') {
|
|
78
|
+
child.kill(signal);
|
|
79
|
+
} else {
|
|
80
|
+
// The CLI and the server it runs share the group startServer gave them.
|
|
81
|
+
process.kill(-child.pid, signal);
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error.code !== 'ESRCH') {
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function isAnswering({ url }) {
|
|
90
|
+
try {
|
|
91
|
+
await fetch(url, {
|
|
92
|
+
redirect: 'manual',
|
|
93
|
+
signal: AbortSignal.timeout(2000)
|
|
94
|
+
});
|
|
95
|
+
return true;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Starts a Lowdefy app's e2e server for a test harness (a vitest or Jest globalSetup, a rig
|
|
101
|
+
// script) and returns once it answers. The server is tied to this process: it stops when this
|
|
102
|
+
// process exits, however it exits (LOWDEFY_EXIT_WITH_PID), so a killed or timed-out run leaves
|
|
103
|
+
// nothing behind. Call stop() in teardown to stop it sooner.
|
|
104
|
+
async function startServer({ appDir, port, env = {}, build = true, timeoutMs = 180000, stopGraceMs = 10000 }) {
|
|
105
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
106
|
+
throw new Error(`startServer requires a port. Received ${JSON.stringify(port)}.`);
|
|
107
|
+
}
|
|
108
|
+
const absoluteAppDir = path.resolve(appDir);
|
|
109
|
+
// An answer from a server already on the port would pass for this one.
|
|
110
|
+
if (!await isPortAvailable({
|
|
111
|
+
port
|
|
112
|
+
})) {
|
|
113
|
+
throw new Error(`Port ${port} is already in use.`);
|
|
114
|
+
}
|
|
115
|
+
const lowdefy = resolveLowdefyCommand({
|
|
116
|
+
appDir: absoluteAppDir
|
|
117
|
+
});
|
|
118
|
+
const serverEnv = {
|
|
119
|
+
...process.env,
|
|
120
|
+
...env,
|
|
121
|
+
LOWDEFY_EXIT_WITH_PID: String(process.pid)
|
|
122
|
+
};
|
|
123
|
+
if (build) {
|
|
124
|
+
await runBuild({
|
|
125
|
+
appDir: absoluteAppDir,
|
|
126
|
+
lowdefy,
|
|
127
|
+
env: serverEnv
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const child = spawn(lowdefy.command, [
|
|
131
|
+
...lowdefy.prefix,
|
|
132
|
+
'start',
|
|
133
|
+
'--port',
|
|
134
|
+
String(port),
|
|
135
|
+
'--log-level',
|
|
136
|
+
'warn'
|
|
137
|
+
], {
|
|
138
|
+
cwd: absoluteAppDir,
|
|
139
|
+
env: serverEnv,
|
|
140
|
+
stdio: [
|
|
141
|
+
'ignore',
|
|
142
|
+
'inherit',
|
|
143
|
+
'inherit'
|
|
144
|
+
],
|
|
145
|
+
// Its own process group, so stop() reaches the server under any wrapper (npx, the CLI).
|
|
146
|
+
detached: process.platform !== 'win32',
|
|
147
|
+
shell: process.platform === 'win32'
|
|
148
|
+
});
|
|
149
|
+
let spawnError = null;
|
|
150
|
+
child.on('error', (error)=>{
|
|
151
|
+
spawnError = error;
|
|
152
|
+
});
|
|
153
|
+
async function stop() {
|
|
154
|
+
if (hasExited(child)) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
signalGroup({
|
|
158
|
+
child,
|
|
159
|
+
signal: 'SIGTERM'
|
|
160
|
+
});
|
|
161
|
+
if (await waitForExit({
|
|
162
|
+
child,
|
|
163
|
+
timeoutMs: stopGraceMs
|
|
164
|
+
})) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
signalGroup({
|
|
168
|
+
child,
|
|
169
|
+
signal: 'SIGKILL'
|
|
170
|
+
});
|
|
171
|
+
await waitForExit({
|
|
172
|
+
child,
|
|
173
|
+
timeoutMs: stopGraceMs
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const url = `http://localhost:${port}`;
|
|
177
|
+
const deadline = Date.now() + timeoutMs;
|
|
178
|
+
try {
|
|
179
|
+
while(Date.now() < deadline){
|
|
180
|
+
if (spawnError !== null) {
|
|
181
|
+
throw spawnError;
|
|
182
|
+
}
|
|
183
|
+
if (hasExited(child)) {
|
|
184
|
+
throw new Error(`lowdefy start exited with code ${child.exitCode} before the server was ready.`);
|
|
185
|
+
}
|
|
186
|
+
if (await isAnswering({
|
|
187
|
+
url
|
|
188
|
+
})) {
|
|
189
|
+
return {
|
|
190
|
+
url,
|
|
191
|
+
port,
|
|
192
|
+
stop
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
await new Promise((resolve)=>setTimeout(resolve, READY_POLL_MS));
|
|
196
|
+
}
|
|
197
|
+
throw new Error(`The server on port ${port} was not ready within ${timeoutMs}ms.`);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
await stop();
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
export default startServer;
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
*/ import resolveTargetInDocument from './resolveTargetInDocument.js';
|
|
16
|
+
import targetFixtures from './targetFixtures.js';
|
|
17
|
+
export { resolveTargetInDocument, targetFixtures };
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
*/ import { findInteractiveControls, journeyTargetSelectors, type } from '@lowdefy/helpers';
|
|
16
|
+
const { blockWrapperPrefix, cellAttribute, layers, rowAttribute } = journeyTargetSelectors;
|
|
17
|
+
function textOf(element) {
|
|
18
|
+
return element.textContent.replace(/\s+/g, ' ').trim();
|
|
19
|
+
}
|
|
20
|
+
function controlsIn(roots) {
|
|
21
|
+
return roots.flatMap((root)=>findInteractiveControls(root));
|
|
22
|
+
}
|
|
23
|
+
function controlsWithText(roots, text) {
|
|
24
|
+
return controlsIn(roots).filter((control)=>textOf(control) === text);
|
|
25
|
+
}
|
|
26
|
+
function attributeSelector(value) {
|
|
27
|
+
return `"${String(value).replace(/["\\]/g, '\\$&')}"`;
|
|
28
|
+
}
|
|
29
|
+
// The roots the target's scope keys name: the block wrapper, narrowed to every grid row with the
|
|
30
|
+
// row index (ag-grid renders a row once per pinned and centre container) and to the cells with
|
|
31
|
+
// the column id inside them. Null for a page-wide target.
|
|
32
|
+
function resolveScope({ document, target }) {
|
|
33
|
+
if (type.isNone(target.block_id)) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const wrapper = document.getElementById(`${blockWrapperPrefix}${target.block_id}`);
|
|
37
|
+
let roots = wrapper ? [
|
|
38
|
+
wrapper
|
|
39
|
+
] : [];
|
|
40
|
+
if (!type.isNone(target.row)) {
|
|
41
|
+
roots = roots.flatMap((root)=>[
|
|
42
|
+
...root.querySelectorAll(`.ag-row[${rowAttribute}=${attributeSelector(target.row)}]`)
|
|
43
|
+
]);
|
|
44
|
+
}
|
|
45
|
+
if (!type.isNone(target.column)) {
|
|
46
|
+
roots = roots.flatMap((root)=>[
|
|
47
|
+
...root.querySelectorAll(`.ag-cell[${cellAttribute}=${attributeSelector(target.column)}]`)
|
|
48
|
+
]);
|
|
49
|
+
}
|
|
50
|
+
return roots;
|
|
51
|
+
}
|
|
52
|
+
// The match a text target names: the nth, or with no nth the only one. The runner fails an
|
|
53
|
+
// action whose text matches several controls and gives no nth, so that resolves to nothing.
|
|
54
|
+
function pickMatch({ matches, nth }) {
|
|
55
|
+
if (type.isNone(nth)) {
|
|
56
|
+
return matches.length === 1 ? matches[0] : null;
|
|
57
|
+
}
|
|
58
|
+
return matches[nth] ?? null;
|
|
59
|
+
}
|
|
60
|
+
// The front-most open layer that holds a control with the text, else the page.
|
|
61
|
+
function resolvePageWideText({ document, target }) {
|
|
62
|
+
for (const layer of layers){
|
|
63
|
+
const open = [
|
|
64
|
+
...document.querySelectorAll(layer)
|
|
65
|
+
];
|
|
66
|
+
if (open.length > 0) {
|
|
67
|
+
const root = open[open.length - 1];
|
|
68
|
+
if (controlsWithText([
|
|
69
|
+
root
|
|
70
|
+
], target.text).length > 0) {
|
|
71
|
+
return pickMatch({
|
|
72
|
+
matches: controlsWithText([
|
|
73
|
+
root
|
|
74
|
+
], target.text),
|
|
75
|
+
nth: target.nth
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return pickMatch({
|
|
81
|
+
matches: controlsWithText([
|
|
82
|
+
document
|
|
83
|
+
], target.text),
|
|
84
|
+
nth: target.nth
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
// The element a journey `click` step acts on for a target in describe form
|
|
88
|
+
// ({ block_id, row, column, text, nth }; null or missing keys are absent), resolved in a DOM
|
|
89
|
+
// without layout (jsdom) step for step as the journey runner resolves it with Playwright:
|
|
90
|
+
// visibility is assumed. A target that names a control (`text`, `nth`) resolves to it; a target
|
|
91
|
+
// that names a container (block, row, cell) resolves to its first control, or to itself when it
|
|
92
|
+
// has none. Null when nothing matches, or when a text with no nth matches several controls.
|
|
93
|
+
function resolveTargetInDocument({ document, target }) {
|
|
94
|
+
const scope = resolveScope({
|
|
95
|
+
document,
|
|
96
|
+
target
|
|
97
|
+
});
|
|
98
|
+
if (!type.isNone(target.text)) {
|
|
99
|
+
if (scope === null) {
|
|
100
|
+
return resolvePageWideText({
|
|
101
|
+
document,
|
|
102
|
+
target
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return pickMatch({
|
|
106
|
+
matches: controlsWithText(scope, target.text),
|
|
107
|
+
nth: target.nth
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (scope === null) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
if (!type.isNone(target.nth)) {
|
|
114
|
+
return controlsIn(scope)[target.nth] ?? null;
|
|
115
|
+
}
|
|
116
|
+
return controlsIn(scope)[0] ?? scope[0] ?? null;
|
|
117
|
+
}
|
|
118
|
+
export default resolveTargetInDocument;
|