@lowdefy/e2e-utils 6.1.0 → 7.1.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.
@@ -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;