Skip to content

Commit de9e8a0

Browse files
committed
Implement onPageLoad for playwright
1 parent 689fdd2 commit de9e8a0

File tree

12 files changed

+436
-3
lines changed

12 files changed

+436
-3
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
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// swift-tools-version: 6.0
2+
import PackageDescription
3+
4+
let package = Package(
5+
name: "Check",
6+
dependencies: [.package(name: "JavaScriptKit", path: "../../../../../")],
7+
targets: [
8+
.testTarget(
9+
name: "CheckTests",
10+
dependencies: [
11+
"JavaScriptKit",
12+
.product(name: "JavaScriptEventLoopTestSupport", package: "JavaScriptKit"),
13+
],
14+
path: "Tests"
15+
)
16+
]
17+
)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import Testing
2+
import JavaScriptKit
3+
import JavaScriptEventLoop
4+
5+
@Test func expectToBeTrue() async throws {
6+
let expectToBeTrue = try #require(JSObject.global.expectToBeTrue.function)
7+
8+
// expectToBeTrue returns a Promise, so we need to await it
9+
let promiseObject = try #require(expectToBeTrue.callAsFunction().object)
10+
let promise = try #require(JSPromise(promiseObject))
11+
12+
let resultValue = try await promise.value
13+
#expect(resultValue.boolean == true)
14+
}
15+
16+
@Test func getTitleOfPage() async throws {
17+
let getTitle = try #require(JSObject.global.getTitle.function)
18+
19+
// getTitle returns a Promise, so we need to await it
20+
let promiseObject = try #require(getTitle.callAsFunction().object)
21+
let promise = try #require(JSPromise(promiseObject))
22+
23+
let resultValue = try await promise.value
24+
#expect(resultValue.string == "")
25+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// swift-tools-version: 6.0
2+
import PackageDescription
3+
4+
let package = Package(
5+
name: "Check",
6+
dependencies: [.package(name: "JavaScriptKit", path: "../../../../../")],
7+
targets: [
8+
.testTarget(
9+
name: "CheckTests",
10+
dependencies: [
11+
"JavaScriptKit",
12+
.product(name: "JavaScriptEventLoopTestSupport", package: "JavaScriptKit"),
13+
],
14+
path: "Tests"
15+
)
16+
]
17+
)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import XCTest
2+
import JavaScriptKit
3+
import JavaScriptEventLoop
4+
5+
final class CheckTests: XCTestCase {
6+
func testExpectToBeTrue() async throws {
7+
guard let expectToBeTrue = JSObject.global.expectToBeTrue.function
8+
else { return XCTFail("Function expectToBeTrue not found") }
9+
10+
// expectToBeTrue returns a Promise, so we need to await it
11+
guard let promiseObject = expectToBeTrue().object
12+
else { return XCTFail("expectToBeTrue() did not return an object") }
13+
14+
guard let promise = JSPromise(promiseObject)
15+
else { return XCTFail("expectToBeTrue() did not return a Promise") }
16+
17+
let resultValue = try await promise.value
18+
guard let result = resultValue.boolean
19+
else { return XCTFail("expectToBeTrue() returned nil") }
20+
21+
XCTAssertTrue(result)
22+
}
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+
}
41+
}
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

0 commit comments

Comments
 (0)