Skip to content

Commit f1c4eb2

Browse files
committed
improvement
1 parent 9332a0b commit f1c4eb2

File tree

10 files changed

+295
-16
lines changed

10 files changed

+295
-16
lines changed
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# Playwright OnPageLoad Test
2+
3+
This example demonstrates how to expose JavaScript functions to your Swift/WebAssembly tests using Playwright's `page.exposeFunction` API.
4+
5+
## How it works
6+
7+
1. **Expose Script**: A JavaScript file that exports functions to be exposed in the browser context
8+
2. **Swift Tests**: Call these exposed functions using JavaScriptKit's `JSObject.global` API
9+
3. **Test Runner**: The `--playwright-expose` flag loads the script and exposes the functions before running tests
10+
11+
## Usage
12+
13+
### Define exposed functions in a JavaScript file
14+
15+
**Important:** All functions exposed via Playwright's `page.exposeFunction` are async from the browser's perspective, meaning they always return Promises. Define them as `async` for clarity.
16+
17+
#### Option 1: Function with Page Access (Recommended)
18+
19+
Export a function that receives the Playwright `page` object. This allows your exposed functions to interact with the browser page:
20+
21+
```javascript
22+
/**
23+
* @param {import('playwright').Page} page - The Playwright Page object
24+
*/
25+
export async function exposedFunctions(page) {
26+
return {
27+
expectToBeTrue: async () => {
28+
return true;
29+
},
30+
31+
// Use the page object to interact with the browser
32+
getTitle: async () => {
33+
return await page.title();
34+
},
35+
36+
clickButton: async (selector) => {
37+
await page.click(selector);
38+
return true;
39+
},
40+
41+
evaluate: async (script) => {
42+
return await page.evaluate(script);
43+
},
44+
45+
screenshot: async () => {
46+
const buffer = await page.screenshot();
47+
return buffer.toString('base64');
48+
}
49+
};
50+
}
51+
```
52+
53+
#### Option 2: Static Object (Simple Cases)
54+
55+
For simple functions that don't need page access:
56+
57+
```javascript
58+
export const exposedFunctions = {
59+
expectToBeTrue: async () => {
60+
return true;
61+
},
62+
63+
addNumbers: async (a, b) => {
64+
return a + b;
65+
}
66+
};
67+
```
68+
69+
### Use the functions in Swift tests
70+
71+
```swift
72+
import XCTest
73+
import JavaScriptKit
74+
import JavaScriptEventLoop
75+
76+
final class CheckTests: XCTestCase {
77+
func testExpectToBeTrue() async throws {
78+
guard let expectToBeTrue = JSObject.global.expectToBeTrue.function
79+
else { return XCTFail("Function expectToBeTrue not found") }
80+
81+
// Functions exposed via Playwright return Promises
82+
guard let promiseObject = expectToBeTrue().object
83+
else { return XCTFail("expectToBeTrue() did not return an object") }
84+
85+
guard let promise = JSPromise(promiseObject)
86+
else { return XCTFail("expectToBeTrue() did not return a Promise") }
87+
88+
let resultValue = try await promise.value
89+
guard let result = resultValue.boolean
90+
else { return XCTFail("expectToBeTrue() returned nil") }
91+
92+
XCTAssertTrue(result)
93+
}
94+
}
95+
```
96+
97+
### Run tests with the expose script
98+
99+
```bash
100+
swift package js test --environment browser --playwright-expose path/to/expose.js
101+
```
102+
103+
### Backward Compatibility
104+
105+
You can also use `--prelude` to define exposed functions, which allows combining WASM setup options (`setupOptions`) and Playwright exposed functions in one file:
106+
107+
```bash
108+
swift package js test --environment browser --prelude path/to/prelude.js
109+
```
110+
111+
However, using `--playwright-expose` is recommended for clarity and separation of concerns.
112+
113+
## Advanced Usage
114+
115+
### Access to Page Context
116+
117+
When you export a function (as shown in Option 1), you receive the Playwright `page` object, which gives you full access to the browser page. This is powerful because you can:
118+
119+
- **Query the DOM**: `await page.$('selector')`
120+
- **Execute JavaScript**: `await page.evaluate('...')`
121+
- **Take screenshots**: `await page.screenshot()`
122+
- **Navigate**: `await page.goto('...')`
123+
- **Handle events**: `page.on('console', ...)`
124+
125+
### Async Initialization
126+
127+
You can perform async initialization before returning your functions:
128+
129+
```javascript
130+
export async function exposedFunctions(page) {
131+
// Perform async setup
132+
const config = await loadConfiguration();
133+
134+
// Navigate to a specific page if needed
135+
await page.goto('http://example.com');
136+
137+
return {
138+
expectToBeTrue: async () => true,
139+
140+
getConfig: async () => config,
141+
142+
// Function that uses both page and initialization data
143+
checkElement: async (selector) => {
144+
const element = await page.$(selector);
145+
return element !== null;
146+
}
147+
};
148+
}
149+
```
150+
151+
### Best Practices
152+
153+
1. **Always use `async` functions**: All exposed functions are async from the browser's perspective
154+
2. **Capture `page` in closures**: Functions returned from `exposedFunctions(page)` can access `page` via closure
155+
3. **Handle errors**: Wrap page interactions in try-catch blocks
156+
4. **Return serializable data**: Functions can only return JSON-serializable values

Plugins/PackageToJS/Fixtures/PlaywrightOnPageLoadTest/SwiftTesting/Tests/CheckTests.swift

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,12 @@ import JavaScriptKit
33
import JavaScriptEventLoop
44

55
@Test func expectToBeTrue() async throws {
6-
let expectToBeTrue = #require(JSObject.global.expectToBeTrue.function)
6+
let expectToBeTrue = try #require(JSObject.global.expectToBeTrue.function)
77

88
// expectToBeTrue returns a Promise, so we need to await it
99
let promiseObject = try #require(expectToBeTrue.callAsFunction().object)
1010
let promise = try #require(JSPromise(promiseObject))
1111

1212
let resultValue = try await promise.value
13-
let result = try #require(resultValue.boolean)
14-
15-
#expect(result)
13+
#expect(resultValue.boolean == true)
1614
}

Plugins/PackageToJS/Fixtures/PlaywrightOnPageLoadTest/XCTest/Tests/CheckTests.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,22 @@ final class CheckTests: XCTestCase {
2020

2121
XCTAssertTrue(result)
2222
}
23+
24+
func testTileOfPage() async throws {
25+
guard let getTitle = JSObject.global.getTitle.function
26+
else { return XCTFail("Function getTitle not found") }
27+
28+
// getTitle returns a Promise, so we need to await it
29+
guard let promiseObject = getTitle().object
30+
else { return XCTFail("getTitle() did not return an object") }
31+
32+
guard let promise = JSPromise(promiseObject)
33+
else { return XCTFail("expectToBeTrue() did not return a Promise") }
34+
35+
let resultValue = try await promise.value
36+
guard let title = resultValue.string
37+
else { return XCTFail("expectToBeTrue() returned nil") }
38+
39+
XCTAssertTrue(title == "")
40+
}
2341
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* Playwright exposed functions for PlaywrightOnPageLoadTest
3+
* These functions will be exposed to the browser context and available as global functions
4+
* in the WASM environment (accessible via JSObject.global)
5+
*
6+
* IMPORTANT: All exposed functions are async from the browser's perspective.
7+
* Playwright's page.exposeFunction automatically wraps them to return Promises.
8+
* Therefore, you must use JSPromise to await them in Swift.
9+
*/
10+
11+
/**
12+
* Export a function that receives the Playwright Page object and returns the exposed functions.
13+
* This allows your functions to interact with the page (click, query DOM, etc.)
14+
*
15+
* @param {import('playwright').Page} page - The Playwright Page object
16+
* @returns {Object} An object mapping function names to async functions
17+
*/
18+
export async function exposedFunctions(page) {
19+
return {
20+
expectToBeTrue: async () => {
21+
return true;
22+
},
23+
24+
getTitle: async () => {
25+
return await page.title();
26+
},
27+
28+
// clickButton: async (selector) => {
29+
// await page.click(selector);
30+
// return true;
31+
// },
32+
33+
// screenshot: async () => {
34+
// const buffer = await page.screenshot();
35+
// return buffer.toString('base64');
36+
// }
37+
};
38+
}
39+
40+
/**
41+
* Alternative: Export a static object if you don't need page access
42+
* (Note: This approach doesn't have access to the page object)
43+
*/
44+
// export const exposedFunctions = {
45+
// expectToBeTrue: async () => {
46+
// return true;
47+
// },
48+
// addNumbers: async (a, b) => a + b,
49+
// };

Plugins/PackageToJS/Sources/PackageToJS.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ struct PackageToJS {
5858
var environment: String?
5959
/// Whether to run tests in the browser with inspector enabled
6060
var inspect: Bool
61+
/// The script defining Playwright exposed functions
62+
var playwrightExpose: String?
6163
/// The extra arguments to pass to node
6264
var extraNodeArguments: [String]
6365
/// The options for packaging
@@ -89,6 +91,14 @@ struct PackageToJS {
8991
testJsArguments.append("--prelude")
9092
testJsArguments.append(preludeURL.path)
9193
}
94+
if let playwrightExpose = testOptions.playwrightExpose {
95+
let playwrightExposeURL = URL(
96+
fileURLWithPath: playwrightExpose,
97+
relativeTo: URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
98+
)
99+
testJsArguments.append("--playwright-expose")
100+
testJsArguments.append(playwrightExposeURL.path)
101+
}
92102
if let environment = testOptions.environment {
93103
testJsArguments.append("--environment")
94104
testJsArguments.append(environment)

Plugins/PackageToJS/Sources/PackageToJSPlugin.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,7 @@ extension PackageToJS.TestOptions {
551551
let prelude = extractor.extractOption(named: "prelude").last
552552
let environment = extractor.extractOption(named: "environment").last
553553
let inspect = extractor.extractFlag(named: "inspect")
554+
let playwrightExpose = extractor.extractOption(named: "playwright-expose").last
554555
let extraNodeArguments = extractor.extractSingleDashOption(named: "Xnode")
555556
let packageOptions = try PackageToJS.PackageOptions.parse(from: &extractor)
556557
var options = PackageToJS.TestOptions(
@@ -560,6 +561,7 @@ extension PackageToJS.TestOptions {
560561
prelude: prelude,
561562
environment: environment,
562563
inspect: inspect != 0,
564+
playwrightExpose: playwrightExpose,
563565
extraNodeArguments: extraNodeArguments,
564566
packageOptions: packageOptions
565567
)
@@ -582,6 +584,7 @@ extension PackageToJS.TestOptions {
582584
--prelude <path> Path to the prelude script
583585
--environment <name> The environment to use for the tests (values: node, browser; default: node)
584586
--inspect Whether to run tests in the browser with inspector enabled
587+
--playwright-expose <path> Path to script defining Playwright exposed functions
585588
-Xnode <args> Extra arguments to pass to Node.js
586589
\(PackageToJS.PackageOptions.optionsHelp())
587590

Plugins/PackageToJS/Templates/bin/test.js

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const args = parseArgs({
3333
environment: { type: "string" },
3434
inspect: { type: "boolean" },
3535
"coverage-file": { type: "string" },
36+
"playwright-expose": { type: "string" },
3637
},
3738
})
3839

@@ -95,15 +96,41 @@ Hint: This typically means that a continuation leak occurred.
9596
}
9697
},
9798
browser: async ({ preludeScript }) => {
98-
const onPageLoad = (page) => {
99-
console.log("===> onPageLoad")
100-
page.exposeFunction("expectToBeTrue", () => {
101-
return true;
102-
});
99+
let onPageLoad = undefined;
100+
101+
// Load exposed functions from playwright-expose flag
102+
if (args.values["playwright-expose"]) {
103+
const exposeScript = path.resolve(process.cwd(), args.values["playwright-expose"]);
104+
try {
105+
const exposeModule = await import(exposeScript);
106+
const exposedFunctions = exposeModule.exposedFunctions;
107+
108+
if (exposedFunctions) {
109+
onPageLoad = async (page) => {
110+
// If exposedFunctions is a function, call it with the page object
111+
// This allows the functions to capture the page in their closure
112+
const functions = typeof exposedFunctions === 'function'
113+
? await exposedFunctions(page)
114+
: exposedFunctions;
115+
116+
for (const [name, fn] of Object.entries(functions)) {
117+
// Bind the page context to each function if needed
118+
// The function can optionally use the page from its closure
119+
page.exposeFunction(name, fn);
120+
}
121+
};
122+
}
123+
} catch (e) {
124+
// If --playwright-expose is specified but file doesn't exist or has no exposedFunctions, that's an error
125+
if (args.values["playwright-expose"]) {
126+
throw e;
127+
}
128+
}
103129
}
104-
const exitCode = await testBrowser({
105-
preludeScript,
106-
inspect: args.values.inspect,
130+
131+
const exitCode = await testBrowser({
132+
preludeScript,
133+
inspect: args.values.inspect,
107134
args: testFrameworkArgs,
108135
playwright: {
109136
onPageLoad

Plugins/PackageToJS/Templates/test.d.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ export type SetupOptionsFn = (
77
}
88
) => Promise<InstantiateOptions>
99

10+
/**
11+
* Functions to be exposed to the browser context via Playwright's page.exposeFunction.
12+
* Note: All functions are treated as async from the browser's perspective and will
13+
* return Promises when called from Swift/WebAssembly via JavaScriptKit.
14+
*/
15+
export type ExposedFunctions = Record<string, (...args: any[]) => Promise<any>>
16+
17+
/**
18+
* A function that receives the Playwright Page object and returns exposed functions.
19+
* This allows the exposed functions to interact with the browser page (click, query DOM, etc.)
20+
* Can also be used for async initialization before returning the functions.
21+
*
22+
* @param page - The Playwright Page object for browser interaction
23+
* @returns An object mapping function names to async functions, or a Promise resolving to such an object
24+
*/
25+
export type ExposedFunctionsFn = (page: import('playwright').Page) => ExposedFunctions | Promise<ExposedFunctions>
26+
1027
export function testBrowser(
1128
options: {
1229
/** Path to the prelude script to be injected before tests run */

Plugins/PackageToJS/Templates/test.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,8 @@ Please run the following command to install it:
100100
const context = await browser.newContext();
101101
const page = await context.newPage();
102102
// Allow the user to customize the page before it's loaded, for defining custom export functions
103-
if (options.playwright?.onPageLoad) {
104-
console.log("===> onPageLoad 2")
105-
await options.playwright.onPageLoad(page);
103+
if (options.playwright?.onPageLoad) {
104+
await options.playwright.onPageLoad(page);
106105
}
107106

108107
// Forward console messages in the page to the Node.js console

0 commit comments

Comments
 (0)