-
Notifications
You must be signed in to change notification settings - Fork 175
Playwright setup #713
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Playwright setup #713
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| import { defineConfig, devices } from '@playwright/test'; | ||
|
|
||
| declare module '@playwright/test' { | ||
| interface PlaywrightTestOptions { | ||
| adminURL: string; | ||
| adminUser: string; | ||
| adminPassword: string; | ||
| adminTOTPSecret: string; | ||
| } | ||
| } | ||
|
|
||
| export default defineConfig({ | ||
| testDir: './tests', | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if we should locate these in a subdirectory of |
||
| /* Run tests in files in parallel */ | ||
| fullyParallel: true, | ||
| /* Fail the build on CI if you accidentally left test.only in the source code. */ | ||
| forbidOnly: !!process.env.CI, | ||
| /* Retry on CI only */ | ||
| retries: process.env.CI ? 2 : 0, | ||
| /* Opt out of parallel tests on CI. */ | ||
| workers: process.env.CI ? 1 : undefined, | ||
| /* Reporter to use. See https://playwright.dev/docs/test-reporters */ | ||
| reporter: 'html', | ||
| /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ | ||
| use: { | ||
| baseURL: 'http://localhost:8888/', | ||
| trace: 'on-first-retry', | ||
| // Admin credentials for admin tests (override via env in CI if needed) | ||
| adminURL: 'http://localhost:8888/wp-admin/', | ||
| adminUser: 'admin', | ||
| adminPassword: 'password', | ||
| // Admin TOTP secret (base32) used by injectTOTP helper | ||
| adminTOTPSecret: 'MRMHA4LTLFMEMPBOMEQES7RQJQ2CCMZX', | ||
| }, | ||
|
|
||
| /* Configure projects for major browsers */ | ||
| projects: [ | ||
| { | ||
| name: 'chromium', | ||
| use: { ...devices['Desktop Chrome'] }, | ||
| }, | ||
| { | ||
| name: 'firefox', | ||
| use: { ...devices['Desktop Firefox'] }, | ||
| }, | ||
| { | ||
| name: 'webkit', | ||
| use: { ...devices['Desktop Safari'] }, | ||
| }, | ||
| ], | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| import { test } from '@playwright/test'; | ||
|
|
||
| test('Admin login', async ({ page, context }) => { | ||
| const info = test.info(); | ||
| const use = (info.project && (info.project as any).use) || {}; | ||
| const ADMIN_URL = (use.adminURL as string) || (use.baseURL as string) || ''; | ||
| const USERNAME = (use.adminUser as string) || ''; | ||
| const PASSWORD = (use.adminPassword as string) || ''; | ||
|
|
||
| await page.goto(ADMIN_URL, { waitUntil: 'domcontentloaded' }); | ||
|
|
||
| const usernameSelector = 'input[name="log"], input#user_login, input[aria-label="Username or Email Address"]'; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is a @wordpress/e2e-test-utils-playwright that includes all kinds of helpers for login and admin navigation. Might be useful to save on boilerplate code we need to write. |
||
| const passwordSelector = 'input[name="pwd"], input#user_pass, input[aria-label="Password"]'; | ||
|
|
||
| await page.locator(usernameSelector).first().waitFor({ state: 'visible', timeout: 15000 }); | ||
| await page.locator(usernameSelector).first().fill(USERNAME); | ||
| await page.locator(passwordSelector).first().fill(PASSWORD); | ||
|
|
||
| // Click login and wait for either a new page, auth code input, or a redirect to /wp-admin | ||
| await page.getByRole('button', { name: 'Log In' }).click(); | ||
|
|
||
| const newPagePromise = context.waitForEvent('page').then(p => ({ type: 'new', page: p })); | ||
| const authPromise = page.waitForSelector('#authcode', { timeout: 30000 }).then(() => ({ type: 'auth' })).catch(() => null); | ||
| const navPromise = page.waitForURL(/.*\/wp-admin.*$/, { timeout: 30000 }).then(() => ({ type: 'nav' })).catch(() => null); | ||
|
|
||
| let result = null; | ||
| try { | ||
| result = await Promise.race([newPagePromise, authPromise, navPromise]) as any; | ||
| } catch (e) { | ||
| // ignore | ||
| } | ||
|
|
||
| // Determine which page to use (original or newly opened) | ||
| let targetPage = page; | ||
| if (result && (result as any).type === 'new') { | ||
| targetPage = (result as any).page; | ||
| await targetPage.waitForLoadState('domcontentloaded'); | ||
| } | ||
|
|
||
| // If an auth code input is present on the active page, generate TOTP and fill it | ||
| if (await targetPage.$('#authcode')) { | ||
| const info = test.info(); | ||
| const use = (info.project && (info.project as any).use) || {}; | ||
| const secret = (use.adminTOTPSecret as string) || ''; | ||
| if (!secret) throw new Error('adminTOTPSecret not set in config'); | ||
|
|
||
| // Generate TOTP inside the page using SubtleCrypto | ||
| const otp = await targetPage.evaluate(async (s) => { | ||
| function base32Decode(input: string): Uint8Array { | ||
| const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; | ||
| const cleaned = input.replace(/=+$/, '').toUpperCase().replace(/[^A-Z2-7]/g, ''); | ||
| let bits = ''; | ||
| for (const ch of cleaned) { | ||
| const val = alphabet.indexOf(ch); | ||
| bits += val.toString(2).padStart(5, '0'); | ||
| } | ||
| const bytes: number[] = []; | ||
| for (let i = 0; i + 8 <= bits.length; i += 8) { | ||
| bytes.push(parseInt(bits.substr(i, 8), 2)); | ||
| } | ||
| return new Uint8Array(bytes); | ||
| } | ||
|
|
||
| /** | ||
| * Converts a given counter to a big-endian Uint8Array | ||
| * @param {number} counter - the counter to convert | ||
| * @returns {Uint8Array} a big-endian Uint8Array | ||
| */ | ||
| function toBigEndianUint8(counter: number): Uint8Array { | ||
| const buf = new ArrayBuffer(8); | ||
| const dv = new DataView(buf); | ||
| // split into hi/lo | ||
| const hi = Math.floor(counter / Math.pow(2, 32)); | ||
| const lo = counter >>> 0; | ||
| dv.setUint32(0, hi); | ||
| dv.setUint32(4, lo); | ||
| return new Uint8Array(buf); | ||
| } | ||
|
|
||
| const key = base32Decode(s); | ||
| const epoch = Math.floor(Date.now() / 1000); | ||
| const timestep = 30; | ||
| const counter = Math.floor(epoch / timestep); | ||
| const counterBytes = toBigEndianUint8(counter); | ||
|
|
||
| const cryptoKey = await crypto.subtle.importKey( | ||
| 'raw', | ||
| key.buffer, | ||
| { name: 'HMAC', hash: 'SHA-1' }, | ||
| false, | ||
| ['sign'] | ||
| ); | ||
| const sig = await crypto.subtle.sign('HMAC', cryptoKey, counterBytes); | ||
| const hmac = new Uint8Array(sig); | ||
| const offset = hmac[hmac.length - 1] & 0xf; | ||
| const code = ((hmac[offset] & 0x7f) << 24) | | ||
| ((hmac[offset + 1] & 0xff) << 16) | | ||
| ((hmac[offset + 2] & 0xff) << 8) | | ||
| (hmac[offset + 3] & 0xff); | ||
| const otp = (code % 10 ** 6).toString().padStart(6, '0'); | ||
| return otp; | ||
| }, secret); | ||
|
|
||
| await targetPage.fill('#authcode', otp); | ||
| } | ||
|
|
||
| if (!targetPage.isClosed()) { | ||
| await targetPage.waitForTimeout(2000); | ||
| } | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.