@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.
- package/client/recorder/createPairingBuffer.test.mjs +4 -3
- package/client/recorder/createRecorder.js +10 -6
- package/client/recorder/isPasswordElement.js +41 -0
- package/lib/build/mobileConfig.js +26 -0
- package/lib/configTree/runConfigTreeBuild.mjs +2 -1
- package/lib/configTree/runConfigTreeBuild.skipPluginChecks.test.mjs +1 -1
- package/lib/docs/createDocsMcpServer.js +165 -36
- package/lib/docs/createDocsMcpServer.test.mjs +156 -0
- package/lib/docs/devToolDefinitions.js +84 -6
- package/lib/docs/device/buildDeviceCommand.js +103 -0
- package/lib/docs/device/buildDeviceCommand.test.mjs +141 -0
- package/lib/docs/device/captureDevice.js +85 -0
- package/lib/docs/device/checkShownPage.js +56 -0
- package/lib/docs/device/createFakePhoneTab.mjs +80 -0
- package/lib/docs/device/deviceAction.js +74 -0
- package/lib/docs/device/deviceTools.test.mjs +636 -0
- package/lib/docs/device/findDeviceTab.js +50 -0
- package/lib/docs/device/findDeviceTarget.js +73 -0
- package/lib/docs/device/injectDevice.js +161 -0
- package/lib/docs/device/listDevices.js +100 -0
- package/lib/docs/device/listDevices.test.mjs +104 -0
- package/lib/docs/device/runDeviceJourney.js +68 -0
- package/lib/docs/device/runDeviceSteps.js +164 -0
- package/lib/docs/device/screenshotDevice.js +68 -0
- package/lib/docs/device/sendDeviceStep.js +52 -0
- package/lib/docs/device/startDevice.js +150 -0
- package/lib/docs/docs.test.mjs +32 -0
- package/lib/docs/evalOperatorHeadless.test.mjs +1 -0
- package/lib/docs/getBrowser.test.mjs +4 -1
- package/lib/docs/inspectState.js +36 -1
- package/lib/docs/inspectStateFromTab.js +5 -4
- package/lib/docs/inspectStateHeadless.test.mjs +1 -0
- package/lib/docs/isDeepEqual.js +37 -0
- package/lib/docs/isPageReady.js +4 -0
- package/lib/docs/isPageReady.test.mjs +14 -0
- package/lib/docs/launchBrowser.js +5 -2
- package/lib/docs/launchBrowser.test.mjs +4 -1
- package/lib/docs/listTypes.js +58 -8
- package/lib/docs/loadState.js +67 -1
- package/lib/docs/pollTab.js +100 -0
- package/lib/docs/pollTab.test.mjs +62 -0
- package/lib/docs/rails/authEngineMethods.test.mjs +1 -1
- package/lib/docs/runCheckChild.js +2 -1
- package/lib/docs/runJourney.test.mjs +148 -7
- package/lib/docs/runJourneySteps.js +169 -35
- package/lib/docs/screenshotPage.test.mjs +1 -0
- package/lib/docs/setupTestFixtures.mjs +25 -0
- package/lib/docs/shownTextEngine.js +122 -0
- package/lib/docs/snapshotState.js +16 -3
- package/lib/docs/tabChannel.js +82 -14
- package/lib/docs/tabChannel.test.mjs +12 -0
- package/lib/server/auth/getHeadlessUser.js +6 -0
- package/lib/server/auth/getHeadlessUser.test.mjs +12 -2
- package/lib/server/jitPageBuilder.js +8 -0
- package/lib/server/jitPageBuilder.mobileTypes.test.mjs +64 -0
- package/lib/server/pairingHeaders.js +26 -0
- package/manager/getContext.mjs +2 -0
- package/manager/processes/lowdefyBuild.mjs +2 -1
- package/manager/processes/lowdefyBuild.skipPluginChecks.test.mjs +1 -1
- package/manager/processes/lowdefyBuild.test.mjs +3 -1
- package/manager/processes/startMailSink.mjs +11 -3
- package/manager/processes/startMailSink.test.mjs +26 -0
- package/manager/processes/startProxy.mjs +68 -17
- package/manager/processes/startProxy.pairing.test.mjs +204 -0
- package/manager/run.mjs +8 -0
- package/manager/utils/checkPairing.mjs +45 -0
- package/manager/utils/checkPairing.test.mjs +52 -0
- package/manager/utils/createCustomPluginMessagesMap.mjs +1 -1
- package/manager/utils/createCustomPluginTypesMap.mjs +36 -9
- package/manager/utils/createCustomPluginTypesMap.test.mjs +95 -1
- package/manager/utils/createLanPairing.mjs +34 -0
- package/manager/utils/createLanPairing.test.mjs +45 -0
- package/manager/utils/createServerArtifactTracker.mjs +1 -0
- package/{client/recorder/collectEventActions.js → manager/utils/findSignInCode.mjs} +13 -10
- package/manager/utils/findSignInCode.test.mjs +32 -0
- package/manager/utils/formatLanPairing.mjs +32 -0
- package/manager/utils/formatLanPairing.test.mjs +39 -0
- package/manager/utils/getLanAddress.mjs +83 -0
- package/manager/utils/getLanAddress.test.mjs +69 -0
- package/{client/recorder/toJsonSafe.js → manager/utils/isLoopbackAddress.mjs} +12 -10
- package/manager/utils/readPluginDefinitions.mjs +6 -6
- package/manager/utils/renderTerminalQr.mjs +54 -0
- package/manager/watchers/pluginSourceWatcher.mjs +1 -1
- package/package.json +39 -37
- package/package.original.json +39 -37
- package/src/app.js +37 -7
- package/{client/recorder/jsonType.js → src/lib/getRequestSurface.js} +5 -19
- package/src/lib/isTabAnswer.js +32 -0
- package/src/lib/isTabChannelCaller.js +35 -0
- package/src/middleware/localDevToolsOnly.js +13 -1
- package/src/middleware/localDevToolsOnly.test.mjs +33 -0
- package/src/routes/agentBearer.test.mjs +183 -0
- package/src/routes/clientError.js +10 -2
- package/src/routes/clientError.test.mjs +105 -0
- package/src/routes/devInspect.js +4 -16
- package/src/routes/devInspect.test.mjs +125 -0
- package/src/routes/devRecording.js +5 -16
- package/src/routes/devRecording.test.mjs +52 -1
- package/src/routes/docs/types.js +5 -1
- package/src/routes/jitPage.js +13 -1
- package/src/routes/jitPage.test.mjs +25 -0
- package/src/routes/mobileRoot.js +30 -0
- package/src/routes/mobileRoutes.test.mjs +223 -0
- package/src/routes/mobileSignIn.js +58 -0
- package/src/routes/mobileSignIn.test.mjs +59 -0
- package/src/routes/mobileSignInCallback.js +34 -0
- package/src/routes/mobileSignInCallback.test.mjs +51 -0
- package/src/routes/mountDevPageRoutes.js +10 -2
- package/src/routes/mountDevPageRoutes.test.mjs +25 -1
- package/src/routes/mountMobileWellKnown.js +35 -0
- package/src/routes/mountMobileWellKnown.test.mjs +78 -0
- package/src/routes/reload.js +51 -6
- package/src/routes/reload.test.mjs +69 -0
- package/client/recorder/buildEngineRecord.js +0 -37
- package/client/recorder/buildInteractionRecord.js +0 -64
- package/client/recorder/buildTraceEvent.js +0 -57
- package/client/recorder/buildTraceEvent.test.mjs +0 -81
- package/client/recorder/chunkRecords.js +0 -69
- package/client/recorder/chunkRecords.test.mjs +0 -59
- package/client/recorder/collectEventEndpoints.js +0 -40
- package/client/recorder/collectEventRequests.js +0 -39
- package/client/recorder/createPairingBuffer.js +0 -187
- package/client/recorder/createPasswordRedactor.js +0 -84
- package/client/recorder/createRecordBatcher.js +0 -54
- package/client/recorder/createRecordBatcher.test.mjs +0 -76
- package/client/recorder/createRecordingSession.js +0 -61
- package/client/recorder/createRecordingSession.test.mjs +0 -72
- package/client/recorder/diffStateWrites.js +0 -77
- package/client/recorder/diffStateWrites.test.mjs +0 -66
- package/client/recorder/isEventCall.js +0 -30
- 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
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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(
|
|
134
|
-
|
|
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
|
-
|
|
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 ({
|
|
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 }) =>
|
|
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
|
+
});
|