Skip to content

Commit 25f22d2

Browse files
authored
chore: wk_webview experiment (#41010)
1 parent 2fc9d1c commit 25f22d2

23 files changed

Lines changed: 12215 additions & 6 deletions
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
name: "tests WebView (iOS Simulator)"
2+
3+
on:
4+
# pull_request trigger disabled to avoid CI churn during wk_wv iteration - restore before merge
5+
6+
concurrency:
7+
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
8+
cancel-in-progress: true
9+
10+
env:
11+
FORCE_COLOR: 1
12+
ELECTRON_SKIP_BINARY_DOWNLOAD: 1
13+
14+
jobs:
15+
test_webview_simulator:
16+
name: "WebView on iOS Simulator (${{ matrix.shard }}/4)"
17+
runs-on: macos-15
18+
timeout-minutes: 60
19+
strategy:
20+
fail-fast: false
21+
matrix:
22+
shard: [1, 2, 3, 4]
23+
steps:
24+
- uses: actions/checkout@v6
25+
- uses: actions/setup-node@v4
26+
with:
27+
node-version: 20
28+
29+
- name: Runner environment
30+
run: |
31+
echo "::group::OS / Xcode"
32+
sw_vers
33+
uname -a
34+
xcode-select -p
35+
xcodebuild -version
36+
echo "::endgroup::"
37+
echo "::group::Available iOS runtimes"
38+
xcrun simctl list runtimes
39+
echo "::endgroup::"
40+
echo "::group::Available device types"
41+
xcrun simctl list devicetypes | grep -i 'iPhone\|iPad' | head -40
42+
echo "::endgroup::"
43+
echo "::group::Network config"
44+
cat /etc/hosts
45+
ifconfig lo0
46+
echo "::endgroup::"
47+
48+
- name: Ensure ::1 localhost in /etc/hosts
49+
run: |
50+
if grep -qE '^::1[[:space:]]+localhost' /etc/hosts; then
51+
echo "::1 localhost already present"
52+
else
53+
echo "::1 localhost" | sudo tee -a /etc/hosts
54+
echo "Added ::1 localhost"
55+
fi
56+
echo "--- /etc/hosts after ---"
57+
cat /etc/hosts
58+
59+
- name: npm ci
60+
run: |
61+
echo "::group::npm ci"
62+
npm ci
63+
echo "::endgroup::"
64+
65+
- name: npm run build
66+
run: |
67+
echo "::group::npm run build"
68+
npm run build
69+
echo "::endgroup::"
70+
71+
- name: Install ios-webkit-debug-proxy
72+
run: |
73+
echo "::group::brew install ios-webkit-debug-proxy"
74+
brew install ios-webkit-debug-proxy
75+
which ios_webkit_debug_proxy
76+
ios_webkit_debug_proxy --help 2>&1 | head -40 || true
77+
echo "::endgroup::"
78+
79+
- name: Boot iOS Simulator
80+
uses: futureware-tech/simulator-action@v5
81+
with:
82+
# Per wiki/Devices-macos-15.md only iPhone 16/17 series ship pre-installed; iPhone 15 isn't.
83+
model: 'iPhone 16'
84+
os_version: '18.6'
85+
wait_for_boot: true
86+
boot_timeout_seconds: 300
87+
88+
- name: Simulator state after boot
89+
run: |
90+
echo "::group::Booted devices"
91+
xcrun simctl list devices booted
92+
echo "::endgroup::"
93+
echo "::group::Simulator processes"
94+
pgrep -lf Simulator || true
95+
pgrep -lf launchd_sim || true
96+
echo "::endgroup::"
97+
98+
- name: Locate simulator webinspectord socket
99+
run: |
100+
echo "::group::Locating com.apple.webinspectord_sim.socket"
101+
# On modern macOS, ios_webkit_debug_proxy can no longer auto-discover the simulator;
102+
# we have to point -s at the launchd-owned unix socket.
103+
for i in $(seq 1 15); do
104+
SOCK=$(lsof -aUc launchd_sim 2>/dev/null | awk '/com\.apple\.webinspectord_sim\.socket/{print $NF; exit}')
105+
[[ -n "$SOCK" ]] && break
106+
echo "attempt $i: socket not found yet"
107+
sleep 1
108+
done
109+
if [[ -z "$SOCK" ]]; then
110+
echo "Failed to locate webinspectord_sim.socket"
111+
echo "--- launchd_sim file table ---"
112+
lsof -aUc launchd_sim 2>/dev/null || true
113+
exit 1
114+
fi
115+
echo "socket: $SOCK"
116+
echo "SIM_WI_SOCKET=unix:$SOCK" >> $GITHUB_ENV
117+
echo "::endgroup::"
118+
119+
- name: Start ios-webkit-debug-proxy
120+
run: |
121+
echo "::group::Starting proxy (SIM_WI_SOCKET=$SIM_WI_SOCKET)"
122+
ios_webkit_debug_proxy -F -d -s "$SIM_WI_SOCKET" -c "null:9221,:9222-9322" > "$RUNNER_TEMP/iwdp.log" 2>&1 &
123+
PID=$!
124+
echo "IWDP_PID=$PID" >> $GITHUB_ENV
125+
echo "proxy pid=$PID"
126+
sleep 3
127+
if ! kill -0 "$PID" 2>/dev/null; then
128+
echo "Proxy died immediately. Log:"
129+
cat "$RUNNER_TEMP/iwdp.log"
130+
exit 1
131+
fi
132+
echo "::endgroup::"
133+
echo "::group::Listening ports"
134+
lsof -nP -iTCP -sTCP:LISTEN | grep -E "9221|9222|ios_webkit" || true
135+
echo "::endgroup::"
136+
137+
- name: Run WebView tests
138+
run: |
139+
echo "::group::Test run (shard ${{ matrix.shard }}/4)"
140+
npx playwright test --config tests/webview/playwright.config.ts --shard=${{ matrix.shard }}/4
141+
echo "::endgroup::"
142+
143+
- name: Stop proxy
144+
if: always()
145+
run: |
146+
[[ -n "$IWDP_PID" ]] && kill "$IWDP_PID" 2>/dev/null || true
147+
# Simulator shutdown is owned by futureware-tech/simulator-action's post step.
148+
149+
- name: Upload artifacts
150+
if: always()
151+
uses: actions/upload-artifact@v4
152+
with:
153+
name: webview-simulator-logs-${{ matrix.shard }}
154+
path: |
155+
${{ github.workspace }}/test-results/**
156+
if-no-files-found: ignore

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"ctest": "playwright test --config=tests/library/playwright.config.ts --project=chromium-*",
2020
"ftest": "playwright test --config=tests/library/playwright.config.ts --project=firefox-*",
2121
"wtest": "playwright test --config=tests/library/playwright.config.ts --project=webkit-*",
22+
"wvtest": "playwright test --config=tests/webview/playwright.config.ts",
2223
"atest": "playwright test --config=tests/android/playwright.config.ts",
2324
"etest": "playwright test --config=tests/electron/playwright.config.ts",
2425
"itest": "playwright test --config=tests/installation/playwright.config.ts",

packages/injected/src/injectedScript.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,8 +1112,9 @@ export class InjectedScript {
11121112
return;
11131113

11141114
// Playwright only issues trusted events, so allow any custom events originating from
1115-
// the page or content scripts.
1116-
if (!event.isTrusted)
1115+
// the page or content scripts. The WebView backend cannot produce trusted events, so
1116+
// it marks synthetic events with __pwTrustedSynthetic to opt back into interception.
1117+
if (!event.isTrusted && !(event as any).__pwTrustedSynthetic)
11171118
return;
11181119

11191120
// Determine the event point. Note that Firefox does not always have window.TouchEvent.

packages/playwright-core/src/client/browserType.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,8 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
150150
}
151151

152152
async _connectOverCDP(endpointURL: string, params: api.ConnectOverCDPOptions = {}): Promise<Browser> {
153-
if (this.name() !== 'chromium')
154-
throw new Error('Connecting over CDP is only supported in Chromium.');
153+
if (this.name() !== 'chromium' && this.name() !== 'webkit')
154+
throw new Error('Connecting over CDP is only supported in Chromium and WebKit.');
155155
const headers = params.headers ? headersObjectToArray(params.headers) : undefined;
156156
const result = await this._channel.connectOverCDP({
157157
endpointURL,
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[*]
2+
@isomorphic/**
3+
@utils/**
4+
../
5+
../registry/
6+
node_modules/jpeg-js
7+
node_modules/pngjs
8+
9+
[webkit.ts]
10+
./webview/wvBrowser.ts

packages/playwright-core/src/server/webkit/webkit.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,14 @@ import path from 'path';
2020
import { wrapInASCIIBox } from '@utils/ascii';
2121
import { spawnAsync } from '@utils/spawnAsync';
2222
import { kBrowserCloseMessageId } from './wkConnection';
23+
import { Browser } from '../browser';
2324
import { BrowserType, kNoXServerRunningError } from '../browserType';
24-
import { WKBrowser } from '../webkit/wkBrowser';
25+
import { WKBrowser } from './wkBrowser';
26+
import { connectOverRDP } from './webview/wvBrowser';
2527

2628
import type { BrowserOptions } from '../browser';
2729
import type { SdkObject } from '../instrumentation';
30+
import type { Progress } from '../progress';
2831
import type { ConnectionTransport } from '../transport';
2932
import type * as types from '../types';
3033

@@ -37,6 +40,10 @@ export class WebKit extends BrowserType {
3740
return WKBrowser.connect(this.attribution.playwright, transport, options);
3841
}
3942

43+
override async connectOverCDP(progress: Progress, endpointURL: string, options: { slowMo?: number, headers?: types.HeadersArray, isLocal?: boolean, noDefaults?: boolean }): Promise<Browser> {
44+
return connectOverRDP(progress, this, endpointURL, options);
45+
}
46+
4047
override amendEnvironment(env: NodeJS.ProcessEnv, userDataDir: string, isPersistent: boolean, options: types.LaunchOptions): NodeJS.ProcessEnv {
4148
return {
4249
...env,
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation.
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+
*/
16+
17+
import { debugLogger } from '@utils/debugLogger';
18+
import { createHttpServer } from '@utils/network';
19+
20+
import type { IncomingMessage, Server, ServerResponse } from 'http';
21+
22+
export type DialogRequest = {
23+
type: 'alert' | 'confirm' | 'prompt';
24+
message: string;
25+
defaultValue: string;
26+
};
27+
28+
export type DialogResult = {
29+
accept: boolean;
30+
promptText?: string;
31+
};
32+
33+
type DialogHandler = (req: DialogRequest) => Promise<DialogResult>;
34+
35+
export class DialogBridge {
36+
private readonly _server: Server;
37+
private readonly _baseUrl: string;
38+
private readonly _handlers = new Map<string, DialogHandler>();
39+
40+
static async start(): Promise<DialogBridge> {
41+
const server = createHttpServer();
42+
await new Promise<void>((resolve, reject) => {
43+
server.once('error', reject);
44+
server.listen(0, '127.0.0.1', () => {
45+
server.removeListener('error', reject);
46+
resolve();
47+
});
48+
});
49+
const address = server.address();
50+
if (!address || typeof address === 'string')
51+
throw new Error('DialogBridge: failed to bind HTTP server');
52+
return new DialogBridge(server, `http://127.0.0.1:${address.port}`);
53+
}
54+
55+
private constructor(server: Server, baseUrl: string) {
56+
this._server = server;
57+
this._baseUrl = baseUrl;
58+
this._server.on('request', (req, res) => this._handleRequest(req, res));
59+
}
60+
61+
endpointFor(pageId: string): string {
62+
return `${this._baseUrl}/dialog?tab=${encodeURIComponent(pageId)}`;
63+
}
64+
65+
registerTab(pageId: string, handler: DialogHandler): void {
66+
this._handlers.set(pageId, handler);
67+
}
68+
69+
unregisterTab(pageId: string): void {
70+
this._handlers.delete(pageId);
71+
}
72+
73+
async close(): Promise<void> {
74+
this._handlers.clear();
75+
await new Promise<void>(resolve => this._server.close(() => resolve()));
76+
}
77+
78+
private _writeCorsHeaders(res: ServerResponse): void {
79+
res.setHeader('Access-Control-Allow-Origin', '*');
80+
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
81+
res.setHeader('Access-Control-Allow-Headers', 'content-type');
82+
}
83+
84+
private async _handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
85+
this._writeCorsHeaders(res);
86+
87+
if (req.method === 'OPTIONS') {
88+
res.statusCode = 204;
89+
res.end();
90+
return;
91+
}
92+
93+
const url = new URL(req.url || '/', this._baseUrl);
94+
if (!(req.method === 'POST' && url.pathname === '/dialog')) {
95+
res.statusCode = 404;
96+
res.end();
97+
return;
98+
}
99+
100+
const tab = url.searchParams.get('tab') || '';
101+
const handler = this._handlers.get(tab);
102+
if (!handler) {
103+
// Either the tab is gone or the page raced ahead of registerTab. Reply
104+
// 404 so the page-side override silently falls through.
105+
res.statusCode = 404;
106+
res.end();
107+
return;
108+
}
109+
110+
let body = '';
111+
req.setEncoding('utf8');
112+
req.on('data', chunk => { body += chunk; });
113+
req.on('end', async () => {
114+
let parsed: DialogRequest;
115+
try {
116+
const json = JSON.parse(body);
117+
if (json.type !== 'alert' && json.type !== 'confirm' && json.type !== 'prompt')
118+
throw new Error(`Invalid dialog type: ${json.type}`);
119+
parsed = {
120+
type: json.type,
121+
message: typeof json.message === 'string' ? json.message : '',
122+
defaultValue: typeof json.defaultValue === 'string' ? json.defaultValue : '',
123+
};
124+
} catch (e) {
125+
debugLogger.log('error', `DialogBridge: bad request body: ${(e as Error).message}`);
126+
res.statusCode = 400;
127+
res.end();
128+
return;
129+
}
130+
131+
try {
132+
const result = await handler(parsed);
133+
res.statusCode = 200;
134+
res.setHeader('Content-Type', 'application/json');
135+
res.end(JSON.stringify({
136+
accept: !!result.accept,
137+
promptText: result.promptText,
138+
}));
139+
} catch (e) {
140+
debugLogger.log('error', `DialogBridge: handler error: ${(e as Error).message}`);
141+
res.statusCode = 500;
142+
res.end();
143+
}
144+
});
145+
}
146+
}

0 commit comments

Comments
 (0)