@lowdefy/e2e-utils 6.1.0 → 7.1.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/README.md +24 -0
- package/dist/config.js +42 -9
- package/dist/core/instanceKey.js +32 -0
- package/dist/core/navigation.js +28 -11
- package/dist/core/readBasePath.js +25 -0
- package/dist/core/requests.js +6 -7
- package/dist/core/state.js +7 -11
- package/dist/core/url.js +14 -10
- package/dist/core/validation.js +9 -8
- package/dist/core/verifyServer.js +49 -0
- package/dist/globalSetup.js +24 -0
- package/dist/index.js +3 -0
- package/dist/init/index.js +1 -1
- package/dist/init/templates/README.md.template +4 -2
- package/dist/proxy/createPageManager.js +17 -5
- package/dist/startServer.js +203 -0
- package/dist/targets/index.js +17 -0
- package/dist/targets/resolveTargetInDocument.js +118 -0
- package/dist/targets/targetFixtures.js +380 -0
- package/dist/testPrep/generateManifest.js +13 -1
- package/package.json +12 -3
package/README.md
CHANGED
|
@@ -321,6 +321,30 @@ export default createConfig({
|
|
|
321
321
|
});
|
|
322
322
|
```
|
|
323
323
|
|
|
324
|
+
The server Playwright starts stops when the Playwright run stops, however the run ends, so a killed or timed-out run leaves no server behind.
|
|
325
|
+
|
|
326
|
+
| Variable | Effect |
|
|
327
|
+
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
328
|
+
| `LOWDEFY_E2E_PORT` | Runs on this port instead of the configured one, so two git worktrees can run the same suite at once. |
|
|
329
|
+
| `LOWDEFY_E2E_REUSE_SERVER=true` | Reuses a server already running on the port (for example one started with `pnpm e2e:server`) instead of failing the run. The global setup still checks that it serves this app's e2e build. |
|
|
330
|
+
|
|
331
|
+
## Starting a Server Outside Playwright
|
|
332
|
+
|
|
333
|
+
`startServer` builds and starts an app's e2e server for other test runners (a vitest or Jest `globalSetup`) and scripts. The server stops when the process that called `startServer` exits, however it exits, so a killed or timed-out run leaves nothing behind.
|
|
334
|
+
|
|
335
|
+
```javascript
|
|
336
|
+
import startServer from '@lowdefy/e2e-utils/startServer';
|
|
337
|
+
|
|
338
|
+
const server = await startServer({
|
|
339
|
+
appDir: './', // Where lowdefy.yaml is
|
|
340
|
+
port: 3191, // Fails at once if the port is already in use
|
|
341
|
+
env: { MY_SECRET: 'value' }, // Extra environment variables for the server
|
|
342
|
+
build: true, // Run `lowdefy build --server e2e` first (default: true)
|
|
343
|
+
});
|
|
344
|
+
// server.url === 'http://localhost:3191'
|
|
345
|
+
await server.stop(); // SIGTERM, then SIGKILL after a grace period
|
|
346
|
+
```
|
|
347
|
+
|
|
324
348
|
## Adding E2E Support to a Block
|
|
325
349
|
|
|
326
350
|
Use `createBlockHelper` to define a block's e2e helper. Common methods (visible, hidden, disabled, enabled, validation) are auto-provided from the locator:
|
package/dist/config.js
CHANGED
|
@@ -14,8 +14,28 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/ import fs from 'fs';
|
|
16
16
|
import path from 'path';
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
17
18
|
import { defineConfig, devices } from '@playwright/test';
|
|
18
|
-
|
|
19
|
+
// Checks each reused or started server is this app's e2e build (see core/verifyServer.js).
|
|
20
|
+
const globalSetup = fileURLToPath(new URL('./globalSetup.js', import.meta.url));
|
|
21
|
+
// A server left running on the port (by a run that was killed, or another checkout) serves
|
|
22
|
+
// the build it loaded, so it is reused only on request.
|
|
23
|
+
function shouldReuseServer() {
|
|
24
|
+
return process.env.LOWDEFY_E2E_REUSE_SERVER === 'true';
|
|
25
|
+
}
|
|
26
|
+
// The server stops when this process does, however it ends: Playwright cleans up its web
|
|
27
|
+
// servers only on a normal exit or SIGINT, so a killed or timed-out run would otherwise leave
|
|
28
|
+
// the server behind. Playwright evaluates this config again in every worker, but only the
|
|
29
|
+
// runner launches webServer, so the runner's pid is the one the server watches.
|
|
30
|
+
function webServerEnv() {
|
|
31
|
+
return {
|
|
32
|
+
...process.env,
|
|
33
|
+
LOWDEFY_EXIT_WITH_PID: String(process.pid)
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function createConfig({ appDir = './', buildDir = '.lowdefy/server/build', commandPrefix = '', mocksFile = 'e2e/mocks.yaml', port: defaultPort = 3000, testDir = 'e2e', testMatch = '**/*.spec.js', timeout = 180000, screenshot = 'only-on-failure', outputDir = 'test-results' } = {}) {
|
|
37
|
+
// LOWDEFY_E2E_PORT moves a run, so two git worktrees can run the same suite at once.
|
|
38
|
+
const port = Number(process.env.LOWDEFY_E2E_PORT ?? defaultPort);
|
|
19
39
|
// Resolve absolute paths for all directories
|
|
20
40
|
const absoluteAppDir = path.resolve(appDir);
|
|
21
41
|
// Use the local binary when available — npx may resolve the latest stable from npm
|
|
@@ -31,12 +51,19 @@ function createConfig({ appDir = './', buildDir = '.lowdefy/server/build', comma
|
|
|
31
51
|
if (fs.existsSync(absoluteMocksFile)) {
|
|
32
52
|
process.env.LOWDEFY_E2E_MOCKS_FILE = absoluteMocksFile;
|
|
33
53
|
}
|
|
54
|
+
process.env.LOWDEFY_E2E_SERVERS = JSON.stringify([
|
|
55
|
+
{
|
|
56
|
+
buildDir: absoluteBuildDir,
|
|
57
|
+
port
|
|
58
|
+
}
|
|
59
|
+
]);
|
|
34
60
|
return defineConfig({
|
|
35
61
|
testDir,
|
|
36
62
|
testMatch,
|
|
37
63
|
fullyParallel: true,
|
|
38
64
|
reporter: 'list',
|
|
39
65
|
outputDir,
|
|
66
|
+
globalSetup,
|
|
40
67
|
use: {
|
|
41
68
|
baseURL: `http://localhost:${port}`,
|
|
42
69
|
trace: 'on-first-retry',
|
|
@@ -58,11 +85,12 @@ function createConfig({ appDir = './', buildDir = '.lowdefy/server/build', comma
|
|
|
58
85
|
webServer: {
|
|
59
86
|
// Build with e2e server and start
|
|
60
87
|
command: `${cliCommand} build --server e2e && ${cliCommand} start --port ${port} --log-level warn`,
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
reuseExistingServer:
|
|
88
|
+
// A reused server must still pass globalSetup's check that it serves this app's e2e build.
|
|
89
|
+
port,
|
|
90
|
+
reuseExistingServer: shouldReuseServer(),
|
|
64
91
|
timeout,
|
|
65
|
-
cwd: absoluteAppDir
|
|
92
|
+
cwd: absoluteAppDir,
|
|
93
|
+
env: webServerEnv()
|
|
66
94
|
}
|
|
67
95
|
});
|
|
68
96
|
}
|
|
@@ -83,6 +111,10 @@ function createMultiAppConfig({ apps = [], commandPrefix = '', testDir = 'e2e',
|
|
|
83
111
|
}
|
|
84
112
|
};
|
|
85
113
|
});
|
|
114
|
+
process.env.LOWDEFY_E2E_SERVERS = JSON.stringify(apps.map((app)=>({
|
|
115
|
+
buildDir: path.resolve(app.appDir, app.buildDir ?? '.lowdefy/server/build'),
|
|
116
|
+
port: app.port
|
|
117
|
+
})));
|
|
86
118
|
// Set up webServers for each app
|
|
87
119
|
const webServer = apps.map((app)=>{
|
|
88
120
|
const appLocalBin = path.join(app.appDir, 'node_modules', '.bin', 'lowdefy');
|
|
@@ -90,11 +122,11 @@ function createMultiAppConfig({ apps = [], commandPrefix = '', testDir = 'e2e',
|
|
|
90
122
|
const appCliCommand = `${commandPrefix ? `${commandPrefix} ` : ''}${appLowdefyCmd}`;
|
|
91
123
|
return {
|
|
92
124
|
command: `${appCliCommand} build --server e2e && ${appCliCommand} start --port ${app.port} --log-level warn`,
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
reuseExistingServer: true,
|
|
125
|
+
port: app.port,
|
|
126
|
+
reuseExistingServer: shouldReuseServer(),
|
|
96
127
|
timeout,
|
|
97
|
-
cwd: app.appDir
|
|
128
|
+
cwd: app.appDir,
|
|
129
|
+
env: webServerEnv()
|
|
98
130
|
};
|
|
99
131
|
});
|
|
100
132
|
return defineConfig({
|
|
@@ -102,6 +134,7 @@ function createMultiAppConfig({ apps = [], commandPrefix = '', testDir = 'e2e',
|
|
|
102
134
|
fullyParallel: true,
|
|
103
135
|
reporter: 'list',
|
|
104
136
|
outputDir,
|
|
137
|
+
globalSetup,
|
|
105
138
|
use: {
|
|
106
139
|
trace: 'on-first-retry',
|
|
107
140
|
screenshot,
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
*/ // Browser-side expression: the context of the page instance on screen. The URL path, without the
|
|
16
|
+
// basePath and one leading and one trailing slash (the form the client's path memory is keyed
|
|
17
|
+
// by), is mapped to its instance through window.lowdefy.pathMemory, so two instances of one
|
|
18
|
+
// patterned page are told apart. As the engine's lookupPath reads it, a path the memory has not
|
|
19
|
+
// seen is the id of a page without a pattern; the app root shows the home page.
|
|
20
|
+
const pageContextExpression = `(() => {
|
|
21
|
+
const lowdefy = window.lowdefy;
|
|
22
|
+
if (!lowdefy) return undefined;
|
|
23
|
+
const basePath = lowdefy.basePath ?? '';
|
|
24
|
+
let path = window.location.pathname;
|
|
25
|
+
if (basePath && path.startsWith(basePath)) path = path.slice(basePath.length);
|
|
26
|
+
path = path.replace(/^\\//, '').replace(/\\/$/, '');
|
|
27
|
+
const entry = lowdefy.pathMemory.get(path);
|
|
28
|
+
if (entry) return lowdefy.contexts[entry.instanceKey];
|
|
29
|
+
const pageId = path === '' ? lowdefy.home?.pageId : path;
|
|
30
|
+
return lowdefy.contexts['page:' + pageId];
|
|
31
|
+
})()`;
|
|
32
|
+
export default pageContextExpression;
|
package/dist/core/navigation.js
CHANGED
|
@@ -12,17 +12,34 @@
|
|
|
12
12
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
13
|
See the License for the specific language governing permissions and
|
|
14
14
|
limitations under the License.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return lowdefy && lowdefy.pageId && lowdefy.contexts[`page:${lowdefy.pageId}`];
|
|
21
|
-
}, {
|
|
15
|
+
*/ import { buildPagePath, type, urlQuery } from '@lowdefy/helpers';
|
|
16
|
+
import pageContextExpression from './instanceKey.js';
|
|
17
|
+
async function waitForReady(page) {
|
|
18
|
+
// Wait for the context of the page instance on screen to exist.
|
|
19
|
+
await page.waitForFunction(`Boolean(${pageContextExpression})`, undefined, {
|
|
22
20
|
timeout: 30000
|
|
23
21
|
});
|
|
24
22
|
}
|
|
25
|
-
|
|
23
|
+
// A target is a URL path, or { basePath, pageId, path, pathParams, urlQuery } where `path` is
|
|
24
|
+
// the page's path pattern (omit it for a page served at its id) and `basePath` the app's
|
|
25
|
+
// config.basePath (omit it for an app served at the root). A string target is used as written,
|
|
26
|
+
// so it carries its own basePath. The query is written as the app's links write it, so
|
|
27
|
+
// `_url_query` reads back numbers, booleans and objects.
|
|
28
|
+
function createPageUrl(target) {
|
|
29
|
+
if (type.isString(target)) {
|
|
30
|
+
return target;
|
|
31
|
+
}
|
|
32
|
+
const { basePath = '', pageId, path, pathParams, urlQuery: query } = target;
|
|
33
|
+
const pathname = `${basePath}/${buildPagePath({
|
|
34
|
+
pageId,
|
|
35
|
+
path,
|
|
36
|
+
pathParams
|
|
37
|
+
})}`;
|
|
38
|
+
const search = urlQuery.stringify(query);
|
|
39
|
+
return search ? `${pathname}?${search}` : pathname;
|
|
40
|
+
}
|
|
41
|
+
async function goto(page, target) {
|
|
42
|
+
const path = createPageUrl(target);
|
|
26
43
|
// domcontentloaded is sufficient — waitForReady gates on the Lowdefy client
|
|
27
44
|
// context, which is a stronger readiness signal than the browser 'load' event.
|
|
28
45
|
// Using 'load' can hang when pages have WebSocket connections or slow resources.
|
|
@@ -31,10 +48,10 @@ async function goto(page, path) {
|
|
|
31
48
|
});
|
|
32
49
|
await waitForReady(page);
|
|
33
50
|
}
|
|
34
|
-
async function waitForPage(page,
|
|
35
|
-
await page.waitForURL(
|
|
51
|
+
async function waitForPage(page, target) {
|
|
52
|
+
await page.waitForURL(createPageUrl(target), {
|
|
36
53
|
waitUntil: 'domcontentloaded'
|
|
37
54
|
});
|
|
38
55
|
await waitForReady(page);
|
|
39
56
|
}
|
|
40
|
-
export { goto, waitForReady, waitForPage };
|
|
57
|
+
export { createPageUrl, goto, waitForReady, waitForPage };
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
*/ import fs from 'fs';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
// The e2e server serves every route under the app's basePath, which the build records in
|
|
18
|
+
// config.json. With no build yet (Playwright skips it when another server holds the
|
|
19
|
+
// port), the server on the port cannot be this app's, and the root path is as good as any.
|
|
20
|
+
function readBasePath({ buildDir }) {
|
|
21
|
+
const configFile = path.join(buildDir, 'config.json');
|
|
22
|
+
if (!fs.existsSync(configFile)) return '';
|
|
23
|
+
return JSON.parse(fs.readFileSync(configFile, 'utf8')).basePath ?? '';
|
|
24
|
+
}
|
|
25
|
+
export default readBasePath;
|
package/dist/core/requests.js
CHANGED
|
@@ -12,13 +12,12 @@
|
|
|
12
12
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
13
|
See the License for the specific language governing permissions and
|
|
14
14
|
limitations under the License.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}, requestId);
|
|
15
|
+
*/ import pageContextExpression from './instanceKey.js';
|
|
16
|
+
async function getRequestState(page, requestId) {
|
|
17
|
+
return page.evaluate(({ reqId, getContext })=>new Function(`return ${getContext}`)()?.requests?.[reqId]?.[0], {
|
|
18
|
+
reqId: requestId,
|
|
19
|
+
getContext: pageContextExpression
|
|
20
|
+
});
|
|
22
21
|
}
|
|
23
22
|
async function getRequestResponse(page, { requestId }) {
|
|
24
23
|
const state = await getRequestState(page, requestId);
|
package/dist/core/state.js
CHANGED
|
@@ -12,12 +12,9 @@
|
|
|
12
12
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
13
|
See the License for the specific language governing permissions and
|
|
14
14
|
limitations under the License.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const pageId = lowdefy?.pageId;
|
|
19
|
-
return lowdefy?.contexts?.[`page:${pageId}`]?.state;
|
|
20
|
-
});
|
|
15
|
+
*/ import pageContextExpression from './instanceKey.js';
|
|
16
|
+
async function getState(page) {
|
|
17
|
+
return page.evaluate(`${pageContextExpression}?.state`);
|
|
21
18
|
}
|
|
22
19
|
async function getBlockState(page, { blockId }) {
|
|
23
20
|
const state = await getState(page);
|
|
@@ -27,15 +24,14 @@ async function setState(page, { key, value }) {
|
|
|
27
24
|
// context.methods.setState does not exist on engine contexts - the engine's
|
|
28
25
|
// action layer (see createSetState.js) writes state via State.set() and then
|
|
29
26
|
// triggers a re-render via context._internal.update().
|
|
30
|
-
await page.evaluate(({ k, v })=>{
|
|
31
|
-
const
|
|
32
|
-
const pageId = lowdefy?.pageId;
|
|
33
|
-
const context = lowdefy?.contexts?.[`page:${pageId}`];
|
|
27
|
+
await page.evaluate(({ k, v, getContext })=>{
|
|
28
|
+
const context = new Function(`return ${getContext}`)();
|
|
34
29
|
context._internal.State.set(k, v);
|
|
35
30
|
context._internal.update();
|
|
36
31
|
}, {
|
|
37
32
|
k: key,
|
|
38
|
-
v: value
|
|
33
|
+
v: value,
|
|
34
|
+
getContext: pageContextExpression
|
|
39
35
|
});
|
|
40
36
|
}
|
|
41
37
|
export { getState, getBlockState, setState };
|
package/dist/core/url.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
See the License for the specific language governing permissions and
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/ import { type } from '@lowdefy/helpers';
|
|
16
|
+
import pageContextExpression from './instanceKey.js';
|
|
16
17
|
import { waitForReady } from './navigation.js';
|
|
17
18
|
// The client router's createUrl prepends basePath, so the pathname handed to it
|
|
18
19
|
// must have basePath stripped or a basePath app receives it twice.
|
|
@@ -35,7 +36,7 @@ function createTargetLocation({ basePath = '', href, key, value }) {
|
|
|
35
36
|
// resolved from the previous urlQuery. Routing through the router is what a
|
|
36
37
|
// Link or SetUrlQuery action in the app does.
|
|
37
38
|
async function setUrlQuery(page, { key, value }) {
|
|
38
|
-
const { basePath, dynamic, href } = await page.evaluate(()=>{
|
|
39
|
+
const { basePath, dynamic, href } = await page.evaluate((getContext)=>{
|
|
39
40
|
const lowdefy = window.lowdefy;
|
|
40
41
|
const router = lowdefy?._internal?.router;
|
|
41
42
|
if (!router) {
|
|
@@ -43,26 +44,26 @@ async function setUrlQuery(page, { key, value }) {
|
|
|
43
44
|
}
|
|
44
45
|
return {
|
|
45
46
|
basePath: router.basePath ?? '',
|
|
46
|
-
dynamic:
|
|
47
|
+
dynamic: new Function(`return ${getContext}`)()?._internal?.pageConfig?.dynamic === true,
|
|
47
48
|
href: window.location.href
|
|
48
49
|
};
|
|
49
|
-
});
|
|
50
|
-
// A
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
const pageConfigFetched = page.waitForResponse((response)=>response.url().includes('/api/page/'), {
|
|
50
|
+
}, pageContextExpression);
|
|
51
|
+
// A query change re-fetches the page config only on a Dynamic page; a static
|
|
52
|
+
// page re-renders on the config it has. Listening starts before the
|
|
53
|
+
// navigation so the response cannot be missed.
|
|
54
|
+
const pageConfigFetched = dynamic ? page.waitForResponse((response)=>response.url().includes('/api/page/'), {
|
|
54
55
|
timeout: 30000
|
|
55
56
|
}).catch((error)=>{
|
|
56
57
|
// A response that never arrives must not hang the suite — fall through and
|
|
57
58
|
// let the test's own assertion report it. Anything else is a real fault.
|
|
58
59
|
if (error.name !== 'TimeoutError') throw error;
|
|
59
|
-
});
|
|
60
|
+
}) : null;
|
|
60
61
|
// A Dynamic page's content is resolved server-side from urlQuery, and the
|
|
61
62
|
// engine rebuilds its context whenever a new config object arrives (see
|
|
62
63
|
// getContext). Waiting for that rebuild proves the re-resolved content is in
|
|
63
64
|
// place; the response arriving only proves it is on its way. Static pages keep
|
|
64
65
|
// a memoized context, so there is no identity change to wait for.
|
|
65
|
-
const previousPageConfig = dynamic ? await page.evaluateHandle(
|
|
66
|
+
const previousPageConfig = dynamic ? await page.evaluateHandle(`${pageContextExpression}._internal.pageConfig`) : null;
|
|
66
67
|
await page.evaluate(({ pathname, query })=>{
|
|
67
68
|
// scroll: false keeps the viewport where it was — a query change is not a
|
|
68
69
|
// page navigation from the test's point of view.
|
|
@@ -79,7 +80,10 @@ async function setUrlQuery(page, { key, value }) {
|
|
|
79
80
|
}));
|
|
80
81
|
await pageConfigFetched;
|
|
81
82
|
if (previousPageConfig) {
|
|
82
|
-
await page.waitForFunction((previous)=>
|
|
83
|
+
await page.waitForFunction(({ previous, getContext })=>new Function(`return ${getContext}`)()?._internal?.pageConfig !== previous, {
|
|
84
|
+
previous: previousPageConfig,
|
|
85
|
+
getContext: pageContextExpression
|
|
86
|
+
}, {
|
|
83
87
|
timeout: 30000
|
|
84
88
|
});
|
|
85
89
|
await previousPageConfig.dispose();
|
package/dist/core/validation.js
CHANGED
|
@@ -12,13 +12,14 @@
|
|
|
12
12
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
13
|
See the License for the specific language governing permissions and
|
|
14
14
|
limitations under the License.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
15
|
+
*/ import pageContextExpression from './instanceKey.js';
|
|
16
|
+
async function getValidation(page, blockId) {
|
|
17
|
+
return page.evaluate(({ id, getContext })=>{
|
|
18
|
+
const context = new Function(`return ${getContext}`)();
|
|
19
|
+
return context?._internal?.RootSlots?.map?.[id]?.validationEval?.output;
|
|
20
|
+
}, {
|
|
21
|
+
id: blockId,
|
|
22
|
+
getContext: pageContextExpression
|
|
23
|
+
});
|
|
23
24
|
}
|
|
24
25
|
export { getValidation };
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
*/ import fs from 'fs';
|
|
16
|
+
import readBasePath from './readBasePath.js';
|
|
17
|
+
async function readIdentity({ identityPath, port }) {
|
|
18
|
+
let response;
|
|
19
|
+
try {
|
|
20
|
+
response = await fetch(`http://localhost:${port}${identityPath}`, {
|
|
21
|
+
signal: AbortSignal.timeout(10000)
|
|
22
|
+
});
|
|
23
|
+
} catch (error) {
|
|
24
|
+
throw new Error(`Could not reach the e2e server on port ${port}: ${error.message}`);
|
|
25
|
+
}
|
|
26
|
+
if (!response.ok) return null;
|
|
27
|
+
return response.json().catch(()=>null);
|
|
28
|
+
}
|
|
29
|
+
// Playwright reuses whatever server listens on the port. Only this app's e2e build applies
|
|
30
|
+
// the request mocks and e2e session, so any other server (lowdefy dev, a production build,
|
|
31
|
+
// another checkout's e2e server) must stop the run instead of being tested by accident.
|
|
32
|
+
async function verifyServer({ buildDir, port }) {
|
|
33
|
+
const identityPath = `${readBasePath({
|
|
34
|
+
buildDir
|
|
35
|
+
})}/api/e2e/identity`;
|
|
36
|
+
const identity = await readIdentity({
|
|
37
|
+
identityPath,
|
|
38
|
+
port
|
|
39
|
+
});
|
|
40
|
+
const change = `Stop that server or set another \`port\` in the Playwright config`;
|
|
41
|
+
if (identity?.server !== 'lowdefy-e2e') {
|
|
42
|
+
throw new Error(`Port ${port} is in use by a server that is not a Lowdefy e2e server (${identityPath} did not answer as one), for example \`lowdefy dev\`. ${change}, so the tests run against this app's e2e build.`);
|
|
43
|
+
}
|
|
44
|
+
const expected = fs.existsSync(buildDir) ? fs.realpathSync(buildDir) : buildDir;
|
|
45
|
+
if (identity.buildDirectory !== expected) {
|
|
46
|
+
throw new Error(`Port ${port} is in use by the Lowdefy e2e server of another app or checkout, which serves ${identity.buildDirectory}. ${change}, so the tests run against this app's e2e build in ${expected}.`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export default verifyServer;
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
*/ import verifyServer from './core/verifyServer.js';
|
|
16
|
+
// Playwright runs global setup after the webServer entries are up, whether it started
|
|
17
|
+
// them or reused servers already running.
|
|
18
|
+
async function globalSetup() {
|
|
19
|
+
const servers = JSON.parse(process.env.LOWDEFY_E2E_SERVERS ?? '[]');
|
|
20
|
+
for (const server of servers){
|
|
21
|
+
await verifyServer(server);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export default globalSetup;
|
package/dist/index.js
CHANGED
|
@@ -52,3 +52,6 @@ export { createBlockHelper, createHelperRegistry, createBlockMethodProxy, create
|
|
|
52
52
|
// Mocking utilities
|
|
53
53
|
import { createMockManager, loadStaticMocks } from './mocking/index.js';
|
|
54
54
|
export { createMockManager, loadStaticMocks };
|
|
55
|
+
// Server lifecycle for harnesses outside Playwright (vitest/Jest globalSetup, rig scripts)
|
|
56
|
+
import startServer from './startServer.js';
|
|
57
|
+
export { startServer };
|
package/dist/init/index.js
CHANGED
|
@@ -144,7 +144,7 @@ async function init() {
|
|
|
144
144
|
console.log(' pnpm e2e # Run all tests');
|
|
145
145
|
console.log(' pnpm e2e:headed # Run with visible browser');
|
|
146
146
|
console.log(' pnpm e2e:ui # Playwright UI mode');
|
|
147
|
-
console.log(' pnpm e2e:server # Start server for reuse');
|
|
147
|
+
console.log(' pnpm e2e:server # Start server for reuse (LOWDEFY_E2E_REUSE_SERVER=true pnpm e2e)');
|
|
148
148
|
} else {
|
|
149
149
|
for (const app of selectedApps){
|
|
150
150
|
console.log(` cd ${app.path} && pnpm e2e`);
|
|
@@ -21,12 +21,14 @@ By default, `pnpm e2e` does a full build before running tests. To skip the build
|
|
|
21
21
|
pnpm e2e:server
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
2. **Run tests** in another terminal
|
|
24
|
+
2. **Run tests** in another terminal with `LOWDEFY_E2E_REUSE_SERVER=true` — Playwright reuses the running server and skips the build. It only reuses this app's e2e server: if another server holds the port (for example `lowdefy dev`), the run stops with an error that names the port:
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
|
-
pnpm e2e
|
|
27
|
+
LOWDEFY_E2E_REUSE_SERVER=true pnpm e2e
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
Without the variable, a server already on the port fails the run, so a server left behind by an earlier run never serves an old build to new tests.
|
|
31
|
+
|
|
30
32
|
This saves 60-90 seconds per run. Keep the server running while iterating on tests.
|
|
31
33
|
|
|
32
34
|
> **Tip:** If port 3000 is already in use, change the port in both the `e2e:server` script in `package.json` and the `port` option in `playwright.config.js`.
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
See the License for the specific language governing permissions and
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/ import { getBlock } from '../core/locators.js';
|
|
16
|
-
import { waitForReady, waitForPage } from '../core/navigation.js';
|
|
16
|
+
import { createPageUrl, waitForReady, waitForPage } from '../core/navigation.js';
|
|
17
17
|
import { getState, getBlockState, setState } from '../core/state.js';
|
|
18
18
|
import { getRequestState, getRequestResponse } from '../core/requests.js';
|
|
19
19
|
import { getApiState, getApiResponse } from '../core/api.js';
|
|
@@ -36,6 +36,18 @@ function createPageManager({ page, manifest, helperRegistry, mockManager, assert
|
|
|
36
36
|
const { expectState, expectRequest, expectApi, expectUrl, expectUrlQuery } = assertions;
|
|
37
37
|
let currentBlockMap = null;
|
|
38
38
|
let currentPageId = null;
|
|
39
|
+
// A { pageId, pathParams, urlQuery } target gets the app's basePath and the page's path
|
|
40
|
+
// pattern from the manifest.
|
|
41
|
+
function withPagePath(target) {
|
|
42
|
+
if (type.isString(target)) {
|
|
43
|
+
return target;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
basePath: manifest.basePath,
|
|
47
|
+
path: manifest.paths?.[target.pageId],
|
|
48
|
+
...target
|
|
49
|
+
};
|
|
50
|
+
}
|
|
39
51
|
function ensurePageLoaded() {
|
|
40
52
|
if (!currentBlockMap) {
|
|
41
53
|
throw new Error('Call goto() before accessing blocks');
|
|
@@ -128,8 +140,8 @@ function createPageManager({ page, manifest, helperRegistry, mockManager, assert
|
|
|
128
140
|
await setUserCookie(page, userObj);
|
|
129
141
|
},
|
|
130
142
|
// Navigation (control flow)
|
|
131
|
-
async goto (
|
|
132
|
-
await page.goto(
|
|
143
|
+
async goto (target) {
|
|
144
|
+
await page.goto(createPageUrl(withPagePath(target)));
|
|
133
145
|
await waitForReady(page);
|
|
134
146
|
currentPageId = await page.evaluate(()=>window.lowdefy?.pageId);
|
|
135
147
|
currentBlockMap = manifest.pages[currentPageId];
|
|
@@ -138,8 +150,8 @@ function createPageManager({ page, manifest, helperRegistry, mockManager, assert
|
|
|
138
150
|
throw new Error(`Page "${currentPageId}" not found in manifest. Available pages: ${available || '(none)'}`);
|
|
139
151
|
}
|
|
140
152
|
},
|
|
141
|
-
async waitForPage (
|
|
142
|
-
await waitForPage(page,
|
|
153
|
+
async waitForPage (target) {
|
|
154
|
+
await waitForPage(page, withPagePath(target));
|
|
143
155
|
currentPageId = await page.evaluate(()=>window.lowdefy?.pageId);
|
|
144
156
|
currentBlockMap = manifest.pages[currentPageId];
|
|
145
157
|
},
|