@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
@@ -13,12 +13,30 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */
16
+
16
17
  /* eslint-disable react/jsx-props-no-spreading */
17
18
 
18
19
  import React, { useEffect, useRef } from 'react';
19
- import { getSession, SessionProvider, signIn, signOut, useSession } from 'next-auth/react';
20
+ import {
21
+ authConfigManager,
22
+ getSession,
23
+ SessionProvider,
24
+ signIn,
25
+ signOut,
26
+ useSession,
27
+ } from '@hono/auth-js/react';
28
+
29
+ import { serializer } from '@lowdefy/helpers';
30
+
31
+ import rawLowdefyConfig from '../../../build/config.json';
20
32
 
21
- import lowdefyConfig from '../../build/config.js';
33
+ const lowdefyConfig = serializer.deserialize(rawLowdefyConfig);
34
+
35
+ // @hono/auth-js/react configures its fetch paths through a module-level
36
+ // manager instead of SessionProvider props.
37
+ if (lowdefyConfig.basePath) {
38
+ authConfigManager.setConfig({ basePath: `${lowdefyConfig.basePath}/api/auth` });
39
+ }
22
40
 
23
41
  function Session({ children }) {
24
42
  const wasAuthenticated = useRef(false);
@@ -31,9 +49,8 @@ function Session({ children }) {
31
49
  }
32
50
  }, [status]);
33
51
 
34
- // If session is passed to SessionProvider from getServerSideProps
35
- // we won't have a loading state here.
36
- // But 404 uses getStaticProps so we have this for 404.
52
+ // If session is passed to SessionProvider from the server-rendered config
53
+ // we won't have a loading state here, but unauthenticated first loads do.
37
54
  if (status === 'loading') {
38
55
  return '';
39
56
  }
@@ -42,12 +59,8 @@ function Session({ children }) {
42
59
 
43
60
  function AuthConfigured({ authConfig, children, serverSession }) {
44
61
  const auth = { authConfig, getSession, signIn, signOut };
45
- let basePath = lowdefyConfig.basePath;
46
- if (basePath) {
47
- basePath = `${basePath}/api/auth`;
48
- }
49
62
  return (
50
- <SessionProvider session={serverSession} basePath={basePath}>
63
+ <SessionProvider session={serverSession}>
51
64
  <Session>
52
65
  {(session) => {
53
66
  auth.session = session;
@@ -14,18 +14,19 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- function setPageId(router, rootConfig) {
18
- if (router.pathname === `/404`) {
17
+ // location comes from the custom router (@lowdefy/client/adapters):
18
+ // { pageId, pathname, search }. pageId is null at the root path.
19
+ function setPageId(location, rootConfig) {
20
+ if (location.pageId === '404') {
19
21
  return { redirect: false, pageId: '404' };
20
22
  }
21
- const segments = router.query.pageId;
22
- if (!segments || segments.length === 0) {
23
+ if (!location.pageId) {
23
24
  if (rootConfig.home.configured === false) {
24
25
  return { redirect: true, pageId: rootConfig.home.pageId };
25
26
  }
26
27
  return { redirect: false, pageId: rootConfig.home.pageId };
27
28
  }
28
- return { redirect: false, pageId: segments.join('/') };
29
+ return { redirect: false, pageId: location.pageId };
29
30
  }
30
31
 
31
32
  export default setPageId;
@@ -16,12 +16,25 @@
16
16
 
17
17
  import { useSWRConfig } from 'swr';
18
18
 
19
+ // Module-level counters that bust usePageConfig SWR keys: reloadVersion moves
20
+ // on config reloads (all pages), navVersion on client-side navigations
21
+ // (dynamic pages only — server-resolved content must re-resolve per
22
+ // navigation, never serve from the SWR cache).
19
23
  let reloadVersion = 0;
24
+ let navVersion = 0;
20
25
 
21
26
  function getReloadVersion() {
22
27
  return reloadVersion;
23
28
  }
24
29
 
30
+ function bumpNavVersion() {
31
+ navVersion += 1;
32
+ }
33
+
34
+ function getNavVersion() {
35
+ return navVersion;
36
+ }
37
+
25
38
  function useMutateCache(basePath) {
26
39
  const { mutate } = useSWRConfig();
27
40
  return () => {
@@ -34,5 +47,5 @@ function useMutateCache(basePath) {
34
47
  };
35
48
  }
36
49
 
37
- export { getReloadVersion };
50
+ export { bumpNavVersion, getNavVersion, getReloadVersion };
38
51
  export default useMutateCache;
@@ -13,7 +13,12 @@
13
13
 
14
14
  import useSWR from 'swr';
15
15
 
16
- import { getReloadVersion } from './useMutateCache.js';
16
+ import { getNavVersion, getReloadVersion } from './useMutateCache.js';
17
+
18
+ // URLs whose config is server-resolved per request — learned from the fetched
19
+ // config's dynamic flag, so the first visit caches like a static page and
20
+ // every later navigation refetches.
21
+ const dynamicUrls = new Set();
17
22
 
18
23
  function parseJsModule(text) {
19
24
  const fn = new Function('exports', text.replace('export default', 'exports.default ='));
@@ -22,30 +27,24 @@ function parseJsModule(text) {
22
27
  return mod.default ?? {};
23
28
  }
24
29
 
25
- async function fetchJsEntries(basePath) {
30
+ export async function fetchPageConfig(url) {
31
+ // A stalled request (server restart mid-request, exhausted sockets) must
32
+ // become a visible error, never an eternal Suspense fallback — the reload
33
+ // recovery path cannot fire while the page tree is suspended.
34
+ let res;
26
35
  try {
27
- const res = await fetch(`${basePath}/api/js/client`);
28
- if (!res.ok) return {};
29
- return parseJsModule(await res.text());
30
- } catch {
31
- return {};
36
+ res = await fetch(url, {
37
+ headers: { 'Content-Type': 'application/json' },
38
+ signal: AbortSignal.timeout(30_000),
39
+ });
40
+ } catch (error) {
41
+ if (error.name === 'TimeoutError' || error.name === 'AbortError') {
42
+ throw new Error(
43
+ `Page config request "${url}" timed out - the dev server may be restarting. Reload the page.`
44
+ );
45
+ }
46
+ throw error;
32
47
  }
33
- }
34
-
35
- async function fetchDynamicIcons(basePath) {
36
- try {
37
- const res = await fetch(`${basePath}/api/icons/dynamic`);
38
- if (!res.ok) return {};
39
- return parseJsModule(await res.text());
40
- } catch {
41
- return {};
42
- }
43
- }
44
-
45
- async function fetchPageConfig(url) {
46
- const res = await fetch(url, {
47
- headers: { 'Content-Type': 'application/json' },
48
- });
49
48
  if (res.status === 404) {
50
49
  return null;
51
50
  }
@@ -60,34 +59,37 @@ async function fetchPageConfig(url) {
60
59
  throw new Error(data.message || 'Request error');
61
60
  }
62
61
 
63
- // Fetch jsMap and dynamic icons after page build completes
64
- // (JIT build may have added new entries).
65
- // Extract basePath from the URL to construct the endpoints.
66
- const basePath = url.replace(/\/api\/page\/.*$/, '');
67
- const [jsEntries, dynamicIcons] = await Promise.all([
68
- fetchJsEntries(basePath),
69
- fetchDynamicIcons(basePath),
70
- ]);
71
- data._jsEntries = jsEntries;
72
- data._dynamicIcons = dynamicIcons;
62
+ // The JIT build folds this page's _js entries and dynamic icons into the
63
+ // response, so first paint needs no secondary fetch. _jsEntries arrives as
64
+ // module text — compile it to the { hash: fn } object Page expects.
65
+ // _dynamicIcons is already plain data — leave it for Page to inject.
66
+ if (data._jsEntries) data._jsEntries = parseJsModule(data._jsEntries);
73
67
 
74
- // Bust CSS cache so the browser picks up newly compiled Tailwind classes
75
- const cssLink = document.getElementById('tailwind-jit-css');
76
- if (cssLink) {
77
- cssLink.href = `${basePath}/tailwind-jit.css?v=${Date.now()}`;
68
+ if (data?.dynamic === true) {
69
+ dynamicUrls.add(url);
78
70
  }
79
71
 
80
72
  return data;
81
73
  }
82
74
 
83
75
  function usePageConfig(pageId, basePath) {
84
- const url = `${basePath}/api/page/${pageId}`;
76
+ // Forward the current query string so server-side Dynamic block resolution
77
+ // sees the same urlQuery as an initial HTML load. Including it in the SWR
78
+ // key also caches dynamic pages per query string.
79
+ const url = `${basePath}/api/page/${pageId}${window.location.search}`;
85
80
  // Include reloadVersion in the SWR key so that after a config reload,
86
- // previously cached page data is not reused. The fetcher receives
87
- // [url, version] but only uses url — the version just busts the cache.
88
- const { data } = useSWR([url, getReloadVersion()], ([fetchUrl]) => fetchPageConfig(fetchUrl), {
89
- suspense: true,
90
- });
81
+ // previously cached page data is not reused. Dynamic pages also key on the
82
+ // navigation version — server-resolved content must re-resolve on every
83
+ // navigation, never serve from the SWR cache. The fetcher receives
84
+ // [url, ...versions] but only uses url — the versions just bust the cache.
85
+ const navVersion = dynamicUrls.has(url) ? getNavVersion() : 0;
86
+ const { data } = useSWR(
87
+ [url, getReloadVersion(), navVersion],
88
+ ([fetchUrl]) => fetchPageConfig(fetchUrl),
89
+ {
90
+ suspense: true,
91
+ }
92
+ );
91
93
  return { data };
92
94
  }
93
95
 
@@ -0,0 +1,62 @@
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
+ // usePageConfig imports swr and useMutateCache only for the hook; the fetcher
20
+ // under test needs neither. Mock them so the module loads in the node test env.
21
+ jest.unstable_mockModule('swr', () => ({ default: jest.fn() }));
22
+ jest.unstable_mockModule('./useMutateCache.js', () => ({
23
+ getNavVersion: jest.fn(),
24
+ getReloadVersion: jest.fn(),
25
+ }));
26
+
27
+ const { fetchPageConfig } = await import('./usePageConfig.js');
28
+
29
+ function mockJsonResponse(body) {
30
+ return { status: 200, ok: true, json: async () => body };
31
+ }
32
+
33
+ afterEach(() => {
34
+ delete global.fetch;
35
+ });
36
+
37
+ test('fetchPageConfig compiles inlined _jsEntries module text into a { hash: fn } object', async () => {
38
+ global.fetch = jest.fn(async () =>
39
+ mockJsonResponse({
40
+ id: 'p',
41
+ _jsEntries: "export default { 'h1': ({ args }) => { return args.x + 1; } };",
42
+ _dynamicIcons: { FiZap: { tag: 'svg' } },
43
+ })
44
+ );
45
+
46
+ const data = await fetchPageConfig('http://localhost/api/page/p');
47
+
48
+ expect(typeof data._jsEntries.h1).toBe('function');
49
+ expect(data._jsEntries.h1({ args: { x: 1 } })).toBe(2);
50
+ // _dynamicIcons passes through untouched.
51
+ expect(data._dynamicIcons).toEqual({ FiZap: { tag: 'svg' } });
52
+ });
53
+
54
+ test('fetchPageConfig issues only the page-config request — no /api/js or /api/icons fetch', async () => {
55
+ global.fetch = jest.fn(async () => mockJsonResponse({ id: 'p' }));
56
+
57
+ const data = await fetchPageConfig('http://localhost/api/page/p');
58
+
59
+ expect(data._jsEntries).toBeUndefined();
60
+ expect(global.fetch).toHaveBeenCalledTimes(1);
61
+ expect(global.fetch.mock.calls[0][0]).toBe('http://localhost/api/page/p');
62
+ });
@@ -0,0 +1,88 @@
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
+
20
+ import drawAnnotationsSvg from '../../client/feedback/drawAnnotationsSvg.js';
21
+ import { getBrowser, openPage } from './getBrowser.js';
22
+
23
+ // Renders the page headless at the batch's recorded viewport/scroll, injects
24
+ // the developer's drawn shapes and element outlines as an SVG layer inside
25
+ // the page, screenshots the composite, and saves it under the config dir's
26
+ // .lowdefy/annotations/ (gitignored). The saved path goes into the formatted
27
+ // feedback text so an agent can read the image. Never throws — returns
28
+ // { path } or { error }.
29
+ async function captureAnnotatedScreenshot({ origin, batch, fileName }) {
30
+ let browser;
31
+ try {
32
+ browser = await getBrowser();
33
+ } catch (error) {
34
+ return { error: `No Chromium available for the annotated screenshot (${error.message}).` };
35
+ }
36
+
37
+ const viewport = batch.viewport ?? {};
38
+ const width = viewport.width || 1280;
39
+ const height = viewport.height || 800;
40
+ const urlQuery = batch.urlQuery && batch.urlQuery !== '?' ? batch.urlQuery : '';
41
+
42
+ let context;
43
+ try {
44
+ const opened = await openPage({
45
+ browser,
46
+ origin,
47
+ pageId: `${batch.pageId}${urlQuery}`,
48
+ width,
49
+ height,
50
+ });
51
+ context = opened.context;
52
+ const { page } = opened;
53
+
54
+ if (viewport.scrollX || viewport.scrollY) {
55
+ await page.evaluate(
56
+ ([x, y]) => window.scrollTo(x, y),
57
+ [viewport.scrollX ?? 0, viewport.scrollY ?? 0]
58
+ );
59
+ await page.waitForTimeout(200);
60
+ }
61
+
62
+ // Shapes and element rects are viewport-relative at the recorded scroll
63
+ // offset — after scrolling to the same offset they overlay 1:1. The
64
+ // compositor is shared with the in-tab capture (drawAnnotationsSvg is
65
+ // self-contained, so Playwright can serialize it into the page).
66
+ await page.evaluate(drawAnnotationsSvg, batch.annotations ?? []);
67
+ await page.waitForTimeout(150);
68
+
69
+ const buffer = await page.screenshot({ type: 'png' });
70
+
71
+ const configDirectory = process.env.LOWDEFY_DIRECTORY_CONFIG || process.cwd();
72
+ const dir = path.join(configDirectory, '.lowdefy', 'annotations');
73
+ fs.mkdirSync(dir, { recursive: true });
74
+ const name =
75
+ fileName ?? `${batch.pageId}-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
76
+ fs.writeFileSync(path.join(dir, name), buffer);
77
+
78
+ return { path: path.join('.lowdefy', 'annotations', name) };
79
+ } catch (error) {
80
+ return { error: `Failed to capture annotated screenshot: ${error.message}` };
81
+ } finally {
82
+ if (context) {
83
+ await context.close();
84
+ }
85
+ }
86
+ }
87
+
88
+ export default captureAnnotatedScreenshot;
@@ -0,0 +1,91 @@
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 path from 'path';
19
+
20
+ // Shared path/file-listing helpers for createConfigCheckpoint.js,
21
+ // listConfigCheckpoints.js and revertConfigCheckpoint.js.
22
+
23
+ const CONFIG_FILE_EXTENSIONS = ['.yaml', '.yml', '.json', '.md', '.js', '.env'];
24
+ const EXCLUDED_DIRECTORY_NAMES = ['.lowdefy', 'node_modules', '.git', 'checkpoints'];
25
+ const MAX_CHECKPOINTS = 20;
26
+ const MANIFEST_FILE_NAME = 'manifest.json';
27
+
28
+ function getConfigDirectory() {
29
+ return process.env.LOWDEFY_DIRECTORY_CONFIG || process.cwd();
30
+ }
31
+
32
+ function getCheckpointsRoot() {
33
+ return path.join(getConfigDirectory(), '.lowdefy', 'checkpoints');
34
+ }
35
+
36
+ // path.extname('.env') is '' (dotfiles have no extension in Node's eyes), so a
37
+ // dotfile named exactly ".env" needs its own basename check.
38
+ function isConfigFile(filePath) {
39
+ if (path.basename(filePath) === '.env') {
40
+ return true;
41
+ }
42
+ return CONFIG_FILE_EXTENSIONS.includes(path.extname(filePath));
43
+ }
44
+
45
+ // Recursively lists config-relevant files under `configDirectory`, returned as
46
+ // paths relative to it. Skips directories that should never be snapshotted —
47
+ // build output (.lowdefy, which also holds the checkpoints themselves),
48
+ // dependencies (node_modules) and version control (.git).
49
+ function listConfigFiles({ configDirectory }) {
50
+ const results = [];
51
+
52
+ function walk(currentDir) {
53
+ for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
54
+ const fullPath = path.join(currentDir, entry.name);
55
+ if (entry.isDirectory()) {
56
+ if (EXCLUDED_DIRECTORY_NAMES.includes(entry.name)) {
57
+ continue;
58
+ }
59
+ walk(fullPath);
60
+ } else if (entry.isFile() && isConfigFile(fullPath)) {
61
+ results.push(path.relative(configDirectory, fullPath));
62
+ }
63
+ }
64
+ }
65
+
66
+ if (fs.existsSync(configDirectory)) {
67
+ walk(configDirectory);
68
+ }
69
+ return results;
70
+ }
71
+
72
+ // Slugifies a checkpoint label for use in a directory name — lowercase,
73
+ // non-alphanumeric runs collapsed to a single "-", leading/trailing "-"
74
+ // trimmed. Falls back to "checkpoint" when the label slugifies to nothing
75
+ // (e.g. an empty string or a label made only of punctuation).
76
+ function slug(label) {
77
+ const slugified = String(label ?? '')
78
+ .toLowerCase()
79
+ .replace(/[^a-z0-9]+/g, '-')
80
+ .replace(/^-+|-+$/g, '');
81
+ return slugified === '' ? 'checkpoint' : slugified;
82
+ }
83
+
84
+ export {
85
+ getCheckpointsRoot,
86
+ getConfigDirectory,
87
+ listConfigFiles,
88
+ MANIFEST_FILE_NAME,
89
+ MAX_CHECKPOINTS,
90
+ slug,
91
+ };