@lowdefy/server-dev 5.6.0 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/{pages/_app.js → client/App.jsx} +21 -33
- package/client/DevStreamContext.js +30 -0
- package/client/Inspector.jsx +226 -0
- package/{lib/client/Page.js → client/Page.jsx} +4 -8
- package/client/Reload.jsx +85 -0
- package/client/Routing.jsx +117 -0
- package/client/clientBuildImports.test.mjs +61 -0
- package/client/feedback/FeedbackMount.jsx +83 -0
- package/client/feedback/FeedbackOverlay.jsx +901 -0
- package/client/feedback/captureTabScreenshot.js +84 -0
- package/client/feedback/copyToClipboard.js +45 -0
- package/client/feedback/drawAnnotationsSvg.js +119 -0
- package/client/feedback/elementInspect.js +99 -0
- package/client/feedback/feedbackStyles.js +301 -0
- package/client/feedback/pinResponsiveImages.js +59 -0
- package/client/feedback/sendFeedback.js +39 -0
- package/client/feedback/useFeedbackToggle.js +70 -0
- package/client/main.jsx +42 -0
- package/client/openInEditor/OpenInEditorListener.jsx +268 -0
- package/client/openInEditor/findBlockLocation.js +51 -0
- package/lib/build/app.js +8 -1
- package/lib/build/appMeta.js +8 -1
- package/lib/build/auth.js +8 -1
- package/lib/build/config.js +8 -1
- package/lib/build/i18n.js +8 -1
- package/lib/build/logger.js +8 -1
- package/lib/build/theme.js +8 -1
- package/lib/client/auth/{Auth.js → Auth.jsx} +11 -2
- package/lib/client/auth/{AuthConfigured.js → AuthConfigured.jsx} +22 -10
- package/lib/client/setPageId.js +6 -5
- package/lib/client/utils/useMutateCache.js +14 -1
- package/lib/client/utils/usePageConfig.js +45 -43
- package/lib/client/utils/usePageConfig.test.mjs +62 -0
- package/lib/docs/captureAnnotatedScreenshot.js +88 -0
- package/lib/docs/checkpointPaths.js +91 -0
- package/lib/docs/checkpointStore.js +240 -0
- package/lib/docs/checkpointToMocks.js +65 -0
- package/lib/docs/clientErrorStore.js +36 -0
- package/lib/docs/configCheckpoints.test.mjs +156 -0
- package/lib/docs/createConfigCheckpoint.js +92 -0
- package/lib/docs/createDocsMcpServer.js +531 -0
- package/lib/docs/createDocsMcpServer.test.mjs +141 -0
- package/lib/docs/devMockRegistry.js +68 -0
- package/lib/docs/docs.test.mjs +448 -0
- package/lib/docs/enrichFeedback.js +71 -0
- package/lib/docs/evalOperator.js +47 -0
- package/lib/docs/evalOperator.test.mjs +157 -0
- package/lib/docs/evalOperatorHeadless.js +87 -0
- package/lib/docs/evalOperatorHeadless.test.mjs +72 -0
- package/lib/docs/evalOperatorInTab.js +48 -0
- package/lib/docs/findConfig.js +260 -0
- package/lib/docs/formatFeedback.js +121 -0
- package/lib/docs/formatFeedback.test.mjs +204 -0
- package/lib/docs/getAppMap.js +180 -0
- package/lib/docs/getBrowser.js +127 -0
- package/lib/docs/getBrowser.test.mjs +122 -0
- package/lib/docs/getBuildStatus.js +37 -0
- package/lib/docs/getCoreDoc.js +63 -0
- package/lib/docs/getDocsManifest.js +40 -0
- package/lib/docs/getExamples.js +58 -0
- package/lib/docs/getOverview.js +115 -0
- package/lib/docs/getPageConfig.js +53 -0
- package/lib/docs/getPluginDoc.js +55 -0
- package/lib/docs/getSchema.js +65 -0
- package/lib/docs/inspectState.js +47 -0
- package/lib/docs/inspectState.test.mjs +140 -0
- package/lib/docs/inspectStateFromTab.js +40 -0
- package/lib/docs/inspectStateHeadless.js +84 -0
- package/lib/docs/inspectStateHeadless.test.mjs +60 -0
- package/lib/docs/isWriteRequestsAllowed.js +50 -0
- package/lib/docs/listConfigCheckpoints.js +55 -0
- package/lib/docs/listPlugins.js +80 -0
- package/lib/docs/listTypes.js +105 -0
- package/lib/docs/loadState.js +152 -0
- package/lib/docs/mapPageBuildErrors.js +28 -0
- package/lib/docs/normalizeTypeKind.js +40 -0
- package/{pages/api/endpoints/[...endpointId].js → lib/docs/readBuildArtifact.js} +15 -12
- package/lib/docs/readPageArtifact.js +26 -0
- package/lib/docs/resolvePluginDir.js +49 -0
- package/lib/docs/resolveSource.js +48 -0
- package/lib/docs/resolveSource.test.mjs +100 -0
- package/lib/docs/revertConfigCheckpoint.js +73 -0
- package/lib/docs/runRequest.js +141 -0
- package/lib/docs/saveAnnotatedScreenshot.js +43 -0
- package/lib/docs/scaffoldPage.js +75 -0
- package/lib/docs/scaffoldPage.test.mjs +97 -0
- package/lib/docs/screenshotPage.js +110 -0
- package/lib/docs/screenshotPage.test.mjs +59 -0
- package/lib/docs/screenshotPageClip.test.mjs +102 -0
- package/lib/docs/searchDocs.js +67 -0
- package/lib/docs/setupTestFixtures.mjs +335 -0
- package/lib/docs/snapshotState.js +59 -0
- package/lib/docs/stateCheckpoints.test.mjs +366 -0
- package/lib/docs/tabAvailable.js +28 -0
- package/lib/docs/tabChannel.js +119 -0
- package/lib/docs/tabChannel.test.mjs +130 -0
- package/lib/server/auth/{getAuthOptions.js → getAuthConfig.js} +9 -4
- package/lib/server/auth/getDevSession.js +70 -0
- package/lib/server/auth/getDevSession.test.mjs +138 -0
- package/lib/server/auth/getHeadlessUser.js +40 -0
- package/lib/server/auth/{getMockSession.js → getMockUser.js} +8 -21
- package/lib/server/auth/headlessUser.js +29 -0
- package/lib/server/auth/resolveHeadlessUser.js +38 -0
- package/lib/server/auth/resolveHeadlessUser.test.mjs +78 -0
- package/lib/server/auth/session.js +39 -0
- package/lib/server/auth/strategies.js +47 -0
- package/lib/server/createLowdefyContext.js +128 -0
- package/lib/server/createLowdefyContext.test.mjs +117 -0
- package/lib/server/getPageJitEnrichment.test.mjs +105 -0
- package/lib/server/jitPageBuilder.js +125 -12
- package/lib/server/log/createHandleError.test.mjs +126 -0
- package/lib/server/log/createLogger.test.mjs +62 -0
- package/lib/server/scrubSecrets.js +23 -0
- package/manager/getContext.mjs +3 -7
- package/manager/processes/initialBuild.mjs +0 -2
- package/manager/processes/installPlugins.mjs +19 -11
- package/manager/processes/lowdefyBuild.mjs +29 -7
- package/manager/processes/lowdefyBuild.test.mjs +102 -0
- package/manager/processes/readDotEnv.mjs +13 -1
- package/manager/processes/shutdownServer.mjs +6 -6
- package/manager/processes/startProxy.mjs +160 -0
- package/manager/processes/startProxy.test.mjs +102 -0
- package/manager/processes/startServer.mjs +33 -16
- package/manager/processes/startWatchers.mjs +2 -2
- package/manager/processes/warnAuthUrlPortMismatch.mjs +47 -0
- package/manager/processes/warnAuthUrlPortMismatch.test.mjs +93 -0
- package/manager/run.mjs +50 -0
- package/manager/utils/createCustomPluginMessagesMap.mjs +8 -1
- package/manager/utils/createCustomPluginTypesMap.mjs +1 -0
- package/manager/utils/formatNoticeBox.mjs +30 -0
- package/manager/utils/{getNextBin.mjs → getViteBin.mjs} +7 -10
- package/manager/utils/updatePageTailwindCss.mjs +7 -1
- package/manager/utils/writeBuildStatus.mjs +39 -0
- package/manager/utils/writeBuildStatus.test.mjs +76 -0
- package/manager/watchers/lowdefyBuildWatcher.mjs +0 -1
- package/manager/watchers/moduleBuildWatcher.mjs +0 -1
- package/manager/watchers/{nextBuildWatcher.mjs → serverArtifactWatcher.mjs} +18 -20
- package/package.json +62 -49
- package/package.original.json +65 -52
- package/postcss.config.cjs +1 -0
- package/src/app.js +175 -0
- package/src/html/renderDevPage.js +86 -0
- package/src/lib/getPathSegments.js +25 -0
- package/src/lib/safeScriptJson.js +38 -0
- package/src/middleware/apiContext.js +32 -0
- package/src/middleware/errorHandler.js +48 -0
- package/src/middleware/errorHandler.test.mjs +247 -0
- package/src/routes/agent.js +64 -0
- package/src/routes/apiPage.js +37 -0
- package/src/routes/auth.js +49 -0
- package/src/routes/auth.test.mjs +84 -0
- package/src/routes/clientError.js +57 -0
- package/src/routes/cron.js +47 -0
- package/src/routes/cronForward.js +47 -0
- package/src/routes/detached.js +47 -0
- package/src/routes/devInspect.js +111 -0
- package/src/routes/devInspect.test.mjs +119 -0
- package/{pages/[[...pageId]].js → src/routes/devTools.js} +4 -2
- package/{pages/404.js → src/routes/docs/appMap.js} +6 -2
- package/{pages/api/dev-tools.js → src/routes/docs/buildStatus.js} +5 -4
- package/src/routes/docs/checkpointsCreate.js +28 -0
- package/src/routes/docs/checkpointsList.js +23 -0
- package/{pages/api/icons/dynamic.js → src/routes/docs/checkpointsRevert.js} +11 -12
- package/src/routes/docs/content.js +31 -0
- package/src/routes/docs/evalOperator.js +56 -0
- package/src/routes/docs/examples.js +33 -0
- package/src/routes/docs/find.js +30 -0
- package/src/routes/docs/index.js +23 -0
- package/src/routes/docs/inspectState.js +44 -0
- package/src/routes/docs/loadState.js +49 -0
- package/src/routes/docs/mcp.js +30 -0
- package/src/routes/docs/pageConfig.js +36 -0
- package/src/routes/docs/parseUserParam.js +52 -0
- package/src/routes/docs/parseUserParam.test.mjs +45 -0
- package/src/routes/docs/pluginDoc.js +33 -0
- package/src/routes/docs/plugins.js +23 -0
- package/src/routes/docs/runRequest.js +36 -0
- package/{lib/server/auth/getServerSession.js → src/routes/docs/schema.js} +17 -15
- package/src/routes/docs/screenshot.js +72 -0
- package/src/routes/docs/search.js +27 -0
- package/src/routes/docs/snapshotState.js +36 -0
- package/src/routes/docs/stateCheckpointsList.js +26 -0
- package/src/routes/docs/types.js +33 -0
- package/src/routes/endpoints.js +71 -0
- package/src/routes/feedback.js +110 -0
- package/src/routes/feedback.test.mjs +204 -0
- package/src/routes/jitPage.js +86 -0
- package/src/routes/mcp.js +34 -0
- package/{pages/api → src/routes}/ping.js +4 -2
- package/src/routes/reload.js +79 -0
- package/src/routes/request.js +74 -0
- package/src/routes/request.test.mjs +124 -0
- package/{pages/api → src/routes}/root.js +4 -4
- package/src/routes/usage.js +46 -0
- package/src/routes/websocket.js +33 -0
- package/src/websocket/devWebSocket.js +78 -0
- package/vite.config.js +111 -0
- package/.eslintrc.yaml +0 -1
- package/lib/client/App.js +0 -80
- package/lib/client/Reload.js +0 -60
- package/lib/server/apiWrapper.js +0 -109
- package/lib/server/compileCss.js +0 -39
- package/manager/processes/compileCss.mjs +0 -54
- package/manager/processes/nextBuild.mjs +0 -50
- package/next.config.js +0 -25
- package/pages/_document.js +0 -109
- package/pages/api/agent/[...path].js +0 -86
- package/pages/api/auth/[...nextauth].js +0 -48
- package/pages/api/client-error.js +0 -45
- package/pages/api/js/[env].js +0 -42
- package/pages/api/page/[...pageId].js +0 -74
- package/pages/api/reload.js +0 -53
- package/pages/api/request/[...path].js +0 -41
- /package/lib/client/{BuildErrorPage.js → BuildErrorPage.jsx} +0 -0
- /package/lib/client/{BuildingPage.js → BuildingPage.jsx} +0 -0
- /package/lib/client/{ErrorBar.js → ErrorBar.jsx} +0 -0
- /package/lib/client/{InstallingPluginsPage.js → InstallingPluginsPage.jsx} +0 -0
- /package/lib/client/{RestartingPage.js → RestartingPage.jsx} +0 -0
|
@@ -0,0 +1,157 @@
|
|
|
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 { jest } from '@jest/globals';
|
|
18
|
+
|
|
19
|
+
// evalOperator.js only chooses between the tab and headless sources - the
|
|
20
|
+
// sources' own behavior is covered by evalOperatorHeadless.test.mjs and (once
|
|
21
|
+
// written) evalOperatorInTab's own tests. Mock both, plus tabAvailable, so
|
|
22
|
+
// only the fallback logic is under test here.
|
|
23
|
+
const mockEvalOperatorInTab = jest.fn();
|
|
24
|
+
const mockEvalOperatorHeadless = jest.fn();
|
|
25
|
+
const mockTabAvailable = jest.fn();
|
|
26
|
+
|
|
27
|
+
jest.unstable_mockModule('./evalOperatorInTab.js', () => ({
|
|
28
|
+
default: mockEvalOperatorInTab,
|
|
29
|
+
}));
|
|
30
|
+
jest.unstable_mockModule('./evalOperatorHeadless.js', () => ({
|
|
31
|
+
default: mockEvalOperatorHeadless,
|
|
32
|
+
}));
|
|
33
|
+
jest.unstable_mockModule('./tabAvailable.js', () => ({
|
|
34
|
+
default: mockTabAvailable,
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
const { default: evalOperator } = await import('./evalOperator.js');
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
jest.clearAllMocks();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const expression = { _state: 'name' };
|
|
44
|
+
|
|
45
|
+
test('evalOperator uses the tab when source is "tab" and it succeeds', async () => {
|
|
46
|
+
mockEvalOperatorInTab.mockResolvedValue({ value: 'Jane', errors: [] });
|
|
47
|
+
|
|
48
|
+
const result = await evalOperator({
|
|
49
|
+
origin: 'http://localhost:3001',
|
|
50
|
+
pageId: 'home',
|
|
51
|
+
expression,
|
|
52
|
+
source: 'tab',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(result).toEqual({ value: 'Jane', errors: [], source: 'tab' });
|
|
56
|
+
expect(mockEvalOperatorInTab).toHaveBeenCalledWith({ pageId: 'home', expression });
|
|
57
|
+
expect(mockEvalOperatorHeadless).not.toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('evalOperator falls back to headless when source is "tab" but the tab errors', async () => {
|
|
61
|
+
mockEvalOperatorInTab.mockResolvedValue({ error: 'No browser tab connected.' });
|
|
62
|
+
mockEvalOperatorHeadless.mockResolvedValue({ value: 'Jane', errors: [] });
|
|
63
|
+
|
|
64
|
+
const result = await evalOperator({
|
|
65
|
+
origin: 'http://localhost:3001',
|
|
66
|
+
pageId: 'home',
|
|
67
|
+
expression,
|
|
68
|
+
source: 'tab',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(result).toEqual({ value: 'Jane', errors: [], source: 'headless' });
|
|
72
|
+
expect(mockEvalOperatorHeadless).toHaveBeenCalledWith({
|
|
73
|
+
origin: 'http://localhost:3001',
|
|
74
|
+
pageId: 'home',
|
|
75
|
+
expression,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('evalOperator goes straight to headless when source is "headless"', async () => {
|
|
80
|
+
mockEvalOperatorHeadless.mockResolvedValue({ value: 'Jane', errors: [] });
|
|
81
|
+
|
|
82
|
+
const result = await evalOperator({
|
|
83
|
+
origin: 'http://localhost:3001',
|
|
84
|
+
pageId: 'home',
|
|
85
|
+
expression,
|
|
86
|
+
source: 'headless',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(result).toEqual({ value: 'Jane', errors: [], source: 'headless' });
|
|
90
|
+
expect(mockTabAvailable).not.toHaveBeenCalled();
|
|
91
|
+
expect(mockEvalOperatorInTab).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('evalOperator prefers the tab when source is unset and a tab is available', async () => {
|
|
95
|
+
mockTabAvailable.mockReturnValue(true);
|
|
96
|
+
mockEvalOperatorInTab.mockResolvedValue({ value: 'Jane', errors: [] });
|
|
97
|
+
|
|
98
|
+
const result = await evalOperator({
|
|
99
|
+
origin: 'http://localhost:3001',
|
|
100
|
+
pageId: 'home',
|
|
101
|
+
expression,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(result).toEqual({ value: 'Jane', errors: [], source: 'tab' });
|
|
105
|
+
expect(mockEvalOperatorHeadless).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('evalOperator ignores an available tab and passes the user to headless when a user is given', async () => {
|
|
109
|
+
mockTabAvailable.mockReturnValue(true);
|
|
110
|
+
mockEvalOperatorHeadless.mockResolvedValue({ value: 'Jane', errors: [] });
|
|
111
|
+
|
|
112
|
+
const result = await evalOperator({
|
|
113
|
+
origin: 'http://localhost:3001',
|
|
114
|
+
pageId: 'home',
|
|
115
|
+
expression,
|
|
116
|
+
user: { roles: ['admin'] },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(result).toEqual({ value: 'Jane', errors: [], source: 'headless' });
|
|
120
|
+
expect(mockEvalOperatorInTab).not.toHaveBeenCalled();
|
|
121
|
+
expect(mockEvalOperatorHeadless).toHaveBeenCalledWith({
|
|
122
|
+
origin: 'http://localhost:3001',
|
|
123
|
+
pageId: 'home',
|
|
124
|
+
expression,
|
|
125
|
+
user: { roles: ['admin'] },
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('evalOperator errors when a user is combined with source "tab"', async () => {
|
|
130
|
+
const result = await evalOperator({
|
|
131
|
+
origin: 'http://localhost:3001',
|
|
132
|
+
pageId: 'home',
|
|
133
|
+
expression,
|
|
134
|
+
source: 'tab',
|
|
135
|
+
user: { roles: ['admin'] },
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(result.error).toMatch(/cannot apply "user" to the developer's live tab/);
|
|
139
|
+
// Flagged as the caller's mistake so the HTTP route answers 400, not 502.
|
|
140
|
+
expect(result.invalidInput).toBe(true);
|
|
141
|
+
expect(mockEvalOperatorInTab).not.toHaveBeenCalled();
|
|
142
|
+
expect(mockEvalOperatorHeadless).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('evalOperator goes straight to headless when source is unset and no tab is available', async () => {
|
|
146
|
+
mockTabAvailable.mockReturnValue(false);
|
|
147
|
+
mockEvalOperatorHeadless.mockResolvedValue({ value: 'Jane', errors: [] });
|
|
148
|
+
|
|
149
|
+
const result = await evalOperator({
|
|
150
|
+
origin: 'http://localhost:3001',
|
|
151
|
+
pageId: 'home',
|
|
152
|
+
expression,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(result).toEqual({ value: 'Jane', errors: [], source: 'headless' });
|
|
156
|
+
expect(mockEvalOperatorInTab).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
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 { type } from '@lowdefy/helpers';
|
|
18
|
+
|
|
19
|
+
import { getBrowser, openPage, buildPageUrl } from './getBrowser.js';
|
|
20
|
+
|
|
21
|
+
// Evaluates an operator expression against the live client state of a
|
|
22
|
+
// headless Chromium tab navigated to the page's own route, using the page's
|
|
23
|
+
// own WebParser instance so results match runtime exactly. Mirrors
|
|
24
|
+
// Inspector.jsx's evalExpression (the live-tab equivalent).
|
|
25
|
+
async function evalOperatorHeadless({ origin, pageId, expression, user, timeout = 15000 }) {
|
|
26
|
+
if (type.isNone(origin) || !type.isString(origin)) {
|
|
27
|
+
return {
|
|
28
|
+
error: `evalOperatorHeadless requires an "origin" string. Received ${JSON.stringify(origin)}.`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (type.isNone(pageId) || !type.isString(pageId)) {
|
|
32
|
+
return {
|
|
33
|
+
error: `evalOperatorHeadless requires a "pageId" string. Received ${JSON.stringify(pageId)}.`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (type.isNone(expression)) {
|
|
37
|
+
return { error: 'evalOperatorHeadless requires an "expression".' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let browser;
|
|
41
|
+
try {
|
|
42
|
+
browser = await getBrowser();
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return {
|
|
45
|
+
error: `No Chromium available. Run: npx playwright install chromium (${error.message})`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const url = buildPageUrl({ origin, pageId });
|
|
50
|
+
|
|
51
|
+
let context;
|
|
52
|
+
try {
|
|
53
|
+
const opened = await openPage({ browser, origin, pageId, user, timeout });
|
|
54
|
+
context = opened.context;
|
|
55
|
+
// Passed through as JSON so the page-side parser always receives a plain
|
|
56
|
+
// value, matching how it arrives at Inspector.jsx's eval-request handler.
|
|
57
|
+
const expressionJson = JSON.stringify(expression);
|
|
58
|
+
const result = await opened.page.evaluate(
|
|
59
|
+
({ id, exprJson }) => {
|
|
60
|
+
const lowdefy = window.lowdefy;
|
|
61
|
+
const pageContext = lowdefy?.contexts?.[`page:${id}`];
|
|
62
|
+
if (!pageContext) {
|
|
63
|
+
return { error: `No live context for page "${id}".` };
|
|
64
|
+
}
|
|
65
|
+
const input = JSON.parse(exprJson);
|
|
66
|
+
const { output, errors } = pageContext._internal.parser.parse({
|
|
67
|
+
input,
|
|
68
|
+
location: 'agent_eval',
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
value: output === undefined ? undefined : JSON.parse(JSON.stringify(output)),
|
|
72
|
+
errors: errors.map((error) => error.message),
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
{ id: pageId, exprJson: expressionJson }
|
|
76
|
+
);
|
|
77
|
+
return result;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return { error: `Failed to evaluate operator at "${url}": ${error.message}` };
|
|
80
|
+
} finally {
|
|
81
|
+
if (context) {
|
|
82
|
+
await context.close();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export default evalOperatorHeadless;
|
|
@@ -0,0 +1,72 @@
|
|
|
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 fs from 'fs';
|
|
18
|
+
import os from 'os';
|
|
19
|
+
import path from 'path';
|
|
20
|
+
import { jest } from '@jest/globals';
|
|
21
|
+
|
|
22
|
+
// A real headless evaluation needs an installed browser, which jest can't
|
|
23
|
+
// rely on, so this only covers the error paths: bad input, and
|
|
24
|
+
// chromium.launch failing (the "no browser installed" case). Mirrors
|
|
25
|
+
// screenshotPage.test.mjs, which exercises the same getBrowser.js singleton.
|
|
26
|
+
jest.unstable_mockModule('playwright-core', () => ({
|
|
27
|
+
chromium: { launch: jest.fn().mockRejectedValue(new Error("Executable doesn't exist")) },
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
// lib/build/config.js reads build/config.json from process.cwd() at import
|
|
31
|
+
// time — chdir into a fixture that has one before getBrowser.js (which
|
|
32
|
+
// imports it transitively) is loaded. Mirrors the pattern in
|
|
33
|
+
// screenshotPage.test.mjs.
|
|
34
|
+
const originalCwd = process.cwd();
|
|
35
|
+
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lowdefy-eval-operator-test-'));
|
|
36
|
+
fs.mkdirSync(path.join(fixtureDir, 'build'), { recursive: true });
|
|
37
|
+
fs.writeFileSync(path.join(fixtureDir, 'build', 'config.json'), JSON.stringify({ basePath: '' }));
|
|
38
|
+
process.chdir(fixtureDir);
|
|
39
|
+
|
|
40
|
+
const { default: evalOperatorHeadless } = await import('./evalOperatorHeadless.js');
|
|
41
|
+
|
|
42
|
+
afterAll(() => {
|
|
43
|
+
process.chdir(originalCwd);
|
|
44
|
+
fs.rmSync(fixtureDir, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('evalOperatorHeadless returns an error when origin is missing', async () => {
|
|
48
|
+
const result = await evalOperatorHeadless({ pageId: 'home', expression: { _state: 'x' } });
|
|
49
|
+
expect(result.error).toMatch(/requires an "origin" string/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('evalOperatorHeadless returns an error when pageId is missing', async () => {
|
|
53
|
+
const result = await evalOperatorHeadless({
|
|
54
|
+
origin: 'http://localhost:3001',
|
|
55
|
+
expression: { _state: 'x' },
|
|
56
|
+
});
|
|
57
|
+
expect(result.error).toMatch(/requires a "pageId" string/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('evalOperatorHeadless returns an error when expression is missing', async () => {
|
|
61
|
+
const result = await evalOperatorHeadless({ origin: 'http://localhost:3001', pageId: 'home' });
|
|
62
|
+
expect(result.error).toMatch(/requires an "expression"/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('evalOperatorHeadless returns an actionable error when no browser is available', async () => {
|
|
66
|
+
const result = await evalOperatorHeadless({
|
|
67
|
+
origin: 'http://localhost:3001',
|
|
68
|
+
pageId: 'home',
|
|
69
|
+
expression: { _state: 'x' },
|
|
70
|
+
});
|
|
71
|
+
expect(result.error).toMatch(/No Chromium available. Run: npx playwright install chromium/);
|
|
72
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
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 { type, serializer } from '@lowdefy/helpers';
|
|
18
|
+
|
|
19
|
+
import { requestFromTab } from './tabChannel.js';
|
|
20
|
+
|
|
21
|
+
// Evaluates an operator expression against the live client state of a page
|
|
22
|
+
// open in a developer's real browser tab (e.g. `{ _state: 'myInput' }`),
|
|
23
|
+
// using the page's own WebParser instance so results match runtime exactly.
|
|
24
|
+
//
|
|
25
|
+
// requestFromTab resolves directly with whatever the tab posted as `result`
|
|
26
|
+
// (see tabChannel.js) — `{ value, errors }` on success, or an `{ error }`
|
|
27
|
+
// object if no tab is connected, the request timed out, or Inspector.jsx
|
|
28
|
+
// itself failed to evaluate the expression.
|
|
29
|
+
async function evalOperatorInTab({ pageId, expression }) {
|
|
30
|
+
if (type.isNone(expression)) {
|
|
31
|
+
throw new Error('evalOperatorInTab requires an "expression".');
|
|
32
|
+
}
|
|
33
|
+
const response = await requestFromTab({
|
|
34
|
+
pageId,
|
|
35
|
+
event: 'eval-request',
|
|
36
|
+
payload: { expression },
|
|
37
|
+
});
|
|
38
|
+
if (response?.error) {
|
|
39
|
+
return { error: response.error };
|
|
40
|
+
}
|
|
41
|
+
const { value, errors } = response;
|
|
42
|
+
return {
|
|
43
|
+
value: type.isUndefined(value) ? undefined : serializer.deserializeFromString(value),
|
|
44
|
+
errors,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default evalOperatorInTab;
|
|
@@ -0,0 +1,260 @@
|
|
|
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 path from 'node:path';
|
|
18
|
+
import { resolveConfigLocation } from '@lowdefy/errors';
|
|
19
|
+
import { type } from '@lowdefy/helpers';
|
|
20
|
+
|
|
21
|
+
import buildPageIfNeeded from '../server/jitPageBuilder.js';
|
|
22
|
+
import readBuildArtifact from './readBuildArtifact.js';
|
|
23
|
+
|
|
24
|
+
const MAX_MATCHES = 20;
|
|
25
|
+
|
|
26
|
+
function escapeRegExp(value) {
|
|
27
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Array segments in keyMap `key` paths look like `[0:home]` or
|
|
31
|
+
// `[2:my_button:Button]` (see packages/build/src/build/addKeys.js recArray) —
|
|
32
|
+
// match an id at either position.
|
|
33
|
+
function buildIdPattern(id) {
|
|
34
|
+
return new RegExp(`\\[\\d+:${escapeRegExp(id)}(:[^\\]]*)?\\]`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The JIT page build runs addKeys on the page object itself (see
|
|
38
|
+
// packages/build/src/build/jit/buildPageJit.js), so a built page's keyMap
|
|
39
|
+
// subtree is keyed `root`, `root.blocks[...]` — the key path carries NO page
|
|
40
|
+
// segment, and every built page looks identical by prefix. The page a keyMap
|
|
41
|
+
// entry belongs to is only recoverable structurally: walk ~k_parent up to the
|
|
42
|
+
// subtree root and compare against the page's own root. The page's root is
|
|
43
|
+
// found from its build artifact — block objects in pages/<pageId>.json keep
|
|
44
|
+
// their ~k, and any of them chains up to the page root. Skeleton-style keys
|
|
45
|
+
// (`root.pages[N:pageId]...`) carry the page segment inline, so a containment
|
|
46
|
+
// pattern covers those.
|
|
47
|
+
function buildPageScope({ pageId, keyMap }) {
|
|
48
|
+
let rootKeyId = findPageRootKeyId({ pageId, keyMap });
|
|
49
|
+
// A page built during the skeleton build (e.g. the default 404 page) chains
|
|
50
|
+
// to the global config root, which every skeleton entry shares — useless as
|
|
51
|
+
// a page discriminator. Those pages' keys carry the page segment inline, so
|
|
52
|
+
// the pattern branch identifies them instead.
|
|
53
|
+
if (!type.isNone(rootKeyId) && rootKeyId === findGlobalRootId(keyMap)) {
|
|
54
|
+
rootKeyId = null;
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
rootKeyId,
|
|
58
|
+
pagePattern: new RegExp(`\\bpages\\[\\d+:${escapeRegExp(pageId)}(:[^\\]]*)?\\]`),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function findGlobalRootId(keyMap) {
|
|
63
|
+
for (const [keyId, entry] of Object.entries(keyMap)) {
|
|
64
|
+
if (entry?.key === 'root.pages') {
|
|
65
|
+
return resolveChainRootId({ keyId, keyMap });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function findPageRootKeyId({ pageId, keyMap }) {
|
|
72
|
+
const artifact = readBuildArtifact({ name: `pages/${pageId}.json`, deserialize: true });
|
|
73
|
+
const keyId = findFirstKeyId(artifact);
|
|
74
|
+
if (type.isNone(keyId)) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return resolveChainRootId({ keyId, keyMap });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function findFirstKeyId(node) {
|
|
81
|
+
if (type.isString(node?.['~k'])) {
|
|
82
|
+
return node['~k'];
|
|
83
|
+
}
|
|
84
|
+
if (type.isArray(node)) {
|
|
85
|
+
for (const item of node) {
|
|
86
|
+
const keyId = findFirstKeyId(item);
|
|
87
|
+
if (keyId) {
|
|
88
|
+
return keyId;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
if (!type.isObject(node)) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
for (const key of Object.keys(node)) {
|
|
97
|
+
const keyId = findFirstKeyId(node[key]);
|
|
98
|
+
if (keyId) {
|
|
99
|
+
return keyId;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Walk ~k_parent to the top of the entry's subtree. addKeys assigns the tree
|
|
106
|
+
// root a parent id that is never written to keyMap, so "parent not in keyMap"
|
|
107
|
+
// is the root. The seen-set guards against a corrupt artifact cycling forever.
|
|
108
|
+
function resolveChainRootId({ keyId, keyMap }) {
|
|
109
|
+
let currentId = keyId;
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
while (!seen.has(currentId)) {
|
|
112
|
+
seen.add(currentId);
|
|
113
|
+
const parentId = keyMap[currentId]?.['~k_parent'];
|
|
114
|
+
if (type.isNone(parentId) || type.isUndefined(keyMap[parentId])) {
|
|
115
|
+
return currentId;
|
|
116
|
+
}
|
|
117
|
+
currentId = parentId;
|
|
118
|
+
}
|
|
119
|
+
return currentId;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function belongsToPage({ keyId, entry, keyMap, pageScope }) {
|
|
123
|
+
if (pageScope.pagePattern.test(entry.key)) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
if (type.isNone(pageScope.rootKeyId)) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
return resolveChainRootId({ keyId, keyMap }) === pageScope.rootKeyId;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function scanKeyMap({ id, keyMap, refMap, configDirectory, pageScope }) {
|
|
133
|
+
const pattern = buildIdPattern(id);
|
|
134
|
+
const matches = [];
|
|
135
|
+
for (const [keyId, entry] of Object.entries(keyMap)) {
|
|
136
|
+
if (matches.length >= MAX_MATCHES) {
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
if (!type.isString(entry?.key)) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (!pattern.test(entry.key)) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (pageScope && !belongsToPage({ keyId, entry, keyMap, pageScope })) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
matches.push({
|
|
149
|
+
keyPath: entry.key,
|
|
150
|
+
location: resolveConfigLocation({ configKey: keyId, keyMap, refMap, configDirectory }),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return matches;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// List item blocks render with array indices applied to their ids
|
|
157
|
+
// (`my_list.0.name`) while config — and therefore keyMap — holds the `$`
|
|
158
|
+
// placeholder form (`my_list.$.name`, see applyArrayIndices in
|
|
159
|
+
// @lowdefy/helpers). Fold numeric segments back to `$` so a runtime id from
|
|
160
|
+
// inside a list resolves to the yaml that defines the item block instead of
|
|
161
|
+
// missing and falling back to an ancestor (usually the list itself).
|
|
162
|
+
function deIndexId(id) {
|
|
163
|
+
return id.replace(/\.\d+(?=\.|$)/g, () => '.$');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function scanKeyMapWithDeIndex({ id, keyMap, refMap, configDirectory, pageScope }) {
|
|
167
|
+
const matches = scanKeyMap({ id, keyMap, refMap, configDirectory, pageScope });
|
|
168
|
+
if (matches.length > 0) {
|
|
169
|
+
return matches;
|
|
170
|
+
}
|
|
171
|
+
const deIndexedId = deIndexId(id);
|
|
172
|
+
if (deIndexedId === id) {
|
|
173
|
+
return matches;
|
|
174
|
+
}
|
|
175
|
+
return scanKeyMap({ id: deIndexedId, keyMap, refMap, configDirectory, pageScope });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Locates a block/request/connection/etc by id, or a page by pageId. Block
|
|
179
|
+
// content only exists in keyMap once its page has been JIT-built (see
|
|
180
|
+
// lib/server/jitPageBuilder.js), so pass `pageId` to force-build and scan a
|
|
181
|
+
// specific page. Never throws on not-found — agents should be able to treat
|
|
182
|
+
// "no matches" as a normal result and try another id or pageId.
|
|
183
|
+
async function findConfig({ id, pageId }) {
|
|
184
|
+
if (type.isNone(id) || !type.isString(id)) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`findConfig requires an "id" string. Received ${JSON.stringify(id)}. ` +
|
|
187
|
+
'Use GET /lowdefy-docs/find/:id.'
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const pageRegistry = readBuildArtifact({ name: 'pageRegistry.json' }) ?? {};
|
|
192
|
+
const configDirectory = process.env.LOWDEFY_DIRECTORY_CONFIG || process.cwd();
|
|
193
|
+
|
|
194
|
+
// A page's root block renders with blockId === pageId, so open-in-editor
|
|
195
|
+
// and feedback enrichment hit this branch for it — they read
|
|
196
|
+
// matches[0].location.source, so the page result must carry a match, not
|
|
197
|
+
// just the file field the docs/MCP consumers use.
|
|
198
|
+
if (pageRegistry[id]) {
|
|
199
|
+
const refPath = pageRegistry[id].refPath;
|
|
200
|
+
const matches = [];
|
|
201
|
+
if (type.isString(refPath)) {
|
|
202
|
+
matches.push({
|
|
203
|
+
keyPath: `pages[${id}]`,
|
|
204
|
+
location: { source: path.resolve(configDirectory, refPath) },
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return { kind: 'page', pageId: id, file: refPath, matches };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!type.isNone(pageId)) {
|
|
211
|
+
if (!pageRegistry[pageId]) {
|
|
212
|
+
return {
|
|
213
|
+
matches: [],
|
|
214
|
+
note:
|
|
215
|
+
`Unknown pageId "${pageId}". See GET /lowdefy-docs/find/${id} without ?pageId= ` +
|
|
216
|
+
'to scan all already-built pages, or check the pageId spelling.',
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const buildDirectory = path.join(process.cwd(), 'build');
|
|
221
|
+
try {
|
|
222
|
+
await buildPageIfNeeded({ pageId, buildDirectory, configDirectory });
|
|
223
|
+
} catch (error) {
|
|
224
|
+
return {
|
|
225
|
+
matches: [],
|
|
226
|
+
note: `Page "${pageId}" failed to build, so its config could not be scanned: ${error.message}`,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const keyMap = readBuildArtifact({ name: 'keyMap.json' }) ?? {};
|
|
231
|
+
const refMap = readBuildArtifact({ name: 'refMap.json' }) ?? {};
|
|
232
|
+
const matches = scanKeyMapWithDeIndex({
|
|
233
|
+
id,
|
|
234
|
+
keyMap,
|
|
235
|
+
refMap,
|
|
236
|
+
configDirectory,
|
|
237
|
+
pageScope: buildPageScope({ pageId, keyMap }),
|
|
238
|
+
});
|
|
239
|
+
return matches.length > 0
|
|
240
|
+
? { matches }
|
|
241
|
+
: {
|
|
242
|
+
matches,
|
|
243
|
+
note:
|
|
244
|
+
`No config found with id "${id}" on page "${pageId}". Retry without ?pageId= to ` +
|
|
245
|
+
'scan all built pages and app-level config (connections, menus, api).',
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const keyMap = readBuildArtifact({ name: 'keyMap.json' }) ?? {};
|
|
250
|
+
const refMap = readBuildArtifact({ name: 'refMap.json' }) ?? {};
|
|
251
|
+
const matches = scanKeyMapWithDeIndex({ id, keyMap, refMap, configDirectory });
|
|
252
|
+
return {
|
|
253
|
+
matches,
|
|
254
|
+
note:
|
|
255
|
+
'Scanned without a pageId — content on pages that have not been JIT-built yet is not ' +
|
|
256
|
+
'in keyMap and was not covered. Pass ?pageId= to force-build and scan a specific page.',
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export default findConfig;
|