feat(pw-web): generate playwright/web.js which can be used in the browser (#455)

This commit is contained in:
Dmitry Gozman 2020-01-10 16:03:10 -08:00 committed by GitHub
parent 15b05e43bc
commit c77fd5e6cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 273 additions and 163 deletions

2
.gitignore vendored
View File

@ -21,3 +21,5 @@ yarn.lock
/utils/browser/playwright-web.js /utils/browser/playwright-web.js
lib/ lib/
playwright-*.tgz playwright-*.tgz
/web.js
/web.js.map

View File

@ -9,17 +9,21 @@ lib/injected/
#types #types
!lib/**/*.d.ts !lib/**/*.d.ts
!index.d.ts !index.d.ts
!web.d.ts
# Install # Install
!install.js !install.js
# root for "playwright" package # root for "playwright" package
!index.js !index.js
# root for "playwright/web"
!web.js
# specific browsers # specific browsers
!chromium.js !chromium.js
!firefox.js !firefox.js
!webkit.js !webkit.js
# Dgozman says to remove these # dgozman says to remove these
!DeviceDescriptors.js !DeviceDescriptors.js
!Errors.js !Errors.js

View File

@ -24,7 +24,7 @@ Playwright can be used to create a browser instance, open pages, and then manipu
### Examples ### Examples
#### Page screenshot #### Page screenshot
This code snippet navigates to example.com in WebKit, and saves a screenshot. This code snippet navigates to example.com in WebKit, and saves a screenshot.
@ -94,3 +94,4 @@ Playwright is actively developed as we get to feature parity across Chromium, Fi
## Resources ## Resources
* [API documentation](https://github.com/microsoft/playwright/blob/master/docs/api.md) * [API documentation](https://github.com/microsoft/playwright/blob/master/docs/api.md)
* [Running in the browser](https://github.com/microsoft/playwright/blob/master/docs/web.md)

35
docs/web.md Normal file
View File

@ -0,0 +1,35 @@
# Bundling for Web
Playwright contains a version bundled for web browsers under `playwright/web.js`, which
installs playwright under `window.playwrightweb`.
You can use it in the web page to drive another browser instance.
API consists of a single `connect` function, similar to
[chromiumPlaywright.connect(options)](api.md#chromiumplaywrightconnectoptions),
[firefoxPlaywright.connect(options)](api.md#firefoxplaywrightconnectoptions) and
[webkitPlaywright.connect(options)](api.md#webkitplaywrightconnectoptions).
```html
<script src='../playwright/web.js'></script>
<script>
async function usePlaywright() {
const connect = window.playwrightweb('chromium'); // or 'firefox', 'webkit'
const browser = await connect(options);
// ... drive automation ...
await browser.disconnect();
}
</script>
```
See our [playwright-web tests](https://github.com/Microsoft/playwright/blob/master/test/web.spec.js) for example.
### Running inside Chrome Extension
You might want to enable `unsafe-eval` inside the extension by adding the following
to your `manifest.json` file:
```
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'"
```
Please see discussion in https://github.com/GoogleChrome/puppeteer/issues/3455.

3
index.d.ts vendored
View File

@ -18,6 +18,3 @@ export * from './lib/api';
export function playwright(browser: 'chromium'): import('./lib/api').ChromiumPlaywright; export function playwright(browser: 'chromium'): import('./lib/api').ChromiumPlaywright;
export function playwright(browser: 'firefox'): import('./lib/api').FirefoxPlaywright; export function playwright(browser: 'firefox'): import('./lib/api').FirefoxPlaywright;
export function playwright(browser: 'webkit'): import('./lib/api').WebKitPlaywright; export function playwright(browser: 'webkit'): import('./lib/api').WebKitPlaywright;
export function connect(browser: 'chromium'): import('./lib/api').ChromiumBrowser.connect;
export function connect(browser: 'firefox'): import('./lib/api').FirefoxBrowser.connect;
export function connect(browser: 'webkit'): import('./lib/api').WebKitBrowser.connect;

View File

@ -33,13 +33,3 @@ module.exports.playwright = browser => {
return new api.WebKitPlaywright(__dirname, packageJson.playwright.webkit_revision); return new api.WebKitPlaywright(__dirname, packageJson.playwright.webkit_revision);
throw new Error(`Unsupported browser "${browser}"`); throw new Error(`Unsupported browser "${browser}"`);
}; };
module.exports.connect = browser => {
if (browser === 'chromium')
return api.ChromiumBrowser.connect;
if (browser === 'firefox')
return api.FirefoxBrowser.connect;
if (browser === 'webkit')
return api.WebKitBrowser.connect;
throw new Error(`Unsupported browser "${browser}"`);
};

View File

@ -26,9 +26,7 @@
"tsc": "tsc -p .", "tsc": "tsc -p .",
"build": "node utils/runWebpack.js --mode='development' && tsc -p .", "build": "node utils/runWebpack.js --mode='development' && tsc -p .",
"watch": "node utils/runWebpack.js --mode='development' --watch --silent | tsc -w -p .", "watch": "node utils/runWebpack.js --mode='development' --watch --silent | tsc -w -p .",
"apply-next-version": "node utils/apply_next_version.js", "apply-next-version": "node utils/apply_next_version.js"
"bundle": "npx browserify -r ./index.js:playwright -o utils/browser/playwright-web.js",
"unit-bundle": "node utils/browser/test.js"
}, },
"author": { "author": {
"name": "Microsoft Corporation" "name": "Microsoft Corporation"

View File

@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation. // Copyright (c) Microsoft Corporation.
// Licensed under the MIT license. // Licensed under the MIT license.
// Note: this is the only file outside of src/server which can import external dependencies.
// All dependencies must be listed in web.webpack.config.js to avoid bundling them.
import * as nodeEvents from 'events'; import * as nodeEvents from 'events';
import * as nodeFS from 'fs'; import * as nodeFS from 'fs';
import * as nodePath from 'path'; import * as nodePath from 'path';

31
src/web.ts Normal file
View File

@ -0,0 +1,31 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CRBrowser as ChromiumBrowser } from './chromium/crBrowser';
import { FFBrowser as FirefoxBrowser } from './firefox/ffBrowser';
import { WKBrowser as WebKitBrowser } from './webkit/wkBrowser';
function connect(browser: 'chromium' | 'firefox' | 'webkit') {
if (browser === 'chromium')
return ChromiumBrowser.connect;
if (browser === 'firefox')
return FirefoxBrowser.connect;
if (browser === 'webkit')
return WebKitBrowser.connect;
throw new Error(`Unsupported browser "${browser}"`);
}
export = connect;

56
src/web.webpack.config.js Normal file
View File

@ -0,0 +1,56 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const path = require('path');
module.exports = {
entry: path.join(__dirname, 'web.ts'),
devtool: 'source-map',
module: {
rules: [
{
test: /\.tsx?$/,
loader: 'ts-loader',
options: {
transpileOnly: true
},
exclude: /node_modules/
}
]
},
resolve: {
extensions: [ '.tsx', '.ts', '.js' ]
},
output: {
filename: 'web.js',
library: 'playwrightweb',
libraryTarget: 'window',
path: path.resolve(__dirname, '../')
},
externals: {
'events': 'dummy',
'fs': 'dummy',
'path': 'dummy',
'debug': 'dummy',
'buffer': 'dummy',
'mime': 'dummy',
'jpeg-js': 'dummy',
'pngjs': 'dummy',
'http': 'dummy',
'https': 'dummy',
'ws': 'dummy',
}
};

View File

@ -0,0 +1,13 @@
<script src='../../web.js'></script>
<script>
async function setup(product, connectOptions) {
window.connect = window.playwrightweb(product);
window.browser = await window.connect(connectOptions);
window.context = await window.browser.newContext();
window.page = await window.context.newPage();
}
async function teardown() {
await window.context.close();
await window.browser.disconnect();
}
</script>

View File

@ -215,4 +215,6 @@ module.exports.describe = ({testRunner, product, playwrightPath}) => {
if (WEBKIT) { if (WEBKIT) {
testRunner.loadTests(require('./webkit/launcher.spec.js'), testOptions); testRunner.loadTests(require('./webkit/launcher.spec.js'), testOptions);
} }
testRunner.loadTests(require('./web.spec.js'), testOptions);
}; };

View File

@ -44,7 +44,7 @@ beforeAll(async state => {
const assetsPath = path.join(__dirname, 'assets'); const assetsPath = path.join(__dirname, 'assets');
const cachedPath = path.join(__dirname, 'assets', 'cached'); const cachedPath = path.join(__dirname, 'assets', 'cached');
const port = 8907 + state.parallelIndex * 2; const port = 8907 + state.parallelIndex * 3;
state.server = await TestServer.create(assetsPath, port); state.server = await TestServer.create(assetsPath, port);
state.server.enableHTTPCache(cachedPath); state.server.enableHTTPCache(cachedPath);
state.server.PORT = port; state.server.PORT = port;
@ -59,6 +59,11 @@ beforeAll(async state => {
state.httpsServer.PREFIX = `https://localhost:${httpsPort}`; state.httpsServer.PREFIX = `https://localhost:${httpsPort}`;
state.httpsServer.CROSS_PROCESS_PREFIX = `https://127.0.0.1:${httpsPort}`; state.httpsServer.CROSS_PROCESS_PREFIX = `https://127.0.0.1:${httpsPort}`;
state.httpsServer.EMPTY_PAGE = `https://localhost:${httpsPort}/empty.html`; state.httpsServer.EMPTY_PAGE = `https://localhost:${httpsPort}/empty.html`;
const sourcePort = port + 2;
state.sourceServer = await TestServer.create(path.join(__dirname, '..'), sourcePort);
state.sourceServer.PORT = sourcePort;
state.sourceServer.PREFIX = `http://localhost:${sourcePort}`;
}); });
afterAll(async({server, httpsServer}) => { afterAll(async({server, httpsServer}) => {

84
test/web.spec.js Normal file
View File

@ -0,0 +1,84 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
module.exports.describe = function({testRunner, expect, defaultBrowserOptions, playwright, product, WEBKIT}) {
const {describe, xdescribe, fdescribe} = testRunner;
const {it, fit, xit, dit} = testRunner;
const {beforeAll, beforeEach, afterAll, afterEach} = testRunner;
describe.skip(WEBKIT)('Web', function() {
beforeAll(async state => {
state.controlledBrowserServer = await playwright.launchServer({ ...defaultBrowserOptions, pipe: false });
state.hostBrowserServer = await playwright.launchServer(defaultBrowserOptions);
state.hostBrowser = await state.hostBrowserServer.connect();
});
afterAll(async state => {
await state.hostBrowserServer.close();
state.hostBrowser = null;
state.hostBrowserServer = null;
await state.controlledBrowserServer.close();
state.controlledBrowserServer = null;
state.webUrl = null;
});
beforeEach(async state => {
state.page = await state.hostBrowser.defaultContext().newPage();
state.page.on('console', message => console.log('TEST: ' + message.text()));
await state.page.goto(state.sourceServer.PREFIX + '/test/assets/playwrightweb.html');
await state.page.evaluate((product, connectOptions) => setup(product, connectOptions), product.toLowerCase(), state.controlledBrowserServer.connectOptions());
});
afterEach(async state => {
await state.page.evaluate(() => teardown());
await state.page.close();
state.page = null;
});
it('should navigate', async({page, server}) => {
const url = await page.evaluate(async url => {
await page.goto(url);
return page.evaluate(() => window.location.href);
}, server.EMPTY_PAGE);
expect(url).toBe(server.EMPTY_PAGE);
});
it('should receive events', async({page, server}) => {
const logs = await page.evaluate(async url => {
const logs = [];
page.on('console', message => logs.push(message.text()));
await page.evaluate(() => console.log('hello'));
await page.evaluate(() => console.log('world'));
return logs;
}, server.EMPTY_PAGE);
expect(logs).toEqual(['hello', 'world']);
});
it('should take screenshot', async({page, server}) => {
const { base64, bufferClassName } = await page.evaluate(async url => {
await page.setViewport({width: 500, height: 500});
await page.goto(url);
const screenshot = await page.screenshot();
return { base64: screenshot.toString('base64'), bufferClassName: screenshot.constructor.name };
}, server.PREFIX + '/grid.html');
const screenshot = Buffer.from(base64, 'base64');
expect(screenshot).toBeGolden('screenshot-sanity.png');
// Verify that we use web versions of node-specific classes.
expect(bufferClassName).toBe('BufferImpl');
});
});
};

View File

@ -1,37 +0,0 @@
# Bundling For Web Browsers
To bundle Playwright using [Browserify](http://browserify.org/):
1. Clone Playwright repository: `git clone https://github.com/Microsoft/playwright && cd playwright`
2. `npm install`
3. Run `npm run bundle`
This will create `./utils/browser/playwright-web.js` file that contains Playwright bundle.
You can use it later on in your web page to drive
another browser instance through its WS Endpoint:
```html
<script src='./playwright-web.js'></script>
<script>
const playwright = require('playwright');
const browser = await playwright.connect({
browserWSEndpoint: '<another-browser-ws-endpont>'
});
// ... drive automation ...
</script>
```
See our [playwright-web tests](https://github.com/Microsoft/playwright/blob/master/utils/browser/test.js)
for details.
### Running inside Chrome Extension
You might want to enable `unsafe-eval` inside the extension by adding the following
to your `manifest.json` file:
```
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'"
```
Please see discussion in https://github.com/GoogleChrome/puppeteer/issues/3455.

View File

@ -1 +0,0 @@
module.exports = window.WebSocket;

View File

@ -1,99 +0,0 @@
const path = require('path');
const fs = require('fs');
const playwright = require('../..');
const {TestServer} = require('../testserver/');
const {TestRunner, Reporter, Matchers} = require('../testrunner/');
const playwrightWebPath = path.join(__dirname, 'playwright-web.js');
if (!fs.existsSync(playwrightWebPath))
throw new Error(`playwright-web is not built; run "npm run bundle"`);
const playwrightWeb = fs.readFileSync(playwrightWebPath, 'utf8');
const testRunner = new TestRunner();
const {describe, fdescribe, xdescribe} = testRunner;
const {it, xit, fit} = testRunner;
const {afterAll, beforeAll, afterEach, beforeEach} = testRunner;
const {expect} = new Matchers();
beforeAll(async state => {
const assetsPath = path.join(__dirname, '..', '..', 'test', 'assets');
const port = 8998;
state.server = await TestServer.create(assetsPath, port);
state.serverConfig = {
PREFIX: `http://localhost:${port}`,
EMPTY_PAGE: `http://localhost:${port}/empty.html`,
};
state.browser = await playwright.launch();
});
afterAll(async state => {
await Promise.all([
state.server.stop(),
state.browser.close()
]);
state.browser = null;
state.server = null;
});
beforeEach(async state => {
state.page = await state.browser.defaultContext().newPage();
await state.page.evaluateOnNewDocument(playwrightWeb);
await state.page.addScriptTag({
content: playwrightWeb + '\n//# sourceURL=playwright-web.js'
});
});
afterEach(async state => {
await state.page.close();
state.page = null;
});
describe('Playwright-Web', () => {
it('should work over web socket', async({page, serverConfig}) => {
const browserServer = await playwright.launchServer();
// Use in-page playwright to create a new page and navigate it to the EMPTY_PAGE
await page.evaluate(async(browserWSEndpoint, serverConfig) => {
const playwright = require('playwright');
const browser = await playwright.connect({browserWSEndpoint});
const page = await browser.defaultContext().newPage();
await page.goto(serverConfig.EMPTY_PAGE);
}, browserServer.wsEndpoint(), serverConfig);
const browser = await browserServer.connect();
const pageURLs = (await browser.defaultContext().pages()).map(page => page.url()).sort();
expect(pageURLs).toEqual([
'about:blank',
serverConfig.EMPTY_PAGE
]);
await browserServer.close();
});
it('should work over exposed DevTools protocol', async({browser, page, serverConfig}) => {
// Expose devtools protocol binding into page.
const session = await browser.browserTarget().createCDPSession();
const pageInfo = (await session.send('Target.getTargets')).targetInfos.find(info => info.attached);
await session.send('Target.exposeDevToolsProtocol', {targetId: pageInfo.targetId});
await session.detach();
// Use in-page playwright to create a new page and navigate it to the EMPTY_PAGE
await page.evaluate(async serverConfig => {
const playwright = require('playwright');
window.cdp.close = () => {};
const browser = await playwright.connect({transport: window.cdp});
const page = await browser.defaultContext().newPage();
await page.goto(serverConfig.EMPTY_PAGE);
}, serverConfig);
const pageURLs = (await browser.defaultContext().pages()).map(page => page.url()).sort();
expect(pageURLs).toEqual([
'about:blank',
'about:blank',
serverConfig.EMPTY_PAGE
]);
});
});
if (process.env.CI && testRunner.hasFocusedTestsOrSuites()) {
console.error('ERROR: "focused" tests/suites are prohibitted on bots. Remove any "fit"/"fdescribe" declarations.');
process.exit(1);
}
new Reporter(testRunner);
testRunner.run();

View File

@ -23,8 +23,9 @@ module.exports = { checkSources, expandPrefix };
/** /**
* @param {!Array<!import('../Source')>} sources * @param {!Array<!import('../Source')>} sources
* @param {!Array<string>} externalDependencies
*/ */
function checkSources(sources) { function checkSources(sources, externalDependencies) {
// special treatment for Events.js // special treatment for Events.js
const classEvents = new Map(); const classEvents = new Map();
const eventsSources = sources.filter(source => source.name().startsWith('events.ts')); const eventsSources = sources.filter(source => source.name().startsWith('events.ts'));
@ -106,17 +107,21 @@ function checkSources(sources) {
} }
if (fileName.endsWith('/api.ts') && ts.isExportSpecifier(node)) if (fileName.endsWith('/api.ts') && ts.isExportSpecifier(node))
apiClassNames.add(expandPrefix((node.propertyName || node.name).text)); apiClassNames.add(expandPrefix((node.propertyName || node.name).text));
if (!fileName.endsWith('platform.ts') && !fileName.includes('src/server/')) { const isPlatform = fileName.endsWith('platform.ts');
if (!fileName.includes('src/server/')) {
// Only relative imports. // Only relative imports.
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
const module = node.moduleSpecifier.text; const module = node.moduleSpecifier.text;
if (!module.startsWith('.') || path.resolve(path.dirname(fileName), module).includes('src/server')) { const isRelative = module.startsWith('.');
const isPlatformDependency = isPlatform && externalDependencies.includes(module);
const isServerDependency = path.resolve(path.dirname(fileName), module).includes('src/server');
if (isServerDependency || (!isRelative && !isPlatformDependency)) {
const lac = ts.getLineAndCharacterOfPosition(node.getSourceFile(), node.moduleSpecifier.pos); const lac = ts.getLineAndCharacterOfPosition(node.getSourceFile(), node.moduleSpecifier.pos);
errors.push(`Disallowed import "${module}" at ${node.getSourceFile().fileName}:${lac.line + 1}`); errors.push(`Disallowed import "${module}" at ${node.getSourceFile().fileName}:${lac.line + 1}`);
} }
} }
// No references to external types. // No references to external types.
if (ts.isTypeReferenceNode(node)) { if (!isPlatform && ts.isTypeReferenceNode(node)) {
const isPlatformReference = ts.isQualifiedName(node.typeName) && ts.isIdentifier(node.typeName.left) && node.typeName.left.escapedText === 'platform'; const isPlatformReference = ts.isQualifiedName(node.typeName) && ts.isIdentifier(node.typeName.left) && node.typeName.left.escapedText === 'platform';
if (!isPlatformReference) { if (!isPlatformReference) {
const type = checker.getTypeAtLocation(node); const type = checker.getTypeAtLocation(node);

View File

@ -33,9 +33,9 @@ const EXCLUDE_PROPERTIES = new Set([
* @param {!Array<!Source>} mdSources * @param {!Array<!Source>} mdSources
* @return {!Promise<!Array<!Message>>} * @return {!Promise<!Array<!Message>>}
*/ */
module.exports = async function lint(page, mdSources, jsSources) { module.exports = async function lint(page, mdSources, jsSources, externalDependencies) {
const mdResult = await mdBuilder(page, mdSources); const mdResult = await mdBuilder(page, mdSources);
const jsResult = jsBuilder.checkSources(jsSources); const jsResult = jsBuilder.checkSources(jsSources, externalDependencies);
const jsDocumentation = filterJSDocumentation(jsSources, jsResult.documentation); const jsDocumentation = filterJSDocumentation(jsSources, jsResult.documentation);
const mdDocumentation = mdResult.documentation; const mdDocumentation = mdResult.documentation;

View File

@ -49,7 +49,8 @@ async function run() {
const page = await browser.defaultContext().newPage(); const page = await browser.defaultContext().newPage();
const checkPublicAPI = require('./check_public_api'); const checkPublicAPI = require('./check_public_api');
const jsSources = await Source.readdir(path.join(PROJECT_DIR, 'src')); const jsSources = await Source.readdir(path.join(PROJECT_DIR, 'src'));
messages.push(...await checkPublicAPI(page, mdSources, jsSources)); const externalDependencies = Object.keys(require('../../src/web.webpack.config').externals);
messages.push(...await checkPublicAPI(page, mdSources, jsSources, externalDependencies));
await browser.close(); await browser.close();
for (const source of mdSources) { for (const source of mdSources) {

View File

@ -20,6 +20,7 @@ const path = require('path');
const files = [ const files = [
path.join('src', 'injected', 'zsSelectorEngine.webpack.config.js'), path.join('src', 'injected', 'zsSelectorEngine.webpack.config.js'),
path.join('src', 'injected', 'injected.webpack.config.js'), path.join('src', 'injected', 'injected.webpack.config.js'),
path.join('src', 'web.webpack.config.js'),
]; ];
function runOne(runner, file) { function runOne(runner, file) {

20
web.d.ts vendored Normal file
View File

@ -0,0 +1,20 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
function connect(browser: 'chromium'): import('./lib/api').ChromiumBrowser.connect;
function connect(browser: 'firefox'): import('./lib/api').FirefoxBrowser.connect;
function connect(browser: 'webkit'): import('./lib/api').WebKitBrowser.connect;
export = connect;