@lowdefy/server-dev 5.6.0 → 6.0.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.
Files changed (214) hide show
  1. package/{pages/_app.js → client/App.jsx} +21 -33
  2. package/client/DevStreamContext.js +30 -0
  3. package/client/Inspector.jsx +226 -0
  4. package/{lib/client/Page.js → client/Page.jsx} +4 -8
  5. package/client/Reload.jsx +85 -0
  6. package/client/Routing.jsx +117 -0
  7. package/client/feedback/FeedbackMount.jsx +83 -0
  8. package/client/feedback/FeedbackOverlay.jsx +901 -0
  9. package/client/feedback/captureTabScreenshot.js +84 -0
  10. package/client/feedback/copyToClipboard.js +45 -0
  11. package/client/feedback/drawAnnotationsSvg.js +119 -0
  12. package/client/feedback/elementInspect.js +99 -0
  13. package/client/feedback/feedbackStyles.js +301 -0
  14. package/client/feedback/pinResponsiveImages.js +59 -0
  15. package/client/feedback/sendFeedback.js +39 -0
  16. package/client/feedback/useFeedbackToggle.js +70 -0
  17. package/client/main.jsx +42 -0
  18. package/client/openInEditor/OpenInEditorListener.jsx +268 -0
  19. package/client/openInEditor/findBlockLocation.js +51 -0
  20. package/lib/build/app.js +8 -1
  21. package/lib/build/appMeta.js +8 -1
  22. package/lib/build/auth.js +8 -1
  23. package/lib/build/config.js +8 -1
  24. package/lib/build/i18n.js +8 -1
  25. package/lib/build/logger.js +8 -1
  26. package/lib/build/theme.js +8 -1
  27. package/lib/client/auth/{Auth.js → Auth.jsx} +11 -2
  28. package/lib/client/auth/{AuthConfigured.js → AuthConfigured.jsx} +23 -10
  29. package/lib/client/setPageId.js +6 -5
  30. package/lib/client/utils/useMutateCache.js +14 -1
  31. package/lib/client/utils/usePageConfig.js +45 -43
  32. package/lib/client/utils/usePageConfig.test.mjs +62 -0
  33. package/lib/docs/captureAnnotatedScreenshot.js +88 -0
  34. package/lib/docs/checkpointPaths.js +91 -0
  35. package/lib/docs/checkpointStore.js +240 -0
  36. package/lib/docs/checkpointToMocks.js +65 -0
  37. package/lib/docs/clientErrorStore.js +36 -0
  38. package/lib/docs/configCheckpoints.test.mjs +156 -0
  39. package/lib/docs/createConfigCheckpoint.js +92 -0
  40. package/lib/docs/createDocsMcpServer.js +531 -0
  41. package/lib/docs/createDocsMcpServer.test.mjs +141 -0
  42. package/lib/docs/devMockRegistry.js +68 -0
  43. package/lib/docs/docs.test.mjs +448 -0
  44. package/lib/docs/enrichFeedback.js +71 -0
  45. package/lib/docs/evalOperator.js +47 -0
  46. package/lib/docs/evalOperator.test.mjs +157 -0
  47. package/lib/docs/evalOperatorHeadless.js +87 -0
  48. package/lib/docs/evalOperatorHeadless.test.mjs +72 -0
  49. package/lib/docs/evalOperatorInTab.js +48 -0
  50. package/lib/docs/findConfig.js +260 -0
  51. package/lib/docs/formatFeedback.js +121 -0
  52. package/lib/docs/formatFeedback.test.mjs +204 -0
  53. package/lib/docs/getAppMap.js +180 -0
  54. package/lib/docs/getBrowser.js +127 -0
  55. package/lib/docs/getBrowser.test.mjs +122 -0
  56. package/lib/docs/getBuildStatus.js +37 -0
  57. package/lib/docs/getCoreDoc.js +63 -0
  58. package/lib/docs/getDocsManifest.js +40 -0
  59. package/lib/docs/getExamples.js +58 -0
  60. package/lib/docs/getOverview.js +115 -0
  61. package/lib/docs/getPageConfig.js +53 -0
  62. package/lib/docs/getPluginDoc.js +55 -0
  63. package/lib/docs/getSchema.js +65 -0
  64. package/lib/docs/inspectState.js +47 -0
  65. package/lib/docs/inspectState.test.mjs +140 -0
  66. package/lib/docs/inspectStateFromTab.js +40 -0
  67. package/lib/docs/inspectStateHeadless.js +84 -0
  68. package/lib/docs/inspectStateHeadless.test.mjs +60 -0
  69. package/lib/docs/isWriteRequestsAllowed.js +50 -0
  70. package/lib/docs/listConfigCheckpoints.js +55 -0
  71. package/lib/docs/listPlugins.js +80 -0
  72. package/lib/docs/listTypes.js +105 -0
  73. package/lib/docs/loadState.js +152 -0
  74. package/lib/docs/mapPageBuildErrors.js +28 -0
  75. package/lib/docs/normalizeTypeKind.js +40 -0
  76. package/{pages/api/endpoints/[...endpointId].js → lib/docs/readBuildArtifact.js} +15 -12
  77. package/lib/docs/readPageArtifact.js +26 -0
  78. package/lib/docs/resolvePluginDir.js +49 -0
  79. package/lib/docs/resolveSource.js +48 -0
  80. package/lib/docs/resolveSource.test.mjs +100 -0
  81. package/lib/docs/revertConfigCheckpoint.js +73 -0
  82. package/lib/docs/runRequest.js +141 -0
  83. package/lib/docs/saveAnnotatedScreenshot.js +43 -0
  84. package/lib/docs/scaffoldPage.js +75 -0
  85. package/lib/docs/scaffoldPage.test.mjs +97 -0
  86. package/lib/docs/screenshotPage.js +110 -0
  87. package/lib/docs/screenshotPage.test.mjs +59 -0
  88. package/lib/docs/screenshotPageClip.test.mjs +102 -0
  89. package/lib/docs/searchDocs.js +67 -0
  90. package/lib/docs/setupTestFixtures.mjs +335 -0
  91. package/lib/docs/snapshotState.js +59 -0
  92. package/lib/docs/stateCheckpoints.test.mjs +366 -0
  93. package/lib/docs/tabAvailable.js +28 -0
  94. package/lib/docs/tabChannel.js +119 -0
  95. package/lib/docs/tabChannel.test.mjs +130 -0
  96. package/lib/server/auth/{getAuthOptions.js → getAuthConfig.js} +7 -4
  97. package/lib/server/auth/getDevSession.js +70 -0
  98. package/lib/server/auth/getDevSession.test.mjs +138 -0
  99. package/lib/server/auth/getHeadlessUser.js +40 -0
  100. package/lib/server/auth/{getMockSession.js → getMockUser.js} +8 -21
  101. package/lib/server/auth/headlessUser.js +29 -0
  102. package/lib/server/auth/resolveHeadlessUser.js +38 -0
  103. package/lib/server/auth/resolveHeadlessUser.test.mjs +78 -0
  104. package/lib/server/auth/session.js +39 -0
  105. package/lib/server/auth/strategies.js +47 -0
  106. package/lib/server/createLowdefyContext.js +122 -0
  107. package/lib/server/getPageJitEnrichment.test.mjs +105 -0
  108. package/lib/server/jitPageBuilder.js +125 -12
  109. package/lib/server/log/createHandleError.test.mjs +126 -0
  110. package/manager/getContext.mjs +3 -7
  111. package/manager/processes/initialBuild.mjs +0 -2
  112. package/manager/processes/installPlugins.mjs +19 -11
  113. package/manager/processes/lowdefyBuild.mjs +29 -7
  114. package/manager/processes/lowdefyBuild.test.mjs +102 -0
  115. package/manager/processes/readDotEnv.mjs +13 -1
  116. package/manager/processes/shutdownServer.mjs +6 -6
  117. package/manager/processes/startProxy.mjs +160 -0
  118. package/manager/processes/startProxy.test.mjs +102 -0
  119. package/manager/processes/startServer.mjs +33 -16
  120. package/manager/processes/startWatchers.mjs +2 -2
  121. package/manager/processes/warnAuthUrlPortMismatch.mjs +47 -0
  122. package/manager/processes/warnAuthUrlPortMismatch.test.mjs +93 -0
  123. package/manager/run.mjs +50 -0
  124. package/manager/utils/createCustomPluginMessagesMap.mjs +8 -1
  125. package/manager/utils/createCustomPluginTypesMap.mjs +1 -0
  126. package/manager/utils/formatNoticeBox.mjs +30 -0
  127. package/manager/utils/{getNextBin.mjs → getViteBin.mjs} +7 -10
  128. package/manager/utils/updatePageTailwindCss.mjs +7 -1
  129. package/manager/utils/writeBuildStatus.mjs +39 -0
  130. package/manager/utils/writeBuildStatus.test.mjs +76 -0
  131. package/manager/watchers/lowdefyBuildWatcher.mjs +0 -1
  132. package/manager/watchers/moduleBuildWatcher.mjs +0 -1
  133. package/manager/watchers/{nextBuildWatcher.mjs → serverArtifactWatcher.mjs} +18 -20
  134. package/package.json +61 -47
  135. package/package.original.json +64 -50
  136. package/postcss.config.cjs +1 -0
  137. package/src/app.js +175 -0
  138. package/src/html/renderDevPage.js +86 -0
  139. package/src/lib/getPathSegments.js +25 -0
  140. package/src/lib/safeScriptJson.js +38 -0
  141. package/src/middleware/apiContext.js +32 -0
  142. package/src/middleware/errorHandler.js +48 -0
  143. package/src/middleware/errorHandler.test.mjs +235 -0
  144. package/src/routes/agent.js +64 -0
  145. package/src/routes/apiPage.js +37 -0
  146. package/src/routes/auth.js +49 -0
  147. package/src/routes/auth.test.mjs +84 -0
  148. package/src/routes/clientError.js +57 -0
  149. package/src/routes/cron.js +47 -0
  150. package/src/routes/cronForward.js +47 -0
  151. package/src/routes/detached.js +47 -0
  152. package/src/routes/devInspect.js +111 -0
  153. package/src/routes/devInspect.test.mjs +119 -0
  154. package/{pages/[[...pageId]].js → src/routes/devTools.js} +4 -2
  155. package/{pages/404.js → src/routes/docs/appMap.js} +6 -2
  156. package/{pages/api/dev-tools.js → src/routes/docs/buildStatus.js} +5 -4
  157. package/src/routes/docs/checkpointsCreate.js +28 -0
  158. package/src/routes/docs/checkpointsList.js +23 -0
  159. package/{pages/api/icons/dynamic.js → src/routes/docs/checkpointsRevert.js} +11 -12
  160. package/src/routes/docs/content.js +31 -0
  161. package/src/routes/docs/evalOperator.js +56 -0
  162. package/src/routes/docs/examples.js +33 -0
  163. package/src/routes/docs/find.js +30 -0
  164. package/src/routes/docs/index.js +23 -0
  165. package/src/routes/docs/inspectState.js +44 -0
  166. package/src/routes/docs/loadState.js +49 -0
  167. package/src/routes/docs/mcp.js +30 -0
  168. package/src/routes/docs/pageConfig.js +36 -0
  169. package/src/routes/docs/parseUserParam.js +52 -0
  170. package/src/routes/docs/parseUserParam.test.mjs +45 -0
  171. package/src/routes/docs/pluginDoc.js +33 -0
  172. package/src/routes/docs/plugins.js +23 -0
  173. package/src/routes/docs/runRequest.js +36 -0
  174. package/{lib/server/auth/getServerSession.js → src/routes/docs/schema.js} +17 -15
  175. package/src/routes/docs/screenshot.js +72 -0
  176. package/src/routes/docs/search.js +27 -0
  177. package/src/routes/docs/snapshotState.js +36 -0
  178. package/src/routes/docs/stateCheckpointsList.js +26 -0
  179. package/src/routes/docs/types.js +33 -0
  180. package/src/routes/endpoints.js +71 -0
  181. package/src/routes/feedback.js +110 -0
  182. package/src/routes/feedback.test.mjs +204 -0
  183. package/src/routes/jitPage.js +86 -0
  184. package/src/routes/mcp.js +34 -0
  185. package/{pages/api → src/routes}/ping.js +4 -2
  186. package/src/routes/reload.js +79 -0
  187. package/src/routes/request.js +74 -0
  188. package/src/routes/request.test.mjs +124 -0
  189. package/{pages/api → src/routes}/root.js +4 -4
  190. package/src/routes/usage.js +46 -0
  191. package/src/routes/websocket.js +33 -0
  192. package/src/websocket/devWebSocket.js +78 -0
  193. package/vite.config.js +111 -0
  194. package/.eslintrc.yaml +0 -1
  195. package/lib/client/App.js +0 -80
  196. package/lib/client/Reload.js +0 -60
  197. package/lib/server/apiWrapper.js +0 -109
  198. package/lib/server/compileCss.js +0 -39
  199. package/manager/processes/compileCss.mjs +0 -54
  200. package/manager/processes/nextBuild.mjs +0 -50
  201. package/next.config.js +0 -25
  202. package/pages/_document.js +0 -109
  203. package/pages/api/agent/[...path].js +0 -86
  204. package/pages/api/auth/[...nextauth].js +0 -48
  205. package/pages/api/client-error.js +0 -45
  206. package/pages/api/js/[env].js +0 -42
  207. package/pages/api/page/[...pageId].js +0 -74
  208. package/pages/api/reload.js +0 -53
  209. package/pages/api/request/[...path].js +0 -41
  210. /package/lib/client/{BuildErrorPage.js → BuildErrorPage.jsx} +0 -0
  211. /package/lib/client/{BuildingPage.js → BuildingPage.jsx} +0 -0
  212. /package/lib/client/{ErrorBar.js → ErrorBar.jsx} +0 -0
  213. /package/lib/client/{InstallingPluginsPage.js → InstallingPluginsPage.jsx} +0 -0
  214. /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;