@lowdefy/server-dev 5.5.1 → 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/{apiWrapper.js → createLowdefyContext.js} +63 -57
  107. package/lib/server/getPageJitEnrichment.test.mjs +105 -0
  108. package/lib/server/jitPageBuilder.js +125 -12
  109. package/lib/server/log/createHandleError.js +9 -0
  110. package/lib/server/log/createHandleError.test.mjs +126 -0
  111. package/manager/getContext.mjs +3 -7
  112. package/manager/processes/initialBuild.mjs +0 -2
  113. package/manager/processes/installPlugins.mjs +19 -11
  114. package/manager/processes/lowdefyBuild.mjs +29 -7
  115. package/manager/processes/lowdefyBuild.test.mjs +102 -0
  116. package/manager/processes/readDotEnv.mjs +13 -1
  117. package/manager/processes/shutdownServer.mjs +6 -6
  118. package/manager/processes/startProxy.mjs +160 -0
  119. package/manager/processes/startProxy.test.mjs +102 -0
  120. package/manager/processes/startServer.mjs +33 -16
  121. package/manager/processes/startWatchers.mjs +2 -2
  122. package/manager/processes/warnAuthUrlPortMismatch.mjs +47 -0
  123. package/manager/processes/warnAuthUrlPortMismatch.test.mjs +93 -0
  124. package/manager/run.mjs +50 -0
  125. package/manager/utils/createCustomPluginMessagesMap.mjs +8 -1
  126. package/manager/utils/createCustomPluginTypesMap.mjs +1 -0
  127. package/manager/utils/formatNoticeBox.mjs +30 -0
  128. package/manager/utils/{getNextBin.mjs → getViteBin.mjs} +7 -10
  129. package/manager/utils/updatePageTailwindCss.mjs +7 -1
  130. package/manager/utils/writeBuildStatus.mjs +39 -0
  131. package/manager/utils/writeBuildStatus.test.mjs +76 -0
  132. package/manager/watchers/lowdefyBuildWatcher.mjs +0 -1
  133. package/manager/watchers/moduleBuildWatcher.mjs +0 -1
  134. package/manager/watchers/{nextBuildWatcher.mjs → serverArtifactWatcher.mjs} +18 -20
  135. package/package.json +61 -47
  136. package/package.original.json +64 -55
  137. package/postcss.config.cjs +1 -0
  138. package/src/app.js +175 -0
  139. package/src/html/renderDevPage.js +86 -0
  140. package/src/lib/getPathSegments.js +25 -0
  141. package/src/lib/safeScriptJson.js +38 -0
  142. package/src/middleware/apiContext.js +32 -0
  143. package/src/middleware/errorHandler.js +48 -0
  144. package/src/middleware/errorHandler.test.mjs +235 -0
  145. package/src/routes/agent.js +64 -0
  146. package/src/routes/apiPage.js +37 -0
  147. package/src/routes/auth.js +49 -0
  148. package/src/routes/auth.test.mjs +84 -0
  149. package/src/routes/clientError.js +57 -0
  150. package/src/routes/cron.js +47 -0
  151. package/src/routes/cronForward.js +47 -0
  152. package/src/routes/detached.js +47 -0
  153. package/src/routes/devInspect.js +111 -0
  154. package/src/routes/devInspect.test.mjs +119 -0
  155. package/{pages/[[...pageId]].js → src/routes/devTools.js} +4 -2
  156. package/{pages/404.js → src/routes/docs/appMap.js} +6 -2
  157. package/{pages/api/dev-tools.js → src/routes/docs/buildStatus.js} +5 -4
  158. package/src/routes/docs/checkpointsCreate.js +28 -0
  159. package/src/routes/docs/checkpointsList.js +23 -0
  160. package/{pages/api/icons/dynamic.js → src/routes/docs/checkpointsRevert.js} +11 -12
  161. package/src/routes/docs/content.js +31 -0
  162. package/src/routes/docs/evalOperator.js +56 -0
  163. package/src/routes/docs/examples.js +33 -0
  164. package/src/routes/docs/find.js +30 -0
  165. package/src/routes/docs/index.js +23 -0
  166. package/src/routes/docs/inspectState.js +44 -0
  167. package/src/routes/docs/loadState.js +49 -0
  168. package/src/routes/docs/mcp.js +30 -0
  169. package/src/routes/docs/pageConfig.js +36 -0
  170. package/src/routes/docs/parseUserParam.js +52 -0
  171. package/src/routes/docs/parseUserParam.test.mjs +45 -0
  172. package/src/routes/docs/pluginDoc.js +33 -0
  173. package/src/routes/docs/plugins.js +23 -0
  174. package/src/routes/docs/runRequest.js +36 -0
  175. package/{lib/server/auth/getServerSession.js → src/routes/docs/schema.js} +17 -15
  176. package/src/routes/docs/screenshot.js +72 -0
  177. package/src/routes/docs/search.js +27 -0
  178. package/src/routes/docs/snapshotState.js +36 -0
  179. package/src/routes/docs/stateCheckpointsList.js +26 -0
  180. package/src/routes/docs/types.js +33 -0
  181. package/src/routes/endpoints.js +71 -0
  182. package/src/routes/feedback.js +110 -0
  183. package/src/routes/feedback.test.mjs +204 -0
  184. package/src/routes/jitPage.js +86 -0
  185. package/src/routes/mcp.js +34 -0
  186. package/{pages/api → src/routes}/ping.js +4 -2
  187. package/src/routes/reload.js +79 -0
  188. package/src/routes/request.js +74 -0
  189. package/src/routes/request.test.mjs +124 -0
  190. package/{pages/api → src/routes}/root.js +4 -4
  191. package/src/routes/usage.js +46 -0
  192. package/src/routes/websocket.js +33 -0
  193. package/src/websocket/devWebSocket.js +78 -0
  194. package/vite.config.js +111 -0
  195. package/.eslintrc.yaml +0 -1
  196. package/lib/client/App.js +0 -80
  197. package/lib/client/Reload.js +0 -60
  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 -107
  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,366 @@
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
+ // checkpointStore.js reads LOWDEFY_DIRECTORY_CONFIG for where to store
23
+ // checkpoints, and lib/build/config.js (imported transitively by
24
+ // loadState.js -> getBrowser.js) reads build/config.json from
25
+ // process.cwd() at import time — one fixture dir serves both, mirroring
26
+ // screenshotPage.test.mjs / configCheckpoints.test.mjs.
27
+ const originalCwd = process.cwd();
28
+ const previousConfigDirectory = process.env.LOWDEFY_DIRECTORY_CONFIG;
29
+ const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lowdefy-state-checkpoints-test-'));
30
+ fs.mkdirSync(path.join(fixtureDir, 'build'), { recursive: true });
31
+ fs.writeFileSync(path.join(fixtureDir, 'build', 'config.json'), JSON.stringify({ basePath: '' }));
32
+ process.chdir(fixtureDir);
33
+ process.env.LOWDEFY_DIRECTORY_CONFIG = fixtureDir;
34
+
35
+ // A real headless load needs an installed browser, which jest can't rely on —
36
+ // loadState's registry-only mode (the only mode under test here) never
37
+ // calls getBrowser(), but chromium is mocked anyway so importing
38
+ // getBrowser.js can never accidentally launch a real browser.
39
+ jest.unstable_mockModule('playwright-core', () => ({
40
+ chromium: { launch: jest.fn().mockRejectedValue(new Error("Executable doesn't exist")) },
41
+ }));
42
+
43
+ const mockInspectState = jest.fn();
44
+ jest.unstable_mockModule('./inspectState.js', () => ({
45
+ default: mockInspectState,
46
+ }));
47
+
48
+ const {
49
+ checkpointExists,
50
+ getStateCheckpointsRoot,
51
+ listStateCheckpoints,
52
+ readCheckpoint,
53
+ writeCheckpoint,
54
+ } = await import('./checkpointStore.js');
55
+ const { clearMocks, getMock, listMocks, loadMocks } = await import('./devMockRegistry.js');
56
+ const { default: checkpointToMocks } = await import('./checkpointToMocks.js');
57
+ const { default: snapshotState } = await import('./snapshotState.js');
58
+ const { default: loadState } = await import('./loadState.js');
59
+
60
+ afterAll(() => {
61
+ process.chdir(originalCwd);
62
+ fs.rmSync(fixtureDir, { recursive: true, force: true });
63
+ if (previousConfigDirectory === undefined) {
64
+ delete process.env.LOWDEFY_DIRECTORY_CONFIG;
65
+ } else {
66
+ process.env.LOWDEFY_DIRECTORY_CONFIG = previousConfigDirectory;
67
+ }
68
+ });
69
+
70
+ afterEach(() => {
71
+ clearMocks();
72
+ });
73
+
74
+ function buildSnapshot(overrides = {}) {
75
+ return {
76
+ pageId: 'home',
77
+ url: 'http://localhost:3111/home',
78
+ state: { count: 1 },
79
+ urlQuery: '?a=1',
80
+ input: { fromInput: true },
81
+ user: { id: 'u1' },
82
+ global: { g: 1 },
83
+ requests: {
84
+ req1: [{ payload: { a: 1 }, response: { ok: true }, error: null, responseTime: 12 }],
85
+ },
86
+ apiResponses: {
87
+ ep1: { payload: {}, response: { v: 1 } },
88
+ },
89
+ ...overrides,
90
+ };
91
+ }
92
+
93
+ describe('checkpointStore', () => {
94
+ test('writeCheckpoint writes a manifest and per-part files, including per-request/api files on disk', () => {
95
+ const snapshot = buildSnapshot();
96
+ const { dir, parts } = writeCheckpoint({ name: 'cp-roundtrip', snapshot, notes: 'a note' });
97
+
98
+ expect(dir).toBe(path.join(getStateCheckpointsRoot(), 'cp-roundtrip'));
99
+ expect(parts.sort()).toEqual(
100
+ [
101
+ 'checkpoint.json',
102
+ 'state.json',
103
+ 'urlQuery.json',
104
+ 'inputs.json',
105
+ 'user.json',
106
+ 'global.json',
107
+ 'requests/req1.json',
108
+ 'api/ep1.json',
109
+ ].sort()
110
+ );
111
+ expect(fs.existsSync(path.join(dir, 'requests', 'req1.json'))).toBe(true);
112
+ expect(fs.existsSync(path.join(dir, 'api', 'ep1.json'))).toBe(true);
113
+
114
+ const result = readCheckpoint({ name: 'cp-roundtrip' });
115
+ expect(result.checkpoint.pageId).toBe('home');
116
+ expect(result.checkpoint.url).toBe('http://localhost:3111/home');
117
+ expect(result.checkpoint.notes).toBe('a note');
118
+ expect(typeof result.checkpoint.capturedAt).toBe('string');
119
+ expect(result.state).toEqual({ count: 1 });
120
+ expect(result.urlQuery).toBe('?a=1');
121
+ expect(result.input).toEqual({ fromInput: true });
122
+ expect(result.user).toEqual({ id: 'u1' });
123
+ expect(result.global).toEqual({ g: 1 });
124
+ expect(result.requests).toEqual({
125
+ req1: { payload: { a: 1 }, response: { ok: true }, error: null, responseTime: 12 },
126
+ });
127
+ expect(result.api).toEqual({
128
+ ep1: { payload: {}, response: { v: 1 }, error: undefined, responseTime: undefined },
129
+ });
130
+ });
131
+
132
+ test('writeCheckpoint throws an actionable error for an invalid checkpoint name', () => {
133
+ expect(() => writeCheckpoint({ name: 'bad name!', snapshot: buildSnapshot() })).toThrow(
134
+ /invalid/
135
+ );
136
+ });
137
+
138
+ test('writeCheckpoint refuses to overwrite an existing checkpoint unless overwrite: true', () => {
139
+ writeCheckpoint({ name: 'cp-overwrite', snapshot: buildSnapshot() });
140
+
141
+ expect(() => writeCheckpoint({ name: 'cp-overwrite', snapshot: buildSnapshot() })).toThrow(
142
+ 'Checkpoint "cp-overwrite" already exists. Pass overwrite: true to replace it.'
143
+ );
144
+
145
+ const fewerRequests = buildSnapshot({ requests: {}, apiResponses: {} });
146
+ const { dir } = writeCheckpoint({
147
+ name: 'cp-overwrite',
148
+ snapshot: fewerRequests,
149
+ overwrite: true,
150
+ });
151
+ // Overwriting replaces the whole folder, so stale per-request files from
152
+ // the first write must not linger.
153
+ expect(fs.existsSync(path.join(dir, 'requests', 'req1.json'))).toBe(false);
154
+ });
155
+
156
+ test('readCheckpoint throws when the checkpoint does not exist', () => {
157
+ expect(() => readCheckpoint({ name: 'does-not-exist' })).toThrow(
158
+ 'Checkpoint "does-not-exist" not found.'
159
+ );
160
+ });
161
+
162
+ test('readCheckpoint is tolerant of missing parts', () => {
163
+ const dir = path.join(getStateCheckpointsRoot(), 'cp-partial');
164
+ fs.mkdirSync(dir, { recursive: true });
165
+ fs.writeFileSync(path.join(dir, 'checkpoint.json'), JSON.stringify({ pageId: 'home' }));
166
+ // Deliberately no state.json/urlQuery.json/etc, no requests/ dir.
167
+
168
+ const result = readCheckpoint({ name: 'cp-partial' });
169
+ expect(result.state).toEqual({});
170
+ expect(result.urlQuery).toBe('');
171
+ expect(result.input).toEqual({});
172
+ expect(result.user).toBe(null);
173
+ expect(result.global).toEqual({});
174
+ expect(result.requests).toEqual({});
175
+ expect(result.api).toEqual({});
176
+ });
177
+
178
+ test('checkpointExists reflects whether a checkpoint folder exists', () => {
179
+ writeCheckpoint({ name: 'cp-exists', snapshot: buildSnapshot() });
180
+ expect(checkpointExists({ name: 'cp-exists' })).toBe(true);
181
+ expect(checkpointExists({ name: 'cp-does-not-exist' })).toBe(false);
182
+ });
183
+
184
+ test('listStateCheckpoints lists checkpoints with requestCount, most recent first', async () => {
185
+ writeCheckpoint({ name: 'cp-list-a', snapshot: buildSnapshot() });
186
+ await new Promise((resolve) => setTimeout(resolve, 5));
187
+ writeCheckpoint({
188
+ name: 'cp-list-b',
189
+ snapshot: buildSnapshot({ requests: {}, apiResponses: {} }),
190
+ });
191
+
192
+ const checkpoints = listStateCheckpoints();
193
+ const a = checkpoints.find((checkpoint) => checkpoint.name === 'cp-list-a');
194
+ const b = checkpoints.find((checkpoint) => checkpoint.name === 'cp-list-b');
195
+ expect(a.requestCount).toBe(1);
196
+ expect(b.requestCount).toBe(0);
197
+ expect(checkpoints.indexOf(b)).toBeLessThan(checkpoints.indexOf(a));
198
+ });
199
+ });
200
+
201
+ describe('devMockRegistry', () => {
202
+ test('loadMocks + getMock replay a recorded response by pageId/requestId', () => {
203
+ loadMocks({ pageId: 'home', mocks: { req1: { response: { ok: true }, error: null } } });
204
+ expect(getMock({ pageId: 'home', requestId: 'req1' })).toEqual({
205
+ pageId: 'home',
206
+ requestId: 'req1',
207
+ response: { ok: true },
208
+ error: null,
209
+ });
210
+ expect(getMock({ pageId: 'other', requestId: 'req1' })).toBeUndefined();
211
+ });
212
+
213
+ test('listMocks reports whether each entry has a response or an error', () => {
214
+ loadMocks({
215
+ pageId: 'home',
216
+ mocks: {
217
+ req1: { response: { ok: true } },
218
+ req2: { error: new Error('boom') },
219
+ },
220
+ });
221
+ const mocks = listMocks().sort((m1, m2) => m1.requestId.localeCompare(m2.requestId));
222
+ expect(mocks).toEqual([
223
+ { pageId: 'home', requestId: 'req1', hasResponse: true, hasError: false },
224
+ { pageId: 'home', requestId: 'req2', hasResponse: false, hasError: true },
225
+ ]);
226
+ });
227
+
228
+ test('clearMocks removes all recorded mocks', () => {
229
+ loadMocks({ pageId: 'home', mocks: { req1: { response: { ok: true } } } });
230
+ clearMocks();
231
+ expect(listMocks()).toEqual([]);
232
+ expect(getMock({ pageId: 'home', requestId: 'req1' })).toBeUndefined();
233
+ });
234
+ });
235
+
236
+ describe('checkpointToMocks', () => {
237
+ test('converts a checkpoint into the loadStaticMocks input shape', () => {
238
+ writeCheckpoint({
239
+ name: 'cp-to-mocks',
240
+ snapshot: buildSnapshot({
241
+ requests: {
242
+ req1: [{ response: { ok: true } }],
243
+ req2: [{ error: new Error('request failed') }],
244
+ },
245
+ apiResponses: {
246
+ ep1: [{ response: { v: 1 } }],
247
+ },
248
+ }),
249
+ });
250
+
251
+ const result = checkpointToMocks({ name: 'cp-to-mocks' });
252
+
253
+ expect(result.requests.sort((a, b) => a.requestId.localeCompare(b.requestId))).toEqual([
254
+ { requestId: 'req1', pageId: 'home', response: { ok: true } },
255
+ { requestId: 'req2', pageId: 'home', error: 'request failed' },
256
+ ]);
257
+ expect(result.api).toEqual([{ endpointId: 'ep1', response: { v: 1 } }]);
258
+ expect(result.yaml).toEqual(expect.any(String));
259
+ expect(result.yaml).toContain('requestId: req1');
260
+ expect(result.yaml).toContain('endpointId: ep1');
261
+ });
262
+
263
+ test('returns an error when the checkpoint does not exist', () => {
264
+ expect(checkpointToMocks({ name: 'does-not-exist' })).toEqual({
265
+ error: 'Checkpoint "does-not-exist" not found.',
266
+ });
267
+ });
268
+ });
269
+
270
+ describe('snapshotState', () => {
271
+ test('snapshots inspectState output into a checkpoint and returns its location', async () => {
272
+ mockInspectState.mockResolvedValue({
273
+ pageId: 'home',
274
+ state: { count: 2 },
275
+ requests: {},
276
+ urlQuery: '',
277
+ source: 'headless',
278
+ });
279
+
280
+ const result = await snapshotState({
281
+ origin: 'http://localhost:3111',
282
+ pageId: 'home',
283
+ name: 'cp-snapshot',
284
+ });
285
+
286
+ expect(result.error).toBeUndefined();
287
+ expect(result.name).toBe('cp-snapshot');
288
+ expect(result.source).toBe('headless');
289
+ expect(result.parts).toContain('state.json');
290
+
291
+ const stored = readCheckpoint({ name: 'cp-snapshot' });
292
+ expect(stored.state).toEqual({ count: 2 });
293
+ expect(stored.checkpoint.url).toBe('http://localhost:3111/home');
294
+ });
295
+
296
+ test('returns the inspectState error instead of writing a checkpoint', async () => {
297
+ mockInspectState.mockResolvedValue({ error: 'No live context for page "home".' });
298
+
299
+ const result = await snapshotState({
300
+ origin: 'http://localhost:3111',
301
+ pageId: 'home',
302
+ name: 'cp-snapshot-error',
303
+ });
304
+
305
+ expect(result).toEqual({ error: 'No live context for page "home".' });
306
+ expect(checkpointExists({ name: 'cp-snapshot-error' })).toBe(false);
307
+ });
308
+
309
+ test('validates required fields before calling inspectState', async () => {
310
+ const result = await snapshotState({ origin: 'http://localhost:3111', name: 'cp-x' });
311
+ expect(result.error).toMatch(/requires a "pageId" string/);
312
+ expect(mockInspectState).not.toHaveBeenCalled();
313
+ });
314
+ });
315
+
316
+ describe('loadState', () => {
317
+ test('registry-only mode loads recorded requests into devMockRegistry and returns a bootstrap URL', async () => {
318
+ writeCheckpoint({
319
+ name: 'cp-load-registry',
320
+ snapshot: buildSnapshot({ urlQuery: '?a=1' }),
321
+ });
322
+
323
+ const result = await loadState({
324
+ origin: 'http://localhost:3111',
325
+ name: 'cp-load-registry',
326
+ mode: 'registry-only',
327
+ });
328
+
329
+ expect(result.error).toBeUndefined();
330
+ expect(result.url).toBe('http://localhost:3111/home?a=1&_checkpoint=cp-load-registry');
331
+ expect(result.instructions).toMatch(/Open this URL/);
332
+ expect(getMock({ pageId: 'home', requestId: 'req1' })).toEqual({
333
+ pageId: 'home',
334
+ requestId: 'req1',
335
+ response: { ok: true },
336
+ error: null,
337
+ });
338
+ });
339
+
340
+ test('returns an error when the checkpoint does not exist', async () => {
341
+ const result = await loadState({
342
+ origin: 'http://localhost:3111',
343
+ name: 'does-not-exist',
344
+ mode: 'registry-only',
345
+ });
346
+ expect(result).toEqual({ error: 'Checkpoint "does-not-exist" not found.' });
347
+ });
348
+
349
+ test('rejects a user in registry-only mode as invalid input', async () => {
350
+ writeCheckpoint({
351
+ name: 'cp-load-registry-user',
352
+ snapshot: buildSnapshot({ urlQuery: '' }),
353
+ });
354
+
355
+ const result = await loadState({
356
+ origin: 'http://localhost:3111',
357
+ name: 'cp-load-registry-user',
358
+ mode: 'registry-only',
359
+ user: { roles: ['admin'] },
360
+ });
361
+
362
+ expect(result.invalidInput).toBe(true);
363
+ expect(result.error).toMatch(/cannot apply "user" in "registry-only" mode/);
364
+ expect(result.url).toBeUndefined();
365
+ });
366
+ });
@@ -0,0 +1,28 @@
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 { listTabs } from './tabChannel.js';
20
+
21
+ // Shared by inspectState.js and evalOperator.js to decide, when no explicit
22
+ // `source` is requested, whether a live browser tab exists to prefer over a
23
+ // fresh headless one.
24
+ function tabAvailable({ pageId }) {
25
+ return listTabs().some((tab) => type.isNone(pageId) || tab.pageId === pageId);
26
+ }
27
+
28
+ export default tabAvailable;
@@ -0,0 +1,119 @@
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 { randomUUID } from 'node:crypto';
18
+
19
+ import { type } from '@lowdefy/helpers';
20
+
21
+ // Live channel between the dev server and a developer's real browser tab —
22
+ // lets an agent read `window.lowdefy` state / evaluate operators against it.
23
+ // Two module-level registries, mirroring the pendingContexts pattern in
24
+ // src/websocket/devWebSocket.js:
25
+ // - tabs: connected SSE streams, one per open browser tab, keyed by tab id.
26
+ // - pendingRequests: in-flight request/response correlations, keyed by
27
+ // requestId, resolved either by resolveTabRequest (POST from the tab) or
28
+ // by the request's own timeout.
29
+ const tabs = new Map();
30
+ const pendingRequests = new Map();
31
+
32
+ function registerTab({ id, pageId, send }) {
33
+ if (type.isNone(id) || !type.isString(id)) {
34
+ throw new Error(`registerTab requires an "id" string. Received ${JSON.stringify(id)}.`);
35
+ }
36
+ if (!type.isFunction(send)) {
37
+ throw new Error('registerTab requires a "send" function.');
38
+ }
39
+ tabs.set(id, { id, pageId: pageId ?? null, send, connectedAt: new Date() });
40
+ }
41
+
42
+ function updateTabPage({ id, pageId }) {
43
+ const tab = tabs.get(id);
44
+ if (type.isNone(tab)) {
45
+ // The tab may have disconnected between the client sending the ping and
46
+ // it arriving — nothing to update, and not worth failing the request.
47
+ return;
48
+ }
49
+ tab.pageId = pageId ?? null;
50
+ }
51
+
52
+ function unregisterTab({ id }) {
53
+ tabs.delete(id);
54
+ }
55
+
56
+ function listTabs() {
57
+ return Array.from(tabs.values()).map(({ id, pageId, connectedAt }) => ({
58
+ id,
59
+ pageId,
60
+ connectedAt,
61
+ }));
62
+ }
63
+
64
+ // Most recently connected tab wins — Map preserves insertion order, and
65
+ // re-registering a tab id (reconnect) deletes then re-sets it, so the last
66
+ // entry is always the most recent connection.
67
+ function findTab({ pageId }) {
68
+ const candidates = Array.from(tabs.values()).filter(
69
+ (tab) => type.isNone(pageId) || tab.pageId === pageId
70
+ );
71
+ if (candidates.length === 0) {
72
+ return undefined;
73
+ }
74
+ return candidates[candidates.length - 1];
75
+ }
76
+
77
+ function requestFromTab({ pageId, event, payload = {}, timeout = 5000 }) {
78
+ if (type.isNone(event) || !type.isString(event)) {
79
+ throw new Error(
80
+ `requestFromTab requires an "event" string. Received ${JSON.stringify(event)}.`
81
+ );
82
+ }
83
+ const tab = findTab({ pageId });
84
+ if (type.isNone(tab)) {
85
+ const location = type.isNone(pageId) ? 'any page' : `page "${pageId}"`;
86
+ return Promise.resolve({
87
+ error: `No browser tab connected on ${location}. Ask the developer to open the page, or use source: "headless".`,
88
+ });
89
+ }
90
+
91
+ const requestId = randomUUID();
92
+ return new Promise((resolve) => {
93
+ const timer = setTimeout(() => {
94
+ pendingRequests.delete(requestId);
95
+ resolve({ error: `Timed out waiting ${timeout}ms for a response from the browser tab.` });
96
+ }, timeout);
97
+
98
+ pendingRequests.set(requestId, (result) => {
99
+ clearTimeout(timer);
100
+ pendingRequests.delete(requestId);
101
+ resolve(result);
102
+ });
103
+
104
+ tab.send(event, { requestId, ...payload });
105
+ });
106
+ }
107
+
108
+ function resolveTabRequest({ requestId, result }) {
109
+ const resolver = pendingRequests.get(requestId);
110
+ if (type.isNone(resolver)) {
111
+ // Answer arrived after the request already timed out (or for an unknown
112
+ // requestId) — nothing left to resolve.
113
+ return false;
114
+ }
115
+ resolver(result);
116
+ return true;
117
+ }
118
+
119
+ export { listTabs, registerTab, requestFromTab, resolveTabRequest, unregisterTab, updateTabPage };
@@ -0,0 +1,130 @@
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 { listTabs, registerTab, requestFromTab, resolveTabRequest, unregisterTab, updateTabPage } =
20
+ await import('./tabChannel.js');
21
+
22
+ // tabChannel keeps its registries at module scope, so clear connected tabs
23
+ // between tests to keep them independent.
24
+ afterEach(() => {
25
+ listTabs().forEach((tab) => unregisterTab({ id: tab.id }));
26
+ });
27
+
28
+ test('registerTab adds a tab visible via listTabs', () => {
29
+ registerTab({ id: 'tab-1', pageId: 'home', send: jest.fn() });
30
+ const tabs = listTabs();
31
+ expect(tabs).toHaveLength(1);
32
+ expect(tabs[0]).toMatchObject({ id: 'tab-1', pageId: 'home' });
33
+ expect(tabs[0].connectedAt).toBeInstanceOf(Date);
34
+ });
35
+
36
+ test('registerTab throws when id is missing', () => {
37
+ expect(() => registerTab({ pageId: 'home', send: jest.fn() })).toThrow('registerTab requires');
38
+ });
39
+
40
+ test('registerTab throws when send is not a function', () => {
41
+ expect(() => registerTab({ id: 'tab-1', pageId: 'home' })).toThrow(
42
+ 'registerTab requires a "send" function'
43
+ );
44
+ });
45
+
46
+ test('unregisterTab removes a tab from the registry', () => {
47
+ registerTab({ id: 'tab-1', pageId: 'home', send: jest.fn() });
48
+ unregisterTab({ id: 'tab-1' });
49
+ expect(listTabs()).toHaveLength(0);
50
+ });
51
+
52
+ test('updateTabPage changes the pageId of a registered tab', () => {
53
+ registerTab({ id: 'tab-1', pageId: 'home', send: jest.fn() });
54
+ updateTabPage({ id: 'tab-1', pageId: 'about' });
55
+ expect(listTabs()[0].pageId).toEqual('about');
56
+ });
57
+
58
+ test('updateTabPage on an unknown tab id does not throw', () => {
59
+ expect(() => updateTabPage({ id: 'missing', pageId: 'about' })).not.toThrow();
60
+ });
61
+
62
+ test('requestFromTab sends the event to the matching tab and resolves via resolveTabRequest', async () => {
63
+ const send = jest.fn();
64
+ registerTab({ id: 'tab-1', pageId: 'home', send });
65
+ const promise = requestFromTab({ pageId: 'home', event: 'inspect-request' });
66
+
67
+ expect(send).toHaveBeenCalledTimes(1);
68
+ const [event, payload] = send.mock.calls[0];
69
+ expect(event).toEqual('inspect-request');
70
+ expect(payload.requestId).toEqual(expect.any(String));
71
+
72
+ const resolved = resolveTabRequest({ requestId: payload.requestId, result: 'snapshot' });
73
+ expect(resolved).toBe(true);
74
+ await expect(promise).resolves.toEqual('snapshot');
75
+ });
76
+
77
+ test('requestFromTab targets the tab registered for the given pageId', async () => {
78
+ const sendHome = jest.fn();
79
+ const sendAbout = jest.fn();
80
+ registerTab({ id: 'tab-home', pageId: 'home', send: sendHome });
81
+ registerTab({ id: 'tab-about', pageId: 'about', send: sendAbout });
82
+
83
+ const promise = requestFromTab({ pageId: 'about', event: 'eval-request' });
84
+ expect(sendAbout).toHaveBeenCalledTimes(1);
85
+ expect(sendHome).not.toHaveBeenCalled();
86
+
87
+ const requestId = sendAbout.mock.calls[0][1].requestId;
88
+ resolveTabRequest({ requestId, result: { value: '1' } });
89
+ await expect(promise).resolves.toEqual({ value: '1' });
90
+ });
91
+
92
+ test('requestFromTab picks the most recently registered tab when pageId is omitted', async () => {
93
+ registerTab({ id: 'tab-1', pageId: 'home', send: jest.fn() });
94
+ const sendLatest = jest.fn();
95
+ registerTab({ id: 'tab-2', pageId: 'about', send: sendLatest });
96
+
97
+ const promise = requestFromTab({ event: 'inspect-request' });
98
+ expect(sendLatest).toHaveBeenCalledTimes(1);
99
+
100
+ const requestId = sendLatest.mock.calls[0][1].requestId;
101
+ resolveTabRequest({ requestId, result: 'ok' });
102
+ await expect(promise).resolves.toEqual('ok');
103
+ });
104
+
105
+ test('requestFromTab resolves with an error when no tab matches the pageId', async () => {
106
+ registerTab({ id: 'tab-1', pageId: 'home', send: jest.fn() });
107
+ const response = await requestFromTab({ pageId: 'missing-page', event: 'inspect-request' });
108
+ expect(response.error).toContain('No browser tab connected');
109
+ });
110
+
111
+ test('requestFromTab resolves with a timeout error when no response arrives in time', async () => {
112
+ registerTab({ id: 'tab-1', pageId: 'home', send: jest.fn() });
113
+ const response = await requestFromTab({ pageId: 'home', event: 'inspect-request', timeout: 10 });
114
+ expect(response.error).toContain('Timed out');
115
+ });
116
+
117
+ test('resolveTabRequest returns false for an unknown requestId', () => {
118
+ expect(resolveTabRequest({ requestId: 'unknown', result: 'x' })).toBe(false);
119
+ });
120
+
121
+ test('resolveTabRequest returns false when called again for an already-settled requestId', async () => {
122
+ const send = jest.fn();
123
+ registerTab({ id: 'tab-1', pageId: 'home', send });
124
+ const promise = requestFromTab({ pageId: 'home', event: 'inspect-request' });
125
+ const requestId = send.mock.calls[0][1].requestId;
126
+
127
+ expect(resolveTabRequest({ requestId, result: 'first' })).toBe(true);
128
+ await promise;
129
+ expect(resolveTabRequest({ requestId, result: 'second' })).toBe(false);
130
+ });
@@ -14,22 +14,25 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- import { getNextAuthConfig } from '@lowdefy/api';
17
+ import { getAuthConfig as getApiAuthConfig } from '@lowdefy/api';
18
18
  import { getSecretsFromEnv } from '@lowdefy/node-utils';
19
19
 
20
20
  import adapters from '../../../build/plugins/auth/adapters.js';
21
+ import appMeta from '../../build/appMeta.js';
21
22
  import authJson from '../../build/auth.js';
22
23
  import callbacks from '../../../build/plugins/auth/callbacks.js';
23
24
  import events from '../../../build/plugins/auth/events.js';
24
25
  import providers from '../../../build/plugins/auth/providers.js';
25
26
 
26
- function getAuthOptions({ logger }) {
27
- return getNextAuthConfig({
27
+ function getAuthConfig({ logger }) {
28
+ return getApiAuthConfig({
29
+ appMeta,
28
30
  authJson,
31
+ dev: true,
29
32
  logger,
30
33
  plugins: { adapters, callbacks, events, providers },
31
34
  secrets: getSecretsFromEnv(),
32
35
  });
33
36
  }
34
37
 
35
- export default getAuthOptions;
38
+ export default getAuthConfig;