diff --git a/docs/src/test-api/class-fixtures.md b/docs/src/test-api/class-fixtures.md index 23c5044ef4..b68c101b4e 100644 --- a/docs/src/test-api/class-fixtures.md +++ b/docs/src/test-api/class-fixtures.md @@ -128,9 +128,3 @@ test('basic test', async ({ request }) => { // ... }); ``` - -## property: Fixtures.storage -* since: v1.28 -- type: <[Storage]> - -[Storage] is shared between all tests in the same run. diff --git a/docs/src/test-api/class-storage.md b/docs/src/test-api/class-storage.md index 8a438d4be6..366544a331 100644 --- a/docs/src/test-api/class-storage.md +++ b/docs/src/test-api/class-storage.md @@ -2,14 +2,14 @@ * since: v1.28 * langs: js -Playwright Test provides a `storage` fixture for passing values between project setup and tests. +Playwright Test provides a [`method: TestInfo.storage`] object for passing values between project setup and tests. TODO: examples -## method: Storage.get +## async method: Storage.get * since: v1.28 - returns: <[any]> -Get named item from the store. +Get named item from the storage. Returns undefined if there is no value with given name. ### param: Storage.get.name * since: v1.28 @@ -17,10 +17,10 @@ Get named item from the store. Item name. -## method: Storage.set +## async method: Storage.set * since: v1.28 -Set value to the store. +Set value to the storage. ### param: Storage.set.name * since: v1.28 @@ -32,5 +32,5 @@ Item name. * since: v1.28 - `value` <[any]> -Item value. The value must be serializable to JSON. +Item value. The value must be serializable to JSON. Passing `undefined` deletes the entry with given name. diff --git a/docs/src/test-api/class-testinfo.md b/docs/src/test-api/class-testinfo.md index 4ff1fac892..b2c6c676a0 100644 --- a/docs/src/test-api/class-testinfo.md +++ b/docs/src/test-api/class-testinfo.md @@ -498,6 +498,12 @@ Output written to `process.stderr` or `console.error` during the test execution. Output written to `process.stdout` or `console.log` during the test execution. +## method: TestInfo.storage +* since: v1.28 +- returns: <[Storage]> + +Returns a [Storage] instance for the currently running project. + ## property: TestInfo.timeout * since: v1.10 - type: <[int]> diff --git a/packages/playwright-test/src/index.ts b/packages/playwright-test/src/index.ts index c514e1f6f5..efbb4d527e 100644 --- a/packages/playwright-test/src/index.ts +++ b/packages/playwright-test/src/index.ts @@ -24,7 +24,6 @@ import { removeFolders } from 'playwright-core/lib/utils/fileUtils'; import type { PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, TestInfo, TestType, TraceMode, VideoMode } from '../types/test'; import type { TestInfoImpl } from './testInfo'; import { rootTestType } from './testType'; -import { sanitizeForFilePath, trimLongString } from './util'; export { expect } from './expect'; export { addRunnerPlugin as _addRunnerPlugin } from './plugins'; export const _baseTest: TestType<{}, {}> = rootTestType.test; @@ -136,35 +135,6 @@ export const test = _baseTest.extend({ await browser.close(); }, { scope: 'worker', timeout: 0 }], - storage: [async ({ }, use, testInfo) => { - const toFilePath = (name: string) => { - const fileName = sanitizeForFilePath(trimLongString(name)) + '.json'; - return path.join(testInfo.project.outputDir, '.playwright-storage', (testInfo as TestInfoImpl).project._id, fileName); - }; - const storage = { - async get(name: string) { - const file = toFilePath(name); - try { - const data = (await fs.promises.readFile(file)).toString('utf-8'); - return JSON.parse(data) as T; - } catch (e) { - return undefined; - } - }, - async set(name: string, value: T | undefined) { - const file = toFilePath(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); - } - }; - await use(storage); - }, { scope: 'worker' }], - acceptDownloads: [({ contextOptions }, use) => use(contextOptions.acceptDownloads ?? true), { option: true }], bypassCSP: [({ contextOptions }, use) => use(contextOptions.bypassCSP), { option: true }], colorScheme: [({ contextOptions }, use) => use(contextOptions.colorScheme), { option: true }], @@ -216,7 +186,6 @@ export const test = _baseTest.extend({ baseURL, contextOptions, serviceWorkers, - storage, }, use) => { const options: BrowserContextOptions = {}; if (acceptDownloads !== undefined) @@ -252,7 +221,7 @@ export const test = _baseTest.extend({ if (storageState !== undefined) { options.storageState = storageState; if (typeof storageState === 'string') { - const value = await storage.get(storageState); + const value = await test.info().storage().get(storageState); if (value) options.storageState = value as any; } diff --git a/packages/playwright-test/src/loader.ts b/packages/playwright-test/src/loader.ts index eb3a8bc214..6923b7d640 100644 --- a/packages/playwright-test/src/loader.ts +++ b/packages/playwright-test/src/loader.ts @@ -121,6 +121,7 @@ export class Loader { config.snapshotDir = path.resolve(configDir, config.snapshotDir); this._fullConfig._configDir = configDir; + this._fullConfig._storageDir = path.resolve(configDir, '.playwright-storage'); this._fullConfig.configFile = this._configFile; this._fullConfig.rootDir = config.testDir || this._configDir; this._fullConfig._globalOutputDir = takeFirst(config.outputDir, throwawayArtifactsPath, baseFullConfig._globalOutputDir); @@ -669,6 +670,7 @@ export const baseFullConfig: FullConfigInternal = { _webServers: [], _globalOutputDir: path.resolve(process.cwd()), _configDir: '', + _storageDir: '', _maxConcurrentTestGroups: 0, _ignoreSnapshots: false, _workerIsolation: 'isolate-pools', diff --git a/packages/playwright-test/src/testInfo.ts b/packages/playwright-test/src/testInfo.ts index 91dc864248..3deb50dfb8 100644 --- a/packages/playwright-test/src/testInfo.ts +++ b/packages/playwright-test/src/testInfo.ts @@ -16,15 +16,14 @@ import fs from 'fs'; import path from 'path'; -import type { TestError, TestInfo, TestStatus } from '../types/test'; -import type { FullConfigInternal, FullProjectInternal } from './types'; +import { monotonicTime } from 'playwright-core/lib/utils'; +import type { Storage, TestError, TestInfo, TestStatus } from '../types/test'; import type { WorkerInitParams } from './ipc'; import type { Loader } from './loader'; import type { TestCase } from './test'; import { TimeoutManager } from './timeoutManager'; -import type { Annotation, TestStepInternal } from './types'; +import type { Annotation, FullConfigInternal, FullProjectInternal, TestStepInternal } from './types'; import { addSuffixToFilePath, getContainedPath, normalizeAndSaveAttachment, sanitizeForFilePath, serializeError, trimLongString } from './util'; -import { monotonicTime } from 'playwright-core/lib/utils'; export class TestInfoImpl implements TestInfo { private _addStepImpl: (data: Omit) => TestStepInternal; @@ -62,6 +61,7 @@ export class TestInfoImpl implements TestInfo { readonly snapshotDir: string; errors: TestError[] = []; currentStep: TestStepInternal | undefined; + private readonly _storage: JsonStorage; get error(): TestError | undefined { return this.errors[0]; @@ -109,6 +109,7 @@ export class TestInfoImpl implements TestInfo { this.expectedStatus = test.expectedStatus; this._timeoutManager = new TimeoutManager(this.project.timeout); + this._storage = new JsonStorage(this); this.outputDir = (() => { const relativeTestFilePath = path.relative(this.project.testDir, test._requireFile.replace(/\.(spec|test)\.(js|ts|mjs)$/, '')); @@ -279,6 +280,41 @@ export class TestInfoImpl implements TestInfo { setTimeout(timeout: number) { this._timeoutManager.setTimeout(timeout); } + + storage() { + return this._storage; + } +} + +class JsonStorage implements Storage { + constructor(private _testInfo: TestInfoImpl) { + } + + private _toFilePath(name: string) { + const fileName = sanitizeForFilePath(trimLongString(name)) + '.json'; + return path.join(this._testInfo.config._storageDir, this._testInfo.project._id, fileName); + } + + async get(name: string) { + const file = this._toFilePath(name); + try { + const data = (await fs.promises.readFile(file)).toString('utf-8'); + return JSON.parse(data) as T; + } catch (e) { + return undefined; + } + } + + async set(name: string, value: T | undefined) { + const file = this._toFilePath(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); + } } class SkipError extends Error { diff --git a/packages/playwright-test/src/types.ts b/packages/playwright-test/src/types.ts index b383bacf90..899c2ae567 100644 --- a/packages/playwright-test/src/types.ts +++ b/packages/playwright-test/src/types.ts @@ -45,6 +45,7 @@ export interface TestStepInternal { export interface FullConfigInternal extends FullConfigPublic { _globalOutputDir: string; _configDir: string; + _storageDir: string; _maxConcurrentTestGroups: number; _ignoreSnapshots: boolean; _workerIsolation: WorkerIsolation; diff --git a/packages/playwright-test/types/test.d.ts b/packages/playwright-test/types/test.d.ts index 68f76bb1af..361ed3ce43 100644 --- a/packages/playwright-test/types/test.d.ts +++ b/packages/playwright-test/types/test.d.ts @@ -1740,6 +1740,11 @@ export interface TestInfo { */ stdout: Array; + /** + * Returns a [Storage] instance for the currently running project. + */ + storage(): Storage; + /** * Timeout in milliseconds for the currently running test. Zero means no timeout. Learn more about * [various timeouts](https://playwright.dev/docs/test-timeouts). @@ -2693,18 +2698,19 @@ type ConnectOptions = { }; /** - * Playwright Test provides a `storage` fixture for passing values between project setup and tests. TODO: examples + * Playwright Test provides a [testInfo.storage()](https://playwright.dev/docs/api/class-testinfo#test-info-storage) object + * for passing values between project setup and tests. TODO: examples */ -interface Storage { +export interface Storage { /** - * Get named item from the store. + * Get named item from the storage. Returns undefined if there is no value with given name. * @param name Item name. */ get(name: string): Promise; /** - * Set value to the store. + * Set value to the storage. * @param name Item name. - * @param value Item value. The value must be serializable to JSON. + * @param value Item value. The value must be serializable to JSON. Passing `undefined` deletes the entry with given name. */ set(name: string, value: T | undefined): Promise; } @@ -3045,10 +3051,6 @@ export interface PlaywrightWorkerArgs { * Learn how to [configure browser](https://playwright.dev/docs/test-configuration) and see [available options][TestOptions]. */ browser: Browser; - /** - * [Storage] is shared between all tests in the same run. - */ - storage: Storage; } /** diff --git a/tests/playwright-test/storage.spec.ts b/tests/playwright-test/storage.spec.ts index c18f3a98f7..b748af660f 100644 --- a/tests/playwright-test/storage.spec.ts +++ b/tests/playwright-test/storage.spec.ts @@ -23,13 +23,15 @@ test('should provide storage fixture', async ({ runInlineTest }) => { `, 'a.test.ts': ` const { test } = pwt; - test('should store number', async ({ storage }) => { + test('should store number', async ({ }) => { + const storage = test.info().storage(); expect(storage).toBeTruthy(); expect(await storage.get('number')).toBe(undefined); await storage.set('number', 2022) expect(await storage.get('number')).toBe(2022); }); - test('should store object', async ({ storage }) => { + test('should store object', async ({ }) => { + const storage = test.info().storage(); expect(storage).toBeTruthy(); expect(await storage.get('object')).toBe(undefined); await storage.set('object', { 'a': 2022 }) @@ -41,7 +43,6 @@ test('should provide storage fixture', async ({ runInlineTest }) => { expect(result.passed).toBe(2); }); - test('should share storage state between project setup and tests', async ({ runInlineTest }) => { const result = await runInlineTest({ 'playwright.config.js': ` @@ -56,7 +57,8 @@ test('should share storage state between project setup and tests', async ({ runI `, 'storage.setup.ts': ` const { test, expect } = pwt; - test('should initialize storage', async ({ storage }) => { + test('should initialize storage', async ({ }) => { + const storage = test.info().storage(); expect(await storage.get('number')).toBe(undefined); await storage.set('number', 2022) expect(await storage.get('number')).toBe(2022); @@ -68,14 +70,16 @@ test('should share storage state between project setup and tests', async ({ runI `, 'a.test.ts': ` const { test } = pwt; - test('should get data from setup', async ({ storage }) => { + test('should get data from setup', async ({ }) => { + const storage = test.info().storage(); expect(await storage.get('number')).toBe(2022); expect(await storage.get('object')).toEqual({ 'a': 2022 }); }); `, 'b.test.ts': ` const { test } = pwt; - test('should get data from setup', async ({ storage }) => { + test('should get data from setup', async ({ }) => { + const storage = test.info().storage(); expect(await storage.get('number')).toBe(2022); expect(await storage.get('object')).toEqual({ 'a': 2022 }); }); @@ -85,6 +89,41 @@ test('should share storage state between project setup and tests', async ({ runI expect(result.passed).toBe(3); }); +test('should persist storage state between project runs', async ({ runInlineTest }) => { + const files = { + 'playwright.config.js': ` + module.exports = { }; + `, + 'a.test.ts': ` + const { test } = pwt; + test('should have no data on first run', async ({ }) => { + const storage = test.info().storage(); + expect(await storage.get('number')).toBe(undefined); + await storage.set('number', 2022) + expect(await storage.get('object')).toBe(undefined); + await storage.set('object', { 'a': 2022 }) + }); + `, + 'b.test.ts': ` + const { test } = pwt; + test('should get data from previous run', async ({ }) => { + const storage = test.info().storage(); + expect(await storage.get('number')).toBe(2022); + expect(await storage.get('object')).toEqual({ 'a': 2022 }); + }); + `, + }; + { + const result = await runInlineTest(files, { grep: 'should have no data on first run' }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + } + { + const result = await runInlineTest(files, { grep: 'should get data from previous run' }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + } +}); test('should isolate storage state between projects', async ({ runInlineTest }) => { const result = await runInlineTest({ @@ -104,28 +143,31 @@ test('should isolate storage state between projects', async ({ runInlineTest }) `, 'storage.setup.ts': ` const { test, expect } = pwt; - test('should initialize storage', async ({ storage }, testInfo) => { + test('should initialize storage', async ({ }) => { + const storage = test.info().storage(); expect(await storage.get('number')).toBe(undefined); await storage.set('number', 2022) expect(await storage.get('number')).toBe(2022); expect(await storage.get('name')).toBe(undefined); - await storage.set('name', 'str-' + testInfo.project.name) - expect(await storage.get('name')).toBe('str-' + testInfo.project.name); + await storage.set('name', 'str-' + test.info().project.name) + expect(await storage.get('name')).toBe('str-' + test.info().project.name); }); `, 'a.test.ts': ` const { test } = pwt; - test('should get data from setup', async ({ storage }, testInfo) => { + test('should get data from setup', async ({ }) => { + const storage = test.info().storage(); expect(await storage.get('number')).toBe(2022); - expect(await storage.get('name')).toBe('str-' + testInfo.project.name); + expect(await storage.get('name')).toBe('str-' + test.info().project.name); }); `, 'b.test.ts': ` const { test } = pwt; - test('should get data from setup', async ({ storage }, testInfo) => { + test('should get data from setup', async ({ }) => { + const storage = test.info().storage(); expect(await storage.get('number')).toBe(2022); - expect(await storage.get('name')).toBe('str-' + testInfo.project.name); + expect(await storage.get('name')).toBe('str-' + test.info().project.name); }); `, }, { workers: 2 }); @@ -133,7 +175,6 @@ test('should isolate storage state between projects', async ({ runInlineTest }) expect(result.passed).toBe(6); }); - test('should load context storageState from storage', async ({ runInlineTest, server }) => { server.setRoute('/setcookie.html', (req, res) => { res.setHeader('Set-Cookie', ['a=v1']); @@ -152,7 +193,8 @@ test('should load context storageState from storage', async ({ runInlineTest, se `, 'storage.setup.ts': ` const { test, expect } = pwt; - test('should save storageState', async ({ page, context, storage }, testInfo) => { + test('should save storageState', async ({ page, context }) => { + const storage = test.info().storage(); expect(await storage.get('user')).toBe(undefined); await page.goto('${server.PREFIX}/setcookie.html'); const state = await page.context().storageState(); @@ -164,7 +206,7 @@ test('should load context storageState from storage', async ({ runInlineTest, se test.use({ storageState: 'user' }) - test('should get data from setup', async ({ page }, testInfo) => { + 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'); @@ -172,7 +214,7 @@ test('should load context storageState from storage', async ({ runInlineTest, se `, 'b.test.ts': ` const { test } = pwt; - test('should not get data from setup if not configured', async ({ page }, testInfo) => { + test('should not get data from setup if not configured', async ({ page }) => { await page.goto('${server.EMPTY_PAGE}'); const cookies = await page.evaluate(() => document.cookie); expect(cookies).toBe(''); @@ -207,17 +249,17 @@ test('should load storageState specified in the project config from storage', as test.reset({ storageState: 'default' }) - test('should save storageState', async ({ page, context, storage }, testInfo) => { + test('should save storageState', async ({ page, context }) => { + const storage = test.info().storage(); expect(await storage.get('stateInStorage')).toBe(undefined); await page.goto('${server.PREFIX}/setcookie.html'); const state = await page.context().storageState(); await storage.set('stateInStorage', state); - console.log('project setup state = ' + state); }); `, 'a.test.ts': ` const { test } = pwt; - test('should get data from setup', async ({ page, storage }, testInfo) => { + 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'); @@ -252,17 +294,17 @@ test('should load storageState specified in the global config from storage', asy test.reset({ storageState: 'default' }) - test('should save storageState', async ({ page, context, storage }, testInfo) => { + test('should save storageState', async ({ page, context }) => { + const storage = test.info().storage(); expect(await storage.get('stateInStorage')).toBe(undefined); await page.goto('${server.PREFIX}/setcookie.html'); const state = await page.context().storageState(); await storage.set('stateInStorage', state); - console.log('project setup state = ' + state); }); `, 'a.test.ts': ` const { test } = pwt; - test('should get data from setup', async ({ page, storage }, testInfo) => { + 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'); diff --git a/tests/playwright-test/types.spec.ts b/tests/playwright-test/types.spec.ts index 1149d1fc84..b0972f4522 100644 --- a/tests/playwright-test/types.spec.ts +++ b/tests/playwright-test/types.spec.ts @@ -195,3 +195,18 @@ test('config should allow void/empty options', async ({ runTSC }) => { }); expect(result.exitCode).toBe(0); }); + +test('should provide storage interface', async ({ runTSC }) => { + const result = await runTSC({ + 'a.spec.ts': ` + const { test } = pwt; + test('my test', async () => { + await test.info().storage().set('foo', 'bar'); + const val = await test.info().storage().get('foo'); + // @ts-expect-error + await test.info().storage().unknown(); + }); + ` + }); + expect(result.exitCode).toBe(0); +}); diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index c2e7a6811a..41d9a6138b 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -198,7 +198,7 @@ type ConnectOptions = { timeout?: number; }; -interface Storage { +export interface Storage { get(name: string): Promise; set(name: string, value: T | undefined): Promise; } @@ -250,7 +250,6 @@ export interface PlaywrightTestOptions { export interface PlaywrightWorkerArgs { playwright: typeof import('playwright-core'); browser: Browser; - storage: Storage; } export interface PlaywrightTestArgs {