@lowdefy/node-utils 0.0.0-experimental-20261006165558 → 0.0.0-experimental-20261007124348

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,45 @@
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 { isIP } from 'node:net';
16
+ import { buildConnector } from 'undici';
17
+ import createLookupPublicAddress from './createLookupPublicAddress.js';
18
+ // An undici connector that opens a socket only to a public address, for the first link and every
19
+ // redirect alike. A socket to an IP literal skips the lookup, so a literal is checked here before
20
+ // connecting. A refused address fails the connection with the error createNotPublicError(hostname)
21
+ // returns.
22
+ function createConnectPublic({ createNotPublicError }) {
23
+ const lookupPublicAddress = createLookupPublicAddress({
24
+ createNotPublicError
25
+ });
26
+ const connect = buildConnector({
27
+ lookup: lookupPublicAddress
28
+ });
29
+ return function connectPublic(options, callback) {
30
+ if (isIP(options.hostname) === 0) {
31
+ connect(options, callback);
32
+ return;
33
+ }
34
+ lookupPublicAddress(options.hostname, {
35
+ all: true
36
+ }, (error)=>{
37
+ if (error) {
38
+ callback(error, null);
39
+ return;
40
+ }
41
+ connect(options, callback);
42
+ });
43
+ };
44
+ }
45
+ export default createConnectPublic;
@@ -0,0 +1,39 @@
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 { lookup } from 'node:dns';
16
+ import isPublicAddress from './isPublicAddress.js';
17
+ // A dns.lookup for the socket to use: the socket connects to the addresses this answers, so the
18
+ // address checked is the address connected to, and a name that resolves differently on a second
19
+ // lookup cannot get past the check. Refuses a name when any of its addresses is not public, with
20
+ // the error createNotPublicError(hostname) returns.
21
+ function createLookupPublicAddress({ createNotPublicError }) {
22
+ return function lookupPublicAddress(hostname, options, callback) {
23
+ lookup(hostname, options, (error, address, family)=>{
24
+ if (error) {
25
+ callback(error);
26
+ return;
27
+ }
28
+ const addresses = Array.isArray(address) ? address.map((entry)=>entry.address) : [
29
+ address
30
+ ];
31
+ if (!addresses.every(isPublicAddress)) {
32
+ callback(createNotPublicError(hostname));
33
+ return;
34
+ }
35
+ callback(null, address, family);
36
+ });
37
+ };
38
+ }
39
+ export default createLookupPublicAddress;
package/dist/index.js CHANGED
@@ -18,7 +18,9 @@ import cleanDirectory from './cleanDirectory.js';
18
18
  import collectEnvironmentGuards from './collectEnvironmentGuards.js';
19
19
  import compareProcessStartTimes from './compareProcessStartTimes.js';
20
20
  import copyFileOrDirectory from './copyFileOrDirectory.js';
21
+ import createConnectPublic from './createConnectPublic.js';
21
22
  import createClientAddressResolver from './createClientAddressResolver.js';
23
+ import createLookupPublicAddress from './createLookupPublicAddress.js';
22
24
  import createSecretScrubber from './createSecretScrubber.js';
23
25
  import devPassiveHeader from './devPassiveHeader.js';
24
26
  import dataSetNamePattern from './dataSetNamePattern.js';
@@ -33,6 +35,7 @@ import getSecretsFromEnv from './getSecretsFromEnv.js';
33
35
  import installIfPackageJsonChanged from './installIfPackageJsonChanged.js';
34
36
  import isPidAlive from './isPidAlive.js';
35
37
  import isProcessAlive from './isProcessAlive.js';
38
+ import isPublicAddress from './isPublicAddress.js';
36
39
  import isProcessStartTime from './isProcessStartTime.js';
37
40
  import isPortAvailable from './isPortAvailable.js';
38
41
  import linkDependenciesToWorkspace from './linkDependenciesToWorkspace.js';
@@ -64,6 +67,8 @@ import findPlaceholderStep from './journeyGrammar/findPlaceholderStep.js';
64
67
  import failurePathKey from './journeyEvidence/failurePathKey.js';
65
68
  import isBackedBy from './journeyEvidence/isBackedBy.js';
66
69
  import normaliseBlockId from './journeyGrammar/normaliseBlockId.js';
70
+ import normaliseJourneySteps from './journeyGrammar/normaliseJourneySteps.js';
71
+ import journeyStepSchema, { JOURNEY_STEP_SCHEMAS } from './journeyGrammar/journeyStepSchema.js';
67
72
  import hashSequence from './journeyCompiler/hashSequence.js';
68
73
  import journeySequence from './journeyCompiler/journeySequence.js';
69
74
  import listFailurePaths from './journeyCompiler/listFailurePaths.js';
@@ -78,4 +83,4 @@ import validateJourneySteps, { getStepKey, INTERACTION_STEP_KEYS, STEP_KEYS, TAR
78
83
  import validateJourneyTags, { JOURNEY_TAG_PATTERN } from './journeyGrammar/validateJourneyTags.js';
79
84
  import validateJourneyUser from './journeyGrammar/validateJourneyUser.js';
80
85
  import validateTraceRecord from './journeyTrace/validateTraceRecord.js';
81
- export { acquireMachineSlot, buildSessionReport, checkEnvironmentGuards, cleanDirectory, collectEnvironmentGuards, compareProcessStartTimes, compileSegments, copyFileOrDirectory, countDataSetDocuments, countTextTokens, createClientAddressResolver, createSecretScrubber, devPassiveHeader, dataSetNamePattern, failurePathKey, findAvailablePort, findPlaceholderStep, findPnpmWorkspaceRoot, formatSessionLog, formatSessionReport, findWorkspacePackages, getDevInstancePath, getFileExtension, getFileSubExtension, getLowdefyHome, getProcessStartTime, getSecretsFromEnv, hashSequence, installIfPackageJsonChanged, isBackedBy, isPidAlive, isProcessAlive, isProcessStartTime, isPortAvailable, journeySequence, linkDependenciesToWorkspace, linkWorkspaceDependencies, listDataSets, parseDataSet, listFailurePaths, listRecordingFiles, matchPagePath, normaliseBlockId, normaliseClickText, parseIpRange, parseSince, parsePsStartTime, parseTraceLines, profileProduction, readDevInstance, readDevInstanceAsync, readProcessStartTime, readServerRegistry, registerServer, readRecordings, RECORDING_SOURCES, spawnProcess, stepIdentity, summariseSessions, readFile, writeFile, writeFileAtomic, watchOwner, writeFileIfChanged, getStepKey, INTERACTION_STEP_KEYS, STEP_KEYS, TARGET_KEYS, validateJourneySteps, validateTraceRecord, JOURNEY_TAG_PATTERN, validateJourneyTags, validateJourneyUser };
86
+ export { acquireMachineSlot, buildSessionReport, checkEnvironmentGuards, cleanDirectory, collectEnvironmentGuards, compareProcessStartTimes, compileSegments, copyFileOrDirectory, countDataSetDocuments, countTextTokens, createClientAddressResolver, createConnectPublic, createLookupPublicAddress, createSecretScrubber, devPassiveHeader, dataSetNamePattern, failurePathKey, findAvailablePort, findPlaceholderStep, findPnpmWorkspaceRoot, formatSessionLog, formatSessionReport, findWorkspacePackages, getDevInstancePath, getFileExtension, getFileSubExtension, getLowdefyHome, getProcessStartTime, getSecretsFromEnv, hashSequence, installIfPackageJsonChanged, isBackedBy, isPidAlive, isProcessAlive, isPublicAddress, isProcessStartTime, isPortAvailable, journeySequence, journeyStepSchema, linkDependenciesToWorkspace, linkWorkspaceDependencies, listDataSets, parseDataSet, listFailurePaths, listRecordingFiles, matchPagePath, normaliseBlockId, normaliseJourneySteps, normaliseClickText, parseIpRange, parseSince, parsePsStartTime, parseTraceLines, profileProduction, readDevInstance, readDevInstanceAsync, readProcessStartTime, readServerRegistry, registerServer, readRecordings, RECORDING_SOURCES, spawnProcess, stepIdentity, summariseSessions, readFile, writeFile, writeFileAtomic, watchOwner, writeFileIfChanged, getStepKey, INTERACTION_STEP_KEYS, JOURNEY_STEP_SCHEMAS, STEP_KEYS, TARGET_KEYS, validateJourneySteps, validateTraceRecord, JOURNEY_TAG_PATTERN, validateJourneyTags, validateJourneyUser };
@@ -0,0 +1,107 @@
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 { BlockList, isIPv6 } from 'node:net';
16
+ // Ranges a server-side fetch of a user-supplied link may not reach: anything that is not a public
17
+ // unicast address. BlockList also matches an IPv4-mapped IPv6 address (::ffff:127.0.0.1) against
18
+ // the IPv4 ranges.
19
+ const notPublicIPv4 = [
20
+ [
21
+ '0.0.0.0',
22
+ 8
23
+ ],
24
+ [
25
+ '10.0.0.0',
26
+ 8
27
+ ],
28
+ [
29
+ '100.64.0.0',
30
+ 10
31
+ ],
32
+ [
33
+ '127.0.0.0',
34
+ 8
35
+ ],
36
+ [
37
+ '169.254.0.0',
38
+ 16
39
+ ],
40
+ [
41
+ '172.16.0.0',
42
+ 12
43
+ ],
44
+ [
45
+ '192.0.0.0',
46
+ 24
47
+ ],
48
+ [
49
+ '192.168.0.0',
50
+ 16
51
+ ],
52
+ [
53
+ '198.18.0.0',
54
+ 15
55
+ ],
56
+ [
57
+ '224.0.0.0',
58
+ 4
59
+ ],
60
+ [
61
+ '240.0.0.0',
62
+ 4
63
+ ]
64
+ ];
65
+ // The 6to4 address (2002::/16) that carries an IPv4 network in its next 32 bits.
66
+ function sixToFourNetwork(network) {
67
+ const [a, b, c, d] = network.split('.').map(Number);
68
+ return `2002:${(a << 8 | b).toString(16)}:${(c << 8 | d).toString(16)}::`;
69
+ }
70
+ const notPublic = new BlockList();
71
+ notPublicIPv4.forEach(([network, prefix])=>{
72
+ notPublic.addSubnet(network, prefix, 'ipv4');
73
+ // An IPv6 address that embeds one of these IPv4 addresses: a NAT64 gateway (64:ff9b::/96)
74
+ // or a 6to4 relay forwards it to that IPv4 address.
75
+ notPublic.addSubnet(`64:ff9b::${network}`, 96 + prefix, 'ipv6');
76
+ notPublic.addSubnet(sixToFourNetwork(network), 16 + prefix, 'ipv6');
77
+ });
78
+ [
79
+ [
80
+ '::',
81
+ 96
82
+ ],
83
+ [
84
+ '64:ff9b:1::',
85
+ 48
86
+ ],
87
+ [
88
+ 'fc00::',
89
+ 7
90
+ ],
91
+ [
92
+ 'fe80::',
93
+ 10
94
+ ],
95
+ [
96
+ 'fec0::',
97
+ 10
98
+ ],
99
+ [
100
+ 'ff00::',
101
+ 8
102
+ ]
103
+ ].forEach(([network, prefix])=>notPublic.addSubnet(network, prefix, 'ipv6'));
104
+ function isPublicAddress(address) {
105
+ return !notPublic.check(address, isIPv6(address) ? 'ipv6' : 'ipv4');
106
+ }
107
+ export default isPublicAddress;
@@ -0,0 +1,219 @@
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
+ */ // Well-formed steps, keyed by step and by expect kind. A step error ends with
16
+ // the examples for its step, so an agent that wrote a step wrong gets the
17
+ // right form back; the journey step schema carries the same examples.
18
+ const JOURNEY_STEP_EXAMPLES = {
19
+ click: [
20
+ {
21
+ click: 'save_button'
22
+ },
23
+ {
24
+ click: {
25
+ text: 'OK'
26
+ }
27
+ }
28
+ ],
29
+ open: [
30
+ {
31
+ open: 'status_selector'
32
+ }
33
+ ],
34
+ fill: [
35
+ {
36
+ fill: {
37
+ blockId: 'name_input',
38
+ value: 'Ada'
39
+ }
40
+ },
41
+ {
42
+ fill: {
43
+ blockId: 'code_input',
44
+ fromEmail: {
45
+ to: 'ada@example.com',
46
+ match: '\\d{6}'
47
+ }
48
+ }
49
+ }
50
+ ],
51
+ select: [
52
+ {
53
+ select: {
54
+ blockId: 'status_selector',
55
+ value: 'Open'
56
+ }
57
+ }
58
+ ],
59
+ press: [
60
+ {
61
+ press: 'Enter'
62
+ }
63
+ ],
64
+ back: [
65
+ {
66
+ back: true
67
+ }
68
+ ],
69
+ goto: [
70
+ {
71
+ goto: 'dashboard'
72
+ },
73
+ {
74
+ goto: {
75
+ pageId: 'ticket',
76
+ pathParams: {
77
+ ticket_id: '1234'
78
+ },
79
+ urlQuery: {
80
+ tab: 'notes'
81
+ }
82
+ }
83
+ }
84
+ ],
85
+ email: [
86
+ {
87
+ email: {
88
+ to: 'ada@example.com',
89
+ subject: 'Verify'
90
+ }
91
+ }
92
+ ],
93
+ as: [
94
+ {
95
+ as: 'invitee'
96
+ }
97
+ ],
98
+ wait: [
99
+ {
100
+ wait: {
101
+ request: 'get_orders'
102
+ }
103
+ },
104
+ {
105
+ wait: {
106
+ state: 'orders'
107
+ }
108
+ },
109
+ {
110
+ wait: {
111
+ ms: 500
112
+ }
113
+ }
114
+ ],
115
+ screenshot: [
116
+ {
117
+ screenshot: 'after_save'
118
+ }
119
+ ],
120
+ expect: [
121
+ {
122
+ expect: {
123
+ visible: 'save_button'
124
+ }
125
+ },
126
+ {
127
+ expect: {
128
+ url: {
129
+ contains: '/orders'
130
+ }
131
+ }
132
+ }
133
+ ],
134
+ 'expect.state': [
135
+ {
136
+ expect: {
137
+ state: {
138
+ path: 'order.status',
139
+ equals: 'Open'
140
+ }
141
+ }
142
+ }
143
+ ],
144
+ 'expect.visible': [
145
+ {
146
+ expect: {
147
+ visible: 'save_button'
148
+ }
149
+ }
150
+ ],
151
+ 'expect.hidden': [
152
+ {
153
+ expect: {
154
+ hidden: 'error_alert'
155
+ }
156
+ }
157
+ ],
158
+ 'expect.text': [
159
+ {
160
+ expect: {
161
+ text: {
162
+ blockId: 'title',
163
+ contains: 'Orders'
164
+ }
165
+ }
166
+ }
167
+ ],
168
+ 'expect.url': [
169
+ {
170
+ expect: {
171
+ url: {
172
+ contains: '/orders'
173
+ }
174
+ }
175
+ }
176
+ ],
177
+ 'expect.title': [
178
+ {
179
+ expect: {
180
+ title: {
181
+ contains: 'Orders'
182
+ }
183
+ }
184
+ }
185
+ ],
186
+ 'expect.calls': [
187
+ {
188
+ expect: {
189
+ calls: {
190
+ request: 'save_order',
191
+ count: 1
192
+ }
193
+ }
194
+ },
195
+ {
196
+ expect: {
197
+ calls: {
198
+ endpoint: 'orders-sync',
199
+ count: 1
200
+ }
201
+ }
202
+ }
203
+ ],
204
+ 'expect.error': [
205
+ {
206
+ expect: {
207
+ error: 'is required'
208
+ }
209
+ }
210
+ ],
211
+ 'expect.effect': [
212
+ {
213
+ expect: {
214
+ effect: true
215
+ }
216
+ }
217
+ ]
218
+ };
219
+ export default JOURNEY_STEP_EXAMPLES;
@@ -0,0 +1,444 @@
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 JOURNEY_STEP_EXAMPLES from './journeyStepExamples.js';
16
+ // The journey step grammar as a JSON schema, for agents to read. The grammar
17
+ // itself is validateJourneySteps; its tests check this schema accepts and
18
+ // refuses the same steps. Rules across steps (expect.error and expect.effect
19
+ // directly follow an interaction) and a fromEmail match being a valid regular
20
+ // expression are checked by the validator only.
21
+ const nonEmptyString = {
22
+ type: 'string',
23
+ minLength: 1
24
+ };
25
+ const index = {
26
+ type: 'integer',
27
+ minimum: 0
28
+ };
29
+ const FROM = {
30
+ enum: [
31
+ 'recorded',
32
+ 'shape'
33
+ ],
34
+ description: 'Where the value came from: "recorded" (observed in a trace) or "shape" (value: null, a placeholder the runner refuses until it is filled in).'
35
+ };
36
+ const TARGET_PROPERTIES = {
37
+ blockId: {
38
+ type: 'string',
39
+ description: "Scopes the target to the block's wrapper."
40
+ },
41
+ text: {
42
+ type: 'string',
43
+ description: 'Exact text of the interactive control to use. Without blockId it searches the whole page, which reaches confirm dialog buttons, modal footers, dropdown menu items and email links.'
44
+ },
45
+ containing: {
46
+ ...nonEmptyString,
47
+ description: 'Text the element shows, e.g. the email address a list row shows.'
48
+ },
49
+ row: {
50
+ ...index,
51
+ description: 'Zero-based grid row, as displayed. Needs blockId.'
52
+ },
53
+ column: {
54
+ type: 'string',
55
+ description: 'Grid column id. Needs blockId.'
56
+ },
57
+ nth: {
58
+ ...index,
59
+ description: 'Zero-based pick among several matches.'
60
+ }
61
+ };
62
+ function targetObject({ properties = {}, required = [], requireBlockId = false }) {
63
+ const schema = {
64
+ type: 'object',
65
+ properties: {
66
+ ...TARGET_PROPERTIES,
67
+ ...properties
68
+ },
69
+ additionalProperties: false,
70
+ required,
71
+ not: {
72
+ required: [
73
+ 'text',
74
+ 'containing'
75
+ ]
76
+ },
77
+ dependencies: {
78
+ row: [
79
+ 'blockId'
80
+ ],
81
+ column: [
82
+ 'blockId'
83
+ ]
84
+ }
85
+ };
86
+ if (requireBlockId) {
87
+ schema.required = [
88
+ 'blockId',
89
+ ...required
90
+ ];
91
+ } else {
92
+ schema.anyOf = [
93
+ {
94
+ required: [
95
+ 'blockId'
96
+ ]
97
+ },
98
+ {
99
+ required: [
100
+ 'text'
101
+ ]
102
+ },
103
+ {
104
+ required: [
105
+ 'containing'
106
+ ]
107
+ }
108
+ ];
109
+ }
110
+ return schema;
111
+ }
112
+ const TARGET = {
113
+ description: 'A blockId string, or a target object.',
114
+ oneOf: [
115
+ {
116
+ type: 'string'
117
+ },
118
+ targetObject({})
119
+ ]
120
+ };
121
+ // A null value is a placeholder, so it must be marked from: shape.
122
+ const VALUE_PLACEHOLDER_RULE = {
123
+ if: {
124
+ properties: {
125
+ value: {
126
+ type: 'null'
127
+ }
128
+ },
129
+ required: [
130
+ 'value'
131
+ ]
132
+ },
133
+ then: {
134
+ properties: {
135
+ from: {
136
+ const: 'shape'
137
+ }
138
+ },
139
+ required: [
140
+ 'from'
141
+ ]
142
+ }
143
+ };
144
+ const FROM_EMAIL = {
145
+ type: 'object',
146
+ description: 'Type text read from the newest email to "to": the first match of the regular expression "match", or its first capture group.',
147
+ properties: {
148
+ to: nonEmptyString,
149
+ subject: {
150
+ type: 'string'
151
+ },
152
+ match: {
153
+ ...nonEmptyString,
154
+ description: 'A regular expression, e.g. "\\\\b\\\\d{6}\\\\b".'
155
+ }
156
+ },
157
+ required: [
158
+ 'to',
159
+ 'match'
160
+ ],
161
+ additionalProperties: false
162
+ };
163
+ function valueTarget() {
164
+ return {
165
+ ...targetObject({
166
+ properties: {
167
+ value: {},
168
+ from: FROM
169
+ },
170
+ required: [
171
+ 'value'
172
+ ],
173
+ requireBlockId: true
174
+ }),
175
+ ...VALUE_PLACEHOLDER_RULE
176
+ };
177
+ }
178
+ function singleKey(properties) {
179
+ return Object.entries(properties).map(([key, value])=>({
180
+ type: 'object',
181
+ properties: {
182
+ [key]: value
183
+ },
184
+ required: [
185
+ key
186
+ ],
187
+ additionalProperties: false
188
+ }));
189
+ }
190
+ const EXPECT_KINDS = {
191
+ state: {
192
+ type: 'object',
193
+ description: 'State at "path" deep-equals "equals".',
194
+ properties: {
195
+ path: {
196
+ type: 'string'
197
+ },
198
+ equals: {},
199
+ from: FROM
200
+ },
201
+ required: [
202
+ 'path',
203
+ 'equals'
204
+ ],
205
+ additionalProperties: false
206
+ },
207
+ visible: TARGET,
208
+ hidden: {
209
+ ...TARGET,
210
+ description: 'Nothing the target names is visible. Passes at once when nothing matches yet, so pair it with something that must be present first.'
211
+ },
212
+ text: targetObject({
213
+ properties: {
214
+ contains: {
215
+ type: 'string'
216
+ }
217
+ },
218
+ required: [
219
+ 'contains'
220
+ ],
221
+ requireBlockId: true
222
+ }),
223
+ url: {
224
+ type: 'object',
225
+ properties: {
226
+ contains: {
227
+ type: 'string'
228
+ }
229
+ },
230
+ required: [
231
+ 'contains'
232
+ ]
233
+ },
234
+ title: {
235
+ description: 'The document title.',
236
+ oneOf: singleKey({
237
+ equals: {
238
+ type: 'string'
239
+ },
240
+ contains: {
241
+ type: 'string'
242
+ }
243
+ })
244
+ },
245
+ calls: {
246
+ description: 'How many times this actor called the request (on pageId, default the current page) or the endpoint since the journey started.',
247
+ oneOf: [
248
+ {
249
+ type: 'object',
250
+ properties: {
251
+ request: nonEmptyString,
252
+ pageId: nonEmptyString,
253
+ count: index
254
+ },
255
+ required: [
256
+ 'request',
257
+ 'count'
258
+ ],
259
+ additionalProperties: false
260
+ },
261
+ {
262
+ type: 'object',
263
+ properties: {
264
+ endpoint: nonEmptyString,
265
+ count: index
266
+ },
267
+ required: [
268
+ 'endpoint',
269
+ 'count'
270
+ ],
271
+ additionalProperties: false
272
+ }
273
+ ]
274
+ },
275
+ error: {
276
+ ...nonEmptyString,
277
+ description: 'Text an app error raised by the interaction step directly before this one contains.'
278
+ },
279
+ effect: {
280
+ const: true,
281
+ description: 'Fails when the interaction step directly before this one did nothing: no event ran, the page did not change, no request or endpoint was called and the URL stayed the same.'
282
+ }
283
+ };
284
+ const STEPS = {
285
+ click: {
286
+ description: 'Click a target. "count": 2 or 3 clicks in quick succession, a double click.',
287
+ oneOf: [
288
+ {
289
+ type: 'string'
290
+ },
291
+ targetObject({
292
+ properties: {
293
+ count: {
294
+ type: 'integer',
295
+ minimum: 1,
296
+ maximum: 3
297
+ }
298
+ }
299
+ })
300
+ ]
301
+ },
302
+ open: {
303
+ ...TARGET,
304
+ description: "Open an input's dropdown or picker popup and wait for it to show, so a following screenshot captures it."
305
+ },
306
+ fill: {
307
+ description: 'Type a value into a block, or text read from an email.',
308
+ oneOf: [
309
+ valueTarget(),
310
+ targetObject({
311
+ properties: {
312
+ fromEmail: FROM_EMAIL
313
+ },
314
+ required: [
315
+ 'fromEmail'
316
+ ],
317
+ requireBlockId: true
318
+ })
319
+ ]
320
+ },
321
+ select: {
322
+ ...valueTarget(),
323
+ description: 'Pick an option by exact text: a dropdown option, or a radio, button or segmented option in the block.'
324
+ },
325
+ press: {
326
+ type: 'string',
327
+ description: 'A key, e.g. "Enter" or "Mod+k" (Mod is Meta or Control per platform).'
328
+ },
329
+ back: {
330
+ enum: [
331
+ true,
332
+ null
333
+ ],
334
+ description: 'The browser Back button.'
335
+ },
336
+ goto: {
337
+ description: 'Load an app page like a typed URL.',
338
+ oneOf: [
339
+ nonEmptyString,
340
+ {
341
+ type: 'object',
342
+ properties: {
343
+ pageId: nonEmptyString,
344
+ pathParams: {
345
+ type: 'object',
346
+ additionalProperties: {
347
+ type: 'string'
348
+ },
349
+ description: "One string per placeholder of the page's path."
350
+ },
351
+ urlQuery: {
352
+ type: 'object'
353
+ }
354
+ },
355
+ required: [
356
+ 'pageId'
357
+ ],
358
+ additionalProperties: false
359
+ }
360
+ ]
361
+ },
362
+ email: {
363
+ type: 'object',
364
+ description: 'Open the newest email to "to" that arrived during this journey, subject containing the text, waiting for it if needed.',
365
+ properties: {
366
+ to: nonEmptyString,
367
+ subject: {
368
+ type: 'string'
369
+ }
370
+ },
371
+ required: [
372
+ 'to'
373
+ ],
374
+ additionalProperties: false
375
+ },
376
+ as: {
377
+ ...nonEmptyString,
378
+ description: 'Switch to another person, with their own browser and cookies. The journey starts as "main".'
379
+ },
380
+ wait: {
381
+ description: 'Wait for a request started since the last interaction to finish, or for a state path to be defined. { ms } (or a bare number of ms) waits a fixed time, which the lowdefy test lint refuses (L3): prefer a request, a state or an expect.',
382
+ oneOf: [
383
+ {
384
+ type: 'number'
385
+ },
386
+ ...singleKey({
387
+ ms: {
388
+ type: 'number'
389
+ },
390
+ request: {
391
+ type: 'string'
392
+ },
393
+ state: {
394
+ type: 'string'
395
+ }
396
+ })
397
+ ]
398
+ },
399
+ screenshot: {
400
+ description: 'Capture the page, with an optional name.',
401
+ anyOf: [
402
+ {
403
+ type: 'string'
404
+ },
405
+ {
406
+ enum: [
407
+ true,
408
+ null
409
+ ]
410
+ }
411
+ ]
412
+ },
413
+ expect: {
414
+ description: 'Assert one thing about the page. Each waits until it holds or the step times out.',
415
+ oneOf: singleKey(EXPECT_KINDS)
416
+ }
417
+ };
418
+ function stepSchema(key) {
419
+ return {
420
+ type: 'object',
421
+ properties: {
422
+ [key]: {
423
+ ...STEPS[key],
424
+ examples: JOURNEY_STEP_EXAMPLES[key]
425
+ }
426
+ },
427
+ required: [
428
+ key
429
+ ],
430
+ additionalProperties: false
431
+ };
432
+ }
433
+ const JOURNEY_STEP_SCHEMAS = Object.fromEntries(Object.keys(STEPS).map((key)=>[
434
+ key,
435
+ stepSchema(key)
436
+ ]));
437
+ const journeyStepSchema = {
438
+ $schema: 'http://json-schema.org/draft-07/schema#',
439
+ title: 'Journey step',
440
+ description: 'One step of a journey: an object with exactly one key, the step name. A target is a blockId string or an object of { blockId, text, containing, row, column, nth }; fill, select and expect.text need a blockId. expect.error and expect.effect must directly follow an interaction step (click, open, fill, select, press, back).',
441
+ oneOf: Object.values(JOURNEY_STEP_SCHEMAS)
442
+ };
443
+ export { JOURNEY_STEP_SCHEMAS };
444
+ export default journeyStepSchema;
@@ -0,0 +1,31 @@
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 { type } from '@lowdefy/helpers';
16
+ // Rewrites the grammar's shorthands into their full form, so the runner reads
17
+ // one shape per step: `wait: 3000` becomes `wait: { ms: 3000 }`. Runs on steps
18
+ // validateJourneySteps accepted.
19
+ function normaliseJourneySteps({ steps }) {
20
+ return steps.map((step)=>{
21
+ if (type.isNumber(step.wait)) {
22
+ return {
23
+ wait: {
24
+ ms: step.wait
25
+ }
26
+ };
27
+ }
28
+ return step;
29
+ });
30
+ }
31
+ export default normaliseJourneySteps;
@@ -13,6 +13,7 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */ import { type } from '@lowdefy/helpers';
16
+ import JOURNEY_STEP_EXAMPLES from './journeyStepExamples.js';
16
17
  const STEP_KEYS = [
17
18
  'click',
18
19
  'open',
@@ -260,7 +261,11 @@ function validateBlockValue({ key, params }) {
260
261
  requireBlockId: true
261
262
  });
262
263
  }
264
+ // wait takes { ms }, { request } or { state }; a bare number is read as { ms }.
263
265
  function validateWait(params) {
266
+ if (type.isNumber(params)) {
267
+ return undefined;
268
+ }
264
269
  if (!type.isObject(params)) {
265
270
  return `Step "wait" requires one of { ms }, { request }, { state }. Received ${describe(params)}.`;
266
271
  }
@@ -505,6 +510,22 @@ function validatePlacement({ step, previous }) {
505
510
  }
506
511
  return undefined;
507
512
  }
513
+ // The examples an error about this step ends with: the expect kind's own when
514
+ // the step names a known one.
515
+ function getExamplesKey(step) {
516
+ const key = getStepKey(step);
517
+ if (key === 'expect' && type.isObject(step.expect)) {
518
+ const expectKey = getStepKey(step.expect);
519
+ if (EXPECT_KEYS.includes(expectKey)) {
520
+ return `expect.${expectKey}`;
521
+ }
522
+ }
523
+ return key;
524
+ }
525
+ function withExamples({ message, step }) {
526
+ const examples = JOURNEY_STEP_EXAMPLES[getExamplesKey(step)];
527
+ return `${message} Example: ${examples.map(describe).join(' or ')}.`;
528
+ }
508
529
  // Validates one step's shape. Returns an error message, or undefined when the
509
530
  // step is well-formed.
510
531
  function validateStep(step) {
@@ -516,7 +537,19 @@ function validateStep(step) {
516
537
  const received = type.isUndefined(key) ? Object.keys(step).join(', ') : key;
517
538
  return `Unknown journey step "${received}". Steps are: ${STEP_KEYS.join(', ')}.`;
518
539
  }
519
- const params = step[key];
540
+ const message = validateStepParams({
541
+ key,
542
+ params: step[key]
543
+ });
544
+ if (type.isUndefined(message)) {
545
+ return undefined;
546
+ }
547
+ return withExamples({
548
+ message,
549
+ step
550
+ });
551
+ }
552
+ function validateStepParams({ key, params }) {
520
553
  switch(key){
521
554
  case 'click':
522
555
  return validateClick(params);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lowdefy/node-utils",
3
- "version": "0.0.0-experimental-20261006165558",
3
+ "version": "0.0.0-experimental-20261007124348",
4
4
  "license": "Apache-2.0",
5
5
  "description": "",
6
6
  "homepage": "https://lowdefy.com",
@@ -34,9 +34,10 @@
34
34
  "dist/*"
35
35
  ],
36
36
  "dependencies": {
37
- "@lowdefy/errors": "0.0.0-experimental-20261006165558",
38
- "@lowdefy/helpers": "0.0.0-experimental-20261006165558",
37
+ "@lowdefy/errors": "0.0.0-experimental-20261007124348",
38
+ "@lowdefy/helpers": "0.0.0-experimental-20261007124348",
39
39
  "fs-extra": "11.3.4",
40
+ "undici": "7.30.0",
40
41
  "yaml": "2.9.0"
41
42
  },
42
43
  "devDependencies": {
@@ -44,6 +45,7 @@
44
45
  "@swc/cli": "0.8.1",
45
46
  "@swc/core": "1.15.32",
46
47
  "@swc/jest": "0.2.39",
48
+ "ajv": "8.20.0",
47
49
  "copyfiles": "2.4.1",
48
50
  "jest": "28.1.3"
49
51
  },