@lowdefy/server-dev 0.0.0-experimental-20260905100314 → 0.0.0-experimental-20260908103554
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/client/DevStreamContext.js +30 -0
- package/client/Inspector.jsx +44 -24
- package/client/Reload.jsx +20 -1
- package/client/Routing.jsx +34 -31
- package/lib/client/auth/AuthConfigured.jsx +7 -2
- package/lib/client/auth/AuthNotConfigured.js +2 -0
- package/lib/docs/createDocsMcpServer.js +2 -2
- package/lib/docs/getOverview.js +1 -1
- package/lib/docs/runJourney.js +179 -45
- package/lib/docs/runJourney.test.mjs +367 -1
- package/lib/docs/validateJourneySteps.js +90 -19
- package/lib/docs/validateJourneySteps.test.mjs +83 -3
- package/manager/processes/startProxy.mjs +9 -0
- package/manager/processes/startProxy.test.mjs +102 -0
- package/package.json +35 -35
- package/package.original.json +35 -35
- package/src/routes/devInspect.js +21 -4
- package/src/routes/devInspect.test.mjs +119 -0
- package/src/routes/reload.js +19 -21
|
@@ -0,0 +1,30 @@
|
|
|
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 { createContext } from 'react';
|
|
18
|
+
|
|
19
|
+
// The one /api/reload EventSource a dev tab holds, owned by Reload.jsx and
|
|
20
|
+
// shared with Inspector.jsx: { source, tabId }. Dev runs on HTTP/1.1, where
|
|
21
|
+
// browsers cap connections per host at six and an open event stream holds
|
|
22
|
+
// one for the life of the tab. Two streams per tab meant three tabs of the
|
|
23
|
+
// same app saturated the pool and the next fetch queued forever, so every
|
|
24
|
+
// dev-only channel rides this single stream instead of opening its own.
|
|
25
|
+
// - source: the EventSource, null until Reload's effect has opened it.
|
|
26
|
+
// - tabId: the id the server assigned this connection in its tab registry
|
|
27
|
+
// (lib/docs/tabChannel.js), null until the stream's `tab` event arrives.
|
|
28
|
+
const DevStreamContext = createContext({ source: null, tabId: null });
|
|
29
|
+
|
|
30
|
+
export default DevStreamContext;
|
package/client/Inspector.jsx
CHANGED
|
@@ -14,40 +14,51 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { useEffect, useRef } from 'react';
|
|
17
|
+
import { useContext, useEffect, useRef } from 'react';
|
|
18
18
|
|
|
19
19
|
import { type, serializer } from '@lowdefy/helpers';
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
21
|
+
import DevStreamContext from './DevStreamContext.js';
|
|
22
|
+
|
|
23
|
+
// Dev-only agent-state-xray channel. Listens for inspect-request/eval-request
|
|
24
|
+
// events on the tab's single /api/reload EventSource (owned by Reload.jsx,
|
|
25
|
+
// shared through DevStreamContext) rather than opening its own — a second
|
|
26
|
+
// stream per tab exhausted the browser's HTTP/1.1 connection pool once a few
|
|
27
|
+
// tabs of the same app were open, and every later fetch queued forever.
|
|
27
28
|
//
|
|
28
|
-
// pageId tracking:
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
29
|
+
// pageId tracking: routes/reload.js registers every connection as an
|
|
30
|
+
// inspectable tab (lib/docs/tabChannel.js) and announces the tab id on the
|
|
31
|
+
// stream. This component then posts { tabId, pageId } to
|
|
32
|
+
// /api/dev-inspect/page whenever the developer navigates, so the registry's
|
|
33
|
+
// view of "what page is this tab on" stays current without reconnecting.
|
|
32
34
|
//
|
|
33
35
|
// Never let a bad payload or a plugin's operator error crash the app this is
|
|
34
36
|
// piggybacking on — every handler is wrapped, and the component itself
|
|
35
37
|
// always renders null.
|
|
36
|
-
function
|
|
38
|
+
function postJson({ basePath, path, body }) {
|
|
37
39
|
try {
|
|
38
|
-
fetch(`${basePath}/api/dev-inspect`, {
|
|
40
|
+
fetch(`${basePath}/api/dev-inspect${path}`, {
|
|
39
41
|
method: 'POST',
|
|
40
42
|
headers: { 'Content-Type': 'application/json' },
|
|
41
|
-
body: JSON.stringify(
|
|
43
|
+
body: JSON.stringify(body),
|
|
42
44
|
}).catch(() => {
|
|
43
45
|
// Best-effort — a failed callback just leaves the agent's request to
|
|
44
|
-
// time out server-side
|
|
46
|
+
// time out server-side, and a failed page update leaves the tab
|
|
47
|
+
// registered on its previous page.
|
|
45
48
|
});
|
|
46
49
|
} catch {
|
|
47
50
|
// JSON.stringify or fetch throwing synchronously — still best-effort.
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
53
|
|
|
54
|
+
function postResult({ basePath, requestId, result }) {
|
|
55
|
+
postJson({ basePath, path: '', body: { requestId, result } });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function postTabPage({ basePath, tabId, pageId }) {
|
|
59
|
+
postJson({ basePath, path: '/page', body: { tabId, pageId } });
|
|
60
|
+
}
|
|
61
|
+
|
|
51
62
|
function buildSnapshot({ lowdefy, pageId }) {
|
|
52
63
|
const context = lowdefy?.contexts?.[`page:${pageId}`];
|
|
53
64
|
if (type.isNone(context)) {
|
|
@@ -133,17 +144,16 @@ function evalExpression({ lowdefy, pageId, expression }) {
|
|
|
133
144
|
}
|
|
134
145
|
|
|
135
146
|
const Inspector = ({ basePath, lowdefy, pageId }) => {
|
|
147
|
+
const { source, tabId } = useContext(DevStreamContext);
|
|
136
148
|
const pageIdRef = useRef(pageId);
|
|
137
149
|
pageIdRef.current = pageId;
|
|
138
150
|
|
|
139
151
|
useEffect(() => {
|
|
140
|
-
if (type.isNone(
|
|
152
|
+
if (type.isNone(source)) {
|
|
141
153
|
return undefined;
|
|
142
154
|
}
|
|
143
155
|
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
sse.addEventListener('inspect-request', (message) => {
|
|
156
|
+
const onInspectRequest = (message) => {
|
|
147
157
|
let requestId;
|
|
148
158
|
try {
|
|
149
159
|
const data = JSON.parse(message.data);
|
|
@@ -154,9 +164,9 @@ const Inspector = ({ basePath, lowdefy, pageId }) => {
|
|
|
154
164
|
} catch (error) {
|
|
155
165
|
postResult({ basePath, requestId, result: { error: error.message } });
|
|
156
166
|
}
|
|
157
|
-
}
|
|
167
|
+
};
|
|
158
168
|
|
|
159
|
-
|
|
169
|
+
const onEvalRequest = (message) => {
|
|
160
170
|
let requestId;
|
|
161
171
|
try {
|
|
162
172
|
const data = JSON.parse(message.data);
|
|
@@ -171,12 +181,22 @@ const Inspector = ({ basePath, lowdefy, pageId }) => {
|
|
|
171
181
|
} catch (error) {
|
|
172
182
|
postResult({ basePath, requestId, result: { error: error.message } });
|
|
173
183
|
}
|
|
174
|
-
}
|
|
184
|
+
};
|
|
175
185
|
|
|
186
|
+
source.addEventListener('inspect-request', onInspectRequest);
|
|
187
|
+
source.addEventListener('eval-request', onEvalRequest);
|
|
176
188
|
return () => {
|
|
177
|
-
|
|
189
|
+
source.removeEventListener('inspect-request', onInspectRequest);
|
|
190
|
+
source.removeEventListener('eval-request', onEvalRequest);
|
|
178
191
|
};
|
|
179
|
-
}, [basePath, lowdefy,
|
|
192
|
+
}, [basePath, lowdefy, source]);
|
|
193
|
+
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
if (type.isNone(tabId) || type.isNone(pageId)) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
postTabPage({ basePath, tabId, pageId });
|
|
199
|
+
}, [basePath, tabId, pageId]);
|
|
180
200
|
|
|
181
201
|
useEffect(() => {
|
|
182
202
|
if (type.isNone(pageId)) {
|
package/client/Reload.jsx
CHANGED
|
@@ -16,15 +16,21 @@
|
|
|
16
16
|
|
|
17
17
|
import React, { useEffect, useState } from 'react';
|
|
18
18
|
|
|
19
|
+
import DevStreamContext from './DevStreamContext.js';
|
|
19
20
|
import useMutateCache from '../lib/client/utils/useMutateCache.js';
|
|
20
21
|
import waitForRestartedServer from '../lib/client/utils/waitForRestartedServer.js';
|
|
21
22
|
|
|
22
23
|
// SSE listener — config rebuilds notify via /api/reload. Tailwind CSS updates
|
|
23
24
|
// arrive through Vite HMR now (globals.css is in the dev module graph), so
|
|
24
25
|
// the old tailwind-jit.css link cache-bust is gone.
|
|
26
|
+
//
|
|
27
|
+
// This is the tab's only EventSource. It is shared through DevStreamContext
|
|
28
|
+
// so Inspector.jsx can listen on the same connection instead of opening a
|
|
29
|
+
// second one — see the connection-limit note in DevStreamContext.js.
|
|
25
30
|
const Reload = ({ children, basePath, lowdefy }) => {
|
|
26
31
|
const [reset, setReset] = useState(false);
|
|
27
32
|
const [restarting, setRestarting] = useState(false);
|
|
33
|
+
const [devStream, setDevStream] = useState({ source: null, tabId: null });
|
|
28
34
|
// reset is a one-shot boolean the engine lowers only after a page mounts
|
|
29
35
|
// (getContext.js) — while a page is suspended it stays true and setReset(true)
|
|
30
36
|
// bails out of re-rendering. The tick always changes, so every reload event
|
|
@@ -36,6 +42,15 @@ const Reload = ({ children, basePath, lowdefy }) => {
|
|
|
36
42
|
useEffect(() => {
|
|
37
43
|
const sse = new EventSource(`${basePath}/api/reload`);
|
|
38
44
|
|
|
45
|
+
// routes/reload.js registers every connection as an inspectable tab and
|
|
46
|
+
// announces the id it chose as the first event on the stream. The stream
|
|
47
|
+
// is published to consumers only then, in one state update, so the page
|
|
48
|
+
// subtree re-renders once at mount rather than once per field.
|
|
49
|
+
sse.addEventListener('tab', (message) => {
|
|
50
|
+
const { tabId } = JSON.parse(message.data);
|
|
51
|
+
setDevStream({ source: sse, tabId });
|
|
52
|
+
});
|
|
53
|
+
|
|
39
54
|
sse.addEventListener('reload', () => {
|
|
40
55
|
// add a update delay to prevent rerender before server is shut down for rebuild, ideally we don't want to do this.
|
|
41
56
|
// TODO: We need to pass a flag when a rebuild will happen so that client does not trigger render.
|
|
@@ -60,7 +75,11 @@ const Reload = ({ children, basePath, lowdefy }) => {
|
|
|
60
75
|
sse.close();
|
|
61
76
|
};
|
|
62
77
|
}, []);
|
|
63
|
-
return
|
|
78
|
+
return (
|
|
79
|
+
<DevStreamContext.Provider value={devStream}>
|
|
80
|
+
{children({ reset, setReset, restarting })}
|
|
81
|
+
</DevStreamContext.Provider>
|
|
82
|
+
);
|
|
64
83
|
};
|
|
65
84
|
|
|
66
85
|
export default Reload;
|
package/client/Routing.jsx
CHANGED
|
@@ -72,40 +72,43 @@ function Routing({ auth, lowdefy, router }) {
|
|
|
72
72
|
|
|
73
73
|
return (
|
|
74
74
|
<>
|
|
75
|
-
<Inspector basePath={router.basePath} lowdefy={lowdefy} pageId={pageId} />
|
|
76
75
|
<FeedbackMount basePath={router.basePath} lowdefy={lowdefy} pageId={pageId} />
|
|
77
76
|
<OpenInEditorListener basePath={router.basePath} pageId={pageId} />
|
|
78
77
|
<Reload basePath={router.basePath} lowdefy={lowdefy}>
|
|
79
|
-
{(resetContext) =>
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
78
|
+
{(resetContext) => (
|
|
79
|
+
<>
|
|
80
|
+
{/* Inside Reload so it can share Reload's event stream (DevStreamContext). */}
|
|
81
|
+
<Inspector basePath={router.basePath} lowdefy={lowdefy} pageId={pageId} />
|
|
82
|
+
{/* Rendered here, not in Page — Page sits below the Suspense boundary
|
|
83
|
+
and cannot render anything while its config fetch is suspended, so
|
|
84
|
+
a restarting server would present as "Building page..." forever. */}
|
|
85
|
+
{resetContext.restarting ? (
|
|
86
|
+
<RestartingPage />
|
|
87
|
+
) : (
|
|
88
|
+
<Suspense key={`${pageId}_${getReloadVersion()}`} fallback={<BuildingPage />}>
|
|
89
|
+
<Page
|
|
90
|
+
auth={auth}
|
|
91
|
+
Components={{ Head, Link }}
|
|
92
|
+
config={{
|
|
93
|
+
rootConfig,
|
|
94
|
+
}}
|
|
95
|
+
jsMap={staticJsMap}
|
|
96
|
+
lowdefy={lowdefy}
|
|
97
|
+
pageId={pageId}
|
|
98
|
+
resetContext={resetContext}
|
|
99
|
+
router={router}
|
|
100
|
+
types={{
|
|
101
|
+
actions,
|
|
102
|
+
blockMetas,
|
|
103
|
+
blocks,
|
|
104
|
+
icons,
|
|
105
|
+
operators,
|
|
106
|
+
}}
|
|
107
|
+
/>
|
|
108
|
+
</Suspense>
|
|
109
|
+
)}
|
|
110
|
+
</>
|
|
111
|
+
)}
|
|
109
112
|
</Reload>
|
|
110
113
|
</>
|
|
111
114
|
);
|
|
@@ -18,6 +18,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
|
|
18
18
|
import { createAuthClient } from 'better-auth/react';
|
|
19
19
|
import {
|
|
20
20
|
adminClient,
|
|
21
|
+
emailOTPClient,
|
|
21
22
|
magicLinkClient,
|
|
22
23
|
organizationClient,
|
|
23
24
|
phoneNumberClient,
|
|
@@ -44,6 +45,7 @@ const authClient = createAuthClient({
|
|
|
44
45
|
baseURL: `${window.location.origin}${lowdefyConfig.basePath ?? ''}/api/auth`,
|
|
45
46
|
plugins: [
|
|
46
47
|
adminClient(),
|
|
48
|
+
emailOTPClient(),
|
|
47
49
|
magicLinkClient(),
|
|
48
50
|
oauthProviderClient(),
|
|
49
51
|
organizationClient(),
|
|
@@ -57,8 +59,8 @@ const authClient = createAuthClient({
|
|
|
57
59
|
// signal-driven refetch on the calls that would otherwise fire one, so
|
|
58
60
|
// nothing competes with (and aborts) the refetch UpdateSession awaits.
|
|
59
61
|
// Only calls that mutate an existing session are suppressed - calls that
|
|
60
|
-
// can establish one (the sign-ins, and the two-factor, phone-number
|
|
61
|
-
// passkey verifies) keep their signal: its refetch is what makes the store
|
|
62
|
+
// can establish one (the sign-ins, and the two-factor, phone-number,
|
|
63
|
+
// email-otp and passkey verifies) keep their signal: its refetch is what makes the store
|
|
62
64
|
// notice a login when no navigation follows.
|
|
63
65
|
// disableSignal silences all atomListeners for the call, including the
|
|
64
66
|
// organization plugin's org atoms - none are exposed to app config, so a
|
|
@@ -193,6 +195,9 @@ function AuthConfigured({ authConfig, children, serverUser }) {
|
|
|
193
195
|
// oauth_query contract as oauth2Consent: the signed query rides from
|
|
194
196
|
// window.location.search, so the call must run before any navigation.
|
|
195
197
|
oauth2Continue: (params) => authClient.oauth2.continue(params),
|
|
198
|
+
// The one-time email code: send, then sign in with it.
|
|
199
|
+
emailOtpSend: (params) => authClient.emailOtp.sendVerificationOtp(params),
|
|
200
|
+
emailOtpVerify: (params) => authClient.signIn.emailOtp(params),
|
|
196
201
|
phoneNumberRequestPasswordReset: (params) =>
|
|
197
202
|
authClient.phoneNumber.requestPasswordReset(params),
|
|
198
203
|
phoneNumberResetPassword: (params) => authClient.phoneNumber.resetPassword(params),
|
|
@@ -39,6 +39,8 @@ function AuthNotConfigured({ authConfig, children, user = null }) {
|
|
|
39
39
|
listOrganizations: authNotConfigured,
|
|
40
40
|
oauth2Consent: authNotConfigured,
|
|
41
41
|
oauth2Continue: authNotConfigured,
|
|
42
|
+
emailOtpSend: authNotConfigured,
|
|
43
|
+
emailOtpVerify: authNotConfigured,
|
|
42
44
|
phoneNumberRequestPasswordReset: authNotConfigured,
|
|
43
45
|
phoneNumberResetPassword: authNotConfigured,
|
|
44
46
|
phoneNumberSendOtp: authNotConfigured,
|
|
@@ -445,13 +445,13 @@ function createDocsMcpServer({ origin, honoContext } = {}) {
|
|
|
445
445
|
'lowdefy_run_journey',
|
|
446
446
|
{
|
|
447
447
|
description:
|
|
448
|
-
'Drive a page of the running dev server headless through declarative steps and assert what happens — the way to verify behaviour (a form submits, a modal opens, a filter works), not just layout. Blocks are addressed by blockId. A step that fails stops the journey and is returned as data (passed: false, failure with index/step/expected/actual/message, later steps "skipped") — never as a tool error. Always returns the final page state and any screenshots taken (as images after the JSON text).',
|
|
448
|
+
'Drive a page of the running dev server headless through declarative steps and assert what happens — the way to verify behaviour (a form submits, a modal opens, a filter works), not just layout. Blocks are addressed by blockId; a target object narrows to a grid row/cell ({"blockId": "grid", "row": 1, "column": "actions"}), to the control with exactly some text ({"blockId": "grid", "row": 1, "text": "Edit"}), or reaches portal-rendered controls page-wide by text alone ({"text": "OK"} for a confirm dialog or modal footer button, a menu item). A step that fails stops the journey and is returned as data (passed: false, failure with index/step/expected/actual/message, later steps "skipped") — never as a tool error. Always returns the final page state and any screenshots taken (as images after the JSON text).',
|
|
449
449
|
inputSchema: {
|
|
450
450
|
pageId: z.string().describe('The page id to open.'),
|
|
451
451
|
steps: z
|
|
452
452
|
.array(z.record(z.any()))
|
|
453
453
|
.describe(
|
|
454
|
-
'Ordered steps, one key each: {"click":
|
|
454
|
+
'Ordered steps, one key each: {"click": target} | {"fill": {...target, "value"}} | {"select": {...target, "value"}} (option by exact text) | {"press": "Enter" | "Mod+k"} (Mod is Meta/Control per platform) | {"wait": {"ms": n} | {"request": requestId} | {"state": path}} | {"screenshot": name?} | {"expect": {"state": {"path", "equals"}} | {"visible": target} | {"text": {...target, "contains"}} | {"url": {"contains"}}}. A target is a blockId string, or an object of {"blockId", "row" (zero-based grid row as displayed), "column" (grid col-id), "text" (exact text of the interactive control to use), "nth" (zero-based pick among several matches)}; "text" without "blockId" searches the whole page, which is how confirm dialog / modal footer buttons and dropdown menu items are reached. fill, select and expect.text need a blockId. Each step gets 5s; after an interaction the runner waits for the page\'s pending events and requests to settle.'
|
|
455
455
|
),
|
|
456
456
|
user: userSchema,
|
|
457
457
|
urlQuery: z
|
package/lib/docs/getOverview.js
CHANGED
|
@@ -71,7 +71,7 @@ function getOverview() {
|
|
|
71
71
|
' Or subscribe instead of polling: `GET /lowdefy-docs/events` streams `build`, `client_error`, `server_error` and `restart` events over SSE (MCP clients receive the same as `notifications/message` from logger `lowdefy`).',
|
|
72
72
|
'2. **Verify the page**: `GET /lowdefy-docs/page-config/{pageId}` — or `lowdefy_get_page_config`. The fully built page config, or its structured build errors.',
|
|
73
73
|
'3. **See it rendered**: `GET /lowdefy-docs/screenshot/{pageId}` — or `lowdefy_screenshot_page`. PNG of the page in headless Chromium.',
|
|
74
|
-
'4. **Verify behaviour**: `POST /lowdefy-docs/journey` `{pageId, steps, user, urlQuery}` — or `lowdefy_run_journey`. Drive the page headless with declarative steps (`click`, `fill`, `select`, `press`, `wait`, `screenshot`, `expect: { state | visible | text | url }
|
|
74
|
+
'4. **Verify behaviour**: `POST /lowdefy-docs/journey` `{pageId, steps, user, urlQuery}` — or `lowdefy_run_journey`. Drive the page headless with declarative steps (`click`, `fill`, `select`, `press`, `wait`, `screenshot`, `expect: { state | visible | text | url }`) and assert what happens. Targets are a blockId, or `{ blockId, row, column, text, nth }` to reach the button in a grid cell, a control by its exact text, or — with `text` alone — a confirm dialog / modal footer button rendered outside every block. A failing step stops the journey and returns `passed: false` with `failure: { index, step, expected, actual, message }` and the final page `state` — data, not an error.',
|
|
75
75
|
'5. **Locate config**: `GET /lowdefy-docs/find/{id}?pageId=...` — or `lowdefy_find_config`. Which yaml file defines a page/block/request id.',
|
|
76
76
|
'6. **Scaffold**: `lowdefy_scaffold_page` creates a canonical new page file (then register it in lowdefy.yaml).',
|
|
77
77
|
'',
|