From 50e1a8b55b65706bf0cdde1e149fd1c065e1e829 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Tue, 4 Feb 2025 07:06:35 -0800 Subject: [PATCH 01/13] feat(chromium-tip-of-tree): roll to r1300 (#34610) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index af25adcb26..6636c35678 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -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", From dc14490f13ca73cfdc8f47c81ed29b909b7f329d Mon Sep 17 00:00:00 2001 From: "Atsushi Kawamura (atzz/a2c)" Date: Wed, 5 Feb 2025 00:38:39 +0900 Subject: [PATCH 02/13] docs: remove unnecessary hyphens in CircleCI's sharding example (#34609) --- docs/src/ci.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/ci.md b/docs/src/ci.md index 54e0a7a749..89ce6242fb 100644 --- a/docs/src/ci.md +++ b/docs/src/ci.md @@ -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 From 11913e6f39197385308bdb93a2310f39c409f35a Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Tue, 4 Feb 2025 17:03:14 -0800 Subject: [PATCH 03/13] feat(webkit): roll to r2127 (#34599) --- packages/playwright-core/browsers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 6636c35678..2ea5fdc437 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -27,7 +27,7 @@ }, { "name": "webkit", - "revision": "2125", + "revision": "2127", "installByDefault": true, "revisionOverrides": { "debian11-x64": "2105", From fb3e8ed114f19300624fc9ecbd94a4fa8d950f05 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Wed, 5 Feb 2025 00:00:04 -0800 Subject: [PATCH 04/13] fix: reset APIRequestContext network trace between chunks (#34616) --- .../src/server/trace/recorder/tracing.ts | 14 +++++--- .../ui-mode-test-network-tab.spec.ts | 36 +++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index c19c0a33d9..fa57a14df1 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -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; } diff --git a/tests/playwright-test/ui-mode-test-network-tab.spec.ts b/tests/playwright-test/ui-mode-test-network-tab.spec.ts index e1b3bef9ed..6905dec505 100644 --- a/tests/playwright-test/ui-mode-test-network-tab.spec.ts +++ b/tests/playwright-test/ui-mode-test-network-tab.spec.ts @@ -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); +}); From 311625b891b96877243503c4c7d37917b49a94c1 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 5 Feb 2025 15:01:53 +0100 Subject: [PATCH 05/13] feat: recreate IndexedDB in storagestate (#34591) --- docs/src/api/class-apirequest.md | 17 + docs/src/api/class-apirequestcontext.md | 17 + docs/src/api/class-browsercontext.md | 19 +- docs/src/api/params.md | 21 +- docs/src/auth.md | 6 +- docs/src/codegen.md | 4 +- .../src/client/browserContext.ts | 2 +- packages/playwright-core/src/client/types.ts | 2 +- .../playwright-core/src/protocol/validator.ts | 33 +- .../src/server/browserContext.ts | 25 +- packages/playwright-core/src/server/fetch.ts | 2 +- packages/playwright-core/src/server/frames.ts | 2 +- .../src/server/storageScript.ts | 123 ++++++ packages/playwright-core/types/types.d.ts | 212 ++++++++++- packages/protocol/src/channels.d.ts | 41 +- packages/protocol/src/protocol.yml | 56 ++- tests/assets/to-do-notifications/LICENSE | 116 ++++++ tests/assets/to-do-notifications/README.md | 1 + tests/assets/to-do-notifications/index.html | 108 ++++++ .../to-do-notifications/manifest.webapp | 18 + .../to-do-notifications/scripts/todo.js | 354 ++++++++++++++++++ .../to-do-notifications/style/style.css | 248 ++++++++++++ .../browsercontext-storage-state.spec.ts | 123 +++++- tests/library/global-fetch-cookie.spec.ts | 21 +- 24 files changed, 1520 insertions(+), 51 deletions(-) create mode 100644 packages/playwright-core/src/server/storageScript.ts create mode 100644 tests/assets/to-do-notifications/LICENSE create mode 100644 tests/assets/to-do-notifications/README.md create mode 100644 tests/assets/to-do-notifications/index.html create mode 100644 tests/assets/to-do-notifications/manifest.webapp create mode 100644 tests/assets/to-do-notifications/scripts/todo.js create mode 100644 tests/assets/to-do-notifications/style/style.css diff --git a/docs/src/api/class-apirequest.md b/docs/src/api/class-apirequest.md index 2f832e1dd8..19e884d9f5 100644 --- a/docs/src/api/class-apirequest.md +++ b/docs/src/api/class-apirequest.md @@ -64,6 +64,23 @@ 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]> + - `value` <[Object]> 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 diff --git a/docs/src/api/class-apirequestcontext.md b/docs/src/api/class-apirequestcontext.md index 40742e2a1f..3cbc9c09cf 100644 --- a/docs/src/api/class-apirequestcontext.md +++ b/docs/src/api/class-apirequestcontext.md @@ -880,6 +880,23 @@ 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]> + - `value` <[Object]> Returns storage state for this request context, contains current cookies and local storage snapshot if it was passed to the constructor. diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index 85cbc4f62a..cae2c0d139 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -1511,8 +1511,25 @@ 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]> + - `value` <[Object]> -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 diff --git a/docs/src/api/params.md b/docs/src/api/params.md index f7e1f8a909..9fe3d1b53a 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -259,11 +259,28 @@ 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]> + - `value` <[Object]> Learn more about [storage state and auth](../auth.md). diff --git a/docs/src/auth.md b/docs/src/auth.md index 929e0ee78d..4a3853edf1 100644 --- a/docs/src/auth.md +++ b/docs/src/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 diff --git a/docs/src/codegen.md b/docs/src/codegen.md index f0d9a605f5..641383d7b4 100644 --- a/docs/src/codegen.md +++ b/docs/src/codegen.md @@ -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 diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 528f1b1a27..c87fc5bc7d 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -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'; diff --git a/packages/playwright-core/src/client/types.ts b/packages/playwright-core/src/client/types.ts index 795b24bde3..e21b34a8a8 100644 --- a/packages/playwright-core/src/client/types.ts +++ b/packages/playwright-core/src/client/types.ts @@ -41,7 +41,7 @@ export type StorageState = { }; export type SetStorageState = { cookies?: channels.SetNetworkCookie[], - origins?: channels.OriginStorage[] + origins?: channels.SetOriginStorage[] }; export type LifecycleEvent = channels.LifecycleEvent; diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index cbc4754a0b..8981df6f4a 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -142,9 +142,36 @@ 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), + value: 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 +388,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 +716,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 +786,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({ diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index b77ceb359b..8de81bb1ee 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -43,6 +43,7 @@ import type { Artifact } from './artifact'; import { Clock } from './clock'; import type { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; import { RecorderApp } from './recorder/recorderApp'; +import * as storageScript from './storageScript'; export abstract class BrowserContext extends SdkObject { static Events = { @@ -519,11 +520,9 @@ export abstract class BrowserContext extends SdkObject { 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(`(${storageScript.collect})()`, '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 +538,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: Awaited> = await frame.evaluateExpression(`(${storageScript.collect})()`, { 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 +605,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.toString(), { isFunction: true, world: 'utility' }, originState); } await page.close(internalMetadata); } diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 336203f33b..44602e0c82 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -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); diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 1570df5770..b1f4d4c1c6 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -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. diff --git a/packages/playwright-core/src/server/storageScript.ts b/packages/playwright-core/src/server/storageScript.ts new file mode 100644 index 0000000000..acde6b54f7 --- /dev/null +++ b/packages/playwright-core/src/server/storageScript.ts @@ -0,0 +1,123 @@ +/** + * 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'; + +export type Storage = Omit; + +export async function collect(): Promise { + 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(request: T) { + return new Promise((resolve, reject) => { + request.addEventListener('success', () => resolve(request.result)); + request.addEventListener('error', () => reject(request.error)); + }); + } + + 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 => { + return { + key: objectStore.keyPath === null ? key : undefined, + value: await idbRequestToPromise(objectStore.get(key)) + }; + })); + + 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) { + 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(request: T) { + return new Promise((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, + objectStore.keyPath === null ? record.key : undefined + ) + ); + })); + })); + })).catch(e => { + throw new Error('Unable to restore IndexedDB: ' + e.message); + }); +} diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index c4b409ae0d..4e86e5b58b 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -9266,7 +9266,8 @@ export interface BrowserContext { setOffline(offline: boolean): Promise; /** - * 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,40 @@ export interface BrowserContext { value: string; }>; + + indexedDB: Array<{ + name: string; + + version: number; + + stores: Array<{ + name: string; + + keyPath?: string; + + keyPathArray?: Array; + + autoIncrement: boolean; + + indexes: Array<{ + name: string; + + keyPath?: string; + + keyPathArray?: Array; + + unique: boolean; + + multiEntry: boolean; + }>; + + records: Array<{ + key?: Object; + + value: Object; + }>; + }>; + }>; }>; }>; @@ -10062,17 +10097,60 @@ 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; + + autoIncrement: boolean; + + indexes: Array<{ + name: string; + + keyPath?: string; + + keyPathArray?: Array; + + unique: boolean; + + multiEntry: boolean; + }>; + + records: Array<{ + key?: Object; + + value: Object; + }>; + }>; + }>; }>; }; @@ -17608,6 +17686,49 @@ 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; + + autoIncrement: boolean; + + indexes: Array<{ + name: string; + + keyPath?: string; + + keyPathArray?: Array; + + unique: boolean; + + multiEntry: boolean; + }>; + + records: Array<{ + key?: Object; + + value: Object; + }>; + }>; + }>; }>; }; @@ -18417,6 +18538,40 @@ export interface APIRequestContext { value: string; }>; + + indexedDB: Array<{ + name: string; + + version: number; + + stores: Array<{ + name: string; + + keyPath?: string; + + keyPathArray?: Array; + + autoIncrement: boolean; + + indexes: Array<{ + name: string; + + keyPath?: string; + + keyPathArray?: Array; + + unique: boolean; + + multiEntry: boolean; + }>; + + records: Array<{ + key?: Object; + + value: Object; + }>; + }>; + }>; }>; }>; @@ -22249,17 +22404,60 @@ 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; + + autoIncrement: boolean; + + indexes: Array<{ + name: string; + + keyPath?: string; + + keyPathArray?: Array; + + unique: boolean; + + multiEntry: boolean; + }>; + + records: Array<{ + key?: Object; + + value: Object; + }>; + }>; + }>; }>; }; diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 0ab6e37266..bc8c7c56bb 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -271,9 +271,38 @@ export type NameValue = { value: string, }; +export type IndexedDBDatabase = { + name: string, + version: number, + stores: { + name: string, + autoIncrement: boolean, + keyPath?: string, + keyPathArray?: string[], + records: { + key?: any, + value: 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 +639,7 @@ export type PlaywrightNewRequestParams = { timeout?: number, storageState?: { cookies?: NetworkCookie[], - origins?: OriginStorage[], + origins?: SetOriginStorage[], }, tracesDir?: string, }; @@ -641,7 +670,7 @@ export type PlaywrightNewRequestOptions = { timeout?: number, storageState?: { cookies?: NetworkCookie[], - origins?: OriginStorage[], + origins?: SetOriginStorage[], }, tracesDir?: string, }; @@ -1223,7 +1252,7 @@ export type BrowserNewContextParams = { }, storageState?: { cookies?: SetNetworkCookie[], - origins?: OriginStorage[], + origins?: SetOriginStorage[], }, }; export type BrowserNewContextOptions = { @@ -1290,7 +1319,7 @@ export type BrowserNewContextOptions = { }, storageState?: { cookies?: SetNetworkCookie[], - origins?: OriginStorage[], + origins?: SetOriginStorage[], }, }; export type BrowserNewContextResult = { @@ -1360,7 +1389,7 @@ export type BrowserNewContextForReuseParams = { }, storageState?: { cookies?: SetNetworkCookie[], - origins?: OriginStorage[], + origins?: SetOriginStorage[], }, }; export type BrowserNewContextForReuseOptions = { @@ -1427,7 +1456,7 @@ export type BrowserNewContextForReuseOptions = { }, storageState?: { cookies?: SetNetworkCookie[], - origins?: OriginStorage[], + origins?: SetOriginStorage[], }, }; export type BrowserNewContextForReuseResult = { diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 8569eef8ff..bdf55069e4 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -222,6 +222,52 @@ 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? + value: 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 +276,9 @@ OriginStorage: localStorage: type: array items: NameValue - + indexedDB: + type: array + items: IndexedDBDatabase SerializedError: type: object @@ -736,7 +784,7 @@ Playwright: items: NetworkCookie origins: type: array? - items: OriginStorage + items: SetOriginStorage tracesDir: string? returns: @@ -970,7 +1018,7 @@ Browser: items: SetNetworkCookie origins: type: array? - items: OriginStorage + items: SetOriginStorage returns: context: BrowserContext @@ -992,7 +1040,7 @@ Browser: items: SetNetworkCookie origins: type: array? - items: OriginStorage + items: SetOriginStorage returns: context: BrowserContext diff --git a/tests/assets/to-do-notifications/LICENSE b/tests/assets/to-do-notifications/LICENSE new file mode 100644 index 0000000000..3bbbc1ee92 --- /dev/null +++ b/tests/assets/to-do-notifications/LICENSE @@ -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 + \ No newline at end of file diff --git a/tests/assets/to-do-notifications/README.md b/tests/assets/to-do-notifications/README.md new file mode 100644 index 0000000000..5bd8b1d38c --- /dev/null +++ b/tests/assets/to-do-notifications/README.md @@ -0,0 +1 @@ +Source: https://github.com/mdn/dom-examples/tree/main/to-do-notifications diff --git a/tests/assets/to-do-notifications/index.html b/tests/assets/to-do-notifications/index.html new file mode 100644 index 0000000000..e9cd52c7b8 --- /dev/null +++ b/tests/assets/to-do-notifications/index.html @@ -0,0 +1,108 @@ + + + + + + + To-do list with Notifications + + + + +

To-do list

+ +
+ +
    + +
+ +
+ +
+

Add new to-do item.

+ +
+
+
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
    + +
+ + +
+ + + \ No newline at end of file diff --git a/tests/assets/to-do-notifications/manifest.webapp b/tests/assets/to-do-notifications/manifest.webapp new file mode 100644 index 0000000000..fe48e2e423 --- /dev/null +++ b/tests/assets/to-do-notifications/manifest.webapp @@ -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." + } + } +} \ No newline at end of file diff --git a/tests/assets/to-do-notifications/scripts/todo.js b/tests/assets/to-do-notifications/scripts/todo.js new file mode 100644 index 0000000000..2e3cfae210 --- /dev/null +++ b/tests/assets/to-do-notifications/scripts/todo.js @@ -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`; +}; \ No newline at end of file diff --git a/tests/assets/to-do-notifications/style/style.css b/tests/assets/to-do-notifications/style/style.css new file mode 100644 index 0000000000..c08ff299ac --- /dev/null +++ b/tests/assets/to-do-notifications/style/style.css @@ -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; + } +} \ No newline at end of file diff --git a/tests/library/browsercontext-storage-state.spec.ts b/tests/library/browsercontext-storage-state.spec.ts index d71657d1be..04a1eee02c 100644 --- a/tests/library/browsercontext-storage-state.spec.ts +++ b/tests/library/browsercontext-storage-state.spec.ts @@ -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: '' }).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('foo', '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((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('foo'); 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/ + `); +}); diff --git a/tests/library/global-fetch-cookie.spec.ts b/tests/library/global-fetch-cookie.spec.ts index 67845f8b8e..f2f522a619 100644 --- a/tests/library/global-fetch-cookie.spec.ts +++ b/tests/library/global-fetch-cookie.spec.ts @@ -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' } + } + ], + } + ] + } + ], }, ] }; From 50f22f13a44adb4cccf0b92b72c2b6d4187eb813 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Wed, 5 Feb 2025 06:10:32 -0800 Subject: [PATCH 06/13] docs: document config executing multiple times (#34576) --- docs/src/test-api/class-testproject.md | 4 ++++ packages/playwright/types/test.d.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/docs/src/test-api/class-testproject.md b/docs/src/test-api/class-testproject.md index d9814708ef..17fecf6930 100644 --- a/docs/src/test-api/class-testproject.md +++ b/docs/src/test-api/class-testproject.md @@ -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]> diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 95eb71d899..5fc76a9168 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -349,6 +349,10 @@ interface TestProject { /** * 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; From cb208836b585a401d327335e6dc9206d5acb25d6 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Wed, 5 Feb 2025 06:58:02 -0800 Subject: [PATCH 07/13] fix: display error when project is not found (#34577) --- packages/playwright/src/worker/workerMain.ts | 14 +++++++++++--- tests/playwright-test/config.spec.ts | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/playwright/src/worker/workerMain.ts b/packages/playwright/src/worker/workerMain.ts index b4878ff659..8bd7f3dcd0 100644 --- a/packages/playwright/src/worker/workerMain.ts +++ b/packages/playwright/src/worker/workerMain.ts @@ -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(); 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); diff --git a/tests/playwright-test/config.spec.ts b/tests/playwright-test/config.spec.ts index 5550d84253..7870d74706 100644 --- a/tests/playwright-test/config.spec.ts +++ b/tests/playwright-test/config.spec.ts @@ -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': ` From f1a392f8442189e81683df206929881b20c95c22 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Wed, 5 Feb 2025 08:30:03 -0800 Subject: [PATCH 08/13] chore: do not store empty step.attachments[] in trace (#34579) --- packages/playwright/src/reporters/teleEmitter.ts | 2 +- packages/playwright/src/worker/testTracing.ts | 2 ++ tests/playwright-test/playwright.trace.spec.ts | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/playwright/src/reporters/teleEmitter.ts b/packages/playwright/src/reporters/teleEmitter.ts index eadd2b05a5..76fc1490a6 100644 --- a/packages/playwright/src/reporters/teleEmitter.ts +++ b/packages/playwright/src/reporters/teleEmitter.ts @@ -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, }; } diff --git a/packages/playwright/src/worker/testTracing.ts b/packages/playwright/src/worker/testTracing.ts index 15bb17db06..e154d6a649 100644 --- a/packages/playwright/src/worker/testTracing.ts +++ b/packages/playwright/src/worker/testTracing.ts @@ -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, diff --git a/tests/playwright-test/playwright.trace.spec.ts b/tests/playwright-test/playwright.trace.spec.ts index 045ad6d419..88995c6f1c 100644 --- a/tests/playwright-test/playwright.trace.spec.ts +++ b/tests/playwright-test/playwright.trace.spec.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); }); From 25ef2f1344c5626d9f94d980345428bc64fe1950 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Wed, 5 Feb 2025 09:05:10 -0800 Subject: [PATCH 09/13] feat(webkit): roll to r2130 (#34631) --- packages/playwright-core/browsers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 2ea5fdc437..07e34d3fcd 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -27,7 +27,7 @@ }, { "name": "webkit", - "revision": "2127", + "revision": "2130", "installByDefault": true, "revisionOverrides": { "debian11-x64": "2105", From 4b64c47a251e11e6634ef7265ea4d13ebf1f6df6 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 5 Feb 2025 10:57:33 -0800 Subject: [PATCH 10/13] chore: use explicit matcher call context (#34620) --- packages/playwright/src/matchers/expect.ts | 96 +++++++++++++--------- 1 file changed, 55 insertions(+), 41 deletions(-) diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index a7e060cc4d..bc8c9ad0e4 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -110,13 +110,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) { +function createExpect(info: ExpectMetaInfo, prefix: string[], userMatchers: Record) { const expectInstance: Expect<{}> = new Proxy(expectLibrary, { apply: function(target: any, thisArg: any, argumentsList: [unknown, ExpectMessage?]) { const [actual, messageOrOptions] = argumentsList; @@ -130,7 +130,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 +139,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 +156,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 +184,56 @@ 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; +}; + +let matcherCallContext: MatcherCallContext | undefined; + +function setMatcherCallContext(context: MatcherCallContext) { + matcherCallContext = context; +} + +function takeMatcherCallContext(): MatcherCallContext { + try { + return matcherCallContext!; + } finally { + matcherCallContext = undefined; + } +} + +type ExpectMatcherStateInternal = ExpectMatcherState & { + _context: 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, + _context: context, + }; + (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 +330,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { } 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); @@ -362,7 +392,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { 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(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); From 7f09ba7fa46f837b6059e23e91aa9a4dee92b40e Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Wed, 5 Feb 2025 12:27:44 -0800 Subject: [PATCH 11/13] feat: step.attach() (#34614) --- docs/src/test-api/class-teststepinfo.md | 64 ++++++++++++++++++ packages/playwright/src/matchers/expect.ts | 12 ++-- packages/playwright/src/matchers/matchers.ts | 3 + .../src/matchers/toMatchSnapshot.ts | 35 +++++----- packages/playwright/src/worker/testInfo.ts | 20 +++++- packages/playwright/types/test.d.ts | 66 +++++++++++++++++++ tests/playwright-test/reporter-html.spec.ts | 21 ++++++ tests/playwright-test/reporter.spec.ts | 37 +++++++++++ 8 files changed, 233 insertions(+), 25 deletions(-) diff --git a/docs/src/test-api/class-teststepinfo.md b/docs/src/test-api/class-teststepinfo.md index a453d212c8..f4bba6232b 100644 --- a/docs/src/test-api/class-teststepinfo.md +++ b/docs/src/test-api/class-teststepinfo.md @@ -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 diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index bc8c9ad0e4..68e52c46c8 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -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'; @@ -195,6 +197,7 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], userMatchers: Reco type MatcherCallContext = { expectInfo: ExpectMetaInfo; testInfo: TestInfoImpl | null; + step?: TestStepInfoImpl; }; let matcherCallContext: MatcherCallContext | undefined; @@ -211,10 +214,6 @@ function takeMatcherCallContext(): MatcherCallContext { } } -type ExpectMatcherStateInternal = ExpectMatcherState & { - _context: MatcherCallContext | undefined; -}; - const defaultExpectTimeout = 5000; function wrapPlaywrightMatcherToPassNiceThis(matcher: any) { @@ -227,7 +226,7 @@ function wrapPlaywrightMatcherToPassNiceThis(matcher: any) { promise, utils, timeout, - _context: context, + _stepInfo: context.step, }; (newThis as any).equals = throwUnsupportedExpectMatcherError; return matcher.call(newThis, ...args); @@ -376,6 +375,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { }; 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) diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts index e426cf9948..be960dd424 100644 --- a/packages/playwright/src/matchers/matchers.ts +++ b/packages/playwright/src/matchers/matchers.ts @@ -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 }>; } diff --git a/packages/playwright/src/matchers/toMatchSnapshot.ts b/packages/playwright/src/matchers/toMatchSnapshot.ts index 193b57058f..09ca66ab3c 100644 --- a/packages/playwright/src/matchers/toMatchSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchSnapshot.ts @@ -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) { diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 09ce7a0ddd..740f7579bd 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -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(skip: boolean, body: (step: TestStepInfo) => T | Promise) { 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 { + this._attachToStep(await normalizeAndSaveAttachment(this._testInfo.outputPath(), name, options)); + } + skip(...args: unknown[]) { // skip(); // skip(condition: boolean, description: string); diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 5fc76a9168..ef839e44cb 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -9575,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; + /** * 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). diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index b05ff8ad55..fdb026d66c 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -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': ` diff --git a/tests/playwright-test/reporter.spec.ts b/tests/playwright-test/reporter.spec.ts index 67a8250086..b2a68bfeb0 100644 --- a/tests/playwright-test/reporter.spec.ts +++ b/tests/playwright-test/reporter.spec.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) { From 11e1b8f30a2c7418b20255390136ddd3bf5373e5 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 5 Feb 2025 14:03:31 -0800 Subject: [PATCH 12/13] chore: fallback to the original code after babel transform (#34635) --- packages/playwright/bundles/babel/src/babelBundleImpl.ts | 6 +++--- packages/playwright/src/transform/babelBundle.ts | 2 +- packages/playwright/src/transform/transform.ts | 7 ++++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/playwright/bundles/babel/src/babelBundleImpl.ts b/packages/playwright/bundles/babel/src/babelBundleImpl.ts index 54822134ea..d58554b44b 100644 --- a/packages/playwright/bundles/babel/src/babelBundleImpl.ts +++ b/packages/playwright/bundles/babel/src/babelBundleImpl.ts @@ -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; } diff --git a/packages/playwright/src/transform/babelBundle.ts b/packages/playwright/src/transform/babelBundle.ts index d2f8b5919a..349b7b9c30 100644 --- a/packages/playwright/src/transform/babelBundle.ts +++ b/packages/playwright/src/transform/babelBundle.ts @@ -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; diff --git a/packages/playwright/src/transform/transform.ts b/packages/playwright/src/transform/transform.ts index 549d83a168..88950670be 100644 --- a/packages/playwright/src/transform/transform.ts +++ b/packages/playwright/src/transform/transform.ts @@ -232,9 +232,10 @@ export function transformHook(originalCode: string, filename: string, moduleUrl? const { babelTransform }: { babelTransform: BabelTransformFunction } = require('./babelBundle'); transformData = new Map(); - 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 }; } From 7aac96d780152e297bd420d3eaf29a9395a37bb6 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 6 Feb 2025 09:48:30 +0100 Subject: [PATCH 13/13] chore: add encoded versions of IndexedDB key/value (#34630) --- docs/src/api/class-apirequest.md | 2 + docs/src/api/class-apirequestcontext.md | 2 + docs/src/api/class-browsercontext.md | 2 + docs/src/api/params.md | 2 + .../playwright-core/src/protocol/validator.ts | 4 +- .../src/server/browserContext.ts | 9 ++- .../src/server/storageScript.ts | 64 ++++++++++++++++--- packages/playwright-core/types/types.d.ts | 50 +++++++++++++++ packages/protocol/src/channels.d.ts | 4 +- packages/protocol/src/protocol.yml | 4 +- .../browsercontext-storage-state.spec.ts | 4 +- 11 files changed, 131 insertions(+), 16 deletions(-) diff --git a/docs/src/api/class-apirequest.md b/docs/src/api/class-apirequest.md index 19e884d9f5..feea5b8378 100644 --- a/docs/src/api/class-apirequest.md +++ b/docs/src/api/class-apirequest.md @@ -80,7 +80,9 @@ Methods like [`method: APIRequestContext.get`] take the base URL into considerat - `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 diff --git a/docs/src/api/class-apirequestcontext.md b/docs/src/api/class-apirequestcontext.md index 3cbc9c09cf..b8c179de4f 100644 --- a/docs/src/api/class-apirequestcontext.md +++ b/docs/src/api/class-apirequestcontext.md @@ -896,7 +896,9 @@ context cookies from the response. The method will automatically follow redirect - `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. diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index cae2c0d139..0da156b664 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -1527,7 +1527,9 @@ Whether to emulate network being offline for the browser context. - `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, local storage snapshot and IndexedDB snapshot. diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 9fe3d1b53a..f2d8734336 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -280,7 +280,9 @@ Specify environment variables that will be visible to the browser. Defaults to ` - `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). diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 8981df6f4a..35832219ee 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -152,7 +152,9 @@ scheme.IndexedDBDatabase = tObject({ keyPathArray: tOptional(tArray(tString)), records: tArray(tObject({ key: tOptional(tAny), - value: tAny, + keyEncoded: tOptional(tAny), + value: tOptional(tAny), + valueEncoded: tOptional(tAny), })), indexes: tArray(tObject({ name: tString, diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 8de81bb1ee..d75be1c34d 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -44,6 +44,7 @@ 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 = { @@ -514,13 +515,15 @@ 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: storageScript.Storage = await page.mainFrame().nonStallingEvaluateInExistingContext(`(${storageScript.collect})()`, 'utility'); + 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); @@ -540,7 +543,7 @@ export abstract class BrowserContext extends SdkObject { for (const origin of originsToSave) { const frame = page.mainFrame(); await frame.goto(internalMetadata, origin); - const storage: Awaited> = await frame.evaluateExpression(`(${storageScript.collect})()`, { world: 'utility' }); + 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 }); } @@ -605,7 +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(storageScript.restore.toString(), { isFunction: true, world: 'utility' }, originState); + await frame.evaluateExpression(`(${storageScript.restore})(${JSON.stringify(originState)}, (${utilityScriptSerializers.source})())`, { world: 'utility' }); } await page.close(internalMetadata); } diff --git a/packages/playwright-core/src/server/storageScript.ts b/packages/playwright-core/src/server/storageScript.ts index acde6b54f7..517a1529d3 100644 --- a/packages/playwright-core/src/server/storageScript.ts +++ b/packages/playwright-core/src/server/storageScript.ts @@ -15,10 +15,11 @@ */ import type * as channels from '@protocol/channels'; +import type { source } from './isomorphic/utilityScriptSerializers'; export type Storage = Omit; -export async function collect(): Promise { +export async function collect(serializers: ReturnType, isFirefox: boolean): Promise { const idbResult = await Promise.all((await indexedDB.databases()).map(async dbInfo => { if (!dbInfo.name) throw new Error('Database name is empty'); @@ -32,6 +33,39 @@ export async function collect(): Promise { }); } + 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 => { @@ -39,10 +73,24 @@ export async function collect(): Promise { const keys = await idbRequestToPromise(objectStore.getAllKeys()); const records = await Promise.all(keys.map(async key => { - return { - key: objectStore.keyPath === null ? key : undefined, - value: await idbRequestToPromise(objectStore.get(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 => { @@ -81,7 +129,7 @@ export async function collect(): Promise { }; } -export async function restore(originState: channels.SetOriginStorage) { +export async function restore(originState: channels.SetOriginStorage, serializers: ReturnType) { for (const { name, value } of (originState.localStorage || [])) localStorage.setItem(name, value); @@ -111,8 +159,8 @@ export async function restore(originState: channels.SetOriginStorage) { await Promise.all(store.records.map(async record => { await idbRequestToPromise( objectStore.add( - record.value, - objectStore.keyPath === null ? record.key : undefined + record.value ?? serializers.parseEvaluationResultValue(record.valueEncoded), + record.key ?? serializers.parseEvaluationResultValue(record.keyEncoded), ) ); })); diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 4e86e5b58b..6f7c35e361 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -9338,7 +9338,17 @@ export interface BrowserContext { 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; }>; }>; }>; @@ -10147,7 +10157,17 @@ export interface Browser { 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; }>; }>; }>; @@ -17725,7 +17745,17 @@ export interface APIRequest { 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; }>; }>; }>; @@ -18568,7 +18598,17 @@ export interface APIRequestContext { 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; }>; }>; }>; @@ -22454,7 +22494,17 @@ export interface BrowserContextOptions { 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; }>; }>; }>; diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index bc8c7c56bb..b0ccdcbc2a 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -281,7 +281,9 @@ export type IndexedDBDatabase = { keyPathArray?: string[], records: { key?: any, - value: any, + keyEncoded?: any, + value?: any, + valueEncoded?: any, }[], indexes: { name: string, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index bdf55069e4..b581eacb4e 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -244,7 +244,9 @@ IndexedDBDatabase: type: object properties: key: json? - value: json + keyEncoded: json? + value: json? + valueEncoded: json? indexes: type: array items: diff --git a/tests/library/browsercontext-storage-state.spec.ts b/tests/library/browsercontext-storage-state.spec.ts index 04a1eee02c..4b003a6c81 100644 --- a/tests/library/browsercontext-storage-state.spec.ts +++ b/tests/library/browsercontext-storage-state.spec.ts @@ -96,7 +96,7 @@ it('should round-trip through the file', async ({ contextFactory }, testInfo) => openRequest.onsuccess = () => { const request = openRequest.result.transaction('store', 'readwrite') .objectStore('store') - .put('foo', 'bar'); + .put({ name: 'foo', date: new Date(0) }, 'bar'); request.addEventListener('success', resolve); request.addEventListener('error', reject); }; @@ -131,7 +131,7 @@ it('should round-trip through the file', async ({ contextFactory }, testInfo) => }); openRequest.addEventListener('error', () => reject(openRequest.error)); })); - expect(idbValue).toEqual('foo'); + expect(idbValue).toEqual({ name: 'foo', date: new Date(0) }); await context2.close(); });