@lowdefy/server-dev 0.0.0-experimental-20261016121021 → 0.0.0-experimental-20261019053059

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 (131) hide show
  1. package/client/recorder/createPairingBuffer.test.mjs +4 -3
  2. package/client/recorder/createRecorder.js +10 -6
  3. package/client/recorder/isPasswordElement.js +41 -0
  4. package/lib/build/mobileConfig.js +26 -0
  5. package/lib/configTree/runConfigTreeBuild.mjs +2 -1
  6. package/lib/configTree/runConfigTreeBuild.skipPluginChecks.test.mjs +1 -1
  7. package/lib/docs/createDocsMcpServer.js +165 -36
  8. package/lib/docs/createDocsMcpServer.test.mjs +156 -0
  9. package/lib/docs/devToolDefinitions.js +84 -6
  10. package/lib/docs/device/buildDeviceCommand.js +103 -0
  11. package/lib/docs/device/buildDeviceCommand.test.mjs +141 -0
  12. package/lib/docs/device/captureDevice.js +85 -0
  13. package/lib/docs/device/checkShownPage.js +56 -0
  14. package/lib/docs/device/createFakePhoneTab.mjs +80 -0
  15. package/lib/docs/device/deviceAction.js +74 -0
  16. package/lib/docs/device/deviceTools.test.mjs +636 -0
  17. package/lib/docs/device/findDeviceTab.js +50 -0
  18. package/lib/docs/device/findDeviceTarget.js +73 -0
  19. package/lib/docs/device/injectDevice.js +161 -0
  20. package/lib/docs/device/listDevices.js +100 -0
  21. package/lib/docs/device/listDevices.test.mjs +104 -0
  22. package/lib/docs/device/runDeviceJourney.js +68 -0
  23. package/lib/docs/device/runDeviceSteps.js +164 -0
  24. package/lib/docs/device/screenshotDevice.js +68 -0
  25. package/lib/docs/device/sendDeviceStep.js +52 -0
  26. package/lib/docs/device/startDevice.js +150 -0
  27. package/lib/docs/docs.test.mjs +32 -0
  28. package/lib/docs/evalOperatorHeadless.test.mjs +1 -0
  29. package/lib/docs/getBrowser.test.mjs +4 -1
  30. package/lib/docs/inspectState.js +36 -1
  31. package/lib/docs/inspectStateFromTab.js +5 -4
  32. package/lib/docs/inspectStateHeadless.test.mjs +1 -0
  33. package/lib/docs/isDeepEqual.js +37 -0
  34. package/lib/docs/isPageReady.js +4 -0
  35. package/lib/docs/isPageReady.test.mjs +14 -0
  36. package/lib/docs/launchBrowser.js +5 -2
  37. package/lib/docs/launchBrowser.test.mjs +4 -1
  38. package/lib/docs/listTypes.js +58 -8
  39. package/lib/docs/loadState.js +67 -1
  40. package/lib/docs/pollTab.js +100 -0
  41. package/lib/docs/pollTab.test.mjs +62 -0
  42. package/lib/docs/rails/authEngineMethods.test.mjs +1 -1
  43. package/lib/docs/runCheckChild.js +2 -1
  44. package/lib/docs/runJourney.test.mjs +148 -7
  45. package/lib/docs/runJourneySteps.js +169 -35
  46. package/lib/docs/screenshotPage.test.mjs +1 -0
  47. package/lib/docs/setupTestFixtures.mjs +25 -0
  48. package/lib/docs/shownTextEngine.js +122 -0
  49. package/lib/docs/snapshotState.js +16 -3
  50. package/lib/docs/tabChannel.js +82 -14
  51. package/lib/docs/tabChannel.test.mjs +12 -0
  52. package/lib/server/auth/getHeadlessUser.js +6 -0
  53. package/lib/server/auth/getHeadlessUser.test.mjs +12 -2
  54. package/lib/server/jitPageBuilder.js +8 -0
  55. package/lib/server/jitPageBuilder.mobileTypes.test.mjs +64 -0
  56. package/lib/server/pairingHeaders.js +26 -0
  57. package/manager/getContext.mjs +2 -0
  58. package/manager/processes/lowdefyBuild.mjs +2 -1
  59. package/manager/processes/lowdefyBuild.skipPluginChecks.test.mjs +1 -1
  60. package/manager/processes/lowdefyBuild.test.mjs +3 -1
  61. package/manager/processes/startMailSink.mjs +11 -3
  62. package/manager/processes/startMailSink.test.mjs +26 -0
  63. package/manager/processes/startProxy.mjs +68 -17
  64. package/manager/processes/startProxy.pairing.test.mjs +204 -0
  65. package/manager/run.mjs +8 -0
  66. package/manager/utils/checkPairing.mjs +45 -0
  67. package/manager/utils/checkPairing.test.mjs +52 -0
  68. package/manager/utils/createCustomPluginMessagesMap.mjs +1 -1
  69. package/manager/utils/createCustomPluginTypesMap.mjs +36 -9
  70. package/manager/utils/createCustomPluginTypesMap.test.mjs +95 -1
  71. package/manager/utils/createLanPairing.mjs +34 -0
  72. package/manager/utils/createLanPairing.test.mjs +45 -0
  73. package/manager/utils/createServerArtifactTracker.mjs +1 -0
  74. package/{client/recorder/collectEventActions.js → manager/utils/findSignInCode.mjs} +13 -10
  75. package/manager/utils/findSignInCode.test.mjs +32 -0
  76. package/manager/utils/formatLanPairing.mjs +32 -0
  77. package/manager/utils/formatLanPairing.test.mjs +39 -0
  78. package/manager/utils/getLanAddress.mjs +83 -0
  79. package/manager/utils/getLanAddress.test.mjs +69 -0
  80. package/{client/recorder/toJsonSafe.js → manager/utils/isLoopbackAddress.mjs} +12 -10
  81. package/manager/utils/readPluginDefinitions.mjs +6 -6
  82. package/manager/utils/renderTerminalQr.mjs +54 -0
  83. package/manager/watchers/pluginSourceWatcher.mjs +1 -1
  84. package/package.json +39 -37
  85. package/package.original.json +39 -37
  86. package/src/app.js +37 -7
  87. package/{client/recorder/jsonType.js → src/lib/getRequestSurface.js} +5 -19
  88. package/src/lib/isTabAnswer.js +32 -0
  89. package/src/lib/isTabChannelCaller.js +35 -0
  90. package/src/middleware/localDevToolsOnly.js +13 -1
  91. package/src/middleware/localDevToolsOnly.test.mjs +33 -0
  92. package/src/routes/agentBearer.test.mjs +183 -0
  93. package/src/routes/clientError.js +10 -2
  94. package/src/routes/clientError.test.mjs +105 -0
  95. package/src/routes/devInspect.js +4 -16
  96. package/src/routes/devInspect.test.mjs +125 -0
  97. package/src/routes/devRecording.js +5 -16
  98. package/src/routes/devRecording.test.mjs +52 -1
  99. package/src/routes/docs/types.js +5 -1
  100. package/src/routes/jitPage.js +13 -1
  101. package/src/routes/jitPage.test.mjs +25 -0
  102. package/src/routes/mobileRoot.js +30 -0
  103. package/src/routes/mobileRoutes.test.mjs +223 -0
  104. package/src/routes/mobileSignIn.js +58 -0
  105. package/src/routes/mobileSignIn.test.mjs +59 -0
  106. package/src/routes/mobileSignInCallback.js +34 -0
  107. package/src/routes/mobileSignInCallback.test.mjs +51 -0
  108. package/src/routes/mountDevPageRoutes.js +10 -2
  109. package/src/routes/mountDevPageRoutes.test.mjs +25 -1
  110. package/src/routes/mountMobileWellKnown.js +35 -0
  111. package/src/routes/mountMobileWellKnown.test.mjs +78 -0
  112. package/src/routes/reload.js +51 -6
  113. package/src/routes/reload.test.mjs +69 -0
  114. package/client/recorder/buildEngineRecord.js +0 -37
  115. package/client/recorder/buildInteractionRecord.js +0 -64
  116. package/client/recorder/buildTraceEvent.js +0 -57
  117. package/client/recorder/buildTraceEvent.test.mjs +0 -81
  118. package/client/recorder/chunkRecords.js +0 -69
  119. package/client/recorder/chunkRecords.test.mjs +0 -59
  120. package/client/recorder/collectEventEndpoints.js +0 -40
  121. package/client/recorder/collectEventRequests.js +0 -39
  122. package/client/recorder/createPairingBuffer.js +0 -187
  123. package/client/recorder/createPasswordRedactor.js +0 -84
  124. package/client/recorder/createRecordBatcher.js +0 -54
  125. package/client/recorder/createRecordBatcher.test.mjs +0 -76
  126. package/client/recorder/createRecordingSession.js +0 -61
  127. package/client/recorder/createRecordingSession.test.mjs +0 -72
  128. package/client/recorder/diffStateWrites.js +0 -77
  129. package/client/recorder/diffStateWrites.test.mjs +0 -66
  130. package/client/recorder/isEventCall.js +0 -30
  131. package/client/recorder/sendRecords.js +0 -40
@@ -18,11 +18,12 @@
18
18
  */
19
19
 
20
20
  import { jest } from '@jest/globals';
21
+ import createPairingBuffer, { HOLD_MS } from '@lowdefy/client-core/recorder/createPairingBuffer.js';
22
+ import createPasswordRedactor from '@lowdefy/client-core/recorder/createPasswordRedactor.js';
21
23
  // Only the validator: the package index loads undici, which needs Node globals jsdom lacks.
22
24
  import validateTraceRecord from '@lowdefy/node-utils/journeyTrace/validateTraceRecord.js';
23
25
 
24
- import createPairingBuffer, { HOLD_MS } from './createPairingBuffer.js';
25
- import createPasswordRedactor from './createPasswordRedactor.js';
26
+ import isPasswordElement from './isPasswordElement.js';
26
27
 
27
28
  const START = Date.parse('2026-10-03T14:00:00.000Z');
28
29
  const SESSION = '20261003T140000Z-k3x9qa';
@@ -114,7 +115,7 @@ beforeEach(() => {
114
115
  getSession: () => SESSION,
115
116
  getRoles: () => ['support'],
116
117
  getBuild: () => build,
117
- redactor: createPasswordRedactor(),
118
+ redactor: createPasswordRedactor({ isPassword: isPasswordElement }),
118
119
  });
119
120
  build = BUILD_A;
120
121
  document.body.innerHTML = `
@@ -14,12 +14,16 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- import createPairingBuffer from './createPairingBuffer.js';
18
- import createPasswordRedactor from './createPasswordRedactor.js';
19
- import createRecordBatcher from './createRecordBatcher.js';
20
- import createRecordingSession from './createRecordingSession.js';
17
+ import {
18
+ createPairingBuffer,
19
+ createPasswordRedactor,
20
+ createRecordBatcher,
21
+ createRecordingSession,
22
+ sendRecords,
23
+ } from '@lowdefy/client-core';
24
+
25
+ import isPasswordElement from './isPasswordElement.js';
21
26
  import keyChord from './keyChord.js';
22
- import sendRecords from './sendRecords.js';
23
27
 
24
28
  // Dev tools draw their own UI over the app; clicks on it are not the app.
25
29
  const IGNORED_ROOTS = '[data-lowdefy-feedback]';
@@ -67,7 +71,7 @@ function createRecorder({ basePath, lowdefy, recording, window, getTrace }) {
67
71
  getSession: () => session.getId(),
68
72
  getRoles: () => lowdefy.user?.roles ?? [],
69
73
  getBuild: () => lowdefy._devBuildId ?? null,
70
- redactor: createPasswordRedactor(),
74
+ redactor: createPasswordRedactor({ isPassword: isPasswordElement }),
71
75
  });
72
76
  let pageId = null;
73
77
  // A pageview waits here until the page it shows is on screen, because only
@@ -0,0 +1,41 @@
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
+ */
16
+
17
+ import { journeyTargetSelectors } from '@lowdefy/helpers';
18
+
19
+ const { blockWrapperPrefix } = journeyTargetSelectors;
20
+
21
+ function isPasswordInput(element) {
22
+ return (
23
+ typeof element?.tagName === 'string' &&
24
+ element.tagName.toLowerCase() === 'input' &&
25
+ element.type === 'password'
26
+ );
27
+ }
28
+
29
+ // Whether an interaction's element belongs to a password block: the element,
30
+ // or its block's `<blockId>_input` element, is an `<input type="password">`.
31
+ // The visibility toggle turns the input into `type="text"`, so the block's own
32
+ // input is checked too, which a click on the toggle reaches.
33
+ function isPasswordElement(element) {
34
+ if (isPasswordInput(element)) return true;
35
+ const wrapper = element?.closest?.(`[id^="${blockWrapperPrefix}"]`);
36
+ if (!wrapper) return false;
37
+ const blockId = wrapper.id.slice(blockWrapperPrefix.length);
38
+ return isPasswordInput(element.ownerDocument?.getElementById(`${blockId}_input`));
39
+ }
40
+
41
+ export default isPasswordElement;
@@ -0,0 +1,26 @@
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
+ */
16
+
17
+ // Build artifacts are read from disk because the Hono server runs as
18
+ // unbundled Node.js ESM — JSON imports would need import attributes, and
19
+ // client code imports the build JSON directly through Vite instead.
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+
23
+ // {} for an app without a mobile key.
24
+ export default JSON.parse(
25
+ fs.readFileSync(path.join(process.cwd(), 'build/mobile/config.json'), 'utf8')
26
+ );
@@ -55,13 +55,14 @@ async function runConfigTreeBuild({
55
55
  await prepareScratchServer({ devDirectory, serverDirectory: directories.server });
56
56
  let result;
57
57
  try {
58
- const [customTypesMap, customMessagesMap] = await Promise.all([
58
+ const [{ customTypesMap, customTypesMapMobile }, customMessagesMap] = await Promise.all([
59
59
  createCustomPluginTypesMap({ directories, logger }),
60
60
  createCustomPluginMessagesMap({ directories, logger }),
61
61
  ]);
62
62
  await build({
63
63
  customMessagesMap,
64
64
  customTypesMap,
65
+ customTypesMapMobile,
65
66
  directories,
66
67
  logger,
67
68
  refResolver,
@@ -29,7 +29,7 @@ jest.unstable_mockModule('./prepareScratchServer.mjs', () => ({
29
29
  default: jest.fn().mockResolvedValue(),
30
30
  }));
31
31
  jest.unstable_mockModule('../../manager/utils/createCustomPluginTypesMap.mjs', () => ({
32
- default: jest.fn().mockResolvedValue({}),
32
+ default: jest.fn().mockResolvedValue({ customTypesMap: {}, customTypesMapMobile: {} }),
33
33
  }));
34
34
  jest.unstable_mockModule('../../manager/utils/createCustomPluginMessagesMap.mjs', () => ({
35
35
  default: jest.fn().mockResolvedValue({}),
@@ -20,6 +20,7 @@ import { validateJourneyUser } from '@lowdefy/node-utils';
20
20
 
21
21
  import checkpointToMocks from './checkpointToMocks.js';
22
22
  import createConfigCheckpoint from './createConfigCheckpoint.js';
23
+ import deviceAction from './device/deviceAction.js';
23
24
  import { subscribe as subscribeToDevEvents } from './devEventBus.js';
24
25
  import evalOperator from './evalOperator.js';
25
26
  import findConfig from './findConfig.js';
@@ -47,6 +48,7 @@ import runEndpoint from './runEndpoint.js';
47
48
  import runJourney from './runJourney.js';
48
49
  import runJourneyAsUsers from './runJourneyAsUsers.js';
49
50
  import selectExercised from './selectExercised.js';
51
+ import runDeviceJourney from './device/runDeviceJourney.js';
50
52
  import runRequest from './runRequest.js';
51
53
  import snapshotState from './snapshotState.js';
52
54
  import { listStateCheckpoints } from './checkpointStore.js';
@@ -54,6 +56,7 @@ import createLogger from '../server/log/createLogger.js';
54
56
  import lowdefyConfig from '../build/config.js';
55
57
  import devToolDefinitions, { INSTRUCTIONS } from './devToolDefinitions.js';
56
58
  import scaffoldPage from './scaffoldPage.js';
59
+ import screenshotDevice from './device/screenshotDevice.js';
57
60
  import screenshotPage from './screenshotPage.js';
58
61
  import searchDocs from './searchDocs.js';
59
62
  import searchIcons from './searchIcons.js';
@@ -82,6 +85,67 @@ function notFoundResult(message) {
82
85
  return { content: [{ type: 'text', text: message }], isError: true };
83
86
  }
84
87
 
88
+ // A screenshot result (screenshotPage, or screenshotDevice for a phone) as MCP
89
+ // content. The final capture shows the page where the steps (or the failing
90
+ // step) left it; the images of `screenshot` steps follow it in step order, named
91
+ // in the JSON. A phone's capture also says how it was taken.
92
+ function imageResult(result) {
93
+ if (result.error) {
94
+ return notFoundResult(result.error);
95
+ }
96
+ const image = { type: 'image', data: result.data, mimeType: result.mimeType };
97
+ const fromDevice = !type.isUndefined(result.device);
98
+ if (!fromDevice && !result.failure && result.screenshots.length === 0) {
99
+ return { content: [image] };
100
+ }
101
+ const summary = { passed: !result.failure, failure: result.failure };
102
+ if (fromDevice) {
103
+ summary.device = result.device;
104
+ summary.capture = result.capture;
105
+ if (!type.isUndefined(result.note)) {
106
+ summary.note = result.note;
107
+ }
108
+ }
109
+ if (result.screenshots.length > 0) {
110
+ summary.screenshots = result.screenshots.map(({ name }) => ({ name }));
111
+ }
112
+ return {
113
+ content: [
114
+ { type: 'text', text: JSON.stringify(summary, null, 2) },
115
+ image,
116
+ ...result.screenshots.map(({ data, mimeType }) => ({ type: 'image', data, mimeType })),
117
+ ],
118
+ };
119
+ }
120
+
121
+ // A journey result's PNGs travel as image content blocks (the shape
122
+ // lowdefy_screenshot_page returns) so an MCP client renders them; the JSON keeps
123
+ // only their names.
124
+ function journeyResult({ summary, screenshots }) {
125
+ return {
126
+ content: [
127
+ { type: 'text', text: JSON.stringify(summary, null, 2) },
128
+ ...screenshots.map(({ data, mimeType }) => ({ type: 'image', data, mimeType })),
129
+ ],
130
+ };
131
+ }
132
+
133
+ // The params a tool takes for the headless browser mean nothing on a phone:
134
+ // named back to the agent rather than silently dropped.
135
+ function deviceParamsError({ tool, params, names }) {
136
+ const given = names.filter((name) => !type.isNone(params[name]));
137
+ if (given.length === 0) {
138
+ return null;
139
+ }
140
+ return notFoundResult(
141
+ `${tool} with device acts on the phone as it is: ${given.join(', ')} ${
142
+ given.length === 1 ? 'applies' : 'apply'
143
+ } to the headless browser only. Leave ${
144
+ given.length === 1 ? 'it' : 'them'
145
+ } out, or leave device out.`
146
+ );
147
+ }
148
+
85
149
  function createDocsMcpServer({ origin, honoContext, version } = {}) {
86
150
  const server = new McpServer(
87
151
  { name: 'lowdefy', version },
@@ -130,8 +194,26 @@ function createDocsMcpServer({ origin, honoContext, version } = {}) {
130
194
  });
131
195
  }
132
196
 
133
- registerDevTool('lowdefy_inspect_state', async ({ pageId, pathParams, source, user, data }) => {
134
- const result = await inspectState({ origin, pageId, pathParams, source, user, data });
197
+ registerDevTool(
198
+ 'lowdefy_inspect_state',
199
+ async ({ pageId, pathParams, source, user, data, device }) => {
200
+ const result = await inspectState({ origin, pageId, pathParams, source, user, data, device });
201
+ if (result.error) {
202
+ return notFoundResult(result.error);
203
+ }
204
+ return textResult(result);
205
+ }
206
+ );
207
+
208
+ registerDevTool('lowdefy_device', async ({ action, platform, device, inject }) => {
209
+ const result = await deviceAction({
210
+ action,
211
+ platform,
212
+ device,
213
+ inject,
214
+ origin,
215
+ log: (message) => logger.info({ event: 'lowdefy_device' }, message),
216
+ });
135
217
  if (result.error) {
136
218
  return notFoundResult(result.error);
137
219
  }
@@ -184,7 +266,7 @@ function createDocsMcpServer({ origin, honoContext, version } = {}) {
184
266
 
185
267
  registerDevTool(
186
268
  'lowdefy_snapshot_state',
187
- async ({ pageId, pathParams, name, notes, source, overwrite }) => {
269
+ async ({ pageId, pathParams, name, notes, source, overwrite, device }) => {
188
270
  const result = await snapshotState({
189
271
  origin,
190
272
  pageId,
@@ -193,6 +275,7 @@ function createDocsMcpServer({ origin, honoContext, version } = {}) {
193
275
  notes,
194
276
  source,
195
277
  overwrite,
278
+ device,
196
279
  });
197
280
  if (result.error) {
198
281
  return notFoundResult(result.error);
@@ -201,8 +284,8 @@ function createDocsMcpServer({ origin, honoContext, version } = {}) {
201
284
  }
202
285
  );
203
286
 
204
- registerDevTool('lowdefy_load_state', async ({ name, mode, user, data }) => {
205
- const result = await loadState({ origin, name, mode, user, data });
287
+ registerDevTool('lowdefy_load_state', async ({ name, mode, user, data, device }) => {
288
+ const result = await loadState({ origin, name, mode, user, data, device });
206
289
  if (result.error) {
207
290
  return notFoundResult(result.error);
208
291
  }
@@ -272,7 +355,41 @@ function createDocsMcpServer({ origin, honoContext, version } = {}) {
272
355
  width,
273
356
  height,
274
357
  colorScheme,
358
+ device,
275
359
  }) => {
360
+ if (!type.isNone(device)) {
361
+ const refused = deviceParamsError({
362
+ tool: 'lowdefy_screenshot_page',
363
+ params: {
364
+ urlQuery,
365
+ fullPage,
366
+ clip,
367
+ scrollX,
368
+ scrollY,
369
+ width,
370
+ height,
371
+ colorScheme,
372
+ user,
373
+ data,
374
+ },
375
+ names: [
376
+ 'urlQuery',
377
+ 'fullPage',
378
+ 'clip',
379
+ 'scrollX',
380
+ 'scrollY',
381
+ 'width',
382
+ 'height',
383
+ 'colorScheme',
384
+ 'user',
385
+ 'data',
386
+ ],
387
+ });
388
+ if (refused !== null) {
389
+ return refused;
390
+ }
391
+ return imageResult(await screenshotDevice({ device, pageId, pathParams, steps }));
392
+ }
276
393
  if (!origin) {
277
394
  return notFoundResult('Screenshot unavailable: server origin unknown for this transport.');
278
395
  }
@@ -292,33 +409,50 @@ function createDocsMcpServer({ origin, honoContext, version } = {}) {
292
409
  height,
293
410
  colorScheme,
294
411
  });
295
- if (result.error) {
296
- return notFoundResult(result.error);
297
- }
298
- const image = { type: 'image', data: result.data, mimeType: result.mimeType };
299
- if (!result.failure && result.screenshots.length === 0) {
300
- return { content: [image] };
301
- }
302
- // The final capture shows the page where the steps (or the failing step)
303
- // left it; the images of `screenshot` steps follow it in step order, named
304
- // in the JSON.
305
- const summary = { passed: !result.failure, failure: result.failure };
306
- if (result.screenshots.length > 0) {
307
- summary.screenshots = result.screenshots.map(({ name }) => ({ name }));
308
- }
309
- return {
310
- content: [
311
- { type: 'text', text: JSON.stringify(summary, null, 2) },
312
- image,
313
- ...result.screenshots.map(({ data, mimeType }) => ({ type: 'image', data, mimeType })),
314
- ],
315
- };
412
+ return imageResult(result);
316
413
  }
317
414
  );
318
415
 
319
416
  registerDevTool(
320
417
  'lowdefy_run_journey',
321
- async ({ pageId, pathParams, steps, user, urlQuery, state, timeout, data, exercised }) => {
418
+ async ({
419
+ pageId,
420
+ pathParams,
421
+ steps,
422
+ user,
423
+ urlQuery,
424
+ state,
425
+ timeout,
426
+ data,
427
+ exercised,
428
+ device,
429
+ }) => {
430
+ if (!type.isNone(device)) {
431
+ const refused = deviceParamsError({
432
+ tool: 'lowdefy_run_journey',
433
+ params: { urlQuery, user, data, exercised },
434
+ names: ['urlQuery', 'user', 'data', 'exercised'],
435
+ });
436
+ if (refused !== null) {
437
+ return refused;
438
+ }
439
+ const result = await runDeviceJourney({
440
+ device,
441
+ pageId,
442
+ pathParams,
443
+ steps,
444
+ stepTimeout: timeout,
445
+ state,
446
+ });
447
+ if (result.error) {
448
+ return notFoundResult(result.error);
449
+ }
450
+ const { screenshots, ...rest } = result;
451
+ return journeyResult({
452
+ summary: { ...rest, screenshots: screenshots.map(({ name }) => ({ name })) },
453
+ screenshots,
454
+ });
455
+ }
322
456
  if (!origin) {
323
457
  return notFoundResult('Journey unavailable: server origin unknown for this transport.');
324
458
  }
@@ -374,20 +508,13 @@ function createDocsMcpServer({ origin, honoContext, version } = {}) {
374
508
  if (result.error) {
375
509
  return notFoundResult(result.error);
376
510
  }
377
- // The PNGs travel as image content blocks (the shape lowdefy_screenshot_page
378
- // returns) so an MCP client renders them; the JSON keeps only their names.
379
511
  const { screenshots, exercised: resultExercised, ...rest } = result;
380
512
  const summary = {
381
513
  ...rest,
382
514
  ...selectExercised({ exercised: resultExercised, selection: exercised }),
383
515
  screenshots: screenshots.map(({ name }) => ({ name })),
384
516
  };
385
- return {
386
- content: [
387
- { type: 'text', text: JSON.stringify(summary, null, 2) },
388
- ...screenshots.map(({ data, mimeType }) => ({ type: 'image', data, mimeType })),
389
- ],
390
- };
517
+ return journeyResult({ summary, screenshots });
391
518
  }
392
519
  );
393
520
 
@@ -401,7 +528,9 @@ function createDocsMcpServer({ origin, honoContext, version } = {}) {
401
528
 
402
529
  registerDevTool('lowdefy_overview', () => textResult(getOverview()));
403
530
 
404
- registerDevTool('lowdefy_list_types', ({ kind }) => textResult(listTypes({ kind })));
531
+ registerDevTool('lowdefy_list_types', ({ kind, target }) =>
532
+ textResult(listTypes({ kind, target }))
533
+ );
405
534
 
406
535
  registerDevTool('lowdefy_list_plugins', () => textResult(listPlugins()));
407
536
 
@@ -46,6 +46,7 @@ const { default: createDocsMcpServer, subscribeMcpServerToDevEvents } = await im
46
46
  './createDocsMcpServer.js'
47
47
  );
48
48
  const { publish } = await import('./devEventBus.js');
49
+ const { default: createFakePhoneTab, VIEW_PNG } = await import('./device/createFakePhoneTab.mjs');
49
50
 
50
51
  const EXPECTED_TOOLS = [
51
52
  'lowdefy_overview',
@@ -77,6 +78,7 @@ const EXPECTED_TOOLS = [
77
78
  'lowdefy_list_state_checkpoints',
78
79
  'lowdefy_checkpoint_to_mocks',
79
80
  'lowdefy_journey_session',
81
+ 'lowdefy_device',
80
82
  ];
81
83
 
82
84
  async function connectClient() {
@@ -779,3 +781,157 @@ test('MCP tools/call lowdefy_journey_session lists dev sessions and prints one a
779
781
  fs.rmSync(configDirectory, { recursive: true, force: true });
780
782
  }
781
783
  });
784
+
785
+ test('MCP the device tool adds one tool to the 29 the dev server had', async () => {
786
+ const client = await connectClient();
787
+ const { tools } = await client.listTools();
788
+ expect(tools).toHaveLength(30);
789
+ const device = tools.find((tool) => tool.name === 'lowdefy_device');
790
+ expect(device.inputSchema.properties.action.enum).toEqual(['list', 'start', 'inject']);
791
+ expect(device.inputSchema.properties.inject.properties.kind.enum).toEqual([
792
+ 'scan',
793
+ 'photo',
794
+ 'nfc',
795
+ 'transcript',
796
+ 'location',
797
+ 'permission',
798
+ 'network',
799
+ 'push',
800
+ 'clear',
801
+ ]);
802
+ expect(device.inputSchema.required).toEqual(['action']);
803
+ await client.close();
804
+ });
805
+
806
+ test('MCP tools that can act on a phone advertise an optional device parameter', async () => {
807
+ const client = await connectClient();
808
+ const { tools } = await client.listTools();
809
+ [
810
+ 'lowdefy_screenshot_page',
811
+ 'lowdefy_run_journey',
812
+ 'lowdefy_inspect_state',
813
+ 'lowdefy_snapshot_state',
814
+ 'lowdefy_load_state',
815
+ ].forEach((name) => {
816
+ const tool = tools.find((candidate) => candidate.name === name);
817
+ expect(tool.inputSchema.properties.device.type).toEqual('string');
818
+ expect(tool.inputSchema.required ?? []).not.toContain('device');
819
+ });
820
+ const listTypesTool = tools.find((tool) => tool.name === 'lowdefy_list_types');
821
+ expect(listTypesTool.inputSchema.properties.target.enum).toEqual(['web', 'mobile']);
822
+ await client.close();
823
+ });
824
+
825
+ test('MCP lowdefy_screenshot_page with device captures the phone, not the headless browser', async () => {
826
+ const phone = createFakePhoneTab({
827
+ id: 'mcp-phone',
828
+ device: { shell: '7.1.0', platform: 'ios', app: 'com.lowdefy.dev', paired: true },
829
+ page: { pageId: 'ticket', pathParams: {}, instanceKey: 'page:ticket' },
830
+ });
831
+ const client = await connectClient();
832
+ const result = await client.callTool({
833
+ name: 'lowdefy_screenshot_page',
834
+ arguments: { pageId: 'ticket', device: 'mcp-phone', steps: [{ click: 'save' }] },
835
+ });
836
+ expect(mockScreenshotPage).not.toHaveBeenCalled();
837
+ expect(phone.requests.map(({ event, data }) => [event, data.step])).toEqual([
838
+ ['step-request', { click: 'save' }],
839
+ ['step-request', { screenshot: null }],
840
+ ]);
841
+ const summary = JSON.parse(result.content[0].text);
842
+ expect(summary).toMatchObject({ passed: true, device: 'mcp-phone', capture: 'view' });
843
+ expect(result.content[1]).toEqual({ type: 'image', data: VIEW_PNG, mimeType: 'image/png' });
844
+ phone.close();
845
+ await client.close();
846
+ });
847
+
848
+ test('MCP lowdefy_screenshot_page with device refuses the headless-only params', async () => {
849
+ const client = await connectClient();
850
+ const result = await client.callTool({
851
+ name: 'lowdefy_screenshot_page',
852
+ arguments: { pageId: 'ticket', device: 'mcp-phone', width: 390, user: 'member' },
853
+ });
854
+ expect(result.isError).toBe(true);
855
+ expect(result.content[0].text).toContain('width, user apply to the headless browser only');
856
+ await client.close();
857
+ });
858
+
859
+ test('MCP lowdefy_run_journey with device runs on the phone and returns the journey shape', async () => {
860
+ const phone = createFakePhoneTab({
861
+ id: 'mcp-phone',
862
+ device: { shell: '7.1.0', platform: 'android', app: 'com.lowdefy.dev', paired: true },
863
+ page: { pageId: 'ticket', pathParams: {}, instanceKey: 'page:ticket' },
864
+ });
865
+ const client = await connectClient();
866
+ const result = await client.callTool({
867
+ name: 'lowdefy_run_journey',
868
+ arguments: {
869
+ pageId: 'ticket',
870
+ device: 'mcp-phone',
871
+ steps: [
872
+ { fill: { blockId: 'title', value: 'Leak' } },
873
+ { screenshot: 'filled' },
874
+ { expect: { state: { path: 'title', equals: 'Leak' } } },
875
+ ],
876
+ },
877
+ });
878
+ expect(mockRunJourney).not.toHaveBeenCalled();
879
+ const summary = JSON.parse(result.content[0].text);
880
+ expect(summary).toMatchObject({
881
+ pageId: 'ticket',
882
+ device: 'mcp-phone',
883
+ passed: true,
884
+ state: { title: 'Leak' },
885
+ screenshots: [{ name: 'filled' }],
886
+ });
887
+ expect(result.content[1]).toEqual({ type: 'image', data: VIEW_PNG, mimeType: 'image/png' });
888
+ phone.close();
889
+ await client.close();
890
+ });
891
+
892
+ test('MCP lowdefy_inspect_state reads a phone by its tab id', async () => {
893
+ const phone = createFakePhoneTab({
894
+ id: 'mcp-phone',
895
+ device: { shell: '7.1.0', platform: 'ios', app: 'com.lowdefy.dev', paired: false },
896
+ page: { pageId: 'ticket', pathParams: {}, instanceKey: 'page:ticket' },
897
+ state: { title: 'Quarterly' },
898
+ });
899
+ const client = await connectClient();
900
+ const result = await client.callTool({
901
+ name: 'lowdefy_inspect_state',
902
+ arguments: { pageId: 'ticket', device: 'mcp-phone', source: 'tab' },
903
+ });
904
+ expect(JSON.parse(result.content[0].text)).toMatchObject({
905
+ source: 'tab',
906
+ device: 'mcp-phone',
907
+ state: { title: 'Quarterly' },
908
+ });
909
+ phone.close();
910
+ await client.close();
911
+ });
912
+
913
+ test('MCP lowdefy_device inject forwards the injection to the phone', async () => {
914
+ const phone = createFakePhoneTab({
915
+ id: 'mcp-phone',
916
+ device: { shell: '7.1.0', platform: 'android', app: 'com.lowdefy.dev', paired: true },
917
+ });
918
+ const client = await connectClient();
919
+ const result = await client.callTool({
920
+ name: 'lowdefy_device',
921
+ arguments: {
922
+ action: 'inject',
923
+ device: 'mcp-phone',
924
+ inject: { kind: 'scan', value: { data: 'JOB-42' } },
925
+ },
926
+ });
927
+ expect(phone.requests.map(({ event, data }) => [event, data.inject])).toEqual([
928
+ ['inject', { kind: 'scan', value: { data: 'JOB-42' } }],
929
+ ]);
930
+ expect(JSON.parse(result.content[0].text)).toEqual({
931
+ device: 'mcp-phone',
932
+ kind: 'scan',
933
+ app: { ok: true, kind: 'scan' },
934
+ });
935
+ phone.close();
936
+ await client.close();
937
+ });