@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.
Files changed (218) 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/clientBuildImports.test.mjs +61 -0
  8. package/client/feedback/FeedbackMount.jsx +83 -0
  9. package/client/feedback/FeedbackOverlay.jsx +901 -0
  10. package/client/feedback/captureTabScreenshot.js +84 -0
  11. package/client/feedback/copyToClipboard.js +45 -0
  12. package/client/feedback/drawAnnotationsSvg.js +119 -0
  13. package/client/feedback/elementInspect.js +99 -0
  14. package/client/feedback/feedbackStyles.js +301 -0
  15. package/client/feedback/pinResponsiveImages.js +59 -0
  16. package/client/feedback/sendFeedback.js +39 -0
  17. package/client/feedback/useFeedbackToggle.js +70 -0
  18. package/client/main.jsx +42 -0
  19. package/client/openInEditor/OpenInEditorListener.jsx +268 -0
  20. package/client/openInEditor/findBlockLocation.js +51 -0
  21. package/lib/build/app.js +8 -1
  22. package/lib/build/appMeta.js +8 -1
  23. package/lib/build/auth.js +8 -1
  24. package/lib/build/config.js +8 -1
  25. package/lib/build/i18n.js +8 -1
  26. package/lib/build/logger.js +8 -1
  27. package/lib/build/theme.js +8 -1
  28. package/lib/client/auth/{Auth.js → Auth.jsx} +11 -2
  29. package/lib/client/auth/{AuthConfigured.js → AuthConfigured.jsx} +22 -10
  30. package/lib/client/setPageId.js +6 -5
  31. package/lib/client/utils/useMutateCache.js +14 -1
  32. package/lib/client/utils/usePageConfig.js +45 -43
  33. package/lib/client/utils/usePageConfig.test.mjs +62 -0
  34. package/lib/docs/captureAnnotatedScreenshot.js +88 -0
  35. package/lib/docs/checkpointPaths.js +91 -0
  36. package/lib/docs/checkpointStore.js +240 -0
  37. package/lib/docs/checkpointToMocks.js +65 -0
  38. package/lib/docs/clientErrorStore.js +36 -0
  39. package/lib/docs/configCheckpoints.test.mjs +156 -0
  40. package/lib/docs/createConfigCheckpoint.js +92 -0
  41. package/lib/docs/createDocsMcpServer.js +531 -0
  42. package/lib/docs/createDocsMcpServer.test.mjs +141 -0
  43. package/lib/docs/devMockRegistry.js +68 -0
  44. package/lib/docs/docs.test.mjs +448 -0
  45. package/lib/docs/enrichFeedback.js +71 -0
  46. package/lib/docs/evalOperator.js +47 -0
  47. package/lib/docs/evalOperator.test.mjs +157 -0
  48. package/lib/docs/evalOperatorHeadless.js +87 -0
  49. package/lib/docs/evalOperatorHeadless.test.mjs +72 -0
  50. package/lib/docs/evalOperatorInTab.js +48 -0
  51. package/lib/docs/findConfig.js +260 -0
  52. package/lib/docs/formatFeedback.js +121 -0
  53. package/lib/docs/formatFeedback.test.mjs +204 -0
  54. package/lib/docs/getAppMap.js +180 -0
  55. package/lib/docs/getBrowser.js +127 -0
  56. package/lib/docs/getBrowser.test.mjs +122 -0
  57. package/lib/docs/getBuildStatus.js +37 -0
  58. package/lib/docs/getCoreDoc.js +63 -0
  59. package/lib/docs/getDocsManifest.js +40 -0
  60. package/lib/docs/getExamples.js +58 -0
  61. package/lib/docs/getOverview.js +115 -0
  62. package/lib/docs/getPageConfig.js +53 -0
  63. package/lib/docs/getPluginDoc.js +55 -0
  64. package/lib/docs/getSchema.js +65 -0
  65. package/lib/docs/inspectState.js +47 -0
  66. package/lib/docs/inspectState.test.mjs +140 -0
  67. package/lib/docs/inspectStateFromTab.js +40 -0
  68. package/lib/docs/inspectStateHeadless.js +84 -0
  69. package/lib/docs/inspectStateHeadless.test.mjs +60 -0
  70. package/lib/docs/isWriteRequestsAllowed.js +50 -0
  71. package/lib/docs/listConfigCheckpoints.js +55 -0
  72. package/lib/docs/listPlugins.js +80 -0
  73. package/lib/docs/listTypes.js +105 -0
  74. package/lib/docs/loadState.js +152 -0
  75. package/lib/docs/mapPageBuildErrors.js +28 -0
  76. package/lib/docs/normalizeTypeKind.js +40 -0
  77. package/{pages/api/endpoints/[...endpointId].js → lib/docs/readBuildArtifact.js} +15 -12
  78. package/lib/docs/readPageArtifact.js +26 -0
  79. package/lib/docs/resolvePluginDir.js +49 -0
  80. package/lib/docs/resolveSource.js +48 -0
  81. package/lib/docs/resolveSource.test.mjs +100 -0
  82. package/lib/docs/revertConfigCheckpoint.js +73 -0
  83. package/lib/docs/runRequest.js +141 -0
  84. package/lib/docs/saveAnnotatedScreenshot.js +43 -0
  85. package/lib/docs/scaffoldPage.js +75 -0
  86. package/lib/docs/scaffoldPage.test.mjs +97 -0
  87. package/lib/docs/screenshotPage.js +110 -0
  88. package/lib/docs/screenshotPage.test.mjs +59 -0
  89. package/lib/docs/screenshotPageClip.test.mjs +102 -0
  90. package/lib/docs/searchDocs.js +67 -0
  91. package/lib/docs/setupTestFixtures.mjs +335 -0
  92. package/lib/docs/snapshotState.js +59 -0
  93. package/lib/docs/stateCheckpoints.test.mjs +366 -0
  94. package/lib/docs/tabAvailable.js +28 -0
  95. package/lib/docs/tabChannel.js +119 -0
  96. package/lib/docs/tabChannel.test.mjs +130 -0
  97. package/lib/server/auth/{getAuthOptions.js → getAuthConfig.js} +9 -4
  98. package/lib/server/auth/getDevSession.js +70 -0
  99. package/lib/server/auth/getDevSession.test.mjs +138 -0
  100. package/lib/server/auth/getHeadlessUser.js +40 -0
  101. package/lib/server/auth/{getMockSession.js → getMockUser.js} +8 -21
  102. package/lib/server/auth/headlessUser.js +29 -0
  103. package/lib/server/auth/resolveHeadlessUser.js +38 -0
  104. package/lib/server/auth/resolveHeadlessUser.test.mjs +78 -0
  105. package/lib/server/auth/session.js +39 -0
  106. package/lib/server/auth/strategies.js +47 -0
  107. package/lib/server/createLowdefyContext.js +128 -0
  108. package/lib/server/createLowdefyContext.test.mjs +117 -0
  109. package/lib/server/getPageJitEnrichment.test.mjs +105 -0
  110. package/lib/server/jitPageBuilder.js +125 -12
  111. package/lib/server/log/createHandleError.test.mjs +126 -0
  112. package/lib/server/log/createLogger.test.mjs +62 -0
  113. package/lib/server/scrubSecrets.js +23 -0
  114. package/manager/getContext.mjs +3 -7
  115. package/manager/processes/initialBuild.mjs +0 -2
  116. package/manager/processes/installPlugins.mjs +19 -11
  117. package/manager/processes/lowdefyBuild.mjs +29 -7
  118. package/manager/processes/lowdefyBuild.test.mjs +102 -0
  119. package/manager/processes/readDotEnv.mjs +13 -1
  120. package/manager/processes/shutdownServer.mjs +6 -6
  121. package/manager/processes/startProxy.mjs +160 -0
  122. package/manager/processes/startProxy.test.mjs +102 -0
  123. package/manager/processes/startServer.mjs +33 -16
  124. package/manager/processes/startWatchers.mjs +2 -2
  125. package/manager/processes/warnAuthUrlPortMismatch.mjs +47 -0
  126. package/manager/processes/warnAuthUrlPortMismatch.test.mjs +93 -0
  127. package/manager/run.mjs +50 -0
  128. package/manager/utils/createCustomPluginMessagesMap.mjs +8 -1
  129. package/manager/utils/createCustomPluginTypesMap.mjs +1 -0
  130. package/manager/utils/formatNoticeBox.mjs +30 -0
  131. package/manager/utils/{getNextBin.mjs → getViteBin.mjs} +7 -10
  132. package/manager/utils/updatePageTailwindCss.mjs +7 -1
  133. package/manager/utils/writeBuildStatus.mjs +39 -0
  134. package/manager/utils/writeBuildStatus.test.mjs +76 -0
  135. package/manager/watchers/lowdefyBuildWatcher.mjs +0 -1
  136. package/manager/watchers/moduleBuildWatcher.mjs +0 -1
  137. package/manager/watchers/{nextBuildWatcher.mjs → serverArtifactWatcher.mjs} +18 -20
  138. package/package.json +62 -49
  139. package/package.original.json +65 -52
  140. package/postcss.config.cjs +1 -0
  141. package/src/app.js +175 -0
  142. package/src/html/renderDevPage.js +86 -0
  143. package/src/lib/getPathSegments.js +25 -0
  144. package/src/lib/safeScriptJson.js +38 -0
  145. package/src/middleware/apiContext.js +32 -0
  146. package/src/middleware/errorHandler.js +48 -0
  147. package/src/middleware/errorHandler.test.mjs +247 -0
  148. package/src/routes/agent.js +64 -0
  149. package/src/routes/apiPage.js +37 -0
  150. package/src/routes/auth.js +49 -0
  151. package/src/routes/auth.test.mjs +84 -0
  152. package/src/routes/clientError.js +57 -0
  153. package/src/routes/cron.js +47 -0
  154. package/src/routes/cronForward.js +47 -0
  155. package/src/routes/detached.js +47 -0
  156. package/src/routes/devInspect.js +111 -0
  157. package/src/routes/devInspect.test.mjs +119 -0
  158. package/{pages/[[...pageId]].js → src/routes/devTools.js} +4 -2
  159. package/{pages/404.js → src/routes/docs/appMap.js} +6 -2
  160. package/{pages/api/dev-tools.js → src/routes/docs/buildStatus.js} +5 -4
  161. package/src/routes/docs/checkpointsCreate.js +28 -0
  162. package/src/routes/docs/checkpointsList.js +23 -0
  163. package/{pages/api/icons/dynamic.js → src/routes/docs/checkpointsRevert.js} +11 -12
  164. package/src/routes/docs/content.js +31 -0
  165. package/src/routes/docs/evalOperator.js +56 -0
  166. package/src/routes/docs/examples.js +33 -0
  167. package/src/routes/docs/find.js +30 -0
  168. package/src/routes/docs/index.js +23 -0
  169. package/src/routes/docs/inspectState.js +44 -0
  170. package/src/routes/docs/loadState.js +49 -0
  171. package/src/routes/docs/mcp.js +30 -0
  172. package/src/routes/docs/pageConfig.js +36 -0
  173. package/src/routes/docs/parseUserParam.js +52 -0
  174. package/src/routes/docs/parseUserParam.test.mjs +45 -0
  175. package/src/routes/docs/pluginDoc.js +33 -0
  176. package/src/routes/docs/plugins.js +23 -0
  177. package/src/routes/docs/runRequest.js +36 -0
  178. package/{lib/server/auth/getServerSession.js → src/routes/docs/schema.js} +17 -15
  179. package/src/routes/docs/screenshot.js +72 -0
  180. package/src/routes/docs/search.js +27 -0
  181. package/src/routes/docs/snapshotState.js +36 -0
  182. package/src/routes/docs/stateCheckpointsList.js +26 -0
  183. package/src/routes/docs/types.js +33 -0
  184. package/src/routes/endpoints.js +71 -0
  185. package/src/routes/feedback.js +110 -0
  186. package/src/routes/feedback.test.mjs +204 -0
  187. package/src/routes/jitPage.js +86 -0
  188. package/src/routes/mcp.js +34 -0
  189. package/{pages/api → src/routes}/ping.js +4 -2
  190. package/src/routes/reload.js +79 -0
  191. package/src/routes/request.js +74 -0
  192. package/src/routes/request.test.mjs +124 -0
  193. package/{pages/api → src/routes}/root.js +4 -4
  194. package/src/routes/usage.js +46 -0
  195. package/src/routes/websocket.js +33 -0
  196. package/src/websocket/devWebSocket.js +78 -0
  197. package/vite.config.js +111 -0
  198. package/.eslintrc.yaml +0 -1
  199. package/lib/client/App.js +0 -80
  200. package/lib/client/Reload.js +0 -60
  201. package/lib/server/apiWrapper.js +0 -109
  202. package/lib/server/compileCss.js +0 -39
  203. package/manager/processes/compileCss.mjs +0 -54
  204. package/manager/processes/nextBuild.mjs +0 -50
  205. package/next.config.js +0 -25
  206. package/pages/_document.js +0 -109
  207. package/pages/api/agent/[...path].js +0 -86
  208. package/pages/api/auth/[...nextauth].js +0 -48
  209. package/pages/api/client-error.js +0 -45
  210. package/pages/api/js/[env].js +0 -42
  211. package/pages/api/page/[...pageId].js +0 -74
  212. package/pages/api/reload.js +0 -53
  213. package/pages/api/request/[...path].js +0 -41
  214. /package/lib/client/{BuildErrorPage.js → BuildErrorPage.jsx} +0 -0
  215. /package/lib/client/{BuildingPage.js → BuildingPage.jsx} +0 -0
  216. /package/lib/client/{ErrorBar.js → ErrorBar.jsx} +0 -0
  217. /package/lib/client/{InstallingPluginsPage.js → InstallingPluginsPage.jsx} +0 -0
  218. /package/lib/client/{RestartingPage.js → RestartingPage.jsx} +0 -0
@@ -0,0 +1,121 @@
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
+ // Turns one or more enriched feedback batches (after enrichFeedback.js has
20
+ // attached `annotation.location`) into a single agent-readable text block.
21
+ // The overlay copies this text to the developer's clipboard — they paste it
22
+ // into their agent session. Kept as a pure string formatter — no
23
+ // build-artifact access.
24
+ function formatFeedback({ items }) {
25
+ if (type.isNone(items) || items.length === 0) {
26
+ return 'No pending feedback. The developer has not submitted any annotations yet.';
27
+ }
28
+ return items.map(formatBatch).join('\n\n---\n\n');
29
+ }
30
+
31
+ function formatBatch(batch) {
32
+ const viewport = batch.viewport ?? {};
33
+ const lines = [
34
+ `Feedback: ${batch.annotations?.length ?? 0} annotation(s) on page "${batch.pageId}" ` +
35
+ `(${batch.url ?? 'unknown url'}) — viewport ${viewport.width}x${viewport.height} ` +
36
+ `@${viewport.dpr ?? 1}x, scrollY ${viewport.scrollY ?? 0}`,
37
+ '',
38
+ ];
39
+
40
+ (batch.annotations ?? []).forEach((annotation, index) => {
41
+ lines.push(formatAnnotation({ annotation, index }));
42
+ });
43
+
44
+ if (batch.screenshotPath) {
45
+ lines.push('');
46
+ lines.push(`Annotated screenshot: ${batch.screenshotPath} (read this image to see the drawings)`);
47
+ }
48
+
49
+ lines.push('');
50
+ lines.push(
51
+ `For the page's live state call lowdefy_inspect_state({ pageId: "${batch.pageId}" }).`
52
+ );
53
+
54
+ return lines.join('\n');
55
+ }
56
+
57
+ function formatAnnotation({ annotation, index }) {
58
+ const lines = [];
59
+ const num = index + 1;
60
+ const locationText = formatLocation(annotation.location);
61
+
62
+ if (annotation.kind === 'element' && annotation.target) {
63
+ const header = locationText
64
+ ? `${num}. Element "${annotation.target.blockId}" (${locationText})`
65
+ : `${num}. Element "${annotation.target.blockId}"`;
66
+ lines.push(header);
67
+ if (annotation.target.ancestorBlockIds?.length > 0) {
68
+ lines.push(` Ancestors: ${annotation.target.ancestorBlockIds.join(' > ')}`);
69
+ }
70
+ if (annotation.target.tag) {
71
+ const text = annotation.target.text ? ` "${annotation.target.text}"` : '';
72
+ lines.push(` Tag: ${annotation.target.tag}${text}`);
73
+ }
74
+ } else {
75
+ lines.push(`${num}. Region`);
76
+ }
77
+
78
+ lines.push(` Comment: ${annotation.comment}`);
79
+
80
+ const shapesSummary = summarizeShapes({
81
+ shapes: annotation.geometry?.shapes,
82
+ hasElementRect: !type.isNone(annotation.geometry?.elementRect),
83
+ });
84
+ if (shapesSummary) {
85
+ lines.push(` Shapes: ${shapesSummary}`);
86
+ }
87
+
88
+ return lines.join('\n');
89
+ }
90
+
91
+ function formatLocation(location) {
92
+ if (type.isNone(location)) {
93
+ return null;
94
+ }
95
+ if (location.source && location.resolvedVia) {
96
+ return `generated at runtime — defined via ancestor "${location.resolvedVia}": ${location.source}`;
97
+ }
98
+ return location.source ?? location.note ?? null;
99
+ }
100
+
101
+ // Counts shapes by type (rect/arrow/freehand) preserving first-seen order,
102
+ // e.g. "1 rect around the element, 1 arrow" — the first counted type is
103
+ // attributed to the targeted element when the annotation carries an
104
+ // elementRect, since that is the shape drawn to select it.
105
+ function summarizeShapes({ shapes, hasElementRect }) {
106
+ if (!shapes || shapes.length === 0) {
107
+ return null;
108
+ }
109
+ const counts = new Map();
110
+ shapes.forEach((shape) => {
111
+ counts.set(shape.type, (counts.get(shape.type) ?? 0) + 1);
112
+ });
113
+ return Array.from(counts.entries())
114
+ .map(([shapeType, shapeCount], index) => {
115
+ const label = `${shapeCount} ${shapeType}${shapeCount > 1 ? 's' : ''}`;
116
+ return index === 0 && hasElementRect ? `${label} around the element` : label;
117
+ })
118
+ .join(', ');
119
+ }
120
+
121
+ export default formatFeedback;
@@ -0,0 +1,204 @@
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
+ const mockFindConfig = jest.fn();
20
+ jest.unstable_mockModule('./findConfig.js', () => ({
21
+ default: mockFindConfig,
22
+ }));
23
+
24
+ const { default: enrichFeedback } = await import('./enrichFeedback.js');
25
+ const { default: formatFeedback } = await import('./formatFeedback.js');
26
+
27
+ beforeEach(() => {
28
+ mockFindConfig.mockReset();
29
+ });
30
+
31
+ // --- enrichFeedback ---
32
+
33
+ test('enrichFeedback attaches a location to annotations with a target blockId', async () => {
34
+ mockFindConfig.mockResolvedValue({
35
+ matches: [
36
+ {
37
+ keyPath: 'root.pages[0:login].blocks[2:submit_button:Button]',
38
+ location: {
39
+ source: 'pages/login.yaml:12',
40
+ config: 'root.pages[0:login].blocks[2:submit_button:Button]',
41
+ },
42
+ },
43
+ ],
44
+ });
45
+ const batch = {
46
+ pageId: 'login',
47
+ annotations: [
48
+ {
49
+ id: '1',
50
+ kind: 'element',
51
+ comment: 'too small',
52
+ target: {
53
+ blockId: 'submit_button',
54
+ ancestorBlockIds: ['submit_button', 'login_form'],
55
+ tag: 'BUTTON',
56
+ text: 'Submit',
57
+ },
58
+ geometry: { elementRect: { x: 220, y: 480, width: 96, height: 40 }, shapes: [] },
59
+ },
60
+ ],
61
+ };
62
+
63
+ const enriched = await enrichFeedback({ batch });
64
+
65
+ expect(mockFindConfig).toHaveBeenCalledWith({ id: 'submit_button', pageId: 'login' });
66
+ expect(enriched.annotations[0].location).toEqual({
67
+ source: 'pages/login.yaml:12',
68
+ config: 'root.pages[0:login].blocks[2:submit_button:Button]',
69
+ });
70
+ });
71
+
72
+ test('enrichFeedback attaches a note when findConfig finds no matches', async () => {
73
+ mockFindConfig.mockResolvedValue({
74
+ matches: [],
75
+ note: 'Block is generated at runtime — no configured ancestor found on page "login".',
76
+ });
77
+ const batch = {
78
+ pageId: 'login',
79
+ annotations: [{ id: '1', kind: 'element', comment: 'x', target: { blockId: 'missing' } }],
80
+ };
81
+
82
+ const enriched = await enrichFeedback({ batch });
83
+
84
+ expect(enriched.annotations[0].location).toEqual({
85
+ note: 'Block is generated at runtime — no configured ancestor found on page "login".',
86
+ });
87
+ });
88
+
89
+ test('enrichFeedback leaves annotations without a target blockId unchanged', async () => {
90
+ const batch = {
91
+ pageId: 'login',
92
+ annotations: [{ id: '1', kind: 'region', comment: 'x', target: null }],
93
+ };
94
+
95
+ const enriched = await enrichFeedback({ batch });
96
+
97
+ expect(mockFindConfig).not.toHaveBeenCalled();
98
+ expect(enriched.annotations[0]).toEqual({ id: '1', kind: 'region', comment: 'x', target: null });
99
+ });
100
+
101
+ test('enrichFeedback still returns the batch when findConfig throws', async () => {
102
+ mockFindConfig.mockRejectedValue(new Error('boom'));
103
+ const batch = {
104
+ pageId: 'login',
105
+ annotations: [{ id: '1', kind: 'element', comment: 'x', target: { blockId: 'submit_button' } }],
106
+ };
107
+
108
+ const enriched = await enrichFeedback({ batch });
109
+
110
+ expect(enriched.annotations).toHaveLength(1);
111
+ expect(enriched.annotations[0].location.note).toMatch(/Failed to resolve location: boom/);
112
+ });
113
+
114
+ // --- formatFeedback ---
115
+
116
+ test('formatFeedback returns a message when there is no pending feedback', () => {
117
+ expect(formatFeedback({ items: [] })).toMatch(/No pending feedback/);
118
+ });
119
+
120
+ test('formatFeedback formats an element annotation with location, ancestors, comment, shapes, and screenshot', () => {
121
+ const items = [
122
+ {
123
+ pageId: 'login',
124
+ url: '/login?x=1',
125
+ viewport: { width: 1280, height: 720, scrollX: 0, scrollY: 340, dpr: 2 },
126
+ annotations: [
127
+ {
128
+ id: '1',
129
+ kind: 'element',
130
+ comment: 'Button is too small on mobile',
131
+ target: {
132
+ blockId: 'submit_button',
133
+ ancestorBlockIds: ['submit_button', 'login_form'],
134
+ tag: 'BUTTON',
135
+ text: 'Submit',
136
+ },
137
+ geometry: {
138
+ elementRect: { x: 220, y: 480, width: 96, height: 40 },
139
+ shapes: [
140
+ { type: 'rect', points: [] },
141
+ { type: 'arrow', points: [] },
142
+ ],
143
+ },
144
+ location: {
145
+ source: 'pages/login.yaml:12',
146
+ config: 'root.pages[0:login].blocks[2:submit_button:Button]',
147
+ },
148
+ },
149
+ ],
150
+ screenshotPath: '.lowdefy/annotations/login-test.png',
151
+ },
152
+ ];
153
+
154
+ const text = formatFeedback({ items });
155
+
156
+ expect(text).toContain('1 annotation(s) on page "login"');
157
+ expect(text).toContain('viewport 1280x720 @2x, scrollY 340');
158
+ expect(text).toContain('Element "submit_button" (pages/login.yaml:12)');
159
+ expect(text).toContain('Ancestors: submit_button > login_form');
160
+ expect(text).toContain('Comment: Button is too small on mobile');
161
+ expect(text).toContain('1 rect around the element, 1 arrow');
162
+ expect(text).toContain('Annotated screenshot: .lowdefy/annotations/login-test.png');
163
+ expect(text).toContain('lowdefy_inspect_state({ pageId: "login" })');
164
+ expect(text).not.toContain('Console');
165
+ });
166
+
167
+ test('formatFeedback formats a region annotation without a target', () => {
168
+ const items = [
169
+ {
170
+ pageId: 'home',
171
+ url: '/home',
172
+ viewport: { width: 1280, height: 720 },
173
+ annotations: [
174
+ {
175
+ id: '1',
176
+ kind: 'region',
177
+ comment: 'Layout looks off here',
178
+ target: null,
179
+ geometry: { elementRect: null, shapes: [{ type: 'freehand', points: [] }] },
180
+ },
181
+ ],
182
+ },
183
+ ];
184
+
185
+ const text = formatFeedback({ items });
186
+
187
+ expect(text).toContain('1. Region');
188
+ expect(text).toContain('Comment: Layout looks off here');
189
+ expect(text).toContain('1 freehand');
190
+ });
191
+
192
+ test('formatFeedback multiple batches are joined and each keeps its own pageId in instructions', () => {
193
+ const items = [
194
+ { pageId: 'login', url: '/login', viewport: { width: 100, height: 100 }, annotations: [] },
195
+ { pageId: 'home', url: '/home', viewport: { width: 100, height: 100 }, annotations: [] },
196
+ ];
197
+
198
+ const text = formatFeedback({ items });
199
+
200
+ expect(text).toContain('page "login"');
201
+ expect(text).toContain('page "home"');
202
+ expect(text).toContain('lowdefy_inspect_state({ pageId: "login" })');
203
+ expect(text).toContain('lowdefy_inspect_state({ pageId: "home" })');
204
+ });
@@ -0,0 +1,180 @@
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 'node:fs';
18
+ import path from 'node:path';
19
+ import { type } from '@lowdefy/helpers';
20
+
21
+ import readBuildArtifact from './readBuildArtifact.js';
22
+
23
+ // Recursively counts blocks on a page, including blocks nested in slots
24
+ // (packages/build/src/build/buildPages/buildBlock/moveAreasToSlots.js moves
25
+ // "areas" to "slots" during build, so built page artifacts only ever have
26
+ // "slots").
27
+ function countBlocks(blocks, types) {
28
+ let count = 0;
29
+ (blocks ?? []).forEach((block) => {
30
+ count += 1;
31
+ if (block.type) {
32
+ types.add(block.type);
33
+ }
34
+ Object.values(block.slots ?? {}).forEach((slot) => {
35
+ count += countBlocks(slot?.blocks, types);
36
+ });
37
+ });
38
+ return count;
39
+ }
40
+
41
+ function summarizeBlocks(blocks) {
42
+ const types = new Set();
43
+ const blockCount = countBlocks(blocks, types);
44
+ return { blockCount, blockTypes: [...types] };
45
+ }
46
+
47
+ // The request's "type" is stripped from the page artifact by the build
48
+ // (packages/build/src/build/full/writeRequests.js deletes
49
+ // type/connectionId/properties/auth after writing the per-request file), so
50
+ // it is read from the dedicated per-request artifact instead.
51
+ function summarizeRequests({ pageId, requests }) {
52
+ return (requests ?? []).map((request) => {
53
+ const requestConfig = readBuildArtifact({
54
+ name: `pages/${pageId}/requests/${request.requestId}.json`,
55
+ deserialize: true,
56
+ });
57
+ return { id: request.requestId, type: requestConfig?.type ?? null };
58
+ });
59
+ }
60
+
61
+ function getPages() {
62
+ const registry = readBuildArtifact({ name: 'pageRegistry.json' }) ?? {};
63
+ const pages = [];
64
+ let unbuiltCount = 0;
65
+
66
+ Object.entries(registry).forEach(([pageId, entry]) => {
67
+ const built = readBuildArtifact({ name: `pages/${pageId}.json`, deserialize: true });
68
+ const page = {
69
+ pageId,
70
+ file: entry.refPath,
71
+ auth: entry.auth,
72
+ built: !type.isNone(built),
73
+ };
74
+ if (type.isNone(built)) {
75
+ unbuiltCount += 1;
76
+ } else {
77
+ Object.assign(page, summarizeBlocks(built.blocks));
78
+ page.requests = summarizeRequests({ pageId, requests: built.requests });
79
+ }
80
+ pages.push(page);
81
+ });
82
+
83
+ return { pages, unbuiltCount };
84
+ }
85
+
86
+ // Menu items can nest further links (e.g. MenuGroup), so this recurses; kept
87
+ // to id/type/pageId/title only — no properties/auth noise.
88
+ function summarizeMenuItem(menuItem) {
89
+ const summary = {
90
+ menuItemId: menuItem.menuItemId ?? menuItem.id,
91
+ type: menuItem.type,
92
+ };
93
+ if (!type.isNone(menuItem.pageId)) {
94
+ summary.pageId = menuItem.pageId;
95
+ }
96
+ if (!type.isNone(menuItem.properties?.title)) {
97
+ summary.title = menuItem.properties.title;
98
+ }
99
+ if (type.isArray(menuItem.links)) {
100
+ summary.links = menuItem.links.map(summarizeMenuItem);
101
+ }
102
+ return summary;
103
+ }
104
+
105
+ function getMenus() {
106
+ const menus = readBuildArtifact({ name: 'menus.json', deserialize: true }) ?? [];
107
+ return menus.map((menu) => ({
108
+ menuId: menu.menuId ?? menu.id,
109
+ links: (menu.links ?? []).map(summarizeMenuItem),
110
+ }));
111
+ }
112
+
113
+ function getConnections() {
114
+ const ids = readBuildArtifact({ name: 'connectionIds.json' }) ?? [];
115
+ return ids.map((id) => {
116
+ const connection = readBuildArtifact({ name: `connections/${id}.json`, deserialize: true });
117
+ return { id, type: connection?.type ?? null };
118
+ });
119
+ }
120
+
121
+ // api/ and agents/ have one artifact file per id and no id manifest (unlike
122
+ // connectionIds.json / websocketIds.json), so their ids come from a
123
+ // directory listing instead of readBuildArtifact.
124
+ function listArtifactIds({ buildDirectory, dirName }) {
125
+ const dirPath = path.join(buildDirectory, dirName);
126
+ if (!fs.existsSync(dirPath)) {
127
+ return [];
128
+ }
129
+ return fs
130
+ .readdirSync(dirPath)
131
+ .filter((filename) => filename.endsWith('.json'))
132
+ .map((filename) => filename.slice(0, -'.json'.length));
133
+ }
134
+
135
+ function getEndpoints({ buildDirectory }) {
136
+ return listArtifactIds({ buildDirectory, dirName: 'api' }).map((id) => {
137
+ const endpoint = readBuildArtifact({ name: `api/${id}.json`, deserialize: true });
138
+ return { id, type: endpoint?.type ?? null };
139
+ });
140
+ }
141
+
142
+ function getAgents({ buildDirectory }) {
143
+ return listArtifactIds({ buildDirectory, dirName: 'agents' }).map((id) => {
144
+ const agent = readBuildArtifact({ name: `agents/${id}.json`, deserialize: true });
145
+ return { id, type: agent?.type ?? null };
146
+ });
147
+ }
148
+
149
+ function getWebsockets() {
150
+ return readBuildArtifact({ name: 'websocketIds.json' }) ?? [];
151
+ }
152
+
153
+ // Compact snapshot of the whole app for an agent to orient itself: every
154
+ // page (with block/request detail only for pages that have a built
155
+ // artifact — pages are JIT-built on first visit, see lib/docs/getPageConfig.js),
156
+ // menus, connections, api endpoints, agents and websockets.
157
+ function getAppMap() {
158
+ const buildDirectory = path.join(process.cwd(), 'build');
159
+ const { pages, unbuiltCount } = getPages();
160
+
161
+ const map = {
162
+ pages,
163
+ menus: getMenus(),
164
+ connections: getConnections(),
165
+ endpoints: getEndpoints({ buildDirectory }),
166
+ agents: getAgents({ buildDirectory }),
167
+ websockets: getWebsockets(),
168
+ };
169
+
170
+ if (unbuiltCount > 0) {
171
+ map.note =
172
+ `${unbuiltCount} page(s) have not been built yet, so only "file" and "auth" are shown ` +
173
+ 'for them. Visit the page in the browser, or GET /lowdefy-docs/page/{pageId}, to trigger ' +
174
+ 'a build and see their blocks and requests.';
175
+ }
176
+
177
+ return map;
178
+ }
179
+
180
+ export default getAppMap;
@@ -0,0 +1,127 @@
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 { chromium } from 'playwright-core';
18
+ import { type } from '@lowdefy/helpers';
19
+
20
+ import lowdefyConfig from '../build/config.js';
21
+ import { HEADLESS_USER_COOKIE } from '../server/auth/headlessUser.js';
22
+ import resolveHeadlessUser from '../server/auth/resolveHeadlessUser.js';
23
+
24
+ // playwright-core does not bundle a browser (unlike @playwright/test) — it
25
+ // only drives one that is already installed. `channel: 'chrome'` picks up a
26
+ // system Chrome install first since that's more likely to already be
27
+ // present than a Playwright-managed Chromium in a dev environment.
28
+ async function launchBrowser() {
29
+ try {
30
+ return await chromium.launch({ channel: 'chrome' });
31
+ } catch {
32
+ return await chromium.launch();
33
+ }
34
+ }
35
+
36
+ // Module-level singleton — a browser process is expensive to start, so it is
37
+ // launched once and reused across every headless caller (screenshots, state
38
+ // inspection, operator evaluation). Cached as a promise so concurrent calls
39
+ // awaiting startup share the same launch instead of racing.
40
+ let browserPromise = null;
41
+
42
+ async function getBrowser() {
43
+ if (type.isNone(browserPromise)) {
44
+ // Clear the cache on failure so the next call retries the launch
45
+ // instead of replaying a cached rejection forever.
46
+ browserPromise = launchBrowser().catch((error) => {
47
+ browserPromise = null;
48
+ throw error;
49
+ });
50
+ }
51
+ let browser = await browserPromise;
52
+ if (!browser.isConnected()) {
53
+ browserPromise = launchBrowser().catch((error) => {
54
+ browserPromise = null;
55
+ throw error;
56
+ });
57
+ browser = await browserPromise;
58
+ }
59
+ return browser;
60
+ }
61
+
62
+ // `origin` should already include any configured basePath prefix that the
63
+ // caller can't derive itself (e.g. from a request URL) — here it's read from
64
+ // build/config.json (the same source app.js uses to mount the app) so
65
+ // callers only need to pass the bare origin.
66
+ function buildPageUrl({ origin, pageId }) {
67
+ const basePath = lowdefyConfig.basePath ?? '';
68
+ return `${origin}${basePath}/${pageId}`;
69
+ }
70
+
71
+ // Opens a fresh browser context + page at the app's pageId route. Callers
72
+ // obtain `browser` via getBrowser() themselves so they can map a launch
73
+ // failure to their own "no browser available" error message, separate from
74
+ // navigation failures.
75
+ async function openPage({
76
+ browser,
77
+ origin,
78
+ pageId,
79
+ user,
80
+ width = 1280,
81
+ height = 800,
82
+ timeout = 15000,
83
+ }) {
84
+ const url = buildPageUrl({ origin, pageId });
85
+ // Resolved before the context is created so an invalid `user` can't leave an
86
+ // orphaned context behind.
87
+ const injectedUser = resolveHeadlessUser({ user });
88
+ const context = await browser.newContext({ viewport: { width, height } });
89
+ // Inject an authenticated user so auth-protected pages don't 404 for the
90
+ // cookieless headless context. Mirrors the e2e user-cookie pattern; scoped to
91
+ // `origin` so it rides along on the same-origin /api/* fetches.
92
+ await context.addCookies([
93
+ {
94
+ name: HEADLESS_USER_COOKIE,
95
+ value: Buffer.from(JSON.stringify(injectedUser)).toString('base64'),
96
+ url: origin,
97
+ },
98
+ ]);
99
+ const page = await context.newPage();
100
+ try {
101
+ await page.goto(url, { waitUntil: 'networkidle', timeout });
102
+ } catch {
103
+ // Pages with long-polling/SSE connections (reload, websockets) never
104
+ // go network-idle — fall back to 'load' rather than failing outright.
105
+ await page.goto(url, { waitUntil: 'load', timeout });
106
+ }
107
+ // The engine builds the page context (and runs onInit + initial requests)
108
+ // after the bundle loads — 'load'/'networkidle' fire before that. Every
109
+ // caller (screenshot, inspect, eval, checkpoint load) needs the app
110
+ // actually mounted, so wait for the context and for initial requests to
111
+ // settle. Tolerant: on timeout proceed and let the caller surface what it
112
+ // finds — a screenshot of a hung page is still useful signal.
113
+ await page
114
+ .waitForFunction(
115
+ (id) => {
116
+ const pageContext = window.lowdefy?.contexts?.[`page:${id}`];
117
+ if (!pageContext) return false;
118
+ return !Object.values(pageContext.requests ?? {}).some((calls) => calls?.[0]?.loading);
119
+ },
120
+ pageId,
121
+ { timeout }
122
+ )
123
+ .catch(() => {});
124
+ return { context, page, url };
125
+ }
126
+
127
+ export { getBrowser, openPage, buildPageUrl };