@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,141 @@
|
|
|
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
|
+
import { callRequest } from '@lowdefy/api';
|
|
19
|
+
|
|
20
|
+
import isWriteRequestsAllowed from './isWriteRequestsAllowed.js';
|
|
21
|
+
import readBuildArtifact from './readBuildArtifact.js';
|
|
22
|
+
|
|
23
|
+
// Cap the serialized response so a large request result can't blow out an
|
|
24
|
+
// agent's context window.
|
|
25
|
+
const MAX_RESPONSE_CHARS = 100_000;
|
|
26
|
+
|
|
27
|
+
function getRequestType({ pageId, requestId }) {
|
|
28
|
+
// The request's `type` is stripped from build/pages/<pageId>.json by the
|
|
29
|
+
// build (packages/build/src/build/full/writeRequests.js deletes
|
|
30
|
+
// type/connectionId/properties/auth after writing the per-request file),
|
|
31
|
+
// so it must be read from the dedicated per-request artifact instead —
|
|
32
|
+
// the same file @lowdefy/api's getRequestConfig reads via readConfigFile.
|
|
33
|
+
const requestConfig = readBuildArtifact({
|
|
34
|
+
name: `pages/${pageId}/requests/${requestId}.json`,
|
|
35
|
+
deserialize: true,
|
|
36
|
+
});
|
|
37
|
+
return requestConfig?.type ?? null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function truncateResponse(result) {
|
|
41
|
+
const json = JSON.stringify(result.response);
|
|
42
|
+
if (json.length <= MAX_RESPONSE_CHARS) {
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
...result,
|
|
47
|
+
response: json.slice(0, MAX_RESPONSE_CHARS),
|
|
48
|
+
truncated: true,
|
|
49
|
+
note: `Response truncated to ${MAX_RESPONSE_CHARS} characters (original serialized size: ${json.length} characters).`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Executes a page request the same way POST /api/request/<pageId>/<requestId>
|
|
54
|
+
// does (src/routes/request.js), but gated for agent use: requests whose type
|
|
55
|
+
// is not declared read-only (checkWrite: false in requestSchemas.json meta)
|
|
56
|
+
// are refused unless the app opts in via lowdefy.yaml's
|
|
57
|
+
// cli.agentTools.allowWriteRequests. Never throws — errors and refusals are
|
|
58
|
+
// returned as data so an agent can reason about them.
|
|
59
|
+
async function runRequest({ pageId, requestId, payload = {}, honoContext }) {
|
|
60
|
+
if (type.isUndefined(pageId) || !type.isString(pageId)) {
|
|
61
|
+
throw new Error(`run_request requires a "pageId" string. Received ${JSON.stringify(pageId)}.`);
|
|
62
|
+
}
|
|
63
|
+
if (type.isUndefined(requestId) || !type.isString(requestId)) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`run_request requires a "requestId" string. Received ${JSON.stringify(requestId)}.`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let requestType = getRequestType({ pageId, requestId });
|
|
70
|
+
if (type.isNone(requestType)) {
|
|
71
|
+
// In dev, page content (including per-request artifacts) is built JIT on
|
|
72
|
+
// first page request — trigger the same build GET /api/page/* runs so a
|
|
73
|
+
// freshly added request is found without the page ever being opened.
|
|
74
|
+
try {
|
|
75
|
+
const { default: buildPageIfNeeded } = await import('../server/jitPageBuilder.js');
|
|
76
|
+
const path = await import('node:path');
|
|
77
|
+
await buildPageIfNeeded({
|
|
78
|
+
pageId,
|
|
79
|
+
buildDirectory: path.join(process.cwd(), 'build'),
|
|
80
|
+
configDirectory: process.env.LOWDEFY_DIRECTORY_CONFIG || process.cwd(),
|
|
81
|
+
});
|
|
82
|
+
requestType = getRequestType({ pageId, requestId });
|
|
83
|
+
} catch {
|
|
84
|
+
// JIT build failure surfaces through lowdefy_build_status — fall
|
|
85
|
+
// through to the not-found refusal below.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (type.isNone(requestType)) {
|
|
89
|
+
return {
|
|
90
|
+
refused: true,
|
|
91
|
+
reason:
|
|
92
|
+
`Request "${requestId}" was not found on page "${pageId}". ` +
|
|
93
|
+
`See GET /lowdefy-docs/app-map for the pages and requests that exist.`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const requestSchemas = readBuildArtifact({ name: 'plugins/requestSchemas.json' }) ?? {};
|
|
98
|
+
const meta = requestSchemas[requestType]?.meta ?? {};
|
|
99
|
+
|
|
100
|
+
if (meta.checkWrite !== false) {
|
|
101
|
+
const allowed = await isWriteRequestsAllowed();
|
|
102
|
+
if (!allowed) {
|
|
103
|
+
return {
|
|
104
|
+
refused: true,
|
|
105
|
+
reason:
|
|
106
|
+
meta.checkWrite === true
|
|
107
|
+
? `Request type "${requestType}" performs writes (checkWrite: true) and agent write access is disabled.`
|
|
108
|
+
: `Request type "${requestType}" has no declared read/write meta, so it is treated as a write for safety.`,
|
|
109
|
+
howToEnable: 'Set cli.agentTools.allowWriteRequests: true in lowdefy.yaml (dev only).',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Deferred import: createLowdefyContext statically imports build/plugins/*
|
|
115
|
+
// artifacts, which only exist in a running server directory — importing it
|
|
116
|
+
// at module load would break every consumer of this module (e.g. the MCP
|
|
117
|
+
// server) in environments without a full build.
|
|
118
|
+
const { default: createLowdefyContext } = await import('../server/createLowdefyContext.js');
|
|
119
|
+
const context = await createLowdefyContext({ c: honoContext });
|
|
120
|
+
context.logger.info({ event: 'agent_run_request', pageId, requestId });
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const result = await callRequest(context, {
|
|
124
|
+
blockId: undefined,
|
|
125
|
+
pageId,
|
|
126
|
+
payload,
|
|
127
|
+
requestId,
|
|
128
|
+
});
|
|
129
|
+
return { refused: false, ...truncateResponse(result) };
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return {
|
|
132
|
+
refused: false,
|
|
133
|
+
error: {
|
|
134
|
+
name: error.name,
|
|
135
|
+
message: error.message,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export default runRequest;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
const PNG_DATA_URL_PREFIX = 'data:image/png;base64,';
|
|
21
|
+
|
|
22
|
+
// Saves a tab-captured annotated screenshot (PNG data URL POSTed with the
|
|
23
|
+
// feedback batch) under the config dir's .lowdefy/annotations/ — the same
|
|
24
|
+
// location and naming captureAnnotatedScreenshot.js uses, so formatFeedback's
|
|
25
|
+
// screenshotPath contract is identical for both capture paths. Never throws —
|
|
26
|
+
// returns { path } or { error }.
|
|
27
|
+
function saveAnnotatedScreenshot({ dataUrl, fileName }) {
|
|
28
|
+
if (typeof dataUrl !== 'string' || !dataUrl.startsWith(PNG_DATA_URL_PREFIX)) {
|
|
29
|
+
return { error: 'Screenshot is not a PNG data URL.' };
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const buffer = Buffer.from(dataUrl.slice(PNG_DATA_URL_PREFIX.length), 'base64');
|
|
33
|
+
const configDirectory = process.env.LOWDEFY_DIRECTORY_CONFIG || process.cwd();
|
|
34
|
+
const dir = path.join(configDirectory, '.lowdefy', 'annotations');
|
|
35
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
36
|
+
fs.writeFileSync(path.join(dir, fileName), buffer);
|
|
37
|
+
return { path: path.join('.lowdefy', 'annotations', fileName) };
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return { error: `Failed to save annotated screenshot: ${error.message}` };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default saveAnnotatedScreenshot;
|
|
@@ -0,0 +1,75 @@
|
|
|
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 { type } from '@lowdefy/helpers';
|
|
20
|
+
|
|
21
|
+
import readBuildArtifact from './readBuildArtifact.js';
|
|
22
|
+
|
|
23
|
+
const pageIdPattern = /^[a-z0-9-_]+$/i;
|
|
24
|
+
|
|
25
|
+
function buildPageYaml({ pageId, title }) {
|
|
26
|
+
const titleJson = JSON.stringify(title);
|
|
27
|
+
return `id: ${pageId}
|
|
28
|
+
type: PageHeaderMenu
|
|
29
|
+
properties:
|
|
30
|
+
title: ${titleJson}
|
|
31
|
+
blocks:
|
|
32
|
+
- id: ${pageId}_title
|
|
33
|
+
type: Title
|
|
34
|
+
properties:
|
|
35
|
+
content: ${titleJson}
|
|
36
|
+
`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Scaffolds a new page config file for an AI agent to wire up — it never edits lowdefy.yaml
|
|
40
|
+
// itself, since that would risk destroying the user's comments and formatting.
|
|
41
|
+
function scaffoldPage({ pageId, title }) {
|
|
42
|
+
if (type.isNone(pageId) || !pageIdPattern.test(pageId)) {
|
|
43
|
+
return {
|
|
44
|
+
error: `Invalid page id ${JSON.stringify(
|
|
45
|
+
pageId
|
|
46
|
+
)}. Page ids may only contain letters, numbers, "-" and "_".`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const configDirectory = process.env.LOWDEFY_DIRECTORY_CONFIG || process.cwd();
|
|
51
|
+
const pageRelativePath = path.join('pages', `${pageId}.yaml`);
|
|
52
|
+
const pageFilePath = path.join(configDirectory, pageRelativePath);
|
|
53
|
+
|
|
54
|
+
if (fs.existsSync(pageFilePath)) {
|
|
55
|
+
return { error: `"${pageRelativePath}" already exists.` };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const pageRegistry = readBuildArtifact({ name: 'pageRegistry.json' }) ?? {};
|
|
59
|
+
if (Object.keys(pageRegistry).includes(pageId)) {
|
|
60
|
+
return {
|
|
61
|
+
error: `Page id "${pageId}" is already used by another page in the build (see build/pageRegistry.json). Choose a different id.`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const pageTitle = title ?? pageId;
|
|
66
|
+
fs.mkdirSync(path.dirname(pageFilePath), { recursive: true });
|
|
67
|
+
fs.writeFileSync(pageFilePath, buildPageYaml({ pageId, title: pageTitle }));
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
created: pageRelativePath,
|
|
71
|
+
next: `Add \`- _ref: ${pageRelativePath}\` under \`pages:\` in lowdefy.yaml (or the relevant pages file), then check lowdefy_build_status.`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export default scaffoldPage;
|
|
@@ -0,0 +1,97 @@
|
|
|
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 scaffoldPage from './scaffoldPage.js';
|
|
22
|
+
|
|
23
|
+
let configDirectory;
|
|
24
|
+
let previousConfigDirectory;
|
|
25
|
+
let previousCwd;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
configDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'lowdefy-scaffold-page-test-'));
|
|
29
|
+
previousConfigDirectory = process.env.LOWDEFY_DIRECTORY_CONFIG;
|
|
30
|
+
process.env.LOWDEFY_DIRECTORY_CONFIG = configDirectory;
|
|
31
|
+
// readBuildArtifact.js (used to check pageRegistry.json) reads from process.cwd(), which is
|
|
32
|
+
// the same directory as LOWDEFY_DIRECTORY_CONFIG in a real dev server run.
|
|
33
|
+
previousCwd = process.cwd();
|
|
34
|
+
process.chdir(configDirectory);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
process.chdir(previousCwd);
|
|
39
|
+
fs.rmSync(configDirectory, { recursive: true, force: true });
|
|
40
|
+
if (previousConfigDirectory === undefined) {
|
|
41
|
+
delete process.env.LOWDEFY_DIRECTORY_CONFIG;
|
|
42
|
+
} else {
|
|
43
|
+
process.env.LOWDEFY_DIRECTORY_CONFIG = previousConfigDirectory;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('scaffoldPage writes a canonical page file and reports the next step', () => {
|
|
48
|
+
const result = scaffoldPage({ pageId: 'reports', title: 'Reports' });
|
|
49
|
+
|
|
50
|
+
expect(result).toEqual({
|
|
51
|
+
created: path.join('pages', 'reports.yaml'),
|
|
52
|
+
next: 'Add `- _ref: pages/reports.yaml` under `pages:` in lowdefy.yaml (or the relevant pages file), then check lowdefy_build_status.',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const written = fs.readFileSync(path.join(configDirectory, 'pages', 'reports.yaml'), 'utf8');
|
|
56
|
+
expect(written).toContain('id: reports');
|
|
57
|
+
expect(written).toContain('type: PageHeaderMenu');
|
|
58
|
+
expect(written).toContain('title: "Reports"');
|
|
59
|
+
expect(written).toContain('type: Title');
|
|
60
|
+
expect(written).toContain('content: "Reports"');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('scaffoldPage defaults the title to the pageId when no title is given', () => {
|
|
64
|
+
scaffoldPage({ pageId: 'dashboard' });
|
|
65
|
+
|
|
66
|
+
const written = fs.readFileSync(path.join(configDirectory, 'pages', 'dashboard.yaml'), 'utf8');
|
|
67
|
+
expect(written).toContain('title: "dashboard"');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('scaffoldPage refuses an invalid page id', () => {
|
|
71
|
+
const result = scaffoldPage({ pageId: 'has a space' });
|
|
72
|
+
|
|
73
|
+
expect(result.error).toContain('Invalid page id');
|
|
74
|
+
expect(fs.existsSync(path.join(configDirectory, 'pages', 'has a space.yaml'))).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('scaffoldPage refuses when the page file already exists', () => {
|
|
78
|
+
fs.mkdirSync(path.join(configDirectory, 'pages'), { recursive: true });
|
|
79
|
+
fs.writeFileSync(path.join(configDirectory, 'pages', 'reports.yaml'), 'id: reports\n');
|
|
80
|
+
|
|
81
|
+
const result = scaffoldPage({ pageId: 'reports' });
|
|
82
|
+
|
|
83
|
+
expect(result.error).toEqual(`"${path.join('pages', 'reports.yaml')}" already exists.`);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('scaffoldPage refuses when the pageId is already used in the build registry', () => {
|
|
87
|
+
fs.mkdirSync(path.join(configDirectory, 'build'), { recursive: true });
|
|
88
|
+
fs.writeFileSync(
|
|
89
|
+
path.join(configDirectory, 'build', 'pageRegistry.json'),
|
|
90
|
+
JSON.stringify({ reports: {} })
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const result = scaffoldPage({ pageId: 'reports' });
|
|
94
|
+
|
|
95
|
+
expect(result.error).toContain('Page id "reports" is already used');
|
|
96
|
+
expect(fs.existsSync(path.join(configDirectory, 'pages', 'reports.yaml'))).toBe(false);
|
|
97
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
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 { getBrowser, openPage, buildPageUrl } from './getBrowser.js';
|
|
20
|
+
|
|
21
|
+
// A feedback annotation's elementRect/shapes are captured in the developer's
|
|
22
|
+
// live tab, viewport-relative at whatever scroll position they were at
|
|
23
|
+
// (batch.viewport.scrollX/scrollY) — so to recapture the same region
|
|
24
|
+
// headless, this scrolls to that same offset first, then converts the
|
|
25
|
+
// viewport-relative clip to document coordinates by adding the scroll
|
|
26
|
+
// offset back in. Mutually exclusive with fullPage: an explicit clip always
|
|
27
|
+
// wins, since it targets a specific annotated region rather than the page.
|
|
28
|
+
async function resolveClip({ page, clip, scrollX, scrollY }) {
|
|
29
|
+
if (type.isNone(clip)) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
const hasValidPosition = type.isNumber(clip.x) && type.isNumber(clip.y);
|
|
33
|
+
const hasPositiveDims =
|
|
34
|
+
type.isNumber(clip.width) && type.isNumber(clip.height) && clip.width > 0 && clip.height > 0;
|
|
35
|
+
if (!hasValidPosition || !hasPositiveDims) {
|
|
36
|
+
// Invalid clip — fall back to a normal viewport screenshot rather than
|
|
37
|
+
// failing the whole request over a bad clip rect.
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
await page.evaluate(({ x, y }) => window.scrollTo(x, y), { x: scrollX, y: scrollY });
|
|
42
|
+
// Let scroll-triggered rendering (sticky headers, lazy content) settle
|
|
43
|
+
// before clipping, mirroring the load-settle wait below.
|
|
44
|
+
await page.waitForTimeout(200);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
x: clip.x + scrollX,
|
|
48
|
+
y: clip.y + scrollY,
|
|
49
|
+
width: clip.width,
|
|
50
|
+
height: clip.height,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// screenshotPage lets an agent visually verify a page rendered by the
|
|
55
|
+
// running dev server.
|
|
56
|
+
async function screenshotPage({
|
|
57
|
+
origin,
|
|
58
|
+
pageId,
|
|
59
|
+
fullPage = false,
|
|
60
|
+
clip,
|
|
61
|
+
scrollX = 0,
|
|
62
|
+
scrollY = 0,
|
|
63
|
+
user,
|
|
64
|
+
width = 1280,
|
|
65
|
+
height = 800,
|
|
66
|
+
timeout = 15000,
|
|
67
|
+
}) {
|
|
68
|
+
if (type.isNone(origin) || !type.isString(origin)) {
|
|
69
|
+
return {
|
|
70
|
+
error: `screenshotPage requires an "origin" string. Received ${JSON.stringify(origin)}.`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (type.isNone(pageId) || !type.isString(pageId)) {
|
|
74
|
+
return {
|
|
75
|
+
error: `screenshotPage requires a "pageId" string. Received ${JSON.stringify(pageId)}.`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let browser;
|
|
80
|
+
try {
|
|
81
|
+
browser = await getBrowser();
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return {
|
|
84
|
+
error: `No Chromium available. Run: npx playwright install chromium (${error.message})`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const url = buildPageUrl({ origin, pageId });
|
|
89
|
+
|
|
90
|
+
let context;
|
|
91
|
+
try {
|
|
92
|
+
const opened = await openPage({ browser, origin, pageId, user, width, height, timeout });
|
|
93
|
+
context = opened.context;
|
|
94
|
+
// Let post-load rendering (fonts, transitions, client-side state) settle.
|
|
95
|
+
await opened.page.waitForTimeout(300);
|
|
96
|
+
|
|
97
|
+
const docClip = await resolveClip({ page: opened.page, clip, scrollX, scrollY });
|
|
98
|
+
const screenshotOptions = docClip ? { type: 'png', clip: docClip } : { type: 'png', fullPage };
|
|
99
|
+
const buffer = await opened.page.screenshot(screenshotOptions);
|
|
100
|
+
return { data: buffer.toString('base64'), mimeType: 'image/png' };
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return { error: `Failed to screenshot "${url}": ${error.message}` };
|
|
103
|
+
} finally {
|
|
104
|
+
if (context) {
|
|
105
|
+
await context.close();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export default screenshotPage;
|
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
// A real screenshot needs an installed browser, which jest can't rely on, so
|
|
23
|
+
// this only covers the error paths: bad input, and chromium.launch failing
|
|
24
|
+
// (the "no browser installed" case). chromium.launch is mocked so the real
|
|
25
|
+
// browser flow is untouched everywhere else.
|
|
26
|
+
jest.unstable_mockModule('playwright-core', () => ({
|
|
27
|
+
chromium: { launch: jest.fn().mockRejectedValue(new Error("Executable doesn't exist")) },
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
// lib/build/config.js reads build/config.json from process.cwd() at import
|
|
31
|
+
// time — chdir into a fixture that has one before screenshotPage.js (which
|
|
32
|
+
// imports it) is loaded. Mirrors the pattern in docs.test.mjs.
|
|
33
|
+
const originalCwd = process.cwd();
|
|
34
|
+
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lowdefy-screenshot-test-'));
|
|
35
|
+
fs.mkdirSync(path.join(fixtureDir, 'build'), { recursive: true });
|
|
36
|
+
fs.writeFileSync(path.join(fixtureDir, 'build', 'config.json'), JSON.stringify({ basePath: '' }));
|
|
37
|
+
process.chdir(fixtureDir);
|
|
38
|
+
|
|
39
|
+
const { default: screenshotPage } = await import('./screenshotPage.js');
|
|
40
|
+
|
|
41
|
+
afterAll(() => {
|
|
42
|
+
process.chdir(originalCwd);
|
|
43
|
+
fs.rmSync(fixtureDir, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('screenshotPage returns an error when origin is missing', async () => {
|
|
47
|
+
const result = await screenshotPage({ pageId: 'home' });
|
|
48
|
+
expect(result.error).toMatch(/requires an "origin" string/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('screenshotPage returns an error when pageId is missing', async () => {
|
|
52
|
+
const result = await screenshotPage({ origin: 'http://localhost:3001' });
|
|
53
|
+
expect(result.error).toMatch(/requires a "pageId" string/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('screenshotPage returns an actionable error when no browser is available', async () => {
|
|
57
|
+
const result = await screenshotPage({ origin: 'http://localhost:3001', pageId: 'home' });
|
|
58
|
+
expect(result.error).toMatch(/No Chromium available. Run: npx playwright install chromium/);
|
|
59
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
// Covers the clip/scroll success path, which screenshotPage.test.mjs cannot
|
|
20
|
+
// (it deliberately fails chromium.launch to test the "no browser" error
|
|
21
|
+
// path). Mocking the whole ./getBrowser.js module — not just
|
|
22
|
+
// playwright-core — means lib/build/config.js (which getBrowser.js reads at
|
|
23
|
+
// import time) is never touched, so no fixture chdir is needed here.
|
|
24
|
+
const mockScreenshot = jest.fn(async () => Buffer.from('fake-png'));
|
|
25
|
+
const mockWaitForTimeout = jest.fn(async () => {});
|
|
26
|
+
const mockEvaluate = jest.fn(async () => {});
|
|
27
|
+
const mockPage = {
|
|
28
|
+
screenshot: mockScreenshot,
|
|
29
|
+
waitForTimeout: mockWaitForTimeout,
|
|
30
|
+
evaluate: mockEvaluate,
|
|
31
|
+
};
|
|
32
|
+
const mockContext = { close: jest.fn(async () => {}) };
|
|
33
|
+
const mockOpenPage = jest.fn(async () => ({
|
|
34
|
+
context: mockContext,
|
|
35
|
+
page: mockPage,
|
|
36
|
+
url: 'http://localhost:3001/home',
|
|
37
|
+
}));
|
|
38
|
+
const mockGetBrowser = jest.fn(async () => ({}));
|
|
39
|
+
|
|
40
|
+
jest.unstable_mockModule('./getBrowser.js', () => ({
|
|
41
|
+
getBrowser: mockGetBrowser,
|
|
42
|
+
openPage: mockOpenPage,
|
|
43
|
+
buildPageUrl: jest.fn(() => 'http://localhost:3001/home'),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
const { default: screenshotPage } = await import('./screenshotPage.js');
|
|
47
|
+
|
|
48
|
+
test('screenshotPage with a valid clip scrolls to scrollX/scrollY and converts clip to document coordinates', async () => {
|
|
49
|
+
const result = await screenshotPage({
|
|
50
|
+
origin: 'http://localhost:3001',
|
|
51
|
+
pageId: 'home',
|
|
52
|
+
clip: { x: 220, y: 480, width: 96, height: 40 },
|
|
53
|
+
scrollX: 10,
|
|
54
|
+
scrollY: 340,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(result.error).toBeUndefined();
|
|
58
|
+
expect(mockEvaluate).toHaveBeenCalledWith(expect.any(Function), { x: 10, y: 340 });
|
|
59
|
+
expect(mockScreenshot).toHaveBeenCalledWith({
|
|
60
|
+
type: 'png',
|
|
61
|
+
clip: { x: 230, y: 820, width: 96, height: 40 },
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('screenshotPage without a clip takes a normal fullPage/viewport screenshot and never scrolls', async () => {
|
|
66
|
+
const result = await screenshotPage({
|
|
67
|
+
origin: 'http://localhost:3001',
|
|
68
|
+
pageId: 'home',
|
|
69
|
+
fullPage: true,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(result.error).toBeUndefined();
|
|
73
|
+
expect(mockEvaluate).not.toHaveBeenCalled();
|
|
74
|
+
expect(mockScreenshot).toHaveBeenCalledWith({ type: 'png', fullPage: true });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('screenshotPage ignores a clip with non-positive dimensions and falls back to a viewport screenshot', async () => {
|
|
78
|
+
const result = await screenshotPage({
|
|
79
|
+
origin: 'http://localhost:3001',
|
|
80
|
+
pageId: 'home',
|
|
81
|
+
clip: { x: 0, y: 0, width: 0, height: 40 },
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(result.error).toBeUndefined();
|
|
85
|
+
expect(mockEvaluate).not.toHaveBeenCalled();
|
|
86
|
+
expect(mockScreenshot).toHaveBeenCalledWith({ type: 'png', fullPage: false });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('screenshotPage clip takes priority over fullPage when both are provided', async () => {
|
|
90
|
+
const result = await screenshotPage({
|
|
91
|
+
origin: 'http://localhost:3001',
|
|
92
|
+
pageId: 'home',
|
|
93
|
+
fullPage: true,
|
|
94
|
+
clip: { x: 1, y: 2, width: 3, height: 4 },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(result.error).toBeUndefined();
|
|
98
|
+
expect(mockScreenshot).toHaveBeenCalledWith({
|
|
99
|
+
type: 'png',
|
|
100
|
+
clip: { x: 1, y: 2, width: 3, height: 4 },
|
|
101
|
+
});
|
|
102
|
+
});
|