Merge branch 'main' into refactor-all-pages
This commit is contained in:
commit
e03cb28583
|
|
@ -64,6 +64,25 @@ Methods like [`method: APIRequestContext.get`] take the base URL into considerat
|
|||
- `localStorage` <[Array]<[Object]>>
|
||||
- `name` <[string]>
|
||||
- `value` <[string]>
|
||||
- `indexedDB` ?<[Array]<[Object]>> indexedDB to set for context
|
||||
- `name` <[string]> database name
|
||||
- `version` <[int]> database version
|
||||
- `stores` <[Array]<[Object]>>
|
||||
- `name` <[string]>
|
||||
- `keyPath` ?<[string]>
|
||||
- `keyPathArray` ?<[Array]<[string]>>
|
||||
- `autoIncrement` <[boolean]>
|
||||
- `indexes` <[Array]<[Object]>>
|
||||
- `name` <[string]>
|
||||
- `keyPath` ?<[string]>
|
||||
- `keyPathArray` ?<[Array]<[string]>>
|
||||
- `unique` <[boolean]>
|
||||
- `multiEntry` <[boolean]>
|
||||
- `records` <[Array]<[Object]>>
|
||||
- `key` ?<[Object]>
|
||||
- `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types.
|
||||
- `value` <[Object]>
|
||||
- `valueEncoded` ?<[Object]> if `value` is not JSON-serializable, this contains an encoded version that preserves types.
|
||||
|
||||
Populates context with given storage state. This option can be used to initialize context with logged-in information
|
||||
obtained via [`method: BrowserContext.storageState`] or [`method: APIRequestContext.storageState`]. Either a path to the
|
||||
|
|
|
|||
|
|
@ -880,6 +880,25 @@ context cookies from the response. The method will automatically follow redirect
|
|||
- `localStorage` <[Array]<[Object]>>
|
||||
- `name` <[string]>
|
||||
- `value` <[string]>
|
||||
- `indexedDB` <[Array]<[Object]>>
|
||||
- `name` <[string]>
|
||||
- `version` <[int]>
|
||||
- `stores` <[Array]<[Object]>>
|
||||
- `name` <[string]>
|
||||
- `keyPath` ?<[string]>
|
||||
- `keyPathArray` ?<[Array]<[string]>>
|
||||
- `autoIncrement` <[boolean]>
|
||||
- `indexes` <[Array]<[Object]>>
|
||||
- `name` <[string]>
|
||||
- `keyPath` ?<[string]>
|
||||
- `keyPathArray` ?<[Array]<[string]>>
|
||||
- `unique` <[boolean]>
|
||||
- `multiEntry` <[boolean]>
|
||||
- `records` <[Array]<[Object]>>
|
||||
- `key` ?<[Object]>
|
||||
- `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types.
|
||||
- `value` <[Object]>
|
||||
- `valueEncoded` ?<[Object]> if `value` is not JSON-serializable, this contains an encoded version that preserves types.
|
||||
|
||||
Returns storage state for this request context, contains current cookies and local storage snapshot if it was passed to the constructor.
|
||||
|
||||
|
|
|
|||
|
|
@ -1511,8 +1511,27 @@ Whether to emulate network being offline for the browser context.
|
|||
- `localStorage` <[Array]<[Object]>>
|
||||
- `name` <[string]>
|
||||
- `value` <[string]>
|
||||
- `indexedDB` <[Array]<[Object]>>
|
||||
- `name` <[string]>
|
||||
- `version` <[int]>
|
||||
- `stores` <[Array]<[Object]>>
|
||||
- `name` <[string]>
|
||||
- `keyPath` ?<[string]>
|
||||
- `keyPathArray` ?<[Array]<[string]>>
|
||||
- `autoIncrement` <[boolean]>
|
||||
- `indexes` <[Array]<[Object]>>
|
||||
- `name` <[string]>
|
||||
- `keyPath` ?<[string]>
|
||||
- `keyPathArray` ?<[Array]<[string]>>
|
||||
- `unique` <[boolean]>
|
||||
- `multiEntry` <[boolean]>
|
||||
- `records` <[Array]<[Object]>>
|
||||
- `key` ?<[Object]>
|
||||
- `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types.
|
||||
- `value` <[Object]>
|
||||
- `valueEncoded` ?<[Object]> if `value` is not JSON-serializable, this contains an encoded version that preserves types.
|
||||
|
||||
Returns storage state for this browser context, contains current cookies and local storage snapshot.
|
||||
Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB snapshot.
|
||||
|
||||
## async method: BrowserContext.storageState
|
||||
* since: v1.8
|
||||
|
|
|
|||
|
|
@ -259,11 +259,30 @@ Specify environment variables that will be visible to the browser. Defaults to `
|
|||
- `httpOnly` <[boolean]>
|
||||
- `secure` <[boolean]>
|
||||
- `sameSite` <[SameSiteAttribute]<"Strict"|"Lax"|"None">> sameSite flag
|
||||
- `origins` <[Array]<[Object]>> localStorage to set for context
|
||||
- `origins` <[Array]<[Object]>>
|
||||
- `origin` <[string]>
|
||||
- `localStorage` <[Array]<[Object]>>
|
||||
- `localStorage` <[Array]<[Object]>> localStorage to set for context
|
||||
- `name` <[string]>
|
||||
- `value` <[string]>
|
||||
- `indexedDB` ?<[Array]<[Object]>> indexedDB to set for context
|
||||
- `name` <[string]> database name
|
||||
- `version` <[int]> database version
|
||||
- `stores` <[Array]<[Object]>>
|
||||
- `name` <[string]>
|
||||
- `keyPath` ?<[string]>
|
||||
- `keyPathArray` ?<[Array]<[string]>>
|
||||
- `autoIncrement` <[boolean]>
|
||||
- `indexes` <[Array]<[Object]>>
|
||||
- `name` <[string]>
|
||||
- `keyPath` ?<[string]>
|
||||
- `keyPathArray` ?<[Array]<[string]>>
|
||||
- `unique` <[boolean]>
|
||||
- `multiEntry` <[boolean]>
|
||||
- `records` <[Array]<[Object]>>
|
||||
- `key` ?<[Object]>
|
||||
- `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types.
|
||||
- `value` <[Object]>
|
||||
- `valueEncoded` ?<[Object]> if `value` is not JSON-serializable, this contains an encoded version that preserves types.
|
||||
|
||||
Learn more about [storage state and auth](../auth.md).
|
||||
|
||||
|
|
|
|||
|
|
@ -266,9 +266,9 @@ existing authentication state instead.
|
|||
Playwright provides a way to reuse the signed-in state in the tests. That way you can log
|
||||
in only once and then skip the log in step for all of the tests.
|
||||
|
||||
Web apps use cookie-based or token-based authentication, where authenticated state is stored as [cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies) or in [local storage](https://developer.mozilla.org/en-US/docs/Web/API/Storage). Playwright provides [`method: BrowserContext.storageState`] method that can be used to retrieve storage state from authenticated contexts and then create new contexts with prepopulated state.
|
||||
Web apps use cookie-based or token-based authentication, where authenticated state is stored as [cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies), in [local storage](https://developer.mozilla.org/en-US/docs/Web/API/Storage) or in [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API). Playwright provides [`method: BrowserContext.storageState`] method that can be used to retrieve storage state from authenticated contexts and then create new contexts with prepopulated state.
|
||||
|
||||
Cookies and local storage state can be used across different browsers. They depend on your application's authentication model: some apps might require both cookies and local storage.
|
||||
Cookies, local storage and IndexedDB state can be used across different browsers. They depend on your application's authentication model which may require some combination of cookies, local storage or IndexedDB.
|
||||
|
||||
The following code snippet retrieves state from an authenticated context and creates a new context with that state.
|
||||
|
||||
|
|
@ -583,7 +583,7 @@ test('admin and user', async ({ adminPage, userPage }) => {
|
|||
|
||||
### Session storage
|
||||
|
||||
Reusing authenticated state covers [cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies) and [local storage](https://developer.mozilla.org/en-US/docs/Web/API/Storage) based authentication. Rarely, [session storage](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage) is used for storing information associated with the signed-in state. Session storage is specific to a particular domain and is not persisted across page loads. Playwright does not provide API to persist session storage, but the following snippet can be used to save/load session storage.
|
||||
Reusing authenticated state covers [cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies), [local storage](https://developer.mozilla.org/en-US/docs/Web/API/Storage) and [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) based authentication. Rarely, [session storage](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage) is used for storing information associated with the signed-in state. Session storage is specific to a particular domain and is not persisted across page loads. Playwright does not provide API to persist session storage, but the following snippet can be used to save/load session storage.
|
||||
|
||||
```js
|
||||
// Get session storage and store as env variable
|
||||
|
|
|
|||
|
|
@ -804,7 +804,7 @@ Sharding in CircleCI is indexed with 0 which means that you will need to overrid
|
|||
executor: pw-noble-development
|
||||
parallelism: 4
|
||||
steps:
|
||||
- run: SHARD="$((${CIRCLE_NODE_INDEX}+1))"; npx playwright test -- --shard=${SHARD}/${CIRCLE_NODE_TOTAL}
|
||||
- run: SHARD="$((${CIRCLE_NODE_INDEX}+1))"; npx playwright test --shard=${SHARD}/${CIRCLE_NODE_TOTAL}
|
||||
```
|
||||
|
||||
### Jenkins
|
||||
|
|
|
|||
|
|
@ -325,7 +325,7 @@ pwsh bin/Debug/netX/playwright.ps1 codegen --timezone="Europe/Rome" --geolocatio
|
|||
|
||||
### Preserve authenticated state
|
||||
|
||||
Run `codegen` with `--save-storage` to save [cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies) and [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) at the end of the session. This is useful to separately record an authentication step and reuse it later when recording more tests.
|
||||
Run `codegen` with `--save-storage` to save [cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies), [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) and [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) data at the end of the session. This is useful to separately record an authentication step and reuse it later when recording more tests.
|
||||
|
||||
```bash js
|
||||
npx playwright codegen github.com/microsoft/playwright --save-storage=auth.json
|
||||
|
|
@ -375,7 +375,7 @@ Make sure you only use the `auth.json` locally as it contains sensitive informat
|
|||
|
||||
#### Load authenticated state
|
||||
|
||||
Run with `--load-storage` to consume the previously loaded storage from the `auth.json`. This way, all [cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies) and [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) will be restored, bringing most web apps to the authenticated state without the need to login again. This means you can continue generating tests from the logged in state.
|
||||
Run with `--load-storage` to consume the previously loaded storage from the `auth.json`. This way, all [cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies), [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) and [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) data will be restored, bringing most web apps to the authenticated state without the need to login again. This means you can continue generating tests from the logged in state.
|
||||
|
||||
```bash js
|
||||
npx playwright codegen --load-storage=auth.json github.com/microsoft/playwright
|
||||
|
|
|
|||
|
|
@ -183,6 +183,10 @@ Metadata that will be put directly to the test report serialized as JSON.
|
|||
|
||||
Project name is visible in the report and during test execution.
|
||||
|
||||
:::warning
|
||||
Playwright executes the configuration file multiple times. Do not dynamically produce non-stable values in your configuration.
|
||||
:::
|
||||
|
||||
## property: TestProject.snapshotDir
|
||||
* since: v1.10
|
||||
- type: ?<[string]>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,70 @@ test('basic test', async ({ page, browserName }, TestStepInfo) => {
|
|||
});
|
||||
```
|
||||
|
||||
## async method: TestStepInfo.attach
|
||||
* since: v1.51
|
||||
|
||||
Attach a value or a file from disk to the current test step. Some reporters show test step attachments. Either [`option: path`] or [`option: body`] must be specified, but not both. Calling this method will attribute the attachment to the step, as opposed to [`method: TestInfo.attach`] which stores all attachments at the test level.
|
||||
|
||||
For example, you can attach a screenshot to the test step:
|
||||
|
||||
```js
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('basic test', async ({ page }) => {
|
||||
await page.goto('https://playwright.dev');
|
||||
await test.step('check page rendering', async step => {
|
||||
const screenshot = await page.screenshot();
|
||||
await step.attach('screenshot', { body: screenshot, contentType: 'image/png' });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Or you can attach files returned by your APIs:
|
||||
|
||||
```js
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { download } from './my-custom-helpers';
|
||||
|
||||
test('basic test', async ({}) => {
|
||||
await test.step('check download behavior', async step => {
|
||||
const tmpPath = await download('a');
|
||||
await step.attach('downloaded', { path: tmpPath });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
:::note
|
||||
[`method: TestStepInfo.attach`] automatically takes care of copying attached files to a
|
||||
location that is accessible to reporters. You can safely remove the attachment
|
||||
after awaiting the attach call.
|
||||
:::
|
||||
|
||||
### param: TestStepInfo.attach.name
|
||||
* since: v1.51
|
||||
- `name` <[string]>
|
||||
|
||||
Attachment name. The name will also be sanitized and used as the prefix of file name
|
||||
when saving to disk.
|
||||
|
||||
### option: TestStepInfo.attach.body
|
||||
* since: v1.51
|
||||
- `body` <[string]|[Buffer]>
|
||||
|
||||
Attachment body. Mutually exclusive with [`option: path`].
|
||||
|
||||
### option: TestStepInfo.attach.contentType
|
||||
* since: v1.51
|
||||
- `contentType` <[string]>
|
||||
|
||||
Content type of this attachment to properly present in the report, for example `'application/json'` or `'image/png'`. If omitted, content type is inferred based on the [`option: path`], or defaults to `text/plain` for [string] attachments and `application/octet-stream` for [Buffer] attachments.
|
||||
|
||||
### option: TestStepInfo.attach.path
|
||||
* since: v1.51
|
||||
- `path` <[string]>
|
||||
|
||||
Path on the filesystem to the attached file. Mutually exclusive with [`option: body`].
|
||||
|
||||
## method: TestStepInfo.skip#1
|
||||
* since: v1.51
|
||||
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@
|
|||
},
|
||||
{
|
||||
"name": "chromium-tip-of-tree",
|
||||
"revision": "1299",
|
||||
"revision": "1300",
|
||||
"installByDefault": false,
|
||||
"browserVersion": "134.0.6988.0"
|
||||
"browserVersion": "134.0.6998.0"
|
||||
},
|
||||
{
|
||||
"name": "firefox",
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
},
|
||||
{
|
||||
"name": "webkit",
|
||||
"revision": "2125",
|
||||
"revision": "2130",
|
||||
"installByDefault": true,
|
||||
"revisionOverrides": {
|
||||
"debian11-x64": "2105",
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { Worker } from './worker';
|
|||
import { Events } from './events';
|
||||
import { TimeoutSettings } from '../common/timeoutSettings';
|
||||
import { Waiter } from './waiter';
|
||||
import type { Headers, WaitForEventOptions, BrowserContextOptions, StorageState, LaunchOptions } from './types';
|
||||
import type { Headers, WaitForEventOptions, BrowserContextOptions, LaunchOptions, StorageState } from './types';
|
||||
import { type URLMatch, headersObjectToArray, isRegExp, isString, urlMatchesEqual, mkdirIfNeeded } from '../utils';
|
||||
import type * as api from '../../types/types';
|
||||
import type * as structs from '../../types/structs';
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export type StorageState = {
|
|||
};
|
||||
export type SetStorageState = {
|
||||
cookies?: channels.SetNetworkCookie[],
|
||||
origins?: channels.OriginStorage[]
|
||||
origins?: channels.SetOriginStorage[]
|
||||
};
|
||||
|
||||
export type LifecycleEvent = channels.LifecycleEvent;
|
||||
|
|
|
|||
|
|
@ -142,9 +142,38 @@ scheme.NameValue = tObject({
|
|||
name: tString,
|
||||
value: tString,
|
||||
});
|
||||
scheme.IndexedDBDatabase = tObject({
|
||||
name: tString,
|
||||
version: tNumber,
|
||||
stores: tArray(tObject({
|
||||
name: tString,
|
||||
autoIncrement: tBoolean,
|
||||
keyPath: tOptional(tString),
|
||||
keyPathArray: tOptional(tArray(tString)),
|
||||
records: tArray(tObject({
|
||||
key: tOptional(tAny),
|
||||
keyEncoded: tOptional(tAny),
|
||||
value: tOptional(tAny),
|
||||
valueEncoded: tOptional(tAny),
|
||||
})),
|
||||
indexes: tArray(tObject({
|
||||
name: tString,
|
||||
keyPath: tOptional(tString),
|
||||
keyPathArray: tOptional(tArray(tString)),
|
||||
multiEntry: tBoolean,
|
||||
unique: tBoolean,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
scheme.SetOriginStorage = tObject({
|
||||
origin: tString,
|
||||
localStorage: tArray(tType('NameValue')),
|
||||
indexedDB: tOptional(tArray(tType('IndexedDBDatabase'))),
|
||||
});
|
||||
scheme.OriginStorage = tObject({
|
||||
origin: tString,
|
||||
localStorage: tArray(tType('NameValue')),
|
||||
indexedDB: tArray(tType('IndexedDBDatabase')),
|
||||
});
|
||||
scheme.SerializedError = tObject({
|
||||
error: tOptional(tObject({
|
||||
|
|
@ -361,7 +390,7 @@ scheme.PlaywrightNewRequestParams = tObject({
|
|||
timeout: tOptional(tNumber),
|
||||
storageState: tOptional(tObject({
|
||||
cookies: tOptional(tArray(tType('NetworkCookie'))),
|
||||
origins: tOptional(tArray(tType('OriginStorage'))),
|
||||
origins: tOptional(tArray(tType('SetOriginStorage'))),
|
||||
})),
|
||||
tracesDir: tOptional(tString),
|
||||
});
|
||||
|
|
@ -689,7 +718,7 @@ scheme.BrowserNewContextParams = tObject({
|
|||
})),
|
||||
storageState: tOptional(tObject({
|
||||
cookies: tOptional(tArray(tType('SetNetworkCookie'))),
|
||||
origins: tOptional(tArray(tType('OriginStorage'))),
|
||||
origins: tOptional(tArray(tType('SetOriginStorage'))),
|
||||
})),
|
||||
});
|
||||
scheme.BrowserNewContextResult = tObject({
|
||||
|
|
@ -759,7 +788,7 @@ scheme.BrowserNewContextForReuseParams = tObject({
|
|||
})),
|
||||
storageState: tOptional(tObject({
|
||||
cookies: tOptional(tArray(tType('SetNetworkCookie'))),
|
||||
origins: tOptional(tArray(tType('OriginStorage'))),
|
||||
origins: tOptional(tArray(tType('SetOriginStorage'))),
|
||||
})),
|
||||
});
|
||||
scheme.BrowserNewContextForReuseResult = tObject({
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ import type { Artifact } from './artifact';
|
|||
import { Clock } from './clock';
|
||||
import type { ClientCertificatesProxy } from './socksClientCertificatesInterceptor';
|
||||
import { RecorderApp } from './recorder/recorderApp';
|
||||
import * as storageScript from './storageScript';
|
||||
import * as utilityScriptSerializers from './isomorphic/utilityScriptSerializers';
|
||||
|
||||
export abstract class BrowserContext extends SdkObject {
|
||||
static Events = {
|
||||
|
|
@ -513,17 +515,17 @@ export abstract class BrowserContext extends SdkObject {
|
|||
};
|
||||
const originsToSave = new Set(this._origins);
|
||||
|
||||
const collectScript = `(${storageScript.collect})((${utilityScriptSerializers.source})(), ${this._browser.options.name === 'firefox'})`;
|
||||
|
||||
// First try collecting storage stage from existing pages.
|
||||
for (const page of this.pages()) {
|
||||
const origin = page.mainFrame().origin();
|
||||
if (!origin || !originsToSave.has(origin))
|
||||
continue;
|
||||
try {
|
||||
const storage = await page.mainFrame().nonStallingEvaluateInExistingContext(`({
|
||||
localStorage: Object.keys(localStorage).map(name => ({ name, value: localStorage.getItem(name) })),
|
||||
})`, 'utility');
|
||||
if (storage.localStorage.length)
|
||||
result.origins.push({ origin, localStorage: storage.localStorage } as channels.OriginStorage);
|
||||
const storage: storageScript.Storage = await page.mainFrame().nonStallingEvaluateInExistingContext(collectScript, 'utility');
|
||||
if (storage.localStorage.length || storage.indexedDB.length)
|
||||
result.origins.push({ origin, localStorage: storage.localStorage, indexedDB: storage.indexedDB });
|
||||
originsToSave.delete(origin);
|
||||
} catch {
|
||||
// When failed on the live page, we'll retry on the blank page below.
|
||||
|
|
@ -539,15 +541,11 @@ export abstract class BrowserContext extends SdkObject {
|
|||
return true;
|
||||
});
|
||||
for (const origin of originsToSave) {
|
||||
const originStorage: channels.OriginStorage = { origin, localStorage: [] };
|
||||
const frame = page.mainFrame();
|
||||
await frame.goto(internalMetadata, origin);
|
||||
const storage = await frame.evaluateExpression(`({
|
||||
localStorage: Object.keys(localStorage).map(name => ({ name, value: localStorage.getItem(name) })),
|
||||
})`, { world: 'utility' });
|
||||
originStorage.localStorage = storage.localStorage;
|
||||
if (storage.localStorage.length)
|
||||
result.origins.push(originStorage);
|
||||
const storage: storageScript.Storage = await frame.evaluateExpression(collectScript, { world: 'utility' });
|
||||
if (storage.localStorage.length || storage.indexedDB.length)
|
||||
result.origins.push({ origin, localStorage: storage.localStorage, indexedDB: storage.indexedDB });
|
||||
}
|
||||
await page.close(internalMetadata);
|
||||
}
|
||||
|
|
@ -610,11 +608,7 @@ export abstract class BrowserContext extends SdkObject {
|
|||
for (const originState of state.origins) {
|
||||
const frame = page.mainFrame();
|
||||
await frame.goto(metadata, originState.origin);
|
||||
await frame.evaluateExpression(`
|
||||
originState => {
|
||||
for (const { name, value } of (originState.localStorage || []))
|
||||
localStorage.setItem(name, value);
|
||||
}`, { isFunction: true, world: 'utility' }, originState);
|
||||
await frame.evaluateExpression(`(${storageScript.restore})(${JSON.stringify(originState)}, (${utilityScriptSerializers.source})())`, { world: 'utility' });
|
||||
}
|
||||
await page.close(internalMetadata);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -644,7 +644,7 @@ export class GlobalAPIRequestContext extends APIRequestContext {
|
|||
proxy.server = url;
|
||||
}
|
||||
if (options.storageState) {
|
||||
this._origins = options.storageState.origins;
|
||||
this._origins = options.storageState.origins?.map(origin => ({ indexedDB: [], ...origin }));
|
||||
this._cookieStore.addCookies(options.storageState.cookies || []);
|
||||
}
|
||||
verifyClientCertificates(options.clientCertificates);
|
||||
|
|
|
|||
|
|
@ -1717,7 +1717,7 @@ export class Frame extends SdkObject {
|
|||
}, { source, arg });
|
||||
}
|
||||
|
||||
async resetStorageForCurrentOriginBestEffort(newStorage: channels.OriginStorage | undefined) {
|
||||
async resetStorageForCurrentOriginBestEffort(newStorage: channels.SetOriginStorage | undefined) {
|
||||
const context = await this._utilityContext();
|
||||
await context.evaluate(async ({ ls }) => {
|
||||
// Clean DOMStorage.
|
||||
|
|
|
|||
171
packages/playwright-core/src/server/storageScript.ts
Normal file
171
packages/playwright-core/src/server/storageScript.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
/**
|
||||
* 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 type * as channels from '@protocol/channels';
|
||||
import type { source } from './isomorphic/utilityScriptSerializers';
|
||||
|
||||
export type Storage = Omit<channels.OriginStorage, 'origin'>;
|
||||
|
||||
export async function collect(serializers: ReturnType<typeof source>, isFirefox: boolean): Promise<Storage> {
|
||||
const idbResult = await Promise.all((await indexedDB.databases()).map(async dbInfo => {
|
||||
if (!dbInfo.name)
|
||||
throw new Error('Database name is empty');
|
||||
if (!dbInfo.version)
|
||||
throw new Error('Database version is unset');
|
||||
|
||||
function idbRequestToPromise<T extends IDBOpenDBRequest | IDBRequest>(request: T) {
|
||||
return new Promise<T['result']>((resolve, reject) => {
|
||||
request.addEventListener('success', () => resolve(request.result));
|
||||
request.addEventListener('error', () => reject(request.error));
|
||||
});
|
||||
}
|
||||
|
||||
function isPlainObject(v: any) {
|
||||
const ctor = v?.constructor;
|
||||
if (isFirefox) {
|
||||
const constructorImpl = ctor?.toString();
|
||||
if (constructorImpl.startsWith('function Object() {') && constructorImpl.includes('[native code]'))
|
||||
return true;
|
||||
}
|
||||
|
||||
return ctor === Object;
|
||||
}
|
||||
|
||||
function trySerialize(value: any): { trivial?: any, encoded?: any } {
|
||||
let trivial = true;
|
||||
const encoded = serializers.serializeAsCallArgument(value, v => {
|
||||
const isTrivial = (
|
||||
isPlainObject(v)
|
||||
|| Array.isArray(v)
|
||||
|| typeof v === 'string'
|
||||
|| typeof v === 'number'
|
||||
|| typeof v === 'boolean'
|
||||
|| Object.is(v, null)
|
||||
);
|
||||
|
||||
if (!isTrivial)
|
||||
trivial = false;
|
||||
|
||||
return { fallThrough: v };
|
||||
});
|
||||
if (trivial)
|
||||
return { trivial: value };
|
||||
return { encoded };
|
||||
}
|
||||
|
||||
const db = await idbRequestToPromise(indexedDB.open(dbInfo.name));
|
||||
const transaction = db.transaction(db.objectStoreNames, 'readonly');
|
||||
const stores = await Promise.all([...db.objectStoreNames].map(async storeName => {
|
||||
const objectStore = transaction.objectStore(storeName);
|
||||
|
||||
const keys = await idbRequestToPromise(objectStore.getAllKeys());
|
||||
const records = await Promise.all(keys.map(async key => {
|
||||
const record: channels.OriginStorage['indexedDB'][0]['stores'][0]['records'][0] = {};
|
||||
|
||||
if (objectStore.keyPath === null) {
|
||||
const { encoded, trivial } = trySerialize(key);
|
||||
if (trivial)
|
||||
record.key = trivial;
|
||||
else
|
||||
record.keyEncoded = encoded;
|
||||
}
|
||||
|
||||
const value = await idbRequestToPromise(objectStore.get(key));
|
||||
const { encoded, trivial } = trySerialize(value);
|
||||
if (trivial)
|
||||
record.value = trivial;
|
||||
else
|
||||
record.valueEncoded = encoded;
|
||||
|
||||
return record;
|
||||
}));
|
||||
|
||||
const indexes = [...objectStore.indexNames].map(indexName => {
|
||||
const index = objectStore.index(indexName);
|
||||
return {
|
||||
name: index.name,
|
||||
keyPath: typeof index.keyPath === 'string' ? index.keyPath : undefined,
|
||||
keyPathArray: Array.isArray(index.keyPath) ? index.keyPath : undefined,
|
||||
multiEntry: index.multiEntry,
|
||||
unique: index.unique,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
name: storeName,
|
||||
records: records,
|
||||
indexes,
|
||||
autoIncrement: objectStore.autoIncrement,
|
||||
keyPath: typeof objectStore.keyPath === 'string' ? objectStore.keyPath : undefined,
|
||||
keyPathArray: Array.isArray(objectStore.keyPath) ? objectStore.keyPath : undefined,
|
||||
};
|
||||
}));
|
||||
|
||||
return {
|
||||
name: dbInfo.name,
|
||||
version: dbInfo.version,
|
||||
stores,
|
||||
};
|
||||
})).catch(e => {
|
||||
throw new Error('Unable to serialize IndexedDB: ' + e.message);
|
||||
});
|
||||
|
||||
return {
|
||||
localStorage: Object.keys(localStorage).map(name => ({ name, value: localStorage.getItem(name)! })),
|
||||
indexedDB: idbResult,
|
||||
};
|
||||
}
|
||||
|
||||
export async function restore(originState: channels.SetOriginStorage, serializers: ReturnType<typeof source>) {
|
||||
for (const { name, value } of (originState.localStorage || []))
|
||||
localStorage.setItem(name, value);
|
||||
|
||||
await Promise.all((originState.indexedDB ?? []).map(async dbInfo => {
|
||||
const openRequest = indexedDB.open(dbInfo.name, dbInfo.version);
|
||||
openRequest.addEventListener('upgradeneeded', () => {
|
||||
const db = openRequest.result;
|
||||
for (const store of dbInfo.stores) {
|
||||
const objectStore = db.createObjectStore(store.name, { autoIncrement: store.autoIncrement, keyPath: store.keyPathArray ?? store.keyPath });
|
||||
for (const index of store.indexes)
|
||||
objectStore.createIndex(index.name, index.keyPathArray ?? index.keyPath!, { unique: index.unique, multiEntry: index.multiEntry });
|
||||
}
|
||||
});
|
||||
|
||||
function idbRequestToPromise<T extends IDBOpenDBRequest | IDBRequest>(request: T) {
|
||||
return new Promise<T['result']>((resolve, reject) => {
|
||||
request.addEventListener('success', () => resolve(request.result));
|
||||
request.addEventListener('error', () => reject(request.error));
|
||||
});
|
||||
}
|
||||
|
||||
// after `upgradeneeded` finishes, `success` event is fired.
|
||||
const db = await idbRequestToPromise(openRequest);
|
||||
const transaction = db.transaction(db.objectStoreNames, 'readwrite');
|
||||
await Promise.all(dbInfo.stores.map(async store => {
|
||||
const objectStore = transaction.objectStore(store.name);
|
||||
await Promise.all(store.records.map(async record => {
|
||||
await idbRequestToPromise(
|
||||
objectStore.add(
|
||||
record.value ?? serializers.parseEvaluationResultValue(record.valueEncoded),
|
||||
record.key ?? serializers.parseEvaluationResultValue(record.keyEncoded),
|
||||
)
|
||||
);
|
||||
}));
|
||||
}));
|
||||
})).catch(e => {
|
||||
throw new Error('Unable to restore IndexedDB: ' + e.message);
|
||||
});
|
||||
}
|
||||
|
|
@ -170,10 +170,16 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
|
|||
this._state.recording = true;
|
||||
this._state.callIds.clear();
|
||||
|
||||
// - Browser context network trace is shared across chunks as it contains resources
|
||||
// used to serve page snapshots, so make a copy with the new name.
|
||||
// - APIRequestContext network traces are chunk-specific, always start from scratch.
|
||||
const preserveNetworkResources = this._context instanceof BrowserContext;
|
||||
if (options.name && options.name !== this._state.traceName)
|
||||
this._changeTraceName(this._state, options.name);
|
||||
this._changeTraceName(this._state, options.name, preserveNetworkResources);
|
||||
else
|
||||
this._allocateNewTraceFile(this._state);
|
||||
if (!preserveNetworkResources)
|
||||
this._fs.writeFile(this._state.networkFile, '');
|
||||
|
||||
this._fs.mkdir(path.dirname(this._state.traceFile));
|
||||
const event: trace.TraceEvent = {
|
||||
|
|
@ -267,14 +273,14 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
|
|||
state.traceFile = path.join(state.tracesDir, `${state.traceName}${suffix}.trace`);
|
||||
}
|
||||
|
||||
private _changeTraceName(state: RecordingState, name: string) {
|
||||
private _changeTraceName(state: RecordingState, name: string, preserveNetworkResources: boolean) {
|
||||
state.traceName = name;
|
||||
state.chunkOrdinal = 0; // Reset ordinal for the new name.
|
||||
this._allocateNewTraceFile(state);
|
||||
|
||||
// Network file survives across chunks, so make a copy with the new name.
|
||||
const newNetworkFile = path.join(state.tracesDir, name + '.network');
|
||||
this._fs.copyFile(state.networkFile, newNetworkFile);
|
||||
if (preserveNetworkResources)
|
||||
this._fs.copyFile(state.networkFile, newNetworkFile);
|
||||
state.networkFile = newNetworkFile;
|
||||
}
|
||||
|
||||
|
|
|
|||
262
packages/playwright-core/types/types.d.ts
vendored
262
packages/playwright-core/types/types.d.ts
vendored
|
|
@ -9266,7 +9266,8 @@ export interface BrowserContext {
|
|||
setOffline(offline: boolean): Promise<void>;
|
||||
|
||||
/**
|
||||
* Returns storage state for this browser context, contains current cookies and local storage snapshot.
|
||||
* Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB
|
||||
* snapshot.
|
||||
* @param options
|
||||
*/
|
||||
storageState(options?: {
|
||||
|
|
@ -9307,6 +9308,50 @@ export interface BrowserContext {
|
|||
|
||||
value: string;
|
||||
}>;
|
||||
|
||||
indexedDB: Array<{
|
||||
name: string;
|
||||
|
||||
version: number;
|
||||
|
||||
stores: Array<{
|
||||
name: string;
|
||||
|
||||
keyPath?: string;
|
||||
|
||||
keyPathArray?: Array<string>;
|
||||
|
||||
autoIncrement: boolean;
|
||||
|
||||
indexes: Array<{
|
||||
name: string;
|
||||
|
||||
keyPath?: string;
|
||||
|
||||
keyPathArray?: Array<string>;
|
||||
|
||||
unique: boolean;
|
||||
|
||||
multiEntry: boolean;
|
||||
}>;
|
||||
|
||||
records: Array<{
|
||||
key?: Object;
|
||||
|
||||
/**
|
||||
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
|
||||
*/
|
||||
keyEncoded?: Object;
|
||||
|
||||
value: Object;
|
||||
|
||||
/**
|
||||
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
|
||||
*/
|
||||
valueEncoded?: Object;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
|
|
@ -10062,17 +10107,70 @@ export interface Browser {
|
|||
sameSite: "Strict"|"Lax"|"None";
|
||||
}>;
|
||||
|
||||
/**
|
||||
* localStorage to set for context
|
||||
*/
|
||||
origins: Array<{
|
||||
origin: string;
|
||||
|
||||
/**
|
||||
* localStorage to set for context
|
||||
*/
|
||||
localStorage: Array<{
|
||||
name: string;
|
||||
|
||||
value: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* indexedDB to set for context
|
||||
*/
|
||||
indexedDB?: Array<{
|
||||
/**
|
||||
* database name
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* database version
|
||||
*/
|
||||
version: number;
|
||||
|
||||
stores: Array<{
|
||||
name: string;
|
||||
|
||||
keyPath?: string;
|
||||
|
||||
keyPathArray?: Array<string>;
|
||||
|
||||
autoIncrement: boolean;
|
||||
|
||||
indexes: Array<{
|
||||
name: string;
|
||||
|
||||
keyPath?: string;
|
||||
|
||||
keyPathArray?: Array<string>;
|
||||
|
||||
unique: boolean;
|
||||
|
||||
multiEntry: boolean;
|
||||
}>;
|
||||
|
||||
records: Array<{
|
||||
key?: Object;
|
||||
|
||||
/**
|
||||
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
|
||||
*/
|
||||
keyEncoded?: Object;
|
||||
|
||||
value: Object;
|
||||
|
||||
/**
|
||||
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
|
||||
*/
|
||||
valueEncoded?: Object;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
|
||||
|
|
@ -17608,6 +17706,59 @@ export interface APIRequest {
|
|||
|
||||
value: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* indexedDB to set for context
|
||||
*/
|
||||
indexedDB?: Array<{
|
||||
/**
|
||||
* database name
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* database version
|
||||
*/
|
||||
version: number;
|
||||
|
||||
stores: Array<{
|
||||
name: string;
|
||||
|
||||
keyPath?: string;
|
||||
|
||||
keyPathArray?: Array<string>;
|
||||
|
||||
autoIncrement: boolean;
|
||||
|
||||
indexes: Array<{
|
||||
name: string;
|
||||
|
||||
keyPath?: string;
|
||||
|
||||
keyPathArray?: Array<string>;
|
||||
|
||||
unique: boolean;
|
||||
|
||||
multiEntry: boolean;
|
||||
}>;
|
||||
|
||||
records: Array<{
|
||||
key?: Object;
|
||||
|
||||
/**
|
||||
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
|
||||
*/
|
||||
keyEncoded?: Object;
|
||||
|
||||
value: Object;
|
||||
|
||||
/**
|
||||
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
|
||||
*/
|
||||
valueEncoded?: Object;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
|
||||
|
|
@ -18417,6 +18568,50 @@ export interface APIRequestContext {
|
|||
|
||||
value: string;
|
||||
}>;
|
||||
|
||||
indexedDB: Array<{
|
||||
name: string;
|
||||
|
||||
version: number;
|
||||
|
||||
stores: Array<{
|
||||
name: string;
|
||||
|
||||
keyPath?: string;
|
||||
|
||||
keyPathArray?: Array<string>;
|
||||
|
||||
autoIncrement: boolean;
|
||||
|
||||
indexes: Array<{
|
||||
name: string;
|
||||
|
||||
keyPath?: string;
|
||||
|
||||
keyPathArray?: Array<string>;
|
||||
|
||||
unique: boolean;
|
||||
|
||||
multiEntry: boolean;
|
||||
}>;
|
||||
|
||||
records: Array<{
|
||||
key?: Object;
|
||||
|
||||
/**
|
||||
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
|
||||
*/
|
||||
keyEncoded?: Object;
|
||||
|
||||
value: Object;
|
||||
|
||||
/**
|
||||
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
|
||||
*/
|
||||
valueEncoded?: Object;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
|
|
@ -22249,17 +22444,70 @@ export interface BrowserContextOptions {
|
|||
sameSite: "Strict"|"Lax"|"None";
|
||||
}>;
|
||||
|
||||
/**
|
||||
* localStorage to set for context
|
||||
*/
|
||||
origins: Array<{
|
||||
origin: string;
|
||||
|
||||
/**
|
||||
* localStorage to set for context
|
||||
*/
|
||||
localStorage: Array<{
|
||||
name: string;
|
||||
|
||||
value: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* indexedDB to set for context
|
||||
*/
|
||||
indexedDB?: Array<{
|
||||
/**
|
||||
* database name
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* database version
|
||||
*/
|
||||
version: number;
|
||||
|
||||
stores: Array<{
|
||||
name: string;
|
||||
|
||||
keyPath?: string;
|
||||
|
||||
keyPathArray?: Array<string>;
|
||||
|
||||
autoIncrement: boolean;
|
||||
|
||||
indexes: Array<{
|
||||
name: string;
|
||||
|
||||
keyPath?: string;
|
||||
|
||||
keyPathArray?: Array<string>;
|
||||
|
||||
unique: boolean;
|
||||
|
||||
multiEntry: boolean;
|
||||
}>;
|
||||
|
||||
records: Array<{
|
||||
key?: Object;
|
||||
|
||||
/**
|
||||
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
|
||||
*/
|
||||
keyEncoded?: Object;
|
||||
|
||||
value: Object;
|
||||
|
||||
/**
|
||||
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
|
||||
*/
|
||||
valueEncoded?: Object;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -118,15 +118,15 @@ function isTypeScript(filename: string) {
|
|||
return filename.endsWith('.ts') || filename.endsWith('.tsx') || filename.endsWith('.mts') || filename.endsWith('.cts');
|
||||
}
|
||||
|
||||
export function babelTransform(code: string, filename: string, isModule: boolean, pluginsPrologue: [string, any?][], pluginsEpilogue: [string, any?][]): BabelFileResult {
|
||||
export function babelTransform(code: string, filename: string, isModule: boolean, pluginsPrologue: [string, any?][], pluginsEpilogue: [string, any?][]): BabelFileResult | null {
|
||||
if (isTransforming)
|
||||
return {};
|
||||
return null;
|
||||
|
||||
// Prevent reentry while requiring plugins lazily.
|
||||
isTransforming = true;
|
||||
try {
|
||||
const options = babelTransformOptions(isTypeScript(filename), isModule, pluginsPrologue, pluginsEpilogue);
|
||||
return babel.transform(code, { filename, ...options })!;
|
||||
return babel.transform(code, { filename, ...options });
|
||||
} finally {
|
||||
isTransforming = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,8 +49,9 @@ import {
|
|||
toHaveValues,
|
||||
toPass
|
||||
} from './matchers';
|
||||
import type { ExpectMatcherStateInternal } from './matchers';
|
||||
import { toMatchSnapshot, toHaveScreenshot, toHaveScreenshotStepTitle } from './toMatchSnapshot';
|
||||
import type { Expect, ExpectMatcherState } from '../../types/test';
|
||||
import type { Expect } from '../../types/test';
|
||||
import { currentTestInfo } from '../common/globals';
|
||||
import { filteredStackTrace, trimLongString } from '../util';
|
||||
import {
|
||||
|
|
@ -61,6 +62,7 @@ import {
|
|||
} from '../common/expectBundle';
|
||||
import { zones } from 'playwright-core/lib/utils';
|
||||
import { TestInfoImpl } from '../worker/testInfo';
|
||||
import type { TestStepInfoImpl } from '../worker/testInfo';
|
||||
import { ExpectError, isJestError } from './matcherHint';
|
||||
import { toMatchAriaSnapshot } from './toMatchAriaSnapshot';
|
||||
|
||||
|
|
@ -110,13 +112,13 @@ function createMatchers(actual: unknown, info: ExpectMetaInfo, prefix: string[])
|
|||
return new Proxy(expectLibrary(actual), new ExpectMetaInfoProxyHandler(info, prefix));
|
||||
}
|
||||
|
||||
const getCustomMatchersSymbol = Symbol('get custom matchers');
|
||||
const userMatchersSymbol = Symbol('userMatchers');
|
||||
|
||||
function qualifiedMatcherName(qualifier: string[], matcherName: string) {
|
||||
return qualifier.join(':') + '$' + matcherName;
|
||||
}
|
||||
|
||||
function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Record<string, Function>) {
|
||||
function createExpect(info: ExpectMetaInfo, prefix: string[], userMatchers: Record<string, Function>) {
|
||||
const expectInstance: Expect<{}> = new Proxy(expectLibrary, {
|
||||
apply: function(target: any, thisArg: any, argumentsList: [unknown, ExpectMessage?]) {
|
||||
const [actual, messageOrOptions] = argumentsList;
|
||||
|
|
@ -130,7 +132,7 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re
|
|||
return createMatchers(actual, newInfo, prefix);
|
||||
},
|
||||
|
||||
get: function(target: any, property: string | typeof getCustomMatchersSymbol) {
|
||||
get: function(target: any, property: string | typeof userMatchersSymbol) {
|
||||
if (property === 'configure')
|
||||
return configure;
|
||||
|
||||
|
|
@ -139,27 +141,14 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re
|
|||
const qualifier = [...prefix, createGuid()];
|
||||
|
||||
const wrappedMatchers: any = {};
|
||||
const extendedMatchers: any = { ...customMatchers };
|
||||
for (const [name, matcher] of Object.entries(matchers)) {
|
||||
wrappedMatchers[name] = function(...args: any[]) {
|
||||
const { isNot, promise, utils } = this;
|
||||
const newThis: ExpectMatcherState = {
|
||||
isNot,
|
||||
promise,
|
||||
utils,
|
||||
timeout: currentExpectTimeout()
|
||||
};
|
||||
(newThis as any).equals = throwUnsupportedExpectMatcherError;
|
||||
return (matcher as any).call(newThis, ...args);
|
||||
};
|
||||
wrappedMatchers[name] = wrapPlaywrightMatcherToPassNiceThis(matcher);
|
||||
const key = qualifiedMatcherName(qualifier, name);
|
||||
wrappedMatchers[key] = wrappedMatchers[name];
|
||||
Object.defineProperty(wrappedMatchers[key], 'name', { value: name });
|
||||
extendedMatchers[name] = wrappedMatchers[key];
|
||||
}
|
||||
expectLibrary.extend(wrappedMatchers);
|
||||
|
||||
return createExpect(info, qualifier, extendedMatchers);
|
||||
return createExpect(info, qualifier, { ...userMatchers, ...matchers });
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -169,8 +158,8 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re
|
|||
};
|
||||
}
|
||||
|
||||
if (property === getCustomMatchersSymbol)
|
||||
return customMatchers;
|
||||
if (property === userMatchersSymbol)
|
||||
return userMatchers;
|
||||
|
||||
if (property === 'poll') {
|
||||
return (actual: unknown, messageOrOptions?: ExpectMessage & { timeout?: number, intervals?: number[] }) => {
|
||||
|
|
@ -197,12 +186,53 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re
|
|||
newInfo.poll!.intervals = configuration._poll.intervals ?? newInfo.poll!.intervals;
|
||||
}
|
||||
}
|
||||
return createExpect(newInfo, prefix, customMatchers);
|
||||
return createExpect(newInfo, prefix, userMatchers);
|
||||
};
|
||||
|
||||
return expectInstance;
|
||||
}
|
||||
|
||||
// Expect wraps matchers, so there is no way to pass this information to the raw Playwright matcher.
|
||||
// Rely on sync call sequence to seed each matcher call with the context.
|
||||
type MatcherCallContext = {
|
||||
expectInfo: ExpectMetaInfo;
|
||||
testInfo: TestInfoImpl | null;
|
||||
step?: TestStepInfoImpl;
|
||||
};
|
||||
|
||||
let matcherCallContext: MatcherCallContext | undefined;
|
||||
|
||||
function setMatcherCallContext(context: MatcherCallContext) {
|
||||
matcherCallContext = context;
|
||||
}
|
||||
|
||||
function takeMatcherCallContext(): MatcherCallContext {
|
||||
try {
|
||||
return matcherCallContext!;
|
||||
} finally {
|
||||
matcherCallContext = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultExpectTimeout = 5000;
|
||||
|
||||
function wrapPlaywrightMatcherToPassNiceThis(matcher: any) {
|
||||
return function(this: any, ...args: any[]) {
|
||||
const { isNot, promise, utils } = this;
|
||||
const context = takeMatcherCallContext();
|
||||
const timeout = context.expectInfo.timeout ?? context.testInfo?._projectInternal?.expect?.timeout ?? defaultExpectTimeout;
|
||||
const newThis: ExpectMatcherStateInternal = {
|
||||
isNot,
|
||||
promise,
|
||||
utils,
|
||||
timeout,
|
||||
_stepInfo: context.step,
|
||||
};
|
||||
(newThis as any).equals = throwUnsupportedExpectMatcherError;
|
||||
return matcher.call(newThis, ...args);
|
||||
};
|
||||
}
|
||||
|
||||
function throwUnsupportedExpectMatcherError() {
|
||||
throw new Error('It looks like you are using custom expect matchers that are not compatible with Playwright. See https://aka.ms/playwright/expect-compatibility');
|
||||
}
|
||||
|
|
@ -299,8 +329,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
|
|||
}
|
||||
return (...args: any[]) => {
|
||||
const testInfo = currentTestInfo();
|
||||
// We assume that the matcher will read the current expect timeout the first thing.
|
||||
setCurrentExpectConfigureTimeout(this._info.timeout);
|
||||
setMatcherCallContext({ expectInfo: this._info, testInfo });
|
||||
if (!testInfo)
|
||||
return matcher.call(target, ...args);
|
||||
|
||||
|
|
@ -346,6 +375,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
|
|||
};
|
||||
|
||||
try {
|
||||
setMatcherCallContext({ expectInfo: this._info, testInfo, step: step.info });
|
||||
const callback = () => matcher.call(target, ...args);
|
||||
const result = zones.run('stepZone', step, callback);
|
||||
if (result instanceof Promise)
|
||||
|
|
@ -362,7 +392,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
|
|||
async function pollMatcher(qualifiedMatcherName: string, info: ExpectMetaInfo, prefix: string[], ...args: any[]) {
|
||||
const testInfo = currentTestInfo();
|
||||
const poll = info.poll!;
|
||||
const timeout = poll.timeout ?? currentExpectTimeout();
|
||||
const timeout = poll.timeout ?? info.timeout ?? testInfo?._projectInternal?.expect?.timeout ?? defaultExpectTimeout;
|
||||
const { deadline, timeoutMessage } = testInfo ? testInfo._deadlineForMatcher(timeout) : TestInfoImpl._defaultDeadlineForMatcher(timeout);
|
||||
|
||||
const result = await pollAgainstDeadline<Error|undefined>(async () => {
|
||||
|
|
@ -398,22 +428,6 @@ async function pollMatcher(qualifiedMatcherName: string, info: ExpectMetaInfo, p
|
|||
}
|
||||
}
|
||||
|
||||
let currentExpectConfigureTimeout: number | undefined;
|
||||
|
||||
function setCurrentExpectConfigureTimeout(timeout: number | undefined) {
|
||||
currentExpectConfigureTimeout = timeout;
|
||||
}
|
||||
|
||||
function currentExpectTimeout() {
|
||||
if (currentExpectConfigureTimeout !== undefined)
|
||||
return currentExpectConfigureTimeout;
|
||||
const testInfo = currentTestInfo();
|
||||
let defaultExpectTimeout = testInfo?._projectInternal?.expect?.timeout;
|
||||
if (typeof defaultExpectTimeout === 'undefined')
|
||||
defaultExpectTimeout = 5000;
|
||||
return defaultExpectTimeout;
|
||||
}
|
||||
|
||||
function computeArgsSuffix(matcherName: string, args: any[]) {
|
||||
let value = '';
|
||||
if (matcherName === 'toHaveScreenshot')
|
||||
|
|
@ -426,7 +440,7 @@ export const expect: Expect<{}> = createExpect({}, [], {}).extend(customMatchers
|
|||
export function mergeExpects(...expects: any[]) {
|
||||
let merged = expect;
|
||||
for (const e of expects) {
|
||||
const internals = e[getCustomMatchersSymbol];
|
||||
const internals = e[userMatchersSymbol];
|
||||
if (!internals) // non-playwright expects mutate the global expect, so we don't need to do anything special
|
||||
continue;
|
||||
merged = merged.extend(internals);
|
||||
|
|
|
|||
|
|
@ -24,10 +24,13 @@ import { toMatchText } from './toMatchText';
|
|||
import { isRegExp, isString, isTextualMimeType, pollAgainstDeadline, serializeExpectedTextValues } from 'playwright-core/lib/utils';
|
||||
import { currentTestInfo } from '../common/globals';
|
||||
import { TestInfoImpl } from '../worker/testInfo';
|
||||
import type { TestStepInfoImpl } from '../worker/testInfo';
|
||||
import type { ExpectMatcherState } from '../../types/test';
|
||||
import { takeFirst } from '../common/config';
|
||||
import { toHaveURL as toHaveURLExternal } from './toHaveURL';
|
||||
|
||||
export type ExpectMatcherStateInternal = ExpectMatcherState & { _stepInfo?: TestStepInfoImpl };
|
||||
|
||||
export interface LocatorEx extends Locator {
|
||||
_expect(expression: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ import { colors } from 'playwright-core/lib/utilsBundle';
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { mime } from 'playwright-core/lib/utilsBundle';
|
||||
import type { TestInfoImpl } from '../worker/testInfo';
|
||||
import type { ExpectMatcherState } from '../../types/test';
|
||||
import type { TestInfoImpl, TestStepInfoImpl } from '../worker/testInfo';
|
||||
import type { ExpectMatcherStateInternal } from './matchers';
|
||||
import { matcherHint, type MatcherResult } from './matcherHint';
|
||||
import type { FullProjectInternal } from '../common/config';
|
||||
|
||||
|
|
@ -221,13 +221,13 @@ class SnapshotHelper {
|
|||
return this.createMatcherResult(message, true);
|
||||
}
|
||||
|
||||
handleMissing(actual: Buffer | string): ImageMatcherResult {
|
||||
handleMissing(actual: Buffer | string, step: TestStepInfoImpl | undefined): ImageMatcherResult {
|
||||
const isWriteMissingMode = this.updateSnapshots !== 'none';
|
||||
if (isWriteMissingMode)
|
||||
writeFileSync(this.expectedPath, actual);
|
||||
this.testInfo.attachments.push({ name: addSuffixToFilePath(this.attachmentBaseName, '-expected'), contentType: this.mimeType, path: this.expectedPath });
|
||||
step?._attachToStep({ name: addSuffixToFilePath(this.attachmentBaseName, '-expected'), contentType: this.mimeType, path: this.expectedPath });
|
||||
writeFileSync(this.actualPath, actual);
|
||||
this.testInfo.attachments.push({ name: addSuffixToFilePath(this.attachmentBaseName, '-actual'), contentType: this.mimeType, path: this.actualPath });
|
||||
step?._attachToStep({ name: addSuffixToFilePath(this.attachmentBaseName, '-actual'), contentType: this.mimeType, path: this.actualPath });
|
||||
const message = `A snapshot doesn't exist at ${this.expectedPath}${isWriteMissingMode ? ', writing actual.' : '.'}`;
|
||||
if (this.updateSnapshots === 'all' || this.updateSnapshots === 'changed') {
|
||||
/* eslint-disable no-console */
|
||||
|
|
@ -249,28 +249,29 @@ class SnapshotHelper {
|
|||
diff: Buffer | string | undefined,
|
||||
header: string,
|
||||
diffError: string,
|
||||
log: string[] | undefined): ImageMatcherResult {
|
||||
log: string[] | undefined,
|
||||
step: TestStepInfoImpl | undefined): ImageMatcherResult {
|
||||
const output = [`${header}${indent(diffError, ' ')}`];
|
||||
if (expected !== undefined) {
|
||||
// Copy the expectation inside the `test-results/` folder for backwards compatibility,
|
||||
// so that one can upload `test-results/` directory and have all the data inside.
|
||||
writeFileSync(this.legacyExpectedPath, expected);
|
||||
this.testInfo.attachments.push({ name: addSuffixToFilePath(this.attachmentBaseName, '-expected'), contentType: this.mimeType, path: this.expectedPath });
|
||||
step?._attachToStep({ name: addSuffixToFilePath(this.attachmentBaseName, '-expected'), contentType: this.mimeType, path: this.expectedPath });
|
||||
output.push(`\nExpected: ${colors.yellow(this.expectedPath)}`);
|
||||
}
|
||||
if (previous !== undefined) {
|
||||
writeFileSync(this.previousPath, previous);
|
||||
this.testInfo.attachments.push({ name: addSuffixToFilePath(this.attachmentBaseName, '-previous'), contentType: this.mimeType, path: this.previousPath });
|
||||
step?._attachToStep({ name: addSuffixToFilePath(this.attachmentBaseName, '-previous'), contentType: this.mimeType, path: this.previousPath });
|
||||
output.push(`Previous: ${colors.yellow(this.previousPath)}`);
|
||||
}
|
||||
if (actual !== undefined) {
|
||||
writeFileSync(this.actualPath, actual);
|
||||
this.testInfo.attachments.push({ name: addSuffixToFilePath(this.attachmentBaseName, '-actual'), contentType: this.mimeType, path: this.actualPath });
|
||||
step?._attachToStep({ name: addSuffixToFilePath(this.attachmentBaseName, '-actual'), contentType: this.mimeType, path: this.actualPath });
|
||||
output.push(`Received: ${colors.yellow(this.actualPath)}`);
|
||||
}
|
||||
if (diff !== undefined) {
|
||||
writeFileSync(this.diffPath, diff);
|
||||
this.testInfo.attachments.push({ name: addSuffixToFilePath(this.attachmentBaseName, '-diff'), contentType: this.mimeType, path: this.diffPath });
|
||||
step?._attachToStep({ name: addSuffixToFilePath(this.attachmentBaseName, '-diff'), contentType: this.mimeType, path: this.diffPath });
|
||||
output.push(` Diff: ${colors.yellow(this.diffPath)}`);
|
||||
}
|
||||
|
||||
|
|
@ -288,7 +289,7 @@ class SnapshotHelper {
|
|||
}
|
||||
|
||||
export function toMatchSnapshot(
|
||||
this: ExpectMatcherState,
|
||||
this: ExpectMatcherStateInternal,
|
||||
received: Buffer | string,
|
||||
nameOrOptions: NameOrSegments | { name?: NameOrSegments } & ImageComparatorOptions = {},
|
||||
optOptions: ImageComparatorOptions = {}
|
||||
|
|
@ -315,7 +316,7 @@ export function toMatchSnapshot(
|
|||
}
|
||||
|
||||
if (!fs.existsSync(helper.expectedPath))
|
||||
return helper.handleMissing(received);
|
||||
return helper.handleMissing(received, this._stepInfo);
|
||||
|
||||
const expected = fs.readFileSync(helper.expectedPath);
|
||||
|
||||
|
|
@ -344,7 +345,7 @@ export function toMatchSnapshot(
|
|||
|
||||
const receiver = isString(received) ? 'string' : 'Buffer';
|
||||
const header = matcherHint(this, undefined, 'toMatchSnapshot', receiver, undefined, undefined);
|
||||
return helper.handleDifferent(received, expected, undefined, result.diff, header, result.errorMessage, undefined);
|
||||
return helper.handleDifferent(received, expected, undefined, result.diff, header, result.errorMessage, undefined, this._stepInfo);
|
||||
}
|
||||
|
||||
export function toHaveScreenshotStepTitle(
|
||||
|
|
@ -360,7 +361,7 @@ export function toHaveScreenshotStepTitle(
|
|||
}
|
||||
|
||||
export async function toHaveScreenshot(
|
||||
this: ExpectMatcherState,
|
||||
this: ExpectMatcherStateInternal,
|
||||
pageOrLocator: Page | Locator,
|
||||
nameOrOptions: NameOrSegments | { name?: NameOrSegments } & ToHaveScreenshotOptions = {},
|
||||
optOptions: ToHaveScreenshotOptions = {}
|
||||
|
|
@ -425,11 +426,11 @@ export async function toHaveScreenshot(
|
|||
// This can be due to e.g. spinning animation, so we want to show it as a diff.
|
||||
if (errorMessage) {
|
||||
const header = matcherHint(this, locator, 'toHaveScreenshot', receiver, undefined, undefined, timedOut ? timeout : undefined);
|
||||
return helper.handleDifferent(actual, undefined, previous, diff, header, errorMessage, log);
|
||||
return helper.handleDifferent(actual, undefined, previous, diff, header, errorMessage, log, this._stepInfo);
|
||||
}
|
||||
|
||||
// We successfully generated new screenshot.
|
||||
return helper.handleMissing(actual!);
|
||||
return helper.handleMissing(actual!, this._stepInfo);
|
||||
}
|
||||
|
||||
// General case:
|
||||
|
|
@ -460,7 +461,7 @@ export async function toHaveScreenshot(
|
|||
return writeFiles();
|
||||
|
||||
const header = matcherHint(this, undefined, 'toHaveScreenshot', receiver, undefined, undefined, timedOut ? timeout : undefined);
|
||||
return helper.handleDifferent(actual, expectScreenshotOptions.expected, previous, diff, header, errorMessage, log);
|
||||
return helper.handleDifferent(actual, expectScreenshotOptions.expected, previous, diff, header, errorMessage, log, this._stepInfo);
|
||||
}
|
||||
|
||||
function writeFileSync(aPath: string, content: Buffer | string) {
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ export class TeleReporterEmitter implements ReporterV2 {
|
|||
id: (step as any)[this._idSymbol],
|
||||
duration: step.duration,
|
||||
error: step.error,
|
||||
attachments: step.attachments.map(a => result.attachments.indexOf(a)),
|
||||
attachments: step.attachments.length ? step.attachments.map(a => result.attachments.indexOf(a)) : undefined,
|
||||
annotations: step.annotations.length ? step.annotations : undefined,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export const declare: typeof import('../../bundles/babel/node_modules/@types/bab
|
|||
export const types: typeof import('../../bundles/babel/node_modules/@types/babel__core').types = require('./babelBundleImpl').types;
|
||||
export const traverse: typeof import('../../bundles/babel/node_modules/@types/babel__traverse').default = require('./babelBundleImpl').traverse;
|
||||
export type BabelPlugin = [string, any?];
|
||||
export type BabelTransformFunction = (code: string, filename: string, isModule: boolean, pluginsPrefix: BabelPlugin[], pluginsSuffix: BabelPlugin[]) => BabelFileResult;
|
||||
export type BabelTransformFunction = (code: string, filename: string, isModule: boolean, pluginsPrefix: BabelPlugin[], pluginsSuffix: BabelPlugin[]) => BabelFileResult | null;
|
||||
export const babelTransform: BabelTransformFunction = require('./babelBundleImpl').babelTransform;
|
||||
export type BabelParseFunction = (code: string, filename: string, isModule: boolean) => ParseResult;
|
||||
export const babelParse: BabelParseFunction = require('./babelBundleImpl').babelParse;
|
||||
|
|
|
|||
|
|
@ -232,9 +232,10 @@ export function transformHook(originalCode: string, filename: string, moduleUrl?
|
|||
|
||||
const { babelTransform }: { babelTransform: BabelTransformFunction } = require('./babelBundle');
|
||||
transformData = new Map<string, any>();
|
||||
const { code, map } = babelTransform(originalCode, filename, !!moduleUrl, pluginsPrologue, pluginsEpilogue);
|
||||
if (!code)
|
||||
return { code: '', serializedCache };
|
||||
const babelResult = babelTransform(originalCode, filename, !!moduleUrl, pluginsPrologue, pluginsEpilogue);
|
||||
if (!babelResult?.code)
|
||||
return { code: originalCode, serializedCache };
|
||||
const { code, map } = babelResult;
|
||||
const added = addToCache!(code, map, transformData);
|
||||
return { code, serializedCache: added.serializedCache };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -270,7 +270,7 @@ export class TestInfoImpl implements TestInfo {
|
|||
...data,
|
||||
steps: [],
|
||||
attachmentIndices,
|
||||
info: new TestStepInfoImpl(),
|
||||
info: new TestStepInfoImpl(this, stepId),
|
||||
complete: result => {
|
||||
if (step.endWallTime)
|
||||
return;
|
||||
|
|
@ -417,7 +417,7 @@ export class TestInfoImpl implements TestInfo {
|
|||
step.complete({});
|
||||
}
|
||||
|
||||
private _attach(attachment: TestInfo['attachments'][0], stepId: string | undefined) {
|
||||
_attach(attachment: TestInfo['attachments'][0], stepId: string | undefined) {
|
||||
const index = this._attachmentsPush(attachment) - 1;
|
||||
if (stepId) {
|
||||
this._stepMap.get(stepId)!.attachmentIndices.push(index);
|
||||
|
|
@ -510,6 +510,14 @@ export class TestInfoImpl implements TestInfo {
|
|||
export class TestStepInfoImpl implements TestStepInfo {
|
||||
annotations: Annotation[] = [];
|
||||
|
||||
private _testInfo: TestInfoImpl;
|
||||
private _stepId: string;
|
||||
|
||||
constructor(testInfo: TestInfoImpl, stepId: string) {
|
||||
this._testInfo = testInfo;
|
||||
this._stepId = stepId;
|
||||
}
|
||||
|
||||
async _runStepBody<T>(skip: boolean, body: (step: TestStepInfo) => T | Promise<T>) {
|
||||
if (skip) {
|
||||
this.annotations.push({ type: 'skip' });
|
||||
|
|
@ -524,6 +532,14 @@ export class TestStepInfoImpl implements TestStepInfo {
|
|||
}
|
||||
}
|
||||
|
||||
_attachToStep(attachment: TestInfo['attachments'][0]): void {
|
||||
this._testInfo._attach(attachment, this._stepId);
|
||||
}
|
||||
|
||||
async attach(name: string, options?: { body?: string | Buffer; contentType?: string; path?: string; }): Promise<void> {
|
||||
this._attachToStep(await normalizeAndSaveAttachment(this._testInfo.outputPath(), name, options));
|
||||
}
|
||||
|
||||
skip(...args: unknown[]) {
|
||||
// skip();
|
||||
// skip(condition: boolean, description: string);
|
||||
|
|
|
|||
|
|
@ -278,6 +278,8 @@ export class TestTracing {
|
|||
}
|
||||
|
||||
function serializeAttachments(attachments: Attachment[]): trace.AfterActionTraceEvent['attachments'] {
|
||||
if (attachments.length === 0)
|
||||
return undefined;
|
||||
return attachments.filter(a => a.name !== 'trace').map(a => {
|
||||
return {
|
||||
name: a.name,
|
||||
|
|
|
|||
|
|
@ -105,6 +105,10 @@ export class WorkerMain extends ProcessRunner {
|
|||
override async gracefullyClose() {
|
||||
try {
|
||||
await this._stop();
|
||||
if (!this._config) {
|
||||
// We never set anything up and we can crash on attempting cleanup
|
||||
return;
|
||||
}
|
||||
// Ignore top-level errors, they are already inside TestInfo.errors.
|
||||
const fakeTestInfo = new TestInfoImpl(this._config, this._project, this._params, undefined, 0, () => {}, () => {}, () => {});
|
||||
const runnable = { type: 'teardown' } as const;
|
||||
|
|
@ -190,15 +194,19 @@ export class WorkerMain extends ProcessRunner {
|
|||
if (this._config)
|
||||
return;
|
||||
|
||||
this._config = await deserializeConfig(this._params.config);
|
||||
this._project = this._config.projects.find(p => p.id === this._params.projectId)!;
|
||||
const config = await deserializeConfig(this._params.config);
|
||||
const project = config.projects.find(p => p.id === this._params.projectId);
|
||||
if (!project)
|
||||
throw new Error(`Project "${this._params.projectId}" not found in the worker process. Make sure project name does not change.`);
|
||||
this._config = config;
|
||||
this._project = project;
|
||||
this._poolBuilder = PoolBuilder.createForWorker(this._project);
|
||||
}
|
||||
|
||||
async runTestGroup(runPayload: RunPayload) {
|
||||
this._runFinished = new ManualPromise<void>();
|
||||
const entries = new Map(runPayload.entries.map(e => [e.testId, e]));
|
||||
let fatalUnknownTestIds;
|
||||
let fatalUnknownTestIds: string[] | undefined;
|
||||
try {
|
||||
await this._loadIfNeeded();
|
||||
const fileSuite = await loadTestFile(runPayload.file, this._config.config.rootDir);
|
||||
|
|
|
|||
70
packages/playwright/types/test.d.ts
vendored
70
packages/playwright/types/test.d.ts
vendored
|
|
@ -349,6 +349,10 @@ interface TestProject<TestArgs = {}, WorkerArgs = {}> {
|
|||
|
||||
/**
|
||||
* Project name is visible in the report and during test execution.
|
||||
*
|
||||
* **NOTE** Playwright executes the configuration file multiple times. Do not dynamically produce non-stable values in
|
||||
* your configuration.
|
||||
*
|
||||
*/
|
||||
name?: string;
|
||||
|
||||
|
|
@ -9571,6 +9575,72 @@ export interface TestInfoError {
|
|||
*
|
||||
*/
|
||||
export interface TestStepInfo {
|
||||
/**
|
||||
* Attach a value or a file from disk to the current test step. Some reporters show test step attachments. Either
|
||||
* [`path`](https://playwright.dev/docs/api/class-teststepinfo#test-step-info-attach-option-path) or
|
||||
* [`body`](https://playwright.dev/docs/api/class-teststepinfo#test-step-info-attach-option-body) must be specified,
|
||||
* but not both. Calling this method will attribute the attachment to the step, as opposed to
|
||||
* [testInfo.attach(name[, options])](https://playwright.dev/docs/api/class-testinfo#test-info-attach) which stores
|
||||
* all attachments at the test level.
|
||||
*
|
||||
* For example, you can attach a screenshot to the test step:
|
||||
*
|
||||
* ```js
|
||||
* import { test, expect } from '@playwright/test';
|
||||
*
|
||||
* test('basic test', async ({ page }) => {
|
||||
* await page.goto('https://playwright.dev');
|
||||
* await test.step('check page rendering', async step => {
|
||||
* const screenshot = await page.screenshot();
|
||||
* await step.attach('screenshot', { body: screenshot, contentType: 'image/png' });
|
||||
* });
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* Or you can attach files returned by your APIs:
|
||||
*
|
||||
* ```js
|
||||
* import { test, expect } from '@playwright/test';
|
||||
* import { download } from './my-custom-helpers';
|
||||
*
|
||||
* test('basic test', async ({}) => {
|
||||
* await test.step('check download behavior', async step => {
|
||||
* const tmpPath = await download('a');
|
||||
* await step.attach('downloaded', { path: tmpPath });
|
||||
* });
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* **NOTE**
|
||||
* [testStepInfo.attach(name[, options])](https://playwright.dev/docs/api/class-teststepinfo#test-step-info-attach)
|
||||
* automatically takes care of copying attached files to a location that is accessible to reporters. You can safely
|
||||
* remove the attachment after awaiting the attach call.
|
||||
*
|
||||
* @param name Attachment name. The name will also be sanitized and used as the prefix of file name when saving to disk.
|
||||
* @param options
|
||||
*/
|
||||
attach(name: string, options?: {
|
||||
/**
|
||||
* Attachment body. Mutually exclusive with
|
||||
* [`path`](https://playwright.dev/docs/api/class-teststepinfo#test-step-info-attach-option-path).
|
||||
*/
|
||||
body?: string|Buffer;
|
||||
|
||||
/**
|
||||
* Content type of this attachment to properly present in the report, for example `'application/json'` or
|
||||
* `'image/png'`. If omitted, content type is inferred based on the
|
||||
* [`path`](https://playwright.dev/docs/api/class-teststepinfo#test-step-info-attach-option-path), or defaults to
|
||||
* `text/plain` for [string] attachments and `application/octet-stream` for [Buffer] attachments.
|
||||
*/
|
||||
contentType?: string;
|
||||
|
||||
/**
|
||||
* Path on the filesystem to the attached file. Mutually exclusive with
|
||||
* [`body`](https://playwright.dev/docs/api/class-teststepinfo#test-step-info-attach-option-body).
|
||||
*/
|
||||
path?: string;
|
||||
}): Promise<void>;
|
||||
|
||||
/**
|
||||
* Unconditionally skip the currently running step. Test step is immediately aborted. This is similar to
|
||||
* [test.step.skip(title, body[, options])](https://playwright.dev/docs/api/class-test#test-step-skip).
|
||||
|
|
|
|||
43
packages/protocol/src/channels.d.ts
vendored
43
packages/protocol/src/channels.d.ts
vendored
|
|
@ -271,9 +271,40 @@ export type NameValue = {
|
|||
value: string,
|
||||
};
|
||||
|
||||
export type IndexedDBDatabase = {
|
||||
name: string,
|
||||
version: number,
|
||||
stores: {
|
||||
name: string,
|
||||
autoIncrement: boolean,
|
||||
keyPath?: string,
|
||||
keyPathArray?: string[],
|
||||
records: {
|
||||
key?: any,
|
||||
keyEncoded?: any,
|
||||
value?: any,
|
||||
valueEncoded?: any,
|
||||
}[],
|
||||
indexes: {
|
||||
name: string,
|
||||
keyPath?: string,
|
||||
keyPathArray?: string[],
|
||||
multiEntry: boolean,
|
||||
unique: boolean,
|
||||
}[],
|
||||
}[],
|
||||
};
|
||||
|
||||
export type SetOriginStorage = {
|
||||
origin: string,
|
||||
localStorage: NameValue[],
|
||||
indexedDB?: IndexedDBDatabase[],
|
||||
};
|
||||
|
||||
export type OriginStorage = {
|
||||
origin: string,
|
||||
localStorage: NameValue[],
|
||||
indexedDB: IndexedDBDatabase[],
|
||||
};
|
||||
|
||||
export type SerializedError = {
|
||||
|
|
@ -610,7 +641,7 @@ export type PlaywrightNewRequestParams = {
|
|||
timeout?: number,
|
||||
storageState?: {
|
||||
cookies?: NetworkCookie[],
|
||||
origins?: OriginStorage[],
|
||||
origins?: SetOriginStorage[],
|
||||
},
|
||||
tracesDir?: string,
|
||||
};
|
||||
|
|
@ -641,7 +672,7 @@ export type PlaywrightNewRequestOptions = {
|
|||
timeout?: number,
|
||||
storageState?: {
|
||||
cookies?: NetworkCookie[],
|
||||
origins?: OriginStorage[],
|
||||
origins?: SetOriginStorage[],
|
||||
},
|
||||
tracesDir?: string,
|
||||
};
|
||||
|
|
@ -1223,7 +1254,7 @@ export type BrowserNewContextParams = {
|
|||
},
|
||||
storageState?: {
|
||||
cookies?: SetNetworkCookie[],
|
||||
origins?: OriginStorage[],
|
||||
origins?: SetOriginStorage[],
|
||||
},
|
||||
};
|
||||
export type BrowserNewContextOptions = {
|
||||
|
|
@ -1290,7 +1321,7 @@ export type BrowserNewContextOptions = {
|
|||
},
|
||||
storageState?: {
|
||||
cookies?: SetNetworkCookie[],
|
||||
origins?: OriginStorage[],
|
||||
origins?: SetOriginStorage[],
|
||||
},
|
||||
};
|
||||
export type BrowserNewContextResult = {
|
||||
|
|
@ -1360,7 +1391,7 @@ export type BrowserNewContextForReuseParams = {
|
|||
},
|
||||
storageState?: {
|
||||
cookies?: SetNetworkCookie[],
|
||||
origins?: OriginStorage[],
|
||||
origins?: SetOriginStorage[],
|
||||
},
|
||||
};
|
||||
export type BrowserNewContextForReuseOptions = {
|
||||
|
|
@ -1427,7 +1458,7 @@ export type BrowserNewContextForReuseOptions = {
|
|||
},
|
||||
storageState?: {
|
||||
cookies?: SetNetworkCookie[],
|
||||
origins?: OriginStorage[],
|
||||
origins?: SetOriginStorage[],
|
||||
},
|
||||
};
|
||||
export type BrowserNewContextForReuseResult = {
|
||||
|
|
|
|||
|
|
@ -222,6 +222,54 @@ NameValue:
|
|||
name: string
|
||||
value: string
|
||||
|
||||
IndexedDBDatabase:
|
||||
type: object
|
||||
properties:
|
||||
name: string
|
||||
version: number
|
||||
stores:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name: string
|
||||
autoIncrement: boolean
|
||||
keyPath: string?
|
||||
keyPathArray:
|
||||
type: array?
|
||||
items: string
|
||||
records:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key: json?
|
||||
keyEncoded: json?
|
||||
value: json?
|
||||
valueEncoded: json?
|
||||
indexes:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name: string
|
||||
keyPath: string?
|
||||
keyPathArray:
|
||||
type: array?
|
||||
items: string
|
||||
multiEntry: boolean
|
||||
unique: boolean
|
||||
|
||||
SetOriginStorage:
|
||||
type: object
|
||||
properties:
|
||||
origin: string
|
||||
localStorage:
|
||||
type: array
|
||||
items: NameValue
|
||||
indexedDB:
|
||||
type: array?
|
||||
items: IndexedDBDatabase
|
||||
|
||||
OriginStorage:
|
||||
type: object
|
||||
|
|
@ -230,7 +278,9 @@ OriginStorage:
|
|||
localStorage:
|
||||
type: array
|
||||
items: NameValue
|
||||
|
||||
indexedDB:
|
||||
type: array
|
||||
items: IndexedDBDatabase
|
||||
|
||||
SerializedError:
|
||||
type: object
|
||||
|
|
@ -736,7 +786,7 @@ Playwright:
|
|||
items: NetworkCookie
|
||||
origins:
|
||||
type: array?
|
||||
items: OriginStorage
|
||||
items: SetOriginStorage
|
||||
tracesDir: string?
|
||||
|
||||
returns:
|
||||
|
|
@ -970,7 +1020,7 @@ Browser:
|
|||
items: SetNetworkCookie
|
||||
origins:
|
||||
type: array?
|
||||
items: OriginStorage
|
||||
items: SetOriginStorage
|
||||
returns:
|
||||
context: BrowserContext
|
||||
|
||||
|
|
@ -992,7 +1042,7 @@ Browser:
|
|||
items: SetNetworkCookie
|
||||
origins:
|
||||
type: array?
|
||||
items: OriginStorage
|
||||
items: SetOriginStorage
|
||||
returns:
|
||||
context: BrowserContext
|
||||
|
||||
|
|
|
|||
116
tests/assets/to-do-notifications/LICENSE
Normal file
116
tests/assets/to-do-notifications/LICENSE
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
CC0 1.0 Universal
|
||||
|
||||
Statement of Purpose
|
||||
|
||||
The laws of most jurisdictions throughout the world automatically confer
|
||||
exclusive Copyright and Related Rights (defined below) upon the creator and
|
||||
subsequent owner(s) (each and all, an "owner") of an original work of
|
||||
authorship and/or a database (each, a "Work").
|
||||
|
||||
Certain owners wish to permanently relinquish those rights to a Work for the
|
||||
purpose of contributing to a commons of creative, cultural and scientific
|
||||
works ("Commons") that the public can reliably and without fear of later
|
||||
claims of infringement build upon, modify, incorporate in other works, reuse
|
||||
and redistribute as freely as possible in any form whatsoever and for any
|
||||
purposes, including without limitation commercial purposes. These owners may
|
||||
contribute to the Commons to promote the ideal of a free culture and the
|
||||
further production of creative, cultural and scientific works, or to gain
|
||||
reputation or greater distribution for their Work in part through the use and
|
||||
efforts of others.
|
||||
|
||||
For these and/or other purposes and motivations, and without any expectation
|
||||
of additional consideration or compensation, the person associating CC0 with a
|
||||
Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
|
||||
and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
|
||||
and publicly distribute the Work under its terms, with knowledge of his or her
|
||||
Copyright and Related Rights in the Work and the meaning and intended legal
|
||||
effect of CC0 on those rights.
|
||||
|
||||
1. Copyright and Related Rights. A Work made available under CC0 may be
|
||||
protected by copyright and related or neighboring rights ("Copyright and
|
||||
Related Rights"). Copyright and Related Rights include, but are not limited
|
||||
to, the following:
|
||||
|
||||
i. the right to reproduce, adapt, distribute, perform, display, communicate,
|
||||
and translate a Work;
|
||||
|
||||
ii. moral rights retained by the original author(s) and/or performer(s);
|
||||
|
||||
iii. publicity and privacy rights pertaining to a person's image or likeness
|
||||
depicted in a Work;
|
||||
|
||||
iv. rights protecting against unfair competition in regards to a Work,
|
||||
subject to the limitations in paragraph 4(a), below;
|
||||
|
||||
v. rights protecting the extraction, dissemination, use and reuse of data in
|
||||
a Work;
|
||||
|
||||
vi. database rights (such as those arising under Directive 96/9/EC of the
|
||||
European Parliament and of the Council of 11 March 1996 on the legal
|
||||
protection of databases, and under any national implementation thereof,
|
||||
including any amended or successor version of such directive); and
|
||||
|
||||
vii. other similar, equivalent or corresponding rights throughout the world
|
||||
based on applicable law or treaty, and any national implementations thereof.
|
||||
|
||||
2. Waiver. To the greatest extent permitted by, but not in contravention of,
|
||||
applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
|
||||
unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
|
||||
and Related Rights and associated claims and causes of action, whether now
|
||||
known or unknown (including existing as well as future claims and causes of
|
||||
action), in the Work (i) in all territories worldwide, (ii) for the maximum
|
||||
duration provided by applicable law or treaty (including future time
|
||||
extensions), (iii) in any current or future medium and for any number of
|
||||
copies, and (iv) for any purpose whatsoever, including without limitation
|
||||
commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes
|
||||
the Waiver for the benefit of each member of the public at large and to the
|
||||
detriment of Affirmer's heirs and successors, fully intending that such Waiver
|
||||
shall not be subject to revocation, rescission, cancellation, termination, or
|
||||
any other legal or equitable action to disrupt the quiet enjoyment of the Work
|
||||
by the public as contemplated by Affirmer's express Statement of Purpose.
|
||||
|
||||
3. Public License Fallback. Should any part of the Waiver for any reason be
|
||||
judged legally invalid or ineffective under applicable law, then the Waiver
|
||||
shall be preserved to the maximum extent permitted taking into account
|
||||
Affirmer's express Statement of Purpose. In addition, to the extent the Waiver
|
||||
is so judged Affirmer hereby grants to each affected person a royalty-free,
|
||||
non transferable, non sublicensable, non exclusive, irrevocable and
|
||||
unconditional license to exercise Affirmer's Copyright and Related Rights in
|
||||
the Work (i) in all territories worldwide, (ii) for the maximum duration
|
||||
provided by applicable law or treaty (including future time extensions), (iii)
|
||||
in any current or future medium and for any number of copies, and (iv) for any
|
||||
purpose whatsoever, including without limitation commercial, advertising or
|
||||
promotional purposes (the "License"). The License shall be deemed effective as
|
||||
of the date CC0 was applied by Affirmer to the Work. Should any part of the
|
||||
License for any reason be judged legally invalid or ineffective under
|
||||
applicable law, such partial invalidity or ineffectiveness shall not
|
||||
invalidate the remainder of the License, and in such case Affirmer hereby
|
||||
affirms that he or she will not (i) exercise any of his or her remaining
|
||||
Copyright and Related Rights in the Work or (ii) assert any associated claims
|
||||
and causes of action with respect to the Work, in either case contrary to
|
||||
Affirmer's express Statement of Purpose.
|
||||
|
||||
4. Limitations and Disclaimers.
|
||||
|
||||
a. No trademark or patent rights held by Affirmer are waived, abandoned,
|
||||
surrendered, licensed or otherwise affected by this document.
|
||||
|
||||
b. Affirmer offers the Work as-is and makes no representations or warranties
|
||||
of any kind concerning the Work, express, implied, statutory or otherwise,
|
||||
including without limitation warranties of title, merchantability, fitness
|
||||
for a particular purpose, non infringement, or the absence of latent or
|
||||
other defects, accuracy, or the present or absence of errors, whether or not
|
||||
discoverable, all to the greatest extent permissible under applicable law.
|
||||
|
||||
c. Affirmer disclaims responsibility for clearing rights of other persons
|
||||
that may apply to the Work or any use thereof, including without limitation
|
||||
any person's Copyright and Related Rights in the Work. Further, Affirmer
|
||||
disclaims responsibility for obtaining any necessary consents, permissions
|
||||
or other rights required for any use of the Work.
|
||||
|
||||
d. Affirmer understands and acknowledges that Creative Commons is not a
|
||||
party to this document and has no duty or obligation with respect to this
|
||||
CC0 or use of the Work.
|
||||
|
||||
For more information, please see
|
||||
<http://creativecommons.org/publicdomain/zero/1.0/>
|
||||
1
tests/assets/to-do-notifications/README.md
Normal file
1
tests/assets/to-do-notifications/README.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
Source: https://github.com/mdn/dom-examples/tree/main/to-do-notifications
|
||||
108
tests/assets/to-do-notifications/index.html
Normal file
108
tests/assets/to-do-notifications/index.html
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<!DOCTYPE HTML>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=380">
|
||||
<script src="scripts/todo.js"></script>
|
||||
<title>To-do list with Notifications</title>
|
||||
<link href="style/style.css" type="text/css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>To-do list</h1>
|
||||
|
||||
<div class="task-box">
|
||||
|
||||
<ul id="task-list">
|
||||
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="form-box">
|
||||
<h2>Add new to-do item.</h2>
|
||||
|
||||
<form id="task-form" action="index.html">
|
||||
<div class="full-width"><label for="title">Task title:</label><input type="text" id="title" required></div>
|
||||
<div class="half-width"><label for="deadline-hours">Hours (hh):</label><input type="number" id="deadline-hours" required></div>
|
||||
<div class="half-width"><label for="deadline-minutes">Mins (mm):</label><input type="number" id="deadline-minutes" required></div>
|
||||
<div class="third-width"><label for="deadline-day">Day:</label>
|
||||
<select id="deadline-day" required>
|
||||
<option value="01">01</option>
|
||||
<option value="02">02</option>
|
||||
<option value="03">03</option>
|
||||
<option value="04">04</option>
|
||||
<option value="05">05</option>
|
||||
<option value="06">06</option>
|
||||
<option value="07">07</option>
|
||||
<option value="08">08</option>
|
||||
<option value="09">09</option>
|
||||
<option value="10">10</option>
|
||||
<option value="11">11</option>
|
||||
<option value="12">12</option>
|
||||
<option value="13">13</option>
|
||||
<option value="14">14</option>
|
||||
<option value="15">15</option>
|
||||
<option value="16">16</option>
|
||||
<option value="17">17</option>
|
||||
<option value="18">18</option>
|
||||
<option value="19">19</option>
|
||||
<option value="20">20</option>
|
||||
<option value="21">21</option>
|
||||
<option value="22">22</option>
|
||||
<option value="23">23</option>
|
||||
<option value="24">24</option>
|
||||
<option value="25">25</option>
|
||||
<option value="26">26</option>
|
||||
<option value="27">27</option>
|
||||
<option value="28">28</option>
|
||||
<option value="29">29</option>
|
||||
<option value="30">30</option>
|
||||
<option value="31">31</option>
|
||||
</select></div>
|
||||
|
||||
<div class="third-width"><label for="deadline-month">Month:</label>
|
||||
<select id="deadline-month" required>
|
||||
<option value="January">January</option>
|
||||
<option value="February">February</option>
|
||||
<option value="March">March</option>
|
||||
<option value="April">April</option>
|
||||
<option value="May">May</option>
|
||||
<option value="June">June</option>
|
||||
<option value="July">July</option>
|
||||
<option value="August">August</option>
|
||||
<option value="September">September</option>
|
||||
<option value="October">October</option>
|
||||
<option value="November">November</option>
|
||||
<option value="December">December</option>
|
||||
</select></div>
|
||||
|
||||
<div class="third-width"><label for="deadline-year">Year:</label>
|
||||
<select id="deadline-year" required>
|
||||
<option value="2025">2025</option>
|
||||
<option value="2024">2024</option>
|
||||
<option value="2023">2023</option>
|
||||
<option value="2022">2022</option>
|
||||
<option value="2021">2021</option>
|
||||
<option value="2020">2020</option>
|
||||
<option value="2019">2019</option>
|
||||
<option value="2018">2018</option>
|
||||
</select></div>
|
||||
|
||||
<div><input type="submit" id="submit" value="Add Task"></div>
|
||||
<div></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="toolbar">
|
||||
<ul id="notifications">
|
||||
|
||||
</ul>
|
||||
|
||||
<button id="enable">
|
||||
Enable notifications
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
18
tests/assets/to-do-notifications/manifest.webapp
Normal file
18
tests/assets/to-do-notifications/manifest.webapp
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"version": "0.1",
|
||||
"name": "To-do list",
|
||||
"description": "Store to-do items on your device, and be notified when the deadlines are up.",
|
||||
"launch_path": "/to-do-notifications/index.html",
|
||||
"icons": {
|
||||
"128": "/to-do-notifications/img/icon-128.png"
|
||||
},
|
||||
"developer": {
|
||||
"name": "Chris Mills",
|
||||
"url": "http://chrisdavidmills.github.io/to-do-notifications/"
|
||||
},
|
||||
"permissions": {
|
||||
"desktop-notification": {
|
||||
"description": "Needed for creating system notifications."
|
||||
}
|
||||
}
|
||||
}
|
||||
354
tests/assets/to-do-notifications/scripts/todo.js
Normal file
354
tests/assets/to-do-notifications/scripts/todo.js
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
window.onload = () => {
|
||||
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
|
||||
// Hold an instance of a db object for us to store the IndexedDB data in
|
||||
let db;
|
||||
|
||||
// Create a reference to the notifications list in the bottom of the app; we will write database messages into this list by
|
||||
// appending list items as children of this element
|
||||
const note = document.getElementById('notifications');
|
||||
|
||||
// All other UI elements we need for the app
|
||||
const taskList = document.getElementById('task-list');
|
||||
const taskForm = document.getElementById('task-form');
|
||||
const title = document.getElementById('title');
|
||||
const hours = document.getElementById('deadline-hours');
|
||||
const minutes = document.getElementById('deadline-minutes');
|
||||
const day = document.getElementById('deadline-day');
|
||||
const month = document.getElementById('deadline-month');
|
||||
const year = document.getElementById('deadline-year');
|
||||
const notificationBtn = document.getElementById('enable');
|
||||
|
||||
// Do an initial check to see what the notification permission state is
|
||||
if (Notification.permission === 'denied' || Notification.permission === 'default') {
|
||||
notificationBtn.style.display = 'block';
|
||||
} else {
|
||||
notificationBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
note.appendChild(createListItem('App initialised.'));
|
||||
|
||||
// Let us open our database
|
||||
const DBOpenRequest = window.indexedDB.open('toDoList', 4);
|
||||
|
||||
// Register two event handlers to act on the database being opened successfully, or not
|
||||
DBOpenRequest.onerror = (event) => {
|
||||
note.appendChild(createListItem('Error loading database.'));
|
||||
};
|
||||
|
||||
DBOpenRequest.onsuccess = (event) => {
|
||||
note.appendChild(createListItem('Database initialised.'));
|
||||
|
||||
// Store the result of opening the database in the db variable. This is used a lot below
|
||||
db = DBOpenRequest.result;
|
||||
|
||||
// Run the displayData() function to populate the task list with all the to-do list data already in the IndexedDB
|
||||
displayData();
|
||||
};
|
||||
|
||||
// This event handles the event whereby a new version of the database needs to be created
|
||||
// Either one has not been created before, or a new version number has been submitted via the
|
||||
// window.indexedDB.open line above
|
||||
//it is only implemented in recent browsers
|
||||
DBOpenRequest.onupgradeneeded = (event) => {
|
||||
db = event.target.result;
|
||||
|
||||
db.onerror = (event) => {
|
||||
note.appendChild(createListItem('Error loading database.'));
|
||||
};
|
||||
|
||||
// Create an objectStore for this database
|
||||
const objectStore = db.createObjectStore('toDoList', { keyPath: 'taskTitle' });
|
||||
|
||||
// Define what data items the objectStore will contain
|
||||
objectStore.createIndex('hours', 'hours', { unique: false });
|
||||
objectStore.createIndex('minutes', 'minutes', { unique: false });
|
||||
objectStore.createIndex('day', 'day', { unique: false });
|
||||
objectStore.createIndex('month', 'month', { unique: false });
|
||||
objectStore.createIndex('year', 'year', { unique: false });
|
||||
|
||||
objectStore.createIndex('notified', 'notified', { unique: false });
|
||||
|
||||
note.appendChild(createListItem('Object store created.'));
|
||||
};
|
||||
|
||||
function displayData() {
|
||||
// First clear the content of the task list so that you don't get a huge long list of duplicate stuff each time
|
||||
// the display is updated.
|
||||
while (taskList.firstChild) {
|
||||
taskList.removeChild(taskList.lastChild);
|
||||
}
|
||||
|
||||
// Open our object store and then get a cursor list of all the different data items in the IDB to iterate through
|
||||
const objectStore = db.transaction('toDoList').objectStore('toDoList');
|
||||
objectStore.openCursor().onsuccess = (event) => {
|
||||
const cursor = event.target.result;
|
||||
// Check if there are no (more) cursor items to iterate through
|
||||
if (!cursor) {
|
||||
// No more items to iterate through, we quit.
|
||||
note.appendChild(createListItem('Entries all displayed.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check which suffix the deadline day of the month needs
|
||||
const { hours, minutes, day, month, year, notified, taskTitle } = cursor.value;
|
||||
const ordDay = ordinal(day);
|
||||
|
||||
// Build the to-do list entry and put it into the list item.
|
||||
const toDoText = `${taskTitle} — ${hours}:${minutes}, ${month} ${ordDay} ${year}.`;
|
||||
const listItem = createListItem(toDoText);
|
||||
|
||||
if (notified === 'yes') {
|
||||
listItem.style.textDecoration = 'line-through';
|
||||
listItem.style.color = 'rgba(255, 0, 0, 0.5)';
|
||||
}
|
||||
|
||||
// Put the item item inside the task list
|
||||
taskList.appendChild(listItem);
|
||||
|
||||
// Create a delete button inside each list item,
|
||||
const deleteButton = document.createElement('button');
|
||||
listItem.appendChild(deleteButton);
|
||||
deleteButton.textContent = 'X';
|
||||
|
||||
// Set a data attribute on our delete button to associate the task it relates to.
|
||||
deleteButton.setAttribute('data-task', taskTitle);
|
||||
|
||||
// Associate action (deletion) when clicked
|
||||
deleteButton.onclick = (event) => {
|
||||
deleteItem(event);
|
||||
};
|
||||
|
||||
// continue on to the next item in the cursor
|
||||
cursor.continue();
|
||||
};
|
||||
};
|
||||
|
||||
// Add listener for clicking the submit button
|
||||
taskForm.addEventListener('submit', addData, false);
|
||||
|
||||
function addData(e) {
|
||||
// Prevent default, as we don't want the form to submit in the conventional way
|
||||
e.preventDefault();
|
||||
|
||||
// Stop the form submitting if any values are left empty.
|
||||
// This should never happen as there is the required attribute
|
||||
if (title.value === '' || hours.value === null || minutes.value === null || day.value === '' || month.value === '' || year.value === null) {
|
||||
note.appendChild(createListItem('Data not submitted — form incomplete.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Grab the values entered into the form fields and store them in an object ready for being inserted into the IndexedDB
|
||||
const newItem = [
|
||||
{ taskTitle: title.value, hours: hours.value, minutes: minutes.value, day: day.value, month: month.value, year: year.value, notified: 'no' },
|
||||
];
|
||||
|
||||
// Open a read/write DB transaction, ready for adding the data
|
||||
const transaction = db.transaction(['toDoList'], 'readwrite');
|
||||
|
||||
// Report on the success of the transaction completing, when everything is done
|
||||
transaction.oncomplete = () => {
|
||||
note.appendChild(createListItem('Transaction completed: database modification finished.'));
|
||||
|
||||
// Update the display of data to show the newly added item, by running displayData() again.
|
||||
displayData();
|
||||
};
|
||||
|
||||
// Handler for any unexpected error
|
||||
transaction.onerror = () => {
|
||||
note.appendChild(createListItem(`Transaction not opened due to error: ${transaction.error}`));
|
||||
};
|
||||
|
||||
// Call an object store that's already been added to the database
|
||||
const objectStore = transaction.objectStore('toDoList');
|
||||
console.log(objectStore.indexNames);
|
||||
console.log(objectStore.keyPath);
|
||||
console.log(objectStore.name);
|
||||
console.log(objectStore.transaction);
|
||||
console.log(objectStore.autoIncrement);
|
||||
|
||||
// Make a request to add our newItem object to the object store
|
||||
const objectStoreRequest = objectStore.add(newItem[0]);
|
||||
objectStoreRequest.onsuccess = (event) => {
|
||||
|
||||
// Report the success of our request
|
||||
// (to detect whether it has been succesfully
|
||||
// added to the database, you'd look at transaction.oncomplete)
|
||||
note.appendChild(createListItem('Request successful.'));
|
||||
|
||||
// Clear the form, ready for adding the next entry
|
||||
title.value = '';
|
||||
hours.value = null;
|
||||
minutes.value = null;
|
||||
day.value = 01;
|
||||
month.value = 'January';
|
||||
year.value = 2020;
|
||||
};
|
||||
};
|
||||
|
||||
function deleteItem(event) {
|
||||
// Retrieve the name of the task we want to delete
|
||||
const dataTask = event.target.getAttribute('data-task');
|
||||
|
||||
// Open a database transaction and delete the task, finding it by the name we retrieved above
|
||||
const transaction = db.transaction(['toDoList'], 'readwrite');
|
||||
transaction.objectStore('toDoList').delete(dataTask);
|
||||
|
||||
// Report that the data item has been deleted
|
||||
transaction.oncomplete = () => {
|
||||
// Delete the parent of the button, which is the list item, so it is no longer displayed
|
||||
event.target.parentNode.parentNode.removeChild(event.target.parentNode);
|
||||
note.appendChild(createListItem(`Task "${dataTask}" deleted.`));
|
||||
};
|
||||
};
|
||||
|
||||
// Check whether the deadline for each task is up or not, and responds appropriately
|
||||
function checkDeadlines() {
|
||||
// First of all check whether notifications are enabled or denied
|
||||
if (Notification.permission === 'denied' || Notification.permission === 'default') {
|
||||
notificationBtn.style.display = 'block';
|
||||
} else {
|
||||
notificationBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
// Grab the current time and date
|
||||
const now = new Date();
|
||||
|
||||
// From the now variable, store the current minutes, hours, day of the month, month, year and seconds
|
||||
const minuteCheck = now.getMinutes();
|
||||
const hourCheck = now.getHours();
|
||||
const dayCheck = now.getDate(); // Do not use getDay() that returns the day of the week, 1 to 7
|
||||
const monthCheck = now.getMonth();
|
||||
const yearCheck = now.getFullYear(); // Do not use getYear() that is deprecated.
|
||||
|
||||
// Open a new transaction
|
||||
const objectStore = db.transaction(['toDoList'], 'readwrite').objectStore('toDoList');
|
||||
|
||||
// Open a cursor to iterate through all the data items in the IndexedDB
|
||||
objectStore.openCursor().onsuccess = (event) => {
|
||||
const cursor = event.target.result;
|
||||
if (!cursor) return;
|
||||
const { hours, minutes, day, month, year, notified, taskTitle } = cursor.value;
|
||||
|
||||
// convert the month names we have installed in the IDB into a month number that JavaScript will understand.
|
||||
// The JavaScript date object creates month values as a number between 0 and 11.
|
||||
const monthNumber = MONTHS.indexOf(month);
|
||||
if (monthNumber === -1) throw new Error('Incorrect month entered in database.');
|
||||
|
||||
// Check if the current hours, minutes, day, month and year values match the stored values for each task.
|
||||
// The parseInt() function transforms the value from a string to a number for comparison
|
||||
// (taking care of leading zeros, and removing spaces and underscores from the string).
|
||||
let matched = parseInt(hours) === hourCheck;
|
||||
matched &&= parseInt(minutes) === minuteCheck;
|
||||
matched &&= parseInt(day) === dayCheck;
|
||||
matched &&= parseInt(monthNumber) === monthCheck;
|
||||
matched &&= parseInt(year) === yearCheck;
|
||||
if (matched && notified === 'no') {
|
||||
// If the numbers all do match, run the createNotification() function to create a system notification
|
||||
// but only if the permission is set
|
||||
if (Notification.permission === 'granted') {
|
||||
createNotification(taskTitle);
|
||||
}
|
||||
}
|
||||
|
||||
// Move on to the next cursor item
|
||||
cursor.continue();
|
||||
};
|
||||
};
|
||||
|
||||
// Ask for permission when the 'Enable notifications' button is clicked
|
||||
function askNotificationPermission() {
|
||||
// Function to actually ask the permissions
|
||||
function handlePermission(permission) {
|
||||
// Whatever the user answers, we make sure Chrome stores the information
|
||||
if (!Reflect.has(Notification, 'permission')) {
|
||||
Notification.permission = permission;
|
||||
}
|
||||
|
||||
// Set the button to shown or hidden, depending on what the user answers
|
||||
if (Notification.permission === 'denied' || Notification.permission === 'default') {
|
||||
notificationBtn.style.display = 'block';
|
||||
} else {
|
||||
notificationBtn.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
// Check if the browser supports notifications
|
||||
if (!Reflect.has(window, 'Notification')) {
|
||||
console.log('This browser does not support notifications.');
|
||||
} else {
|
||||
if (checkNotificationPromise()) {
|
||||
Notification.requestPermission().then(handlePermission);
|
||||
} else {
|
||||
Notification.requestPermission(handlePermission);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Check whether browser supports the promise version of requestPermission()
|
||||
// Safari only supports the old callback-based version
|
||||
function checkNotificationPromise() {
|
||||
try {
|
||||
Notification.requestPermission().then();
|
||||
} catch(e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Wire up notification permission functionality to 'Enable notifications' button
|
||||
notificationBtn.addEventListener('click', askNotificationPermission);
|
||||
|
||||
function createListItem(contents) {
|
||||
const listItem = document.createElement('li');
|
||||
listItem.textContent = contents;
|
||||
return listItem;
|
||||
};
|
||||
|
||||
// Create a notification with the given title
|
||||
function createNotification(title) {
|
||||
// Create and show the notification
|
||||
const img = '/to-do-notifications/img/icon-128.png';
|
||||
const text = `HEY! Your task "${title}" is now overdue.`;
|
||||
const notification = new Notification('To do list', { body: text, icon: img });
|
||||
|
||||
// We need to update the value of notified to 'yes' in this particular data object, so the
|
||||
// notification won't be set off on it again
|
||||
|
||||
// First open up a transaction
|
||||
const objectStore = db.transaction(['toDoList'], 'readwrite').objectStore('toDoList');
|
||||
|
||||
// Get the to-do list object that has this title as its title
|
||||
const objectStoreTitleRequest = objectStore.get(title);
|
||||
|
||||
objectStoreTitleRequest.onsuccess = () => {
|
||||
// Grab the data object returned as the result
|
||||
const data = objectStoreTitleRequest.result;
|
||||
|
||||
// Update the notified value in the object to 'yes'
|
||||
data.notified = 'yes';
|
||||
|
||||
// Create another request that inserts the item back into the database
|
||||
const updateTitleRequest = objectStore.put(data);
|
||||
|
||||
// When this new request succeeds, run the displayData() function again to update the display
|
||||
updateTitleRequest.onsuccess = () => {
|
||||
displayData();
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// Using a setInterval to run the checkDeadlines() function every second
|
||||
setInterval(checkDeadlines, 1000);
|
||||
}
|
||||
|
||||
// Helper function returning the day of the month followed by an ordinal (st, nd, or rd)
|
||||
function ordinal(day) {
|
||||
const n = day.toString();
|
||||
const last = n.slice(-1);
|
||||
if (last === '1' && n !== '11') return `${n}st`;
|
||||
if (last === '2' && n !== '12') return `${n}nd`;
|
||||
if (last === '3' && n !== '13') return `${n}rd`;
|
||||
return `${n}th`;
|
||||
};
|
||||
248
tests/assets/to-do-notifications/style/style.css
Normal file
248
tests/assets/to-do-notifications/style/style.css
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
/* Basic set up + sizing for containers */
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 10px;
|
||||
font-family: Georgia, "Times New Roman", Times, serif;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 50rem;
|
||||
position: relative;
|
||||
background: #d88;
|
||||
margin: 0 auto;
|
||||
border-left: 2px solid #d33;
|
||||
border-right: 2px solid #d33;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
text-align: center;
|
||||
background: #d88;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 6rem;
|
||||
margin: 0;
|
||||
background: #d66;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2.4rem;
|
||||
}
|
||||
|
||||
/* Bottom toolbar styling */
|
||||
|
||||
#toolbar {
|
||||
position: relative;
|
||||
height: 6rem;
|
||||
width: 100%;
|
||||
background: #d66;
|
||||
border-top: 2px solid #d33;
|
||||
border-bottom: 2px solid #d33;
|
||||
}
|
||||
|
||||
#enable,
|
||||
input[type="submit"] {
|
||||
line-height: 1.8;
|
||||
font-size: 1.3rem;
|
||||
border-radius: 5px;
|
||||
border: 1px solid black;
|
||||
color: black;
|
||||
text-shadow: 1px 1px 1px black;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow:
|
||||
inset 0px 5px 3px rgba(255, 255, 255, 0.2),
|
||||
inset 0px -5px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
#enable {
|
||||
position: absolute;
|
||||
bottom: 0.3rem;
|
||||
right: 0.3rem;
|
||||
}
|
||||
|
||||
#notifications {
|
||||
margin: 0;
|
||||
position: relative;
|
||||
padding: 0.3rem;
|
||||
background: #ddd;
|
||||
position: absolute;
|
||||
top: 0rem;
|
||||
left: 0rem;
|
||||
height: 5.4rem;
|
||||
width: 50%;
|
||||
overflow: auto;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
#notifications li {
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
/* New item form styling */
|
||||
|
||||
.form-box {
|
||||
background: #d66;
|
||||
width: 85%;
|
||||
padding: 1rem;
|
||||
margin: 2rem auto;
|
||||
box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
form div {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
form .full-width {
|
||||
margin: 1rem auto 2rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
form .half-width {
|
||||
width: 50%;
|
||||
float: left;
|
||||
}
|
||||
|
||||
form .third-width {
|
||||
width: 33%;
|
||||
float: left;
|
||||
}
|
||||
|
||||
form div label {
|
||||
width: 10rem;
|
||||
float: left;
|
||||
padding-right: 1rem;
|
||||
font-size: 1.6rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
form .full-width input {
|
||||
width: 30rem;
|
||||
}
|
||||
|
||||
form .half-width input {
|
||||
width: 8.75rem;
|
||||
}
|
||||
|
||||
form .third-width select {
|
||||
width: 13.5rem;
|
||||
}
|
||||
|
||||
form div input[type="submit"] {
|
||||
clear: both;
|
||||
width: 20rem;
|
||||
display: block;
|
||||
height: 3rem;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
top: 0.5rem;
|
||||
}
|
||||
|
||||
/* || tasks box */
|
||||
|
||||
.task-box {
|
||||
width: 85%;
|
||||
padding: 1rem;
|
||||
margin: 2rem auto;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.task-box ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.task-box li {
|
||||
list-style-type: none;
|
||||
padding: 1rem;
|
||||
border-bottom: 2px solid #d33;
|
||||
}
|
||||
|
||||
.task-box li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.task-box li:last-child {
|
||||
margin-bottom: 0rem;
|
||||
}
|
||||
|
||||
.task-box button {
|
||||
margin-left: 2rem;
|
||||
font-size: 1.6rem;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 5px;
|
||||
box-shadow: inset 0 -2px 5px rgba(0, 0, 0, 0.5) 1px 1px 1px black;
|
||||
}
|
||||
|
||||
/* setting cursor for interactive controls */
|
||||
|
||||
button,
|
||||
input[type="submit"],
|
||||
select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* media query for small screens */
|
||||
|
||||
@media (max-width: 32rem) {
|
||||
body {
|
||||
width: 100%;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
form div {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
form .full-width {
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
form .half-width {
|
||||
width: 100%;
|
||||
float: none;
|
||||
}
|
||||
|
||||
form .third-width {
|
||||
width: 100%;
|
||||
float: none;
|
||||
}
|
||||
|
||||
form div label {
|
||||
width: 36%;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
form input,
|
||||
form select,
|
||||
form label {
|
||||
line-height: 2.5rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
form .full-width input {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
form .half-width input {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
form .third-width select {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
#enable {
|
||||
right: 1rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -40,12 +40,14 @@ it('should capture local storage', async ({ contextFactory }) => {
|
|||
name: 'name2',
|
||||
value: 'value2'
|
||||
}],
|
||||
indexedDB: [],
|
||||
}, {
|
||||
origin: 'https://www.example.com',
|
||||
localStorage: [{
|
||||
name: 'name1',
|
||||
value: 'value1'
|
||||
}],
|
||||
indexedDB: [],
|
||||
}]);
|
||||
});
|
||||
|
||||
|
|
@ -81,9 +83,25 @@ it('should round-trip through the file', async ({ contextFactory }, testInfo) =>
|
|||
route.fulfill({ body: '<html></html>' }).catch(() => {});
|
||||
});
|
||||
await page1.goto('https://www.example.com');
|
||||
await page1.evaluate(() => {
|
||||
await page1.evaluate(async () => {
|
||||
localStorage['name1'] = 'value1';
|
||||
document.cookie = 'username=John Doe';
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const openRequest = indexedDB.open('db', 42);
|
||||
openRequest.onupgradeneeded = () => {
|
||||
openRequest.result.createObjectStore('store');
|
||||
|
||||
};
|
||||
openRequest.onsuccess = () => {
|
||||
const request = openRequest.result.transaction('store', 'readwrite')
|
||||
.objectStore('store')
|
||||
.put({ name: 'foo', date: new Date(0) }, 'bar');
|
||||
request.addEventListener('success', resolve);
|
||||
request.addEventListener('error', reject);
|
||||
};
|
||||
});
|
||||
|
||||
return document.cookie;
|
||||
});
|
||||
|
||||
|
|
@ -102,6 +120,18 @@ it('should round-trip through the file', async ({ contextFactory }, testInfo) =>
|
|||
expect(localStorage).toEqual({ name1: 'value1' });
|
||||
const cookie = await page2.evaluate('document.cookie');
|
||||
expect(cookie).toEqual('username=John Doe');
|
||||
const idbValue = await page2.evaluate(() => new Promise<string>((resolve, reject) => {
|
||||
const openRequest = indexedDB.open('db', 42);
|
||||
openRequest.addEventListener('success', () => {
|
||||
const db = openRequest.result;
|
||||
const transaction = db.transaction('store', 'readonly');
|
||||
const getRequest = transaction.objectStore('store').get('bar');
|
||||
getRequest.addEventListener('success', () => resolve(getRequest.result));
|
||||
getRequest.addEventListener('error', () => reject(getRequest.error));
|
||||
});
|
||||
openRequest.addEventListener('error', () => reject(openRequest.error));
|
||||
}));
|
||||
expect(idbValue).toEqual({ name: 'foo', date: new Date(0) });
|
||||
await context2.close();
|
||||
});
|
||||
|
||||
|
|
@ -316,3 +346,94 @@ it('should roundtrip local storage in third-party context', async ({ page, conte
|
|||
expect(localStorage).toEqual({ name1: 'value1' });
|
||||
await context2.close();
|
||||
});
|
||||
|
||||
it('should support IndexedDB', async ({ page, server, contextFactory }) => {
|
||||
await page.goto(server.PREFIX + '/to-do-notifications/index.html');
|
||||
await page.getByLabel('Task title').fill('Pet the cat');
|
||||
await page.getByLabel('Hours').fill('1');
|
||||
await page.getByLabel('Mins').fill('1');
|
||||
await page.getByText('Add Task').click();
|
||||
|
||||
const storageState = await page.context().storageState();
|
||||
expect(storageState.origins).toEqual([
|
||||
{
|
||||
origin: server.PREFIX,
|
||||
localStorage: [],
|
||||
indexedDB: [
|
||||
{
|
||||
name: 'toDoList',
|
||||
version: 4,
|
||||
stores: [
|
||||
{
|
||||
name: 'toDoList',
|
||||
autoIncrement: false,
|
||||
keyPath: 'taskTitle',
|
||||
records: [
|
||||
{
|
||||
value: {
|
||||
day: '01',
|
||||
hours: '1',
|
||||
minutes: '1',
|
||||
month: 'January',
|
||||
notified: 'no',
|
||||
taskTitle: 'Pet the cat',
|
||||
year: '2025',
|
||||
},
|
||||
},
|
||||
],
|
||||
indexes: [
|
||||
{
|
||||
name: 'day',
|
||||
keyPath: 'day',
|
||||
multiEntry: false,
|
||||
unique: false,
|
||||
},
|
||||
{
|
||||
name: 'hours',
|
||||
keyPath: 'hours',
|
||||
multiEntry: false,
|
||||
unique: false,
|
||||
},
|
||||
{
|
||||
name: 'minutes',
|
||||
keyPath: 'minutes',
|
||||
multiEntry: false,
|
||||
unique: false,
|
||||
},
|
||||
{
|
||||
name: 'month',
|
||||
keyPath: 'month',
|
||||
multiEntry: false,
|
||||
unique: false,
|
||||
},
|
||||
{
|
||||
name: 'notified',
|
||||
keyPath: 'notified',
|
||||
multiEntry: false,
|
||||
unique: false,
|
||||
},
|
||||
{
|
||||
name: 'year',
|
||||
keyPath: 'year',
|
||||
multiEntry: false,
|
||||
unique: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const context = await contextFactory({ storageState });
|
||||
expect(await context.storageState()).toEqual(storageState);
|
||||
|
||||
const recreatedPage = await context.newPage();
|
||||
await recreatedPage.goto(server.PREFIX + '/to-do-notifications/index.html');
|
||||
await expect(recreatedPage.locator('#task-list')).toMatchAriaSnapshot(`
|
||||
- list:
|
||||
- listitem:
|
||||
- text: /Pet the cat/
|
||||
`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -351,7 +351,26 @@ it('should preserve local storage on import/export of storage state', async ({ p
|
|||
localStorage: [{
|
||||
name: 'name1',
|
||||
value: 'value1'
|
||||
}]
|
||||
}],
|
||||
indexedDB: [
|
||||
{
|
||||
name: 'db',
|
||||
version: 5,
|
||||
stores: [
|
||||
{
|
||||
name: 'store',
|
||||
keyPath: 'id',
|
||||
autoIncrement: false,
|
||||
indexes: [],
|
||||
records: [
|
||||
{
|
||||
value: { id: 'foo', name: 'John Doe' }
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -392,6 +392,24 @@ test('should print nice error when some of the projects are unknown', async ({ r
|
|||
expect(output).toContain('Project(s) "suIte3", "SUite4" not found. Available projects: "suite1", "suite2"');
|
||||
});
|
||||
|
||||
test('should print nice error when project name is not stable', async ({ runInlineTest }) => {
|
||||
const { output, exitCode } = await runInlineTest({
|
||||
'playwright.config.ts': `
|
||||
module.exports = { projects: [
|
||||
{ name: \`calculated \$\{Date.now()\}\` },
|
||||
] };
|
||||
`,
|
||||
'a.test.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('pass', async ({}, testInfo) => {
|
||||
console.log(testInfo.project.name);
|
||||
});
|
||||
`
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
expect(output).toContain('not found in the worker process. Make sure project name does not change.');
|
||||
});
|
||||
|
||||
test('should work without config file', async ({ runInlineTest }) => {
|
||||
const { exitCode, passed, failed, skipped } = await runInlineTest({
|
||||
'playwright.config.ts': `
|
||||
|
|
|
|||
|
|
@ -761,7 +761,7 @@ test('should not throw when screenshot on failure fails', async ({ runInlineTest
|
|||
expect(result.exitCode).toBe(0);
|
||||
expect(result.passed).toBe(1);
|
||||
const trace = await parseTrace(testInfo.outputPath('test-results', 'a-has-download-page', 'trace.zip'));
|
||||
const attachedScreenshots = trace.actions.flatMap(a => a.attachments);
|
||||
const attachedScreenshots = trace.actions.filter(a => a.attachments).flatMap(a => a.attachments);
|
||||
// One screenshot for the page, no screenshot for the download page since it should have failed.
|
||||
expect(attachedScreenshots.length).toBe(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1019,6 +1019,27 @@ for (const useIntermediateMergeReport of [true, false] as const) {
|
|||
await expect(attachment).toBeInViewport();
|
||||
});
|
||||
|
||||
test('step.attach have links', async ({ runInlineTest, page, showReport }) => {
|
||||
const result = await runInlineTest({
|
||||
'a.test.js': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('passing test', async ({ page }, testInfo) => {
|
||||
await test.step('step', async (step) => {
|
||||
await step.attach('text attachment', { body: 'content', contentType: 'text/plain' });
|
||||
})
|
||||
});
|
||||
`,
|
||||
}, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' });
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
await showReport();
|
||||
await page.getByRole('link', { name: 'passing test' }).click();
|
||||
|
||||
await page.getByLabel('step').getByTitle('reveal attachment').click();
|
||||
await page.getByText('text attachment', { exact: true }).click();
|
||||
await expect(page.locator('.attachment-body')).toHaveText('content');
|
||||
});
|
||||
|
||||
test('should highlight textual diff', async ({ runInlineTest, showReport, page }) => {
|
||||
const result = await runInlineTest({
|
||||
'helper.ts': `
|
||||
|
|
|
|||
|
|
@ -735,6 +735,43 @@ test('step attachments are referentially equal to result attachments', async ({
|
|||
]);
|
||||
});
|
||||
|
||||
test('step.attach attachments are reported on right steps', async ({ runInlineTest }) => {
|
||||
class TestReporter implements Reporter {
|
||||
onStepEnd(test: TestCase, result: TestResult, step: TestStep) {
|
||||
console.log('%%%', JSON.stringify({
|
||||
title: step.title,
|
||||
attachments: step.attachments.map(a => ({ ...a, body: a.body.toString('utf8') })),
|
||||
}));
|
||||
}
|
||||
}
|
||||
const result = await runInlineTest({
|
||||
'reporter.ts': `module.exports = ${TestReporter.toString()}`,
|
||||
'playwright.config.ts': `module.exports = { reporter: './reporter' };`,
|
||||
'a.spec.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test.beforeAll(async () => {
|
||||
await test.step('step in beforeAll', async (step) => {
|
||||
await step.attach('attachment1', { body: 'content1' });
|
||||
});
|
||||
});
|
||||
test('test', async () => {
|
||||
await test.step('step', async (step) => {
|
||||
await step.attach('attachment2', { body: 'content2' });
|
||||
});
|
||||
});
|
||||
`,
|
||||
}, { 'reporter': '', 'workers': 1 });
|
||||
|
||||
const steps = result.outputLines.map(line => JSON.parse(line));
|
||||
expect(steps).toEqual([
|
||||
{ title: 'step in beforeAll', attachments: [{ body: 'content1', contentType: 'text/plain', name: 'attachment1' }] },
|
||||
{ title: 'beforeAll hook', attachments: [] },
|
||||
{ title: 'Before Hooks', attachments: [] },
|
||||
{ title: 'step', attachments: [{ body: 'content2', contentType: 'text/plain', name: 'attachment2' }] },
|
||||
{ title: 'After Hooks', attachments: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('attachments are reported in onStepEnd', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/14364' } }, async ({ runInlineTest }) => {
|
||||
class TestReporter implements Reporter {
|
||||
onStepEnd(test: TestCase, result: TestResult, step: TestStep) {
|
||||
|
|
|
|||
|
|
@ -160,3 +160,39 @@ test('should display list of query parameters (only if present)', async ({ runUI
|
|||
|
||||
await expect(page.getByText('Query String Parameters')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should not duplicate network entries from beforeAll', {
|
||||
annotation: [
|
||||
{ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/34404' },
|
||||
{ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/33106' },
|
||||
]
|
||||
}, async ({ runUITest, server }) => {
|
||||
const { page } = await runUITest({
|
||||
'playwright.config.ts': `
|
||||
module.exports = { use: { trace: 'on' } };
|
||||
`,
|
||||
'a.spec.ts': `
|
||||
import { test as base, expect, request, type APIRequestContext } from '@playwright/test';
|
||||
|
||||
const test = base.extend<{}, { apiRequest: APIRequestContext }>({
|
||||
apiRequest: [async ({ }, use) => {
|
||||
const apiContext = await request.newContext();
|
||||
await use(apiContext);
|
||||
await apiContext.dispose();
|
||||
}, { scope: 'worker' }]
|
||||
});
|
||||
|
||||
test.beforeAll(async ({ apiRequest }) => {
|
||||
await apiRequest.get("${server.EMPTY_PAGE}");
|
||||
});
|
||||
|
||||
test('first test', async ({ }) => { });
|
||||
|
||||
test.afterAll(async ({ apiRequest }) => { });
|
||||
`,
|
||||
});
|
||||
|
||||
await page.getByText('first test').dblclick();
|
||||
await page.getByText('Network', { exact: true }).click();
|
||||
await expect(page.getByTestId('network-list').getByText('empty.html')).toHaveCount(1);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue