@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.
- package/{pages/_app.js → client/App.jsx} +21 -33
- package/client/DevStreamContext.js +30 -0
- package/client/Inspector.jsx +226 -0
- package/{lib/client/Page.js → client/Page.jsx} +4 -8
- package/client/Reload.jsx +85 -0
- package/client/Routing.jsx +117 -0
- package/client/feedback/FeedbackMount.jsx +83 -0
- package/client/feedback/FeedbackOverlay.jsx +901 -0
- package/client/feedback/captureTabScreenshot.js +84 -0
- package/client/feedback/copyToClipboard.js +45 -0
- package/client/feedback/drawAnnotationsSvg.js +119 -0
- package/client/feedback/elementInspect.js +99 -0
- package/client/feedback/feedbackStyles.js +301 -0
- package/client/feedback/pinResponsiveImages.js +59 -0
- package/client/feedback/sendFeedback.js +39 -0
- package/client/feedback/useFeedbackToggle.js +70 -0
- package/client/main.jsx +42 -0
- package/client/openInEditor/OpenInEditorListener.jsx +268 -0
- package/client/openInEditor/findBlockLocation.js +51 -0
- package/lib/build/app.js +8 -1
- package/lib/build/appMeta.js +8 -1
- package/lib/build/auth.js +8 -1
- package/lib/build/config.js +8 -1
- package/lib/build/i18n.js +8 -1
- package/lib/build/logger.js +8 -1
- package/lib/build/theme.js +8 -1
- package/lib/client/auth/{Auth.js → Auth.jsx} +11 -2
- package/lib/client/auth/{AuthConfigured.js → AuthConfigured.jsx} +23 -10
- package/lib/client/setPageId.js +6 -5
- package/lib/client/utils/useMutateCache.js +14 -1
- package/lib/client/utils/usePageConfig.js +45 -43
- package/lib/client/utils/usePageConfig.test.mjs +62 -0
- package/lib/docs/captureAnnotatedScreenshot.js +88 -0
- package/lib/docs/checkpointPaths.js +91 -0
- package/lib/docs/checkpointStore.js +240 -0
- package/lib/docs/checkpointToMocks.js +65 -0
- package/lib/docs/clientErrorStore.js +36 -0
- package/lib/docs/configCheckpoints.test.mjs +156 -0
- package/lib/docs/createConfigCheckpoint.js +92 -0
- package/lib/docs/createDocsMcpServer.js +531 -0
- package/lib/docs/createDocsMcpServer.test.mjs +141 -0
- package/lib/docs/devMockRegistry.js +68 -0
- package/lib/docs/docs.test.mjs +448 -0
- package/lib/docs/enrichFeedback.js +71 -0
- package/lib/docs/evalOperator.js +47 -0
- package/lib/docs/evalOperator.test.mjs +157 -0
- package/lib/docs/evalOperatorHeadless.js +87 -0
- package/lib/docs/evalOperatorHeadless.test.mjs +72 -0
- package/lib/docs/evalOperatorInTab.js +48 -0
- package/lib/docs/findConfig.js +260 -0
- package/lib/docs/formatFeedback.js +121 -0
- package/lib/docs/formatFeedback.test.mjs +204 -0
- package/lib/docs/getAppMap.js +180 -0
- package/lib/docs/getBrowser.js +127 -0
- package/lib/docs/getBrowser.test.mjs +122 -0
- package/lib/docs/getBuildStatus.js +37 -0
- package/lib/docs/getCoreDoc.js +63 -0
- package/lib/docs/getDocsManifest.js +40 -0
- package/lib/docs/getExamples.js +58 -0
- package/lib/docs/getOverview.js +115 -0
- package/lib/docs/getPageConfig.js +53 -0
- package/lib/docs/getPluginDoc.js +55 -0
- package/lib/docs/getSchema.js +65 -0
- package/lib/docs/inspectState.js +47 -0
- package/lib/docs/inspectState.test.mjs +140 -0
- package/lib/docs/inspectStateFromTab.js +40 -0
- package/lib/docs/inspectStateHeadless.js +84 -0
- package/lib/docs/inspectStateHeadless.test.mjs +60 -0
- package/lib/docs/isWriteRequestsAllowed.js +50 -0
- package/lib/docs/listConfigCheckpoints.js +55 -0
- package/lib/docs/listPlugins.js +80 -0
- package/lib/docs/listTypes.js +105 -0
- package/lib/docs/loadState.js +152 -0
- package/lib/docs/mapPageBuildErrors.js +28 -0
- package/lib/docs/normalizeTypeKind.js +40 -0
- package/{pages/api/endpoints/[...endpointId].js → lib/docs/readBuildArtifact.js} +15 -12
- package/lib/docs/readPageArtifact.js +26 -0
- package/lib/docs/resolvePluginDir.js +49 -0
- package/lib/docs/resolveSource.js +48 -0
- package/lib/docs/resolveSource.test.mjs +100 -0
- package/lib/docs/revertConfigCheckpoint.js +73 -0
- package/lib/docs/runRequest.js +141 -0
- package/lib/docs/saveAnnotatedScreenshot.js +43 -0
- package/lib/docs/scaffoldPage.js +75 -0
- package/lib/docs/scaffoldPage.test.mjs +97 -0
- package/lib/docs/screenshotPage.js +110 -0
- package/lib/docs/screenshotPage.test.mjs +59 -0
- package/lib/docs/screenshotPageClip.test.mjs +102 -0
- package/lib/docs/searchDocs.js +67 -0
- package/lib/docs/setupTestFixtures.mjs +335 -0
- package/lib/docs/snapshotState.js +59 -0
- package/lib/docs/stateCheckpoints.test.mjs +366 -0
- package/lib/docs/tabAvailable.js +28 -0
- package/lib/docs/tabChannel.js +119 -0
- package/lib/docs/tabChannel.test.mjs +130 -0
- package/lib/server/auth/{getAuthOptions.js → getAuthConfig.js} +7 -4
- package/lib/server/auth/getDevSession.js +70 -0
- package/lib/server/auth/getDevSession.test.mjs +138 -0
- package/lib/server/auth/getHeadlessUser.js +40 -0
- package/lib/server/auth/{getMockSession.js → getMockUser.js} +8 -21
- package/lib/server/auth/headlessUser.js +29 -0
- package/lib/server/auth/resolveHeadlessUser.js +38 -0
- package/lib/server/auth/resolveHeadlessUser.test.mjs +78 -0
- package/lib/server/auth/session.js +39 -0
- package/lib/server/auth/strategies.js +47 -0
- package/lib/server/createLowdefyContext.js +122 -0
- package/lib/server/getPageJitEnrichment.test.mjs +105 -0
- package/lib/server/jitPageBuilder.js +125 -12
- package/lib/server/log/createHandleError.test.mjs +126 -0
- package/manager/getContext.mjs +3 -7
- package/manager/processes/initialBuild.mjs +0 -2
- package/manager/processes/installPlugins.mjs +19 -11
- package/manager/processes/lowdefyBuild.mjs +29 -7
- package/manager/processes/lowdefyBuild.test.mjs +102 -0
- package/manager/processes/readDotEnv.mjs +13 -1
- package/manager/processes/shutdownServer.mjs +6 -6
- package/manager/processes/startProxy.mjs +160 -0
- package/manager/processes/startProxy.test.mjs +102 -0
- package/manager/processes/startServer.mjs +33 -16
- package/manager/processes/startWatchers.mjs +2 -2
- package/manager/processes/warnAuthUrlPortMismatch.mjs +47 -0
- package/manager/processes/warnAuthUrlPortMismatch.test.mjs +93 -0
- package/manager/run.mjs +50 -0
- package/manager/utils/createCustomPluginMessagesMap.mjs +8 -1
- package/manager/utils/createCustomPluginTypesMap.mjs +1 -0
- package/manager/utils/formatNoticeBox.mjs +30 -0
- package/manager/utils/{getNextBin.mjs → getViteBin.mjs} +7 -10
- package/manager/utils/updatePageTailwindCss.mjs +7 -1
- package/manager/utils/writeBuildStatus.mjs +39 -0
- package/manager/utils/writeBuildStatus.test.mjs +76 -0
- package/manager/watchers/lowdefyBuildWatcher.mjs +0 -1
- package/manager/watchers/moduleBuildWatcher.mjs +0 -1
- package/manager/watchers/{nextBuildWatcher.mjs → serverArtifactWatcher.mjs} +18 -20
- package/package.json +61 -47
- package/package.original.json +64 -50
- package/postcss.config.cjs +1 -0
- package/src/app.js +175 -0
- package/src/html/renderDevPage.js +86 -0
- package/src/lib/getPathSegments.js +25 -0
- package/src/lib/safeScriptJson.js +38 -0
- package/src/middleware/apiContext.js +32 -0
- package/src/middleware/errorHandler.js +48 -0
- package/src/middleware/errorHandler.test.mjs +235 -0
- package/src/routes/agent.js +64 -0
- package/src/routes/apiPage.js +37 -0
- package/src/routes/auth.js +49 -0
- package/src/routes/auth.test.mjs +84 -0
- package/src/routes/clientError.js +57 -0
- package/src/routes/cron.js +47 -0
- package/src/routes/cronForward.js +47 -0
- package/src/routes/detached.js +47 -0
- package/src/routes/devInspect.js +111 -0
- package/src/routes/devInspect.test.mjs +119 -0
- package/{pages/[[...pageId]].js → src/routes/devTools.js} +4 -2
- package/{pages/404.js → src/routes/docs/appMap.js} +6 -2
- package/{pages/api/dev-tools.js → src/routes/docs/buildStatus.js} +5 -4
- package/src/routes/docs/checkpointsCreate.js +28 -0
- package/src/routes/docs/checkpointsList.js +23 -0
- package/{pages/api/icons/dynamic.js → src/routes/docs/checkpointsRevert.js} +11 -12
- package/src/routes/docs/content.js +31 -0
- package/src/routes/docs/evalOperator.js +56 -0
- package/src/routes/docs/examples.js +33 -0
- package/src/routes/docs/find.js +30 -0
- package/src/routes/docs/index.js +23 -0
- package/src/routes/docs/inspectState.js +44 -0
- package/src/routes/docs/loadState.js +49 -0
- package/src/routes/docs/mcp.js +30 -0
- package/src/routes/docs/pageConfig.js +36 -0
- package/src/routes/docs/parseUserParam.js +52 -0
- package/src/routes/docs/parseUserParam.test.mjs +45 -0
- package/src/routes/docs/pluginDoc.js +33 -0
- package/src/routes/docs/plugins.js +23 -0
- package/src/routes/docs/runRequest.js +36 -0
- package/{lib/server/auth/getServerSession.js → src/routes/docs/schema.js} +17 -15
- package/src/routes/docs/screenshot.js +72 -0
- package/src/routes/docs/search.js +27 -0
- package/src/routes/docs/snapshotState.js +36 -0
- package/src/routes/docs/stateCheckpointsList.js +26 -0
- package/src/routes/docs/types.js +33 -0
- package/src/routes/endpoints.js +71 -0
- package/src/routes/feedback.js +110 -0
- package/src/routes/feedback.test.mjs +204 -0
- package/src/routes/jitPage.js +86 -0
- package/src/routes/mcp.js +34 -0
- package/{pages/api → src/routes}/ping.js +4 -2
- package/src/routes/reload.js +79 -0
- package/src/routes/request.js +74 -0
- package/src/routes/request.test.mjs +124 -0
- package/{pages/api → src/routes}/root.js +4 -4
- package/src/routes/usage.js +46 -0
- package/src/routes/websocket.js +33 -0
- package/src/websocket/devWebSocket.js +78 -0
- package/vite.config.js +111 -0
- package/.eslintrc.yaml +0 -1
- package/lib/client/App.js +0 -80
- package/lib/client/Reload.js +0 -60
- package/lib/server/apiWrapper.js +0 -109
- package/lib/server/compileCss.js +0 -39
- package/manager/processes/compileCss.mjs +0 -54
- package/manager/processes/nextBuild.mjs +0 -50
- package/next.config.js +0 -25
- package/pages/_document.js +0 -109
- package/pages/api/agent/[...path].js +0 -86
- package/pages/api/auth/[...nextauth].js +0 -48
- package/pages/api/client-error.js +0 -45
- package/pages/api/js/[env].js +0 -42
- package/pages/api/page/[...pageId].js +0 -74
- package/pages/api/reload.js +0 -53
- package/pages/api/request/[...path].js +0 -41
- /package/lib/client/{BuildErrorPage.js → BuildErrorPage.jsx} +0 -0
- /package/lib/client/{BuildingPage.js → BuildingPage.jsx} +0 -0
- /package/lib/client/{ErrorBar.js → ErrorBar.jsx} +0 -0
- /package/lib/client/{InstallingPluginsPage.js → InstallingPluginsPage.jsx} +0 -0
- /package/lib/client/{RestartingPage.js → RestartingPage.jsx} +0 -0
|
@@ -0,0 +1,240 @@
|
|
|
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 { isReserved, serializer, setKey, type } from '@lowdefy/helpers';
|
|
20
|
+
|
|
21
|
+
import { getConfigDirectory } from './checkpointPaths.js';
|
|
22
|
+
|
|
23
|
+
// State checkpoints (page state + recorded request/api responses) live under
|
|
24
|
+
// <configDir>/.lowdefy/state-checkpoints/<name>/ — inside .lowdefy because a
|
|
25
|
+
// checkpoint captures the signed-in user's data and live backend responses,
|
|
26
|
+
// which must not sit in a committable spot by default (.lowdefy is gitignored
|
|
27
|
+
// by convention and ignored by the dev file watcher). A sibling of
|
|
28
|
+
// .lowdefy/checkpoints (the config-directory-snapshot checkpoints in
|
|
29
|
+
// checkpointPaths.js/createConfigCheckpoint.js) rather than a subdirectory,
|
|
30
|
+
// because that store prunes its oldest directories beyond a cap. These two
|
|
31
|
+
// "checkpoint" concepts are unrelated: this one is for putting the *running
|
|
32
|
+
// app* into a specific state (agent verification, human testing, e2e test
|
|
33
|
+
// generation), not for reverting config files.
|
|
34
|
+
const NAME_PATTERN = /^[a-z0-9-_]+$/i;
|
|
35
|
+
|
|
36
|
+
function getStateCheckpointsRoot() {
|
|
37
|
+
return path.join(getConfigDirectory(), '.lowdefy', 'state-checkpoints');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function validateName(name) {
|
|
41
|
+
if (type.isNone(name) || !type.isString(name)) {
|
|
42
|
+
throw new Error(`Checkpoint "name" must be a string. Received ${JSON.stringify(name)}.`);
|
|
43
|
+
}
|
|
44
|
+
if (!NAME_PATTERN.test(name)) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Checkpoint name "${name}" is invalid — use only letters, numbers, "-" and "_".`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeJsonFile(filePath, value) {
|
|
52
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
53
|
+
fs.writeFileSync(filePath, JSON.stringify(serializer.serialize(value), null, 2));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readJsonFile(filePath, defaultValue) {
|
|
57
|
+
if (!fs.existsSync(filePath)) {
|
|
58
|
+
return defaultValue;
|
|
59
|
+
}
|
|
60
|
+
return serializer.deserialize(JSON.parse(fs.readFileSync(filePath, 'utf8')));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Requests/api responses arrive as either an array of entries (newest first —
|
|
64
|
+
// the shape of context.requests in both Requests.js and Inspector.jsx's
|
|
65
|
+
// buildSnapshot) or a single entry object. Either way only the newest
|
|
66
|
+
// recording is kept per id.
|
|
67
|
+
function newestEntry(value) {
|
|
68
|
+
return Array.isArray(value) ? value[0] : value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function writeEntries({ dir, subdir, entries, parts }) {
|
|
72
|
+
Object.entries(entries ?? {}).forEach(([id, value]) => {
|
|
73
|
+
const entry = newestEntry(value);
|
|
74
|
+
if (type.isNone(entry)) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const { payload, response, error, responseTime } = entry;
|
|
78
|
+
writeJsonFile(path.join(dir, subdir, `${id}.json`), { payload, response, error, responseTime });
|
|
79
|
+
parts.push(`${subdir}/${id}.json`);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readEntries({ dir, subdir }) {
|
|
84
|
+
const entriesDir = path.join(dir, subdir);
|
|
85
|
+
const entries = {};
|
|
86
|
+
if (!fs.existsSync(entriesDir)) {
|
|
87
|
+
return entries;
|
|
88
|
+
}
|
|
89
|
+
fs.readdirSync(entriesDir)
|
|
90
|
+
.filter((fileName) => fileName.endsWith('.json'))
|
|
91
|
+
.forEach((fileName) => {
|
|
92
|
+
const id = fileName.slice(0, -'.json'.length);
|
|
93
|
+
// A checkpoint directory is on-disk state a developer can edit by hand.
|
|
94
|
+
// A reserved file name can't round-trip through the entries map, so skip
|
|
95
|
+
// it rather than fail the whole checkpoint load.
|
|
96
|
+
if (isReserved(id)) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
setKey(entries, id, readJsonFile(path.join(entriesDir, fileName), null));
|
|
100
|
+
});
|
|
101
|
+
return entries;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Snapshots a running page's state + recorded request/api responses into a
|
|
105
|
+
// checkpoint folder. Refuses to clobber an existing checkpoint unless
|
|
106
|
+
// `overwrite: true` — when overwriting, the old folder is removed first so a
|
|
107
|
+
// checkpoint written with fewer requests than a previous one doesn't leave
|
|
108
|
+
// stale per-request files behind.
|
|
109
|
+
function writeCheckpoint({ name, snapshot, notes, overwrite }) {
|
|
110
|
+
validateName(name);
|
|
111
|
+
if (type.isNone(snapshot) || !type.isObject(snapshot)) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`writeCheckpoint requires a "snapshot" object. Received ${JSON.stringify(snapshot)}.`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const dir = path.join(getStateCheckpointsRoot(), name);
|
|
118
|
+
const exists = fs.existsSync(dir);
|
|
119
|
+
if (exists && overwrite !== true) {
|
|
120
|
+
throw new Error(`Checkpoint "${name}" already exists. Pass overwrite: true to replace it.`);
|
|
121
|
+
}
|
|
122
|
+
if (exists) {
|
|
123
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
126
|
+
|
|
127
|
+
const parts = [];
|
|
128
|
+
const manifest = {
|
|
129
|
+
name,
|
|
130
|
+
pageId: snapshot.pageId ?? null,
|
|
131
|
+
url: snapshot.url ?? null,
|
|
132
|
+
capturedAt: new Date().toISOString(),
|
|
133
|
+
notes: notes ?? null,
|
|
134
|
+
};
|
|
135
|
+
writeJsonFile(path.join(dir, 'checkpoint.json'), manifest);
|
|
136
|
+
parts.push('checkpoint.json');
|
|
137
|
+
|
|
138
|
+
writeJsonFile(path.join(dir, 'state.json'), snapshot.state ?? {});
|
|
139
|
+
parts.push('state.json');
|
|
140
|
+
writeJsonFile(path.join(dir, 'urlQuery.json'), snapshot.urlQuery ?? '');
|
|
141
|
+
parts.push('urlQuery.json');
|
|
142
|
+
writeJsonFile(path.join(dir, 'inputs.json'), snapshot.input ?? {});
|
|
143
|
+
parts.push('inputs.json');
|
|
144
|
+
writeJsonFile(path.join(dir, 'user.json'), snapshot.user ?? null);
|
|
145
|
+
parts.push('user.json');
|
|
146
|
+
writeJsonFile(path.join(dir, 'global.json'), snapshot.global ?? {});
|
|
147
|
+
parts.push('global.json');
|
|
148
|
+
|
|
149
|
+
writeEntries({ dir, subdir: 'requests', entries: snapshot.requests, parts });
|
|
150
|
+
if (!type.isNone(snapshot.apiResponses)) {
|
|
151
|
+
writeEntries({ dir, subdir: 'api', entries: snapshot.apiResponses, parts });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { dir, parts };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Reads a checkpoint back, tolerant of individually missing parts (a
|
|
158
|
+
// developer may hand-edit or delete files in a committed checkpoint folder) —
|
|
159
|
+
// only a missing checkpoint directory itself is an error.
|
|
160
|
+
function readCheckpoint({ name }) {
|
|
161
|
+
validateName(name);
|
|
162
|
+
const dir = path.join(getStateCheckpointsRoot(), name);
|
|
163
|
+
if (!fs.existsSync(dir)) {
|
|
164
|
+
throw new Error(`Checkpoint "${name}" not found.`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const checkpoint = readJsonFile(path.join(dir, 'checkpoint.json'), {
|
|
168
|
+
name,
|
|
169
|
+
pageId: null,
|
|
170
|
+
url: null,
|
|
171
|
+
capturedAt: null,
|
|
172
|
+
notes: null,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
name,
|
|
177
|
+
checkpoint,
|
|
178
|
+
state: readJsonFile(path.join(dir, 'state.json'), {}),
|
|
179
|
+
urlQuery: readJsonFile(path.join(dir, 'urlQuery.json'), ''),
|
|
180
|
+
input: readJsonFile(path.join(dir, 'inputs.json'), {}),
|
|
181
|
+
user: readJsonFile(path.join(dir, 'user.json'), null),
|
|
182
|
+
global: readJsonFile(path.join(dir, 'global.json'), {}),
|
|
183
|
+
requests: readEntries({ dir, subdir: 'requests' }),
|
|
184
|
+
api: readEntries({ dir, subdir: 'api' }),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function checkpointExists({ name }) {
|
|
189
|
+
validateName(name);
|
|
190
|
+
return fs.existsSync(path.join(getStateCheckpointsRoot(), name));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Lists stored state checkpoints, most recent first. Corrupted/hand-edited
|
|
194
|
+
// checkpoints without a manifest still show up (with null fields) rather
|
|
195
|
+
// than being silently hidden, since these folders are meant to be
|
|
196
|
+
// human-editable.
|
|
197
|
+
function listStateCheckpoints() {
|
|
198
|
+
const root = getStateCheckpointsRoot();
|
|
199
|
+
if (!fs.existsSync(root)) {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const checkpoints = fs
|
|
204
|
+
.readdirSync(root, { withFileTypes: true })
|
|
205
|
+
.filter((entry) => entry.isDirectory())
|
|
206
|
+
.map((entry) => {
|
|
207
|
+
const dir = path.join(root, entry.name);
|
|
208
|
+
const manifest = readJsonFile(path.join(dir, 'checkpoint.json'), {
|
|
209
|
+
name: entry.name,
|
|
210
|
+
pageId: null,
|
|
211
|
+
capturedAt: null,
|
|
212
|
+
notes: null,
|
|
213
|
+
});
|
|
214
|
+
const requestsDir = path.join(dir, 'requests');
|
|
215
|
+
const requestCount = fs.existsSync(requestsDir)
|
|
216
|
+
? fs.readdirSync(requestsDir).filter((fileName) => fileName.endsWith('.json')).length
|
|
217
|
+
: 0;
|
|
218
|
+
return {
|
|
219
|
+
name: entry.name,
|
|
220
|
+
pageId: manifest.pageId ?? null,
|
|
221
|
+
capturedAt: manifest.capturedAt ?? null,
|
|
222
|
+
notes: manifest.notes ?? null,
|
|
223
|
+
requestCount,
|
|
224
|
+
};
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return checkpoints.sort((a, b) => {
|
|
228
|
+
if (type.isNone(a.capturedAt)) return 1;
|
|
229
|
+
if (type.isNone(b.capturedAt)) return -1;
|
|
230
|
+
return a.capturedAt < b.capturedAt ? 1 : -1;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export {
|
|
235
|
+
checkpointExists,
|
|
236
|
+
getStateCheckpointsRoot,
|
|
237
|
+
listStateCheckpoints,
|
|
238
|
+
readCheckpoint,
|
|
239
|
+
writeCheckpoint,
|
|
240
|
+
};
|
|
@@ -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;
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
// Module-level ring buffer of recent client-reported errors — feeds the
|
|
18
|
+
// getBuildStatus feedback endpoint so agents can see browser errors without
|
|
19
|
+
// tailing server logs. Deliberately in-memory only: entries are lost on
|
|
20
|
+
// server restart, which is fine since this is a live-session debugging aid.
|
|
21
|
+
const MAX_ENTRIES = 50;
|
|
22
|
+
|
|
23
|
+
const entries = [];
|
|
24
|
+
|
|
25
|
+
function push(entry) {
|
|
26
|
+
entries.push(entry);
|
|
27
|
+
if (entries.length > MAX_ENTRIES) {
|
|
28
|
+
entries.shift();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function list() {
|
|
33
|
+
return [...entries];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default { push, list };
|
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
|
|
21
|
+
import { MAX_CHECKPOINTS } from './checkpointPaths.js';
|
|
22
|
+
import createConfigCheckpoint from './createConfigCheckpoint.js';
|
|
23
|
+
import listConfigCheckpoints from './listConfigCheckpoints.js';
|
|
24
|
+
import revertConfigCheckpoint from './revertConfigCheckpoint.js';
|
|
25
|
+
|
|
26
|
+
let configDirectory;
|
|
27
|
+
let previousConfigDirectory;
|
|
28
|
+
let previousCwd;
|
|
29
|
+
|
|
30
|
+
function writeFile(relativePath, content) {
|
|
31
|
+
const filePath = path.join(configDirectory, relativePath);
|
|
32
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
33
|
+
fs.writeFileSync(filePath, content);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readFile(relativePath) {
|
|
37
|
+
return fs.readFileSync(path.join(configDirectory, relativePath), 'utf8');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function exists(relativePath) {
|
|
41
|
+
return fs.existsSync(path.join(configDirectory, relativePath));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Checkpoint ids are timestamp-prefixed (see createConfigCheckpoint.js), so
|
|
45
|
+
// tests asserting ordering/eviction need genuinely distinct timestamps
|
|
46
|
+
// between calls. A real (short) delay is used instead of mocking the clock —
|
|
47
|
+
// this Node/Jest combination's fake timers cannot redefine `performance`.
|
|
48
|
+
function sleep(ms) {
|
|
49
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
configDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'lowdefy-config-checkpoints-test-'));
|
|
54
|
+
previousConfigDirectory = process.env.LOWDEFY_DIRECTORY_CONFIG;
|
|
55
|
+
process.env.LOWDEFY_DIRECTORY_CONFIG = configDirectory;
|
|
56
|
+
previousCwd = process.cwd();
|
|
57
|
+
process.chdir(configDirectory);
|
|
58
|
+
|
|
59
|
+
writeFile('lowdefy.yaml', 'name: my-app\n');
|
|
60
|
+
writeFile(path.join('pages', 'home.yaml'), 'id: home\n');
|
|
61
|
+
writeFile('.env', 'SECRET=abc\n');
|
|
62
|
+
writeFile('README.md', '# My App\n');
|
|
63
|
+
// Should never be snapshotted.
|
|
64
|
+
writeFile(path.join('node_modules', 'some-dep', 'index.js'), 'module.exports = {};\n');
|
|
65
|
+
writeFile(path.join('.git', 'HEAD'), 'ref: refs/heads/main\n');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
process.chdir(previousCwd);
|
|
70
|
+
fs.rmSync(configDirectory, { recursive: true, force: true });
|
|
71
|
+
if (previousConfigDirectory === undefined) {
|
|
72
|
+
delete process.env.LOWDEFY_DIRECTORY_CONFIG;
|
|
73
|
+
} else {
|
|
74
|
+
process.env.LOWDEFY_DIRECTORY_CONFIG = previousConfigDirectory;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('createConfigCheckpoint snapshots config-relevant files and excludes build/vcs/dependency directories', () => {
|
|
79
|
+
const result = createConfigCheckpoint({ label: 'before change' });
|
|
80
|
+
|
|
81
|
+
expect(result.fileCount).toBe(4);
|
|
82
|
+
expect(result.id).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-before-change$/);
|
|
83
|
+
|
|
84
|
+
const snapshotDirectory = path.join(configDirectory, '.lowdefy', 'checkpoints', result.id);
|
|
85
|
+
const manifest = JSON.parse(
|
|
86
|
+
fs.readFileSync(path.join(snapshotDirectory, 'manifest.json'), 'utf8')
|
|
87
|
+
);
|
|
88
|
+
expect(manifest.files.sort()).toEqual(
|
|
89
|
+
['lowdefy.yaml', path.join('pages', 'home.yaml'), '.env', 'README.md'].sort()
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
expect(fs.readFileSync(path.join(snapshotDirectory, 'lowdefy.yaml'), 'utf8')).toBe(
|
|
93
|
+
'name: my-app\n'
|
|
94
|
+
);
|
|
95
|
+
expect(fs.existsSync(path.join(snapshotDirectory, 'node_modules'))).toBe(false);
|
|
96
|
+
expect(fs.existsSync(path.join(snapshotDirectory, '.git'))).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('listConfigCheckpoints returns an empty array when no checkpoints exist', () => {
|
|
100
|
+
expect(listConfigCheckpoints()).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('listConfigCheckpoints lists checkpoints most recent first', async () => {
|
|
104
|
+
const first = createConfigCheckpoint({ label: 'first' });
|
|
105
|
+
await sleep(5);
|
|
106
|
+
const second = createConfigCheckpoint({ label: 'second' });
|
|
107
|
+
|
|
108
|
+
const checkpoints = listConfigCheckpoints();
|
|
109
|
+
expect(checkpoints).toEqual([
|
|
110
|
+
{ id: second.id, label: 'second', createdAt: expect.any(String), fileCount: 4 },
|
|
111
|
+
{ id: first.id, label: 'first', createdAt: expect.any(String), fileCount: 4 },
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('revertConfigCheckpoint restores modified and deleted files, and deletes files added since the checkpoint', () => {
|
|
116
|
+
const { id } = createConfigCheckpoint({ label: 'snapshot' });
|
|
117
|
+
|
|
118
|
+
// Modify an existing file.
|
|
119
|
+
writeFile('lowdefy.yaml', 'name: modified-app\n');
|
|
120
|
+
// Delete a file that was in the checkpoint.
|
|
121
|
+
fs.rmSync(path.join(configDirectory, 'pages', 'home.yaml'));
|
|
122
|
+
// Add a new config-relevant file that was not in the checkpoint.
|
|
123
|
+
writeFile(path.join('pages', 'extra.yaml'), 'id: extra\n');
|
|
124
|
+
|
|
125
|
+
const result = revertConfigCheckpoint({ id });
|
|
126
|
+
|
|
127
|
+
expect(readFile('lowdefy.yaml')).toBe('name: my-app\n');
|
|
128
|
+
expect(exists(path.join('pages', 'home.yaml'))).toBe(true);
|
|
129
|
+
expect(readFile(path.join('pages', 'home.yaml'))).toBe('id: home\n');
|
|
130
|
+
expect(exists(path.join('pages', 'extra.yaml'))).toBe(false);
|
|
131
|
+
|
|
132
|
+
expect(result.restored.sort()).toEqual(
|
|
133
|
+
['lowdefy.yaml', path.join('pages', 'home.yaml'), '.env', 'README.md'].sort()
|
|
134
|
+
);
|
|
135
|
+
expect(result.deleted).toEqual([path.join('pages', 'extra.yaml')]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('revertConfigCheckpoint throws an actionable error when the checkpoint id does not exist', () => {
|
|
139
|
+
expect(() => revertConfigCheckpoint({ id: 'does-not-exist' })).toThrow(
|
|
140
|
+
'Config checkpoint "does-not-exist" not found.'
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('createConfigCheckpoint evicts the oldest checkpoint once more than the cap are stored', async () => {
|
|
145
|
+
for (let i = 0; i < MAX_CHECKPOINTS + 1; i += 1) {
|
|
146
|
+
createConfigCheckpoint({ label: `checkpoint-${i}` });
|
|
147
|
+
await sleep(2);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const checkpoints = listConfigCheckpoints();
|
|
151
|
+
expect(checkpoints).toHaveLength(MAX_CHECKPOINTS);
|
|
152
|
+
expect(checkpoints.some((checkpoint) => checkpoint.label === 'checkpoint-0')).toBe(false);
|
|
153
|
+
expect(
|
|
154
|
+
checkpoints.some((checkpoint) => checkpoint.label === `checkpoint-${MAX_CHECKPOINTS}`)
|
|
155
|
+
).toBe(true);
|
|
156
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
import {
|
|
21
|
+
getCheckpointsRoot,
|
|
22
|
+
getConfigDirectory,
|
|
23
|
+
listConfigFiles,
|
|
24
|
+
MANIFEST_FILE_NAME,
|
|
25
|
+
MAX_CHECKPOINTS,
|
|
26
|
+
slug,
|
|
27
|
+
} from './checkpointPaths.js';
|
|
28
|
+
|
|
29
|
+
// Checkpoint ids sort chronologically as plain strings (ISO timestamp
|
|
30
|
+
// prefix), which is what listConfigCheckpoints and the checkpoint cap rely
|
|
31
|
+
// on — ":" and "." are replaced since they're awkward/unsafe in directory
|
|
32
|
+
// names on some filesystems.
|
|
33
|
+
function buildCheckpointId(label) {
|
|
34
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
35
|
+
return `${timestamp}-${slug(label)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function copyIntoSnapshot({ configDirectory, snapshotDirectory, relativeFilePath }) {
|
|
39
|
+
const source = path.join(configDirectory, relativeFilePath);
|
|
40
|
+
const destination = path.join(snapshotDirectory, relativeFilePath);
|
|
41
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
42
|
+
fs.copyFileSync(source, destination);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Deletes the oldest checkpoints once the stored count exceeds MAX_CHECKPOINTS.
|
|
46
|
+
// Checkpoint directory names sort chronologically, so a plain string sort
|
|
47
|
+
// puts the oldest first.
|
|
48
|
+
function enforceCheckpointCap({ checkpointsRoot }) {
|
|
49
|
+
const ids = fs
|
|
50
|
+
.readdirSync(checkpointsRoot, { withFileTypes: true })
|
|
51
|
+
.filter((entry) => entry.isDirectory())
|
|
52
|
+
.map((entry) => entry.name)
|
|
53
|
+
.sort();
|
|
54
|
+
|
|
55
|
+
const excessCount = ids.length - MAX_CHECKPOINTS;
|
|
56
|
+
if (excessCount <= 0) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
ids.slice(0, excessCount).forEach((id) => {
|
|
60
|
+
fs.rmSync(path.join(checkpointsRoot, id), { recursive: true, force: true });
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Snapshots every config-relevant file in the config directory into
|
|
65
|
+
// <configDir>/.lowdefy/checkpoints/<id>/, alongside a manifest.json listing
|
|
66
|
+
// the files it holds — revertConfigCheckpoint uses that manifest to restore
|
|
67
|
+
// exactly, including deleting files that didn't exist at checkpoint time.
|
|
68
|
+
function createConfigCheckpoint({ label }) {
|
|
69
|
+
const configDirectory = getConfigDirectory();
|
|
70
|
+
const checkpointsRoot = getCheckpointsRoot();
|
|
71
|
+
const id = buildCheckpointId(label);
|
|
72
|
+
const snapshotDirectory = path.join(checkpointsRoot, id);
|
|
73
|
+
|
|
74
|
+
const files = listConfigFiles({ configDirectory });
|
|
75
|
+
|
|
76
|
+
fs.mkdirSync(snapshotDirectory, { recursive: true });
|
|
77
|
+
files.forEach((relativeFilePath) => {
|
|
78
|
+
copyIntoSnapshot({ configDirectory, snapshotDirectory, relativeFilePath });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const createdAt = new Date().toISOString();
|
|
82
|
+
fs.writeFileSync(
|
|
83
|
+
path.join(snapshotDirectory, MANIFEST_FILE_NAME),
|
|
84
|
+
JSON.stringify({ id, label: label ?? null, createdAt, files }, null, 2)
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
enforceCheckpointCap({ checkpointsRoot });
|
|
88
|
+
|
|
89
|
+
return { id, fileCount: files.length };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default createConfigCheckpoint;
|