From a5214b8a85858f5494b2a9aefa0328765f4eb961 Mon Sep 17 00:00:00 2001 From: maximfromit Date: Tue, 20 Aug 2024 22:09:36 +0300 Subject: [PATCH] Initial commit --- .github/workflows/playwright.yml | 27 ++ .gitignore | 12 + README.md | 57 ++++ e2e/example.spec.ts | 18 ++ index-original.js | 16 + index.js | 114 +++++++ package-lock.json | 97 ++++++ package.json | 19 ++ playwright.config.js | 130 ++++++++ playwright.config.ts | 81 +++++ tests-examples/demo-todo-app.spec.ts | 437 +++++++++++++++++++++++++++ tests/example.spec.ts | 54 ++++ why_qa_wolf.txt | 0 13 files changed, 1062 insertions(+) create mode 100644 .github/workflows/playwright.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 e2e/example.spec.ts create mode 100644 index-original.js create mode 100644 index.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.js create mode 100644 playwright.config.ts create mode 100644 tests-examples/demo-todo-app.spec.ts create mode 100644 tests/example.spec.ts create mode 100644 why_qa_wolf.txt diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..467190b --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,27 @@ +name: Playwright Tests +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b08a70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +/test-results/ +/playwright-report/ +/playwright/.cache/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f44c7e --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# 🐺 QA Wolf Take Home Assignment + +Welcome to the QA Wolf take home assignment for our [QA Engineer](https://www.notion.so/qawolf/QA-Wolf-QA-Engineer-Remote-156203a1e476459ea5e6ffca972d0efe) role! We appreciate your interest and look forward to seeing what you come up with. + +## Instructions + +This assignment has two questions as outlined below. When you are done, send [qa-hiring@qawolf.com](mailto:qa-hiring@qawolf.com) the following: + +1. A link to a zip file of this folder on Google Drive + +2. A note indicating your work location (Country/State) + +3. A note of how you found this job post (LinkedIn, Handshake, Wellfound, referral, etc.) + +### Question 1 + +In this assignment, you will create a script on [Hacker News](https://news.ycombinator.com/) using JavaScript and Microsoft's [Playwright](https://playwright.dev/) framework. + +1. Install node modules by running `npm i`. + +2. Edit the `index.js` file in this project to go to [Hacker News/newest](https://news.ycombinator.com/newest) and validate that EXACTLY the first 100 articles are sorted from newest to oldest. You can run your script with the `node index.js` command. + +Note that you are welcome to update Playwright or install other packages as you see fit, however you must utilize Playwright in this assignment. + +### Question 2 + +Why do you want to work at QA Wolf? Please record a short, ~2 min video that includes: + +1. Your answer + +2. A walk-through demonstration of your code, showing a successful execution + +Post the link in `why_qa_wolf.txt` (Please use [Loom](https://www.loom.com) to record your response). The answer and walkthrough should be combined into *one* video. + +## Frequently Asked Questions + +### What is your hiring process? When will I hear about next steps? + +This take home assignment is the first step in our hiring process, followed by a final round interview if it goes well. **We review every take home assignment submission and promise to get back to you either way within one week (usually sooner).** The only caveat is if we are out of the office, in which case we will get back to you when we return. If it has been more than one week and you have not heard from us, please do follow up. + +The final round interview is a 2-hour technical work session that reflects what it is like to work here. We provide a $150 stipend for your time for the final round interview regardless of how it goes. After that, there may be a short chat with our director about your experience and the role. + +Our hiring process is rolling where we review candidates until we have filled our openings. If there are no openings left, we will keep your contact information on file and reach out when we are hiring again. + +### How do you decide who to hire? + +We evaluate candidates based on three criteria: + +- Technical ability (as demonstrated in the take home and final round) +- Customer service orientation (as this role is customer facing) +- Alignment with our values (captured [here](https://www.notion.so/qawolf/QA-Wolf-QA-Engineer-Remote-156203a1e476459ea5e6ffca972d0efe)) + +This means whether we hire you is based on how you do during our interview process, not on your previous experience (or lack thereof). Note that you will also need to pass a background check to work here as our customers require this. + +### How can I help my application stand out? + +We've found that our best hires have been the most enthusiastic throughout our process. If you are very excited about working here, please feel free to go above and beyond on this assignment. diff --git a/e2e/example.spec.ts b/e2e/example.spec.ts new file mode 100644 index 0000000..54a906a --- /dev/null +++ b/e2e/example.spec.ts @@ -0,0 +1,18 @@ +import { test, expect } from '@playwright/test'; + +test('has title', async ({ page }) => { + await page.goto('https://playwright.dev/'); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Playwright/); +}); + +test('get started link', async ({ page }) => { + await page.goto('https://playwright.dev/'); + + // Click the get started link. + await page.getByRole('link', { name: 'Get started' }).click(); + + // Expects page to have a heading with the name of Installation. + await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); +}); diff --git a/index-original.js b/index-original.js new file mode 100644 index 0000000..6906f7b --- /dev/null +++ b/index-original.js @@ -0,0 +1,16 @@ +// EDIT THIS FILE TO COMPLETE ASSIGNMENT QUESTION 1 +const { chromium } = require("playwright"); + +async function sortHackerNewsArticles() { + // launch browser + const browser = await chromium.launch({ headless: false }); + const context = await browser.newContext(); + const page = await context.newPage(); + + // go to Hacker News + await page.goto("https://news.ycombinator.com/newest"); +} + +(async () => { + await sortHackerNewsArticles(); +})(); diff --git a/index.js b/index.js new file mode 100644 index 0000000..668c627 --- /dev/null +++ b/index.js @@ -0,0 +1,114 @@ +import { test, expect, chromium } from "@playwright/test" +import dayjs from "dayjs" +import { link } from "fs" + +async function sortHackerNewsArticles() { + // launch browser + const browser = await chromium.launch({ headless: false }) + const context = await browser.newContext() + const page = await context.newPage() + + console.log("Navigating to Hacker News newest page...") + await page.goto("https://news.ycombinator.com/newest") + console.log("Page loaded.") + + console.log("Extracting article data...") + // Locate all rows + const rows = await page.getByRole("row") + + // Use evaluateAll to get the IDs and ranks + const news = await rows.evaluateAll((rows) => { + // Step 1: Extract id and rank + const newsComputed = rows + .map((row) => { + const id = row.getAttribute("id") + + // Find the element with text matching the rank pattern + const isMaxThreeDigitsAndDot = (value) => { + return /^\d{1,3}\.$/.test(value) + } + const findedRank = () => { + const findedRankByClassRank = Array.from( + row.querySelectorAll(".rank") + ).find((el) => { + const text = el.textContent.trim() + return isMaxThreeDigitsAndDot(text) + }) + const findedRankByAllRow = Array.from(row.querySelectorAll("*")).find( + (el) => { + const text = el.textContent.trim() + return isMaxThreeDigitsAndDot(text) + } + ) + if (!!findedRankByClassRank) + return parseInt( + findedRankByClassRank.textContent.trim().replace(".", ""), + 10 + ) + if (!!findedRankByAllRow) + return parseInt( + findedRankByAllRow.textContent.trim().replace(".", ""), + 10 + ) + return null + } + + return { id: id ?? null, rank: findedRank() } + }) + .filter((item) => item.id !== null && item.rank !== null) + + // Step 2: Check for timestamps in subsequent rows + rows.forEach((row) => { + const hrefsInRowForCheck = Array.from( + row.querySelectorAll("a[href]") + ).map((a) => a.getAttribute("href")) + const idsInRowForCheck = Array.from(row.querySelectorAll("[id]")).map( + (el) => el.getAttribute("id") + ) + + newsComputed.forEach((newsItem) => { + if ( + hrefsInRowForCheck.some((href) => href.includes(newsItem.id)) || + idsInRowForCheck.some((id) => id.includes(newsItem.id)) + ) { + const isTime = (value) => { + return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(value) + } + + const findedTime = () => { + const findedTimeInTitle = Array.from( + row.querySelectorAll("*") + ).find((el) => isTime(el.getAttribute("tit2le"))) + + const findedTimeInAttributes = Array.from( + row.querySelectorAll("*") + ).find((el) => { + return Array.from(el.attributes).some((attr) => + isTime(attr.value) + ) + }) + + if (findedTimeInTitle) + return findedTimeInTitle.getAttribute("title") + if (findedTimeInAttributes) { + return Array.from(findedTimeInAttributes.attributes).find( + (attr) => isTime(attr.value) + ).value + } + return null + } + + if (!!findedTime()) newsItem.time = findedTime() + } + }) + }) + + return newsComputed + }) + + console.log(news) +} + +;(async () => { + await sortHackerNewsArticles() +})() diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..38c56a7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,97 @@ +{ + "name": "qa_wolf_take_home", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "qa_wolf_take_home", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "dayjs": "^1.11.12", + "playwright": "^1.39.0" + }, + "devDependencies": { + "@playwright/test": "^1.46.1", + "@types/node": "^20.8.9" + } + }, + "node_modules/@playwright/test": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.46.1.tgz", + "integrity": "sha512-Fq6SwLujA/DOIvNC2EL/SojJnkKf/rAwJ//APpJJHRyMi1PdKrY3Az+4XNQ51N4RTbItbIByQ0jgd1tayq1aeA==", + "dev": true, + "dependencies": { + "playwright": "1.46.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "20.8.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.9.tgz", + "integrity": "sha512-UzykFsT3FhHb1h7yD4CA4YhBHq545JC0YnEz41xkipN88eKQtL6rSgocL5tbAP6Ola9Izm/Aw4Ora8He4x0BHg==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/dayjs": { + "version": "1.11.12", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.12.tgz", + "integrity": "sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg==" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.46.1.tgz", + "integrity": "sha512-oPcr1yqoXLCkgKtD5eNUPLiN40rYEM39odNpIb6VE6S7/15gJmA1NzVv6zJYusV0e7tzvkU/utBFNa/Kpxmwng==", + "dependencies": { + "playwright-core": "1.46.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.46.1.tgz", + "integrity": "sha512-h9LqIQaAv+CYvWzsZ+h3RsrqCStkBHlgo6/TJlFst3cOTlLghBQlJwPOZKQJTKNaD3QIB7aAVQ+gfWbN3NXB7A==", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..56c6722 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "qa_wolf_take_home", + "version": "1.0.0", + "description": "", + "main": "index.js", + "type": "module", + "scripts": {}, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "dayjs": "^1.11.12", + "playwright": "^1.39.0" + }, + "devDependencies": { + "@playwright/test": "^1.46.1", + "@types/node": "^20.8.9" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..f02eded --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,130 @@ +// @ts-check +// const { defineConfig, devices } = require("@playwright/test") +import { defineConfig, devices } from "@playwright/test" + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * @see https://playwright.dev/docs/test-configuration + */ +module.exports = defineConfig({ + testDir: "./", + /* 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: { + headless: false, + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, + + + // Locate all rows +const rows = await page.getByRole('row'); + +// Use evaluateAll to process each row +const news = await rows.evaluateAll((rows) => { +// Step 1: Extract id and rank +const news = rows +.map((row) => { +const id = row.getAttribute('id'); +let rank = null; + +// Find the element with text matching the rank pattern +const rankElement = Array.from(row.querySelectorAll('*')).find((el) => { +const text = el.textContent.trim(); +return /^\d{1,3}\.$/.test(text); +}); + +if (rankElement) { +rank = parseInt(rankElement.textContent.trim().replace('.', ''), 10); +} + +return { id: id ?? null, rank: rank, row }; +}) +.filter((item) => item.id !== null); + +// Step 2: Check for timestamps in subsequent rows +rows.forEach((row) => { +const rowId = row.getAttribute('id'); +const hrefs = Array.from(row.querySelectorAll('a[href]')).map((a) => a.getAttribute('href')); + +news.forEach((newsItem) => { +if ( +(rowId && rowId.includes(newsItem.id)) || +hrefs.some((href) => href.includes(newsItem.id)) +) { +const timeElement = Array.from(row.querySelectorAll('*')).find((el) => { +const title = el.getAttribute('title'); +return title && dayjs(title).isValid(); +}); +if (timeElement) { +newsItem.time = timeElement.getAttribute('title'); +} +} +}); +}); + +return news; +}); +}) diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..c00bade --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,81 @@ +import { defineConfig, devices, PlaywrightTestConfig } from "@playwright/test" + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); +const config: PlaywrightTestConfig = { + testMatch: ["index.js"], +} +export default config +/** + * See https://playwright.dev/docs/test-configuration. + */ +// export default defineConfig({ +// testDir: "./", +// /* 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: { +// /* Base URL to use in actions like `await page.goto('/')`. */ +// // baseURL: 'http://127.0.0.1:3000', + +// /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ +// trace: "on-first-retry", +// }, + +// /* Configure projects for major browsers */ +// projects: [ +// { +// name: "chromium", +// use: { ...devices["Desktop Chrome"] }, +// }, + +// { +// name: "firefox", +// use: { ...devices["Desktop Firefox"] }, +// }, + +// { +// name: "webkit", +// use: { ...devices["Desktop Safari"] }, +// }, + +// /* Test against mobile viewports. */ +// // { +// // name: 'Mobile Chrome', +// // use: { ...devices['Pixel 5'] }, +// // }, +// // { +// // name: 'Mobile Safari', +// // use: { ...devices['iPhone 12'] }, +// // }, + +// /* Test against branded browsers. */ +// // { +// // name: 'Microsoft Edge', +// // use: { ...devices['Desktop Edge'], channel: 'msedge' }, +// // }, +// // { +// // name: 'Google Chrome', +// // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, +// // }, +// ], + +// /* Run your local dev server before starting the tests */ +// // webServer: { +// // command: 'npm run start', +// // url: 'http://127.0.0.1:3000', +// // reuseExistingServer: !process.env.CI, +// // }, +// }) diff --git a/tests-examples/demo-todo-app.spec.ts b/tests-examples/demo-todo-app.spec.ts new file mode 100644 index 0000000..8641cb5 --- /dev/null +++ b/tests-examples/demo-todo-app.spec.ts @@ -0,0 +1,437 @@ +import { test, expect, type Page } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.goto('https://demo.playwright.dev/todomvc'); +}); + +const TODO_ITEMS = [ + 'buy some cheese', + 'feed the cat', + 'book a doctors appointment' +] as const; + +test.describe('New Todo', () => { + test('should allow me to add todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create 1st todo. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Make sure the list only has one todo item. + await expect(page.getByTestId('todo-title')).toHaveText([ + TODO_ITEMS[0] + ]); + + // Create 2nd todo. + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + + // Make sure the list now has two todo items. + await expect(page.getByTestId('todo-title')).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[1] + ]); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); + + test('should clear text input field when an item is added', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create one todo item. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Check that input is empty. + await expect(newTodo).toBeEmpty(); + await checkNumberOfTodosInLocalStorage(page, 1); + }); + + test('should append new items to the bottom of the list', async ({ page }) => { + // Create 3 items. + await createDefaultTodos(page); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count') + + // Check test using different methods. + await expect(page.getByText('3 items left')).toBeVisible(); + await expect(todoCount).toHaveText('3 items left'); + await expect(todoCount).toContainText('3'); + await expect(todoCount).toHaveText(/3/); + + // Check all items in one call. + await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS); + await checkNumberOfTodosInLocalStorage(page, 3); + }); +}); + +test.describe('Mark all as completed', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test.afterEach(async ({ page }) => { + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should allow me to mark all items as completed', async ({ page }) => { + // Complete all todos. + await page.getByLabel('Mark all as complete').check(); + + // Ensure all todos have 'completed' class. + await expect(page.getByTestId('todo-item')).toHaveClass(['completed', 'completed', 'completed']); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + }); + + test('should allow me to clear the complete state of all items', async ({ page }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + // Check and then immediately uncheck. + await toggleAll.check(); + await toggleAll.uncheck(); + + // Should be no completed classes. + await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']); + }); + + test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + await toggleAll.check(); + await expect(toggleAll).toBeChecked(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Uncheck first todo. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').uncheck(); + + // Reuse toggleAll locator and make sure its not checked. + await expect(toggleAll).not.toBeChecked(); + + await firstTodo.getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Assert the toggle all is checked again. + await expect(toggleAll).toBeChecked(); + }); +}); + +test.describe('Item', () => { + + test('should allow me to mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + // Check first item. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').check(); + await expect(firstTodo).toHaveClass('completed'); + + // Check second item. + const secondTodo = page.getByTestId('todo-item').nth(1); + await expect(secondTodo).not.toHaveClass('completed'); + await secondTodo.getByRole('checkbox').check(); + + // Assert completed class. + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).toHaveClass('completed'); + }); + + test('should allow me to un-mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const firstTodo = page.getByTestId('todo-item').nth(0); + const secondTodo = page.getByTestId('todo-item').nth(1); + const firstTodoCheckbox = firstTodo.getByRole('checkbox'); + + await firstTodoCheckbox.check(); + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await firstTodoCheckbox.uncheck(); + await expect(firstTodo).not.toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 0); + }); + + test('should allow me to edit an item', async ({ page }) => { + await createDefaultTodos(page); + + const todoItems = page.getByTestId('todo-item'); + const secondTodo = todoItems.nth(1); + await secondTodo.dblclick(); + await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue(TODO_ITEMS[1]); + await secondTodo.getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter'); + + // Explicitly assert the new text value. + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2] + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); +}); + +test.describe('Editing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should hide other controls when editing', async ({ page }) => { + const todoItem = page.getByTestId('todo-item').nth(1); + await todoItem.dblclick(); + await expect(todoItem.getByRole('checkbox')).not.toBeVisible(); + await expect(todoItem.locator('label', { + hasText: TODO_ITEMS[1], + })).not.toBeVisible(); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should save edits on blur', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).dispatchEvent('blur'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should trim entered text', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(' buy some sausages '); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should remove the item if an empty text string was entered', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(''); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[2], + ]); + }); + + test('should cancel edits on escape', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Escape'); + await expect(todoItems).toHaveText(TODO_ITEMS); + }); +}); + +test.describe('Counter', () => { + test('should display the current number of todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count') + + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + await expect(todoCount).toContainText('1'); + + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + await expect(todoCount).toContainText('2'); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); +}); + +test.describe('Clear completed button', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + }); + + test('should display the correct text', async ({ page }) => { + await page.locator('.todo-list li .toggle').first().check(); + await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible(); + }); + + test('should remove completed items when clicked', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).getByRole('checkbox').check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect(todoItems).toHaveCount(2); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should be hidden when there are no items that are completed', async ({ page }) => { + await page.locator('.todo-list li .toggle').first().check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect(page.getByRole('button', { name: 'Clear completed' })).toBeHidden(); + }); +}); + +test.describe('Persistence', () => { + test('should persist its data', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const todoItems = page.getByTestId('todo-item'); + const firstTodoCheck = todoItems.nth(0).getByRole('checkbox'); + await firstTodoCheck.check(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + + // Ensure there is 1 completed item. + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + // Now reload. + await page.reload(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + }); +}); + +test.describe('Routing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + // make sure the app had a chance to save updated todos in storage + // before navigating to a new view, otherwise the items can get lost :( + // in some frameworks like Durandal + await checkTodosInLocalStorage(page, TODO_ITEMS[0]); + }); + + test('should allow me to display active items', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await expect(todoItem).toHaveCount(2); + await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should respect the back button', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await test.step('Showing all items', async () => { + await page.getByRole('link', { name: 'All' }).click(); + await expect(todoItem).toHaveCount(3); + }); + + await test.step('Showing active items', async () => { + await page.getByRole('link', { name: 'Active' }).click(); + }); + + await test.step('Showing completed items', async () => { + await page.getByRole('link', { name: 'Completed' }).click(); + }); + + await expect(todoItem).toHaveCount(1); + await page.goBack(); + await expect(todoItem).toHaveCount(2); + await page.goBack(); + await expect(todoItem).toHaveCount(3); + }); + + test('should allow me to display completed items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Completed' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(1); + }); + + test('should allow me to display all items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await page.getByRole('link', { name: 'Completed' }).click(); + await page.getByRole('link', { name: 'All' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(3); + }); + + test('should highlight the currently applied filter', async ({ page }) => { + await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected'); + + //create locators for active and completed links + const activeLink = page.getByRole('link', { name: 'Active' }); + const completedLink = page.getByRole('link', { name: 'Completed' }); + await activeLink.click(); + + // Page change - active items. + await expect(activeLink).toHaveClass('selected'); + await completedLink.click(); + + // Page change - completed items. + await expect(completedLink).toHaveClass('selected'); + }); +}); + +async function createDefaultTodos(page: Page) { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } +} + +async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) { + return await page.waitForFunction(e => { + return JSON.parse(localStorage['react-todos']).length === e; + }, expected); +} + +async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) { + return await page.waitForFunction(e => { + return JSON.parse(localStorage['react-todos']).filter((todo: any) => todo.completed).length === e; + }, expected); +} + +async function checkTodosInLocalStorage(page: Page, title: string) { + return await page.waitForFunction(t => { + return JSON.parse(localStorage['react-todos']).map((todo: any) => todo.title).includes(t); + }, title); +} diff --git a/tests/example.spec.ts b/tests/example.spec.ts new file mode 100644 index 0000000..b1a4dc3 --- /dev/null +++ b/tests/example.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from "@playwright/test" + +test.beforeEach(async ({ page }) => { + await page.goto("https://news.ycombinator.com/newest") + expect(page).toBeDefined +}) + +test.describe("extract page", () => { + test("1=1", () => { + expect(1).toBe(1) + }) +}) +test("has title", async ({ page }) => { + await page.goto("https://playwright.dev/") + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Playwright/) +}) + +test("get started link", async ({ page }) => { + await page.goto("https://playwright.dev/") + + // Click the get started link. + await page.getByRole("link", { name: "Get started" }).click() + + // Expects page to have a heading with the name of Installation. + await expect( + page.getByRole("heading", { name: "Installation" }) + ).toBeVisible() +}) + +// await page.getByRole("link", { name: "More" }).click() + +// // Find time in the next row +// const nextRow = row.nextElementSibling +// if (nextRow) { +// const timeElement = nextRow.querySelector(".age a") +// if (timeElement) { +// const timeText = timeElement.innerText.trim() +// if (dayjs(timeText, "YYYY-MM-DDTHH:mm:ss", true).isValid()) { +// time = timeText +// } +// } +// } + +// console.log("Articles extracted:") +// articles.forEach((article, index) => { +// console.log( +// `${index + 1}. ID: ${article.id}, existedUpId: ${ +// article.existedUpId +// }, existedHrefId: ${article.existedHrefId}, Time: ${article.time}` +// ) +// }) +// diff --git a/why_qa_wolf.txt b/why_qa_wolf.txt new file mode 100644 index 0000000..e69de29