Compare commits

...

15 commits

Author SHA1 Message Date
Max Schmitt a4096676ab
chore: mark 1.41.2 (#29293) 2024-02-01 20:24:16 +01:00
Playwright Service 414affaed4
cherry-pick(#29271): Revert "chore: remove fake error from expect calls (#28112)" (#29296)
This PR cherry-picks the following commits:

- 622153db18

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2024-02-01 18:11:50 +01:00
Yury Semikhatsky 7a72adc33c
cherry-pick(#29180): fix: interception id not found error in route.co… (#29222)
…ntinue

We stopped catching all exceptions in
https://github.com/microsoft/playwright/pull/28539 in hope that we'll
get loadingFailed even before Fetch.continue/fulfill command's error.
Turns out this is racy and may fail if the test cancels the request
while we are continuing it. The following test could in theory reproduce
it if stars align and the timing is good:

```js
it('page.continue on canceled request', async ({ page }) => {
  let resolveRoute;
  const routePromise = new Promise<Route>(f => resolveRoute = f);
  await page.route('http://test.com/x', resolveRoute);

  const evalPromise = page.evaluate(async () => {
    const abortController = new AbortController();
    (window as any).abortController = abortController;
    return fetch('http://test.com/x', { signal: abortController.signal }).catch(e => 'cancelled');
  });
  const route = await routePromise;
  void page.evaluate(() => (window as any).abortController.abort());
  await new Promise(f => setTimeout(f, 10));
  await route.continue();
  const req = await evalPromise;
  expect(req).toBe('cancelled');
});
```

Fixes https://github.com/microsoft/playwright/issues/29123
2024-01-29 09:33:09 -08:00
Max Schmitt 8f0163f3f4
chore: mark 1.41.1 (#29075) 2024-01-20 00:05:04 +01:00
Playwright Service 98a661824d
cherry-pick(#29069): Revert "feat(codegen): add range input recording support (#28767)" (#29074)
This PR cherry-picks the following commits:

- e551506c9e

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2024-01-19 22:08:43 +01:00
Sander 50f1f08e9c cherry-pick(#29070): chore(ct): revert export mount result 2024-01-19 11:12:07 -08:00
Pavel Feldman e5d201b459 cherry-pick(#29031): fix(ct): allow passing date, url, bigint as properties 2024-01-18 11:44:08 -08:00
Pavel Feldman 8ee2d81143 cherry-pick(#29026): test: add a props.children test 2024-01-18 11:43:45 -08:00
Pavel Feldman 50a8e4f52a cherry-pick(#29016): chore: add an image import component test 2024-01-18 11:43:21 -08:00
Pavel Feldman cb6c64cc33 cherry-pick(#28986): fix(ct): move import list into the compilation cache data 2024-01-18 11:42:56 -08:00
Pavel Feldman 06518b2091 cherry-pick(#28978): chore: build import registry source 2024-01-18 11:42:04 -08:00
Pavel Feldman d47ed6a076 cherry-pick(#28975): chore: refactor import processing in ct 2024-01-18 11:41:24 -08:00
Playwright Service 4d9f923dfe
cherry-pick(#29034): docs: fix typo for stylePath (#29035)
This PR cherry-picks the following commits:

- a217d6a08d

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2024-01-18 10:06:32 +01:00
Dmitry Gozman ece2a97702 Revert "feat(trace): allow Trace Viewer to include credentials when fetching traces cross-origin (#28502)" (#29024)
This reverts commit 3f3f332060.

References #29019.
2024-01-17 10:41:44 -08:00
Max Schmitt 38d699f64f
chore: mark 1.41.0 (#29007) 2024-01-16 19:27:23 +01:00
54 changed files with 1050 additions and 1025 deletions

View file

@ -4,6 +4,7 @@ test/assets/modernizr.js
/packages/playwright-core/src/generated/*
/packages/playwright-core/src/third_party/
/packages/playwright-core/types/*
/packages/playwright-ct-core/src/generated/*
/index.d.ts
utils/generate_types/overrides.d.ts
utils/generate_types/test/test.ts

3
.gitignore vendored
View file

@ -9,7 +9,8 @@ node_modules/
.vscode
.idea
yarn.lock
/packages/playwright-core/src/generated/*
/packages/playwright-core/src/generated
/packages/playwright-ct-core/src/generated
packages/*/lib/
drivers/
.android-sdk/

View file

@ -98,7 +98,7 @@ import { test, expect } from '@playwright/test';
test('example test', async ({ page }) => {
await page.goto('https://playwright.dev');
await expect(page).toHaveScreenshot({ styleFile: path.join(__dirname, 'screenshot.css') });
await expect(page).toHaveScreenshot({ stylePath: path.join(__dirname, 'screenshot.css') });
});
```
@ -109,7 +109,7 @@ import { defineConfig } from '@playwright/test';
export default defineConfig({
expect: {
toHaveScreenshot: {
styleFile: './screenshot.css'
stylePath: './screenshot.css'
},
},
});

100
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "playwright-internal",
"version": "1.41.0-next",
"version": "1.41.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "playwright-internal",
"version": "1.41.0-next",
"version": "1.41.2",
"license": "Apache-2.0",
"workspaces": [
"packages/*"
@ -7530,10 +7530,10 @@
}
},
"packages/playwright": {
"version": "1.41.0-next",
"version": "1.41.2",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.41.0-next"
"playwright-core": "1.41.2"
},
"bin": {
"playwright": "cli.js"
@ -7547,11 +7547,11 @@
},
"packages/playwright-browser-chromium": {
"name": "@playwright/browser-chromium",
"version": "1.41.0-next",
"version": "1.41.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.41.0-next"
"playwright-core": "1.41.2"
},
"engines": {
"node": ">=16"
@ -7559,11 +7559,11 @@
},
"packages/playwright-browser-firefox": {
"name": "@playwright/browser-firefox",
"version": "1.41.0-next",
"version": "1.41.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.41.0-next"
"playwright-core": "1.41.2"
},
"engines": {
"node": ">=16"
@ -7571,22 +7571,22 @@
},
"packages/playwright-browser-webkit": {
"name": "@playwright/browser-webkit",
"version": "1.41.0-next",
"version": "1.41.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.41.0-next"
"playwright-core": "1.41.2"
},
"engines": {
"node": ">=16"
}
},
"packages/playwright-chromium": {
"version": "1.41.0-next",
"version": "1.41.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.41.0-next"
"playwright-core": "1.41.2"
},
"bin": {
"playwright": "cli.js"
@ -7596,7 +7596,7 @@
}
},
"packages/playwright-core": {
"version": "1.41.0-next",
"version": "1.41.2",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
@ -7607,11 +7607,11 @@
},
"packages/playwright-ct-core": {
"name": "@playwright/experimental-ct-core",
"version": "1.41.0-next",
"version": "1.41.2",
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.41.0-next",
"playwright-core": "1.41.0-next",
"playwright": "1.41.2",
"playwright-core": "1.41.2",
"vite": "^4.4.12"
},
"bin": {
@ -7623,10 +7623,10 @@
},
"packages/playwright-ct-react": {
"name": "@playwright/experimental-ct-react",
"version": "1.41.0-next",
"version": "1.41.2",
"license": "Apache-2.0",
"dependencies": {
"@playwright/experimental-ct-core": "1.41.0-next",
"@playwright/experimental-ct-core": "1.41.2",
"@vitejs/plugin-react": "^4.0.0"
},
"bin": {
@ -7655,10 +7655,10 @@
},
"packages/playwright-ct-react17": {
"name": "@playwright/experimental-ct-react17",
"version": "1.41.0-next",
"version": "1.41.2",
"license": "Apache-2.0",
"dependencies": {
"@playwright/experimental-ct-core": "1.41.0-next",
"@playwright/experimental-ct-core": "1.41.2",
"@vitejs/plugin-react": "^4.0.0"
},
"bin": {
@ -7687,10 +7687,10 @@
},
"packages/playwright-ct-solid": {
"name": "@playwright/experimental-ct-solid",
"version": "1.41.0-next",
"version": "1.41.2",
"license": "Apache-2.0",
"dependencies": {
"@playwright/experimental-ct-core": "1.41.0-next",
"@playwright/experimental-ct-core": "1.41.2",
"vite-plugin-solid": "^2.7.0"
},
"bin": {
@ -7705,10 +7705,10 @@
},
"packages/playwright-ct-svelte": {
"name": "@playwright/experimental-ct-svelte",
"version": "1.41.0-next",
"version": "1.41.2",
"license": "Apache-2.0",
"dependencies": {
"@playwright/experimental-ct-core": "1.41.0-next",
"@playwright/experimental-ct-core": "1.41.2",
"@sveltejs/vite-plugin-svelte": "^3.0.1"
},
"bin": {
@ -7972,10 +7972,10 @@
},
"packages/playwright-ct-vue": {
"name": "@playwright/experimental-ct-vue",
"version": "1.41.0-next",
"version": "1.41.2",
"license": "Apache-2.0",
"dependencies": {
"@playwright/experimental-ct-core": "1.41.0-next",
"@playwright/experimental-ct-core": "1.41.2",
"@vitejs/plugin-vue": "^4.2.1"
},
"bin": {
@ -8023,10 +8023,10 @@
},
"packages/playwright-ct-vue2": {
"name": "@playwright/experimental-ct-vue2",
"version": "1.41.0-next",
"version": "1.41.2",
"license": "Apache-2.0",
"dependencies": {
"@playwright/experimental-ct-core": "1.41.0-next",
"@playwright/experimental-ct-core": "1.41.2",
"@vitejs/plugin-vue2": "^2.2.0"
},
"bin": {
@ -8040,11 +8040,11 @@
}
},
"packages/playwright-firefox": {
"version": "1.41.0-next",
"version": "1.41.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.41.0-next"
"playwright-core": "1.41.2"
},
"bin": {
"playwright": "cli.js"
@ -8078,10 +8078,10 @@
},
"packages/playwright-test": {
"name": "@playwright/test",
"version": "1.41.0-next",
"version": "1.41.2",
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.41.0-next"
"playwright": "1.41.2"
},
"bin": {
"playwright": "cli.js"
@ -8091,11 +8091,11 @@
}
},
"packages/playwright-webkit": {
"version": "1.41.0-next",
"version": "1.41.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.41.0-next"
"playwright-core": "1.41.2"
},
"bin": {
"playwright": "cli.js"
@ -8973,33 +8973,33 @@
"@playwright/browser-chromium": {
"version": "file:packages/playwright-browser-chromium",
"requires": {
"playwright-core": "1.41.0-next"
"playwright-core": "1.41.2"
}
},
"@playwright/browser-firefox": {
"version": "file:packages/playwright-browser-firefox",
"requires": {
"playwright-core": "1.41.0-next"
"playwright-core": "1.41.2"
}
},
"@playwright/browser-webkit": {
"version": "file:packages/playwright-browser-webkit",
"requires": {
"playwright-core": "1.41.0-next"
"playwright-core": "1.41.2"
}
},
"@playwright/experimental-ct-core": {
"version": "file:packages/playwright-ct-core",
"requires": {
"playwright": "1.41.0-next",
"playwright-core": "1.41.0-next",
"playwright": "1.41.2",
"playwright-core": "1.41.2",
"vite": "^4.4.12"
}
},
"@playwright/experimental-ct-react": {
"version": "file:packages/playwright-ct-react",
"requires": {
"@playwright/experimental-ct-core": "1.41.0-next",
"@playwright/experimental-ct-core": "1.41.2",
"@vitejs/plugin-react": "^4.0.0"
},
"dependencies": {
@ -9019,7 +9019,7 @@
"@playwright/experimental-ct-react17": {
"version": "file:packages/playwright-ct-react17",
"requires": {
"@playwright/experimental-ct-core": "1.41.0-next",
"@playwright/experimental-ct-core": "1.41.2",
"@vitejs/plugin-react": "^4.0.0"
},
"dependencies": {
@ -9039,7 +9039,7 @@
"@playwright/experimental-ct-solid": {
"version": "file:packages/playwright-ct-solid",
"requires": {
"@playwright/experimental-ct-core": "1.41.0-next",
"@playwright/experimental-ct-core": "1.41.2",
"solid-js": "^1.7.0",
"vite-plugin-solid": "^2.7.0"
}
@ -9047,7 +9047,7 @@
"@playwright/experimental-ct-svelte": {
"version": "file:packages/playwright-ct-svelte",
"requires": {
"@playwright/experimental-ct-core": "1.41.0-next",
"@playwright/experimental-ct-core": "1.41.2",
"@sveltejs/vite-plugin-svelte": "^3.0.1",
"svelte": "^4.2.8"
},
@ -9203,7 +9203,7 @@
"@playwright/experimental-ct-vue": {
"version": "file:packages/playwright-ct-vue",
"requires": {
"@playwright/experimental-ct-core": "1.41.0-next",
"@playwright/experimental-ct-core": "1.41.2",
"@vitejs/plugin-vue": "^4.2.1"
},
"dependencies": {
@ -9237,7 +9237,7 @@
"@playwright/experimental-ct-vue2": {
"version": "file:packages/playwright-ct-vue2",
"requires": {
"@playwright/experimental-ct-core": "1.41.0-next",
"@playwright/experimental-ct-core": "1.41.2",
"@vitejs/plugin-vue2": "^2.2.0",
"vue": "^2.7.14"
}
@ -9245,7 +9245,7 @@
"@playwright/test": {
"version": "file:packages/playwright-test",
"requires": {
"playwright": "1.41.0-next"
"playwright": "1.41.2"
}
},
"@rollup/rollup-android-arm-eabi": {
@ -12246,13 +12246,13 @@
"version": "file:packages/playwright",
"requires": {
"fsevents": "2.3.2",
"playwright-core": "1.41.0-next"
"playwright-core": "1.41.2"
}
},
"playwright-chromium": {
"version": "file:packages/playwright-chromium",
"requires": {
"playwright-core": "1.41.0-next"
"playwright-core": "1.41.2"
}
},
"playwright-core": {
@ -12261,13 +12261,13 @@
"playwright-firefox": {
"version": "file:packages/playwright-firefox",
"requires": {
"playwright-core": "1.41.0-next"
"playwright-core": "1.41.2"
}
},
"playwright-webkit": {
"version": "file:packages/playwright-webkit",
"requires": {
"playwright-core": "1.41.0-next"
"playwright-core": "1.41.2"
}
},
"postcss": {

View file

@ -1,7 +1,7 @@
{
"name": "playwright-internal",
"private": true,
"version": "1.41.0-next",
"version": "1.41.2",
"description": "A high-level API to automate web browsers",
"repository": {
"type": "git",

View file

@ -1,6 +1,6 @@
{
"name": "@playwright/browser-chromium",
"version": "1.41.0-next",
"version": "1.41.2",
"description": "Playwright package that automatically installs Chromium",
"repository": {
"type": "git",
@ -27,6 +27,6 @@
"install": "node install.js"
},
"dependencies": {
"playwright-core": "1.41.0-next"
"playwright-core": "1.41.2"
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@playwright/browser-firefox",
"version": "1.41.0-next",
"version": "1.41.2",
"description": "Playwright package that automatically installs Firefox",
"repository": {
"type": "git",
@ -27,6 +27,6 @@
"install": "node install.js"
},
"dependencies": {
"playwright-core": "1.41.0-next"
"playwright-core": "1.41.2"
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@playwright/browser-webkit",
"version": "1.41.0-next",
"version": "1.41.2",
"description": "Playwright package that automatically installs WebKit",
"repository": {
"type": "git",
@ -27,6 +27,6 @@
"install": "node install.js"
},
"dependencies": {
"playwright-core": "1.41.0-next"
"playwright-core": "1.41.2"
}
}

View file

@ -1,6 +1,6 @@
{
"name": "playwright-chromium",
"version": "1.41.0-next",
"version": "1.41.2",
"description": "A high-level API to automate Chromium",
"repository": {
"type": "git",
@ -30,6 +30,6 @@
"install": "node install.js"
},
"dependencies": {
"playwright-core": "1.41.0-next"
"playwright-core": "1.41.2"
}
}

View file

@ -1,6 +1,6 @@
{
"name": "playwright-core",
"version": "1.41.0-next",
"version": "1.41.2",
"description": "A high-level API to automate web browsers",
"repository": {
"type": "git",

View file

@ -28,6 +28,7 @@ import type * as types from '../types';
import type { CRPage } from './crPage';
import { assert, headersObjectToArray } from '../../utils';
import type { CRServiceWorker } from './crServiceWorker';
import { isProtocolError } from '../protocolError';
type SessionInfo = {
session: CRSession;
@ -571,13 +572,16 @@ class RouteImpl implements network.RouteDelegate {
method: overrides.method,
postData: overrides.postData ? overrides.postData.toString('base64') : undefined
};
await catchDisallowedErrors(async () => {
await this._session.send('Fetch.continueRequest', this._alreadyContinuedParams);
});
}
async fulfill(response: types.NormalizedFulfillResponse) {
const body = response.isBase64 ? response.body : Buffer.from(response.body).toString('base64');
const responseHeaders = splitSetCookieHeader(response.headers);
await catchDisallowedErrors(async () => {
await this._session.send('Fetch.fulfillRequest', {
requestId: this._interceptionId!,
responseCode: response.status,
@ -585,20 +589,33 @@ class RouteImpl implements network.RouteDelegate {
responseHeaders,
body,
});
});
}
async abort(errorCode: string = 'failed') {
const errorReason = errorReasons[errorCode];
assert(errorReason, 'Unknown error code: ' + errorCode);
// In certain cases, protocol will return error if the request was already canceled
// or the page was closed. We should tolerate these errors.
await this._session._sendMayFail('Fetch.failRequest', {
await catchDisallowedErrors(async () => {
await this._session.send('Fetch.failRequest', {
requestId: this._interceptionId!,
errorReason
});
});
}
}
// In certain cases, protocol will return error if the request was already canceled
// or the page was closed. We should tolerate these errors but propagate other.
async function catchDisallowedErrors(callback: () => Promise<void>) {
try {
return await callback();
} catch (e) {
if (isProtocolError(e) && e.message.includes('Invalid http status code or phrase'))
throw e;
}
}
function splitSetCookieHeader(headers: types.HeadersArray): types.HeadersArray {
const index = headers.findIndex(({ name }) => name.toLowerCase() === 'set-cookie');
if (index === -1)

View file

@ -262,6 +262,8 @@ export class FrameDispatcher extends Dispatcher<Frame, channels.FrameChannel, Br
const result = await this._frame.expect(metadata, params.selector, { ...params, expectedValue });
if (result.received !== undefined)
result.received = serializeResult(result.received);
if (result.matches === params.isNot)
metadata.error = { error: { name: 'Expect', message: 'Expect failed' } };
return result;
}
}

View file

@ -188,7 +188,7 @@ class RecordActionTool implements RecorderTool {
return;
if (this._actionInProgress(event))
return;
if (this._consumedDueWrongTarget(event, this._hoveredModel))
if (this._consumedDueToNoModel(event, this._hoveredModel))
return;
const checkbox = asCheckbox(this._recorder.deepEventTarget(event));
@ -283,7 +283,7 @@ class RecordActionTool implements RecorderTool {
}
// Non-navigating actions are simply recorded by Playwright.
if (this._consumedDueWrongTarget(event, this._activeModel))
if (this._consumedDueWrongTarget(event))
return;
this._recorder.delegate.recordAction?.({
name: 'fill',
@ -313,7 +313,7 @@ class RecordActionTool implements RecorderTool {
this._expectProgrammaticKeyUp = true;
return;
}
if (this._consumedDueWrongTarget(event, this._activeModel))
if (this._consumedDueWrongTarget(event))
return;
// Similarly to click, trigger checkbox on key event, not input.
if (event.key === ' ') {
@ -373,7 +373,7 @@ class RecordActionTool implements RecorderTool {
const nodeName = target.nodeName;
if (nodeName === 'SELECT' || nodeName === 'OPTION')
return true;
if (nodeName === 'INPUT' && ['date', 'range'].includes((target as HTMLInputElement).type))
if (nodeName === 'INPUT' && ['date'].includes((target as HTMLInputElement).type))
return true;
return false;
}
@ -387,8 +387,15 @@ class RecordActionTool implements RecorderTool {
return false;
}
private _consumedDueWrongTarget(event: Event, model: HighlightModel | null): boolean {
if (model && model.elements[0] === this._recorder.deepEventTarget(event))
private _consumedDueToNoModel(event: Event, model: HighlightModel | null): boolean {
if (model)
return false;
consumeEvent(event);
return true;
}
private _consumedDueWrongTarget(event: Event): boolean {
if (this._activeModel && this._activeModel.elements[0] === this._recorder.deepEventTarget(event))
return false;
consumeEvent(event);
return true;

View file

@ -134,18 +134,6 @@ export class Request extends SdkObject {
this._waitForResponsePromise.resolve(null);
}
async _waitForRequestFailure() {
const response = await this._waitForResponsePromise;
// If response is null it was a failure an we are done.
if (!response)
return;
await response._finishedPromise;
if (this.failure())
return;
// If request finished without errors, we stall.
await new Promise(() => {});
}
_setOverrides(overrides: types.NormalizedContinueOverrides) {
this._overrides = overrides;
this._updateHeadersMap();
@ -270,13 +258,7 @@ export class Route extends SdkObject {
async abort(errorCode: string = 'failed') {
this._startHandling();
this._request._context.emit(BrowserContext.Events.RequestAborted, this._request);
await Promise.race([
this._delegate.abort(errorCode),
// If the request is already cancelled by the page before we handle the route,
// we'll receive loading failed event and will ignore route handling error.
this._request._waitForRequestFailure()
]);
await this._delegate.abort(errorCode);
this._endHandling();
}
@ -304,17 +286,12 @@ export class Route extends SdkObject {
const headers = [...(overrides.headers || [])];
this._maybeAddCorsHeaders(headers);
this._request._context.emit(BrowserContext.Events.RequestFulfilled, this._request);
await Promise.race([
this._delegate.fulfill({
await this._delegate.fulfill({
status: overrides.status || 200,
headers,
body,
body: body!,
isBase64,
}),
// If the request is already cancelled by the page before we handle the route,
// we'll receive loading failed event and will ignore route handling error.
this._request._waitForRequestFailure()
]);
});
this._endHandling();
}
@ -347,13 +324,7 @@ export class Route extends SdkObject {
this._request._setOverrides(overrides);
if (!overrides.isFallback)
this._request._context.emit(BrowserContext.Events.RequestContinued, this._request);
await Promise.race([
this._delegate.continue(this._request, overrides),
// If the request is already cancelled by the page before we handle the route,
// we'll receive loading failed event and will ignore route handling error.
this._request._waitForRequestFailure()
]);
await this._delegate.continue(this._request, overrides);
this._endHandling();
}

View file

@ -1,6 +1,6 @@
{
"name": "@playwright/experimental-ct-core",
"version": "1.41.0-next",
"version": "1.41.2",
"description": "Playwright Component Testing Helpers",
"repository": {
"type": "git",
@ -27,9 +27,9 @@
}
},
"dependencies": {
"playwright-core": "1.41.0-next",
"playwright-core": "1.41.2",
"vite": "^4.4.12",
"playwright": "1.41.0-next"
"playwright": "1.41.2"
},
"bin": {
"playwright": "cli.js"

View file

@ -0,0 +1,6 @@
[vitePlugin.ts]
generated/indexSource.ts
[mount.ts]
generated/serializers.ts
injected/**

View file

@ -0,0 +1,49 @@
/**
* 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.
*/
export type ImportRef = {
__pw_type: 'importRef',
id: string,
property?: string,
};
export function isImportRef(value: any): value is ImportRef {
return typeof value === 'object' && value && value.__pw_type === 'importRef';
}
export class ImportRegistry {
private _registry = new Map<string, () => Promise<any>>();
initialize(components: Record<string, () => Promise<any>>) {
for (const [name, value] of Object.entries(components))
this._registry.set(name, value);
}
async resolveImportRef(importRef: ImportRef): Promise<any> {
const importFunction = this._registry.get(importRef.id);
if (!importFunction)
throw new Error(`Unregistered component: ${importRef.id}. Following components are registered: ${[...this._registry.keys()]}`);
let importedObject = await importFunction();
if (!importedObject)
throw new Error(`Could not resolve component: ${importRef.id}.`);
if (importRef.property) {
importedObject = importedObject[importRef.property];
if (!importedObject)
throw new Error(`Could not instantiate component: ${importRef.id}.${importRef.property}.`);
}
return importedObject;
}
}

View file

@ -0,0 +1,22 @@
/**
* 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 { ImportRegistry } from './importRegistry';
import { transformObject, unwrapObject } from './serializers';
window.__pwRegistry = new ImportRegistry();
window.__pwUnwrapObject = unwrapObject;
window.__pwTransformObject = transformObject;

View file

@ -0,0 +1,93 @@
/**
* 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 { isImportRef } from './importRegistry';
type FunctionRef = {
__pw_type: 'function';
ordinal: number;
};
function isFunctionRef(value: any): value is FunctionRef {
return value && typeof value === 'object' && value.__pw_type === 'function';
}
export function wrapObject(value: any, callbacks: Function[]): any {
return transformObject(value, (v: any) => {
if (typeof v === 'function') {
const ordinal = callbacks.length;
callbacks.push(v as Function);
const result: FunctionRef = {
__pw_type: 'function',
ordinal,
};
return { result };
}
});
}
export async function unwrapObject(value: any): Promise<any> {
return transformObjectAsync(value, async (v: any) => {
if (isFunctionRef(v)) {
const result = (...args: any[]) => {
window.__ctDispatchFunction(v.ordinal, args);
};
return { result };
}
if (isImportRef(v))
return { result: await window.__pwRegistry.resolveImportRef(v) };
});
}
export function transformObject(value: any, mapping: (v: any) => { result: any } | undefined): any {
const result = mapping(value);
if (result)
return result.result;
if (value === null || typeof value !== 'object')
return value;
if (value instanceof Date || value instanceof RegExp || value instanceof URL)
return value;
if (Array.isArray(value)) {
const result = [];
for (const item of value)
result.push(transformObject(item, mapping));
return result;
}
const result2: any = {};
for (const [key, prop] of Object.entries(value))
result2[key] = transformObject(prop, mapping);
return result2;
}
export async function transformObjectAsync(value: any, mapping: (v: any) => Promise<{ result: any } | undefined>): Promise<any> {
const result = await mapping(value);
if (result)
return result.result;
if (value === null || typeof value !== 'object')
return value;
if (value instanceof Date || value instanceof RegExp || value instanceof URL)
return value;
if (Array.isArray(value)) {
const result = [];
for (const item of value)
result.push(await transformObjectAsync(item, mapping));
return result;
}
const result2: any = {};
for (const [key, prop] of Object.entries(value))
result2[key] = await transformObjectAsync(prop, mapping);
return result2;
}

View file

@ -15,8 +15,10 @@
*/
import type { Fixtures, Locator, Page, BrowserContextOptions, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, BrowserContext } from 'playwright/test';
import type { Component, JsxComponent, MountOptions } from '../types/component';
import type { Component, JsxComponent, MountOptions, ObjectComponentOptions } from '../types/component';
import type { ContextReuseMode, FullConfigInternal } from '../../playwright/src/common/config';
import type { ImportRef } from './injected/importRegistry';
import { wrapObject } from './injected/serializers';
let boundCallbacksForMount: Function[] = [];
@ -25,12 +27,16 @@ interface MountResult extends Locator {
update(options: Omit<MountOptions, 'hooksConfig'> | string | JsxComponent): Promise<void>;
}
export const fixtures: Fixtures<
PlaywrightTestArgs & PlaywrightTestOptions & {
type TestFixtures = PlaywrightTestArgs & PlaywrightTestOptions & {
mount: (component: any, options: any) => Promise<MountResult>;
},
PlaywrightWorkerArgs & PlaywrightWorkerOptions & { _ctWorker: { context: BrowserContext | undefined, hash: string } },
{ _contextFactory: (options?: BrowserContextOptions) => Promise<BrowserContext>, _contextReuseMode: ContextReuseMode }> = {
};
type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & { _ctWorker: { context: BrowserContext | undefined, hash: string } };
type BaseTestFixtures = {
_contextFactory: (options?: BrowserContextOptions) => Promise<BrowserContext>,
_contextReuseMode: ContextReuseMode
};
export const fixtures: Fixtures<TestFixtures, WorkerFixtures, BaseTestFixtures> = {
_contextReuseMode: 'when-possible',
@ -42,7 +48,7 @@ export const fixtures: Fixtures<
if (!((info as any)._configInternal as FullConfigInternal).defineConfigWasUsed)
throw new Error('Component testing requires the use of the defineConfig() in your playwright-ct.config.{ts,js}: https://aka.ms/playwright/ct-define-config');
await (page as any)._wrapApiCall(async () => {
await page.exposeFunction('__ct_dispatch', (ordinal: number, args: any[]) => {
await page.exposeFunction('__ctDispatchFunction', (ordinal: number, args: any[]) => {
boundCallbacksForMount[ordinal](...args);
});
await page.goto(process.env.PLAYWRIGHT_TEST_BASE_URL!);
@ -51,9 +57,9 @@ export const fixtures: Fixtures<
},
mount: async ({ page }, use) => {
await use(async (component: JsxComponent | string, options?: MountOptions) => {
await use(async (componentRef: JsxComponent | ImportRef, options?: ObjectComponentOptions & MountOptions) => {
const selector = await (page as any)._wrapApiCall(async () => {
return await innerMount(page, component, options);
return await innerMount(page, componentRef, options);
}, true);
const locator = page.locator(selector);
return Object.assign(locator, {
@ -63,74 +69,45 @@ export const fixtures: Fixtures<
await window.playwrightUnmount(rootElement);
});
},
update: async (options: JsxComponent | Omit<MountOptions, 'hooksConfig'>) => {
if (isJsxApi(options))
update: async (options: JsxComponent | ObjectComponentOptions) => {
if (isJsxComponent(options))
return await innerUpdate(page, options);
await innerUpdate(page, component, options);
await innerUpdate(page, componentRef, options);
}
});
});
boundCallbacksForMount = [];
},
};
};
function isJsxApi(options: Record<string, unknown>): options is JsxComponent {
return options?.kind === 'jsx';
function isJsxComponent(component: any): component is JsxComponent {
return typeof component === 'object' && component && component.__pw_type === 'jsx';
}
async function innerUpdate(page: Page, jsxOrType: JsxComponent | string, options: Omit<MountOptions, 'hooksConfig'> = {}): Promise<void> {
const component = createComponent(jsxOrType, options);
wrapFunctions(component, page, boundCallbacksForMount);
async function innerUpdate(page: Page, componentRef: JsxComponent | ImportRef, options: ObjectComponentOptions = {}): Promise<void> {
const component = wrapObject(createComponent(componentRef, options), boundCallbacksForMount);
await page.evaluate(async ({ component }) => {
const unwrapFunctions = (object: any) => {
for (const [key, value] of Object.entries(object)) {
if (typeof value === 'string' && (value as string).startsWith('__pw_func_')) {
const ordinal = +value.substring('__pw_func_'.length);
object[key] = (...args: any[]) => {
(window as any)['__ct_dispatch'](ordinal, args);
};
} else if (typeof value === 'object' && value) {
unwrapFunctions(value);
}
}
};
unwrapFunctions(component);
component = await window.__pwUnwrapObject(component);
const rootElement = document.getElementById('root')!;
return await window.playwrightUpdate(rootElement, component);
}, { component });
}
async function innerMount(page: Page, jsxOrType: JsxComponent | string, options: MountOptions = {}): Promise<string> {
const component = createComponent(jsxOrType, options);
wrapFunctions(component, page, boundCallbacksForMount);
async function innerMount(page: Page, componentRef: JsxComponent | ImportRef, options: ObjectComponentOptions & MountOptions = {}): Promise<string> {
const component = wrapObject(createComponent(componentRef, options), boundCallbacksForMount);
// WebKit does not wait for deferred scripts.
await page.waitForFunction(() => !!window.playwrightMount);
const selector = await page.evaluate(async ({ component, hooksConfig }) => {
const unwrapFunctions = (object: any) => {
for (const [key, value] of Object.entries(object)) {
if (typeof value === 'string' && (value as string).startsWith('__pw_func_')) {
const ordinal = +value.substring('__pw_func_'.length);
object[key] = (...args: any[]) => {
(window as any)['__ct_dispatch'](ordinal, args);
};
} else if (typeof value === 'object' && value) {
unwrapFunctions(value);
}
}
};
unwrapFunctions(component);
component = await window.__pwUnwrapObject(component);
let rootElement = document.getElementById('root');
if (!rootElement) {
rootElement = document.createElement('div');
rootElement.id = 'root';
document.body.appendChild(rootElement);
}
await window.playwrightMount(component, rootElement, hooksConfig);
return '#root >> internal:control=component';
@ -138,20 +115,12 @@ async function innerMount(page: Page, jsxOrType: JsxComponent | string, options:
return selector;
}
function createComponent(jsxOrType: JsxComponent | string, options: Omit<MountOptions, 'hooksConfig'> = {}): Component {
if (typeof jsxOrType !== 'string') return jsxOrType;
return { __pw_component_marker: true, kind: 'object', type: jsxOrType, options };
}
function wrapFunctions(object: any, page: Page, callbacks: Function[]) {
for (const [key, value] of Object.entries(object)) {
const type = typeof value;
if (type === 'function') {
const functionName = '__pw_func_' + callbacks.length;
callbacks.push(value as Function);
object[key] = functionName;
} else if (type === 'object' && value) {
wrapFunctions(value, page, callbacks);
}
}
function createComponent(component: JsxComponent | ImportRef, options: ObjectComponentOptions = {}): Component {
if (component.__pw_type === 'jsx')
return component;
return {
__pw_type: 'object-component',
type: component,
...options,
};
}

View file

@ -18,11 +18,11 @@ import path from 'path';
import type { T, BabelAPI, PluginObj } from 'playwright/src/transform/babelBundle';
import { types, declare, traverse } from 'playwright/lib/transform/babelBundle';
import { resolveImportSpecifierExtension } from 'playwright/lib/util';
import { setTransformData } from 'playwright/lib/transform/transform';
const t: typeof T = types;
const fullNames = new Map<string, string | undefined>();
let componentNames: Set<string>;
let componentIdentifiers: Set<T.Identifier>;
let jsxComponentNames: Set<string>;
let importInfos: Map<string, ImportInfo>;
export default declare((api: BabelAPI) => {
api.assertVersion(7);
@ -30,11 +30,42 @@ export default declare((api: BabelAPI) => {
const result: PluginObj = {
name: 'playwright-debug-transform',
visitor: {
Program(path) {
fullNames.clear();
const result = collectComponentUsages(path.node);
componentNames = result.names;
componentIdentifiers = result.identifiers;
Program: {
enter(path) {
jsxComponentNames = collectJsxComponentUsages(path.node);
importInfos = new Map();
},
exit(path) {
let firstDeclaration: any;
let lastImportDeclaration: any;
path.get('body').forEach(p => {
if (p.isImportDeclaration())
lastImportDeclaration = p;
else if (!firstDeclaration)
firstDeclaration = p;
});
const insertionPath = lastImportDeclaration || firstDeclaration;
if (!insertionPath)
return;
for (const [localName, componentImport] of [...importInfos.entries()].reverse()) {
insertionPath.insertAfter(
t.variableDeclaration(
'const',
[
t.variableDeclarator(
t.identifier(localName),
t.objectExpression([
t.objectProperty(t.identifier('__pw_type'), t.stringLiteral('importRef')),
t.objectProperty(t.identifier('id'), t.stringLiteral(componentImport.id)),
]),
)
]
)
);
}
setTransformData('playwright-ct-core', [...importInfos.values()]);
}
},
ImportDeclaration(p) {
@ -42,176 +73,109 @@ export default declare((api: BabelAPI) => {
if (!t.isStringLiteral(importNode.source))
return;
let components = 0;
const ext = path.extname(importNode.source.value);
// Convert all non-JS imports into refs.
if (!allJsExtensions.has(ext)) {
for (const specifier of importNode.specifiers) {
const specifierName = specifier.local.name;
const componentName = componentNames.has(specifierName) ? specifierName : [...componentNames].find(c => c.startsWith(specifierName + '.'));
if (!componentName)
continue;
if (t.isImportNamespaceSpecifier(specifier))
continue;
const { fullName } = componentInfo(specifier, importNode.source.value, this.filename!, componentName);
fullNames.set(componentName, fullName);
++components;
const { localName, info } = importInfo(importNode, specifier, this.filename!);
importInfos.set(localName, info);
}
p.skip();
p.remove();
return;
}
// All the imports were components => delete.
if (components && components === importNode.specifiers.length) {
// Convert JS imports that are used as components in JSX expressions into refs.
let importCount = 0;
for (const specifier of importNode.specifiers) {
if (t.isImportNamespaceSpecifier(specifier))
continue;
const { localName, info } = importInfo(importNode, specifier, this.filename!);
if (jsxComponentNames.has(localName)) {
importInfos.set(localName, info);
++importCount;
}
}
// All the imports were from JSX => delete.
if (importCount && importCount === importNode.specifiers.length) {
p.skip();
p.remove();
}
},
Identifier(p) {
if (componentIdentifiers.has(p.node)) {
const componentName = fullNames.get(p.node.name) || p.node.name;
p.replaceWith(t.stringLiteral(componentName));
}
},
JSXElement(path) {
const jsxElement = path.node;
const jsxName = jsxElement.openingElement.name;
let nameOrExpression: string = '';
if (t.isJSXIdentifier(jsxName))
nameOrExpression = jsxName.name;
else if (t.isJSXMemberExpression(jsxName) && t.isJSXIdentifier(jsxName.object) && t.isJSXIdentifier(jsxName.property))
nameOrExpression = jsxName.object.name + '.' + jsxName.property.name;
if (!nameOrExpression)
MemberExpression(path) {
if (!t.isIdentifier(path.node.object))
return;
const componentName = fullNames.get(nameOrExpression) || nameOrExpression;
const props: (T.ObjectProperty | T.SpreadElement)[] = [];
for (const jsxAttribute of jsxElement.openingElement.attributes) {
if (t.isJSXAttribute(jsxAttribute)) {
let namespace: T.JSXIdentifier | undefined;
let name: T.JSXIdentifier | undefined;
if (t.isJSXNamespacedName(jsxAttribute.name)) {
namespace = jsxAttribute.name.namespace;
name = jsxAttribute.name.name;
} else if (t.isJSXIdentifier(jsxAttribute.name)) {
name = jsxAttribute.name;
}
if (!name)
continue;
const attrName = (namespace ? namespace.name + ':' : '') + name.name;
if (t.isStringLiteral(jsxAttribute.value))
props.push(t.objectProperty(t.stringLiteral(attrName), jsxAttribute.value));
else if (t.isJSXExpressionContainer(jsxAttribute.value) && t.isExpression(jsxAttribute.value.expression))
props.push(t.objectProperty(t.stringLiteral(attrName), jsxAttribute.value.expression));
else if (jsxAttribute.value === null)
props.push(t.objectProperty(t.stringLiteral(attrName), t.booleanLiteral(true)));
else
props.push(t.objectProperty(t.stringLiteral(attrName), t.nullLiteral()));
} else if (t.isJSXSpreadAttribute(jsxAttribute)) {
props.push(t.spreadElement(jsxAttribute.argument));
}
}
const children: (T.Expression | T.SpreadElement)[] = [];
for (const child of jsxElement.children) {
if (t.isJSXText(child))
children.push(t.stringLiteral(child.value));
else if (t.isJSXElement(child))
children.push(child);
else if (t.isJSXExpressionContainer(child) && !t.isJSXEmptyExpression(child.expression))
children.push(child.expression);
else if (t.isJSXSpreadChild(child))
children.push(t.spreadElement(child.expression));
}
const component: T.ObjectProperty[] = [
t.objectProperty(t.identifier('__pw_component_marker'), t.booleanLiteral(true)),
t.objectProperty(t.identifier('kind'), t.stringLiteral('jsx')),
t.objectProperty(t.identifier('type'), t.stringLiteral(componentName)),
t.objectProperty(t.identifier('props'), t.objectExpression(props)),
];
if (children.length)
component.push(t.objectProperty(t.identifier('children'), t.arrayExpression(children)));
path.replaceWith(t.objectExpression(component));
}
if (!importInfos.has(path.node.object.name))
return;
if (!t.isIdentifier(path.node.property))
return;
path.replaceWith(
t.objectExpression([
t.spreadElement(t.identifier(path.node.object.name)),
t.objectProperty(t.identifier('property'), t.stringLiteral(path.node.property.name)),
])
);
},
}
};
return result;
});
export function collectComponentUsages(node: T.Node) {
const importedLocalNames = new Set<string>();
function collectJsxComponentUsages(node: T.Node): Set<string> {
const names = new Set<string>();
const identifiers = new Set<T.Identifier>();
traverse(node, {
enter: p => {
// First look at all the imports.
if (t.isImportDeclaration(p.node)) {
const importNode = p.node;
if (!t.isStringLiteral(importNode.source))
return;
for (const specifier of importNode.specifiers) {
if (t.isImportNamespaceSpecifier(specifier))
continue;
importedLocalNames.add(specifier.local.name);
}
}
// Treat JSX-everything as component usages.
if (t.isJSXElement(p.node)) {
if (t.isJSXIdentifier(p.node.openingElement.name))
names.add(p.node.openingElement.name.name);
if (t.isJSXMemberExpression(p.node.openingElement.name) && t.isJSXIdentifier(p.node.openingElement.name.object) && t.isJSXIdentifier(p.node.openingElement.name.property))
names.add(p.node.openingElement.name.object.name + '.' + p.node.openingElement.name.property.name);
}
// Treat mount(identifier, ...) as component usage if it is in the importedLocalNames list.
if (t.isAwaitExpression(p.node) && t.isCallExpression(p.node.argument) && t.isIdentifier(p.node.argument.callee) && p.node.argument.callee.name === 'mount') {
const callExpression = p.node.argument;
const arg = callExpression.arguments[0];
if (!t.isIdentifier(arg) || !importedLocalNames.has(arg.name))
return;
names.add(arg.name);
identifiers.add(arg);
names.add(p.node.openingElement.name.object.name);
}
}
});
return { names, identifiers };
return names;
}
export type ComponentInfo = {
fullName: string;
importPath: string;
export type ImportInfo = {
id: string;
isModuleOrAlias: boolean;
importedName?: string;
importedNameProperty?: string;
deps: string[];
importPath: string;
remoteName: string | undefined;
};
export function componentInfo(specifier: T.ImportSpecifier | T.ImportDefaultSpecifier, importSource: string, filename: string, componentName: string): ComponentInfo {
export function importInfo(importNode: T.ImportDeclaration, specifier: T.ImportSpecifier | T.ImportDefaultSpecifier, filename: string): { localName: string, info: ImportInfo } {
const importSource = importNode.source.value;
const isModuleOrAlias = !importSource.startsWith('.');
const unresolvedImportPath = path.resolve(path.dirname(filename), importSource);
// Support following notations for Button.tsx:
// - import { Button } from './Button.js' - via resolveImportSpecifierExtension
// - import { Button } from './Button' - via require.resolve
const importPath = isModuleOrAlias ? importSource : resolveImportSpecifierExtension(unresolvedImportPath) || require.resolve(unresolvedImportPath);
const prefix = importPath.replace(/[^\w_\d]/g, '_');
const pathInfo = { importPath, isModuleOrAlias };
const idPrefix = importPath.replace(/[^\w_\d]/g, '_');
const specifierName = specifier.local.name;
let fullNameSuffix = '';
let importedNameProperty = '';
if (componentName !== specifierName) {
const suffix = componentName.substring(specifierName.length + 1);
fullNameSuffix = '_' + suffix;
importedNameProperty = '.' + suffix;
const result: ImportInfo = {
id: idPrefix,
importPath,
isModuleOrAlias,
remoteName: undefined,
};
if (t.isImportDefaultSpecifier(specifier)) {
} else if (t.isIdentifier(specifier.imported)) {
result.remoteName = specifier.imported.name;
} else {
result.remoteName = specifier.imported.value;
}
if (t.isImportDefaultSpecifier(specifier))
return { fullName: prefix + fullNameSuffix, importedNameProperty, deps: [], ...pathInfo };
if (t.isIdentifier(specifier.imported))
return { fullName: prefix + '_' + specifier.imported.name + fullNameSuffix, importedName: specifier.imported.name, importedNameProperty, deps: [], ...pathInfo };
return { fullName: prefix + '_' + specifier.imported.value + fullNameSuffix, importedName: specifier.imported.value, importedNameProperty, deps: [], ...pathInfo };
if (result.remoteName)
result.id += '_' + result.remoteName;
return { localName: specifier.local.name, info: result };
}
const allJsExtensions = new Set(['.js', '.jsx', '.cjs', '.mjs', '.ts', '.tsx', '.cts', '.mts', '']);

View file

@ -19,20 +19,19 @@ import type { PlaywrightTestConfig as BasePlaywrightTestConfig, FullConfig } fro
import type { InlineConfig, Plugin, ResolveFn, ResolvedConfig, UserConfig } from 'vite';
import type { TestRunnerPlugin } from '../../playwright/src/plugins';
import type { ComponentInfo } from './tsxTransform';
import type { AddressInfo } from 'net';
import type { PluginContext } from 'rollup';
import { debug } from 'playwright-core/lib/utilsBundle';
import fs from 'fs';
import path from 'path';
import { parse, traverse, types as t } from 'playwright/lib/transform/babelBundle';
import { stoppable } from 'playwright/lib/utilsBundle';
import { assert, calculateSha1 } from 'playwright-core/lib/utils';
import { getPlaywrightVersion } from 'playwright-core/lib/utils';
import { setExternalDependencies } from 'playwright/lib/transform/compilationCache';
import { collectComponentUsages, componentInfo } from './tsxTransform';
import { getUserData, internalDependenciesForTestFile, setExternalDependencies } from 'playwright/lib/transform/compilationCache';
import { version as viteVersion, build, preview, mergeConfig } from 'vite';
import { source as injectedSource } from './generated/indexSource';
import type { ImportInfo } from './tsxTransform';
const log = debug('pw:vite');
@ -107,7 +106,7 @@ export function createPlugin(
let buildExists = false;
let buildInfo: BuildInfo;
const registerSource = await fs.promises.readFile(registerSourceFile, 'utf-8');
const registerSource = injectedSource + '\n' + await fs.promises.readFile(registerSourceFile, 'utf-8');
const registerSourceHash = calculateSha1(registerSource);
try {
@ -122,16 +121,16 @@ export function createPlugin(
viteVersion,
registerSourceHash,
components: [],
tests: {},
sources: {},
deps: {},
};
}
log('build exists:', buildExists);
const componentRegistry: ComponentRegistry = new Map();
// 1. Re-parse changed tests and collect required components.
const hasNewTests = await checkNewTests(suite, buildInfo, componentRegistry);
log('has new tests:', hasNewTests);
const componentsByImportingFile = new Map<string, string[]>();
// 1. Populate component registry based on tests' component imports.
await populateComponentsFromTests(componentRegistry, componentsByImportingFile);
// 2. Check if the set of required components has changed.
const hasNewComponents = await checkNewComponents(buildInfo, componentRegistry);
@ -168,8 +167,9 @@ export function createPlugin(
frameworkOverrides.plugins = [await frameworkPluginFactory()];
// But only add out own plugin when we actually build / transform.
const depsCollector = new Map<string, string[]>();
if (sourcesDirty)
frameworkOverrides.plugins!.push(vitePlugin(registerSource, templateDir, buildInfo, componentRegistry));
frameworkOverrides.plugins!.push(vitePlugin(registerSource, templateDir, buildInfo, componentRegistry, depsCollector));
frameworkOverrides.build = {
target: 'esnext',
@ -189,20 +189,31 @@ export function createPlugin(
log('build');
await build(finalConfig);
await fs.promises.rename(`${finalConfig.build.outDir}/${relativeTemplateDir}/index.html`, `${finalConfig.build.outDir}/index.html`);
buildInfo.deps = Object.fromEntries(depsCollector.entries());
}
if (hasNewTests || hasNewComponents || sourcesDirty) {
if (hasNewComponents || sourcesDirty) {
log('write manifest');
await fs.promises.writeFile(buildInfoFile, JSON.stringify(buildInfo, undefined, 2));
}
for (const [filename, testInfo] of Object.entries(buildInfo.tests)) {
for (const projectSuite of suite.suites) {
for (const fileSuite of projectSuite.suites) {
// For every test file...
const testFile = fileSuite.location!.file;
const deps = new Set<string>();
for (const componentName of testInfo.components) {
const component = componentRegistry.get(componentName);
component?.deps.forEach(d => deps.add(d));
// Collect its JS dependencies (helpers).
for (const file of [testFile, ...(internalDependenciesForTestFile(testFile) || [])]) {
// For each helper, get all the imported components.
for (const componentFile of componentsByImportingFile.get(file) || []) {
// For each component, get all the dependencies.
for (const d of depsCollector.get(componentFile) || [])
deps.add(d);
}
}
// Now we have test file => all components along with dependencies.
setExternalDependencies(testFile, [...deps]);
}
setExternalDependencies(filename, [...deps]);
}
const previewServer = await preview(finalConfig);
@ -231,16 +242,13 @@ type BuildInfo = {
timestamp: number;
}
};
components: ComponentInfo[];
tests: {
[key: string]: {
timestamp: number;
components: string[];
components: ImportInfo[];
deps: {
[key: string]: string[];
}
};
};
type ComponentRegistry = Map<string, ComponentInfo>;
type ComponentRegistry = Map<string, ImportInfo>;
async function checkSources(buildInfo: BuildInfo): Promise<boolean> {
for (const [source, sourceInfo] of Object.entries(buildInfo.sources)) {
@ -258,32 +266,18 @@ async function checkSources(buildInfo: BuildInfo): Promise<boolean> {
return false;
}
async function checkNewTests(suite: Suite, buildInfo: BuildInfo, componentRegistry: ComponentRegistry): Promise<boolean> {
const testFiles = new Set<string>();
for (const project of suite.suites) {
for (const file of project.suites)
testFiles.add(file.location!.file);
async function populateComponentsFromTests(componentRegistry: ComponentRegistry, componentsByImportingFile: Map<string, string[]>) {
const importInfos: Map<string, ImportInfo[]> = await getUserData('playwright-ct-core');
for (const [file, importList] of importInfos) {
for (const importInfo of importList)
componentRegistry.set(importInfo.id, importInfo);
componentsByImportingFile.set(file, importList.filter(i => !i.isModuleOrAlias).map(i => i.importPath));
}
let hasNewTests = false;
for (const testFile of testFiles) {
const timestamp = (await fs.promises.stat(testFile)).mtimeMs;
if (buildInfo.tests[testFile]?.timestamp !== timestamp) {
const components = await parseTestFile(testFile);
log('changed test:', testFile);
for (const component of components)
componentRegistry.set(component.fullName, component);
buildInfo.tests[testFile] = { timestamp, components: components.map(c => c.fullName) };
hasNewTests = true;
}
}
return hasNewTests;
}
async function checkNewComponents(buildInfo: BuildInfo, componentRegistry: ComponentRegistry): Promise<boolean> {
const newComponents = [...componentRegistry.keys()];
const oldComponents = new Map(buildInfo.components.map(c => [c.fullName, c]));
const oldComponents = new Map(buildInfo.components.map(c => [c.id, c]));
let hasNewComponents = false;
for (const c of newComponents) {
@ -293,42 +287,12 @@ async function checkNewComponents(buildInfo: BuildInfo, componentRegistry: Compo
}
}
for (const c of oldComponents.values())
componentRegistry.set(c.fullName, c);
componentRegistry.set(c.id, c);
return hasNewComponents;
}
async function parseTestFile(testFile: string): Promise<ComponentInfo[]> {
const text = await fs.promises.readFile(testFile, 'utf-8');
const ast = parse(text, { errorRecovery: true, plugins: ['typescript', 'jsx'], sourceType: 'module' });
const componentUsages = collectComponentUsages(ast);
const componentNames = componentUsages.names;
const result: ComponentInfo[] = [];
traverse(ast, {
enter: p => {
if (t.isImportDeclaration(p.node)) {
const importNode = p.node;
if (!t.isStringLiteral(importNode.source))
return;
for (const specifier of importNode.specifiers) {
const specifierName = specifier.local.name;
const componentName = componentNames.has(specifierName) ? specifierName : [...componentNames].find(c => c.startsWith(specifierName + '.'));
if (!componentName)
continue;
if (t.isImportNamespaceSpecifier(specifier))
continue;
result.push(componentInfo(specifier, importNode.source.value, testFile, componentName));
}
}
}
});
return result;
}
function vitePlugin(registerSource: string, templateDir: string, buildInfo: BuildInfo, componentRegistry: ComponentRegistry): Plugin {
function vitePlugin(registerSource: string, templateDir: string, buildInfo: BuildInfo, importInfos: Map<string, ImportInfo>, depsCollector: Map<string, string[]>): Plugin {
buildInfo.sources = {};
let moduleResolver: ResolveFn;
return {
@ -368,15 +332,12 @@ function vitePlugin(registerSource: string, templateDir: string, buildInfo: Buil
const lines = [content, ''];
lines.push(registerSource);
for (const [alias, value] of componentRegistry) {
for (const value of importInfos.values()) {
const importPath = value.isModuleOrAlias ? value.importPath : './' + path.relative(folder, value.importPath).replace(/\\/g, '/');
if (value.importedName)
lines.push(`const ${alias} = () => import('${importPath}').then((mod) => mod.${value.importedName + (value.importedNameProperty || '')});`);
else
lines.push(`const ${alias} = () => import('${importPath}').then((mod) => mod.default${value.importedNameProperty || ''});`);
lines.push(`const ${value.id} = () => import('${importPath}').then((mod) => mod.${value.remoteName || 'default'});`);
}
lines.push(`pwRegister({ ${[...componentRegistry.keys()].join(',\n ')} });`);
lines.push(`__pwRegistry.initialize({ ${[...importInfos.keys()].join(',\n ')} });`);
return {
code: lines.join('\n'),
map: { mappings: '' }
@ -384,13 +345,13 @@ function vitePlugin(registerSource: string, templateDir: string, buildInfo: Buil
},
async writeBundle(this: PluginContext) {
for (const component of componentRegistry.values()) {
const id = (await moduleResolver(component.importPath));
for (const importInfo of importInfos.values()) {
const deps = new Set<string>();
const id = await moduleResolver(importInfo.importPath);
if (!id)
continue;
const deps = new Set<string>();
collectViteModuleDependencies(this, id, deps);
component.deps = [...deps];
depsCollector.set(importInfo.importPath, [...deps]);
}
},
};
@ -410,7 +371,7 @@ function collectViteModuleDependencies(context: PluginContext, id: string, deps:
collectViteModuleDependencies(context, importedId, deps);
}
function hasJSComponents(components: ComponentInfo[]): boolean {
function hasJSComponents(components: ImportInfo[]): boolean {
for (const component of components) {
const extname = path.extname(component.importPath);
if (extname === '.js' || !extname && fs.existsSync(component.importPath + '.js'))

View file

@ -14,33 +14,32 @@
* limitations under the License.
*/
import type { ImportRegistry } from '../src/injected/importRegistry';
type JsonPrimitive = string | number | boolean | null;
type JsonValue = JsonPrimitive | JsonObject | JsonArray;
type JsonArray = JsonValue[];
export type JsonObject = { [Key in string]?: JsonValue };
// JsxComponentChild can be anything, consider cases like: <>{1}</>, <>{null}</>
export type JsxComponentChild = JsxComponent | string | number | boolean | null;
export type JsxComponent = {
__pw_component_marker: true,
kind: 'jsx',
type: string,
__pw_type: 'jsx',
type: any,
props: Record<string, any>,
children?: JsxComponentChild[],
};
export type MountOptions = {
props?: Record<string, any>,
slots?: Record<string, string | string[]>,
on?: Record<string, Function>,
hooksConfig?: any,
};
export type ObjectComponent = {
__pw_component_marker: true,
kind: 'object',
type: string,
options?: MountOptions
export type ObjectComponentOptions = {
props?: Record<string, any>;
slots?: Record<string, string | string[]>;
on?: Record<string, Function>;
};
export type ObjectComponent = ObjectComponentOptions & {
__pw_type: 'object-component',
type: any,
};
export type Component = JsxComponent | ObjectComponent;
@ -56,5 +55,10 @@ declare global {
__pw_hooks_after_mount?: (<HooksConfig extends JsonObject = JsonObject>(
params: { hooksConfig?: HooksConfig; [key: string]: any }
) => Promise<void>)[];
__pwRegistry: ImportRegistry;
// Can't start with __pw due to core reuse bindings logic for __pw*.
__ctDispatchFunction: (ordinal: number, args: any[]) => void;
__pwUnwrapObject: (value: any) => Promise<any>;
__pwTransformObject: (value: any, mapping: (v: any) => { result: any } | undefined) => any;
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@playwright/experimental-ct-react",
"version": "1.41.0-next",
"version": "1.41.2",
"description": "Playwright Component Testing for React",
"repository": {
"type": "git",
@ -29,7 +29,7 @@
}
},
"dependencies": {
"@playwright/experimental-ct-core": "1.41.0-next",
"@playwright/experimental-ct-core": "1.41.2",
"@vitejs/plugin-react": "^4.0.0"
},
"bin": {

View file

@ -17,130 +17,38 @@
// @ts-check
// This file is injected into the registry as text, no dependencies are allowed.
import * as __pwReact from 'react';
import __pwReact from 'react';
import { createRoot as __pwCreateRoot } from 'react-dom/client';
/** @typedef {import('../playwright-ct-core/types/component').JsxComponentChild} JsxComponentChild */
/** @typedef {import('../playwright-ct-core/types/component').JsxComponent} JsxComponent */
/** @typedef {import('react').FunctionComponent} FrameworkComponent */
/** @type {Map<string, () => Promise<FrameworkComponent>>} */
const __pwLoaderRegistry = new Map();
/** @type {Map<string, FrameworkComponent>} */
const __pwRegistry = new Map();
/** @type {Map<Element, import('react-dom/client').Root>} */
const __pwRootRegistry = new Map();
/**
* @param {Record<string, () => Promise<FrameworkComponent>>} components
*/
export function pwRegister(components) {
for (const [name, value] of Object.entries(components))
__pwLoaderRegistry.set(name, value);
}
/**
* @param {any} component
* @returns {component is JsxComponent}
*/
function isComponent(component) {
return component.__pw_component_marker === true && component.kind === 'jsx';
function isJsxComponent(component) {
return typeof component === 'object' && component && component.__pw_type === 'jsx';
}
/**
* @param {JsxComponent | JsxComponentChild} component
* @param {any} value
*/
async function __pwResolveComponent(component) {
if (!isComponent(component))
return;
let componentFactory = __pwLoaderRegistry.get(component.type);
if (!componentFactory) {
// Lookup by shorthand.
for (const [name, value] of __pwLoaderRegistry) {
if (component.type.endsWith(`_${name}`)) {
componentFactory = value;
break;
function __pwRender(value) {
return window.__pwTransformObject(value, v => {
if (isJsxComponent(v)) {
const component = v;
const props = component.props ? __pwRender(component.props) : {};
return { result: __pwReact.createElement(/** @type { any } */ (component.type), { ...props, children: undefined }, props.children) };
}
}
}
if (!componentFactory && component.type[0].toUpperCase() === component.type[0])
throw new Error(`Unregistered component: ${component.type}. Following components are registered: ${[...__pwRegistry.keys()]}`);
if (componentFactory)
__pwRegistry.set(component.type, await componentFactory());
if (component.children?.length)
await Promise.all(component.children.map(child => __pwResolveComponent(child)));
if (component.props)
await __resolveProps(component.props);
}
/**
* @param {Record<string, any>} props
*/
async function __resolveProps(props) {
for (const prop of Object.values(props)) {
if (Array.isArray(prop))
await Promise.all(prop.map(child => __pwResolveComponent(child)));
else if (isComponent(prop))
await __pwResolveComponent(prop);
else if (typeof prop === 'object' && prop !== null)
await __resolveProps(prop);
}
}
/**
* @param {JsxComponentChild} child
*/
function __renderChild(child) {
if (Array.isArray(child))
return child.map(grandChild => __renderChild(grandChild));
if (isComponent(child))
return __pwRender(child);
return child;
}
/**
* @param {Record<string, any>} props
*/
function __renderProps(props) {
const newProps = {};
for (const [key, prop] of Object.entries(props)) {
if (Array.isArray(prop))
newProps[key] = prop.map(child => __renderChild(child));
else if (isComponent(prop))
newProps[key] = __renderChild(prop);
else if (typeof prop === 'object' && prop !== null)
newProps[key] = __renderProps(prop);
else
newProps[key] = prop;
}
return newProps;
}
/**
* @param {JsxComponent} component
*/
function __pwRender(component) {
const componentFunc = __pwRegistry.get(component.type);
const props = __renderProps(component.props || {});
const children = component.children?.map(child => __renderChild(child)).filter(child => {
if (typeof child === 'string')
return !!child.trim();
return true;
});
const reactChildren = Array.isArray(children) && children.length === 1 ? children[0] : children;
return __pwReact.createElement(componentFunc || component.type, props, reactChildren);
}
window.playwrightMount = async (component, rootElement, hooksConfig) => {
if (component.kind !== 'jsx')
if (!isJsxComponent(component))
throw new Error('Object mount notation is not supported');
await __pwResolveComponent(component);
let App = () => __pwRender(component);
for (const hook of window.__pw_hooks_before_mount || []) {
const wrapper = await hook({ App, hooksConfig });
@ -171,10 +79,9 @@ window.playwrightUnmount = async rootElement => {
};
window.playwrightUpdate = async (rootElement, component) => {
if (component.kind !== 'jsx')
if (!isJsxComponent(component))
throw new Error('Object mount notation is not supported');
await __pwResolveComponent(component);
const root = __pwRootRegistry.get(rootElement);
if (root === undefined)
throw new Error('Component was not mounted');

View file

@ -1,6 +1,6 @@
{
"name": "@playwright/experimental-ct-react17",
"version": "1.41.0-next",
"version": "1.41.2",
"description": "Playwright Component Testing for React",
"repository": {
"type": "git",
@ -29,7 +29,7 @@
}
},
"dependencies": {
"@playwright/experimental-ct-core": "1.41.0-next",
"@playwright/experimental-ct-core": "1.41.2",
"@vitejs/plugin-react": "^4.0.0"
},
"bin": {

View file

@ -17,93 +17,35 @@
// @ts-check
// This file is injected into the registry as text, no dependencies are allowed.
// Don't clash with the user land.
import __pwReact from 'react';
import __pwReactDOM from 'react-dom';
/** @typedef {import('../playwright-ct-core/types/component').JsxComponentChild} JsxComponentChild */
/** @typedef {import('../playwright-ct-core/types/component').JsxComponent} JsxComponent */
/** @typedef {import('react').FunctionComponent} FrameworkComponent */
/** @type {Map<string, () => Promise<FrameworkComponent>>} */
const __pwLoaderRegistry = new Map();
/** @type {Map<string, FrameworkComponent>} */
const __pwRegistry = new Map();
/**
* @param {{[key: string]: () => Promise<FrameworkComponent>}} components
*/
export function pwRegister(components) {
for (const [name, value] of Object.entries(components))
__pwLoaderRegistry.set(name, value);
}
/**
* @param {any} component
* @returns {component is JsxComponent}
*/
function isComponent(component) {
return !(typeof component !== 'object' || Array.isArray(component));
function isJsxComponent(component) {
return typeof component === 'object' && component && component.__pw_type === 'jsx';
}
/**
* @param {JsxComponent | JsxComponentChild} component
* @param {any} value
*/
async function __pwResolveComponent(component) {
if (!isComponent(component))
return;
let componentFactory = __pwLoaderRegistry.get(component.type);
if (!componentFactory) {
// Lookup by shorthand.
for (const [name, value] of __pwLoaderRegistry) {
if (component.type.endsWith(`_${name}`)) {
componentFactory = value;
break;
function __pwRender(value) {
return window.__pwTransformObject(value, v => {
if (isJsxComponent(v)) {
const component = v;
const props = component.props ? __pwRender(component.props) : {};
return { result: __pwReact.createElement(/** @type { any } */ (component.type), { ...props, children: undefined }, props.children) };
}
}
}
if (!componentFactory && component.type[0].toUpperCase() === component.type[0])
throw new Error(`Unregistered component: ${component.type}. Following components are registered: ${[...__pwRegistry.keys()]}`);
if (componentFactory)
__pwRegistry.set(component.type, await componentFactory());
if (component.children?.length)
await Promise.all(component.children.map(child => __pwResolveComponent(child)));
}
/**
* @param {JsxComponentChild} child
*/
function __renderChild(child) {
if (Array.isArray(child))
return child.map(grandChild => __renderChild(grandChild));
if (isComponent(child))
return __pwRender(child);
return child;
}
/**
* @param {JsxComponent} component
*/
function __pwRender(component) {
const componentFunc = __pwRegistry.get(component.type);
const children = component.children?.map(child => __renderChild(child)).filter(child => {
if (typeof child === 'string')
return !!child.trim();
return true;
});
const reactChildren = Array.isArray(children) && children.length === 1 ? children[0] : children;
return __pwReact.createElement(componentFunc || component.type, component.props, reactChildren);
}
window.playwrightMount = async (component, rootElement, hooksConfig) => {
if (component.kind !== 'jsx')
if (!isJsxComponent(component))
throw new Error('Object mount notation is not supported');
await __pwResolveComponent(component);
let App = () => __pwRender(component);
for (const hook of window.__pw_hooks_before_mount || []) {
const wrapper = await hook({ App, hooksConfig });
@ -123,9 +65,8 @@ window.playwrightUnmount = async rootElement => {
};
window.playwrightUpdate = async (rootElement, component) => {
if (component.kind !== 'jsx')
if (!isJsxComponent(component))
throw new Error('Object mount notation is not supported');
await __pwResolveComponent(component);
__pwReactDOM.render(__pwRender(component), rootElement);
};

View file

@ -1,6 +1,6 @@
{
"name": "@playwright/experimental-ct-solid",
"version": "1.41.0-next",
"version": "1.41.2",
"description": "Playwright Component Testing for Solid",
"repository": {
"type": "git",
@ -29,7 +29,7 @@
}
},
"dependencies": {
"@playwright/experimental-ct-core": "1.41.0-next",
"@playwright/experimental-ct-core": "1.41.2",
"vite-plugin-solid": "^2.7.0"
},
"devDependencies": {

View file

@ -20,94 +20,62 @@
import { render as __pwSolidRender, createComponent as __pwSolidCreateComponent } from 'solid-js/web';
import __pwH from 'solid-js/h';
/** @typedef {import('../playwright-ct-core/types/component').JsxComponentChild} JsxComponentChild */
/** @typedef {import('../playwright-ct-core/types/component').JsxComponent} JsxComponent */
/** @typedef {() => import('solid-js').JSX.Element} FrameworkComponent */
/** @type {Map<string, () => Promise<FrameworkComponent>>} */
const __pwLoaderRegistry = new Map();
/** @type {Map<string, FrameworkComponent>} */
const __pwRegistry = new Map();
/**
* @param {{[key: string]: () => Promise<FrameworkComponent>}} components
*/
export function pwRegister(components) {
for (const [name, value] of Object.entries(components))
__pwLoaderRegistry.set(name, value);
}
/**
* @param {any} component
* @returns {component is JsxComponent}
*/
function isComponent(component) {
return !(typeof component !== 'object' || Array.isArray(component));
function isJsxComponent(component) {
return typeof component === 'object' && component && component.__pw_type === 'jsx';
}
/**
* @param {JsxComponent | JsxComponentChild} component
*/
async function __pwResolveComponent(component) {
if (!isComponent(component))
return;
let componentFactory = __pwLoaderRegistry.get(component.type);
if (!componentFactory) {
// Lookup by shorthand.
for (const [name, value] of __pwLoaderRegistry) {
if (component.type.endsWith(`_${name}`)) {
componentFactory = value;
break;
}
}
}
if (!componentFactory && component.type[0].toUpperCase() === component.type[0])
throw new Error(`Unregistered component: ${component.type}. Following components are registered: ${[...__pwRegistry.keys()]}`);
if (componentFactory)
__pwRegistry.set(component.type, await componentFactory());
if (component.children?.length)
await Promise.all(component.children.map(child => __pwResolveComponent(child)));
}
/**
* @param {JsxComponentChild} child
* @param {any} child
*/
function __pwCreateChild(child) {
if (Array.isArray(child))
return child.map(grandChild => __pwCreateChild(grandChild));
if (isComponent(child))
if (isJsxComponent(child))
return __pwCreateComponent(child);
return child;
}
/**
* @param {JsxComponent} component
* @returns {any[] | undefined}
*/
function __pwJsxChildArray(component) {
if (!component.props.children)
return;
if (Array.isArray(component.props.children))
return component.props.children;
return [component.props.children];
}
/**
* @param {JsxComponent} component
*/
function __pwCreateComponent(component) {
const componentFunc = __pwRegistry.get(component.type);
const children = component.children?.map(child => __pwCreateChild(child)).filter(child => {
const children = __pwJsxChildArray(component)?.map(child => __pwCreateChild(child)).filter(child => {
if (typeof child === 'string')
return !!child.trim();
return true;
});
if (!componentFunc)
if (typeof component.type === 'string')
return __pwH(component.type, component.props, children);
return __pwSolidCreateComponent(componentFunc, { ...component.props, children });
return __pwSolidCreateComponent(component.type, { ...component.props, children });
}
const __pwUnmountKey = Symbol('unmountKey');
window.playwrightMount = async (component, rootElement, hooksConfig) => {
if (component.kind !== 'jsx')
if (!isJsxComponent(component))
throw new Error('Object mount notation is not supported');
await __pwResolveComponent(component);
let App = () => __pwCreateComponent(component);
for (const hook of window.__pw_hooks_before_mount || []) {
const wrapper = await hook({ App, hooksConfig });
@ -131,7 +99,7 @@ window.playwrightUnmount = async rootElement => {
};
window.playwrightUpdate = async (rootElement, component) => {
if (component.kind !== 'jsx')
if (!isJsxComponent(component))
throw new Error('Object mount notation is not supported');
window.playwrightUnmount(rootElement);

View file

@ -1,6 +1,6 @@
{
"name": "@playwright/experimental-ct-svelte",
"version": "1.41.0-next",
"version": "1.41.2",
"description": "Playwright Component Testing for Svelte",
"repository": {
"type": "git",
@ -29,7 +29,7 @@
}
},
"dependencies": {
"@playwright/experimental-ct-core": "1.41.0-next",
"@playwright/experimental-ct-core": "1.41.2",
"@sveltejs/vite-plugin-svelte": "^3.0.1"
},
"devDependencies": {

View file

@ -25,50 +25,12 @@ import { detach as __pwDetach, insert as __pwInsert, noop as __pwNoop } from 'sv
/** @typedef {any} FrameworkComponent */
/** @typedef {import('svelte').SvelteComponent} SvelteComponent */
/** @type {Map<string, () => Promise<FrameworkComponent>>} */
const __pwLoaderRegistry = new Map();
/** @type {Map<string, FrameworkComponent>} */
const __pwRegistry = new Map();
/**
* @param {{[key: string]: () => Promise<FrameworkComponent>}} components
*/
export function pwRegister(components) {
for (const [name, value] of Object.entries(components))
__pwLoaderRegistry.set(name, value);
}
/**
* @param {any} component
* @returns {component is ObjectComponent}
*/
function isComponent(component) {
return !(typeof component !== 'object' || Array.isArray(component));
}
/**
* @param {ObjectComponent} component
*/
async function __pwResolveComponent(component) {
if (!isComponent(component))
return;
let componentFactory = __pwLoaderRegistry.get(component.type);
if (!componentFactory) {
// Lookup by shorthand.
for (const [name, value] of __pwLoaderRegistry) {
if (component.type.endsWith(`_${name}_svelte`)) {
componentFactory = value;
break;
}
}
}
if (!componentFactory)
throw new Error(`Unregistered component: ${component.type}. Following components are registered: ${[...__pwRegistry.keys()]}`);
if (componentFactory)
__pwRegistry.set(component.type, await componentFactory());
function isObjectComponent(component) {
return typeof component === 'object' && component && component.__pw_type === 'object-component';
}
/**
@ -105,19 +67,19 @@ function __pwCreateSlots(slots) {
const __pwSvelteComponentKey = Symbol('svelteComponent');
window.playwrightMount = async (component, rootElement, hooksConfig) => {
if (component.kind !== 'object')
if (!isObjectComponent(component))
throw new Error('JSX mount notation is not supported');
await __pwResolveComponent(component);
const componentCtor = __pwRegistry.get(component.type);
const objectComponent = component;
const componentCtor = component.type;
class App extends componentCtor {
constructor(options = {}) {
super({
target: rootElement,
props: {
...component.options?.props,
$$slots: __pwCreateSlots(component.options?.slots),
...objectComponent.props,
$$slots: __pwCreateSlots(objectComponent.slots),
$$scope: {},
},
...options
@ -134,7 +96,7 @@ window.playwrightMount = async (component, rootElement, hooksConfig) => {
rootElement[__pwSvelteComponentKey] = svelteComponent;
for (const [key, listener] of Object.entries(component.options?.on || {}))
for (const [key, listener] of Object.entries(objectComponent.on || {}))
svelteComponent.$on(key, event => listener(event.detail));
for (const hook of window.__pw_hooks_after_mount || [])
@ -149,17 +111,16 @@ window.playwrightUnmount = async rootElement => {
};
window.playwrightUpdate = async (rootElement, component) => {
if (component.kind !== 'object')
if (!isObjectComponent(component))
throw new Error('JSX mount notation is not supported');
await __pwResolveComponent(component);
const svelteComponent = /** @type {SvelteComponent} */ (rootElement[__pwSvelteComponentKey]);
if (!svelteComponent)
throw new Error('Component was not mounted');
for (const [key, listener] of Object.entries(component.options?.on || {}))
for (const [key, listener] of Object.entries(component.on || {}))
svelteComponent.$on(key, event => listener(event.detail));
if (component.options?.props)
svelteComponent.$set(component.options.props);
if (component.props)
svelteComponent.$set(component.props);
};

View file

@ -1,6 +1,6 @@
{
"name": "@playwright/experimental-ct-vue",
"version": "1.41.0-next",
"version": "1.41.2",
"description": "Playwright Component Testing for Vue",
"repository": {
"type": "git",
@ -29,7 +29,7 @@
}
},
"dependencies": {
"@playwright/experimental-ct-core": "1.41.0-next",
"@playwright/experimental-ct-core": "1.41.2",
"@vitejs/plugin-vue": "^4.2.1"
},
"bin": {

View file

@ -22,69 +22,35 @@ import { compile as __pwCompile } from '@vue/compiler-dom';
import * as __pwVue from 'vue';
/** @typedef {import('../playwright-ct-core/types/component').Component} Component */
/** @typedef {import('../playwright-ct-core/types/component').JsxComponentChild} JsxComponentChild */
/** @typedef {import('../playwright-ct-core/types/component').JsxComponent} JsxComponent */
/** @typedef {import('../playwright-ct-core/types/component').ObjectComponent} ObjectComponent */
/** @typedef {import('vue').Component} FrameworkComponent */
/** @type {Map<string, () => Promise<FrameworkComponent>>} */
const __pwLoaderRegistry = new Map();
/** @type {Map<string, FrameworkComponent>} */
const __pwRegistry = new Map();
const __pwAllListeners = new Map();
/**
* @param {{[key: string]: () => Promise<FrameworkComponent>}} components
* @param {any} component
* @returns {component is ObjectComponent}
*/
export function pwRegister(components) {
for (const [name, value] of Object.entries(components))
__pwLoaderRegistry.set(name, value);
function isObjectComponent(component) {
return typeof component === 'object' && component && component.__pw_type === 'object-component';
}
/**
* @param {any} component
* @returns {component is Component}
* @returns {component is JsxComponent}
*/
function isComponent(component) {
return !(typeof component !== 'object' || Array.isArray(component));
function isJsxComponent(component) {
return typeof component === 'object' && component && component.__pw_type === 'jsx';
}
/**
* @param {Component | JsxComponentChild} component
*/
async function __pwResolveComponent(component) {
if (!isComponent(component))
return;
let componentFactory = __pwLoaderRegistry.get(component.type);
if (!componentFactory) {
// Lookup by shorthand.
for (const [name, value] of __pwLoaderRegistry) {
if (component.type.endsWith(`_${name}_vue`)) {
componentFactory = value;
break;
}
}
}
if (!componentFactory && component.type[0].toUpperCase() === component.type[0])
throw new Error(`Unregistered component: ${component.type}. Following components are registered: ${[...__pwRegistry.keys()]}`);
if (componentFactory)
__pwRegistry.set(component.type, await componentFactory());
if ('children' in component && component.children?.length)
await Promise.all(component.children.map(child => __pwResolveComponent(child)));
}
const __pwAllListeners = new Map();
/**
* @param {JsxComponentChild} child
* @param {any} child
*/
function __pwCreateChild(child) {
if (Array.isArray(child))
return child.map(grandChild => __pwCreateChild(grandChild));
if (isComponent(child))
if (isJsxComponent(child) || isObjectComponent(child))
return __pwCreateWrapper(child);
return child;
}
@ -132,14 +98,23 @@ function __pwSlotToFunction(slot) {
throw Error(`Invalid slot received.`);
}
/**
* @param {JsxComponent} component
* @returns {any[] | undefined}
*/
function __pwJsxChildArray(component) {
if (!component.props.children)
return;
if (Array.isArray(component.props.children))
return component.props.children;
return [component.props.children];
}
/**
* @param {Component} component
*/
function __pwCreateComponent(component) {
let componentFunc = __pwRegistry.get(component.type);
componentFunc = componentFunc || component.type;
const isVueComponent = componentFunc !== component.type;
const isVueComponent = typeof component.type !== 'string';
/**
* @type {(import('vue').VNode | string)[]}
@ -151,12 +126,12 @@ function __pwCreateComponent(component) {
/** @type {{[key: string]: any}} */
let props = {};
if (component.kind === 'jsx') {
for (const child of component.children || []) {
if (typeof child !== 'string' && child.type === 'template' && child.kind === 'jsx') {
if (component.__pw_type === 'jsx') {
for (const child of __pwJsxChildArray(component) || []) {
if (isJsxComponent(child) && child.type === 'template') {
const slotProperty = Object.keys(child.props).find(k => k.startsWith('v-slot:'));
const slot = slotProperty ? slotProperty.substring('v-slot:'.length) : 'default';
slots[slot] = child.children?.map(__pwCreateChild);
slots[slot] = __pwJsxChildArray(child)?.map(__pwCreateChild);
} else {
children.push(__pwCreateChild(child));
}
@ -175,16 +150,16 @@ function __pwCreateComponent(component) {
}
}
if (component.kind === 'object') {
if (component.__pw_type === 'object-component') {
// Vue test util syntax.
for (const [key, value] of Object.entries(component.options?.slots || {})) {
for (const [key, value] of Object.entries(component.slots || {})) {
if (key === 'default')
children.push(__pwSlotToFunction(value));
else
slots[key] = __pwSlotToFunction(value);
}
props = component.options?.props || {};
for (const [key, value] of Object.entries(component.options?.on || {}))
props = component.props || {};
for (const [key, value] of Object.entries(component.on || {}))
listeners[key] = value;
}
@ -197,7 +172,7 @@ function __pwCreateComponent(component) {
lastArg = children;
}
return { Component: componentFunc, props, slots: lastArg, listeners };
return { Component: component.type, props, slots: lastArg, listeners };
}
function __pwWrapFunctions(slots) {
@ -248,7 +223,6 @@ const __pwAppKey = Symbol('appKey');
const __pwWrapperKey = Symbol('wrapperKey');
window.playwrightMount = async (component, rootElement, hooksConfig) => {
await __pwResolveComponent(component);
const app = __pwCreateApp({
render: () => {
const wrapper = __pwCreateWrapper(component);
@ -275,7 +249,6 @@ window.playwrightUnmount = async rootElement => {
};
window.playwrightUpdate = async (rootElement, component) => {
await __pwResolveComponent(component);
const wrapper = rootElement[__pwWrapperKey];
if (!wrapper)
throw new Error('Component was not mounted');

View file

@ -1,6 +1,6 @@
{
"name": "@playwright/experimental-ct-vue2",
"version": "1.41.0-next",
"version": "1.41.2",
"description": "Playwright Component Testing for Vue2",
"repository": {
"type": "git",
@ -29,7 +29,7 @@
}
},
"dependencies": {
"@playwright/experimental-ct-core": "1.41.0-next",
"@playwright/experimental-ct-core": "1.41.2",
"@vitejs/plugin-vue2": "^2.2.0"
},
"devDependencies": {

View file

@ -21,67 +21,33 @@
import __pwVue, { h as __pwH } from 'vue';
/** @typedef {import('../playwright-ct-core/types/component').Component} Component */
/** @typedef {import('../playwright-ct-core/types/component').JsxComponentChild} JsxComponentChild */
/** @typedef {import('../playwright-ct-core/types/component').JsxComponent} JsxComponent */
/** @typedef {import('../playwright-ct-core/types/component').ObjectComponent} ObjectComponent */
/** @typedef {import('vue').Component} FrameworkComponent */
/** @type {Map<string, () => Promise<FrameworkComponent>>} */
const __pwLoaderRegistry = new Map();
/** @type {Map<string, FrameworkComponent>} */
const __pwRegistry = new Map();
/**
* @param {{[key: string]: () => Promise<FrameworkComponent>}} components
* @param {any} component
* @returns {component is ObjectComponent}
*/
export function pwRegister(components) {
for (const [name, value] of Object.entries(components))
__pwLoaderRegistry.set(name, value);
function isObjectComponent(component) {
return typeof component === 'object' && component && component.__pw_type === 'object-component';
}
/**
* @param {any} component
* @returns {component is Component}
* @returns {component is JsxComponent}
*/
function isComponent(component) {
return !(typeof component !== 'object' || Array.isArray(component));
function isJsxComponent(component) {
return typeof component === 'object' && component && component.__pw_type === 'jsx';
}
/**
* @param {Component | JsxComponentChild} component
*/
async function __pwResolveComponent(component) {
if (!isComponent(component))
return;
let componentFactory = __pwLoaderRegistry.get(component.type);
if (!componentFactory) {
// Lookup by shorthand.
for (const [name, value] of __pwLoaderRegistry) {
if (component.type.endsWith(`_${name}_vue`)) {
componentFactory = value;
break;
}
}
}
if (!componentFactory && component.type[0].toUpperCase() === component.type[0])
throw new Error(`Unregistered component: ${component.type}. Following components are registered: ${[...__pwRegistry.keys()]}`);
if (componentFactory)
__pwRegistry.set(component.type, await componentFactory());
if ('children' in component && component.children?.length)
await Promise.all(component.children.map(child => __pwResolveComponent(child)));
}
/**
* @param {Component | JsxComponentChild} child
* @param {any} child
*/
function __pwCreateChild(child) {
if (Array.isArray(child))
return child.map(grandChild => __pwCreateChild(grandChild));
if (isComponent(child))
if (isJsxComponent(child) || isObjectComponent(child))
return __pwCreateWrapper(child);
return child;
}
@ -94,18 +60,26 @@ function __pwCreateChild(child) {
* @return {boolean}
*/
function __pwComponentHasKeyInProps(Component, key) {
if (Array.isArray(Component.props))
return Component.props.includes(key);
return typeof Component.props === 'object' && Component.props && key in Component.props;
}
return Object.entries(Component.props).flat().includes(key);
/**
* @param {JsxComponent} component
* @returns {any[] | undefined}
*/
function __pwJsxChildArray(component) {
if (!component.props.children)
return;
if (Array.isArray(component.props.children))
return component.props.children;
return [component.props.children];
}
/**
* @param {Component} component
*/
function __pwCreateComponent(component) {
const componentFunc = __pwRegistry.get(component.type) || component.type;
const isVueComponent = componentFunc !== component.type;
const isVueComponent = typeof component.type !== 'string';
/**
* @type {(import('vue').VNode | string)[]}
@ -119,12 +93,12 @@ function __pwCreateComponent(component) {
nodeData.scopedSlots = {};
nodeData.on = {};
if (component.kind === 'jsx') {
for (const child of component.children || []) {
if (typeof child !== 'string' && child.type === 'template' && child.kind === 'jsx') {
if (component.__pw_type === 'jsx') {
for (const child of __pwJsxChildArray(component) || []) {
if (isJsxComponent(child) && child.type === 'template') {
const slotProperty = Object.keys(child.props).find(k => k.startsWith('v-slot:'));
const slot = slotProperty ? slotProperty.substring('v-slot:'.length) : 'default';
nodeData.scopedSlots[slot] = () => child.children?.map(c => __pwCreateChild(c));
nodeData.scopedSlots[slot] = () => __pwJsxChildArray(child)?.map(c => __pwCreateChild(c));
} else {
children.push(__pwCreateChild(child));
}
@ -135,7 +109,7 @@ function __pwCreateComponent(component) {
const event = key.substring('v-on:'.length);
nodeData.on[event] = value;
} else {
if (isVueComponent && __pwComponentHasKeyInProps(componentFunc, key))
if (isVueComponent && __pwComponentHasKeyInProps(component.type, key))
nodeData.props[key] = value;
else
nodeData.attrs[key] = value;
@ -143,18 +117,17 @@ function __pwCreateComponent(component) {
}
}
if (component.kind === 'object') {
if (component.__pw_type === 'object-component') {
// Vue test util syntax.
const options = component.options || {};
for (const [key, value] of Object.entries(options.slots || {})) {
for (const [key, value] of Object.entries(component.slots || {})) {
const list = (Array.isArray(value) ? value : [value]).map(v => __pwCreateChild(v));
if (key === 'default')
children.push(...list);
else
nodeData.scopedSlots[key] = () => list;
}
nodeData.props = options.props || {};
for (const [key, value] of Object.entries(options.on || {}))
nodeData.props = component.props || {};
for (const [key, value] of Object.entries(component.on || {}))
nodeData.on[key] = value;
}
@ -167,7 +140,7 @@ function __pwCreateComponent(component) {
lastArg = children;
}
return { Component: componentFunc, nodeData, slots: lastArg };
return { Component: component.type, nodeData, slots: lastArg };
}
/**
@ -184,7 +157,6 @@ const instanceKey = Symbol('instanceKey');
const wrapperKey = Symbol('wrapperKey');
window.playwrightMount = async (component, rootElement, hooksConfig) => {
await __pwResolveComponent(component);
let options = {};
for (const hook of window.__pw_hooks_before_mount || [])
options = await hook({ hooksConfig, Vue: __pwVue });
@ -213,7 +185,6 @@ window.playwrightUnmount = async rootElement => {
};
window.playwrightUpdate = async (element, options) => {
await __pwResolveComponent(options);
const wrapper = /** @type {any} */(element)[wrapperKey];
if (!wrapper)
throw new Error('Component was not mounted');

View file

@ -1,6 +1,6 @@
{
"name": "playwright-firefox",
"version": "1.41.0-next",
"version": "1.41.2",
"description": "A high-level API to automate Firefox",
"repository": {
"type": "git",
@ -30,6 +30,6 @@
"install": "node install.js"
},
"dependencies": {
"playwright-core": "1.41.0-next"
"playwright-core": "1.41.2"
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@playwright/test",
"version": "1.41.0-next",
"version": "1.41.2",
"description": "A high-level API to automate web browsers",
"repository": {
"type": "git",
@ -30,6 +30,6 @@
},
"scripts": {},
"dependencies": {
"playwright": "1.41.0-next"
"playwright": "1.41.2"
}
}

View file

@ -1,6 +1,6 @@
{
"name": "playwright-webkit",
"version": "1.41.0-next",
"version": "1.41.2",
"description": "A high-level API to automate WebKit",
"repository": {
"type": "git",
@ -30,6 +30,6 @@
"install": "node install.js"
},
"dependencies": {
"playwright-core": "1.41.0-next"
"playwright-core": "1.41.2"
}
}

View file

@ -66,6 +66,7 @@ function babelTransformOptions(isTypeScript: boolean, isModule: boolean, plugins
// Support JSX/TSX at all times, regardless of the file extension.
plugins.push([require('@babel/plugin-transform-react-jsx'), {
throwIfNamespace: false,
runtime: 'automatic',
importSource: path.dirname(require.resolve('playwright')),
}]);

View file

@ -16,6 +16,7 @@
function jsx(type, props) {
return {
__pw_type: 'jsx',
type,
props,
};
@ -23,6 +24,7 @@ function jsx(type, props) {
function jsxs(type, props) {
return {
__pw_type: 'jsx',
type,
props,
};

View file

@ -1,6 +1,6 @@
{
"name": "playwright",
"version": "1.41.0-next",
"version": "1.41.2",
"description": "A high-level API to automate web browsers",
"repository": {
"type": "git",
@ -24,6 +24,7 @@
"./lib/transform/babelBundle": "./lib/transform/babelBundle.js",
"./lib/transform/compilationCache": "./lib/transform/compilationCache.js",
"./lib/transform/esmLoader": "./lib/transform/esmLoader.js",
"./lib/transform/transform": "./lib/transform/transform.js",
"./lib/internalsForTest": "./lib/internalsForTest.js",
"./lib/plugins": "./lib/plugins/index.js",
"./jsx-runtime": {
@ -54,7 +55,7 @@
},
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.41.0-next"
"playwright-core": "1.41.2"
},
"optionalDependencies": {
"fsevents": "2.3.2"

View file

@ -23,9 +23,17 @@ import { isWorkerProcess } from '../common/globals';
export type MemoryCache = {
codePath: string;
sourceMapPath: string;
dataPath: string;
moduleUrl?: string;
};
type SerializedCompilationCache = {
sourceMaps: [string, string][],
memoryCache: [string, MemoryCache][],
fileDependencies: [string, string[]][],
externalDependencies: [string, string[]][],
};
// Assumptions for the compilation cache:
// - Files in the temp directory we work with can disappear at any moment, either some of them or all together.
// - Multiple workers can be trying to read from the compilation cache at the same time.
@ -84,12 +92,12 @@ export function installSourceMapSupportIfNeeded() {
});
}
function _innerAddToCompilationCache(filename: string, options: { codePath: string, sourceMapPath: string, moduleUrl?: string }) {
sourceMaps.set(options.moduleUrl || filename, options.sourceMapPath);
memoryCache.set(filename, options);
function _innerAddToCompilationCache(filename: string, entry: MemoryCache) {
sourceMaps.set(entry.moduleUrl || filename, entry.sourceMapPath);
memoryCache.set(filename, entry);
}
export function getFromCompilationCache(filename: string, hash: string, moduleUrl?: string): { cachedCode?: string, addToCache?: (code: string, map?: any) => void } {
export function getFromCompilationCache(filename: string, hash: string, moduleUrl?: string): { cachedCode?: string, addToCache?: (code: string, map: any | undefined | null, data: Map<string, any>) => void } {
// First check the memory cache by filename, this cache will always work in the worker,
// because we just compiled this file in the loader.
const cache = memoryCache.get(filename);
@ -105,27 +113,30 @@ export function getFromCompilationCache(filename: string, hash: string, moduleUr
const cachePath = calculateCachePath(filename, hash);
const codePath = cachePath + '.js';
const sourceMapPath = cachePath + '.map';
const dataPath = cachePath + '.data';
try {
const cachedCode = fs.readFileSync(codePath, 'utf8');
_innerAddToCompilationCache(filename, { codePath, sourceMapPath, moduleUrl });
_innerAddToCompilationCache(filename, { codePath, sourceMapPath, dataPath, moduleUrl });
return { cachedCode };
} catch {
}
return {
addToCache: (code: string, map: any) => {
addToCache: (code: string, map: any | undefined | null, data: Map<string, any>) => {
if (isWorkerProcess())
return;
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
if (map)
fs.writeFileSync(sourceMapPath, JSON.stringify(map), 'utf8');
if (data.size)
fs.writeFileSync(dataPath, JSON.stringify(Object.fromEntries(data.entries()), undefined, 2), 'utf8');
fs.writeFileSync(codePath, code, 'utf8');
_innerAddToCompilationCache(filename, { codePath, sourceMapPath, moduleUrl });
_innerAddToCompilationCache(filename, { codePath, sourceMapPath, dataPath, moduleUrl });
}
};
}
export function serializeCompilationCache(): any {
export function serializeCompilationCache(): SerializedCompilationCache {
return {
sourceMaps: [...sourceMaps.entries()],
memoryCache: [...memoryCache.entries()],
@ -200,6 +211,10 @@ export function collectAffectedTestFiles(dependency: string, testFileCollector:
}
}
export function internalDependenciesForTestFile(filename: string): Set<string> | undefined{
return fileDependencies.get(filename);
}
export function dependenciesForTestFile(filename: string): Set<string> {
const result = new Set<string>();
for (const dep of fileDependencies.get(filename) || [])
@ -224,3 +239,17 @@ export function belongsToNodeModules(file: string) {
return true;
return false;
}
export async function getUserData(pluginName: string): Promise<Map<string, any>> {
const result = new Map<string, any>();
for (const [fileName, cache] of memoryCache) {
if (!cache.dataPath)
continue;
if (!fs.existsSync(cache.dataPath))
continue;
const data = JSON.parse(await fs.promises.readFile(cache.dataPath, 'utf8'));
if (data[pluginName])
result.set(fileName, data[pluginName]);
}
return result;
}

View file

@ -16,8 +16,8 @@
import crypto from 'crypto';
import path from 'path';
import { sourceMapSupport, pirates } from '../utilsBundle';
import url from 'url';
import { sourceMapSupport, pirates } from '../utilsBundle';
import type { Location } from '../../types/testReporter';
import type { TsConfigLoaderResult } from '../third_party/tsconfig-loader';
import { tsConfigLoader } from '../third_party/tsconfig-loader';
@ -159,6 +159,12 @@ export function shouldTransform(filename: string): boolean {
return !belongsToNodeModules(filename);
}
let transformData: Map<string, any>;
export function setTransformData(pluginName: string, value: any) {
transformData.set(pluginName, value);
}
export function transformHook(originalCode: string, filename: string, moduleUrl?: string): string {
const isTypeScript = filename.endsWith('.ts') || filename.endsWith('.tsx') || filename.endsWith('.mts') || filename.endsWith('.cts');
const hasPreprocessor =
@ -177,9 +183,10 @@ export function transformHook(originalCode: string, filename: string, moduleUrl?
process.env.BROWSERSLIST_IGNORE_OLD_DATA = 'true';
const { babelTransform }: { babelTransform: BabelTransformFunction } = require('./babelBundle');
transformData = new Map<string, any>();
const { code, map } = babelTransform(originalCode, filename, isTypeScript, !!moduleUrl, pluginsPrologue, pluginsEpilogue);
if (code)
addToCache!(code, map);
addToCache!(code, map, transformData);
return code || '';
}

View file

@ -32,7 +32,7 @@ export class ZipTraceModelBackend implements TraceModelBackend {
this._traceURL = traceURL;
zipjs.configure({ baseURL: self.location.href } as any);
this._zipReader = new zipjs.ZipReader(
new zipjs.HttpReader(formatUrl(traceURL), { mode: 'cors', credentials: 'include', preventHeadRequest: true } as any),
new zipjs.HttpReader(formatUrl(traceURL), { mode: 'cors', preventHeadRequest: true } as any),
{ useWebWorkers: false });
this._entriesPromise = this._zipReader.getEntries({ onprogress: progress }).then(entries => {
const map = new Map<string, zip.Entry>();

View file

@ -76,3 +76,4 @@ test('drag resize', async ({ page, mount }) => {
expect.soft(mainBox).toEqual({ x: 0, y: 0, width: 500, height: 100 });
expect.soft(sidebarBox).toEqual({ x: 0, y: 101, width: 500, height: 399 });
});

View file

@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "ES2015",
"lib": [
"dom",
"dom.iterable",

View file

@ -746,32 +746,4 @@ await page.GetByText("Click me").ClickAsync(new LocatorClickOptions
Button = MouseButton.Middle,
});`);
});
test('should record slider', async ({ page, openRecorder }) => {
const recorder = await openRecorder();
await recorder.setContentAndWait(`<input type="range" min="0" max="10" value="5">`);
const dragSlider = async () => {
await page.locator('input').focus();
const { x, y, width, height } = await page.locator('input').boundingBox();
await page.mouse.move(x + width / 2, y + height / 2);
await page.mouse.down();
await page.mouse.move(x + width, y + height / 2);
await page.mouse.up();
};
const [sources] = await Promise.all([
recorder.waitForOutput('JavaScript', 'fill'),
dragSlider(),
]);
await expect(page.locator('input')).toHaveValue('10');
expect(sources.get('JavaScript')!.text).toContain(`
await page.getByRole('slider').fill('10');`);
expect(sources.get('JavaScript')!.text).not.toContain(`
await page.getByRole('slider').click();`);
});
});

View file

@ -523,11 +523,13 @@ test('should load jsx with top-level component', async ({ runInlineTest }) => {
const component = <div>Hello <span>world</span></div>;
test('succeeds', () => {
expect(component).toEqual({
__pw_type: 'jsx',
type: 'div',
props: {
children: [
'Hello ',
{
__pw_type: 'jsx',
type: 'span',
props: {
children: 'world'

View file

@ -76,6 +76,8 @@ export async function writeFiles(testInfo: TestInfo, files: Files, initial: bool
await Promise.all(Object.keys(files).map(async name => {
const fullName = path.join(baseDir, name);
if (files[name] === undefined)
return;
await fs.promises.mkdir(path.dirname(fullName), { recursive: true });
await fs.promises.writeFile(fullName, files[name]);
}));

View file

@ -44,7 +44,7 @@ test('should work with the empty component list', async ({ runInlineTest }, test
const metainfo = JSON.parse(fs.readFileSync(testInfo.outputPath('playwright/.cache/metainfo.json'), 'utf-8'));
expect(metainfo.version).toEqual(require('playwright-core/package.json').version);
expect(metainfo.viteVersion).toEqual(require('vite/package.json').version);
expect(Object.entries(metainfo.tests)).toHaveLength(1);
expect(Object.entries(metainfo.deps)).toHaveLength(0);
expect(Object.entries(metainfo.sources)).toHaveLength(9);
});
@ -135,102 +135,61 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => {
});
expect(metainfo.components).toEqual([{
fullName: expect.stringContaining('playwright_test_src_button_tsx_Button'),
importedName: 'Button',
importedNameProperty: '',
id: expect.stringContaining('playwright_test_src_button_tsx_Button'),
remoteName: 'Button',
importPath: expect.stringContaining('button.tsx'),
isModuleOrAlias: false,
deps: [
expect.stringContaining('button.tsx'),
expect.stringContaining('jsx-runtime.js'),
]
}, {
fullName: expect.stringContaining('playwright_test_src_clashingNames1_tsx_ClashingName'),
importedName: 'ClashingName',
importedNameProperty: '',
id: expect.stringContaining('playwright_test_src_clashingNames1_tsx_ClashingName'),
remoteName: 'ClashingName',
importPath: expect.stringContaining('clashingNames1.tsx'),
isModuleOrAlias: false,
deps: [
expect.stringContaining('clashingNames1.tsx'),
expect.stringContaining('jsx-runtime.js'),
]
}, {
fullName: expect.stringContaining('playwright_test_src_clashingNames2_tsx_ClashingName'),
importedName: 'ClashingName',
importedNameProperty: '',
id: expect.stringContaining('playwright_test_src_clashingNames2_tsx_ClashingName'),
remoteName: 'ClashingName',
importPath: expect.stringContaining('clashingNames2.tsx'),
isModuleOrAlias: false,
deps: [
expect.stringContaining('clashingNames2.tsx'),
expect.stringContaining('jsx-runtime.js'),
]
}, {
fullName: expect.stringContaining('playwright_test_src_components_tsx_Component1'),
importedName: 'Component1',
importedNameProperty: '',
id: expect.stringContaining('playwright_test_src_components_tsx_Component1'),
remoteName: 'Component1',
importPath: expect.stringContaining('components.tsx'),
isModuleOrAlias: false,
deps: [
expect.stringContaining('components.tsx'),
expect.stringContaining('jsx-runtime.js'),
]
}, {
fullName: expect.stringContaining('playwright_test_src_components_tsx_Component2'),
importedName: 'Component2',
importedNameProperty: '',
id: expect.stringContaining('playwright_test_src_components_tsx_Component2'),
remoteName: 'Component2',
importPath: expect.stringContaining('components.tsx'),
isModuleOrAlias: false,
deps: [
expect.stringContaining('components.tsx'),
expect.stringContaining('jsx-runtime.js'),
]
}, {
fullName: expect.stringContaining('playwright_test_src_defaultExport_tsx'),
id: expect.stringContaining('playwright_test_src_defaultExport_tsx'),
importPath: expect.stringContaining('defaultExport.tsx'),
importedNameProperty: '',
isModuleOrAlias: false,
deps: [
expect.stringContaining('defaultExport.tsx'),
expect.stringContaining('jsx-runtime.js'),
]
}]);
for (const [file, test] of Object.entries(metainfo.tests)) {
if (file.endsWith('clashing-imports.spec.tsx')) {
expect(test).toEqual({
timestamp: expect.any(Number),
components: [
expect.stringContaining('clashingNames1_tsx_ClashingName'),
expect.stringContaining('clashingNames2_tsx_ClashingName'),
],
});
}
if (file.endsWith('default-import.spec.tsx')) {
expect(test).toEqual({
timestamp: expect.any(Number),
components: [
expect.stringContaining('defaultExport_tsx'),
],
});
}
if (file.endsWith('named-imports.spec.tsx')) {
expect(test).toEqual({
timestamp: expect.any(Number),
components: [
expect.stringContaining('components_tsx_Component1'),
expect.stringContaining('components_tsx_Component2'),
],
});
}
if (file.endsWith('one-import.spec.tsx')) {
expect(test).toEqual({
timestamp: expect.any(Number),
components: [
expect.stringContaining('button_tsx_Button'),
],
});
}
}
for (const [, value] of Object.entries(metainfo.deps))
(value as string[]).sort();
expect(Object.entries(metainfo.deps)).toEqual([
[expect.stringContaining('clashingNames1.tsx'), [
expect.stringContaining('jsx-runtime.js'),
expect.stringContaining('clashingNames1.tsx'),
]],
[expect.stringContaining('clashingNames2.tsx'), [
expect.stringContaining('jsx-runtime.js'),
expect.stringContaining('clashingNames2.tsx'),
]],
[expect.stringContaining('defaultExport.tsx'), [
expect.stringContaining('jsx-runtime.js'),
expect.stringContaining('defaultExport.tsx'),
]],
[expect.stringContaining('components.tsx'), [
expect.stringContaining('jsx-runtime.js'),
expect.stringContaining('components.tsx'),
]],
[expect.stringContaining('button.tsx'), [
expect.stringContaining('jsx-runtime.js'),
expect.stringContaining('button.tsx'),
]],
]);
});
test('should cache build', async ({ runInlineTest }, testInfo) => {
@ -497,26 +456,160 @@ test('should retain deps when test changes', async ({ runInlineTest }, testInfo)
const metainfo = JSON.parse(fs.readFileSync(testInfo.outputPath('playwright/.cache/metainfo.json'), 'utf-8'));
expect(metainfo.components).toEqual([{
fullName: expect.stringContaining('playwright_test_src_button_tsx_Button'),
importedName: 'Button',
importedNameProperty: '',
id: expect.stringContaining('playwright_test_src_button_tsx_Button'),
remoteName: 'Button',
importPath: expect.stringContaining('button.tsx'),
isModuleOrAlias: false,
deps: [
expect.stringContaining('button.tsx'),
expect.stringContaining('jsx-runtime.js'),
]
}]);
expect(Object.entries(metainfo.tests)).toEqual([
for (const [, value] of Object.entries(metainfo.deps))
(value as string[]).sort();
expect(Object.entries(metainfo.deps)).toEqual([
[
expect.stringContaining('button.test.tsx'),
{
components: [
expect.stringContaining('src_button_tsx_Button'),
expect.stringContaining('button.tsx'),
[
expect.stringContaining('jsx-runtime.js'),
expect.stringContaining('button.tsx'),
],
timestamp: expect.any(Number)
}
]
]);
});
test('should render component via re-export', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'playwright.config.ts': playwrightConfig,
'playwright/index.html': `<script type="module" src="./index.ts"></script>`,
'playwright/index.ts': ``,
'src/button.tsx': `
export const Button = () => <button>Button</button>;
`,
'src/buttonHelper.ts': `
import { Button } from './button.tsx';
export { Button };
`,
'src/button.test.tsx': `
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from './buttonHelper';
test('pass', async ({ mount }) => {
const component = await mount(<Button></Button>);
await expect(component).toHaveText('Button');
});
`,
}, { workers: 1 });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});
test('should render component exported via fixture', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'playwright.config.ts': playwrightConfig,
'playwright/index.html': `<script type="module" src="./index.ts"></script>`,
'playwright/index.ts': ``,
'src/button.tsx': `
export const Button = () => <button>Button</button>;
`,
'src/buttonFixture.tsx': `
import { Button } from './button';
import { test as baseTest } from '@playwright/experimental-ct-react';
export { expect } from '@playwright/experimental-ct-react';
export const test = baseTest.extend({
button: async ({ mount }, use) => {
await use(await mount(<Button></Button>));
}
});
`,
'src/button.test.tsx': `
import { test, expect } from './buttonFixture';
test('pass', async ({ button }) => {
await expect(button).toHaveText('Button');
});
`,
}, { workers: 1 });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});
test('should pass imported images from test to component', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'playwright.config.ts': playwrightConfig,
'playwright/index.html': `<script type="module" src="./index.ts"></script>`,
'playwright/index.ts': ``,
'src/image.png': Buffer.from('iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAQAAAD9CzEMAAACMElEQVRYw+1XT0tCQRD/9Qci0Cw7mp1C6BMYnt5niMhPEEFCh07evNk54XnuGkhFehA/QxHkqYMEFWXpscMTipri7fqeu+vbfY+EoBkQ3Zn5zTo7MzsL/NNfoClkUUQNN3jCJ/ETfavRSpYkkSmFQzz8wMr4gaSp8OBJ2HCU4Iwd0kqGgd9GPxCccZ+0jWgWVW1wxlWy0qR51I3hv7lOllq7b4SC/+aGzr+QBadjEKgAykvzJGXwr/Lj4JfRk5hUSLKIa00HPUJRki0xeMWSWxVXmi5sddXKymqTyxdwquXAUVV3WREeLx3gTcNFWQY/jXtB8QIzgt4qTvAR4OCe0ATKCmrnmFMEM0Pp2BvrIisaFUdUjgKKZgYWSjjDLR5J+x13lATHuHSti6JBzQP+gq2QHXjfRaiJojbPgYqbmGFow0VpiyIW0/VIF9QKLzeBWA2MHmwCu8QJQV++Ps/joHQQH4HpuO0uobUeVztgIcr4Vnf4we9orWfUIWKHbEVyYKkPmaVpIVKICuo0ZYXWjHTITXWhsVYxkIDpUoKsla1i2Oz2QjvYG9fshu36GbFQ8DGyHNOuvRdOKZSDUtCFM7wyHeSM4XN8e7bOpd9F2gg+TRYal753bGkbuEjzMg0YW/yDV1czUDm+e43Byz86OnRwsYDMKXlmkYbeAOwffrtU/nGpXpwkXfPhVza+D9AiMAtrtOMYfVr0q8Wr1nh8n8ADZCJPqAk8AifyjP2n36cvkA6/Wln9MokAAAAASUVORK5CYII=', 'base64'),
'src/image.test.tsx': `
import { test, expect } from '@playwright/experimental-ct-react';
import imageSrc from './image.png';
test('pass', async ({ mount }) => {
const component = await mount(<img src={imageSrc}></img>);
await expect(component).toHaveJSProperty('naturalWidth', 48);
});
`,
}, { workers: 1 });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});
test('should pass dates, regex, urls and bigints', async ({ runInlineTest }) => {
const result = await runInlineTest({
'playwright.config.ts': playwrightConfig,
'playwright/index.html': `<script type="module" src="./index.ts"></script>`,
'playwright/index.ts': ``,
'src/button.tsx': `
export const Button = ({ props }: any) => {
const { date, url, bigint, regex } = props;
const types = [
date instanceof Date,
url instanceof URL,
typeof bigint === 'bigint',
regex instanceof RegExp,
];
return <div>{types.join(' ')}</div>;
};
`,
'src/component.spec.tsx': `
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from './button';
test('renders props with builtin types', async ({ mount, page }) => {
const component = await mount(<Button props={{
date: new Date(),
url: new URL('https://example.com'),
bigint: BigInt(42),
regex: /foo/,
}} />);
await expect(component).toHaveText('true true true true');
});
`,
}, { workers: 1 });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});
test('should pass undefined value as param', async ({ runInlineTest }) => {
const result = await runInlineTest({
'playwright.config.ts': playwrightConfig,
'playwright/index.html': `<script type="module" src="./index.ts"></script>`,
'playwright/index.ts': ``,
'src/component.tsx': `
export const Component = ({ value }: { value?: number }) => {
return <div>{typeof value}</div>;
};
`,
'src/component.spec.tsx': `
import { test, expect } from '@playwright/experimental-ct-react';
import { Component } from './component';
test('renders props with undefined type', async ({ mount, page }) => {
const component = await mount(<Component value={undefined} />);
await expect(component).toHaveText('undefined');
});
`,
}, { workers: 1 });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});

View file

@ -196,7 +196,7 @@ test('should work with stray JSX import', async ({ runInlineTest }) => {
expect(result.passed).toBe(1);
});
test.fixme('should work with stray JS import', async ({ runInlineTest }) => {
test('should work with stray JS import', async ({ runInlineTest }) => {
const result = await runInlineTest({
'playwright.config.ts': playwrightConfig,
'playwright/index.html': `<script type="module" src="./index.js"></script>`,
@ -481,7 +481,7 @@ test('should normalize children', async ({ runInlineTest }) => {
import { OneChild, OtherComponent } from './component';
test("can pass an HTML element to OneChild", async ({ mount }) => {
const component = await mount(<OneChild><p>child</p> </OneChild>);
const component = await mount(<OneChild><p>child</p></OneChild>);
await expect(component).toHaveText("child");
});
@ -495,3 +495,23 @@ test('should normalize children', async ({ runInlineTest }) => {
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(2);
});
test('should allow props children', async ({ runInlineTest }) => {
const result = await runInlineTest({
'playwright.config.ts': playwrightConfig,
'playwright/index.html': `<script type="module" src="./index.ts"></script>`,
'playwright/index.ts': ``,
'src/component.spec.tsx': `
import { test, expect } from '@playwright/experimental-ct-react';
test("renders children from props object", async ({ mount, page }) => {
const props = { children: 'test' };
await mount(<button {...props} />);
await expect(page.getByText('test')).toBeVisible();
});
`,
}, { workers: 1 });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});

View file

@ -207,3 +207,81 @@ test('should watch component', async ({ runUITest, writeFiles }) => {
pass <=
`);
});
test('should watch component via util', async ({ runUITest, writeFiles }) => {
const { page } = await runUITest({
...basicTestTree,
'src/button.tsx': undefined,
'src/button.ts': `
import { Button } from './buttonComponent';
export { Button };
`,
'src/buttonComponent.tsx': `
export const Button = () => <button>Button</button>;
`,
});
await expect.poll(dumpTestTree(page)).toBe(`
button.test.tsx
pass
`);
await page.getByTitle('Watch all').click();
await page.getByTitle('Run all').click();
await expect.poll(dumpTestTree(page)).toBe(`
button.test.tsx
pass
`);
await writeFiles({
'src/buttonComponent.tsx': `
export const Button = () => <button>Button2</button>;
`
});
await expect.poll(dumpTestTree(page)).toBe(`
button.test.tsx
pass <=
`);
});
test('should watch component when editing util', async ({ runUITest, writeFiles }) => {
const { page } = await runUITest({
...basicTestTree,
'src/button.tsx': undefined,
'src/button.ts': `
import { Button } from './buttonComponent';
export { Button };
`,
'src/buttonComponent.tsx': `
export const Button = () => <button>Button</button>;
`,
'src/buttonComponent2.tsx': `
export const Button = () => <button>Button2</button>;
`,
});
await expect.poll(dumpTestTree(page)).toBe(`
button.test.tsx
pass
`);
await page.getByTitle('Watch all').click();
await page.getByTitle('Run all').click();
await expect.poll(dumpTestTree(page)).toBe(`
button.test.tsx
pass
`);
await writeFiles({
'src/button.ts': `
import { Button } from './buttonComponent2';
export { Button };
`,
});
await expect.poll(dumpTestTree(page)).toBe(`
button.test.tsx
pass <=
`);
});

View file

@ -304,6 +304,7 @@ steps.push({
onChanges.push({
inputs: [
'packages/playwright-core/src/server/injected/**',
'packages/playwright-ct-core/src/injected/**',
'packages/playwright-core/src/utils/isomorphic/**',
'utils/generate_injected.js',
],

View file

@ -22,11 +22,40 @@ const path = require('path');
const ROOT = path.join(__dirname, '..');
const esbuild = require('esbuild');
/**
* @type {[string, string, string, boolean][]}
*/
const injectedScripts = [
[
path.join(ROOT, 'packages', 'playwright-core', 'src', 'server', 'injected', 'utilityScript.ts'),
path.join(ROOT, 'packages', 'playwright-core', 'lib', 'server', 'injected', 'packed'),
path.join(ROOT, 'packages', 'playwright-core', 'src', 'generated'),
true,
],
[
path.join(ROOT, 'packages', 'playwright-core', 'src', 'server', 'injected', 'injectedScript.ts'),
path.join(ROOT, 'packages', 'playwright-core', 'lib', 'server', 'injected', 'packed'),
path.join(ROOT, 'packages', 'playwright-core', 'src', 'generated'),
true,
],
[
path.join(ROOT, 'packages', 'playwright-core', 'src', 'server', 'injected', 'consoleApi.ts'),
path.join(ROOT, 'packages', 'playwright-core', 'lib', 'server', 'injected', 'packed'),
path.join(ROOT, 'packages', 'playwright-core', 'src', 'generated'),
true,
],
[
path.join(ROOT, 'packages', 'playwright-core', 'src', 'server', 'injected', 'recorder.ts'),
path.join(ROOT, 'packages', 'playwright-core', 'lib', 'server', 'injected', 'packed'),
path.join(ROOT, 'packages', 'playwright-core', 'src', 'generated'),
true,
],
[
path.join(ROOT, 'packages', 'playwright-ct-core', 'src', 'injected', 'index.ts'),
path.join(ROOT, 'packages', 'playwright-ct-core', 'lib', 'injected', 'packed'),
path.join(ROOT, 'packages', 'playwright-ct-core', 'src', 'generated'),
false,
]
];
const modulePrefix = `
@ -79,10 +108,8 @@ const inlineCSSPlugin = {
};
(async () => {
const generatedFolder = path.join(ROOT, 'packages', 'playwright-core', 'src', 'generated');
for (const [injected, outdir, generatedFolder, hasExports] of injectedScripts) {
await fs.promises.mkdir(generatedFolder, { recursive: true });
for (const injected of injectedScripts) {
const outdir = path.join(ROOT, 'packages', 'playwright-core', 'lib', 'server', 'injected', 'packed');
const buildOutput = await esbuild.build({
entryPoints: [injected],
bundle: true,
@ -97,6 +124,7 @@ const inlineCSSPlugin = {
const baseName = path.basename(injected);
const outFileJs = path.join(outdir, baseName.replace('.ts', '.js'));
let content = await fs.promises.readFile(outFileJs, 'utf-8');
if (hasExports)
content = await replaceEsbuildHeader(content, outFileJs);
const newContent = `export const source = ${JSON.stringify(content)};`;
await fs.promises.writeFile(path.join(generatedFolder, baseName.replace('.ts', 'Source.ts')), newContent);