diff --git a/docs/helpers/Playwright.md b/docs/helpers/Playwright.md index d03602539..2df11ee95 100644 --- a/docs/helpers/Playwright.md +++ b/docs/helpers/Playwright.md @@ -82,6 +82,12 @@ Type: [object][6] * `recordHar` **[object][6]?** record HAR and will be saved to `output/har`. See more of [HAR options][3]. * `testIdAttribute` **[string][9]?** locate elements based on the testIdAttribute. See more of [locate by test id][49]. * `customLocatorStrategies` **[object][6]?** custom locator strategies. An object with keys as strategy names and values as JavaScript functions. Example: `{ byRole: (selector, root) => { return root.querySelector(`[role="${selector}"]`) } }` +* `storageState` **([string][9] | [object][6])?** Playwright storage state (path to JSON file or object) + passed directly to `browser.newContext`. + If a Scenario is declared with a `cookies` option (e.g. `Scenario('name', { cookies: [...] }, fn)`), + those cookies are used instead and the configured `storageState` is ignored (no merge). + May include session cookies, auth tokens, localStorage and (if captured with + `grabStorageState({ indexedDB: true })`) IndexedDB data; treat as sensitive and do not commit. @@ -1333,6 +1339,28 @@ let pageSource = await I.grabSource(); Returns **[Promise][22]<[string][9]>** source code +### grabStorageState + +Grab the current storage state (cookies, localStorage, etc.) via Playwright's `browserContext.storageState()`. +Returns the raw object that Playwright provides. + +Security: The returned object can contain authentication tokens, session cookies +and (when `indexedDB: true` is used) data that may include user PII. Treat it as a secret. +Avoid committing it to source control and prefer storing it in a protected secrets store / CI artifact vault. + +#### Parameters + +* `options` **[object][6]?** + + * `options.indexedDB` **[boolean][26]?** set to true to include IndexedDB in snapshot (Playwright>=1.51)```js + // basic usage + const state = await I.grabStorageState(); + require('fs').writeFileSync('authState.json', JSON.stringify(state)); + + // include IndexedDB when using Firebase Auth, etc. + const stateWithIDB = await I.grabStorageState({ indexedDB: true }); + ``` + ### grabTextFrom Retrieves a text from an element located by CSS or XPath and returns it to test. diff --git a/docs/playwright.md b/docs/playwright.md index f43bbc0a9..5c5d6bb3c 100644 --- a/docs/playwright.md +++ b/docs/playwright.md @@ -664,3 +664,48 @@ Playwright can be added to GitHub Actions using [official action](https://github - name: run CodeceptJS tests run: npx codeceptjs run ``` + +## Reusing Auth State (storageState) + +Use Playwright's native `storageState` to start tests already authenticated. +Pass either a JSON file path or a state object to the Playwright helper; CodeceptJS forwards it directly to Playwright (no pre-checks). + +**Sensitive**: A storage state contains session cookies, auth tokens and may contain localStorage / IndexedDB application data. Treat it like a secret: do not commit it to git, encrypt or store it in a secure CI artifact store. + +Reference: https://playwright.dev/docs/auth#reuse-authentication-state + +**Limitation**: If a Scenario is declared with a `cookies` option (e.g. `Scenario('My test', { cookies: [...] }, ({ I }) => { ... })`), those cookies are used to initialize the context and any helper-level `storageState` is ignored (no merge). Choose one mechanism per Scenario. + +Minimal examples: + +```js +// File path +helpers: { Playwright: { url: 'http://localhost', browser: 'chromium', storageState: 'authState.json' } } + +// Inline object +const state = require('./authState.json'); +helpers: { Playwright: { url: 'http://localhost', browser: 'chromium', storageState: state } } +``` + +Scenario with explicit cookies (bypasses configured storageState): + +```js +const authCookies = [{ name: 'session', value: 'abc123', domain: 'localhost', path: '/', httpOnly: true, secure: false, sameSite: 'Lax' }] +Scenario('Dashboard (authenticated)', { cookies: authCookies }, ({ I }) => { + I.amOnPage('/dashboard') + I.see('Welcome') +}) +``` + +Helper snippet: + +```js +// Grab current state as object +const state = await I.grabStorageState() +// Persist manually (sensitive file!) +require('fs').writeFileSync('authState.json', JSON.stringify(state)) + +// Include IndexedDB (Playwright>= 1.51) if your app relies on it (e.g. Firebase Auth persistence) +const stateWithIDB = await I.grabStorageState({ indexedDB: true }) +require('fs').writeFileSync('authState-with-idb.json', JSON.stringify(stateWithIDB)) +``` diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index ff0118793..e12180d6b 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -98,7 +98,13 @@ const pathSeparator = path.sep * @prop {boolean} [highlightElement] - highlight the interacting elements. Default: false. Note: only activate under verbose mode (--verbose). * @prop {object} [recordHar] - record HAR and will be saved to `output/har`. See more of [HAR options](https://playwright.dev/docs/api/class-browser#browser-new-context-option-record-har). * @prop {string} [testIdAttribute=data-testid] - locate elements based on the testIdAttribute. See more of [locate by test id](https://playwright.dev/docs/locators#locate-by-test-id). - * @prop {object} [customLocatorStrategies] - custom locator strategies. An object with keys as strategy names and values as JavaScript functions. Example: `{ byRole: (selector, root) => { return root.querySelector(\`[role="\${selector}\"]\`) } }` + * @prop {object} [customLocatorStrategies] - custom locator strategies. An object with keys as strategy names and values as JavaScript functions. Example: `{ byRole: (selector, root) => { return root.querySelector(`[role="${selector}"]`) } }` + * @prop {string|object} [storageState] - Playwright storage state (path to JSON file or object) + * passed directly to `browser.newContext`. + * If a Scenario is declared with a `cookies` option (e.g. `Scenario('name', { cookies: [...] }, fn)`), + * those cookies are used instead and the configured `storageState` is ignored (no merge). + * May include session cookies, auth tokens, localStorage and (if captured with + * `grabStorageState({ indexedDB: true })`) IndexedDB data; treat as sensitive and do not commit. */ const config = {} @@ -360,6 +366,11 @@ class Playwright extends Helper { // override defaults with config this._setConfig(config) + // pass storageState directly (string path or object) and let Playwright handle errors/missing file + if (typeof config.storageState !== 'undefined') { + this.storageState = config.storageState + } + } _validateConfig(config) { @@ -386,6 +397,7 @@ class Playwright extends Helper { use: { actionTimeout: 0 }, ignoreHTTPSErrors: false, // Adding it here o that context can be set up to ignore the SSL errors, highlightElement: false, + storageState: undefined, } process.env.testIdAttribute = 'data-testid' @@ -589,8 +601,7 @@ class Playwright extends Helper { // load pre-saved cookies if (test?.opts?.cookies) contextOptions.storageState = { cookies: test.opts.cookies } - - if (this.storageState) contextOptions.storageState = this.storageState + else if (this.storageState) contextOptions.storageState = this.storageState if (this.options.userAgent) contextOptions.userAgent = this.options.userAgent if (this.options.locale) contextOptions.locale = this.options.locale if (this.options.colorScheme) contextOptions.colorScheme = this.options.colorScheme @@ -2162,6 +2173,30 @@ class Playwright extends Helper { if (cookie[0]) return cookie[0] } + /** + * Grab the current storage state (cookies, localStorage, etc.) via Playwright's `browserContext.storageState()`. + * Returns the raw object that Playwright provides. + * + * Security: The returned object can contain authentication tokens, session cookies + * and (when `indexedDB: true` is used) data that may include user PII. Treat it as a secret. + * Avoid committing it to source control and prefer storing it in a protected secrets store / CI artifact vault. + * + * @param {object} [options] + * @param {boolean} [options.indexedDB] set to true to include IndexedDB in snapshot (Playwright>=1.51) + * + * ```js + * // basic usage + * const state = await I.grabStorageState(); + * require('fs').writeFileSync('authState.json', JSON.stringify(state)); + * + * // include IndexedDB when using Firebase Auth, etc. + * const stateWithIDB = await I.grabStorageState({ indexedDB: true }); + * ``` + */ + async grabStorageState(options = {}) { + return this.browserContext.storageState(options) + } + /** * {{> clearCookie }} */ diff --git a/test/helper/Playwright_test.js b/test/helper/Playwright_test.js index 430798cf9..d16af3281 100644 --- a/test/helper/Playwright_test.js +++ b/test/helper/Playwright_test.js @@ -1980,3 +1980,193 @@ describe('using data-testid attribute', () => { assert.equal(webElements.length, 1) }) }) + +// Tests for storageState configuration & helper behavior +describe('Playwright - storageState object ', function () { + let I + + before(() => { + global.codecept_dir = path.join(__dirname, '/../data') + + // Provide a storageState object (cookie + localStorage) to seed the context + I = new Playwright({ + url: siteUrl, + browser: 'chromium', + restart: true, + show: false, + waitForTimeout: 5000, + waitForAction: 200, + storageState: { + cookies: [ + { + name: 'auth', + value: '123', + domain: 'localhost', + path: '/', + httpOnly: false, + secure: false, + sameSite: 'Lax', + }, + ], + origins: [ + { + origin: siteUrl, + localStorage: [{ name: 'ls_key', value: 'ls_val' }], + }, + ], + }, + }) + I._init() + return I._beforeSuite() + }) + + afterEach(async () => { + return I._after() + }) + + it('should apply config storageState (cookies & localStorage)', async () => { + await I._before() + await I.amOnPage('/') + const cookies = await I.grabCookie() + const names = cookies.map(c => c.name) + expect(names).to.include('auth') + const authCookie = cookies.find(c => c.name === 'auth') + expect(authCookie && authCookie.value).to.equal('123') + const lsVal = await I.executeScript(() => localStorage.getItem('ls_key')) + assert.equal(lsVal, 'ls_val') + }) + + it('should allow Scenario cookies to override config storageState', async () => { + const test = { + title: 'override cookies scenario', + opts: { + cookies: [ + { + name: 'override', + value: '2', + domain: 'localhost', + path: '/', + }, + ], + }, + } + await I._before(test) + await I.amOnPage('/') + const cookies = await I.grabCookie() + const names = cookies.map(c => c.name) + expect(names).to.include('override') + expect(names).to.not.include('auth') // original config cookie ignored for this Scenario + const overrideCookie = cookies.find(c => c.name === 'override') + expect(overrideCookie && overrideCookie.value).to.equal('2') + }) + + it('grabStorageState should return current state', async () => { + await I._before() + await I.amOnPage('/') + const state = await I.grabStorageState() + expect(state.cookies).to.be.an('array') + const names = state.cookies.map(c => c.name) + expect(names).to.include('auth') + expect(state.origins).to.be.an('array') + const originEntry = state.origins.find(o => o.origin === siteUrl) + expect(originEntry).to.exist + if (originEntry && originEntry.localStorage) { + const lsNames = originEntry.localStorage.map(e => e.name) + expect(lsNames).to.include('ls_key') + } + // With IndexedDB flag (will include same base data; presence suffices) + const stateIdx = await I.grabStorageState({ indexedDB: true }) + expect(stateIdx).to.be.ok + }) +}) + +// Additional tests for storageState file path usage and error conditions +describe('Playwright - storageState file path', function () { + this.timeout(15000) + it('should load storageState from a JSON file path', async () => { + const tmpPath = path.join(__dirname, '../data/output/tmp-auth-state.json') + const fileState = { + cookies: [{ name: 'filecookie', value: 'f1', domain: 'localhost', path: '/' }], + origins: [{ origin: siteUrl, localStorage: [{ name: 'from_file', value: 'yes' }] }], + } + fs.mkdirSync(path.dirname(tmpPath), { recursive: true }) + fs.writeFileSync(tmpPath, JSON.stringify(fileState, null, 2)) + + let I = new Playwright({ + url: siteUrl, + browser: 'chromium', + restart: true, + show: false, + storageState: tmpPath, + }) + I._init() + await I._beforeSuite() + await I._before() + await I.amOnPage('/') + const cookies = await I.grabCookie() + const names = cookies.map(c => c.name) + expect(names).to.include('filecookie') + const lsVal = await I.executeScript(() => localStorage.getItem('from_file')) + expect(lsVal).to.equal('yes') + await I._after() + }) + + it('should allow Scenario cookies to override file-based storageState', async () => { + const tmpPath = path.join(__dirname, '../data/output/tmp-auth-state-override.json') + const fileState = { + cookies: [{ name: 'basecookie', value: 'b1', domain: 'localhost', path: '/' }], + origins: [{ origin: siteUrl, localStorage: [{ name: 'persist', value: 'keep' }] }], + } + fs.mkdirSync(path.dirname(tmpPath), { recursive: true }) + fs.writeFileSync(tmpPath, JSON.stringify(fileState, null, 2)) + + let I = new Playwright({ + url: siteUrl, + browser: 'chromium', + restart: true, + show: false, + storageState: tmpPath, + }) + I._init() + await I._beforeSuite() + const test = { + title: 'override cookies with file-based storageState', + opts: { + cookies: [{ name: 'override_from_file', value: 'ov1', domain: 'localhost', path: '/' }], + }, + } + await I._before(test) + await I.amOnPage('/') + const cookies = await I.grabCookie() + const names = cookies.map(c => c.name) + expect(names).to.include('override_from_file') + expect(names).to.not.include('basecookie') + const overrideCookie = cookies.find(c => c.name === 'override_from_file') + expect(overrideCookie && overrideCookie.value).to.equal('ov1') + await I._after() + }) + + it('should throw when storageState file path does not exist', async () => { + const badPath = path.join(__dirname, '../data/output/missing-auth-state.json') + let I = new Playwright({ + url: siteUrl, + browser: 'chromium', + restart: true, + show: false, + storageState: badPath, + }) + I._init() + await I._beforeSuite() + let threw = false + try { + await I._before() + } catch (e) { + threw = true + expect(e.message).to.match(/ENOENT|no such file|cannot find/i) + } + expect(threw, 'expected missing storageState path to throw').to.be.true + try { + await I._after() + } catch (_) {} + }) +})

AltStyle によって変換されたページ (->オリジナル) /