Skip to content

Commit

Permalink
fix(playwright): fix and test all action step definitions
Browse files Browse the repository at this point in the history
  • Loading branch information
dnotes committed Oct 21, 2024
1 parent f1167dc commit 07920cd
Show file tree
Hide file tree
Showing 14 changed files with 510 additions and 91 deletions.
5 changes: 5 additions & 0 deletions .changeset/new-plums-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@quickpickle/playwright": patch
---

fixed all action step definitions, with tests
3 changes: 2 additions & 1 deletion packages/playwright/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,10 @@
"author": "David Hunt",
"dependencies": {
"@playwright/test": "^1.48.0",
"pngjs": "^7.0.0",
"lodash-es": "^4.17.21",
"pixelmatch": "^6.0.0",
"playwright": "^1.48.0",
"pngjs": "^7.0.0",
"quickpickle": "workspace:^",
"vite": "^5.0.11"
},
Expand All @@ -82,6 +82,7 @@
"@types/pixelmatch": "^5.2.6",
"@types/pngjs": "^6.0.5",
"fast-glob": "^3.3.2",
"js-yaml": "^4.1.0",
"playwright-core": "^1.48.0",
"rollup": "^3.20.7",
"typescript": "^5.6.2",
Expand Down
Binary file modified packages/playwright/screenshots/playwright-example.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
45 changes: 33 additions & 12 deletions packages/playwright/src/PlaywrightWorld.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export type PlaywrightWorldConfigSetting = Partial<{
headless: boolean // whether to run the browser in headless mode (default true)
slowMo: boolean|number // whether to run the browser with slow motion enabled (default false)
slowMoMs: number // the number of milliseconds to slow down the browser by (default 500)
keyboardDelay: number // the number of milliseconds between key presses
defaultBrowser: 'chromium'|'firefox'|'webkit' // the default browser to use
}>

export const defaultPlaywrightWorldConfig = {
Expand All @@ -27,6 +29,8 @@ export const defaultPlaywrightWorldConfig = {
headless: true,
slowMo: false,
slowMoMs: 500,
keyboardDelay: 20,
defaultBrowser: 'chromium'
}

export type PlaywrightWorldConfig = typeof defaultPlaywrightWorldConfig & { port?:number }
Expand All @@ -39,35 +43,52 @@ export class PlaywrightWorld extends QuickPickleWorld {

constructor(context:TestContext, info:QuickPickleWorldInterface['info']|undefined, worldConfig:PlaywrightWorldConfigSetting = {}) {
super(context, info)
let newConfig = defaultsDeep(worldConfig || {}, defaultPlaywrightWorldConfig, )
this.setConfig(worldConfig)
}

async init() {
await this.startBrowser()
this.browserContext = await this.browser.newContext({
serviceWorkers: 'block',
javaScriptEnabled: this.tagsMatch(this.playwrightConfig.nojsTags) ? false : true,
})
this.page = await this.browserContext.newPage()
}

get browserName() {
return this.info.tags.find(t => t.match(
/^@(?:chromium|firefox|webkit)$/
))?.replace(/^@/, '') as 'chromium'|'firefox'|'webkit' ?? this.playwrightConfig.defaultBrowser ?? 'chromium'
}

setConfig(worldConfig:PlaywrightWorldConfigSetting) {
let newConfig = defaultsDeep(worldConfig || {}, defaultPlaywrightWorldConfig )
newConfig.nojsTags = normalizeTags(newConfig.nojsTags)
newConfig.showBrowserTags = normalizeTags(newConfig.showBrowserTags)
newConfig.slowMoTags = normalizeTags(newConfig.slowMoTags)
if (!['chromium','firefox','webkit'].includes(newConfig.defaultBrowser)) newConfig.defaultBrowser = 'chromium'
if (typeof newConfig.slowMo === 'number') {
newConfig.slowMoMs = newConfig.slowMo
newConfig.slowMo = newConfig.slowMoMs > 0
}
this.playwrightConfig = newConfig
}

async init() {
let browserName = this.info.tags.find(t => t.match(
/^@(?:chromium|firefox|webkit)$/
))?.replace(/^@/, '') as 'chromium'|'firefox'|'webkit' ?? 'chromium'
this.browser = await browsers[browserName].launch({
async startBrowser() {
this.browser = await browsers[this.browserName].launch({
headless: this.tagsMatch(this.playwrightConfig.showBrowserTags) ? false : this.playwrightConfig.headless,
slowMo: (this.playwrightConfig.slowMo || this.tagsMatch(this.playwrightConfig.slowMoTags)) ? this.playwrightConfig.slowMoMs : 0
})
this.browserContext = await this.browser.newContext({
serviceWorkers: 'block',
javaScriptEnabled: this.tagsMatch(this.playwrightConfig.nojsTags) ? false : true,
})
this.page = await this.browserContext.newPage()
}

async reset() {
async reset(conf?:PlaywrightWorldConfigSetting) {
await this.page?.close()
await this.browserContext?.close()
if (conf) {
await this.browser.close()
await this.setConfig(conf)
await this.startBrowser()
}
this.browserContext = await this.browser.newContext({
serviceWorkers: 'block'
})
Expand Down
150 changes: 102 additions & 48 deletions packages/playwright/src/actions.steps.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { Given, When, Then, DataTable } from "quickpickle";
import type { PlaywrightWorld } from "./PlaywrightWorld";
import { expect } from "@playwright/test";
import { getLocator, sanitizeFilepath, setValue } from "./helpers";

import path from 'node:path'
import url from 'node:url'
import type { Locator } from "playwright/test";

export const projectRoot = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..')

// ================
// Navigation

Given('I am on {string}', async function (world:PlaywrightWorld, path) {
let url = new URL(path, world.baseUrl)
await world.page.goto(url.href)
Expand All @@ -23,67 +27,125 @@ When('I load the file {string}', async (world:PlaywrightWorld, path) => {
await world.page.goto(`file://${projectRoot}/${path}`)
})

When('I click/press/tap/touch (on ){string}', async function (world:PlaywrightWorld, identifier) {
await world.page.getByText(identifier, { exact:true }).click()
When('I go back/forward/forwards', async function (world:PlaywrightWorld) {
let direction = world.info.step?.match(/(back|forwards?)$/)![0] as 'back'|'forwards'
if (direction === 'back') await world.page.goBack()
else await world.page.goForward()
})

// ================
// Interaction

When('I click/press/tap/touch (on ){string}', async function (world:PlaywrightWorld, identifier) {
let locator = world.page.getByText(identifier, { exact:true })
await expect(locator).toBeVisible({ timeout:1000 })
await locator.click()
})
When('I click/press/tap/touch (on )the {string} {word}', async function (world:PlaywrightWorld, identifier, role) {
let locator:Locator
if (role === 'element') locator = await world.page.getByText(identifier, { exact:true }).or(world.page.locator(identifier))
else locator = await world.page.getByRole(role, { name: identifier })
let locator = await getLocator(world.page, identifier, role)
await expect(locator).toBeVisible({ timeout:1000 })
await locator.click()
})

When('I focus/select/activate (on ){string}', async function (world:PlaywrightWorld, identifier) {
let locator = await world.page.getByText(identifier, { exact:true }).or(world.page.locator(identifier))
let locator = await world.page.getByText(identifier, { exact:true })
await expect(locator).toBeVisible({ timeout:1000 })
await locator.focus()
})

When('I focus/select/activate (on )the {string} {word}', async function (world:PlaywrightWorld, identifier, role) {
let locator:Locator
if (role === 'element') locator = await world.page.getByText(identifier, { exact:true }).or(world.page.locator(identifier))
else locator = await world.page.getByRole(role, { name: identifier })
let locator = await getLocator(world.page, identifier, role)
await expect(locator).toBeVisible({ timeout:1000 })
await locator.focus()
})

When("for (the ){string} I enter/fill (in ){string}", async function (world:PlaywrightWorld, identifier, text) {
let locator = await world.page.getByLabel(identifier).or(world.page.getByPlaceholder(identifier)).or(world.page.locator(identifier))
await locator.fill(text)
// ================
// Typing

When("for/in/on (the ){string} I type {string}", async function (world:PlaywrightWorld, identifier, value) {
let locator = await getLocator(world.page, identifier, 'input')
await locator.pressSequentially(value)
})
When("for/in/on (the ){string} {word} I type {string}", async function (world:PlaywrightWorld, identifier, role, value) {
let locator = await getLocator(world.page, identifier, role)
await locator.pressSequentially(value)
})

When('I type the following keys: {}', async function (world:PlaywrightWorld, keys:string) {
let keyPresses = keys.split(' ')
for (let key of keyPresses) await world.page.keyboard.press(key, { delay:world.playwrightConfig.keyboardDelay })
})
When("for/in/on (the ){string} I type the following keys: {}", async function (world:PlaywrightWorld, identifier, keys) {
let locator = await getLocator(world.page, identifier, 'input')
for (let key of keys) await locator.press(key, { delay:world.playwrightConfig.keyboardDelay })
})
When("for (the ){string} I enter/fill (in )the following( text):", async function (world:PlaywrightWorld, identifier, text) {
let locator = await world.page.getByLabel(identifier).or(world.page.getByPlaceholder(identifier)).or(world.page.locator(identifier))
await locator.fill(text)
When("for/in/on (the ){string} {word} I type the following keys: {}", async function (world:PlaywrightWorld, identifier, role, keys) {
let locator = await getLocator(world.page, identifier, role)
for (let key of keys) await locator.press(key, { delay:world.playwrightConfig.keyboardDelay })
})

// ================
// Forms

When("for/in/on (the ){string} I enter/fill/select (in ){string}", async function (world:PlaywrightWorld, identifier, value) {
let locator = await getLocator(world.page, identifier, 'input')
await setValue(locator, value)
})
When("for/in/on (the ){string} {word} I enter/fill/select (in ){string}", async function (world:PlaywrightWorld, identifier, role, value) {
let locator = await getLocator(world.page, identifier, role)
await setValue(locator, value)
})
When("for/in/on (the ){string} I enter/fill/select (in )the following( text):", async function (world:PlaywrightWorld, identifier, value) {
let locator = await getLocator(world.page, identifier, 'input')
await setValue(locator, value.toString())
})
When("for/in/on (the ){string} {word} I enter/fill/select (in )the following( text):", async function (world:PlaywrightWorld, identifier, role, value) {
let locator = await getLocator(world.page, identifier, role)
await setValue(locator, value.toString())
})
When('I enter/fill (in )the following( fields):', async function (world:PlaywrightWorld, table:DataTable) {
let rows = table.raw()
let hasRole = rows[0].length === 3
for (let row of table.raw()) {
let [identifier, text] = row
let locator = await world.page.getByLabel(identifier).or(world.page.getByPlaceholder(identifier)).or(world.page.locator(identifier))
let tag = await locator.evaluate(e => e.tagName.toLowerCase())
let type = await locator.getAttribute('type')
if (tag === 'select') {
await locator.selectOption(text)
}
else if (type === 'checkbox' || type === 'radio') {
let checked:boolean = (['','false','no','unchecked','null','undefined','0']).includes(text.toLowerCase()) ? false : true
await locator.setChecked(checked)
}
else {
await locator.fill(text)
let [identifier, role, value] = row
if (!hasRole) {
value = role
role = 'input'
}
let locator = await getLocator(world.page, identifier, role)
await setValue(locator, value)
}
})

When('I wait for {string} to be attached/detatched/visible/hidden', async function (world:PlaywrightWorld, identifier) {
When('I check (the ){string}( radio)( checkbox)( box)', async function (world:PlaywrightWorld, indentifier) {
let locator = await getLocator(world.page, indentifier, 'input')
await locator.check()
})
When('I uncheck (the ){string}( checkbox)( box)', async function (world:PlaywrightWorld, indentifier) {
let locator = await getLocator(world.page, indentifier, 'input')
await locator.uncheck()
})

// ================
// Waiting

When('I wait for {string} to be attached/detatched/visible/hidden', async function (world:PlaywrightWorld, text) {
let state = world.info.step?.match(/(attached|detatched|visible|hidden)$/)![0] as 'attached'|'detached'|'visible'|'hidden'
let locator = world.page.getByText(text)
await locator.waitFor({ state, timeout:5000 })
})
When('I wait for a/an/the {string} {word} to be attached/detatched/visible/hidden', async function (world:PlaywrightWorld, identifier, role) {
let state = world.info.step?.match(/(attached|detatched|visible|hidden)$/)![0] as 'attached'|'detached'|'visible'|'hidden'
let locator = await world.page.getByText(identifier, { exact:true }).or(world.page.getByLabel(identifier)).or(world.page.locator(identifier))
await locator.waitFor({ state })
let locator = await getLocator(world.page, identifier, role)
await locator.waitFor({ state, timeout:5000 })
})

When('I wait for {int}ms', async function (world:PlaywrightWorld, num) {
await world.page.waitForTimeout(num)
})

// ================
// Scrolling

When('I scroll down/up/left/right', async function (world:PlaywrightWorld) {
let direction = world.info.step?.match(/(down|up|left|right)$/)![0] as 'down'|'up'|'left'|'right'
let num = 100
Expand All @@ -98,22 +160,14 @@ When('I scroll down/up/left/right {int}(px)( pixels)', async function (world:Pla
await world.page.mouse.wheel(0, direction === 'down' ? num : -num)
})

When('I type the following keys: {}', async function (world:PlaywrightWorld, keys:string) {
let keyPresses = keys.split(' ')
for (let key of keyPresses) {
await world.page.keyboard.press(key)
}
})

When('I go back/forwards?', async function (world:PlaywrightWorld) {
let direction = world.info.step?.match(/(back|forwards?)$/)![0] as 'back'|'forwards'
if (direction === 'back') await world.page.goBack()
else await world.page.goForward()
})
// ================
// Screenshots

Then('(I )take (a )screenshot', async function (world:PlaywrightWorld) {
await world.page.screenshot({ path: `${projectRoot}/${world.playwrightConfig.screenshotDir}/${world.info.rule ? world.info.rule + '__' + world.info.scenario : world.info.scenario}__${world.info.line}.png`.replace(/\/\//g,'/') })
let path = sanitizeFilepath(`${projectRoot}/${world.playwrightConfig.screenshotDir}/${world.info.rule ? world.info.rule + '__' + world.info.scenario : world.info.scenario}__${world.info.line}.png`)
await world.page.screenshot({ path })
})
Then('(I )take (a )screenshot named {string}', async function (world:PlaywrightWorld, name:string) {
await world.page.screenshot({ path: `${projectRoot}/${world.playwrightConfig.screenshotDir}/${name}.png`.replace(/\/\//g,'/') })
let path = sanitizeFilepath(`${projectRoot}/${world.playwrightConfig.screenshotDir}/${name}.png`)
await world.page.screenshot({ path })
})
29 changes: 29 additions & 0 deletions packages/playwright/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,37 @@ import { Locator, Page } from "playwright-core"
export async function getLocator(el:Locator|Page, identifier:string, role:string, text:string|null=null) {
let locator:Locator
if (role === 'element') locator = await el.locator(identifier)
else if (role === 'input') locator = await el.getByLabel(identifier).or(el.getByPlaceholder(identifier))
else locator = await el.getByRole(role as any, { name: identifier })
if (text) return await locator.filter({ hasText: text })
return locator
}

export async function setValue(locator:Locator, value:string|any) {
let { tag, type, role } = await locator.evaluate((el) => ({ tag:el.tagName.toLowerCase(), type:el.getAttribute('type')?.toLowerCase(), role:el.getAttribute('role')?.toLowerCase() }))
if (tag === 'select') {
let values = value.split(/\s*(?<!\\),\s*/).map((v:string) => v.replace(/\\,/g, ','))
await locator.selectOption(values)
}
else if (type === 'checkbox' || type === 'radio' || role === 'checkbox') {
let check = !( ['false','no','unchecked','','null','undefined','0'].includes(value.toString().toLowerCase()) )
if (check) await locator.check()
else await locator.uncheck()
}
else {
await locator.fill(value)
}
}

export async function testMetatag(page:Page, name:string, expected:string, exact:boolean) {
let actual:string|null

if (name === 'title') actual = await page.title()
else actual = await (await page.locator(`meta[name="${name}"]`)).getAttribute('content')

return exact ? actual === expected : actual?.includes(expected)
}

export function sanitizeFilepath(filepath:string) {
return filepath.replace(/\/\/+/g, '/').replace(/\/[\.~]+\//g, '/')
}
Loading

0 comments on commit 07920cd

Please sign in to comment.