From 09be9d6425d79546330e629755d460f37daead29 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 16 Feb 2023 16:48:28 -0800 Subject: [PATCH] feat: key value store backed by filesystem (#20932) Keys are used as a relative file path without any sanitization assuming that the underlying fs will throw on error. --- docs/src/test-api/class-testconfig.md | 28 ++ docs/src/test-api/class-teststore.md | 76 +++++ .../src/common/configLoader.ts | 5 +- .../playwright-test/src/common/globals.ts | 9 + packages/playwright-test/src/index.ts | 1 + packages/playwright-test/src/store.ts | 61 ++++ packages/playwright-test/types/test.d.ts | 55 ++++ tests/playwright-test/playwright.spec.ts | 53 ++-- tests/playwright-test/store.spec.ts | 262 ++++++++---------- utils/generate_types/overrides-test.d.ts | 6 + 10 files changed, 388 insertions(+), 168 deletions(-) create mode 100644 docs/src/test-api/class-teststore.md create mode 100644 packages/playwright-test/src/store.ts diff --git a/docs/src/test-api/class-testconfig.md b/docs/src/test-api/class-testconfig.md index ea33da565b..359dda0840 100644 --- a/docs/src/test-api/class-testconfig.md +++ b/docs/src/test-api/class-testconfig.md @@ -749,6 +749,34 @@ export default defineConfig({ }); ``` +## property: TestConfig.storeDir +* since: v1.32 +- type: ?<[string]> + +Directory where the values accessible via [TestStore] are persisted. All pahts in [TestStore] are relative to `storeDir`. Defaults to `./playwright`. + +**Usage** + +```js tab=js-js +// playwright.config.js +// @ts-check + +const { defineConfig } = require('@playwright/test'); + +module.exports = defineConfig({ + storeDir: './playwright-store', +}); +``` + +```js tab=js-ts +// playwright.config.ts +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + storeDir: './playwright-store', +}); +``` + ## property: TestConfig.testDir * since: v1.10 - type: ?<[string]> diff --git a/docs/src/test-api/class-teststore.md b/docs/src/test-api/class-teststore.md new file mode 100644 index 0000000000..7e2d4b66f9 --- /dev/null +++ b/docs/src/test-api/class-teststore.md @@ -0,0 +1,76 @@ +# class: TestStore +* since: v1.32 +* langs: js + +Playwright Test provides a global `store` object that can be used to read/write values on the filesystem. Each value is stored in its own file inside './playwright' directory, configurable with [`property: TestConfig.storeDir`]. + +```ts +import { test, store } from '@playwright/test'; + +test('get user name', async ({ page, context }) => { + await page.goto('/'); + // Return mock user info from the store. + await page.route('**/info/user', route => route.fulfill({ path: store.path('mocks/user.json')})) + await page.getByText('My Profile'); + // Check that the name matches mock data. + await expect(page.getByLabel('Name')).toHaveText('John'); +}); +``` + +## async method: TestStore.delete +* since: v1.32 + +Delete named item from the store. Does nothing if the path is not in the store. + +### param: TestStore.delete.path +* since: v1.32 +- `path` <[string]> + +Item path. + +## async method: TestStore.get +* since: v1.32 +- returns: <[any]> + +Get named item from the store. Returns undefined if there is no value with given path. + +### param: TestStore.get.path +* since: v1.32 +- `path` <[string]> + +Item path. + +## method: TestStore.path +* since: v1.32 +- returns: <[string]> + +Returns absolute path of the corresponding store entry on the file system. + +### param: TestStore.path.path +* since: v1.32 +- `path` <[string]> + +Path of the item in the store. + +## method: TestStore.root +* since: v1.32 +- returns: <[string]> + +Returns absolute path of the store root directory. + +## async method: TestStore.set +* since: v1.32 + +Set value to the store. + +### param: TestStore.set.path +* since: v1.32 +- `path` <[string]> + +Item path. + +### param: TestStore.set.value +* since: v1.32 +- `value` <[any]> + +Item value. The value must be serializable to JSON. Passing `undefined` deletes the entry with given path. diff --git a/packages/playwright-test/src/common/configLoader.ts b/packages/playwright-test/src/common/configLoader.ts index 797f70e480..a5c1665e41 100644 --- a/packages/playwright-test/src/common/configLoader.ts +++ b/packages/playwright-test/src/common/configLoader.ts @@ -22,6 +22,7 @@ import type { ConfigCLIOverrides, SerializedConfig } from './ipc'; import { requireOrImport } from './transform'; import type { Config, FullConfigInternal, FullProjectInternal, Project, ReporterDescription } from './types'; import { errorWithFile, getPackageJsonPath, mergeObjects } from '../util'; +import { setCurrentConfig } from './globals'; export const defaultTimeout = 30000; @@ -47,11 +48,13 @@ export class ConfigLoader { throw new Error('Cannot load two config files'); const config = await requireOrImportDefaultObject(file) as Config; await this._processConfigObject(config, path.dirname(file), file); + setCurrentConfig(this._fullConfig); return this._fullConfig; } async loadEmptyConfig(configDir: string): Promise { await this._processConfigObject({}, configDir); + setCurrentConfig(this._fullConfig); return {}; } @@ -102,7 +105,7 @@ export class ConfigLoader { config.snapshotDir = path.resolve(configDir, config.snapshotDir); this._fullConfig._internal.configDir = configDir; - this._fullConfig._internal.storeDir = path.resolve(configDir, '.playwright-store'); + this._fullConfig._internal.storeDir = path.resolve(configDir, config.storeDir || 'playwright'); this._fullConfig.configFile = configFile; this._fullConfig.rootDir = config.testDir || configDir; this._fullConfig._internal.globalOutputDir = takeFirst(config.outputDir, throwawayArtifactsPath, baseFullConfig._internal.globalOutputDir); diff --git a/packages/playwright-test/src/common/globals.ts b/packages/playwright-test/src/common/globals.ts index ff60e5a4e9..2bee5c5a41 100644 --- a/packages/playwright-test/src/common/globals.ts +++ b/packages/playwright-test/src/common/globals.ts @@ -16,6 +16,7 @@ import type { TestInfoImpl } from '../worker/testInfo'; import type { Suite } from './test'; +import type { FullConfigInternal } from './types'; let currentTestInfoValue: TestInfoImpl | null = null; export function setCurrentTestInfo(testInfo: TestInfoImpl | null) { @@ -52,3 +53,11 @@ export function setIsWorkerProcess() { export function isWorkerProcess() { return _isWorkerProcess; } + +let currentConfigValue: FullConfigInternal | null = null; +export function setCurrentConfig(config: FullConfigInternal | null) { + currentConfigValue = config; +} +export function currentConfig(): FullConfigInternal | null { + return currentConfigValue; +} diff --git a/packages/playwright-test/src/index.ts b/packages/playwright-test/src/index.ts index 7b683f62e6..c7c47e5d12 100644 --- a/packages/playwright-test/src/index.ts +++ b/packages/playwright-test/src/index.ts @@ -24,6 +24,7 @@ import type { TestInfoImpl } from './worker/testInfo'; import { rootTestType } from './common/testType'; import { type ContextReuseMode } from './common/types'; export { expect } from './matchers/expect'; +export { store } from './store'; export const _baseTest: TestType<{}, {}> = rootTestType.test; addStackIgnoreFilter((frame: StackFrame) => frame.file.startsWith(path.dirname(require.resolve('../package.json')))); diff --git a/packages/playwright-test/src/store.ts b/packages/playwright-test/src/store.ts new file mode 100644 index 0000000000..a0e50598b1 --- /dev/null +++ b/packages/playwright-test/src/store.ts @@ -0,0 +1,61 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * 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 fs from 'fs'; +import path from 'path'; +import type { TestStore } from '../types/test'; +import { currentConfig } from './common/globals'; + +class JsonStore implements TestStore { + async delete(name: string) { + const file = this.path(name); + await fs.promises.rm(file, { force: true }); + } + + async get(name: string) { + const file = this.path(name); + try { + const data = await fs.promises.readFile(file, 'utf-8'); + return JSON.parse(data) as T; + } catch (e) { + return undefined; + } + } + + path(name: string): string { + return path.join(this.root(), name); + } + + root(): string { + const config = currentConfig(); + if (!config) + throw new Error('Cannot access store before config is loaded'); + return config._internal.storeDir; + } + + async set(name: string, value: T | undefined) { + const file = this.path(name); + if (value === undefined) { + await fs.promises.rm(file, { force: true }); + return; + } + const data = JSON.stringify(value, undefined, 2); + await fs.promises.mkdir(path.dirname(file), { recursive: true }); + await fs.promises.writeFile(file, data); + } +} + +export const store = new JsonStore(); diff --git a/packages/playwright-test/types/test.d.ts b/packages/playwright-test/types/test.d.ts index acf01877ba..694200a71f 100644 --- a/packages/playwright-test/types/test.d.ts +++ b/packages/playwright-test/types/test.d.ts @@ -1149,6 +1149,24 @@ interface TestConfig { */ snapshotPathTemplate?: string; + /** + * Directory where the values accessible via [TestStore] are persisted. All pahts in [TestStore] are relative to + * `storeDir`. Defaults to `./playwright`. + * + * **Usage** + * + * ```js + * // playwright.config.ts + * import { defineConfig } from '@playwright/test'; + * + * export default defineConfig({ + * storeDir: './playwright-store', + * }); + * ``` + * + */ + storeDir?: string; + /** * Directory that will be recursively scanned for test files. Defaults to the directory of the configuration file. * @@ -3304,6 +3322,42 @@ type ConnectOptions = { timeout?: number; }; +/** + * Playwright Test provides a global `store` object that can be used to read/write values on the filesystem. Each + * value is stored in its own file inside './playwright' directory, configurable with + * [testConfig.storeDir](https://playwright.dev/docs/api/class-testconfig#test-config-store-dir). + * + */ +export interface TestStore { + /** + * Get named item from the store. Returns undefined if there is no value with given path. + * @param path Item path. + */ + get(path: string): Promise; + /** + * Set value to the store. + * @param path Item path. + * @param value Item value. The value must be serializable to JSON. Passing `undefined` deletes the entry with given path. + */ + set(path: string, value: T | undefined): Promise; + /** + * Delete named item from the store. Does nothing if the path is not in the store. + * @param path Item path. + */ + delete(path: string): Promise; + + /** + * Returns absolute path of the corresponding store entry on the file system. + * @param path Path of the item in the store. + */ + path(path: string): string; + + /** + * Returns absolute path of the store root directory. + */ + root(): string; +} + /** * Playwright Test provides many options to configure test environment, [Browser], [BrowserContext] and more. * @@ -4254,6 +4308,7 @@ export default test; export const _baseTest: TestType<{}, {}>; export const expect: Expect; +export const store: TestStore; /** * Defines Playwright config diff --git a/tests/playwright-test/playwright.spec.ts b/tests/playwright-test/playwright.spec.ts index cafc190b30..659f534815 100644 --- a/tests/playwright-test/playwright.spec.ts +++ b/tests/playwright-test/playwright.spec.ts @@ -178,7 +178,7 @@ test('should override use:browserName with --browser', async ({ runInlineTest }) ]); }); -test('should respect context options in various contexts', async ({ runInlineTest }, testInfo) => { +test('should respect context options in various contexts', async ({ runInlineTest }) => { const result = await runInlineTest({ 'playwright.config.ts': ` module.exports = { use: { viewport: { width: 500, height: 500 } } }; @@ -294,7 +294,7 @@ test('should respect headless in modifiers that run before tests', async ({ runI expect(result.passed).toBe(1); }); -test('should call logger from launchOptions config', async ({ runInlineTest }, testInfo) => { +test('should call logger from launchOptions config', async ({ runInlineTest }) => { const result = await runInlineTest({ 'a.test.ts': ` import { test, expect } from '@playwright/test'; @@ -322,7 +322,7 @@ test('should call logger from launchOptions config', async ({ runInlineTest }, t expect(result.passed).toBe(1); }); -test('should report error and pending operations on timeout', async ({ runInlineTest }, testInfo) => { +test('should report error and pending operations on timeout', async ({ runInlineTest }) => { const result = await runInlineTest({ 'a.test.ts': ` import { test, expect } from '@playwright/test'; @@ -346,7 +346,7 @@ test('should report error and pending operations on timeout', async ({ runInline expect(result.output).toContain(`7 | page.getByText('More missing').textContent(),`); }); -test('should report error on timeout with shared page', async ({ runInlineTest }, testInfo) => { +test('should report error on timeout with shared page', async ({ runInlineTest }) => { const result = await runInlineTest({ 'a.test.ts': ` import { test, expect } from '@playwright/test'; @@ -370,7 +370,7 @@ test('should report error on timeout with shared page', async ({ runInlineTest } expect(result.output).toContain(`11 | await page.getByText('Missing').click();`); }); -test('should report error from beforeAll timeout', async ({ runInlineTest }, testInfo) => { +test('should report error from beforeAll timeout', async ({ runInlineTest }) => { const result = await runInlineTest({ 'a.test.ts': ` import { test, expect } from '@playwright/test'; @@ -394,7 +394,7 @@ test('should report error from beforeAll timeout', async ({ runInlineTest }, tes expect(result.output).toContain(`8 | page.getByText('More missing').textContent(),`); }); -test('should not report waitForEventInfo as pending', async ({ runInlineTest }, testInfo) => { +test('should not report waitForEventInfo as pending', async ({ runInlineTest }) => { const result = await runInlineTest({ 'a.test.ts': ` import { test, expect } from '@playwright/test'; @@ -414,7 +414,7 @@ test('should not report waitForEventInfo as pending', async ({ runInlineTest }, expect(result.output).not.toContain('- page.waitForLoadState'); }); -test('should throw when using page in beforeAll', async ({ runInlineTest }, testInfo) => { +test('should throw when using page in beforeAll', async ({ runInlineTest }) => { const result = await runInlineTest({ 'a.test.ts': ` import { test, expect } from '@playwright/test'; @@ -454,7 +454,7 @@ test('should report click error on sigint', async ({ runInlineTest }) => { expect(result.output).toContain(`5 | const promise = page.click('text=Missing');`); }); -test('should work with video: retain-on-failure', async ({ runInlineTest }, testInfo) => { +test('should work with video: retain-on-failure', async ({ runInlineTest }) => { const result = await runInlineTest({ 'playwright.config.ts': ` module.exports = { use: { video: 'retain-on-failure' }, name: 'chromium' }; @@ -478,15 +478,15 @@ test('should work with video: retain-on-failure', async ({ runInlineTest }, test expect(result.passed).toBe(1); expect(result.failed).toBe(1); - const dirPass = testInfo.outputPath('test-results', 'a-pass-chromium'); + const dirPass = test.info().outputPath('test-results', 'a-pass-chromium'); const videoPass = fs.existsSync(dirPass) ? fs.readdirSync(dirPass).find(file => file.endsWith('webm')) : undefined; expect(videoPass).toBeFalsy(); - const videoFail = fs.readdirSync(testInfo.outputPath('test-results', 'a-fail-chromium')).find(file => file.endsWith('webm')); + const videoFail = fs.readdirSync(test.info().outputPath('test-results', 'a-fail-chromium')).find(file => file.endsWith('webm')); expect(videoFail).toBeTruthy(); }); -test('should work with video: on-first-retry', async ({ runInlineTest }, testInfo) => { +test('should work with video: on-first-retry', async ({ runInlineTest }) => { const result = await runInlineTest({ 'playwright.config.ts': ` module.exports = { use: { video: 'on-first-retry' }, retries: 1, name: 'chromium' }; @@ -510,13 +510,13 @@ test('should work with video: on-first-retry', async ({ runInlineTest }, testInf expect(result.passed).toBe(1); expect(result.failed).toBe(1); - const dirPass = testInfo.outputPath('test-results', 'a-pass-chromium'); + const dirPass = test.info().outputPath('test-results', 'a-pass-chromium'); expect(fs.existsSync(dirPass)).toBeFalsy(); - const dirFail = testInfo.outputPath('test-results', 'a-fail-chromium'); + const dirFail = test.info().outputPath('test-results', 'a-fail-chromium'); expect(fs.existsSync(dirFail)).toBeFalsy(); - const dirRetry = testInfo.outputPath('test-results', 'a-fail-chromium-retry1'); + const dirRetry = test.info().outputPath('test-results', 'a-fail-chromium-retry1'); const videoFailRetry = fs.readdirSync(dirRetry).find(file => file.endsWith('webm')); expect(videoFailRetry).toBeTruthy(); @@ -528,7 +528,7 @@ test('should work with video: on-first-retry', async ({ runInlineTest }, testInf }]); }); -test('should work with video size', async ({ runInlineTest }, testInfo) => { +test('should work with video size', async ({ runInlineTest }) => { const result = await runInlineTest({ 'playwright.config.js': ` module.exports = { @@ -548,7 +548,7 @@ test('should work with video size', async ({ runInlineTest }, testInfo) => { }, { workers: 1 }); expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); - const folder = testInfo.outputPath(`test-results/a-pass-chromium/`); + const folder = test.info().outputPath(`test-results/a-pass-chromium/`); const [file] = fs.readdirSync(folder); const videoPlayer = new VideoPlayer(path.join(folder, file)); expect(videoPlayer.videoWidth).toBe(220); @@ -601,7 +601,7 @@ test('should pass fixture defaults to tests', async ({ runInlineTest }) => { expect(result.passed).toBe(1); }); -test('should not throw with many fixtures set to undefined', async ({ runInlineTest }, testInfo) => { +test('should not throw with many fixtures set to undefined', async ({ runInlineTest }) => { const result = await runInlineTest({ 'playwright.config.ts': ` module.exports = { use: { @@ -748,3 +748,22 @@ test('should skip on mobile', async ({ runInlineTest }) => { expect(result.skipped).toBe(1); expect(result.passed).toBe(1); }); + +test('fulfill with return path of the entry', async ({ runInlineTest }) => { + const storeDir = path.join(test.info().outputPath(), 'playwright'); + const file = path.join(storeDir, 'foo/body.json'); + await fs.promises.mkdir(path.dirname(file), { recursive: true }); + await fs.promises.writeFile(file, JSON.stringify({ 'a': 2023 })); + const result = await runInlineTest({ + 'a.test.ts': ` + import { test, store, expect } from '@playwright/test'; + test('should read value from path', async ({ page }) => { + await page.route('**/*', route => route.fulfill({ path: store.path('foo/body.json')})) + await page.goto('http://example.com'); + expect(await page.textContent('body')).toBe(JSON.stringify({ 'a': 2023 })) + }); + `, + }, { workers: 1 }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); diff --git a/tests/playwright-test/store.spec.ts b/tests/playwright-test/store.spec.ts index e95bc69624..e18585d069 100644 --- a/tests/playwright-test/store.spec.ts +++ b/tests/playwright-test/store.spec.ts @@ -14,10 +14,10 @@ * limitations under the License. */ +import fs from 'fs'; +import path from 'path'; import { expect, test } from './playwright-test-fixtures'; -test.fixme(true, 'Restore this'); - test('should provide store fixture', async ({ runInlineTest }) => { const result = await runInlineTest({ 'playwright.config.js': ` @@ -50,14 +50,19 @@ test('should share store state between project setup and tests', async ({ runInl projects: [ { name: 'p1', - setupMatch: /.*store.setup.ts/ + testMatch: /.*store.setup.ts/ + }, + { + name: 'p2', + dependencies: ['p1'], + testMatch: /.*.test.ts/ } ] }; `, 'store.setup.ts': ` import { test, store, expect } from '@playwright/test'; - test.projectSetup('should initialize store', async ({ }) => { + test('should initialize store', async ({ }) => { expect(await store.get('number')).toBe(undefined); await store.set('number', 2022) expect(await store.get('number')).toBe(2022); @@ -120,53 +125,6 @@ test('should persist store state between project runs', async ({ runInlineTest } } }); -test('should isolate store state between projects', async ({ runInlineTest }) => { - const result = await runInlineTest({ - 'playwright.config.js': ` - module.exports = { - projects: [ - { - name: 'p1', - setupMatch: /.*store.setup.ts/ - }, - { - name: 'p2', - setupMatch: /.*store.setup.ts/ - } - ] - }; - `, - 'store.setup.ts': ` - import { test, store, expect } from '@playwright/test'; - test.projectSetup('should initialize store', async ({ }) => { - expect(await store.get('number')).toBe(undefined); - await store.set('number', 2022) - expect(await store.get('number')).toBe(2022); - - expect(await store.get('name')).toBe(undefined); - await store.set('name', 'str-' + test.info().project.name) - expect(await store.get('name')).toBe('str-' + test.info().project.name); - }); - `, - 'a.test.ts': ` - import { test, store, expect } from '@playwright/test'; - test('should get data from setup', async ({ }) => { - expect(await store.get('number')).toBe(2022); - expect(await store.get('name')).toBe('str-' + test.info().project.name); - }); - `, - 'b.test.ts': ` - import { test, store, expect } from '@playwright/test'; - test('should get data from setup', async ({ }) => { - expect(await store.get('number')).toBe(2022); - expect(await store.get('name')).toBe('str-' + test.info().project.name); - }); - `, - }, { workers: 2 }); - expect(result.exitCode).toBe(0); - expect(result.passed).toBe(6); -}); - test('should load context storageState from store', async ({ runInlineTest, server }) => { server.setRoute('/setcookie.html', (req, res) => { res.setHeader('Set-Cookie', ['a=v1']); @@ -177,15 +135,20 @@ test('should load context storageState from store', async ({ runInlineTest, serv module.exports = { projects: [ { - name: 'p1', - setupMatch: /.*store.setup.ts/ + name: 'setup', + testMatch: /.*store.setup.ts/ + }, + { + name: 'p2', + dependencies: ['setup'], + testMatch: /.*.test.ts/ } ] }; `, 'store.setup.ts': ` import { test, store, expect } from '@playwright/test'; - test.projectSetup('should save storageState', async ({ page, context }) => { + test('should save storageState', async ({ page, context }) => { expect(await store.get('user')).toBe(undefined); await page.goto('${server.PREFIX}/setcookie.html'); const state = await page.context().storageState(); @@ -193,9 +156,9 @@ test('should load context storageState from store', async ({ runInlineTest, serv }); `, 'a.test.ts': ` - import { test, expect } from '@playwright/test'; + import { test, store, expect } from '@playwright/test'; test.use({ - storageStateName: 'user' + storageState: async ({}, use) => use(store.get('user')) }) test('should get data from setup', async ({ page }) => { await page.goto('${server.EMPTY_PAGE}'); @@ -216,115 +179,114 @@ test('should load context storageState from store', async ({ runInlineTest, serv expect(result.passed).toBe(3); }); -test('should load storageStateName specified in the project config from store', async ({ runInlineTest, server }) => { - server.setRoute('/setcookie.html', (req, res) => { - res.setHeader('Set-Cookie', ['a=v1']); - res.end(); - }); +test('should load value from filesystem', async ({ runInlineTest }) => { + const storeDir = test.info().outputPath('playwright'); + const file = path.join(storeDir, 'foo/bar.json'); + await fs.promises.mkdir(path.dirname(file), { recursive: true }); + await fs.promises.writeFile(file, JSON.stringify({ 'a': 2023 })); const result = await runInlineTest({ 'playwright.config.js': ` - module.exports = { - projects: [ - { - name: 'p1', - setupMatch: /.*store.setup.ts/, - use: { - storageStateName: 'stateInStorage', - }, - } - ] - }; - `, - 'store.setup.ts': ` - import { test, store, expect } from '@playwright/test'; - test.use({ - storageStateName: ({}, use) => use(undefined), - }) - test.projectSetup('should save storageState', async ({ page, context }) => { - expect(await store.get('stateInStorage')).toBe(undefined); - await page.goto('${server.PREFIX}/setcookie.html'); - const state = await page.context().storageState(); - await store.set('stateInStorage', state); - }); + module.exports = {}; `, 'a.test.ts': ` - import { test, expect } from '@playwright/test'; - test('should get data from setup', async ({ page }) => { - await page.goto('${server.EMPTY_PAGE}'); - const cookies = await page.evaluate(() => document.cookie); - expect(cookies).toBe('a=v1'); + import { test, store, expect } from '@playwright/test'; + test('should store number', async ({ }) => { + expect(await store.get('foo/bar.json')).toEqual({ 'a': 2023 }); + }); + `, + }, { workers: 1 }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('should return root path', async ({ runInlineTest }) => { + const storeDir = test.info().outputPath('playwright'); + const result = await runInlineTest({ + 'playwright.config.js': ` + module.exports = {}; + `, + 'a.test.ts': ` + import { test, store, expect } from '@playwright/test'; + test('should store number', async ({ }) => { + expect(store.root()).toBe('${storeDir.replace(/\\/g, '\\\\')}'); + }); + `, + }, { workers: 1 }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('should work in global setup and teardown', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + import * as path from 'path'; + module.exports = { + globalSetup: 'globalSetup.ts', + globalTeardown: 'globalTeardown.ts', + }; + `, + 'globalSetup.ts': ` + import { store, expect } from '@playwright/test'; + module.exports = async () => { + expect(store).toBeTruthy(); + await store.set('foo/bar.json', {'a': 2023}); + }; + `, + 'globalTeardown.ts': ` + import { store, expect } from '@playwright/test'; + module.exports = async () => { + const val = await store.get('foo/bar.json'); + console.log('teardown=' + val); + }; + `, + 'a.test.ts': ` + import { test, store, expect } from '@playwright/test'; + test('should read value from global setup', async ({ }) => { + expect(await store.get('foo/bar.json')).toEqual({ 'a': 2023 }); + await store.set('foo/bar.json', 'from test'); + }); + `, + }, { workers: 1 }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('store root can be changed with TestConfig.storeDir', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + import * as path from 'path'; + module.exports = { + storeDir: 'my/store/dir', + }; + `, + 'a.test.ts': ` + import { test, store, expect } from '@playwright/test'; + test('should store value', async ({ }) => { + await store.set('foo/bar.json', {'a': 2023}); + }); + test('should read value', async ({ }) => { + expect(await store.get('foo/bar.json')).toEqual({ 'a': 2023 }); }); `, }, { workers: 1 }); expect(result.exitCode).toBe(0); expect(result.passed).toBe(2); + const file = path.join(test.info().outputPath(), 'my/store/dir/foo/bar.json'); + expect(JSON.parse(await fs.promises.readFile(file, 'utf-8'))).toEqual({ 'a': 2023 }); }); -test('should load storageStateName specified in the global config from store', async ({ runInlineTest, server }) => { - server.setRoute('/setcookie.html', (req, res) => { - res.setHeader('Set-Cookie', ['a=v1']); - res.end(); - }); +test('should delete value', async ({ runInlineTest }) => { const result = await runInlineTest({ - 'playwright.config.js': ` - module.exports = { - use: { - storageStateName: 'stateInStorage', - }, - projects: [ - { - name: 'p1', - setupMatch: /.*store.setup.ts/, - } - ] - }; - `, - 'store.setup.ts': ` - import { test, store, expect } from '@playwright/test'; - test.use({ - storageStateName: ({}, use) => use(undefined), - }) - test.projectSetup('should save storageStateName', async ({ page, context }) => { - expect(await store.get('stateInStorage')).toBe(undefined); - await page.goto('${server.PREFIX}/setcookie.html'); - const state = await page.context().storageState(); - await store.set('stateInStorage', state); - }); - `, 'a.test.ts': ` - import { test, expect } from '@playwright/test'; - test('should get data from setup', async ({ page }) => { - await page.goto('${server.EMPTY_PAGE}'); - const cookies = await page.evaluate(() => document.cookie); - expect(cookies).toBe('a=v1'); + import { test, store, expect } from '@playwright/test'; + test('should store value', async ({ }) => { + await store.set('foo/bar.json', {'a': 2023}); + expect(await store.get('foo/bar.json')).toEqual({ 'a': 2023 }); + await store.delete('foo/bar.json'); + expect(await store.get('foo/bar.json')).toBe(undefined); }); `, }, { workers: 1 }); expect(result.exitCode).toBe(0); - expect(result.passed).toBe(2); + expect(result.passed).toBe(1); }); - -test('should throw on unknown storageStateName value', async ({ runInlineTest, server }) => { - const result = await runInlineTest({ - 'playwright.config.js': ` - module.exports = { - projects: [ - { - name: 'p1', - use: { - storageStateName: 'stateInStorage', - }, - } - ] - }; - `, - 'a.test.ts': ` - import { test, expect } from '@playwright/test'; - test('should fail to initialize page', async ({ page }) => { - }); - `, - }, { workers: 1 }); - expect(result.exitCode).toBe(1); - expect(result.passed).toBe(0); - expect(result.output).toContain('Error: Cannot find value in the store for storageStateName: "stateInStorage"'); -}); \ No newline at end of file diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index 9451e00393..72b0ecf118 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -197,6 +197,11 @@ type ConnectOptions = { timeout?: number; }; +export interface TestStore { + get(path: string): Promise; + set(path: string, value: T | undefined): Promise; +} + export interface PlaywrightWorkerOptions { browserName: BrowserName; defaultBrowserType: BrowserName; @@ -371,6 +376,7 @@ export default test; export const _baseTest: TestType<{}, {}>; export const expect: Expect; +export const store: TestStore; /** * Defines Playwright config