@lowdefy/server-dev 0.0.0-experimental-20260707145139 → 0.0.0-experimental-20260710073520

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 (92) hide show
  1. package/client/Inspector.jsx +206 -0
  2. package/client/Routing.jsx +29 -25
  3. package/lib/docs/checkpointPaths.js +91 -0
  4. package/lib/docs/checkpointStore.js +231 -0
  5. package/lib/docs/checkpointToMocks.js +65 -0
  6. package/lib/docs/clientErrorStore.js +36 -0
  7. package/lib/docs/configCheckpoints.test.mjs +156 -0
  8. package/lib/docs/createConfigCheckpoint.js +92 -0
  9. package/lib/docs/createDocsMcpServer.js +470 -0
  10. package/lib/docs/createDocsMcpServer.test.mjs +120 -0
  11. package/lib/docs/devMockRegistry.js +68 -0
  12. package/lib/docs/docs.test.mjs +366 -0
  13. package/lib/docs/evalOperator.js +39 -0
  14. package/lib/docs/evalOperator.test.mjs +112 -0
  15. package/lib/docs/evalOperatorHeadless.js +87 -0
  16. package/lib/docs/evalOperatorHeadless.test.mjs +72 -0
  17. package/lib/docs/evalOperatorInTab.js +48 -0
  18. package/lib/docs/findConfig.js +113 -0
  19. package/lib/docs/getAppMap.js +180 -0
  20. package/lib/docs/getBrowser.js +104 -0
  21. package/lib/docs/getBuildStatus.js +37 -0
  22. package/lib/docs/getCoreDoc.js +63 -0
  23. package/lib/docs/getDocsManifest.js +40 -0
  24. package/lib/docs/getExamples.js +58 -0
  25. package/lib/docs/getOverview.js +110 -0
  26. package/lib/docs/getPageConfig.js +53 -0
  27. package/lib/docs/getPluginDoc.js +55 -0
  28. package/lib/docs/getSchema.js +65 -0
  29. package/lib/docs/inspectState.js +41 -0
  30. package/lib/docs/inspectState.test.mjs +91 -0
  31. package/lib/docs/inspectStateFromTab.js +40 -0
  32. package/lib/docs/inspectStateHeadless.js +84 -0
  33. package/lib/docs/inspectStateHeadless.test.mjs +60 -0
  34. package/lib/docs/isWriteRequestsAllowed.js +50 -0
  35. package/lib/docs/listConfigCheckpoints.js +55 -0
  36. package/lib/docs/listPlugins.js +80 -0
  37. package/lib/docs/listTypes.js +105 -0
  38. package/lib/docs/loadState.js +139 -0
  39. package/lib/docs/mapPageBuildErrors.js +28 -0
  40. package/lib/docs/normalizeTypeKind.js +40 -0
  41. package/lib/docs/readBuildArtifact.js +35 -0
  42. package/lib/docs/readPageArtifact.js +26 -0
  43. package/lib/docs/resolvePluginDir.js +49 -0
  44. package/lib/docs/revertConfigCheckpoint.js +73 -0
  45. package/lib/docs/runRequest.js +141 -0
  46. package/lib/docs/scaffoldPage.js +75 -0
  47. package/lib/docs/scaffoldPage.test.mjs +97 -0
  48. package/lib/docs/screenshotPage.js +66 -0
  49. package/lib/docs/screenshotPage.test.mjs +59 -0
  50. package/lib/docs/searchDocs.js +67 -0
  51. package/lib/docs/setupTestFixtures.mjs +272 -0
  52. package/lib/docs/snapshotState.js +59 -0
  53. package/lib/docs/stateCheckpoints.test.mjs +348 -0
  54. package/lib/docs/tabAvailable.js +28 -0
  55. package/lib/docs/tabChannel.js +119 -0
  56. package/lib/docs/tabChannel.test.mjs +130 -0
  57. package/lib/server/createLowdefyContext.js +118 -0
  58. package/manager/processes/lowdefyBuild.mjs +29 -7
  59. package/manager/processes/lowdefyBuild.test.mjs +102 -0
  60. package/manager/utils/writeBuildStatus.mjs +39 -0
  61. package/manager/utils/writeBuildStatus.test.mjs +76 -0
  62. package/package.json +37 -32
  63. package/package.original.json +37 -32
  64. package/src/app.js +59 -0
  65. package/src/middleware/apiContext.js +2 -91
  66. package/src/routes/clientError.js +14 -0
  67. package/src/routes/devInspect.js +94 -0
  68. package/src/routes/docs/appMap.js +23 -0
  69. package/src/routes/docs/buildStatus.js +23 -0
  70. package/src/routes/docs/checkpointsCreate.js +28 -0
  71. package/src/routes/docs/checkpointsList.js +23 -0
  72. package/src/routes/docs/checkpointsRevert.js +33 -0
  73. package/src/routes/docs/content.js +31 -0
  74. package/src/routes/docs/evalOperator.js +34 -0
  75. package/src/routes/docs/examples.js +33 -0
  76. package/src/routes/docs/find.js +30 -0
  77. package/src/routes/docs/index.js +23 -0
  78. package/src/routes/docs/inspectState.js +34 -0
  79. package/src/routes/docs/loadState.js +38 -0
  80. package/src/routes/docs/mcp.js +30 -0
  81. package/src/routes/docs/pageConfig.js +36 -0
  82. package/src/routes/docs/pluginDoc.js +33 -0
  83. package/src/routes/docs/plugins.js +23 -0
  84. package/src/routes/docs/runRequest.js +31 -0
  85. package/src/routes/docs/schema.js +37 -0
  86. package/src/routes/docs/screenshot.js +35 -0
  87. package/src/routes/docs/search.js +27 -0
  88. package/src/routes/docs/snapshotState.js +36 -0
  89. package/src/routes/docs/stateCheckpointsList.js +26 -0
  90. package/src/routes/docs/types.js +33 -0
  91. package/src/routes/reload.js +28 -1
  92. package/src/routes/request.js +28 -0
@@ -0,0 +1,206 @@
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 { useEffect, useRef } from 'react';
18
+
19
+ import { type, serializer } from '@lowdefy/helpers';
20
+
21
+ // Dev-only agent-state-xray channel — a sibling of Reload.jsx, but with its
22
+ // own /api/reload SSE connection (rather than reusing Reload's) so that
23
+ // connection can carry ?pageId=<pageId>. routes/reload.js only registers a
24
+ // connection as an inspectable tab (lib/docs/tabChannel.js) when that query
25
+ // param is present, which keeps Reload.jsx's plain reload/ping connection
26
+ // out of the tab registry entirely.
27
+ //
28
+ // pageId tracking: this effect depends on `pageId`, so React tears down and
29
+ // reopens the EventSource (a fresh connection, fresh tab id server-side)
30
+ // whenever the developer navigates to a different page — see the longer
31
+ // design note in src/routes/reload.js.
32
+ //
33
+ // Never let a bad payload or a plugin's operator error crash the app this is
34
+ // piggybacking on — every handler is wrapped, and the component itself
35
+ // always renders null.
36
+ function postResult({ basePath, requestId, result }) {
37
+ try {
38
+ fetch(`${basePath}/api/dev-inspect`, {
39
+ method: 'POST',
40
+ headers: { 'Content-Type': 'application/json' },
41
+ body: JSON.stringify({ requestId, result }),
42
+ }).catch(() => {
43
+ // Best-effort — a failed callback just leaves the agent's request to
44
+ // time out server-side.
45
+ });
46
+ } catch {
47
+ // JSON.stringify or fetch throwing synchronously — still best-effort.
48
+ }
49
+ }
50
+
51
+ function buildSnapshot({ lowdefy, pageId }) {
52
+ const context = lowdefy?.contexts?.[`page:${pageId}`];
53
+ if (type.isNone(context)) {
54
+ return { error: `No live context for page "${pageId}".` };
55
+ }
56
+ return serializer.serializeToString({
57
+ pageId,
58
+ state: context.state,
59
+ requests: context.requests,
60
+ eventLog: (context.eventLog ?? []).slice(-50),
61
+ global: lowdefy.lowdefyGlobal,
62
+ user: lowdefy.user,
63
+ input: lowdefy.inputs?.[`page:${pageId}`],
64
+ urlQuery: window.location.search,
65
+ });
66
+ }
67
+
68
+ // Dev-only agent-state-xray: puts a human tester's real browser tab into a
69
+ // checkpoint's recorded state when it loads a page with `?_checkpoint=<name>`
70
+ // in the URL. Fetches the checkpoint's state part from
71
+ // GET <basePath>/api/dev-inspect/checkpoint/<name> (src/routes/devInspect.js
72
+ // — that fetch also loads the checkpoint's recorded requests into
73
+ // devMockRegistry server-side, so this tab's own requests replay recorded
74
+ // data), then injects state the same way e2e-utils' setState does:
75
+ // context._internal.State.set(...) + context._internal.update(). Polls
76
+ // briefly for the page context to exist since this can run before the page
77
+ // has finished mounting. Never lets a bad checkpoint or a missing context
78
+ // break the app — every step is try/catch, and failures just leave the app
79
+ // running unmodified.
80
+ async function bootstrapFromCheckpoint({ basePath, pageId, checkpointName, isCancelled }) {
81
+ try {
82
+ const response = await fetch(
83
+ `${basePath}/api/dev-inspect/checkpoint/${encodeURIComponent(checkpointName)}`
84
+ );
85
+ const body = await response.json();
86
+ if (!response.ok || body?.error) {
87
+ // eslint-disable-next-line no-console
88
+ console.error(
89
+ `Failed to load checkpoint "${checkpointName}": ${body?.error ?? response.statusText}`
90
+ );
91
+ return;
92
+ }
93
+ const payload = serializer.deserialize(body);
94
+
95
+ const deadline = Date.now() + 5000;
96
+ let context;
97
+ while (!isCancelled() && Date.now() < deadline) {
98
+ context = window.lowdefy?.contexts?.[`page:${pageId}`];
99
+ if (context) {
100
+ break;
101
+ }
102
+ // eslint-disable-next-line no-await-in-loop
103
+ await new Promise((resolve) => setTimeout(resolve, 100));
104
+ }
105
+ if (isCancelled() || type.isNone(context)) {
106
+ return;
107
+ }
108
+
109
+ Object.entries(payload.state ?? {}).forEach(([key, value]) => {
110
+ context._internal.State.set(key, value);
111
+ });
112
+ context._internal.update();
113
+ } catch (error) {
114
+ // eslint-disable-next-line no-console
115
+ console.error(`Failed to load checkpoint "${checkpointName}":`, error);
116
+ }
117
+ }
118
+
119
+ function evalExpression({ lowdefy, pageId, expression }) {
120
+ const context = lowdefy?.contexts?.[`page:${pageId}`];
121
+ if (type.isNone(context)) {
122
+ return { error: `No live context for page "${pageId}".` };
123
+ }
124
+ // Callers may pass the operator expression as a JSON-serializable object,
125
+ // or (since it travels as a JSON string end-to-end from an MCP tool
126
+ // argument) as a JSON string that needs one more parse.
127
+ const input = type.isString(expression) ? JSON.parse(expression) : expression;
128
+ const { output, errors } = context._internal.parser.parse({ input, location: 'agent_eval' });
129
+ return {
130
+ value: serializer.serializeToString(output),
131
+ errors: errors.map((error) => error.message),
132
+ };
133
+ }
134
+
135
+ const Inspector = ({ basePath, lowdefy, pageId }) => {
136
+ const pageIdRef = useRef(pageId);
137
+ pageIdRef.current = pageId;
138
+
139
+ useEffect(() => {
140
+ if (type.isNone(pageId)) {
141
+ return undefined;
142
+ }
143
+
144
+ const sse = new EventSource(`${basePath}/api/reload?pageId=${encodeURIComponent(pageId)}`);
145
+
146
+ sse.addEventListener('inspect-request', (message) => {
147
+ let requestId;
148
+ try {
149
+ const data = JSON.parse(message.data);
150
+ requestId = data.requestId;
151
+ const targetPageId = data.pageId ?? pageIdRef.current;
152
+ const result = buildSnapshot({ lowdefy, pageId: targetPageId });
153
+ postResult({ basePath, requestId, result });
154
+ } catch (error) {
155
+ postResult({ basePath, requestId, result: { error: error.message } });
156
+ }
157
+ });
158
+
159
+ sse.addEventListener('eval-request', (message) => {
160
+ let requestId;
161
+ try {
162
+ const data = JSON.parse(message.data);
163
+ requestId = data.requestId;
164
+ const targetPageId = data.pageId ?? pageIdRef.current;
165
+ const result = evalExpression({
166
+ lowdefy,
167
+ pageId: targetPageId,
168
+ expression: data.expression,
169
+ });
170
+ postResult({ basePath, requestId, result });
171
+ } catch (error) {
172
+ postResult({ basePath, requestId, result: { error: error.message } });
173
+ }
174
+ });
175
+
176
+ return () => {
177
+ sse.close();
178
+ };
179
+ }, [basePath, lowdefy, pageId]);
180
+
181
+ useEffect(() => {
182
+ if (type.isNone(pageId)) {
183
+ return undefined;
184
+ }
185
+ const checkpointName = new URLSearchParams(window.location.search).get('_checkpoint');
186
+ if (type.isNone(checkpointName)) {
187
+ return undefined;
188
+ }
189
+
190
+ let cancelled = false;
191
+ bootstrapFromCheckpoint({
192
+ basePath,
193
+ pageId,
194
+ checkpointName,
195
+ isCancelled: () => cancelled,
196
+ });
197
+
198
+ return () => {
199
+ cancelled = true;
200
+ };
201
+ }, [basePath, pageId]);
202
+
203
+ return null;
204
+ };
205
+
206
+ export default Inspector;
@@ -20,6 +20,7 @@ import Head from '@lowdefy/client/adapters/Head.js';
20
20
  import createLinkComponent from '@lowdefy/client/adapters/Link.js';
21
21
 
22
22
  import BuildingPage from '../lib/client/BuildingPage.jsx';
23
+ import Inspector from './Inspector.jsx';
23
24
  import Reload from './Reload.jsx';
24
25
  import Page from './Page.jsx';
25
26
  import setPageId from '../lib/client/setPageId.js';
@@ -67,31 +68,34 @@ function Routing({ auth, lowdefy, router }) {
67
68
  }
68
69
 
69
70
  return (
70
- <Reload basePath={router.basePath} lowdefy={lowdefy}>
71
- {(resetContext) => (
72
- <Suspense key={`${pageId}_${getReloadVersion()}`} fallback={<BuildingPage />}>
73
- <Page
74
- auth={auth}
75
- Components={{ Head, Link }}
76
- config={{
77
- rootConfig,
78
- }}
79
- jsMap={staticJsMap}
80
- lowdefy={lowdefy}
81
- pageId={pageId}
82
- resetContext={resetContext}
83
- router={router}
84
- types={{
85
- actions,
86
- blockMetas,
87
- blocks,
88
- icons,
89
- operators,
90
- }}
91
- />
92
- </Suspense>
93
- )}
94
- </Reload>
71
+ <>
72
+ <Inspector basePath={router.basePath} lowdefy={lowdefy} pageId={pageId} />
73
+ <Reload basePath={router.basePath} lowdefy={lowdefy}>
74
+ {(resetContext) => (
75
+ <Suspense key={`${pageId}_${getReloadVersion()}`} fallback={<BuildingPage />}>
76
+ <Page
77
+ auth={auth}
78
+ Components={{ Head, Link }}
79
+ config={{
80
+ rootConfig,
81
+ }}
82
+ jsMap={staticJsMap}
83
+ lowdefy={lowdefy}
84
+ pageId={pageId}
85
+ resetContext={resetContext}
86
+ router={router}
87
+ types={{
88
+ actions,
89
+ blockMetas,
90
+ blocks,
91
+ icons,
92
+ operators,
93
+ }}
94
+ />
95
+ </Suspense>
96
+ )}
97
+ </Reload>
98
+ </>
95
99
  );
96
100
  }
97
101
 
@@ -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
+ };
@@ -0,0 +1,231 @@
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
+ import { serializer, type } from '@lowdefy/helpers';
20
+
21
+ import { getConfigDirectory } from './checkpointPaths.js';
22
+
23
+ // State checkpoints (page state + recorded request/api responses) live in a
24
+ // committable, human-diffable folder per checkpoint — <configDir>/checkpoints/
25
+ // <name>/ — separate from the config-directory-snapshot checkpoints in
26
+ // checkpointPaths.js/createConfigCheckpoint.js (those live under
27
+ // .lowdefy/checkpoints and exist to let an agent revert an experimental
28
+ // config edit). These two "checkpoint" concepts are unrelated: this one is
29
+ // for putting the *running app* into a specific state (agent verification,
30
+ // human testing, e2e test generation), not for reverting config files.
31
+ const NAME_PATTERN = /^[a-z0-9-_]+$/i;
32
+
33
+ function getStateCheckpointsRoot() {
34
+ return path.join(getConfigDirectory(), 'checkpoints');
35
+ }
36
+
37
+ function validateName(name) {
38
+ if (type.isNone(name) || !type.isString(name)) {
39
+ throw new Error(`Checkpoint "name" must be a string. Received ${JSON.stringify(name)}.`);
40
+ }
41
+ if (!NAME_PATTERN.test(name)) {
42
+ throw new Error(
43
+ `Checkpoint name "${name}" is invalid — use only letters, numbers, "-" and "_".`
44
+ );
45
+ }
46
+ }
47
+
48
+ function writeJsonFile(filePath, value) {
49
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
50
+ fs.writeFileSync(filePath, JSON.stringify(serializer.serialize(value), null, 2));
51
+ }
52
+
53
+ function readJsonFile(filePath, defaultValue) {
54
+ if (!fs.existsSync(filePath)) {
55
+ return defaultValue;
56
+ }
57
+ return serializer.deserialize(JSON.parse(fs.readFileSync(filePath, 'utf8')));
58
+ }
59
+
60
+ // Requests/api responses arrive as either an array of entries (newest first —
61
+ // the shape of context.requests in both Requests.js and Inspector.jsx's
62
+ // buildSnapshot) or a single entry object. Either way only the newest
63
+ // recording is kept per id.
64
+ function newestEntry(value) {
65
+ return Array.isArray(value) ? value[0] : value;
66
+ }
67
+
68
+ function writeEntries({ dir, subdir, entries, parts }) {
69
+ Object.entries(entries ?? {}).forEach(([id, value]) => {
70
+ const entry = newestEntry(value);
71
+ if (type.isNone(entry)) {
72
+ return;
73
+ }
74
+ const { payload, response, error, responseTime } = entry;
75
+ writeJsonFile(path.join(dir, subdir, `${id}.json`), { payload, response, error, responseTime });
76
+ parts.push(`${subdir}/${id}.json`);
77
+ });
78
+ }
79
+
80
+ function readEntries({ dir, subdir }) {
81
+ const entriesDir = path.join(dir, subdir);
82
+ const entries = {};
83
+ if (!fs.existsSync(entriesDir)) {
84
+ return entries;
85
+ }
86
+ fs.readdirSync(entriesDir)
87
+ .filter((fileName) => fileName.endsWith('.json'))
88
+ .forEach((fileName) => {
89
+ const id = fileName.slice(0, -'.json'.length);
90
+ entries[id] = readJsonFile(path.join(entriesDir, fileName), null);
91
+ });
92
+ return entries;
93
+ }
94
+
95
+ // Snapshots a running page's state + recorded request/api responses into a
96
+ // committable folder. Refuses to clobber an existing checkpoint unless
97
+ // `overwrite: true` — when overwriting, the old folder is removed first so a
98
+ // checkpoint written with fewer requests than a previous one doesn't leave
99
+ // stale per-request files behind.
100
+ function writeCheckpoint({ name, snapshot, notes, overwrite }) {
101
+ validateName(name);
102
+ if (type.isNone(snapshot) || !type.isObject(snapshot)) {
103
+ throw new Error(
104
+ `writeCheckpoint requires a "snapshot" object. Received ${JSON.stringify(snapshot)}.`
105
+ );
106
+ }
107
+
108
+ const dir = path.join(getStateCheckpointsRoot(), name);
109
+ const exists = fs.existsSync(dir);
110
+ if (exists && overwrite !== true) {
111
+ throw new Error(`Checkpoint "${name}" already exists. Pass overwrite: true to replace it.`);
112
+ }
113
+ if (exists) {
114
+ fs.rmSync(dir, { recursive: true, force: true });
115
+ }
116
+ fs.mkdirSync(dir, { recursive: true });
117
+
118
+ const parts = [];
119
+ const manifest = {
120
+ name,
121
+ pageId: snapshot.pageId ?? null,
122
+ url: snapshot.url ?? null,
123
+ capturedAt: new Date().toISOString(),
124
+ notes: notes ?? null,
125
+ };
126
+ writeJsonFile(path.join(dir, 'checkpoint.json'), manifest);
127
+ parts.push('checkpoint.json');
128
+
129
+ writeJsonFile(path.join(dir, 'state.json'), snapshot.state ?? {});
130
+ parts.push('state.json');
131
+ writeJsonFile(path.join(dir, 'urlQuery.json'), snapshot.urlQuery ?? '');
132
+ parts.push('urlQuery.json');
133
+ writeJsonFile(path.join(dir, 'inputs.json'), snapshot.input ?? {});
134
+ parts.push('inputs.json');
135
+ writeJsonFile(path.join(dir, 'user.json'), snapshot.user ?? null);
136
+ parts.push('user.json');
137
+ writeJsonFile(path.join(dir, 'global.json'), snapshot.global ?? {});
138
+ parts.push('global.json');
139
+
140
+ writeEntries({ dir, subdir: 'requests', entries: snapshot.requests, parts });
141
+ if (!type.isNone(snapshot.apiResponses)) {
142
+ writeEntries({ dir, subdir: 'api', entries: snapshot.apiResponses, parts });
143
+ }
144
+
145
+ return { dir, parts };
146
+ }
147
+
148
+ // Reads a checkpoint back, tolerant of individually missing parts (a
149
+ // developer may hand-edit or delete files in a committed checkpoint folder) —
150
+ // only a missing checkpoint directory itself is an error.
151
+ function readCheckpoint({ name }) {
152
+ validateName(name);
153
+ const dir = path.join(getStateCheckpointsRoot(), name);
154
+ if (!fs.existsSync(dir)) {
155
+ throw new Error(`Checkpoint "${name}" not found.`);
156
+ }
157
+
158
+ const checkpoint = readJsonFile(path.join(dir, 'checkpoint.json'), {
159
+ name,
160
+ pageId: null,
161
+ url: null,
162
+ capturedAt: null,
163
+ notes: null,
164
+ });
165
+
166
+ return {
167
+ name,
168
+ checkpoint,
169
+ state: readJsonFile(path.join(dir, 'state.json'), {}),
170
+ urlQuery: readJsonFile(path.join(dir, 'urlQuery.json'), ''),
171
+ input: readJsonFile(path.join(dir, 'inputs.json'), {}),
172
+ user: readJsonFile(path.join(dir, 'user.json'), null),
173
+ global: readJsonFile(path.join(dir, 'global.json'), {}),
174
+ requests: readEntries({ dir, subdir: 'requests' }),
175
+ api: readEntries({ dir, subdir: 'api' }),
176
+ };
177
+ }
178
+
179
+ function checkpointExists({ name }) {
180
+ validateName(name);
181
+ return fs.existsSync(path.join(getStateCheckpointsRoot(), name));
182
+ }
183
+
184
+ // Lists stored state checkpoints, most recent first. Corrupted/hand-edited
185
+ // checkpoints without a manifest still show up (with null fields) rather
186
+ // than being silently hidden, since these folders are meant to be
187
+ // human-editable and committable.
188
+ function listStateCheckpoints() {
189
+ const root = getStateCheckpointsRoot();
190
+ if (!fs.existsSync(root)) {
191
+ return [];
192
+ }
193
+
194
+ const checkpoints = fs
195
+ .readdirSync(root, { withFileTypes: true })
196
+ .filter((entry) => entry.isDirectory())
197
+ .map((entry) => {
198
+ const dir = path.join(root, entry.name);
199
+ const manifest = readJsonFile(path.join(dir, 'checkpoint.json'), {
200
+ name: entry.name,
201
+ pageId: null,
202
+ capturedAt: null,
203
+ notes: null,
204
+ });
205
+ const requestsDir = path.join(dir, 'requests');
206
+ const requestCount = fs.existsSync(requestsDir)
207
+ ? fs.readdirSync(requestsDir).filter((fileName) => fileName.endsWith('.json')).length
208
+ : 0;
209
+ return {
210
+ name: entry.name,
211
+ pageId: manifest.pageId ?? null,
212
+ capturedAt: manifest.capturedAt ?? null,
213
+ notes: manifest.notes ?? null,
214
+ requestCount,
215
+ };
216
+ });
217
+
218
+ return checkpoints.sort((a, b) => {
219
+ if (type.isNone(a.capturedAt)) return 1;
220
+ if (type.isNone(b.capturedAt)) return -1;
221
+ return a.capturedAt < b.capturedAt ? 1 : -1;
222
+ });
223
+ }
224
+
225
+ export {
226
+ checkpointExists,
227
+ getStateCheckpointsRoot,
228
+ listStateCheckpoints,
229
+ readCheckpoint,
230
+ writeCheckpoint,
231
+ };
@@ -0,0 +1,65 @@
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 YAML from 'yaml';
18
+
19
+ import { readCheckpoint } from './checkpointStore.js';
20
+
21
+ // Errors can't round-trip through YAML as Error instances (and don't need
22
+ // to) — @lowdefy/e2e-utils' createMockManager accepts a plain string and
23
+ // wraps it itself (`error instanceof Error ? error : new Error(error)`), so
24
+ // only the message is kept here.
25
+ function toMockError(error) {
26
+ if (!error) {
27
+ return undefined;
28
+ }
29
+ return error.message ?? String(error);
30
+ }
31
+
32
+ function toMockEntries(entries, idKey, extraFields = {}) {
33
+ return Object.entries(entries ?? {}).map(([id, entry]) => {
34
+ const error = toMockError(entry?.error);
35
+ const base = { [idKey]: id, ...extraFields };
36
+ if (error) {
37
+ return { ...base, error };
38
+ }
39
+ return { ...base, response: entry?.response ?? null };
40
+ });
41
+ }
42
+
43
+ // Converts a state checkpoint's recorded requests/api responses into the
44
+ // exact shape @lowdefy/e2e-utils' loadStaticMocks/createMockManager expect
45
+ // (`{ requests: [...], api: [...] }`), so a checkpoint recorded from a live
46
+ // dev session can seed a future AI-generated (or hand-written) e2e test's
47
+ // mocks file directly.
48
+ function checkpointToMocks({ name }) {
49
+ let checkpoint;
50
+ try {
51
+ checkpoint = readCheckpoint({ name });
52
+ } catch (error) {
53
+ return { error: error.message };
54
+ }
55
+
56
+ const pageId = checkpoint.checkpoint.pageId;
57
+ const requests = toMockEntries(checkpoint.requests, 'requestId', { pageId });
58
+ const api = toMockEntries(checkpoint.api, 'endpointId');
59
+
60
+ const yamlContent = YAML.stringify({ requests, api });
61
+
62
+ return { requests, api, yaml: yamlContent };
63
+ }
64
+
65
+ export default checkpointToMocks;