@lowdefy/e2e-utils 5.5.1 → 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/README.md CHANGED
@@ -151,6 +151,8 @@ await ldf.url().expect.toMatch(/\/tickets\/\d+/);
151
151
 
152
152
  // Query params
153
153
  await ldf.urlQuery('filter').expect.toBe('active');
154
+ // do.set navigates through the app router, so a Dynamic page re-resolves its
155
+ // content for the new query before this resolves.
154
156
  await ldf.urlQuery('page').do.set('2');
155
157
 
156
158
  // Get raw values
@@ -205,6 +207,20 @@ const blockState = await ldf.block('name_input').state();
205
207
  const validation = await ldf.block('email_input').validation();
206
208
  ```
207
209
 
210
+ ### Keyboard Shortcuts
211
+
212
+ A `mod` shortcut resolves to Cmd or Ctrl depending on the platform the **browser** reports, which
213
+ Playwright emulates per project — a Desktop Chrome project reports Windows even on macOS. Derive the
214
+ key from the page rather than from `process.platform`, or the test will press a key the app is not
215
+ listening for:
216
+
217
+ ```javascript
218
+ import { getShortcutModifier } from '@lowdefy/e2e-utils';
219
+
220
+ const mod = await getShortcutModifier(ldf.page);
221
+ await ldf.page.keyboard.press(`${mod}+k`);
222
+ ```
223
+
208
224
  ## Complete Example
209
225
 
210
226
  ```javascript
@@ -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
+ */ import { expect } from '@playwright/test';
16
+ import { getApiState } from '../core/api.js';
17
+ async function expectApi(page, { endpointId, loading, response, payload, timeout = 30000 }) {
18
+ if (loading !== undefined) {
19
+ await expect.poll(async ()=>{
20
+ const state = await getApiState(page, endpointId);
21
+ return state?.loading;
22
+ }, {
23
+ timeout
24
+ }).toBe(loading);
25
+ }
26
+ if (response !== undefined) {
27
+ await expect.poll(async ()=>{
28
+ const state = await getApiState(page, endpointId);
29
+ return state?.response;
30
+ }, {
31
+ timeout
32
+ }).toEqual(expect.objectContaining(response));
33
+ }
34
+ if (payload !== undefined) {
35
+ await expect.poll(async ()=>{
36
+ const state = await getApiState(page, endpointId);
37
+ return state?.payload;
38
+ }, {
39
+ timeout
40
+ }).toEqual(expect.objectContaining(payload));
41
+ }
42
+ }
43
+ export { expectApi };
@@ -0,0 +1,20 @@
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
+ */ export { expectState } from './state.js';
16
+ export { expectRequest } from './requests.js';
17
+ export { expectApi } from './api.js';
18
+ export { expectNavigation } from './navigation.js';
19
+ export { expectUrl, expectUrlQuery } from './url.js';
20
+ export { expectValidationStatus, expectValidationError, expectValidationWarning, expectValidationSuccess } from './validation.js';
@@ -0,0 +1,19 @@
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 { expect } from '@playwright/test';
16
+ async function expectNavigation(page, urlPattern) {
17
+ await expect(page).toHaveURL(urlPattern);
18
+ }
19
+ export { expectNavigation };
@@ -0,0 +1,45 @@
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 { expect } from '@playwright/test';
16
+ import { getRequestState } from '../core/requests.js';
17
+ async function expectRequest(page, { requestId, loading, response, payload, timeout = 30000 }) {
18
+ if (loading !== undefined) {
19
+ await expect.poll(async ()=>{
20
+ const state = await getRequestState(page, requestId);
21
+ return state?.loading;
22
+ }, {
23
+ timeout
24
+ }).toBe(loading);
25
+ }
26
+ if (response !== undefined) {
27
+ await expect.poll(async ()=>{
28
+ const state = await getRequestState(page, requestId);
29
+ return state?.response;
30
+ }, {
31
+ timeout
32
+ }).toEqual(expect.objectContaining(response));
33
+ }
34
+ // Payload is stored in the request state by the Lowdefy engine.
35
+ // It's the evaluated payload from the request config (client-side).
36
+ if (payload !== undefined) {
37
+ await expect.poll(async ()=>{
38
+ const state = await getRequestState(page, requestId);
39
+ return state?.payload;
40
+ }, {
41
+ timeout
42
+ }).toEqual(expect.objectContaining(payload));
43
+ }
44
+ }
45
+ export { expectRequest };
@@ -0,0 +1,26 @@
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 { expect } from '@playwright/test';
16
+ import { get } from '@lowdefy/helpers';
17
+ import { getState } from '../core/state.js';
18
+ async function expectState(page, { key, value, timeout = 5000 }) {
19
+ await expect.poll(async ()=>{
20
+ const state = await getState(page);
21
+ return get(state, key);
22
+ }, {
23
+ timeout
24
+ }).toEqual(value);
25
+ }
26
+ export { expectState };
@@ -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
+ */ import { expect } from '@playwright/test';
16
+ async function expectUrl(page, { url, timeout = 5000 }) {
17
+ if (!url) {
18
+ throw new Error('expectUrl requires a "url" parameter (string or RegExp).');
19
+ }
20
+ await expect(page).toHaveURL(url, {
21
+ timeout
22
+ });
23
+ }
24
+ async function expectUrlQuery(page, { key, value, timeout = 5000 }) {
25
+ await expect.poll(async ()=>{
26
+ const url = new URL(page.url());
27
+ return url.searchParams.get(key);
28
+ }, {
29
+ timeout
30
+ }).toBe(value);
31
+ }
32
+ export { expectUrl, expectUrlQuery };
@@ -0,0 +1,64 @@
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 { expect } from '@playwright/test';
16
+ import { getValidation } from '../core/validation.js';
17
+ async function expectValidationStatus(page, { blockId, status, timeout = 5000 }) {
18
+ await expect.poll(async ()=>{
19
+ const validation = await getValidation(page, blockId);
20
+ return validation?.status;
21
+ }, {
22
+ timeout
23
+ }).toBe(status);
24
+ }
25
+ async function expectValidationError(page, { blockId, message, timeout = 5000 }) {
26
+ await expect.poll(async ()=>{
27
+ const validation = await getValidation(page, blockId);
28
+ if (validation?.status !== 'error') return {
29
+ status: validation?.status
30
+ };
31
+ if (message && !validation?.errors?.includes(message)) {
32
+ return {
33
+ errors: validation?.errors
34
+ };
35
+ }
36
+ return true;
37
+ }, {
38
+ timeout
39
+ }).toBe(true);
40
+ }
41
+ async function expectValidationWarning(page, { blockId, message, timeout = 5000 }) {
42
+ await expect.poll(async ()=>{
43
+ const validation = await getValidation(page, blockId);
44
+ if (validation?.status !== 'warning') return {
45
+ status: validation?.status
46
+ };
47
+ if (message && !validation?.warnings?.includes(message)) {
48
+ return {
49
+ warnings: validation?.warnings
50
+ };
51
+ }
52
+ return true;
53
+ }, {
54
+ timeout
55
+ }).toBe(true);
56
+ }
57
+ async function expectValidationSuccess(page, { blockId, timeout = 5000 }) {
58
+ await expectValidationStatus(page, {
59
+ blockId,
60
+ status: 'success',
61
+ timeout
62
+ });
63
+ }
64
+ export { expectValidationStatus, expectValidationError, expectValidationWarning, expectValidationSuccess };
package/dist/config.js CHANGED
@@ -94,10 +94,7 @@ function createMultiAppConfig({ apps = [], commandPrefix = '', testDir = 'e2e',
94
94
  url: `http://localhost:${app.port}/api/auth/session`,
95
95
  reuseExistingServer: true,
96
96
  timeout,
97
- cwd: app.appDir,
98
- env: {
99
- NEXT_PUBLIC_LOWDEFY_E2E: 'true'
100
- }
97
+ cwd: app.appDir
101
98
  };
102
99
  });
103
100
  return defineConfig({
package/dist/core/api.js CHANGED
@@ -12,8 +12,7 @@
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
- */ import { expect } from '@playwright/test';
16
- async function getApiState(page, endpointId) {
15
+ */ async function getApiState(page, endpointId) {
17
16
  return page.evaluate((id)=>{
18
17
  const lowdefy = window.lowdefy;
19
18
  return lowdefy?.apiResponses?.[id]?.[0];
@@ -23,30 +22,4 @@ async function getApiResponse(page, { endpointId }) {
23
22
  const state = await getApiState(page, endpointId);
24
23
  return state?.response;
25
24
  }
26
- async function expectApi(page, { endpointId, loading, response, payload, timeout = 30000 }) {
27
- if (loading !== undefined) {
28
- await expect.poll(async ()=>{
29
- const state = await getApiState(page, endpointId);
30
- return state?.loading;
31
- }, {
32
- timeout
33
- }).toBe(loading);
34
- }
35
- if (response !== undefined) {
36
- await expect.poll(async ()=>{
37
- const state = await getApiState(page, endpointId);
38
- return state?.response;
39
- }, {
40
- timeout
41
- }).toEqual(expect.objectContaining(response));
42
- }
43
- if (payload !== undefined) {
44
- await expect.poll(async ()=>{
45
- const state = await getApiState(page, endpointId);
46
- return state?.payload;
47
- }, {
48
- timeout
49
- }).toEqual(expect.objectContaining(payload));
50
- }
51
- }
52
- export { getApiState, getApiResponse, expectApi };
25
+ export { getApiState, getApiResponse };
@@ -12,9 +12,10 @@
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
- */ export { getBlock } from './locators.js';
16
- export { goto, waitForReady, expectNavigation, waitForPage } from './navigation.js';
17
- export { getRequestState, getRequestResponse, expectRequest } from './requests.js';
18
- export { getState, getBlockState, setState, expectState } from './state.js';
19
- export { getValidation, expectValidationError, expectValidationWarning, expectValidationSuccess } from './validation.js';
20
- export { expectUrl, expectUrlQuery, setUrlQuery } from './url.js';
15
+ */ export { getShortcutModifier } from './keyboard.js';
16
+ export { getBlock } from './locators.js';
17
+ export { goto, waitForReady, waitForPage } from './navigation.js';
18
+ export { getRequestState, getRequestResponse } from './requests.js';
19
+ export { getState, getBlockState, setState } from './state.js';
20
+ export { getValidation } from './validation.js';
21
+ export { setUrlQuery } from './url.js';
@@ -0,0 +1,23 @@
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
+ */ // A `mod` shortcut is resolved by tinykeys, which reads navigator.platform in the browser. That is
16
+ // the platform Playwright emulates for the project's device, not the one the test runner is on — a
17
+ // Desktop Chrome project reports Win32 even on macOS. Reading it from the page is what keeps the key
18
+ // a test presses the same key the app is listening for. Mirrors tinykeys' own check.
19
+ async function getShortcutModifier(page) {
20
+ const isApple = await page.evaluate(()=>/Mac|iPod|iPhone|iPad/.test(navigator.platform));
21
+ return isApple ? 'Meta' : 'Control';
22
+ }
23
+ export { getShortcutModifier };
@@ -12,8 +12,7 @@
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
- */ import { expect } from '@playwright/test';
16
- async function waitForReady(page) {
15
+ */ async function waitForReady(page) {
17
16
  // Wait for Lowdefy client to initialize (context exists)
18
17
  // Context key is "page:{pageId}" not just pageId
19
18
  await page.waitForFunction(()=>{
@@ -32,13 +31,10 @@ async function goto(page, path) {
32
31
  });
33
32
  await waitForReady(page);
34
33
  }
35
- async function expectNavigation(page, urlPattern) {
36
- await expect(page).toHaveURL(urlPattern);
37
- }
38
34
  async function waitForPage(page, path) {
39
35
  await page.waitForURL(path, {
40
36
  waitUntil: 'domcontentloaded'
41
37
  });
42
38
  await waitForReady(page);
43
39
  }
44
- export { goto, waitForReady, expectNavigation, waitForPage };
40
+ export { goto, waitForReady, waitForPage };
@@ -12,8 +12,7 @@
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
- */ import { expect } from '@playwright/test';
16
- async function getRequestState(page, requestId) {
15
+ */ async function getRequestState(page, requestId) {
17
16
  return page.evaluate((reqId)=>{
18
17
  const lowdefy = window.lowdefy;
19
18
  const pageId = lowdefy?.pageId;
@@ -25,32 +24,4 @@ async function getRequestResponse(page, { requestId }) {
25
24
  const state = await getRequestState(page, requestId);
26
25
  return state?.response;
27
26
  }
28
- async function expectRequest(page, { requestId, loading, response, payload, timeout = 30000 }) {
29
- if (loading !== undefined) {
30
- await expect.poll(async ()=>{
31
- const state = await getRequestState(page, requestId);
32
- return state?.loading;
33
- }, {
34
- timeout
35
- }).toBe(loading);
36
- }
37
- if (response !== undefined) {
38
- await expect.poll(async ()=>{
39
- const state = await getRequestState(page, requestId);
40
- return state?.response;
41
- }, {
42
- timeout
43
- }).toEqual(expect.objectContaining(response));
44
- }
45
- // Payload is stored in the request state by the Lowdefy engine.
46
- // It's the evaluated payload from the request config (client-side).
47
- if (payload !== undefined) {
48
- await expect.poll(async ()=>{
49
- const state = await getRequestState(page, requestId);
50
- return state?.payload;
51
- }, {
52
- timeout
53
- }).toEqual(expect.objectContaining(payload));
54
- }
55
- }
56
- export { getRequestState, getRequestResponse, expectRequest };
27
+ export { getRequestState, getRequestResponse };
@@ -12,9 +12,7 @@
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
- */ import { expect } from '@playwright/test';
16
- import { get } from '@lowdefy/helpers';
17
- async function getState(page) {
15
+ */ async function getState(page) {
18
16
  return page.evaluate(()=>{
19
17
  const lowdefy = window.lowdefy;
20
18
  const pageId = lowdefy?.pageId;
@@ -26,26 +24,18 @@ async function getBlockState(page, { blockId }) {
26
24
  return state?.[blockId];
27
25
  }
28
26
  async function setState(page, { key, value }) {
27
+ // context.methods.setState does not exist on engine contexts - the engine's
28
+ // action layer (see createSetState.js) writes state via State.set() and then
29
+ // triggers a re-render via context._internal.update().
29
30
  await page.evaluate(({ k, v })=>{
30
31
  const lowdefy = window.lowdefy;
31
32
  const pageId = lowdefy?.pageId;
32
33
  const context = lowdefy?.contexts?.[`page:${pageId}`];
33
- if (context?.methods?.setState) {
34
- context.methods.setState({
35
- [k]: v
36
- });
37
- }
34
+ context._internal.State.set(k, v);
35
+ context._internal.update();
38
36
  }, {
39
37
  k: key,
40
38
  v: value
41
39
  });
42
40
  }
43
- async function expectState(page, { key, value, timeout = 5000 }) {
44
- await expect.poll(async ()=>{
45
- const state = await getState(page);
46
- return get(state, key);
47
- }, {
48
- timeout
49
- }).toEqual(value);
50
- }
51
- export { getState, getBlockState, setState, expectState };
41
+ export { getState, getBlockState, setState };
package/dist/core/url.js CHANGED
@@ -12,35 +12,78 @@
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
- */ import { expect } from '@playwright/test';
16
- async function expectUrl(page, { url, timeout = 5000 }) {
17
- if (!url) {
18
- throw new Error('expectUrl requires a "url" parameter (string or RegExp).');
15
+ */ import { type } from '@lowdefy/helpers';
16
+ import { waitForReady } from './navigation.js';
17
+ // The client router's createUrl prepends basePath, so the pathname handed to it
18
+ // must have basePath stripped or a basePath app receives it twice.
19
+ function createTargetLocation({ basePath = '', href, key, value }) {
20
+ const url = new URL(href);
21
+ if (type.isNone(value)) {
22
+ url.searchParams.delete(key);
23
+ } else {
24
+ url.searchParams.set(key, value);
19
25
  }
20
- await expect(page).toHaveURL(url, {
21
- timeout
22
- });
23
- }
24
- async function expectUrlQuery(page, { key, value, timeout = 5000 }) {
25
- await expect.poll(async ()=>{
26
- const url = new URL(page.url());
27
- return url.searchParams.get(key);
28
- }, {
29
- timeout
30
- }).toBe(value);
26
+ const pathname = basePath && url.pathname.startsWith(basePath) ? url.pathname.slice(basePath.length) : url.pathname;
27
+ return {
28
+ pathname,
29
+ query: url.searchParams.toString()
30
+ };
31
31
  }
32
+ // Navigates through the app's own router rather than calling history.pushState
33
+ // directly. A bare pushState updates the URL without notifying the router, so
34
+ // the page config is never re-fetched and Dynamic pages keep serving content
35
+ // resolved from the previous urlQuery. Routing through the router is what a
36
+ // Link or SetUrlQuery action in the app does.
32
37
  async function setUrlQuery(page, { key, value }) {
33
- await page.evaluate(({ k, v })=>{
34
- const url = new URL(window.location.href);
35
- if (v === null || v === undefined) {
36
- url.searchParams.delete(k);
37
- } else {
38
- url.searchParams.set(k, v);
38
+ const { basePath, dynamic, href } = await page.evaluate(()=>{
39
+ const lowdefy = window.lowdefy;
40
+ const router = lowdefy?._internal?.router;
41
+ if (!router) {
42
+ throw new Error('Lowdefy client not initialized. Call goto() before setting urlQuery.');
39
43
  }
40
- window.history.pushState({}, '', url);
41
- }, {
42
- k: key,
43
- v: value
44
+ return {
45
+ basePath: router.basePath ?? '',
46
+ dynamic: lowdefy.contexts[`page:${lowdefy.pageId}`]?._internal?.pageConfig?.dynamic === true,
47
+ href: window.location.href
48
+ };
44
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/'), {
54
+ timeout: 30000
55
+ }).catch((error)=>{
56
+ // A response that never arrives must not hang the suite — fall through and
57
+ // let the test's own assertion report it. Anything else is a real fault.
58
+ if (error.name !== 'TimeoutError') throw error;
59
+ });
60
+ // A Dynamic page's content is resolved server-side from urlQuery, and the
61
+ // engine rebuilds its context whenever a new config object arrives (see
62
+ // getContext). Waiting for that rebuild proves the re-resolved content is in
63
+ // place; the response arriving only proves it is on its way. Static pages keep
64
+ // 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
+ await page.evaluate(({ pathname, query })=>{
67
+ // scroll: false keeps the viewport where it was — a query change is not a
68
+ // page navigation from the test's point of view.
69
+ window.lowdefy._internal.router.push({
70
+ pathname,
71
+ query,
72
+ scroll: false
73
+ });
74
+ }, createTargetLocation({
75
+ basePath,
76
+ href,
77
+ key,
78
+ value
79
+ }));
80
+ await pageConfigFetched;
81
+ if (previousPageConfig) {
82
+ await page.waitForFunction((previous)=>window.lowdefy.contexts[`page:${window.lowdefy.pageId}`]?._internal?.pageConfig !== previous, previousPageConfig, {
83
+ timeout: 30000
84
+ });
85
+ await previousPageConfig.dispose();
86
+ }
87
+ await waitForReady(page);
45
88
  }
46
- export { expectUrl, expectUrlQuery, setUrlQuery };
89
+ export { createTargetLocation, setUrlQuery };
@@ -12,8 +12,7 @@
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
- */ import { expect } from '@playwright/test';
16
- async function getValidation(page, blockId) {
15
+ */ async function getValidation(page, blockId) {
17
16
  return page.evaluate((id)=>{
18
17
  const lowdefy = window.lowdefy;
19
18
  const pageId = lowdefy?.pageId;
@@ -22,51 +21,4 @@ async function getValidation(page, blockId) {
22
21
  return block?.validationEval?.output;
23
22
  }, blockId);
24
23
  }
25
- async function expectValidationStatus(page, { blockId, status, timeout = 5000 }) {
26
- await expect.poll(async ()=>{
27
- const validation = await getValidation(page, blockId);
28
- return validation?.status;
29
- }, {
30
- timeout
31
- }).toBe(status);
32
- }
33
- async function expectValidationError(page, { blockId, message, timeout = 5000 }) {
34
- await expect.poll(async ()=>{
35
- const validation = await getValidation(page, blockId);
36
- if (validation?.status !== 'error') return {
37
- status: validation?.status
38
- };
39
- if (message && !validation?.errors?.includes(message)) {
40
- return {
41
- errors: validation?.errors
42
- };
43
- }
44
- return true;
45
- }, {
46
- timeout
47
- }).toBe(true);
48
- }
49
- async function expectValidationWarning(page, { blockId, message, timeout = 5000 }) {
50
- await expect.poll(async ()=>{
51
- const validation = await getValidation(page, blockId);
52
- if (validation?.status !== 'warning') return {
53
- status: validation?.status
54
- };
55
- if (message && !validation?.warnings?.includes(message)) {
56
- return {
57
- warnings: validation?.warnings
58
- };
59
- }
60
- return true;
61
- }, {
62
- timeout
63
- }).toBe(true);
64
- }
65
- async function expectValidationSuccess(page, { blockId, timeout = 5000 }) {
66
- await expectValidationStatus(page, {
67
- blockId,
68
- status: 'success',
69
- timeout
70
- });
71
- }
72
- export { getValidation, expectValidationStatus, expectValidationError, expectValidationWarning, expectValidationSuccess };
24
+ export { getValidation };
@@ -20,6 +20,20 @@ import createPageManager from '../proxy/createPageManager.js';
20
20
  import { generateManifest, loadManifest } from '../testPrep/generateManifest.js';
21
21
  import { createMockManager, loadStaticMocks } from '../mocking/index.js';
22
22
  import { setUserCookie } from '../core/userCookie.js';
23
+ import { expectState } from '../assertions/state.js';
24
+ import { expectRequest } from '../assertions/requests.js';
25
+ import { expectApi } from '../assertions/api.js';
26
+ import { expectUrl, expectUrlQuery } from '../assertions/url.js';
27
+ // The Playwright `expect` implementations createPageManager needs for its
28
+ // `.expect.*` methods - injected here so createPageManager itself (exported from
29
+ // the "./runtime" subpath too) never has to import @playwright/test.
30
+ const assertions = {
31
+ expectState,
32
+ expectRequest,
33
+ expectApi,
34
+ expectUrl,
35
+ expectUrlQuery
36
+ };
23
37
  const DEFAULT_BUILD_DIR = '.lowdefy/server/build';
24
38
  // Create test with ldf fixture
25
39
  export const test = base.extend({
@@ -149,7 +163,8 @@ export const test = base.extend({
149
163
  page,
150
164
  manifest,
151
165
  helperRegistry,
152
- mockManager
166
+ mockManager,
167
+ assertions
153
168
  });
154
169
  await use(pageManager);
155
170
  await mockManager.cleanup();
package/dist/index.js CHANGED
@@ -12,21 +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
- */ // Core helpers
15
+ */ // Core helpers (runner-agnostic - also available from the "./runtime" subpath)
16
16
  import { escapeId } from './core/escapeId.js';
17
+ import { getShortcutModifier } from './core/keyboard.js';
17
18
  import { getBlock } from './core/locators.js';
18
- import { goto, waitForReady, expectNavigation, waitForPage } from './core/navigation.js';
19
- import { getRequestState, getRequestResponse, expectRequest } from './core/requests.js';
20
- import { getState, getBlockState, setState, expectState } from './core/state.js';
21
- import { getValidation, expectValidationStatus, expectValidationError, expectValidationWarning, expectValidationSuccess } from './core/validation.js';
22
- import { expectUrl, expectUrlQuery, setUrlQuery } from './core/url.js';
19
+ import { goto, waitForReady, waitForPage } from './core/navigation.js';
20
+ import { getRequestState, getRequestResponse } from './core/requests.js';
21
+ import { getState, getBlockState, setState } from './core/state.js';
22
+ import { getValidation } from './core/validation.js';
23
+ import { setUrlQuery } from './core/url.js';
23
24
  export { escapeId };
25
+ export { getShortcutModifier };
24
26
  export { getBlock };
25
- export { goto, waitForReady, expectNavigation, waitForPage };
26
- export { getRequestState, getRequestResponse, expectRequest };
27
- export { getState, getBlockState, setState, expectState };
28
- export { getValidation, expectValidationStatus, expectValidationError, expectValidationWarning, expectValidationSuccess };
29
- export { expectUrl, expectUrlQuery, setUrlQuery };
27
+ export { goto, waitForReady, waitForPage };
28
+ export { getRequestState, getRequestResponse };
29
+ export { getState, getBlockState, setState };
30
+ export { getValidation };
31
+ export { setUrlQuery };
32
+ // Assertions (coupled to @playwright/test)
33
+ import { expectNavigation } from './assertions/navigation.js';
34
+ import { expectRequest } from './assertions/requests.js';
35
+ import { expectState } from './assertions/state.js';
36
+ import { expectValidationStatus, expectValidationError, expectValidationWarning, expectValidationSuccess } from './assertions/validation.js';
37
+ import { expectUrl, expectUrlQuery } from './assertions/url.js';
38
+ export { expectNavigation };
39
+ export { expectRequest };
40
+ export { expectState };
41
+ export { expectValidationStatus, expectValidationError, expectValidationWarning, expectValidationSuccess };
42
+ export { expectUrl, expectUrlQuery };
30
43
  // Test prep utilities
31
44
  import { generateManifest, loadManifest } from './testPrep/generateManifest.js';
32
45
  export { generateManifest, loadManifest };
@@ -0,0 +1,61 @@
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
+ */ /* eslint-disable no-console */ import fs from 'fs';
16
+ import path from 'path';
17
+ import yaml from 'js-yaml';
18
+ function findWorkspaceRoot(startDir) {
19
+ let dir = startDir;
20
+ while(dir !== path.dirname(dir)){
21
+ if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) return dir;
22
+ dir = path.dirname(dir);
23
+ }
24
+ return null;
25
+ }
26
+ // pnpm no longer reads the "pnpm" field in package.json, and pnpm 11 fails installs
27
+ // with ERR_PNPM_IGNORED_BUILDS unless dependency build scripts are allowed in
28
+ // pnpm-workspace.yaml. The key differs by version:
29
+ // - packages is required by pnpm 9 and 10.0.
30
+ // - onlyBuiltDependencies is read by early pnpm 10 versions.
31
+ // - allowBuilds is read by pnpm >=10.29 and pnpm 11.
32
+ function allowDependencyBuild({ cwd, appPath, dependency }) {
33
+ const appDir = path.join(cwd, appPath);
34
+ const workspaceRoot = findWorkspaceRoot(appDir);
35
+ const targetDir = workspaceRoot ?? appDir;
36
+ const workspacePath = path.join(targetDir, 'pnpm-workspace.yaml');
37
+ const config = workspaceRoot ? yaml.load(fs.readFileSync(workspacePath, 'utf8')) ?? {} : {
38
+ packages: [
39
+ '.'
40
+ ]
41
+ };
42
+ const onlyBuiltDependencies = config.onlyBuiltDependencies ?? [];
43
+ const allowBuilds = config.allowBuilds ?? {};
44
+ if (onlyBuiltDependencies.includes(dependency) && allowBuilds[dependency] === true) {
45
+ return;
46
+ }
47
+ if (!onlyBuiltDependencies.includes(dependency)) {
48
+ config.onlyBuiltDependencies = [
49
+ ...onlyBuiltDependencies,
50
+ dependency
51
+ ];
52
+ }
53
+ config.allowBuilds = {
54
+ ...allowBuilds,
55
+ [dependency]: true
56
+ };
57
+ fs.writeFileSync(workspacePath, yaml.dump(config));
58
+ const label = workspaceRoot ? 'workspace root pnpm-workspace.yaml' : `${appPath}/pnpm-workspace.yaml`;
59
+ console.log(` ✓ Allowed ${dependency} build scripts in ${label}`);
60
+ }
61
+ export default allowDependencyBuild;
@@ -15,6 +15,7 @@
15
15
  */ /* eslint-disable no-console */ import fs from 'fs';
16
16
  import path from 'path';
17
17
  import { fileURLToPath } from 'url';
18
+ import allowDependencyBuild from './allowDependencyBuild.js';
18
19
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
20
  function copyTemplate(templateName, destPath) {
20
21
  const srcPath = path.join(__dirname, 'templates', templateName);
@@ -24,34 +25,6 @@ function copyTemplate(templateName, destPath) {
24
25
  }
25
26
  return false;
26
27
  }
27
- function findWorkspaceRoot(startDir) {
28
- let dir = startDir;
29
- while(dir !== path.dirname(dir)){
30
- if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) return dir;
31
- dir = path.dirname(dir);
32
- }
33
- return null;
34
- }
35
- function addOnlyBuiltDependency({ cwd, appPath, dependency }) {
36
- const appDir = path.join(cwd, appPath);
37
- const workspaceRoot = findWorkspaceRoot(appDir);
38
- // In a pnpm workspace, onlyBuiltDependencies must be in the workspace root package.json
39
- const targetDir = workspaceRoot ?? path.join(cwd, appPath);
40
- const pkgPath = path.join(targetDir, 'package.json');
41
- if (!fs.existsSync(pkgPath)) return;
42
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
43
- const allowedBuilds = pkg.pnpm?.onlyBuiltDependencies ?? [];
44
- if (!allowedBuilds.includes(dependency)) {
45
- pkg.pnpm = pkg.pnpm || {};
46
- pkg.pnpm.onlyBuiltDependencies = [
47
- ...allowedBuilds,
48
- dependency
49
- ];
50
- fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
51
- const label = workspaceRoot ? 'workspace root package.json' : `${appPath}/package.json`;
52
- console.log(` ✓ Added ${dependency} to pnpm.onlyBuiltDependencies in ${label}`);
53
- }
54
- }
55
28
  function updatePackageJson({ appPath, cwd, useExperimental, useMongoDB }) {
56
29
  const pkgPath = path.join(cwd, appPath, 'package.json');
57
30
  if (!fs.existsSync(pkgPath)) {
@@ -97,9 +70,9 @@ function updatePackageJson({ appPath, cwd, useExperimental, useMongoDB }) {
97
70
  fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
98
71
  console.log(` ✓ Updated ${appPath}/package.json with e2e scripts and dependencies`);
99
72
  }
100
- // mongodb-memory-server needs onlyBuiltDependencies (workspace-aware)
73
+ // mongodb-memory-server downloads mongod binaries in a postinstall script
101
74
  if (useMongoDB) {
102
- addOnlyBuiltDependency({
75
+ allowDependencyBuild({
103
76
  cwd,
104
77
  appPath,
105
78
  dependency: 'mongodb-memory-server'
@@ -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 { expect } from '@playwright/test';
16
- import { expectValidationError, expectValidationWarning, expectValidationSuccess } from '../core/validation.js';
16
+ import { expectValidationError, expectValidationWarning, expectValidationSuccess } from '../assertions/validation.js';
17
17
  // Each block's e2e helper defines a `locator` function that targets the block element on the page.
18
18
  // Two locator patterns exist:
19
19
  // - `#${escapeId(blockId)}` — for blocks that render `id={blockId}` on their root DOM element
@@ -14,16 +14,26 @@
14
14
  limitations under the License.
15
15
  */ import { getBlock } from '../core/locators.js';
16
16
  import { waitForReady, waitForPage } from '../core/navigation.js';
17
- import { getState, getBlockState, setState, expectState } from '../core/state.js';
18
- import { getRequestState, getRequestResponse, expectRequest } from '../core/requests.js';
19
- import { getApiState, getApiResponse, expectApi } from '../core/api.js';
17
+ import { getState, getBlockState, setState } from '../core/state.js';
18
+ import { getRequestState, getRequestResponse } from '../core/requests.js';
19
+ import { getApiState, getApiResponse } from '../core/api.js';
20
20
  import { getValidation } from '../core/validation.js';
21
- import { expectUrl, expectUrlQuery, setUrlQuery } from '../core/url.js';
21
+ import { setUrlQuery } from '../core/url.js';
22
22
  import { setUserCookie, clearUserCookie } from '../core/userCookie.js';
23
23
  import { get, type } from '@lowdefy/helpers';
24
24
  import createBlockMethodProxy from './createBlockMethodProxy.js';
25
25
  import resolveTemplateId from './resolveTemplateId.js';
26
- function createPageManager({ page, manifest, helperRegistry, mockManager }) {
26
+ // createPageManager itself stays free of @playwright/test (it is exported from the
27
+ // "./runtime" subpath). The Playwright-backed `expect*` functions are injected via
28
+ // `assertions` by the caller - the "./fixtures" ldf fixture wires these automatically.
29
+ function requireAssertion(fn, name) {
30
+ if (type.isUndefined(fn)) {
31
+ throw new Error(`createPageManager: "${name}" assertion is not available on this instance. ` + `Import { ${name} } from "@lowdefy/e2e-utils" and pass it as assertions.${name}, ` + `or use the "@lowdefy/e2e-utils/fixtures" ldf fixture, which wires assertions automatically.`);
32
+ }
33
+ return fn;
34
+ }
35
+ function createPageManager({ page, manifest, helperRegistry, mockManager, assertions = {} }) {
36
+ const { expectState, expectRequest, expectApi, expectUrl, expectUrlQuery } = assertions;
27
37
  let currentBlockMap = null;
28
38
  let currentPageId = null;
29
39
  function ensurePageLoaded() {
@@ -142,17 +152,17 @@ function createPageManager({ page, manifest, helperRegistry, mockManager }) {
142
152
  request (requestId) {
143
153
  return {
144
154
  expect: {
145
- toFinish: (opts)=>expectRequest(page, {
155
+ toFinish: (opts)=>requireAssertion(expectRequest, 'expectRequest')(page, {
146
156
  requestId,
147
157
  loading: false,
148
158
  ...opts
149
159
  }),
150
- toHaveResponse: (response, opts)=>expectRequest(page, {
160
+ toHaveResponse: (response, opts)=>requireAssertion(expectRequest, 'expectRequest')(page, {
151
161
  requestId,
152
162
  response,
153
163
  ...opts
154
164
  }),
155
- toHavePayload: (payload, opts)=>expectRequest(page, {
165
+ toHavePayload: (payload, opts)=>requireAssertion(expectRequest, 'expectRequest')(page, {
156
166
  requestId,
157
167
  payload,
158
168
  ...opts
@@ -168,17 +178,17 @@ function createPageManager({ page, manifest, helperRegistry, mockManager }) {
168
178
  api (endpointId) {
169
179
  return {
170
180
  expect: {
171
- toFinish: (opts)=>expectApi(page, {
181
+ toFinish: (opts)=>requireAssertion(expectApi, 'expectApi')(page, {
172
182
  endpointId,
173
183
  loading: false,
174
184
  ...opts
175
185
  }),
176
- toHaveResponse: (response, opts)=>expectApi(page, {
186
+ toHaveResponse: (response, opts)=>requireAssertion(expectApi, 'expectApi')(page, {
177
187
  endpointId,
178
188
  response,
179
189
  ...opts
180
190
  }),
181
- toHavePayload: (payload, opts)=>expectApi(page, {
191
+ toHavePayload: (payload, opts)=>requireAssertion(expectApi, 'expectApi')(page, {
182
192
  endpointId,
183
193
  payload,
184
194
  ...opts
@@ -205,7 +215,7 @@ function createPageManager({ page, manifest, helperRegistry, mockManager }) {
205
215
  })
206
216
  },
207
217
  expect: {
208
- toBe: (value, opts)=>expectState(page, {
218
+ toBe: (value, opts)=>requireAssertion(expectState, 'expectState')(page, {
209
219
  key,
210
220
  value,
211
221
  ...opts
@@ -218,11 +228,11 @@ function createPageManager({ page, manifest, helperRegistry, mockManager }) {
218
228
  url () {
219
229
  return {
220
230
  expect: {
221
- toBe: (path, opts)=>expectUrl(page, {
231
+ toBe: (path, opts)=>requireAssertion(expectUrl, 'expectUrl')(page, {
222
232
  url: path,
223
233
  ...opts
224
234
  }),
225
- toMatch: (pattern, opts)=>expectUrl(page, {
235
+ toMatch: (pattern, opts)=>requireAssertion(expectUrl, 'expectUrl')(page, {
226
236
  url: pattern,
227
237
  ...opts
228
238
  })
@@ -240,7 +250,7 @@ function createPageManager({ page, manifest, helperRegistry, mockManager }) {
240
250
  })
241
251
  },
242
252
  expect: {
243
- toBe: (value, opts)=>expectUrlQuery(page, {
253
+ toBe: (value, opts)=>requireAssertion(expectUrlQuery, 'expectUrlQuery')(page, {
244
254
  key,
245
255
  value,
246
256
  ...opts
@@ -0,0 +1,33 @@
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
+ */ // Runner-agnostic surface: reading/writing a Lowdefy Page via the Playwright
16
+ // `page` interface, with no dependency on @playwright/test. Useful for driving
17
+ // a page from tooling that isn't a Playwright test run (e.g. headless
18
+ // inspection). The `expect*` assertion helpers live in "./assertions" and the
19
+ // full test-authoring API (which does depend on @playwright/test) is exported
20
+ // from "." and "./fixtures".
21
+ export { escapeId } from './core/escapeId.js';
22
+ export { getShortcutModifier } from './core/keyboard.js';
23
+ export { getBlock } from './core/locators.js';
24
+ export { goto, waitForReady, waitForPage } from './core/navigation.js';
25
+ export { getRequestState, getRequestResponse } from './core/requests.js';
26
+ export { getApiState, getApiResponse } from './core/api.js';
27
+ export { getState, getBlockState, setState } from './core/state.js';
28
+ export { getValidation } from './core/validation.js';
29
+ export { setUrlQuery } from './core/url.js';
30
+ export { setUserCookie, clearUserCookie } from './core/userCookie.js';
31
+ export { generateManifest, loadManifest } from './testPrep/generateManifest.js';
32
+ export { createMockManager, loadStaticMocks } from './mocking/index.js';
33
+ export { default as createPageManager } from './proxy/createPageManager.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lowdefy/e2e-utils",
3
- "version": "5.5.1",
3
+ "version": "6.0.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Lowdefy E2E Testing Utilities for Playwright",
6
6
  "homepage": "https://lowdefy.com",
@@ -30,22 +30,23 @@
30
30
  "exports": {
31
31
  ".": "./dist/index.js",
32
32
  "./config": "./dist/config.js",
33
- "./fixtures": "./dist/fixtures/index.js"
33
+ "./fixtures": "./dist/fixtures/index.js",
34
+ "./runtime": "./dist/runtime.js"
34
35
  },
35
36
  "files": [
36
37
  "dist/*"
37
38
  ],
38
39
  "dependencies": {
39
- "@lowdefy/helpers": "5.5.1",
40
+ "@lowdefy/helpers": "6.0.0",
40
41
  "js-yaml": "4.1.0",
41
42
  "prompts": "2.4.2"
42
43
  },
43
44
  "peerDependencies": {
44
- "@playwright/test": "1.50.1"
45
+ "@playwright/test": "1.59.1"
45
46
  },
46
47
  "devDependencies": {
47
- "@swc/cli": "0.8.0",
48
- "@swc/core": "1.15.18",
48
+ "@swc/cli": "0.8.1",
49
+ "@swc/core": "1.15.32",
49
50
  "copyfiles": "2.4.1",
50
51
  "jest": "28.1.3"
51
52
  },