@lowdefy/server-dev 0.0.0-experimental-20261007090522 → 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.
Files changed (46) hide show
  1. package/lib/docs/createDocsMcpServer.js +22 -10
  2. package/lib/docs/createDocsMcpServer.test.mjs +75 -0
  3. package/lib/docs/dataSets/resolveJourneyDataSet.js +11 -8
  4. package/lib/docs/devToolDefinitions.js +50 -18
  5. package/lib/docs/docs.test.mjs +23 -0
  6. package/lib/docs/evalOperator.js +3 -2
  7. package/lib/docs/evalOperatorHeadless.js +24 -1
  8. package/lib/docs/getSchema.js +29 -1
  9. package/lib/docs/inspectState.js +3 -2
  10. package/lib/docs/inspectStateHeadless.js +32 -3
  11. package/lib/docs/loadState.js +31 -16
  12. package/lib/docs/resolveSource.js +8 -6
  13. package/lib/docs/resolveSource.test.mjs +1 -1
  14. package/lib/docs/resolveToolCaller.js +62 -0
  15. package/lib/docs/runEndpoint.js +50 -9
  16. package/lib/docs/runEndpoint.test.mjs +96 -3
  17. package/lib/docs/runJourney.js +8 -3
  18. package/lib/docs/runJourney.test.mjs +27 -3
  19. package/lib/docs/runJourneySteps.js +26 -4
  20. package/lib/docs/runRequest.js +44 -30
  21. package/lib/docs/runRequest.test.mjs +2 -2
  22. package/lib/docs/screenshotPage.js +43 -23
  23. package/lib/docs/screenshotPage.test.mjs +110 -0
  24. package/lib/docs/withDataSession.js +53 -0
  25. package/lib/server/createLowdefyContext.js +9 -4
  26. package/lib/server/createLowdefyContext.test.mjs +15 -0
  27. package/lib/server/journeyCookies/forwardJourneyCookies.js +10 -2
  28. package/lib/server/journeyCookies.test.mjs +17 -4
  29. package/lib/server/mutants/groupMutantCopies.js +9 -2
  30. package/lib/server/mutants/listMutants.js +18 -2
  31. package/lib/server/mutants/listMutants.test.mjs +40 -0
  32. package/package.json +37 -37
  33. package/package.original.json +37 -37
  34. package/src/routes/docs/evalOperator.js +9 -1
  35. package/src/routes/docs/inspectState.js +8 -1
  36. package/src/routes/docs/journey.test.mjs +1 -1
  37. package/src/routes/docs/loadState.js +1 -1
  38. package/src/routes/docs/mutants.js +10 -1
  39. package/src/routes/docs/mutants.test.mjs +14 -0
  40. package/src/routes/docs/parseUserParam.js +27 -21
  41. package/src/routes/docs/parseUserParam.test.mjs +17 -5
  42. package/src/routes/docs/runEndpoint.js +2 -1
  43. package/src/routes/docs/runEndpoint.test.mjs +11 -1
  44. package/src/routes/docs/runRequest.js +2 -1
  45. package/src/routes/docs/runRequest.test.mjs +12 -2
  46. package/src/routes/docs/screenshot.js +1 -0
@@ -129,8 +129,8 @@ function createDocsMcpServer({ origin, honoContext, version } = {}) {
129
129
  });
130
130
  }
131
131
 
132
- registerDevTool('lowdefy_inspect_state', async ({ pageId, pathParams, source, user }) => {
133
- const result = await inspectState({ origin, pageId, pathParams, source, user });
132
+ registerDevTool('lowdefy_inspect_state', async ({ pageId, pathParams, source, user, data }) => {
133
+ const result = await inspectState({ origin, pageId, pathParams, source, user, data });
134
134
  if (result.error) {
135
135
  return notFoundResult(result.error);
136
136
  }
@@ -139,8 +139,16 @@ function createDocsMcpServer({ origin, honoContext, version } = {}) {
139
139
 
140
140
  registerDevTool(
141
141
  'lowdefy_eval_operator',
142
- async ({ pageId, pathParams, expression, source, user }) => {
143
- const result = await evalOperator({ origin, pageId, pathParams, expression, source, user });
142
+ async ({ pageId, pathParams, expression, source, user, data }) => {
143
+ const result = await evalOperator({
144
+ origin,
145
+ pageId,
146
+ pathParams,
147
+ expression,
148
+ source,
149
+ user,
150
+ data,
151
+ });
144
152
  if (result.error) {
145
153
  return notFoundResult(result.error);
146
154
  }
@@ -150,15 +158,17 @@ function createDocsMcpServer({ origin, honoContext, version } = {}) {
150
158
 
151
159
  registerDevTool(
152
160
  'lowdefy_run_request',
153
- async ({ pageId, requestId, payload, user, saveResponse }) =>
154
- textResult(await runRequest({ pageId, requestId, payload, user, saveResponse, honoContext }))
161
+ async ({ pageId, requestId, payload, user, data, saveResponse }) =>
162
+ textResult(
163
+ await runRequest({ pageId, requestId, payload, user, data, saveResponse, honoContext })
164
+ )
155
165
  );
156
166
 
157
167
  registerDevTool(
158
168
  'lowdefy_run_endpoint',
159
- async ({ endpointId, payload, user, system, saveResponse }) =>
169
+ async ({ endpointId, payload, user, data, system, saveResponse }) =>
160
170
  textResult(
161
- await runEndpoint({ endpointId, payload, user, system, saveResponse, honoContext })
171
+ await runEndpoint({ endpointId, payload, user, data, system, saveResponse, honoContext })
162
172
  )
163
173
  );
164
174
 
@@ -190,8 +200,8 @@ function createDocsMcpServer({ origin, honoContext, version } = {}) {
190
200
  }
191
201
  );
192
202
 
193
- registerDevTool('lowdefy_load_state', async ({ name, mode, user }) => {
194
- const result = await loadState({ origin, name, mode, user });
203
+ registerDevTool('lowdefy_load_state', async ({ name, mode, user, data }) => {
204
+ const result = await loadState({ origin, name, mode, user, data });
195
205
  if (result.error) {
196
206
  return notFoundResult(result.error);
197
207
  }
@@ -257,6 +267,7 @@ function createDocsMcpServer({ origin, honoContext, version } = {}) {
257
267
  scrollX,
258
268
  scrollY,
259
269
  user,
270
+ data,
260
271
  width,
261
272
  height,
262
273
  colorScheme,
@@ -275,6 +286,7 @@ function createDocsMcpServer({ origin, honoContext, version } = {}) {
275
286
  scrollX,
276
287
  scrollY,
277
288
  user,
289
+ data,
278
290
  width,
279
291
  height,
280
292
  colorScheme,
@@ -197,6 +197,48 @@ test('MCP tools that render a page headless advertise an optional user parameter
197
197
  await client.close();
198
198
  });
199
199
 
200
+ test('MCP tools that act as someone take one user option and share one sentence explaining it', async () => {
201
+ const client = await connectClient();
202
+ const { tools } = await client.listTools();
203
+ const sentence =
204
+ 'Pass user to act as someone: a user object such as {"roles":["admin"]}, "none" to act signed out, or the name of a user in the data set that data names';
205
+
206
+ [
207
+ 'lowdefy_screenshot_page',
208
+ 'lowdefy_run_journey',
209
+ 'lowdefy_inspect_state',
210
+ 'lowdefy_eval_operator',
211
+ 'lowdefy_load_state',
212
+ 'lowdefy_run_request',
213
+ 'lowdefy_run_endpoint',
214
+ ].forEach((name) => {
215
+ const tool = tools.find((candidate) => candidate.name === name);
216
+ expect(tool.description).toContain(sentence);
217
+ expect(tool.inputSchema.properties.data.type).toEqual('string');
218
+ const forms = JSON.stringify(tool.inputSchema.properties.user);
219
+ expect(forms).toContain('"const":"none"');
220
+ expect(forms).toContain('"type":"string"');
221
+ expect(forms).toContain('"type":"object"');
222
+ });
223
+
224
+ await client.close();
225
+ });
226
+
227
+ test('MCP tools/call lowdefy_screenshot_page passes data and a data set user name through', async () => {
228
+ mockScreenshotPage.mockResolvedValue({ data: 'AAAA', mimeType: 'image/png', screenshots: [] });
229
+ const client = await connectClient();
230
+
231
+ await client.callTool({
232
+ name: 'lowdefy_screenshot_page',
233
+ arguments: { pageId: 'tickets', user: 'member', data: 'crm' },
234
+ });
235
+
236
+ expect(mockScreenshotPage).toHaveBeenCalledWith(
237
+ expect.objectContaining({ pageId: 'tickets', user: 'member', data: 'crm' })
238
+ );
239
+ await client.close();
240
+ });
241
+
200
242
  test('MCP lowdefy_screenshot_page advertises width and height up to 4096, and colorScheme', async () => {
201
243
  const client = await connectClient();
202
244
  const { tools } = await client.listTools();
@@ -381,6 +423,39 @@ test('MCP tools/call with an unknown type returns isError with guidance', async
381
423
  await client.close();
382
424
  });
383
425
 
426
+ test('MCP lowdefy_get_schema returns the journey step grammar for kind journey-step without a type', async () => {
427
+ const client = await connectClient();
428
+ const { tools } = await client.listTools();
429
+ const getSchemaTool = tools.find((tool) => tool.name === 'lowdefy_get_schema');
430
+ expect(getSchemaTool.inputSchema.properties.kind.enum).toContain('journey-step');
431
+ expect(getSchemaTool.inputSchema.required).toEqual(['kind']);
432
+ const runJourneyTool = tools.find((tool) => tool.name === 'lowdefy_run_journey');
433
+ expect(runJourneyTool.inputSchema.properties.steps.description).toContain(
434
+ 'lowdefy_get_schema with kind "journey-step"'
435
+ );
436
+
437
+ const all = await client.callTool({
438
+ name: 'lowdefy_get_schema',
439
+ arguments: { kind: 'journey-step' },
440
+ });
441
+ expect(all.isError).toBeUndefined();
442
+ expect(JSON.parse(all.content[0].text).schema.title).toEqual('Journey step');
443
+
444
+ const wait = await client.callTool({
445
+ name: 'lowdefy_get_schema',
446
+ arguments: { kind: 'journey-step', type: 'wait' },
447
+ });
448
+ expect(JSON.parse(wait.content[0].text)).toMatchObject({ kind: 'journey-step', type: 'wait' });
449
+
450
+ const noType = await client.callTool({
451
+ name: 'lowdefy_get_schema',
452
+ arguments: { kind: 'blocks' },
453
+ });
454
+ expect(noType.isError).toBe(true);
455
+ expect(noType.content[0].text).toContain('requires a "type"');
456
+ await client.close();
457
+ });
458
+
384
459
  // The dev server keeps serving the previous build when a rebuild fails, so
385
460
  // every tool result must announce that its answer predates the caller's edits.
386
461
  test('MCP tools/call prepends a STALE notice while the last build failed', async () => {
@@ -43,9 +43,13 @@ function describeDeclaredUsers({ users }) {
43
43
  // which is bound to the real auth database while its requests read the data set;
44
44
  // - any data-set journey while a dev mock user is active: the mock user wins over the injected
45
45
  // caller, so the journey would silently act as someone outside the data set.
46
+ //
47
+ // `subject` names the run in the refusals: "journey" for a journey, "call" for the other dev tools
48
+ // that act as someone (resolveToolCaller), which take the same user and data.
46
49
  async function resolveJourneyDataSet({
47
50
  data,
48
51
  user,
52
+ subject = 'journey',
49
53
  configDirectory,
50
54
  buildDirectory,
51
55
  authConfigured,
@@ -54,31 +58,30 @@ async function resolveJourneyDataSet({
54
58
  if (type.isNone(data)) {
55
59
  if (user === 'none' && mockUserActive) {
56
60
  return {
57
- error:
58
- 'The journey has user "none", which cannot run while a dev mock user is active (auth.dev.mockUser or LOWDEFY_DEV_USER): with no caller of its own, every request would act as the mock user, not signed out. Remove the mock user to run journeys signed out, or give the journey a user.',
61
+ error: `The ${subject} has user "none", which cannot run while a dev mock user is active (auth.dev.mockUser or LOWDEFY_DEV_USER): with no caller of its own, every request would act as the mock user, not signed out. Remove the mock user to run signed out, or give the ${subject} a user.`,
59
62
  };
60
63
  }
61
64
  if (isUserName(user)) {
62
65
  return {
63
- error: `The journey's user ${JSON.stringify(
66
+ error: `The ${subject}'s user ${JSON.stringify(
64
67
  user
65
- )} names a data set user, but the journey has no "data". Add data: <data set name>, or give user as an object.`,
68
+ )} names a data set user, but the ${subject} has no "data". Add data: <data set name>, or give user as an object.`,
66
69
  };
67
70
  }
68
71
  return { user };
69
72
  }
70
73
  if (mockUserActive) {
71
74
  return {
72
- error: `The journey on data set ${JSON.stringify(
75
+ error: `The ${subject} on data set ${JSON.stringify(
73
76
  data
74
- )} cannot run while a dev mock user is active (auth.dev.mockUser or LOWDEFY_DEV_USER): every request would act as the mock user, who is not in the data set. Remove the mock user to run journeys on data sets.`,
77
+ )} cannot run while a dev mock user is active (auth.dev.mockUser or LOWDEFY_DEV_USER): every request would act as the mock user, who is not in the data set. Remove the mock user to run on data sets.`,
75
78
  };
76
79
  }
77
80
  if (user === 'none' && authConfigured) {
78
81
  return {
79
- error: `The journey on data set ${JSON.stringify(
82
+ error: `The ${subject} on data set ${JSON.stringify(
80
83
  data
81
- )} has user "none", which is refused while auth is configured: signing in through the app would write to the app's real auth database. Name a data set user instead; journeys that sign in stay off data sets.`,
84
+ )} has user "none", which is refused while auth is configured: signing in through the app would write to the app's real auth database. Name a data set user instead; runs that sign in stay off data sets.`,
82
85
  };
83
86
  }
84
87
  let dataSet;
@@ -37,7 +37,7 @@ Live state: lowdefy_inspect_state reads the ACTUAL state, request results, and e
37
37
 
38
38
  Behaviour, not just layout: a screenshot shows what rendered, not what works. To verify behaviour, drive the page with lowdefy_run_journey — a declarative list of steps (click, fill, select, press, back, goto, email, as, wait, screenshot, expect) addressed by blockId — and assert on state, visibility, text, url or title. A failing step stops the journey and comes back as data (passed: false, failure with expected/actual, the remaining steps skipped) together with the final page state, so you can read what the app actually did and write the next assertion. A large final state comes back as a summary of its keys; pass state with the paths you need. Pass user to act as a real member (e.g. {"roles":["admin"]}) when the flow is role-gated, or user "none" to test auth itself (sign-up, sign-in, invitations) through the app's own pages and sessions.
39
39
 
40
- Role-gated pages: the headless renderer signs in as a roleless user, so a page or request gated on a role renders empty or refused. Pass user to lowdefy_screenshot_page, lowdefy_run_journey, lowdefy_inspect_state, lowdefy_eval_operator, lowdefy_load_state, lowdefy_run_request or lowdefy_run_endpoint to act as a specific caller — e.g. user {"roles":["admin"]} — and vary it per call to compare what different roles see. A request run without user runs as a roleless anonymous caller, so a tenant-walled or role-gated request returns empty rather than an error.
40
+ Role-gated pages: the headless renderer signs in as a roleless user, so a page or request gated on a role renders empty or refused. Pass user to lowdefy_screenshot_page, lowdefy_run_journey, lowdefy_inspect_state, lowdefy_eval_operator, lowdefy_load_state, lowdefy_run_request or lowdefy_run_endpoint to act as a specific caller, in the same forms on every one: a user object (e.g. {"roles":["admin"]}), "none" to act signed out, or the name of a data set user together with data (e.g. user "member", data "crm"), which runs the call on that data set's own database. Vary it per call to compare what different roles see. A request run without user runs as a roleless anonymous caller, so a tenant-walled or role-gated request returns empty rather than an error.
41
41
 
42
42
  Safety: lowdefy_checkpoint snapshots the config files before risky multi-file changes; lowdefy_revert_checkpoint restores them.
43
43
 
@@ -48,15 +48,27 @@ State checkpoints (testing): lowdefy_snapshot_state captures a page's live state
48
48
  const HAZARDS_NOTE =
49
49
  ' Results include `hazards`: behaviours of this type that its schema does not show. Read them before writing config.';
50
50
 
51
- // Shared by every tool that renders a page headless, so one call can act as an
52
- // admin and the next as a plain member — each headless call gets its own browser
53
- // context, so they never share an identity.
51
+ // One sentence, shared by the description of every tool that acts as someone,
52
+ // so the caller option reads the same wherever an agent meets it.
53
+ const CALLER_NOTE =
54
+ ' Pass user to act as someone: a user object such as {"roles":["admin"]}, "none" to act signed out, or the name of a user in the data set that data names (tests/data/<name>.yaml), which also runs the call on that data set\'s own database.';
55
+
56
+ // Shared by every tool that acts as someone, in the forms a journey takes, so
57
+ // one call can act as an admin and the next as a plain member - each headless
58
+ // call gets its own browser context, so they never share an identity.
54
59
  const userSchema = z
55
- .object({})
56
- .passthrough()
60
+ .union([z.literal('none'), z.string().min(1), z.object({}).passthrough()])
61
+ .optional()
62
+ .describe(
63
+ 'Who the call acts as. Omitted: the default roleless headless user. An object: that injected caller, e.g. {"roles":["user-admin"]}, merged over the default, so include email/profile/attributes fields too if the page reads them; no auth engine runs for an injected caller, so nothing derives them. "none": no injected caller, so the app\'s own auth decides and the call runs signed out. Any other string: the name of a user in the data set named by data, e.g. "member". Headless only: it is never applied to a page the developer opens in their own browser, so combining it with source "tab" or load_state mode "registry-only" is an error rather than a silently dropped role.'
64
+ );
65
+
66
+ // Shared by every tool that takes userSchema.
67
+ const dataSchema = z
68
+ .string()
57
69
  .optional()
58
70
  .describe(
59
- 'Act as this caller instead of the default roleless headless user, e.g. {"roles":["user-admin"]} to render a role-gated page. Merged over the default, so include email/profile/attributes fields too if the page reads them — no auth engine runs for an injected caller, so nothing derives them. Headless only: it is never applied to a page the developer opens in their own browser, so combining it with source "tab" or load_state mode "registry-only" is an error rather than a silently dropped role, and on lowdefy_run_request / lowdefy_run_endpoint it sets the caller the request or routine runs as.'
71
+ 'The name of a data set in tests/data/<name>.yaml. The call then runs against a fresh in-memory database of its own, loaded with the data set, as a journey with data does, and user can name one of its users. Headless only, like user.'
60
72
  );
61
73
 
62
74
  // Shared by every tool that names a page instance: a page whose `path` has
@@ -79,7 +91,8 @@ const saveResponseSchema = z
79
91
  const devToolDefinitions = {
80
92
  lowdefy_inspect_state: {
81
93
  description:
82
- "Read the LIVE state of a running page: state, request results, event log (recent actions fired), global, user, input, and urlQuery. If the developer has the page open in a browser it reads their actual tab (ask them to interact first, then inspect); otherwise it runs the page headless. Use this to see what the app's data model really looks like.",
94
+ "Read the LIVE state of a running page: state, request results, event log (recent actions fired), global, user, input, and urlQuery. If the developer has the page open in a browser it reads their actual tab (ask them to interact first, then inspect); otherwise it runs the page headless. Use this to see what the app's data model really looks like." +
95
+ CALLER_NOTE,
83
96
  inputSchema: {
84
97
  pageId: z.string().describe('The page id to inspect.'),
85
98
  pathParams: pathParamsSchema.describe(
@@ -90,12 +103,14 @@ const devToolDefinitions = {
90
103
  .optional()
91
104
  .describe('Force a source. Default: live tab if connected, else headless.'),
92
105
  user: userSchema,
106
+ data: dataSchema,
93
107
  },
94
108
  },
95
109
 
96
110
  lowdefy_eval_operator: {
97
111
  description:
98
- 'Evaluate a Lowdefy operator expression against the live state of a running page — a REPL for config. Pass the operator object in the "expression" argument — any JSON value, e.g. {"_state": "customer.name"} or {"_if": {...}}. Evaluates in the real browser runtime (live tab if connected, else headless).',
112
+ 'Evaluate a Lowdefy operator expression against the live state of a running page — a REPL for config. Pass the operator object in the "expression" argument — any JSON value, e.g. {"_state": "customer.name"} or {"_if": {...}}. Evaluates in the real browser runtime (live tab if connected, else headless).' +
113
+ CALLER_NOTE,
99
114
  inputSchema: {
100
115
  pageId: z.string().describe('The page id whose context to evaluate against.'),
101
116
  pathParams: pathParamsSchema.describe(
@@ -106,28 +121,33 @@ const devToolDefinitions = {
106
121
  .describe('The operator expression — any JSON value, e.g. {"_state": "key"}.'),
107
122
  source: z.enum(['tab', 'headless']).optional(),
108
123
  user: userSchema,
124
+ data: dataSchema,
109
125
  },
110
126
  },
111
127
 
112
128
  lowdefy_run_request: {
113
129
  description:
114
- 'Execute a request in dev with a test payload to verify the data shape a page receives. The page is built first when it changed since its last build, so the request that runs is the one in the config now; a page that fails to build is refused with its build errors. Read-only request types always run; write requests are refused unless the app opts in (cli.agentTools.allowWriteRequests in lowdefy.yaml). A response too large to return inline is written in full to responseFile.',
130
+ 'Execute a request in dev with a test payload to verify the data shape a page receives. The page is built first when it changed since its last build, so the request that runs is the one in the config now; a page that fails to build is refused with its build errors. Read-only request types always run; write requests are refused unless the app opts in (cli.agentTools.allowWriteRequests in lowdefy.yaml). A response too large to return inline is written in full to responseFile.' +
131
+ CALLER_NOTE,
115
132
  inputSchema: {
116
133
  pageId: z.string().describe('The page the request is defined on.'),
117
134
  requestId: z.string().describe('The request id.'),
118
135
  payload: z.record(z.any()).optional().describe('Test payload for _payload operators.'),
119
136
  user: userSchema,
137
+ data: dataSchema,
120
138
  saveResponse: saveResponseSchema,
121
139
  },
122
140
  },
123
141
 
124
142
  lowdefy_run_endpoint: {
125
143
  description:
126
- 'Execute an Api endpoint routine in dev with a test payload and caller, to verify what it returns, rejects or throws. Requires agent write access (cli.agentTools.allowWriteRequests) because routines are not classified read-only. A :reject or :throw comes back as data (success: false, status "reject"/"error" with the routine\'s own error), not as a tool failure. Pass system: true to run it as a system context the way a cron or detached run does — no user (_user undefined), endpoint auth not checked, InternalApi endpoints allowed — which is the local test path for scheduled (schedules) and detached-only routines. Nested CallApi steps with detached: true still dispatch over HTTP and need CRON_SECRET set on the dev server; they are not faked.',
144
+ 'Execute an Api endpoint routine in dev with a test payload and caller, to verify what it returns, rejects or throws. Requires agent write access (cli.agentTools.allowWriteRequests) because routines are not classified read-only. A :reject or :throw comes back as data (success: false, status "reject"/"error" with the routine\'s own error), not as a tool failure. Pass system: true to run it as a system context the way a cron or detached run does — no user (_user undefined), endpoint auth not checked, InternalApi endpoints allowed — which is the local test path for scheduled (schedules) and detached-only routines. Nested CallApi steps with detached: true still dispatch over HTTP and need CRON_SECRET set on the dev server; they are not faked.' +
145
+ CALLER_NOTE,
127
146
  inputSchema: {
128
147
  endpointId: z.string().describe('The Api endpoint id.'),
129
148
  payload: z.record(z.any()).optional().describe('Test payload for _payload operators.'),
130
149
  user: userSchema,
150
+ data: dataSchema,
131
151
  system: z
132
152
  .boolean()
133
153
  .optional()
@@ -169,11 +189,13 @@ const devToolDefinitions = {
169
189
 
170
190
  lowdefy_load_state: {
171
191
  description:
172
- "Put the app back into a saved state checkpoint. mode 'headless' (default) verifies the restored state itself; mode 'registry-only' loads the recorded request data into the dev server and returns a ?_checkpoint URL the developer can open to manually test the app in that exact state.",
192
+ "Put the app back into a saved state checkpoint. mode 'headless' (default) verifies the restored state itself; mode 'registry-only' loads the recorded request data into the dev server and returns a ?_checkpoint URL the developer can open to manually test the app in that exact state." +
193
+ CALLER_NOTE,
173
194
  inputSchema: {
174
195
  name: z.string().describe('The checkpoint name.'),
175
196
  mode: z.enum(['headless', 'registry-only']).optional(),
176
197
  user: userSchema,
198
+ data: dataSchema,
177
199
  },
178
200
  },
179
201
 
@@ -250,7 +272,8 @@ const devToolDefinitions = {
250
272
 
251
273
  lowdefy_screenshot_page: {
252
274
  description:
253
- 'Screenshot a page of the running dev server (headless Chromium) to visually verify layout and rendering. Returns a PNG image. Set width (and height) to check a narrow or phone layout, e.g. width 390, and colorScheme "dark" to check dark mode. To capture a state beyond the page as it loads — an OPEN Selector / MultipleSelector / AutoComplete / DateSelector (calendar) / Cascader / TreeSelector dropdown, a modal a button opens — pass steps, e.g. [{"open": "status"}]: they run after the page settles and before the capture. Popups antd renders in a portal are included, also with fullPage. Pass urlQuery for a page that reads _url_query, and pathParams for a page whose path has placeholders.',
275
+ 'Screenshot a page of the running dev server (headless Chromium) to visually verify layout and rendering. Returns a PNG image. Set width (and height) to check a narrow or phone layout, e.g. width 390, and colorScheme "dark" to check dark mode. To capture a state beyond the page as it loads — an OPEN Selector / MultipleSelector / AutoComplete / DateSelector (calendar) / Cascader / TreeSelector dropdown, a modal a button opens — pass steps, e.g. [{"open": "status"}]: they run after the page settles and before the capture. Popups antd renders in a portal are included, also with fullPage. Pass urlQuery for a page that reads _url_query, and pathParams for a page whose path has placeholders.' +
276
+ CALLER_NOTE,
254
277
  inputSchema: {
255
278
  pageId: z.string().describe('The page id to screenshot.'),
256
279
  pathParams: pathParamsSchema,
@@ -305,18 +328,20 @@ const devToolDefinitions = {
305
328
  'The colour scheme the page\'s prefers-color-scheme reports. Default "light". An app that follows the system theme renders dark with "dark"; a darkMode fixed in the app config wins.'
306
329
  ),
307
330
  user: userSchema,
331
+ data: dataSchema,
308
332
  },
309
333
  },
310
334
 
311
335
  lowdefy_run_journey: {
312
336
  description:
313
- 'Drive a page of the running dev server headless through declarative steps and assert what happens — the way to verify behaviour (a form submits, a modal opens, a filter works), not just layout. Blocks are addressed by blockId; a target object narrows to a grid row/cell ({"blockId": "grid", "row": 1, "column": "actions"}), to the control with exactly some text ({"blockId": "grid", "row": 1, "text": "Edit"}), or reaches portal-rendered controls page-wide by text alone ({"text": "OK"} for a confirm dialog or modal footer button, a menu item). A step that fails stops the journey and is returned as data (passed: false, failure with index/step/expected/actual/message, later steps "skipped") — never as a tool error. A journey also fails at the step that causes an app error its own browsers raised (an action failing with an error that is not a user error, an uncaught page error, a server error or 5xx from a request or endpoint): failure.kind "app-error" with errors [{kind, message, source, configKey, key}], or phase "open" when opening the page raised it. A failed Validate, a Throw action and 401/403 refusals never count. Where an error is the intended outcome, {"expect": {"error": text}} straight after the interaction claims its errors whose message contains text. Returns the final page state (whole when small, otherwise stateOmitted with its size and top-level keys; see the state param) and any screenshots taken (as images after the JSON text).',
337
+ 'Drive a page of the running dev server headless through declarative steps and assert what happens — the way to verify behaviour (a form submits, a modal opens, a filter works), not just layout. Blocks are addressed by blockId; a target object narrows to a grid row/cell ({"blockId": "grid", "row": 1, "column": "actions"}), to the control with exactly some text ({"blockId": "grid", "row": 1, "text": "Edit"}), or reaches portal-rendered controls page-wide by text alone ({"text": "OK"} for a confirm dialog or modal footer button, a menu item). A step that fails stops the journey and is returned as data (passed: false, failure with index/step/expected/actual/message, later steps "skipped") — never as a tool error. A journey also fails at the step that causes an app error its own browsers raised (an action failing with an error that is not a user error, an uncaught page error, a server error or 5xx from a request or endpoint): failure.kind "app-error" with errors [{kind, message, source, configKey, key}], or phase "open" when opening the page raised it. A failed Validate, a Throw action and 401/403 refusals never count. Where an error is the intended outcome, {"expect": {"error": text}} straight after the interaction claims its errors whose message contains text. Returns the final page state (whole when small, otherwise stateOmitted with its size and top-level keys; see the state param) and any screenshots taken (as images after the JSON text).' +
338
+ CALLER_NOTE,
314
339
  inputSchema: {
315
340
  pageId: z.string().describe('The page id to open.'),
316
341
  steps: z
317
342
  .array(z.record(z.any()))
318
343
  .describe(
319
- 'Ordered steps, one key each: {"click": target} (a target object may add "count": 2 or 3 for that many clicks in quick succession, a double click, before the runner settles) | {"open": target} (open an input\'s dropdown or picker popup - Selector, MultipleSelector, AutoComplete, DateSelector, Cascader... - and wait for it to show, so a following screenshot step captures it) | {"fill": {...target, "value"}} | {"fill": {...target, "fromEmail": {"to", "subject"?, "match"}}} (type text read from the newest email to that address, like the email step, without leaving the page: the first match of the regular expression "match", or its first capture group, e.g. "\\\\b\\\\d{6}\\\\b" for a one-time sign-in code) | {"select": {...target, "value"}} (option by exact text: a dropdown option, or a radio, button or segmented option in the block) | {"press": "Enter" | "Mod+k"} (Mod is Meta/Control per platform) | {"back": true} (the browser Back button) | {"goto": pageId | {"pageId", "pathParams", "urlQuery"}} (load an app page like a typed URL, its path built from pathParams, one string per placeholder of the page\'s path; a protected page may redirect to sign-in) | {"email": {"to", "subject"?}} (open the newest email to that address, subject containing the text, that arrived during this journey, waiting for it if needed; opening it again opens the same message; then click its links by text, e.g. {"click": {"text": "Verify email address"}}; needs the dev server started with LOWDEFY_DEV_SMTP_PORT and the app\'s SMTP connection pointed at 127.0.0.1 on that port) | {"as": name} (switch to another person, each with their own browser and cookies; the journey starts as "main", and a new name opens the journey\'s page) | {"wait": {"ms": n} | {"request": requestId} (a call started since the last interaction, or since the page opened, has finished) | {"state": path}} | {"screenshot": name?} | {"expect": {"state": {"path", "equals"}} | {"visible": target} | {"hidden": target} (nothing the target names is visible; passes at once when nothing matches yet, so pair it with something that must be present first) | {"text": {...target, "contains"}} | {"url": {"contains"}} | {"title": {"equals"} | {"contains"}} | {"calls": {"request", "pageId"?, "count"} | {"endpoint", "count"}} | {"error": text} | {"effect": true}} (title is the document title; calls compares, once the page settles, how many times this actor called the request - on pageId, default the current page - or the endpoint since the journey started; error must directly follow an interaction step and claims the app errors it raised whose message contains text, with the failed action that reported them; effect must directly follow an interaction step and fails when that interaction did nothing: no event ran, the page did not change, no request or endpoint was called and the URL stayed the same, which proves a dead click). A target is a blockId string, or an object of {"blockId", "row" (zero-based grid row as displayed), "column" (grid col-id), "text" (exact text of the interactive control to use), "containing" (text the element shows, e.g. the email a list row shows; a click on it reaches the row\'s click handler), "nth" (zero-based pick among several matches; a click, open, fill or select whose text or containing matches more than one visible element fails without it)}; "text" without "blockId" searches the whole page, which is how confirm dialog / modal footer buttons, dropdown menu items and email links are reached. fill, select and expect.text need a blockId. Every blockId, requestId, endpoint and goto pageId a step names is checked against the build before it runs (and an "as" name against the data set\'s users on a data set run): an unknown one fails the step and names it. fill, select and expect.state take an optional "from": "recorded" (the value came from a recorded trace) or "shape" (value: null, a placeholder a trace could not hold); a journey holding a from: shape placeholder is refused until it is filled in. Each step gets 5s, or the journey\'s timeout: every wait for something to happen (a control to be actionable, an expect to match, a request, state or email to arrive) is bounded by it; page opens get at least 15s; after an interaction the runner waits at most 5s for the page\'s pending events and requests to settle, without failing; wait.ms is exact.'
344
+ 'Ordered steps, one key each: {"click": target} (a target object may add "count": 2 or 3 for that many clicks in quick succession, a double click, before the runner settles) | {"open": target} (open an input\'s dropdown or picker popup - Selector, MultipleSelector, AutoComplete, DateSelector, Cascader... - and wait for it to show, so a following screenshot step captures it) | {"fill": {...target, "value"}} | {"fill": {...target, "fromEmail": {"to", "subject"?, "match"}}} (type text read from the newest email to that address, like the email step, without leaving the page: the first match of the regular expression "match", or its first capture group, e.g. "\\\\b\\\\d{6}\\\\b" for a one-time sign-in code) | {"select": {...target, "value"}} (option by exact text: a dropdown option, or a radio, button or segmented option in the block) | {"press": "Enter" | "Mod+k"} (Mod is Meta/Control per platform) | {"back": true} (the browser Back button) | {"goto": pageId | {"pageId", "pathParams", "urlQuery"}} (load an app page like a typed URL, its path built from pathParams, one string per placeholder of the page\'s path; a protected page may redirect to sign-in) | {"email": {"to", "subject"?}} (open the newest email to that address, subject containing the text, that arrived during this journey, waiting for it if needed; opening it again opens the same message; then click its links by text, e.g. {"click": {"text": "Verify email address"}}; needs the dev server started with LOWDEFY_DEV_SMTP_PORT and the app\'s SMTP connection pointed at 127.0.0.1 on that port) | {"as": name} (switch to another person, each with their own browser and cookies; the journey starts as "main", and a new name opens the journey\'s page) | {"wait": {"ms": n} | {"request": requestId} (a call started since the last interaction, or since the page opened, has finished) | {"state": path}} | {"screenshot": name?} | {"expect": {"state": {"path", "equals"}} | {"visible": target} | {"hidden": target} (nothing the target names is visible; passes at once when nothing matches yet, so pair it with something that must be present first) | {"text": {...target, "contains"}} | {"url": {"contains"}} | {"title": {"equals"} | {"contains"}} | {"calls": {"request", "pageId"?, "count"} | {"endpoint", "count"}} | {"error": text} | {"effect": true}} (title is the document title; calls compares, once the page settles, how many times this actor called the request - on pageId, default the current page - or the endpoint since the journey started; error must directly follow an interaction step and claims the app errors it raised whose message contains text, with the failed action that reported them; effect must directly follow an interaction step and fails when that interaction did nothing: no event ran, the page did not change, no request or endpoint was called and the URL stayed the same, which proves a dead click). A target is a blockId string, or an object of {"blockId", "row" (zero-based grid row as displayed), "column" (grid col-id), "text" (exact text of the interactive control to use), "containing" (text the element shows, e.g. the email a list row shows; a click on it reaches the row\'s click handler), "nth" (zero-based pick among several matches; a click, open, fill or select whose text or containing matches more than one visible element fails without it)}; "text" without "blockId" searches the whole page, which is how confirm dialog / modal footer buttons, dropdown menu items and email links are reached. fill, select and expect.text need a blockId. Every blockId, requestId, endpoint and goto pageId a step names is checked against the build before it runs (and an "as" name against the data set\'s users on a data set run): an unknown one fails the step and names it. fill, select and expect.state take an optional "from": "recorded" (the value came from a recorded trace) or "shape" (value: null, a placeholder a trace could not hold); a journey holding a from: shape placeholder is refused until it is filled in. Each step gets 5s, or the journey\'s timeout: every wait for something to happen (a control to be actionable, an expect to match, a request, state or email to arrive) is bounded by it; page opens get at least 15s; after an interaction the runner waits at most 5s for the page\'s pending events and requests to settle, without failing; wait.ms is exact. lowdefy_get_schema with kind "journey-step" returns this grammar as a JSON schema, with examples of every step.'
320
345
  ),
321
346
  user: z
322
347
  .union([
@@ -409,9 +434,16 @@ const devToolDefinitions = {
409
434
  HAZARDS_NOTE,
410
435
  inputSchema: {
411
436
  kind: z
412
- .enum(['blocks', 'operators', 'actions', 'connections', 'requests'])
413
- .describe('The kind of the type.'),
414
- type: z.string().describe('The exact type name, e.g. "Button", "_get", "MongoDBFind".'),
437
+ .enum(['blocks', 'operators', 'actions', 'connections', 'requests', 'journey-step'])
438
+ .describe(
439
+ 'The kind of the type. "journey-step" returns the lowdefy_run_journey step grammar, with examples of every step.'
440
+ ),
441
+ type: z
442
+ .string()
443
+ .optional()
444
+ .describe(
445
+ 'The exact type name, e.g. "Button", "_get", "MongoDBFind". Required, except for kind "journey-step", where it names one step (e.g. "wait") and is omitted for every step.'
446
+ ),
415
447
  },
416
448
  },
417
449
 
@@ -158,6 +158,29 @@ test('getSchema returns null for unknown type', () => {
158
158
 
159
159
  test('getSchema throws for kinds without schemas', () => {
160
160
  expect(() => getSchema({ kind: 'websockets', type: 'X' })).toThrow('No schemas available');
161
+ expect(() => getSchema({ kind: 'tool', type: 'run_journey' })).toThrow(
162
+ 'or journey-step for the journey step grammar'
163
+ );
164
+ });
165
+
166
+ test('getSchema throws when a type kind is given no type', () => {
167
+ expect(() => getSchema({ kind: 'blocks' })).toThrow(
168
+ 'Getting a blocks schema requires a "type", the exact type name. Received undefined.'
169
+ );
170
+ });
171
+
172
+ test('getSchema returns the journey step grammar for kind journey-step', () => {
173
+ const result = getSchema({ kind: 'journey-step' });
174
+ expect(result.kind).toEqual('journey-step');
175
+ expect(result.schema.title).toEqual('Journey step');
176
+ expect(result.schema.oneOf.map((step) => step.required[0])).toContain('wait');
177
+ });
178
+
179
+ test('getSchema returns one journey step schema when type names the step', () => {
180
+ const result = getSchema({ kind: 'journey-step', type: 'wait' });
181
+ expect(result).toMatchObject({ kind: 'journey-step', type: 'wait' });
182
+ expect(result.schema.properties.wait.examples).toContainEqual({ wait: { ms: 500 } });
183
+ expect(getSchema({ kind: 'journey-step', type: 'hover' })).toBeNull();
161
184
  });
162
185
 
163
186
  test('getExamples reads example yaml from plugin dist by convention', () => {
@@ -22,13 +22,14 @@ import resolveSource from './resolveSource.js';
22
22
  // headless-only. Falls back to headless whenever the tab path isn't usable
23
23
  // (no tab connected, or `source: 'tab'` was requested but it errored) so
24
24
  // agents always get an answer.
25
- async function evalOperator({ origin, pageId, pathParams, expression, source, user }) {
25
+ async function evalOperator({ origin, pageId, pathParams, expression, source, user, data }) {
26
26
  const { tryTab, error, invalidInput } = resolveSource({
27
27
  name: 'evalOperator',
28
28
  pageId,
29
29
  pathParams,
30
30
  source,
31
31
  user,
32
+ data,
32
33
  });
33
34
  if (error) {
34
35
  return { error, invalidInput };
@@ -41,7 +42,7 @@ async function evalOperator({ origin, pageId, pathParams, expression, source, us
41
42
  }
42
43
  }
43
44
 
44
- const result = await evalOperatorHeadless({ origin, pageId, pathParams, expression, user });
45
+ const result = await evalOperatorHeadless({ origin, pageId, pathParams, expression, user, data });
45
46
  return { ...result, source: 'headless' };
46
47
  }
47
48
 
@@ -19,8 +19,10 @@ import { type } from '@lowdefy/helpers';
19
19
  import { getBrowser, openPage, buildPageUrl } from './getBrowser.js';
20
20
  import noBrowserError from './noBrowserError.js';
21
21
  import resolvePageInstance from './resolvePageInstance.js';
22
+ import resolveToolCaller from './resolveToolCaller.js';
22
23
  import unsettledPageNote from './unsettledPageNote.js';
23
24
  import withBrowserSlot from './withBrowserSlot.js';
25
+ import withDataSession from './withDataSession.js';
24
26
 
25
27
  // Evaluates an operator expression against the live client state of a
26
28
  // headless Chromium tab navigated to the page instance's own route (`pathParams`
@@ -33,6 +35,7 @@ async function evalOperatorHeadless({
33
35
  pathParams,
34
36
  expression,
35
37
  user,
38
+ data,
36
39
  timeout = 15000,
37
40
  }) {
38
41
  if (type.isNone(origin) || !type.isString(origin)) {
@@ -56,9 +59,27 @@ async function evalOperatorHeadless({
56
59
  return { error: instance.error, invalidInput: true };
57
60
  }
58
61
 
62
+ const caller = await resolveToolCaller({ user, data });
63
+ if (!type.isUndefined(caller.error)) {
64
+ return caller;
65
+ }
66
+
59
67
  return withBrowserSlot({
60
68
  task: () =>
61
- evalOperatorInBrowser({ origin, pageId, pathParams, instance, expression, user, timeout }),
69
+ withDataSession({
70
+ dataSet: caller.dataSet,
71
+ task: ({ dataCookie }) =>
72
+ evalOperatorInBrowser({
73
+ origin,
74
+ pageId,
75
+ pathParams,
76
+ instance,
77
+ expression,
78
+ user: caller.user,
79
+ dataCookie,
80
+ timeout,
81
+ }),
82
+ }),
62
83
  });
63
84
  }
64
85
 
@@ -70,6 +91,7 @@ async function evalOperatorInBrowser({
70
91
  instance,
71
92
  expression,
72
93
  user,
94
+ dataCookie,
73
95
  timeout,
74
96
  }) {
75
97
  let browser;
@@ -90,6 +112,7 @@ async function evalOperatorInBrowser({
90
112
  path: instance.path,
91
113
  pathParams,
92
114
  user,
115
+ dataCookie,
93
116
  timeout,
94
117
  });
95
118
  context = opened.context;
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { type } from '@lowdefy/helpers';
18
+ import { JOURNEY_STEP_SCHEMAS, journeyStepSchema } from '@lowdefy/node-utils';
18
19
 
19
20
  import getHazards from './getHazards.js';
20
21
  import normalizeTypeKind from './normalizeTypeKind.js';
@@ -28,13 +29,40 @@ const SCHEMA_ARTIFACTS = {
28
29
  requests: 'plugins/requestSchemas.json',
29
30
  };
30
31
 
32
+ const JOURNEY_STEP_KINDS = ['journey-step', 'journey-steps'];
33
+
34
+ // kind journey-step returns the journey step grammar: every step, or the one
35
+ // step `type` names.
36
+ function getJourneyStepSchema({ typeName }) {
37
+ if (type.isNone(typeName) || typeName === '' || typeName === 'all') {
38
+ return { kind: 'journey-step', schema: journeyStepSchema };
39
+ }
40
+ const schema = JOURNEY_STEP_SCHEMAS[typeName];
41
+ if (type.isNone(schema)) {
42
+ return null;
43
+ }
44
+ return { kind: 'journey-step', type: typeName, schema };
45
+ }
46
+
31
47
  function getSchema({ kind, type: typeName }) {
48
+ if (JOURNEY_STEP_KINDS.includes(String(kind ?? '').toLowerCase())) {
49
+ return getJourneyStepSchema({ typeName });
50
+ }
32
51
  const normalizedKind = normalizeTypeKind({ kind });
33
52
  if (type.isNone(SCHEMA_ARTIFACTS[normalizedKind])) {
34
53
  throw new Error(
35
54
  `No schemas available for type kind. Received ${JSON.stringify(
36
55
  kind
37
- )}. Use one of: blocks, operators, actions, connections, requests.`
56
+ )}. Use one of: blocks, operators, actions, connections, requests, or journey-step for the journey step grammar.`
57
+ );
58
+ }
59
+ // type is optional only for journey-step, so a type kind without one is
60
+ // refused rather than looked up as "undefined".
61
+ if (type.isNone(typeName) || typeName === '') {
62
+ throw new Error(
63
+ `Getting a ${normalizedKind} schema requires a "type", the exact type name. Received ${JSON.stringify(
64
+ typeName
65
+ )}.`
38
66
  );
39
67
  }
40
68
  const schemas = readBuildArtifact({ name: SCHEMA_ARTIFACTS[normalizedKind] }) ?? {};
@@ -22,13 +22,14 @@ import resolveSource from './resolveSource.js';
22
22
  // headless-only. Falls back to headless whenever the tab path isn't usable
23
23
  // (no tab connected, or `source: 'tab'` was requested but it errored) so
24
24
  // agents always get an answer.
25
- async function inspectState({ origin, pageId, pathParams, source, user }) {
25
+ async function inspectState({ origin, pageId, pathParams, source, user, data }) {
26
26
  const { tryTab, error, invalidInput } = resolveSource({
27
27
  name: 'inspectState',
28
28
  pageId,
29
29
  pathParams,
30
30
  source,
31
31
  user,
32
+ data,
32
33
  });
33
34
  if (error) {
34
35
  return { error, invalidInput };
@@ -41,7 +42,7 @@ async function inspectState({ origin, pageId, pathParams, source, user }) {
41
42
  }
42
43
  }
43
44
 
44
- const result = await inspectStateHeadless({ origin, pageId, pathParams, user });
45
+ const result = await inspectStateHeadless({ origin, pageId, pathParams, user, data });
45
46
  return { ...result, source: 'headless' };
46
47
  }
47
48