@lowdefy/e2e-utils 6.0.0 → 7.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/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
- function createConfig({ appDir = './', buildDir = '.lowdefy/server/build', commandPrefix = '', mocksFile = 'e2e/mocks.yaml', port = 3000, testDir = 'e2e', testMatch = '**/*.spec.js', timeout = 180000, screenshot = 'only-on-failure', outputDir = 'test-results' } = {}) {
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
- // Use session API for health check — page URLs may redirect when auth is configured
62
- url: `http://localhost:${port}/api/auth/session`,
63
- reuseExistingServer: true,
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
- // Use session API for health check — page URLs may redirect when auth is configured
94
- url: `http://localhost:${app.port}/api/auth/session`,
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;
@@ -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
- */ async function waitForReady(page) {
16
- // Wait for Lowdefy client to initialize (context exists)
17
- // Context key is "page:{pageId}" not just pageId
18
- await page.waitForFunction(()=>{
19
- const lowdefy = window.lowdefy;
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
- async function goto(page, path) {
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, path) {
35
- await page.waitForURL(path, {
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;
@@ -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
- */ async function getRequestState(page, requestId) {
16
- return page.evaluate((reqId)=>{
17
- const lowdefy = window.lowdefy;
18
- const pageId = lowdefy?.pageId;
19
- const requests = lowdefy?.contexts?.[`page:${pageId}`]?.requests?.[reqId];
20
- return requests?.[0];
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);
@@ -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
- */ async function getState(page) {
16
- return page.evaluate(()=>{
17
- const lowdefy = window.lowdefy;
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 lowdefy = window.lowdefy;
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: lowdefy.contexts[`page:${lowdefy.pageId}`]?._internal?.pageConfig?.dynamic === true,
47
+ dynamic: new Function(`return ${getContext}`)()?._internal?.pageConfig?.dynamic === true,
47
48
  href: window.location.href
48
49
  };
49
- });
50
- // A router navigation always re-fetches the page config, and blocks reading
51
- // _url_query re-render only once that config is applied. Listening starts
52
- // before the navigation so the response cannot be missed.
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(()=>window.lowdefy.contexts[`page:${window.lowdefy.pageId}`]._internal.pageConfig) : null;
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)=>window.lowdefy.contexts[`page:${window.lowdefy.pageId}`]?._internal?.pageConfig !== previous, previousPageConfig, {
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();
@@ -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
- */ async function getValidation(page, blockId) {
16
- return page.evaluate((id)=>{
17
- const lowdefy = window.lowdefy;
18
- const pageId = lowdefy?.pageId;
19
- const context = lowdefy?.contexts?.[`page:${pageId}`];
20
- const block = context?._internal?.RootSlots?.map?.[id];
21
- return block?.validationEval?.output;
22
- }, blockId);
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 };
@@ -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 — the config has `reuseExistingServer: true`, so Playwright detects the running server and skips the build:
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 (path) {
132
- await page.goto(path);
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 (path) {
142
- await waitForPage(page, path);
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
  },