feat: recreate IndexedDB in storagestate (#34591)

This commit is contained in:
Simon Knott 2025-02-05 15:01:53 +01:00 committed by GitHub
parent fb3e8ed114
commit 311625b891
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1520 additions and 51 deletions

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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).

View file

@ -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

View file

@ -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

View file

@ -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';

View file

@ -41,7 +41,7 @@ export type StorageState = {
};
export type SetStorageState = {
cookies?: channels.SetNetworkCookie[],
origins?: channels.OriginStorage[]
origins?: channels.SetOriginStorage[]
};
export type LifecycleEvent = channels.LifecycleEvent;

View file

@ -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({

View file

@ -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<ReturnType<typeof storageScript.collect>> = 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);
}

View file

@ -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);

View file

@ -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.

View file

@ -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<channels.OriginStorage, 'origin'>;
export async function collect(): Promise<Storage> {
const idbResult = await Promise.all((await indexedDB.databases()).map(async dbInfo => {
if (!dbInfo.name)
throw new Error('Database name is empty');
if (!dbInfo.version)
throw new Error('Database version is unset');
function idbRequestToPromise<T extends IDBOpenDBRequest | IDBRequest>(request: T) {
return new Promise<T['result']>((resolve, reject) => {
request.addEventListener('success', () => resolve(request.result));
request.addEventListener('error', () => reject(request.error));
});
}
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<T extends IDBOpenDBRequest | IDBRequest>(request: T) {
return new Promise<T['result']>((resolve, reject) => {
request.addEventListener('success', () => resolve(request.result));
request.addEventListener('error', () => reject(request.error));
});
}
// after `upgradeneeded` finishes, `success` event is fired.
const db = await idbRequestToPromise(openRequest);
const transaction = db.transaction(db.objectStoreNames, 'readwrite');
await Promise.all(dbInfo.stores.map(async store => {
const objectStore = transaction.objectStore(store.name);
await Promise.all(store.records.map(async record => {
await idbRequestToPromise(
objectStore.add(
record.value,
objectStore.keyPath === null ? record.key : undefined
)
);
}));
}));
})).catch(e => {
throw new Error('Unable to restore IndexedDB: ' + e.message);
});
}

View file

@ -9266,7 +9266,8 @@ export interface BrowserContext {
setOffline(offline: boolean): Promise<void>;
/**
* Returns storage state for this browser context, contains current cookies and local storage snapshot.
* Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB
* snapshot.
* @param options
*/
storageState(options?: {
@ -9307,6 +9308,40 @@ export interface BrowserContext {
value: string;
}>;
indexedDB: Array<{
name: string;
version: number;
stores: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
unique: boolean;
multiEntry: boolean;
}>;
records: Array<{
key?: Object;
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<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
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<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
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<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
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<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
unique: boolean;
multiEntry: boolean;
}>;
records: Array<{
key?: Object;
value: Object;
}>;
}>;
}>;
}>;
};

View file

@ -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 = {

View file

@ -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

View file

@ -0,0 +1,116 @@
CC0 1.0 Universal
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator and
subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for the
purpose of contributing to a commons of creative, cultural and scientific
works ("Commons") that the public can reliably and without fear of later
claims of infringement build upon, modify, incorporate in other works, reuse
and redistribute as freely as possible in any form whatsoever and for any
purposes, including without limitation commercial purposes. These owners may
contribute to the Commons to promote the ideal of a free culture and the
further production of creative, cultural and scientific works, or to gain
reputation or greater distribution for their Work in part through the use and
efforts of others.
For these and/or other purposes and motivations, and without any expectation
of additional consideration or compensation, the person associating CC0 with a
Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
and publicly distribute the Work under its terms, with knowledge of his or her
Copyright and Related Rights in the Work and the meaning and intended legal
effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not limited
to, the following:
i. the right to reproduce, adapt, distribute, perform, display, communicate,
and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or likeness
depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data in
a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation thereof,
including any amended or successor version of such directive); and
vii. other similar, equivalent or corresponding rights throughout the world
based on applicable law or treaty, and any national implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention of,
applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
and Related Rights and associated claims and causes of action, whether now
known or unknown (including existing as well as future claims and causes of
action), in the Work (i) in all territories worldwide, (ii) for the maximum
duration provided by applicable law or treaty (including future time
extensions), (iii) in any current or future medium and for any number of
copies, and (iv) for any purpose whatsoever, including without limitation
commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes
the Waiver for the benefit of each member of the public at large and to the
detriment of Affirmer's heirs and successors, fully intending that such Waiver
shall not be subject to revocation, rescission, cancellation, termination, or
any other legal or equitable action to disrupt the quiet enjoyment of the Work
by the public as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason be
judged legally invalid or ineffective under applicable law, then the Waiver
shall be preserved to the maximum extent permitted taking into account
Affirmer's express Statement of Purpose. In addition, to the extent the Waiver
is so judged Affirmer hereby grants to each affected person a royalty-free,
non transferable, non sublicensable, non exclusive, irrevocable and
unconditional license to exercise Affirmer's Copyright and Related Rights in
the Work (i) in all territories worldwide, (ii) for the maximum duration
provided by applicable law or treaty (including future time extensions), (iii)
in any current or future medium and for any number of copies, and (iv) for any
purpose whatsoever, including without limitation commercial, advertising or
promotional purposes (the "License"). The License shall be deemed effective as
of the date CC0 was applied by Affirmer to the Work. Should any part of the
License for any reason be judged legally invalid or ineffective under
applicable law, such partial invalidity or ineffectiveness shall not
invalidate the remainder of the License, and in such case Affirmer hereby
affirms that he or she will not (i) exercise any of his or her remaining
Copyright and Related Rights in the Work or (ii) assert any associated claims
and causes of action with respect to the Work, in either case contrary to
Affirmer's express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or warranties
of any kind concerning the Work, express, implied, statutory or otherwise,
including without limitation warranties of title, merchantability, fitness
for a particular purpose, non infringement, or the absence of latent or
other defects, accuracy, or the present or absence of errors, whether or not
discoverable, all to the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without limitation
any person's Copyright and Related Rights in the Work. Further, Affirmer
disclaims responsibility for obtaining any necessary consents, permissions
or other rights required for any use of the Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to this
CC0 or use of the Work.
For more information, please see
<http://creativecommons.org/publicdomain/zero/1.0/>

View file

@ -0,0 +1 @@
Source: https://github.com/mdn/dom-examples/tree/main/to-do-notifications

View file

@ -0,0 +1,108 @@
<!DOCTYPE HTML>
<html lang="en-US">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=380">
<script src="scripts/todo.js"></script>
<title>To-do list with Notifications</title>
<link href="style/style.css" type="text/css" rel="stylesheet">
</head>
<body>
<h1>To-do list</h1>
<div class="task-box">
<ul id="task-list">
</ul>
</div>
<div class="form-box">
<h2>Add new to-do item.</h2>
<form id="task-form" action="index.html">
<div class="full-width"><label for="title">Task title:</label><input type="text" id="title" required></div>
<div class="half-width"><label for="deadline-hours">Hours (hh):</label><input type="number" id="deadline-hours" required></div>
<div class="half-width"><label for="deadline-minutes">Mins (mm):</label><input type="number" id="deadline-minutes" required></div>
<div class="third-width"><label for="deadline-day">Day:</label>
<select id="deadline-day" required>
<option value="01">01</option>
<option value="02">02</option>
<option value="03">03</option>
<option value="04">04</option>
<option value="05">05</option>
<option value="06">06</option>
<option value="07">07</option>
<option value="08">08</option>
<option value="09">09</option>
<option value="10">10</option>
<option value="11">11</option>
<option value="12">12</option>
<option value="13">13</option>
<option value="14">14</option>
<option value="15">15</option>
<option value="16">16</option>
<option value="17">17</option>
<option value="18">18</option>
<option value="19">19</option>
<option value="20">20</option>
<option value="21">21</option>
<option value="22">22</option>
<option value="23">23</option>
<option value="24">24</option>
<option value="25">25</option>
<option value="26">26</option>
<option value="27">27</option>
<option value="28">28</option>
<option value="29">29</option>
<option value="30">30</option>
<option value="31">31</option>
</select></div>
<div class="third-width"><label for="deadline-month">Month:</label>
<select id="deadline-month" required>
<option value="January">January</option>
<option value="February">February</option>
<option value="March">March</option>
<option value="April">April</option>
<option value="May">May</option>
<option value="June">June</option>
<option value="July">July</option>
<option value="August">August</option>
<option value="September">September</option>
<option value="October">October</option>
<option value="November">November</option>
<option value="December">December</option>
</select></div>
<div class="third-width"><label for="deadline-year">Year:</label>
<select id="deadline-year" required>
<option value="2025">2025</option>
<option value="2024">2024</option>
<option value="2023">2023</option>
<option value="2022">2022</option>
<option value="2021">2021</option>
<option value="2020">2020</option>
<option value="2019">2019</option>
<option value="2018">2018</option>
</select></div>
<div><input type="submit" id="submit" value="Add Task"></div>
<div></div>
</form>
</div>
<div id="toolbar">
<ul id="notifications">
</ul>
<button id="enable">
Enable notifications
</button>
</div>
</body>
</html>

View file

@ -0,0 +1,18 @@
{
"version": "0.1",
"name": "To-do list",
"description": "Store to-do items on your device, and be notified when the deadlines are up.",
"launch_path": "/to-do-notifications/index.html",
"icons": {
"128": "/to-do-notifications/img/icon-128.png"
},
"developer": {
"name": "Chris Mills",
"url": "http://chrisdavidmills.github.io/to-do-notifications/"
},
"permissions": {
"desktop-notification": {
"description": "Needed for creating system notifications."
}
}
}

View file

@ -0,0 +1,354 @@
window.onload = () => {
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
// Hold an instance of a db object for us to store the IndexedDB data in
let db;
// Create a reference to the notifications list in the bottom of the app; we will write database messages into this list by
// appending list items as children of this element
const note = document.getElementById('notifications');
// All other UI elements we need for the app
const taskList = document.getElementById('task-list');
const taskForm = document.getElementById('task-form');
const title = document.getElementById('title');
const hours = document.getElementById('deadline-hours');
const minutes = document.getElementById('deadline-minutes');
const day = document.getElementById('deadline-day');
const month = document.getElementById('deadline-month');
const year = document.getElementById('deadline-year');
const notificationBtn = document.getElementById('enable');
// Do an initial check to see what the notification permission state is
if (Notification.permission === 'denied' || Notification.permission === 'default') {
notificationBtn.style.display = 'block';
} else {
notificationBtn.style.display = 'none';
}
note.appendChild(createListItem('App initialised.'));
// Let us open our database
const DBOpenRequest = window.indexedDB.open('toDoList', 4);
// Register two event handlers to act on the database being opened successfully, or not
DBOpenRequest.onerror = (event) => {
note.appendChild(createListItem('Error loading database.'));
};
DBOpenRequest.onsuccess = (event) => {
note.appendChild(createListItem('Database initialised.'));
// Store the result of opening the database in the db variable. This is used a lot below
db = DBOpenRequest.result;
// Run the displayData() function to populate the task list with all the to-do list data already in the IndexedDB
displayData();
};
// This event handles the event whereby a new version of the database needs to be created
// Either one has not been created before, or a new version number has been submitted via the
// window.indexedDB.open line above
//it is only implemented in recent browsers
DBOpenRequest.onupgradeneeded = (event) => {
db = event.target.result;
db.onerror = (event) => {
note.appendChild(createListItem('Error loading database.'));
};
// Create an objectStore for this database
const objectStore = db.createObjectStore('toDoList', { keyPath: 'taskTitle' });
// Define what data items the objectStore will contain
objectStore.createIndex('hours', 'hours', { unique: false });
objectStore.createIndex('minutes', 'minutes', { unique: false });
objectStore.createIndex('day', 'day', { unique: false });
objectStore.createIndex('month', 'month', { unique: false });
objectStore.createIndex('year', 'year', { unique: false });
objectStore.createIndex('notified', 'notified', { unique: false });
note.appendChild(createListItem('Object store created.'));
};
function displayData() {
// First clear the content of the task list so that you don't get a huge long list of duplicate stuff each time
// the display is updated.
while (taskList.firstChild) {
taskList.removeChild(taskList.lastChild);
}
// Open our object store and then get a cursor list of all the different data items in the IDB to iterate through
const objectStore = db.transaction('toDoList').objectStore('toDoList');
objectStore.openCursor().onsuccess = (event) => {
const cursor = event.target.result;
// Check if there are no (more) cursor items to iterate through
if (!cursor) {
// No more items to iterate through, we quit.
note.appendChild(createListItem('Entries all displayed.'));
return;
}
// Check which suffix the deadline day of the month needs
const { hours, minutes, day, month, year, notified, taskTitle } = cursor.value;
const ordDay = ordinal(day);
// Build the to-do list entry and put it into the list item.
const toDoText = `${taskTitle}${hours}:${minutes}, ${month} ${ordDay} ${year}.`;
const listItem = createListItem(toDoText);
if (notified === 'yes') {
listItem.style.textDecoration = 'line-through';
listItem.style.color = 'rgba(255, 0, 0, 0.5)';
}
// Put the item item inside the task list
taskList.appendChild(listItem);
// Create a delete button inside each list item,
const deleteButton = document.createElement('button');
listItem.appendChild(deleteButton);
deleteButton.textContent = 'X';
// Set a data attribute on our delete button to associate the task it relates to.
deleteButton.setAttribute('data-task', taskTitle);
// Associate action (deletion) when clicked
deleteButton.onclick = (event) => {
deleteItem(event);
};
// continue on to the next item in the cursor
cursor.continue();
};
};
// Add listener for clicking the submit button
taskForm.addEventListener('submit', addData, false);
function addData(e) {
// Prevent default, as we don't want the form to submit in the conventional way
e.preventDefault();
// Stop the form submitting if any values are left empty.
// This should never happen as there is the required attribute
if (title.value === '' || hours.value === null || minutes.value === null || day.value === '' || month.value === '' || year.value === null) {
note.appendChild(createListItem('Data not submitted — form incomplete.'));
return;
}
// Grab the values entered into the form fields and store them in an object ready for being inserted into the IndexedDB
const newItem = [
{ taskTitle: title.value, hours: hours.value, minutes: minutes.value, day: day.value, month: month.value, year: year.value, notified: 'no' },
];
// Open a read/write DB transaction, ready for adding the data
const transaction = db.transaction(['toDoList'], 'readwrite');
// Report on the success of the transaction completing, when everything is done
transaction.oncomplete = () => {
note.appendChild(createListItem('Transaction completed: database modification finished.'));
// Update the display of data to show the newly added item, by running displayData() again.
displayData();
};
// Handler for any unexpected error
transaction.onerror = () => {
note.appendChild(createListItem(`Transaction not opened due to error: ${transaction.error}`));
};
// Call an object store that's already been added to the database
const objectStore = transaction.objectStore('toDoList');
console.log(objectStore.indexNames);
console.log(objectStore.keyPath);
console.log(objectStore.name);
console.log(objectStore.transaction);
console.log(objectStore.autoIncrement);
// Make a request to add our newItem object to the object store
const objectStoreRequest = objectStore.add(newItem[0]);
objectStoreRequest.onsuccess = (event) => {
// Report the success of our request
// (to detect whether it has been succesfully
// added to the database, you'd look at transaction.oncomplete)
note.appendChild(createListItem('Request successful.'));
// Clear the form, ready for adding the next entry
title.value = '';
hours.value = null;
minutes.value = null;
day.value = 01;
month.value = 'January';
year.value = 2020;
};
};
function deleteItem(event) {
// Retrieve the name of the task we want to delete
const dataTask = event.target.getAttribute('data-task');
// Open a database transaction and delete the task, finding it by the name we retrieved above
const transaction = db.transaction(['toDoList'], 'readwrite');
transaction.objectStore('toDoList').delete(dataTask);
// Report that the data item has been deleted
transaction.oncomplete = () => {
// Delete the parent of the button, which is the list item, so it is no longer displayed
event.target.parentNode.parentNode.removeChild(event.target.parentNode);
note.appendChild(createListItem(`Task "${dataTask}" deleted.`));
};
};
// Check whether the deadline for each task is up or not, and responds appropriately
function checkDeadlines() {
// First of all check whether notifications are enabled or denied
if (Notification.permission === 'denied' || Notification.permission === 'default') {
notificationBtn.style.display = 'block';
} else {
notificationBtn.style.display = 'none';
}
// Grab the current time and date
const now = new Date();
// From the now variable, store the current minutes, hours, day of the month, month, year and seconds
const minuteCheck = now.getMinutes();
const hourCheck = now.getHours();
const dayCheck = now.getDate(); // Do not use getDay() that returns the day of the week, 1 to 7
const monthCheck = now.getMonth();
const yearCheck = now.getFullYear(); // Do not use getYear() that is deprecated.
// Open a new transaction
const objectStore = db.transaction(['toDoList'], 'readwrite').objectStore('toDoList');
// Open a cursor to iterate through all the data items in the IndexedDB
objectStore.openCursor().onsuccess = (event) => {
const cursor = event.target.result;
if (!cursor) return;
const { hours, minutes, day, month, year, notified, taskTitle } = cursor.value;
// convert the month names we have installed in the IDB into a month number that JavaScript will understand.
// The JavaScript date object creates month values as a number between 0 and 11.
const monthNumber = MONTHS.indexOf(month);
if (monthNumber === -1) throw new Error('Incorrect month entered in database.');
// Check if the current hours, minutes, day, month and year values match the stored values for each task.
// The parseInt() function transforms the value from a string to a number for comparison
// (taking care of leading zeros, and removing spaces and underscores from the string).
let matched = parseInt(hours) === hourCheck;
matched &&= parseInt(minutes) === minuteCheck;
matched &&= parseInt(day) === dayCheck;
matched &&= parseInt(monthNumber) === monthCheck;
matched &&= parseInt(year) === yearCheck;
if (matched && notified === 'no') {
// If the numbers all do match, run the createNotification() function to create a system notification
// but only if the permission is set
if (Notification.permission === 'granted') {
createNotification(taskTitle);
}
}
// Move on to the next cursor item
cursor.continue();
};
};
// Ask for permission when the 'Enable notifications' button is clicked
function askNotificationPermission() {
// Function to actually ask the permissions
function handlePermission(permission) {
// Whatever the user answers, we make sure Chrome stores the information
if (!Reflect.has(Notification, 'permission')) {
Notification.permission = permission;
}
// Set the button to shown or hidden, depending on what the user answers
if (Notification.permission === 'denied' || Notification.permission === 'default') {
notificationBtn.style.display = 'block';
} else {
notificationBtn.style.display = 'none';
}
};
// Check if the browser supports notifications
if (!Reflect.has(window, 'Notification')) {
console.log('This browser does not support notifications.');
} else {
if (checkNotificationPromise()) {
Notification.requestPermission().then(handlePermission);
} else {
Notification.requestPermission(handlePermission);
}
}
};
// Check whether browser supports the promise version of requestPermission()
// Safari only supports the old callback-based version
function checkNotificationPromise() {
try {
Notification.requestPermission().then();
} catch(e) {
return false;
}
return true;
};
// Wire up notification permission functionality to 'Enable notifications' button
notificationBtn.addEventListener('click', askNotificationPermission);
function createListItem(contents) {
const listItem = document.createElement('li');
listItem.textContent = contents;
return listItem;
};
// Create a notification with the given title
function createNotification(title) {
// Create and show the notification
const img = '/to-do-notifications/img/icon-128.png';
const text = `HEY! Your task "${title}" is now overdue.`;
const notification = new Notification('To do list', { body: text, icon: img });
// We need to update the value of notified to 'yes' in this particular data object, so the
// notification won't be set off on it again
// First open up a transaction
const objectStore = db.transaction(['toDoList'], 'readwrite').objectStore('toDoList');
// Get the to-do list object that has this title as its title
const objectStoreTitleRequest = objectStore.get(title);
objectStoreTitleRequest.onsuccess = () => {
// Grab the data object returned as the result
const data = objectStoreTitleRequest.result;
// Update the notified value in the object to 'yes'
data.notified = 'yes';
// Create another request that inserts the item back into the database
const updateTitleRequest = objectStore.put(data);
// When this new request succeeds, run the displayData() function again to update the display
updateTitleRequest.onsuccess = () => {
displayData();
};
};
};
// Using a setInterval to run the checkDeadlines() function every second
setInterval(checkDeadlines, 1000);
}
// Helper function returning the day of the month followed by an ordinal (st, nd, or rd)
function ordinal(day) {
const n = day.toString();
const last = n.slice(-1);
if (last === '1' && n !== '11') return `${n}st`;
if (last === '2' && n !== '12') return `${n}nd`;
if (last === '3' && n !== '13') return `${n}rd`;
return `${n}th`;
};

View file

@ -0,0 +1,248 @@
/* Basic set up + sizing for containers */
html,
body {
margin: 0;
}
html {
width: 100%;
height: 100%;
font-size: 10px;
font-family: Georgia, "Times New Roman", Times, serif;
background: #111;
}
body {
width: 50rem;
position: relative;
background: #d88;
margin: 0 auto;
border-left: 2px solid #d33;
border-right: 2px solid #d33;
}
h1,
h2 {
text-align: center;
background: #d88;
font-family: Arial, Helvetica, sans-serif;
}
h1 {
font-size: 6rem;
margin: 0;
background: #d66;
}
h2 {
font-size: 2.4rem;
}
/* Bottom toolbar styling */
#toolbar {
position: relative;
height: 6rem;
width: 100%;
background: #d66;
border-top: 2px solid #d33;
border-bottom: 2px solid #d33;
}
#enable,
input[type="submit"] {
line-height: 1.8;
font-size: 1.3rem;
border-radius: 5px;
border: 1px solid black;
color: black;
text-shadow: 1px 1px 1px black;
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
inset 0px 5px 3px rgba(255, 255, 255, 0.2),
inset 0px -5px 3px rgba(0, 0, 0, 0.2);
}
#enable {
position: absolute;
bottom: 0.3rem;
right: 0.3rem;
}
#notifications {
margin: 0;
position: relative;
padding: 0.3rem;
background: #ddd;
position: absolute;
top: 0rem;
left: 0rem;
height: 5.4rem;
width: 50%;
overflow: auto;
line-height: 1.2;
}
#notifications li {
margin-left: 1.5rem;
}
/* New item form styling */
.form-box {
background: #d66;
width: 85%;
padding: 1rem;
margin: 2rem auto;
box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.7);
}
form div {
margin-bottom: 1rem;
}
form .full-width {
margin: 1rem auto 2rem;
width: 100%;
}
form .half-width {
width: 50%;
float: left;
}
form .third-width {
width: 33%;
float: left;
}
form div label {
width: 10rem;
float: left;
padding-right: 1rem;
font-size: 1.6rem;
line-height: 1.6;
}
form .full-width input {
width: 30rem;
}
form .half-width input {
width: 8.75rem;
}
form .third-width select {
width: 13.5rem;
}
form div input[type="submit"] {
clear: both;
width: 20rem;
display: block;
height: 3rem;
margin: 0 auto;
position: relative;
top: 0.5rem;
}
/* || tasks box */
.task-box {
width: 85%;
padding: 1rem;
margin: 2rem auto;
font-size: 1.8rem;
}
.task-box ul {
margin: 0;
padding: 0;
}
.task-box li {
list-style-type: none;
padding: 1rem;
border-bottom: 2px solid #d33;
}
.task-box li:last-child {
border-bottom: none;
}
.task-box li:last-child {
margin-bottom: 0rem;
}
.task-box button {
margin-left: 2rem;
font-size: 1.6rem;
border: 1px solid #eee;
border-radius: 5px;
box-shadow: inset 0 -2px 5px rgba(0, 0, 0, 0.5) 1px 1px 1px black;
}
/* setting cursor for interactive controls */
button,
input[type="submit"],
select {
cursor: pointer;
}
/* media query for small screens */
@media (max-width: 32rem) {
body {
width: 100%;
border-left: none;
border-right: none;
}
form div {
clear: both;
}
form .full-width {
margin: 1rem auto;
}
form .half-width {
width: 100%;
float: none;
}
form .third-width {
width: 100%;
float: none;
}
form div label {
width: 36%;
padding-left: 1rem;
}
form input,
form select,
form label {
line-height: 2.5rem;
font-size: 2rem;
}
form .full-width input {
width: 50%;
}
form .half-width input {
width: 50%;
}
form .third-width select {
width: 50%;
}
#enable {
right: 1rem;
}
}

View file

@ -40,12 +40,14 @@ it('should capture local storage', async ({ contextFactory }) => {
name: 'name2',
value: 'value2'
}],
indexedDB: [],
}, {
origin: 'https://www.example.com',
localStorage: [{
name: 'name1',
value: 'value1'
}],
indexedDB: [],
}]);
});
@ -81,9 +83,25 @@ it('should round-trip through the file', async ({ contextFactory }, testInfo) =>
route.fulfill({ body: '<html></html>' }).catch(() => {});
});
await page1.goto('https://www.example.com');
await page1.evaluate(() => {
await page1.evaluate(async () => {
localStorage['name1'] = 'value1';
document.cookie = 'username=John Doe';
await new Promise((resolve, reject) => {
const openRequest = indexedDB.open('db', 42);
openRequest.onupgradeneeded = () => {
openRequest.result.createObjectStore('store');
};
openRequest.onsuccess = () => {
const request = openRequest.result.transaction('store', 'readwrite')
.objectStore('store')
.put('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<string>((resolve, reject) => {
const openRequest = indexedDB.open('db', 42);
openRequest.addEventListener('success', () => {
const db = openRequest.result;
const transaction = db.transaction('store', 'readonly');
const getRequest = transaction.objectStore('store').get('bar');
getRequest.addEventListener('success', () => resolve(getRequest.result));
getRequest.addEventListener('error', () => reject(getRequest.error));
});
openRequest.addEventListener('error', () => reject(openRequest.error));
}));
expect(idbValue).toEqual('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/
`);
});

View file

@ -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' }
}
],
}
]
}
],
},
]
};