From 8f5f6615790b2795fe80f27cf3c35bb21035f71b Mon Sep 17 00:00:00 2001 From: David Hunt Date: Wed, 9 Oct 2024 17:35:31 +1300 Subject: [PATCH] feat(playwright): Initial release feat: added worldConfig to QuickPickleConfig, for config passed to World constructor chore: added vitest config to root for playwright workspace fix: added re-export of QuickPickleWorld and QuickPickleWorldInterface types feat: World constructor classes can now accept three params for the constructor: 1. context: the vitest test context 2. info: the gherkin info for the step 3. config: the worldConfig object of QuickPickleConfig --- .changeset/tough-gifts-rest.md | 6 + .../main/gherkin-example/example.feature.js | 2 +- packages/main/src/index.ts | 15 +- packages/main/src/render.ts | 6 +- packages/main/src/world.ts | 15 +- packages/playwright/package.json | 74 ++++++++++ packages/playwright/rollup.config.js | 54 ++++++++ packages/playwright/src/PlaywrightWorld.ts | 59 ++++++++ packages/playwright/src/actions.steps.ts | 111 +++++++++++++++ packages/playwright/src/outcomes.steps.ts | 73 ++++++++++ packages/playwright/src/world.ts | 4 + .../playwright/tests/gherkin-intro.feature | 30 ++++ packages/playwright/tests/playwright.feature | 10 ++ packages/playwright/tsconfig.json | 17 +++ packages/playwright/vite.config.ts | 16 +++ packages/steps-playwright/PlaywrightWorld.ts | 44 ------ packages/steps-playwright/package.json | 13 -- packages/steps-playwright/steps.ts | 129 ------------------ packages/steps-playwright/world.ts | 38 ------ pnpm-lock.yaml | 27 +++- vitest.workspace.ts | 4 + 21 files changed, 511 insertions(+), 236 deletions(-) create mode 100644 .changeset/tough-gifts-rest.md create mode 100644 packages/playwright/package.json create mode 100644 packages/playwright/rollup.config.js create mode 100644 packages/playwright/src/PlaywrightWorld.ts create mode 100644 packages/playwright/src/actions.steps.ts create mode 100644 packages/playwright/src/outcomes.steps.ts create mode 100644 packages/playwright/src/world.ts create mode 100644 packages/playwright/tests/gherkin-intro.feature create mode 100644 packages/playwright/tests/playwright.feature create mode 100644 packages/playwright/tsconfig.json create mode 100644 packages/playwright/vite.config.ts delete mode 100644 packages/steps-playwright/PlaywrightWorld.ts delete mode 100644 packages/steps-playwright/package.json delete mode 100644 packages/steps-playwright/steps.ts delete mode 100644 packages/steps-playwright/world.ts diff --git a/.changeset/tough-gifts-rest.md b/.changeset/tough-gifts-rest.md new file mode 100644 index 0000000..bffbe17 --- /dev/null +++ b/.changeset/tough-gifts-rest.md @@ -0,0 +1,6 @@ +--- +"quickpickle": minor +"@quickpickle/playwright": patch +--- + +Release playwright extension, and many fixes to make it work. diff --git a/packages/main/gherkin-example/example.feature.js b/packages/main/gherkin-example/example.feature.js index 73af164..17bfc9c 100644 --- a/packages/main/gherkin-example/example.feature.js +++ b/packages/main/gherkin-example/example.feature.js @@ -26,7 +26,7 @@ const afterScenario = async(state) => { } const initScenario = async(context, scenario, tags) => { - let state = new World(context); + let state = new World(context, { feature:'Feature: QuickPickle\'s Comprehensive Gherkin Syntax Example', scenario, tags }, {}); await state.init(); state.common = common; state.info.feature = 'Feature: QuickPickle\'s Comprehensive Gherkin Syntax Example'; diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts index 082866d..d61fe32 100644 --- a/packages/main/src/index.ts +++ b/packages/main/src/index.ts @@ -13,7 +13,7 @@ import { renderGherkin } from './render'; import { DataTable } from '@cucumber/cucumber'; import { DocString } from './models/DocString'; -export { setWorldConstructor, getWorldConstructor } from './world'; +export { setWorldConstructor, getWorldConstructor, QuickPickleWorld, QuickPickleWorldInterface } from './world'; export { DocString, DataTable } const featureRegex = /\.feature(?:\.md)?$/; @@ -84,6 +84,7 @@ export type QuickPickleConfig = { failTags: string|string[] concurrentTags: string|string[] sequentialTags: string|string[] + worldConfig: {[key:string]:any} }; export const defaultConfig: QuickPickleConfig = { @@ -101,7 +102,7 @@ export const defaultConfig: QuickPickleConfig = { /** * Tags to mark as failing, using Vitest's `test.failing` implementation. */ - failTags: ['@fails'], + failTags: ['@fails', '@failing'], /** * Tags to run in parallel, using Vitest's `test.concurrent` implementation. @@ -113,6 +114,13 @@ export const defaultConfig: QuickPickleConfig = { */ sequentialTags: ['@sequential'], + /** + * The config for the World class. Must be serializable with JSON.stringify. + * Not used by the default World class, but may be used by plugins or custom + * implementations, like @quickpickle/playwright. + */ + worldConfig: {} + } interface ResolvedConfig { @@ -121,7 +129,8 @@ interface ResolvedConfig { }; } -export function normalizeTags(tags:string|string[]):string[] { +export function normalizeTags(tags?:string|string[]|undefined):string[] { + if (!tags) return [] tags = Array.isArray(tags) ? tags : tags.split(/\s*,\s*/g) return tags.filter(Boolean).map(tag => tag.startsWith('@') ? tag : `@${tag}`) } diff --git a/packages/main/src/render.ts b/packages/main/src/render.ts index 06f22f6..d4af08e 100644 --- a/packages/main/src/render.ts +++ b/packages/main/src/render.ts @@ -60,13 +60,15 @@ export function renderFeature(feature:Feature, config:QuickPickleConfig) { // Get the background stes and all the scenarios let { backgroundSteps, children } = renderChildren(feature.children as FeatureChild[], config, tags) + let featureName = `${q(feature.keyword)}: ${q(feature.name)}` + // Render the initScenario function, which will be called at the beginning of each scenario return` const initScenario = async(context, scenario, tags) => { - let state = new World(context); + let state = new World(context, { feature:'${featureName}', scenario, tags }, ${JSON.stringify(config.worldConfig || {})}); await state.init(); state.common = common; - state.info.feature = '${q(feature.keyword)}: ${q(feature.name)}'; + state.info.feature = '${featureName}'; state.info.scenario = scenario; state.info.tags = [...tags]; await applyBeforeHooks(state); diff --git a/packages/main/src/world.ts b/packages/main/src/world.ts index 5ab506e..4f9eaee 100644 --- a/packages/main/src/world.ts +++ b/packages/main/src/world.ts @@ -20,23 +20,28 @@ export class QuickPickleWorld implements QuickPickleWorldInterface { feature: '', scenario: '', tags: [], - rule: '', - step: '', } common: QuickPickleWorldInterface['common'] = {} context: TestContext - constructor(context:TestContext) { + constructor(context:TestContext, info?:QuickPickleWorldInterface['info']) { this.context = context + if (info) this.info = {...info} } async init() {} } -let worldConstructor = QuickPickleWorld +export type WorldConstructor = new ( + context: TestContext, + info?: QuickPickleWorldInterface['info'], + worldConfig?: any +) => QuickPickleWorldInterface; + +let worldConstructor:WorldConstructor = QuickPickleWorld export function getWorldConstructor() { return worldConstructor } -export function setWorldConstructor(constructor: new (context:TestContext) => QuickPickleWorldInterface) { +export function setWorldConstructor(constructor: WorldConstructor) { worldConstructor = constructor } \ No newline at end of file diff --git a/packages/playwright/package.json b/packages/playwright/package.json new file mode 100644 index 0000000..d78e32a --- /dev/null +++ b/packages/playwright/package.json @@ -0,0 +1,74 @@ +{ + "name": "@quickpickle/playwright", + "version": "0.9.0", + "description": "Support files for running tests with Playwright using QuickPickle (Gherkin in Vitest).", + "keywords": [ + "BDD", + "testing", + "behavioral", + "cucumber", + "gherkin", + "vitest", + "playwright", + "react", + "svelte", + "vue", + "angular" + ], + "homepage": "https://github.com/dnotes/quickpickle#readme", + "bugs": { + "url": "https://github.com/dnotes/quickpickle/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/dnotes/quickpickle.git" + }, + "license": "MIT", + "type": "module", + "main": "./dist/PlaywrightWorld.cjs", + "module": "./dist/PlaywrightWorld.esm.js", + "types": "./dist/PlaywrightWorld.d.ts", + "exports": { + ".": { + "require": "./dist/world.cjs", + "import": "./dist/world.esm.js", + "types": "./dist/world.d.ts" + }, + "./actions": { + "require": "./dist/actions.steps.cjs", + "import": "./dist/actions.steps.esm.js", + "types": "./dist/actions.steps.d.ts" + }, + "./outcomes": { + "require": "./dist/outcomes.steps.cjs", + "import": "./dist/outcomes.steps.esm.js", + "types": "./dist/outcomes.steps.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "rollup -c", + "type-check": "tsc --noEmit", + "test:watch": "vitest", + "test": "vitest --run" + }, + "author": "David Hunt", + "dependencies": { + "@playwright/test": "^1.48.0", + "lodash-es": "^4.17.21", + "playwright": "^1.48.0", + "quickpickle": "workspace:^", + "vite": "^5.0.11" + }, + "devDependencies": { + "@rollup/plugin-replace": "^6.0.1", + "@rollup/plugin-typescript": "^12.1.0", + "@types/lodash-es": "^4.17.12", + "fast-glob": "^3.3.2", + "rollup": "^3.20.7", + "typescript": "^5.6.2", + "vitest": "^2.1.2" + } +} diff --git a/packages/playwright/rollup.config.js b/packages/playwright/rollup.config.js new file mode 100644 index 0000000..fb36973 --- /dev/null +++ b/packages/playwright/rollup.config.js @@ -0,0 +1,54 @@ +import typescript from '@rollup/plugin-typescript'; +import replace from '@rollup/plugin-replace'; +import glob from 'fast-glob'; +import path from 'node:path'; + +const input = Object.fromEntries( + glob.sync('src/**/*.ts').map(file => [ + // This will remove `src/` from the beginning and `.ts` from the end + path.relative('src', file.slice(0, -3)), + file + ]) +); + +export default { + input, + output: [ + { + dir: 'dist', + format: 'cjs', + sourcemap: true, + exports: 'named', + entryFileNames: '[name].cjs' + }, + { + dir: 'dist', + format: 'esm', + sourcemap: true, + exports: 'named', + entryFileNames: '[name].esm.js' + } + ], + plugins: [ + replace({ + preventAssignment: true, + values: { + 'import.meta?.env?.MODE': JSON.stringify('production'), + 'process?.env?.NODE_ENV': JSON.stringify('production'), + } + }), + typescript({ + tsconfig: './tsconfig.json', + declaration: true, + }), + ], + external: [ + '@playwright/test', + 'playwright', + 'quickpickle', + 'vite', + 'node:path', + 'node:url', + 'lodash-es', + ] +}; \ No newline at end of file diff --git a/packages/playwright/src/PlaywrightWorld.ts b/packages/playwright/src/PlaywrightWorld.ts new file mode 100644 index 0000000..aaea35d --- /dev/null +++ b/packages/playwright/src/PlaywrightWorld.ts @@ -0,0 +1,59 @@ +import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'; +import { normalizeTags, QuickPickleWorld, QuickPickleWorldInterface } from 'quickpickle'; +import { After } from 'quickpickle'; +import type { TestContext } from 'vitest'; +import { defaults, intersection } from 'lodash-es' + +export type PlaywrightWorldConfigSetting = { + nojsTags?: string|string[] + headless: boolean + sloMo: number +} + +export const defaultPlaywrightWorldConfig = { + nojsTags: ['@nojs', '@noscript'], + headless: true, + slowMo: 0, +} + +export type PlaywrightWorldConfig = typeof defaultPlaywrightWorldConfig + +export class PlaywrightWorld extends QuickPickleWorld { + browser!: Browser + browserContext!: BrowserContext + page!: Page + playwrightConfig:PlaywrightWorldConfig = defaultPlaywrightWorldConfig + + constructor(context:TestContext, info:QuickPickleWorldInterface['info']|undefined, worldConfig?:PlaywrightWorldConfigSetting) { + super(context, info) + let newConfig = defaults(defaultPlaywrightWorldConfig, worldConfig || {}) + newConfig.nojsTags = normalizeTags(newConfig.nojsTags) + this.playwrightConfig = newConfig + } + + async init() { + this.browser = await chromium.launch() + this.browserContext = await this.browser.newContext({ + serviceWorkers: 'block', + javaScriptEnabled: intersection(this.info.tags, this.playwrightConfig.nojsTags)?.length ? false : true, + }) + this.page = await this.browserContext.newPage() + } + + async reset() { + await this.page?.close() + await this.browserContext?.close() + this.browserContext = await this.browser.newContext({ + serviceWorkers: 'block' + }) + this.page = await this.browserContext.newPage() + } + + async close() { + await this.browser.close() + } +} + +After(async (world:PlaywrightWorld) => { + await world.browserContext.close() +}) diff --git a/packages/playwright/src/actions.steps.ts b/packages/playwright/src/actions.steps.ts new file mode 100644 index 0000000..c24850b --- /dev/null +++ b/packages/playwright/src/actions.steps.ts @@ -0,0 +1,111 @@ +import { Given, When, Then, DataTable } from "quickpickle"; +import type { PlaywrightWorld } from "./PlaywrightWorld"; +import path from 'node:path' +import url from 'node:url' + +export const projectRoot = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..') + +Given('I am on {string}', async function (world:PlaywrightWorld, path) { + let url = new URL(path, 'http://localhost:5173') + await world.page.goto(url.href) + await world.page.waitForTimeout(80) +}) +When(`I visit {string}`, async function (world:PlaywrightWorld, path) { + let url = new URL(path, 'http://localhost:5173') + await world.page.goto(url.href) + await world.page.waitForTimeout(80) +}) +When(`I navigate/go to {string}`, async function (world:PlaywrightWorld, path) { + let url = new URL(path, 'http://localhost:5173') + await world.page.goto(url.href) + await world.page.waitForTimeout(80) +}) + + +When('I click/press/tap/touch (on ){string}', async function (world:PlaywrightWorld, identifier) { + let locator = await world.page.getByText(identifier).or(world.page.locator(identifier)) + await locator.click() +}) + +When('I click/press/tap/touch (on )the {string} {word}', async function (world:PlaywrightWorld, identifier, role) { + let locator = await world.page.getByRole(role, { name: identifier }) + await locator.click() +}) + +When('I focus/select/activate (on ){string}', async function (world:PlaywrightWorld, identifier) { + let locator = await world.page.locator(identifier) + await locator.focus() +}) + +When('I focus/select/activate (on )the {string} {word}', async function (world:PlaywrightWorld, identifier, role) { + let locator = await world.page.getByRole(role, { name: identifier }) + 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) +}) +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(await world.page.getByPlaceholder(identifier)).or(await world.page.locator(identifier)) + await locator.fill(text) +}) + +When('I enter/fill (in )the following( fields):', async function (world:PlaywrightWorld, table:DataTable) { + 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) + } + } +}) + +When(/^I wait for "([^"]+)" to be (attached|detatched|visible|hidden)$/, async function (world:PlaywrightWorld, identifier, state) { + let locator = await world.page.getByText(identifier).or(world.page.getByLabel(identifier)).or(world.page.locator(identifier)) + await locator.waitFor({ state }) +}) + +When(/^I scroll (down|up|left|right)$/, async function (world:PlaywrightWorld, direction) { + let num = 100 + let horiz = direction.includes('t') + if (horiz) await world.page.mouse.wheel(direction === 'right' ? num : -num, 0) + await world.page.mouse.wheel(0, direction === 'down' ? num : -num) +}) +When(/^I scroll (down|up|left|right) (\d*)(?:px| pixels?)$/, async function (world:PlaywrightWorld, direction, int) { + let num = parseInt(int || '100') + let horiz = direction.includes('t') + if (horiz) await world.page.mouse.wheel(direction === 'right' ? num : -num, 0) + 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, direction) { + if (direction === 'back') await world.page.goBack() + else await world.page.goForward() +}) + +Then('(I )take (a )screenshot', async function (world:PlaywrightWorld, str:string) { + await world.page.waitForTimeout(50) + await world.page.screenshot({ path: `${projectRoot}/screenshots/${world.info.scenario}__${world.info.line}.png`.replace(/\/\//g,'/') }) +}) +Then('(I )take (a )screenshot #{int} in (the folder ){string}', async function (world:PlaywrightWorld, dir:string) { + await world.page.waitForTimeout(50) + await world.page.screenshot({ path: `${projectRoot}/${dir}/${world.info.scenario}__${world.info.line}.png`.replace(/\/\//g,'/') }) +}) + diff --git a/packages/playwright/src/outcomes.steps.ts b/packages/playwright/src/outcomes.steps.ts new file mode 100644 index 0000000..9734d68 --- /dev/null +++ b/packages/playwright/src/outcomes.steps.ts @@ -0,0 +1,73 @@ +import { Then } from "quickpickle"; +import type { PlaywrightWorld } from "./PlaywrightWorld"; +import { expect } from '@playwright/test' + +Then('I should see {string}', async function (world:PlaywrightWorld, text) { + await world.page.waitForTimeout(50) + await expect(world.page.getByText(text)).toBeVisible() +}) +Then('I should not see {string}', async function (world:PlaywrightWorld, text) { + await world.page.waitForTimeout(50) + await expect(world.page.getByText(text)).not.toBeVisible() +}) + +Then('I should see a(n)/the {string} {word}', async function (world:PlaywrightWorld, identifier, role) { + await world.page.waitForTimeout(50) + if (role === 'element') await expect(world.page.locator(identifier)).toBeVisible() + else await expect(world.page.getByRole(role, { name: identifier })).toBeVisible() +}) +Then('I should not see a(n)/the {string} {word}', async function (world:PlaywrightWorld, identifier, role) { + await world.page.waitForTimeout(50) + if (role === 'element') await expect(world.page.locator(identifier)).not.toBeVisible() + else await expect(world.page.getByRole(role, { name: identifier })).not.toBeVisible() +}) + +Then('I should see a(n)/the {string} with the text {string}', async function (world:PlaywrightWorld, identifier, text) { + await world.page.waitForTimeout(50) + await expect(world.page.locator(identifier).filter({ hasText: text })).toBeVisible() +}) +Then('I should not see a(n)/the {string} with the text {string}', async function (world:PlaywrightWorld, identifier, text) { + await world.page.waitForTimeout(50) + await expect(world.page.locator(identifier).filter({ hasText: text })).not.toBeVisible() +}) + + + +Then('the (value of ){string} (value )should contain/include/be/equal {string}', async function (world:PlaywrightWorld, identifier, val) { + let exact = world.info.step?.match(/should not (?:be|equal) ["']/) ? true : false + await world.page.waitForTimeout(50) + let value = await (await world.page.locator(identifier)).inputValue() + if (exact) await expect(value).toEqual(val) + else await expect(value).toContain(val) +}) +Then('the (value of ){string} (value )should not contain/include/be/equal {string}', async function (world:PlaywrightWorld, identifier, val) { + let exact = world.info.step?.match(/should not (?:be|equal) ["']/) ? true : false + await world.page.waitForTimeout(50) + let value = await (await world.page.locator(identifier)).inputValue() + if (exact) await expect(value).not.toEqual(val) + else await expect(value).not.toContain(val) +}) + +Then(/^the metatag for "([^"]+)" should (be|equal|contain) "(.*)"$/, async function (world:PlaywrightWorld, name, eq, value) { + await world.page.waitForTimeout(50) + let val:string|null + + if (name === 'title') val = await world.page.title() + else val = await (await world.page.locator(`meta[name="${name}"]`)).getAttribute('content') + + if (value === "") await expect(val).toBeNull() + else if (eq === 'contain') await expect(val).toContain(value) + else await expect(val).toBe(value) +}) + +Then('the active element should be {string}', async function (world:PlaywrightWorld, identifier) { + await world.page.waitForTimeout(50) + let locator = await world.page.locator(identifier) + await expect(locator).toBeFocused() +}) + +Then('the active element should be the {string} {word}', async function (world:PlaywrightWorld, identifier, role) { + await world.page.waitForTimeout(50) + let locator = await world.page.getByRole(role, { name: identifier }) + await expect(locator).toBeFocused() +}) diff --git a/packages/playwright/src/world.ts b/packages/playwright/src/world.ts new file mode 100644 index 0000000..6105883 --- /dev/null +++ b/packages/playwright/src/world.ts @@ -0,0 +1,4 @@ +import { setWorldConstructor } from "quickpickle"; +import { PlaywrightWorld } from "./PlaywrightWorld"; + +setWorldConstructor(PlaywrightWorld) \ No newline at end of file diff --git a/packages/playwright/tests/gherkin-intro.feature b/packages/playwright/tests/gherkin-intro.feature new file mode 100644 index 0000000..39fa080 --- /dev/null +++ b/packages/playwright/tests/gherkin-intro.feature @@ -0,0 +1,30 @@ +@concurrent +Feature: FAQ pages + As a curious user + I want the FAQ page to answer my questions + + Rule: Answers should be HIDDEN by default + People should have to work for their answers! + + # Gherkin tests are written in Scenarios that test a unit of behavior + Scenario: I expand a FAQ question to see the answer + Given I am on "https://docs.astro.build/en/recipes/sharing-state-islands/#why-nano-stores" + Then I should not see "Nano Stores and Svelte stores are very similar!" + When I click "How do Svelte stores compare" + Then I should see "Nano Stores and Svelte stores are very similar!" + When I click "How do Svelte stores compare" + Then I should not see "Nano Stores and Svelte stores are very similar!" + + @nojs + Scenario: FAQ questions expand even without javascript + Given I am on "https://docs.astro.build/en/recipes/sharing-state-islands/#why-nano-stores" + Then I should not see "Nano Stores and Svelte stores are very similar!" + When I click "How do Svelte stores compare" + Then I should see "Nano Stores and Svelte stores are very similar!" + + @nojs @fails + Scenario: Not like SOME people's widgets + Given I am on "https://www.w3.org/WAI/ARIA/apg/patterns/accordion/examples/accordion/" + Then I should see a "label" with the text "Phone:" + When I click on the "Personal Information" button + Then I should not see a "label" with the text "Phone:" diff --git a/packages/playwright/tests/playwright.feature b/packages/playwright/tests/playwright.feature new file mode 100644 index 0000000..7fc7c07 --- /dev/null +++ b/packages/playwright/tests/playwright.feature @@ -0,0 +1,10 @@ +@concurrent +Feature: Basic tests of Playwright browser and steps + + Scenario: I can go to a page + Given I go to "http://acid3.acidtests.org/" + Then I should see "Acid3" + + Scenario: Getting another page should support concurrency + Given I go to "https://xkcd.com/2928/" + Then I should see "Software Testing Day" \ No newline at end of file diff --git a/packages/playwright/tsconfig.json b/packages/playwright/tsconfig.json new file mode 100644 index 0000000..2e337f3 --- /dev/null +++ b/packages/playwright/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/packages/playwright/vite.config.ts b/packages/playwright/vite.config.ts new file mode 100644 index 0000000..4a70377 --- /dev/null +++ b/packages/playwright/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite'; +import { quickpickle } from 'quickpickle'; + +export default defineConfig({ + plugins: [ + // @ts-ignore + quickpickle(), + ], + test: { + testTimeout: 10000, + include: [ + 'tests/**/*.feature' + ], + setupFiles: ['./src/world.ts', './src/actions.steps.ts','./src/outcomes.steps.ts'], + }, +}); diff --git a/packages/steps-playwright/PlaywrightWorld.ts b/packages/steps-playwright/PlaywrightWorld.ts deleted file mode 100644 index cd452cc..0000000 --- a/packages/steps-playwright/PlaywrightWorld.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { World } from '@cucumber/cucumber'; -import { type Browser, type BrowserContext, type Page } from 'playwright'; - -export interface PlaywrightWorldInterface { - browser: Browser; - context: BrowserContext; - page: Page; - - scenario:string; - step:string; - tags:string[]; - - screenshots:string; - - data:{[key:string]:any} - -} - -export class PlaywrightWorld extends World implements PlaywrightWorldInterface { - browser: Browser; - context: BrowserContext; - page: Page; - scenario = '' - step = '' - tags = [] - screenshots = './screenshots' - data = {} - - constructor(browser:Browser, context:BrowserContext, page:Page, options:any = {}) { - super(options) - this.browser = browser - this.context = context - this.page = page - } - - async reset() { - await this.page?.close() - await this.context?.close() - this.context = await this.browser.newContext({ - serviceWorkers: 'block' - }) - this.page = await this.context.newPage() - } -} diff --git a/packages/steps-playwright/package.json b/packages/steps-playwright/package.json deleted file mode 100644 index 01c4d1f..0000000 --- a/packages/steps-playwright/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "@quickpickle/steps-playwright", - "version": "0.0.1", - "private": true, - "description": "", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "keywords": [], - "author": "", - "license": "ISC" -} diff --git a/packages/steps-playwright/steps.ts b/packages/steps-playwright/steps.ts deleted file mode 100644 index e54cb1d..0000000 --- a/packages/steps-playwright/steps.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { Given, When, Then, DataTable } from '@cucumber/cucumber' -import { PlaywrightWorldInterface } from './PlaywrightWorld.js' -import { expect } from '@playwright/test' - -Given('I am on {string}', async function (this:PlaywrightWorldInterface, url) { - await this.page.goto(url) -}) -When(`I visit {string}`, async function (this:PlaywrightWorldInterface, url) { - await this.page.goto(url) -}) -When(`I navigate/go to {string}`, async function (this:PlaywrightWorldInterface, url) { - await this.page.goto(url) -}) - - -When('I click/press/tap/touch {string}', async function (this:PlaywrightWorldInterface, identifier) { - let locator = await this.page.getByText(identifier).or(this.page.locator(identifier)) - await locator.click() -}) - -When('I click/press/tap/touch the {string} {word}', async function (this:PlaywrightWorldInterface, identifier, role) { - let locator = await this.page.getByRole(role, { name: identifier }) - await locator.click() -}) - -When('I focus/select/activate {string}', async function (this:PlaywrightWorldInterface, identifier) { - let locator = await this.page.locator(identifier) - await locator.focus() -}) - -When('I focus/select/activate the {string} {word}', async function (this:PlaywrightWorldInterface, identifier, role) { - let locator = await this.page.getByRole(role, { name: identifier }) - await locator.focus() -}) - -When("for {string} I enter {string}", async function (this:PlaywrightWorldInterface, identifier, text) { - let locator = await this.page.getByLabel(identifier).or(this.page.getByPlaceholder(identifier)).or(this.page.locator(identifier)) - await locator.fill(text) -}) - -When('I fill in the following( fields):', async function (this:PlaywrightWorldInterface, table:DataTable) { - for (let row of table.raw()) { - let [identifier, text] = row - let locator = await this.page.getByLabel(identifier).or(this.page.getByPlaceholder(identifier)).or(this.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) - } - } -}) - -When(/^I wait for "([^"]+)" to be (attached|detatched|visible|hidden)$/, async function (this:PlaywrightWorldInterface, identifier, state) { - let locator = await this.page.getByText(identifier).or(this.page.getByLabel(identifier)).or(this.page.locator(identifier)) - await locator.waitFor({ state }) -}) - -When(/^I scroll (down|up|left|right)$/, async function (this:PlaywrightWorldInterface, direction) { - let num = 100 - let horiz = direction.includes('t') - if (horiz) await this.page.mouse.wheel(direction === 'right' ? num : -num, 0) - await this.page.mouse.wheel(0, direction === 'down' ? num : -num) -}) -When(/^I scroll (down|up|left|right) (\d*)(?:px| pixels?)$/, async function (this:PlaywrightWorldInterface, direction, int) { - let num = parseInt(int || '100') - let horiz = direction.includes('t') - if (horiz) await this.page.mouse.wheel(direction === 'right' ? num : -num, 0) - await this.page.mouse.wheel(0, direction === 'down' ? num : -num) -}) - -When('I type the following keys: {}', async function (this:PlaywrightWorldInterface, keys:string) { - let keyPresses = keys.split(' ') - for (let key of keyPresses) { - await this.page.keyboard.press(key) - } -}) - -When(/^I go (back|forwards?)$/, async function (this:PlaywrightWorldInterface, direction) { - if (direction === 'back') await this.page.goBack() - else await this.page.goForward() -}) - -Then('I should see {string}', async function (this:PlaywrightWorldInterface, text) { - await expect(this.page.getByText(text).or(this.page.locator(text))).toBeVisible() -}) - -Then('I should not see {string}', async function (this:PlaywrightWorldInterface, text) { - await expect(this.page.getByText(text).or(this.page.locator(text))).not.toBeVisible() -}) - -Then('I should see a(n) {string} with the text {string}', async function (this:PlaywrightWorldInterface, identifier, text) { - await expect(this.page.locator(identifier, { hasText: text })).toBeVisible() -}) - -Then('I should not see a(n) {string} with the text {string}', async function (this:PlaywrightWorldInterface, identifier, text) { - await expect(this.page.locator(identifier, { hasText: text })).not.toBeVisible() -}) - -Then(/^the metatag for "([^"]+)" should (be|equal|contain) "(.*)"$/, async function (this:PlaywrightWorldInterface, name, eq, value) { - let val:string|null - - if (name === 'title') val = await this.page.title() - else val = await (await this.page.locator(`meta[name="${name}"]`)).getAttribute('content') - - if (value === "") await expect(val).toBeNull() - else if (eq === 'contain') await expect(val).toContain(value) - else await expect(val).toBe(value) -}) - -Then('take a screenshot', async function (this:PlaywrightWorldInterface) { - await this.page.screenshot({ path: `${this.screenshots}/${this.scenario}__${this.step}.png`.replace(/\/\//g,'/') }) -}) - -Then('The active element should be {string}', async function (this:PlaywrightWorldInterface, identifier) { - let locator = await this.page.locator(identifier) - await expect(locator).toBeFocused() -}) - -Then('The active element should be the {string} {word}', async function (this:PlaywrightWorldInterface, identifier, role) { - let locator = await this.page.getByRole(role, { name: identifier }) - await expect(locator).toBeFocused() -}) \ No newline at end of file diff --git a/packages/steps-playwright/world.ts b/packages/steps-playwright/world.ts deleted file mode 100644 index 730d125..0000000 --- a/packages/steps-playwright/world.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { AfterAll, Before, BeforeStep, IWorldOptions, setWorldConstructor, World } from '@cucumber/cucumber'; -import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'; -import { PlaywrightWorld } from './PlaywrightWorld.js'; - -let browser: Browser; -let context: BrowserContext; -let page: Page; - -browser = await(chromium.launch()) -context = await browser.newContext({ - serviceWorkers: 'block' -}) -page = await context.newPage() - -let webWorld = new PlaywrightWorld(browser, context, page) - -setWorldConstructor(webWorld) - -BeforeStep(async function (step) { - - this.step = step.pickleStep.text - -}) - -Before(async function (scenario) { - - this.browser = browser - this.context = context - this.page = page - - this.scenario = scenario.pickle.name - this.tags = scenario.pickle.tags.map(t => t.name) - -}); - -AfterAll(async function () { - await browser.close() -}) \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3eb6642..07164b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,11 +74,14 @@ importers: specifier: ^2.1.2 version: 2.1.2(@types/node@18.19.54)(@vitest/browser@2.1.2)(jsdom@25.0.1)(msw@2.4.9(typescript@5.6.2))(terser@5.34.1) - packages/steps-playwright: + packages/playwright: dependencies: '@playwright/test': specifier: ^1.48.0 version: 1.48.0 + lodash-es: + specifier: ^4.17.21 + version: 4.17.21 playwright: specifier: ^1.48.0 version: 1.48.0 @@ -88,6 +91,28 @@ importers: vite: specifier: ^5.0.11 version: 5.4.8(@types/node@22.7.4)(terser@5.34.1) + devDependencies: + '@rollup/plugin-replace': + specifier: ^6.0.1 + version: 6.0.1(rollup@3.29.5) + '@rollup/plugin-typescript': + specifier: ^12.1.0 + version: 12.1.0(rollup@3.29.5)(tslib@2.7.0)(typescript@5.6.2) + '@types/lodash-es': + specifier: ^4.17.12 + version: 4.17.12 + fast-glob: + specifier: ^3.3.2 + version: 3.3.2 + rollup: + specifier: ^3.20.7 + version: 3.29.5 + typescript: + specifier: ^5.6.2 + version: 5.6.2 + vitest: + specifier: ^2.1.2 + version: 2.1.2(@types/node@22.7.4)(@vitest/browser@2.1.2)(jsdom@25.0.1)(msw@2.4.9(typescript@5.6.2))(terser@5.34.1) packages/svelte: dependencies: diff --git a/vitest.workspace.ts b/vitest.workspace.ts index c2e2fac..1e8a40a 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -15,5 +15,9 @@ export default defineWorkspace([ test: { include : [ 'tests/*.test.ts' ], } + }, + { + root: './packages/playwright', + extends: './packages/playwright/vite.config.ts', } ])