test: migrate to playwright tests
This commit is contained in:
parent
82dd7a4ae3
commit
7db05d7f91
|
|
@ -10,14 +10,14 @@
|
|||
"serve": "vite preview --port 4173",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"lint": "eslint --fix .",
|
||||
"test": "npm run test:unit && npm run test:e2e:ci",
|
||||
"test": "npm run test:unit && npm run test:playwright",
|
||||
"test:cypress:local": "cypress open --e2e -c baseUrl=http://localhost:5173",
|
||||
"test:cyprsss:prod": "cypress run --e2e -c baseUrl=https://vue3-realworld-example-app-mutoe.vercel.app",
|
||||
"test:e2e": "npm run build && concurrently -rk -s first \"npm run serve\" \"cypress open --e2e -c baseUrl=http://localhost:4173\"",
|
||||
"test:e2e:ci": "npm run build && concurrently -rk -s first \"npm run serve\" \"cypress run --e2e -c baseUrl=http://localhost:4173\"",
|
||||
"test:playwright:ci": "playwright test",
|
||||
"test:playwright:local": "playwright test --ui",
|
||||
"test:playwright:local:debug": "playwright test --ui --debug",
|
||||
"test:playwright:local:debug": "playwright test --ui --headed --debug",
|
||||
"test:unit": "vitest run",
|
||||
"generate:api": "curl -sL https://raw.githubusercontent.com/gothinkster/realworld/main/api/openapi.yml -o ./src/services/openapi.yml && sta -p ./src/services/openapi.yml -o ./src/services -n api.ts"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -9,28 +9,37 @@ import { defineConfig, devices } from '@playwright/test'
|
|||
|
||||
const baseURL = 'http://localhost:5173'
|
||||
|
||||
const isCI = process.env.CI
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './playwright',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
forbidOnly: !!isCI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
retries: isCI ? 1 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
workers: isCI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
reporter: [
|
||||
['line'],
|
||||
['html', { open: 'never' }],
|
||||
],
|
||||
/* 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,
|
||||
|
||||
navigationTimeout: 4000,
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
trace: isCI ? 'on-first-retry' : 'retain-on-failure',
|
||||
video: isCI ? 'on-first-retry' : 'retain-on-failure',
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
|
|
@ -40,12 +49,12 @@ export default defineConfig({
|
|||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
|
||||
{
|
||||
isCI && {
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
|
||||
{
|
||||
isCI && {
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
|
|
@ -69,13 +78,13 @@ export default defineConfig({
|
|||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
],
|
||||
].filter(Boolean),
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
reuseExistingServer: !isCI,
|
||||
ignoreHTTPSErrors: true,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
import type { Article } from 'src/services/api.ts'
|
||||
import { Route } from '../constant.ts'
|
||||
import { expect, test } from '../extends'
|
||||
import { formatHTML } from '../utils/formatHTML.ts'
|
||||
|
||||
test.describe('article', () => {
|
||||
test.beforeEach(async ({ conduct }) => {
|
||||
await conduct.intercept('GET', /articles\?limit/, { fixture: 'articles.json' })
|
||||
await conduct.intercept('GET', /tags/, { fixture: 'articles-of-tag.json' })
|
||||
await conduct.intercept('GET', /profiles\/.+/, { fixture: 'profile.json' })
|
||||
|
||||
await conduct.login()
|
||||
})
|
||||
|
||||
test.describe('post article', () => {
|
||||
test('jump to post detail page when submit create article form', async ({ page, conduct }) => {
|
||||
await conduct.goto(Route.ArticleCreate)
|
||||
|
||||
const articleFixture = await conduct.getFixture<{ article: Article }>('article.json')
|
||||
const waitForPostArticle = await conduct.intercept('POST', /articles$/, { body: articleFixture })
|
||||
|
||||
await page.getByPlaceholder('Article Title').fill(articleFixture.article.title)
|
||||
await page.getByPlaceholder("What's this article about?").fill(articleFixture.article.description)
|
||||
await page.getByPlaceholder('Write your article (in markdown)').fill(articleFixture.article.body)
|
||||
for (const tag of articleFixture.article.tagList) {
|
||||
await page.getByPlaceholder('Enter tags').fill(tag)
|
||||
await page.getByPlaceholder('Enter tags').press('Enter')
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Publish Article' }).dispatchEvent('click')
|
||||
await waitForPostArticle()
|
||||
|
||||
await conduct.intercept('GET', /articles\/.+/, { fixture: 'article.json' })
|
||||
await page.waitForURL(/article\/article-title/)
|
||||
await expect (page.getByRole('heading', { name: 'Article title' })).toContainText('Article title')
|
||||
})
|
||||
|
||||
test('should render markdown correctly', async ({ browserName, page, conduct }) => {
|
||||
test.skip(browserName !== 'chromium')
|
||||
await conduct.goto(Route.ArticleDetail)
|
||||
|
||||
const waitForArticle = await conduct.intercept('GET', /articles\/.+/, { fixture: 'article.json' })
|
||||
await waitForArticle()
|
||||
const innerHTML = await page.locator('.article-content').innerHTML()
|
||||
expect(formatHTML(innerHTML)).toMatchSnapshot('markdown-render.html')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('delete article', () => {
|
||||
for (const [index, position] of ['banner', 'article footer'].entries()) {
|
||||
test(`delete article from ${position}`, async ({ page, conduct }) => {
|
||||
const waitForArticle = await conduct.intercept('GET', /articles\/.+/, { fixture: 'article.json' })
|
||||
await conduct.goto(Route.ArticleDetail)
|
||||
await waitForArticle()
|
||||
|
||||
await conduct.intercept('DELETE', /articles\/.+/)
|
||||
await page.getByRole('button', { name: 'Delete Article' }).nth(index).click()
|
||||
|
||||
await expect(page).toHaveURL(Route.Home)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { test as base } from '@playwright/test'
|
||||
import { ConductPageObject } from './conduct.page-object.ts'
|
||||
import { ConduitPageObject } from 'page-objects/conduit.page-object'
|
||||
|
||||
export const test = base.extend<{
|
||||
conduct: ConductPageObject
|
||||
conduit: ConduitPageObject
|
||||
}>({
|
||||
conduct: async ({ page }, use) => {
|
||||
const buyscoutPageObject = new ConductPageObject(page)
|
||||
conduit: async ({ page }, use) => {
|
||||
const buyscoutPageObject = new ConduitPageObject(page)
|
||||
await use(buyscoutPageObject)
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import type { Page } from '@playwright/test'
|
||||
import { ConduitPageObject } from './conduit.page-object'
|
||||
|
||||
export class ArticleDetailPageObject extends ConduitPageObject {
|
||||
constructor(public page: Page) {
|
||||
super(page)
|
||||
}
|
||||
|
||||
positionMap = {
|
||||
'banner': 0,
|
||||
'article footer': 1,
|
||||
} as const
|
||||
|
||||
private async clickOperationButton(position: keyof typeof this.positionMap = 'banner', buttonName: string) {
|
||||
await this.page.getByRole('button', { name: buttonName }).nth(this.positionMap[position]).click()
|
||||
}
|
||||
|
||||
async clickEditArticle(position: keyof typeof this.positionMap = 'banner') {
|
||||
return this.clickOperationButton(position, 'Edit Article')
|
||||
}
|
||||
|
||||
async clickDeleteArticle(position: keyof typeof this.positionMap = 'banner') {
|
||||
await this.page.getByRole('button', { name: 'Delete article' }).nth(this.positionMap[position]).dispatchEvent('click')
|
||||
}
|
||||
|
||||
async clickFollowUser(position: keyof typeof this.positionMap = 'banner') {
|
||||
await this.page.getByRole('button', { name: 'Follow' }).nth(this.positionMap[position]).dispatchEvent('click')
|
||||
}
|
||||
|
||||
async clickFavoriteArticle(position: keyof typeof this.positionMap = 'banner') {
|
||||
await this.page.getByRole('button', { name: 'Favorite article' }).nth(this.positionMap[position]).dispatchEvent('click')
|
||||
}
|
||||
}
|
||||
|
|
@ -2,28 +2,37 @@ import fs from 'node:fs/promises'
|
|||
import path from 'node:path'
|
||||
import url from 'node:url'
|
||||
import type { Page, Response } from '@playwright/test'
|
||||
import type { User } from '../src/services/api.ts'
|
||||
import { Route } from './constant'
|
||||
import { expect } from './extends.ts'
|
||||
import { boxedStep } from './utils/test-decorators.ts'
|
||||
import type { User } from 'src/services/api.ts'
|
||||
import { Route } from '../constant'
|
||||
import { expect } from '../extends.ts'
|
||||
import { boxedStep } from '../utils/test-decorators.ts'
|
||||
|
||||
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
|
||||
const fixtureDir = path.join(__dirname, '../cypress/fixtures')
|
||||
const fixtureDir = path.join(__dirname, '../../cypress/fixtures')
|
||||
|
||||
export class ConductPageObject {
|
||||
export class ConduitPageObject {
|
||||
constructor(
|
||||
public readonly page: Page,
|
||||
) {}
|
||||
|
||||
async intercept(method: 'POST' | 'GET' | 'PATCH' | 'DELETE' | 'PUT', url: string | RegExp, options: {
|
||||
fixture?: string
|
||||
postFixture?: (fixture: any) => void | unknown
|
||||
statusCode?: number
|
||||
body?: unknown
|
||||
} = {}): Promise<(timeout?: number) => Promise<Response>> {
|
||||
timeout?: number
|
||||
} = {}): Promise<() => Promise<Response>> {
|
||||
await this.page.route(url, async route => {
|
||||
if (route.request().method() !== method)
|
||||
return route.continue()
|
||||
|
||||
if (options.postFixture && options.fixture) {
|
||||
const body = await this.getFixture(options.fixture)
|
||||
const returnValue = await options.postFixture(body)
|
||||
options.body = returnValue === undefined ? body : returnValue
|
||||
options.fixture = undefined
|
||||
}
|
||||
|
||||
return await route.fulfill({
|
||||
status: options.statusCode || undefined,
|
||||
json: options.body ?? undefined,
|
||||
|
|
@ -31,7 +40,7 @@ export class ConductPageObject {
|
|||
})
|
||||
})
|
||||
|
||||
return (timeout: number = 1000) => this.page.waitForResponse(response => {
|
||||
return () => this.page.waitForResponse(response => {
|
||||
const request = response.request()
|
||||
if (request.method() !== method)
|
||||
return false
|
||||
|
|
@ -40,7 +49,7 @@ export class ConductPageObject {
|
|||
return request.url().includes(url)
|
||||
|
||||
return url.test(request.url())
|
||||
}, { timeout })
|
||||
}, { timeout: options.timeout ?? 4000 })
|
||||
}
|
||||
|
||||
async getFixture<T = unknown>(fixture: string): Promise<T> {
|
||||
|
|
@ -49,21 +58,29 @@ export class ConductPageObject {
|
|||
}
|
||||
|
||||
async goto(route: Route) {
|
||||
await this.page.goto(route)
|
||||
await this.page.goto(route, { waitUntil: 'domcontentloaded' })
|
||||
}
|
||||
|
||||
@boxedStep
|
||||
async login(username = 'plumrx') {
|
||||
const userFixture = await this.getFixture<{ user: User }>('user.json')
|
||||
userFixture.user.username = username
|
||||
await this.intercept('POST', /users\/login$/, { statusCode: 200, body: userFixture })
|
||||
|
||||
await this.goto(Route.Login)
|
||||
|
||||
await this.page.getByPlaceholder('Email').fill('foo@example.com')
|
||||
await this.page.getByPlaceholder('Password').fill('12345678')
|
||||
await this.page.getByRole('button', { name: 'Sign in' }).click()
|
||||
|
||||
const waitForLogin = await this.intercept('POST', /users\/login$/, { statusCode: 200, body: userFixture })
|
||||
await Promise.all([
|
||||
waitForLogin(),
|
||||
this.page.getByRole('button', { name: 'Sign in' }).click(),
|
||||
])
|
||||
|
||||
await expect(this.page).toHaveURL(Route.Home)
|
||||
}
|
||||
|
||||
async toContainText(text: string) {
|
||||
await expect(this.page.locator('body')).toContainText(text)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import type { Page } from '@playwright/test'
|
||||
import { ConduitPageObject } from './conduit.page-object.ts'
|
||||
|
||||
export class EditArticlePageObject extends ConduitPageObject {
|
||||
constructor(public page: Page) {
|
||||
super(page)
|
||||
}
|
||||
|
||||
async fillTitle(title: string) {
|
||||
await this.page.getByPlaceholder('Article Title').fill(title)
|
||||
}
|
||||
|
||||
async fillDescription(description: string) {
|
||||
await this.page.getByPlaceholder("What's this article about?").fill(description)
|
||||
}
|
||||
|
||||
async fillContent(content: string) {
|
||||
await this.page.getByPlaceholder('Write your article (in markdown)').fill(content)
|
||||
}
|
||||
|
||||
async fillTags(tags: string | string[]) {
|
||||
if (!Array.isArray(tags))
|
||||
tags = [tags]
|
||||
for (const tag of tags) {
|
||||
await this.page.getByPlaceholder('Enter tags').fill(tag)
|
||||
await this.page.getByPlaceholder('Enter tags').press('Enter')
|
||||
}
|
||||
}
|
||||
|
||||
async fillForm({ title, description, content, tags }: { title?: string, description?: string, content?: string, tags?: string | string[] }) {
|
||||
if (title !== undefined)
|
||||
await this.fillTitle(title)
|
||||
if (description !== undefined)
|
||||
await this.fillDescription(description)
|
||||
if (content !== undefined)
|
||||
await this.fillContent(content)
|
||||
if (tags !== undefined)
|
||||
await this.fillTags(tags)
|
||||
}
|
||||
|
||||
async clickPublishArticle() {
|
||||
await this.page.getByRole('button', { name: 'Publish Article' }).dispatchEvent('click')
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import type { Page } from '@playwright/test'
|
||||
import { ConduitPageObject } from './conduit.page-object.ts'
|
||||
|
||||
export class LoginPageObject extends ConduitPageObject {
|
||||
constructor(public page: Page) {
|
||||
super(page)
|
||||
}
|
||||
|
||||
async fillEmail(email: string = 'foo@example.com') {
|
||||
await this.page.getByPlaceholder('Email').fill(email)
|
||||
}
|
||||
|
||||
async fillPassword(password = '12345678') {
|
||||
await this.page.getByPlaceholder('Password').fill(password)
|
||||
}
|
||||
|
||||
async fillForm(form: { email?: string, password?: string }) {
|
||||
if (form.email !== undefined)
|
||||
await this.fillEmail(form.email)
|
||||
if (form.password !== undefined)
|
||||
await this.fillPassword(form.password)
|
||||
}
|
||||
|
||||
async clickSignIn() {
|
||||
await this.page.getByRole('button', { name: 'Sign in' }).dispatchEvent('click')
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import type { Page } from '@playwright/test'
|
||||
import { ConduitPageObject } from './conduit.page-object.ts'
|
||||
|
||||
export class RegisterPageObject extends ConduitPageObject {
|
||||
constructor(public page: Page) {
|
||||
super(page)
|
||||
}
|
||||
|
||||
async fillName(name: string = 'foo') {
|
||||
await this.page.getByPlaceholder('Your Name').fill(name)
|
||||
}
|
||||
|
||||
async fillEmail(email: string = 'foo@example.com') {
|
||||
await this.page.getByPlaceholder('Email').fill(email)
|
||||
}
|
||||
|
||||
async fillPassword(password = '12345678') {
|
||||
await this.page.getByPlaceholder('Password').fill(password)
|
||||
}
|
||||
|
||||
async fillForm(form: { name?: string, email?: string, password?: string }) {
|
||||
if (form.name !== undefined)
|
||||
await this.fillName(form.name)
|
||||
if (form.email !== undefined)
|
||||
await this.fillEmail(form.email)
|
||||
if (form.password !== undefined)
|
||||
await this.fillPassword(form.password)
|
||||
}
|
||||
|
||||
async clickSignUp() {
|
||||
await this.page.getByRole('button', { name: 'Sign up' }).dispatchEvent('click')
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { ArticleDetailPageObject } from 'page-objects/article-detail.page-object.ts'
|
||||
import { EditArticlePageObject } from 'page-objects/edit-article.page-object.ts'
|
||||
import type { Article } from 'src/services/api.ts'
|
||||
import { Route } from '../constant.ts'
|
||||
import { expect, test } from '../extends'
|
||||
import { formatHTML, formatJSON } from '../utils/prettify.ts'
|
||||
|
||||
test.beforeEach(async ({ conduit }) => {
|
||||
await conduit.intercept('GET', /articles\?limit/, { fixture: 'articles.json' })
|
||||
await conduit.intercept('GET', /tags/, { fixture: 'tags.json' })
|
||||
await conduit.intercept('GET', /profiles\/.+/, { fixture: 'profile.json' })
|
||||
|
||||
await conduit.login()
|
||||
})
|
||||
|
||||
test.describe('post article', () => {
|
||||
let editArticlePage!: EditArticlePageObject
|
||||
|
||||
test.beforeEach(({ page }) => {
|
||||
editArticlePage = new EditArticlePageObject(page)
|
||||
})
|
||||
|
||||
test('jump to post detail page when submit create article form', async ({ page, conduit }) => {
|
||||
await conduit.goto(Route.ArticleCreate)
|
||||
|
||||
const articleFixture = await conduit.getFixture<{ article: Article }>('article.json')
|
||||
const waitForPostArticle = await editArticlePage.intercept('POST', /articles$/, { body: articleFixture })
|
||||
|
||||
await editArticlePage.fillForm({
|
||||
title: articleFixture.article.title,
|
||||
description: articleFixture.article.description,
|
||||
content: articleFixture.article.body,
|
||||
tags: articleFixture.article.tagList,
|
||||
})
|
||||
|
||||
await editArticlePage.clickPublishArticle()
|
||||
await waitForPostArticle()
|
||||
|
||||
await conduit.intercept('GET', /articles\/.+/, { fixture: 'article.json' })
|
||||
await page.waitForURL(/article\/article-title/)
|
||||
await conduit.toContainText('Article title')
|
||||
})
|
||||
|
||||
test('should render markdown correctly', async ({ browserName, page, conduit }) => {
|
||||
test.skip(browserName !== 'chromium')
|
||||
const waitForArticleRequest = await conduit.intercept('GET', /articles\/.+/, { fixture: 'article.json' })
|
||||
await Promise.all([
|
||||
waitForArticleRequest(),
|
||||
conduit.goto(Route.ArticleDetail),
|
||||
])
|
||||
const innerHTML = await page.locator('.article-content').innerHTML()
|
||||
expect(formatHTML(innerHTML)).toMatchSnapshot('markdown-render.html')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('delete article', () => {
|
||||
for (const position of ['banner', 'article footer'] as const) {
|
||||
test(`delete article from ${position}`, async ({ page, conduit }) => {
|
||||
const articlePage = new ArticleDetailPageObject(page)
|
||||
const waitForArticle = await articlePage.intercept('GET', /articles\/.+/, { fixture: 'article.json' })
|
||||
await conduit.goto(Route.ArticleDetail)
|
||||
await waitForArticle()
|
||||
|
||||
const waitForDeleteArticle = await conduit.intercept('DELETE', /articles\/.+/)
|
||||
|
||||
const [response] = await Promise.all([
|
||||
waitForDeleteArticle(),
|
||||
articlePage.clickDeleteArticle(position),
|
||||
])
|
||||
|
||||
expect(response).toBeInstanceOf(Object)
|
||||
await expect(page).toHaveURL(Route.Home)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('favorite article', () => {
|
||||
test.beforeEach(async ({ conduit }) => {
|
||||
await conduit.intercept('GET', /tags/, { fixture: 'tags.json' })
|
||||
})
|
||||
|
||||
test('should jump to login page when click favorite article button given user not logged', async ({ page, conduit }) => {
|
||||
await conduit.goto(Route.Home)
|
||||
|
||||
const waitForFavoriteArticle = await conduit.intercept('POST', /articles\/\S+\/favorite$/, { statusCode: 401 })
|
||||
await Promise.all([
|
||||
waitForFavoriteArticle(),
|
||||
page.getByRole('button', { name: 'Favorite article' }).first().click(),
|
||||
])
|
||||
|
||||
await expect(page).toHaveURL(Route.Login)
|
||||
})
|
||||
|
||||
test('should call favorite api and highlight favorite button when click favorite button', async ({ page, conduit }) => {
|
||||
await conduit.login()
|
||||
await conduit.goto(Route.Home)
|
||||
|
||||
// like articles
|
||||
const waitForFavoriteArticle = await conduit.intercept('POST', /articles\/\S+\/favorite$/, { fixture: 'article.json' })
|
||||
await Promise.all([
|
||||
waitForFavoriteArticle(),
|
||||
page.getByRole('button', { name: 'Favorite article' }).first().click(),
|
||||
])
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Favorite article' }).first()).toHaveClass('btn-primary')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('tag', () => {
|
||||
test.beforeEach(async ({ conduit }) => {
|
||||
await conduit.intercept('GET', /articles\?tag=butt/, { fixture: 'articles-of-tag.json' })
|
||||
})
|
||||
|
||||
test('should display popular tags in home page', async ({ page, conduit }) => {
|
||||
await conduit.goto(Route.Home)
|
||||
|
||||
const tagItems = await page.getByText('Popular Tags')
|
||||
.locator('..')
|
||||
.locator('.tag-pill')
|
||||
.all()
|
||||
.then(items => Promise.all(items.map(item => item.textContent())))
|
||||
expect(tagItems).toHaveLength(8)
|
||||
expect(formatJSON(tagItems)).toMatchSnapshot('popular-tags-in-home-page.json')
|
||||
})
|
||||
|
||||
test('should show right articles of tag', async ({ page, conduit }) => {
|
||||
const tagName = 'butt'
|
||||
await conduit.goto(Route.Home)
|
||||
|
||||
await conduit.intercept('GET', /articles\?tag/, { fixture: 'articles-of-tag.json' })
|
||||
await page.getByLabel(tagName).click()
|
||||
|
||||
await expect(page).toHaveURL(`/#/tag/${tagName}`)
|
||||
await expect(page.locator('a.tag-pill.tag-default').last())
|
||||
.toHaveClass(/(router-link-active|router-link-exact-active)/)
|
||||
|
||||
await expect(page.getByLabel('tag')).toContainText('butt')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
[
|
||||
"HuManIty",
|
||||
"Gandhi",
|
||||
"HITLER",
|
||||
"SIDA",
|
||||
"BlackLivesMatter",
|
||||
"test",
|
||||
"dragons",
|
||||
"butt"
|
||||
]
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
[
|
||||
"HuManIty",
|
||||
"Gandhi",
|
||||
"HITLER",
|
||||
"SIDA",
|
||||
"BlackLivesMatter",
|
||||
"test",
|
||||
"dragons",
|
||||
"butt"
|
||||
]
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
[
|
||||
"HuManIty",
|
||||
"Gandhi",
|
||||
"HITLER",
|
||||
"SIDA",
|
||||
"BlackLivesMatter",
|
||||
"test",
|
||||
"dragons",
|
||||
"butt"
|
||||
]
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import { LoginPageObject } from 'page-objects/login.page-object.ts'
|
||||
import { RegisterPageObject } from 'page-objects/register.page-object.ts'
|
||||
import { Route } from '../constant.ts'
|
||||
import { expect, test } from '../extends'
|
||||
|
||||
test.beforeEach(async ({ conduit }) => {
|
||||
await conduit.intercept('GET', /users/, { fixture: 'user.json' })
|
||||
await conduit.intercept('GET', /tags/, { fixture: 'tags.json' })
|
||||
await conduit.intercept('GET', /articles/, { fixture: 'articles.json' })
|
||||
})
|
||||
|
||||
test.describe('login and logout', () => {
|
||||
let loginPage!: LoginPageObject
|
||||
|
||||
test.beforeEach(({ page }) => {
|
||||
loginPage = new LoginPageObject(page)
|
||||
})
|
||||
|
||||
test('should login success when submit a valid login form', async ({ page, conduit }) => {
|
||||
await conduit.login()
|
||||
|
||||
await expect(page).toHaveURL(Route.Home)
|
||||
})
|
||||
|
||||
test('should logout when click logout button', async ({ page, conduit }) => {
|
||||
await conduit.login()
|
||||
await conduit.goto(Route.Settings)
|
||||
|
||||
await page.getByRole('button', { name: 'logout' }).click()
|
||||
await conduit.toContainText('Sign in')
|
||||
})
|
||||
|
||||
test('should display error when submit an invalid form (password not match)', async ({ conduit }) => {
|
||||
await conduit.goto(Route.Login)
|
||||
|
||||
await loginPage.intercept('POST', /users\/login/, {
|
||||
statusCode: 403,
|
||||
body: { errors: { 'email or password': ['is invalid'] } },
|
||||
})
|
||||
await loginPage.fillForm({ email: 'foo@example.com', password: '12345678' })
|
||||
await loginPage.clickSignIn()
|
||||
|
||||
await loginPage.toContainText('email or password is invalid')
|
||||
})
|
||||
|
||||
test('should display format error without API call when submit an invalid format', async ({ page, conduit }) => {
|
||||
await conduit.goto(Route.Login)
|
||||
|
||||
await loginPage.intercept('POST', /users\/login/)
|
||||
await loginPage.fillForm({ email: 'foo', password: '123456' })
|
||||
await loginPage.clickSignIn()
|
||||
|
||||
expect(await page.$eval('form', form => form.checkValidity())).toBe(false)
|
||||
})
|
||||
|
||||
test('should not allow visiting login page when the user is logged in', async ({ page, conduit }) => {
|
||||
await conduit.login()
|
||||
await conduit.goto(Route.Login)
|
||||
|
||||
await expect(page).toHaveURL(Route.Home)
|
||||
})
|
||||
|
||||
test('should has credential header after login success', async ({ page, conduit }) => {
|
||||
await conduit.login()
|
||||
await conduit.goto(Route.Settings)
|
||||
|
||||
const waitForUpdateSettingsRequest = await conduit.intercept('PUT', /user/)
|
||||
|
||||
await page.getByRole('textbox', { name: 'Username' }).fill('foo')
|
||||
await page.getByRole('button', { name: 'Update Settings' }).dispatchEvent('click')
|
||||
|
||||
const response = await waitForUpdateSettingsRequest()
|
||||
expect(response.request().headers()).toHaveProperty('authorization')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('register', () => {
|
||||
let registerPage!: RegisterPageObject
|
||||
|
||||
test.beforeEach(({ page }) => {
|
||||
registerPage = new RegisterPageObject(page)
|
||||
})
|
||||
|
||||
test('should call register API and jump to home page when submit a valid form', async ({ conduit }) => {
|
||||
await conduit.goto(Route.Register)
|
||||
|
||||
const waitForRegisterRequest = await registerPage.intercept('POST', /users$/, { fixture: 'user.json' })
|
||||
await registerPage.fillForm({
|
||||
name: 'foo',
|
||||
email: 'foo@example.com',
|
||||
password: '12345678',
|
||||
})
|
||||
await registerPage.clickSignUp()
|
||||
|
||||
await waitForRegisterRequest()
|
||||
await expect(conduit.page).toHaveURL(Route.Home)
|
||||
})
|
||||
|
||||
test('should display error message when submit the form that username already exist', async ({ conduit }) => {
|
||||
await conduit.goto(Route.Register)
|
||||
|
||||
const waitForRegisterRequest = await registerPage.intercept('POST', /users$/, {
|
||||
statusCode: 422,
|
||||
body: { errors: { email: ['has already been taken'], username: ['has already been taken'] } },
|
||||
})
|
||||
await registerPage.fillForm({
|
||||
name: 'foo',
|
||||
email: 'foo@example.com',
|
||||
password: '12345678',
|
||||
})
|
||||
await registerPage.clickSignUp()
|
||||
|
||||
await waitForRegisterRequest()
|
||||
await registerPage.toContainText('email has already been taken')
|
||||
await registerPage.toContainText('username has already been taken')
|
||||
})
|
||||
|
||||
test('should not allow visiting register page when the user is logged in', async ({ page, conduit }) => {
|
||||
await conduit.login()
|
||||
await conduit.goto(Route.Register)
|
||||
|
||||
await expect(page).toHaveURL(Route.Home)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { Route } from '../constant.ts'
|
||||
import { expect, test } from '../extends.ts'
|
||||
|
||||
test.beforeEach(async ({ conduit }) => {
|
||||
await conduit.intercept('GET', /articles\?tag=butt/, { fixture: 'articles-of-tag.json' })
|
||||
await conduit.intercept('GET', /articles\?limit/, { fixture: 'articles.json' })
|
||||
await conduit.intercept('GET', /articles\/.+/, { fixture: 'article.json' })
|
||||
await conduit.intercept('GET', /tags/, { fixture: 'tags.json' })
|
||||
})
|
||||
|
||||
test('should can access home page', async ({ page, conduit }) => {
|
||||
await conduit.goto(Route.Home)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'conduit' })).toContainText('conduit')
|
||||
})
|
||||
|
||||
test.describe('navigation bar', () => {
|
||||
test('should highlight Home nav-item top menu bar when page load', async ({ page, conduit }) => {
|
||||
await conduit.goto(Route.Home)
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Home', exact: true })).toHaveClass(/active/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('article previews', () => {
|
||||
test('should highlight Global Feed when home page loaded', async ({ page, conduit }) => {
|
||||
await conduit.goto(Route.Home)
|
||||
await expect(page.getByText('Global Feed')).toHaveClass(/active/)
|
||||
})
|
||||
|
||||
test('should display article when page loaded', async ({ page, conduit }) => {
|
||||
await conduit.goto(Route.Home)
|
||||
const articlePreview = page.getByTestId('article-preview').first()
|
||||
|
||||
await test.step('should have article preview', async () => {
|
||||
await expect(articlePreview.getByRole('heading')).toContainText('abc123')
|
||||
await expect(articlePreview.getByTestId('article-description')).toContainText('aaaaaaaaaaassssssssss')
|
||||
})
|
||||
|
||||
await test.step('should redirect to article details page when click read more', async () => {
|
||||
await articlePreview.getByText('Read more...').click()
|
||||
|
||||
await expect(page).toHaveURL(/#\/article\/.+/)
|
||||
})
|
||||
})
|
||||
|
||||
test('should jump to next page when click page 2 in pagination', async ({ page, conduit }) => {
|
||||
await conduit.goto(Route.Home)
|
||||
|
||||
const waitForGetArticles = await conduit.intercept('GET', /articles\?limit=10&offset=10/, { fixture: 'articles.json' })
|
||||
|
||||
const [response] = await Promise.all([
|
||||
waitForGetArticles(),
|
||||
page.getByRole('link', { name: 'Go to page 2', exact: true }).click(),
|
||||
])
|
||||
|
||||
expect(response.request().url()).toContain('limit=10&offset=10')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import type { Article, Profile } from 'src/services/api.ts'
|
||||
import { Route } from '../constant'
|
||||
import { expect, test } from '../extends'
|
||||
import { ArticleDetailPageObject } from '../page-objects/article-detail.page-object.ts'
|
||||
|
||||
test.beforeEach(async ({ conduit }) => {
|
||||
await conduit.intercept('GET', /articles\?/, { fixture: 'articles.json' })
|
||||
await conduit.intercept('GET', /tags/, { fixture: 'tags.json' })
|
||||
await conduit.intercept('GET', /profiles\/\S+/, { fixture: 'profile.json' })
|
||||
})
|
||||
|
||||
test.describe('follow', () => {
|
||||
test.beforeEach(async ({ conduit }) => {
|
||||
await conduit.intercept('GET', /articles\/\S+/, {
|
||||
statusCode: 200,
|
||||
fixture: 'article.json',
|
||||
postFixture: (article: { article: Article }) => {
|
||||
article.article.author.username = 'foo'
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
for (const [index, position] of (['banner', 'article footer'] as const).entries()) {
|
||||
test(`should call follow user api when click ${position} follow user button`, async ({ page, conduit }) => {
|
||||
await conduit.login()
|
||||
await conduit.goto(Route.ArticleDetail)
|
||||
const articlePage = new ArticleDetailPageObject(page)
|
||||
|
||||
const waitForFollowUser = await conduit.intercept('POST', /profiles\/\S+\/follow/, {
|
||||
statusCode: 200,
|
||||
fixture: 'profile.json',
|
||||
postFixture: (profile: { profile: Profile }) => {
|
||||
profile.profile.following = true
|
||||
},
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
waitForFollowUser(),
|
||||
articlePage.clickFollowUser(position),
|
||||
])
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Unfollow' }).nth(index)).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test('should not display follow button when user not logged', async ({ page, conduit }) => {
|
||||
await conduit.goto(Route.ArticleDetail)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Article body' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Follow' })).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
|
@ -3,6 +3,12 @@
|
|||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"baseUrl": "..",
|
||||
"paths": {
|
||||
"src/*": ["./src/*"],
|
||||
"page-objects/*": ["./playwright/page-objects/*"]
|
||||
},
|
||||
"noEmit": true,
|
||||
"isolatedModules": false
|
||||
},
|
||||
"include": [
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
import { prettyPrint } from 'html'
|
||||
|
||||
export function formatHTML(rawHTMLString: string) {
|
||||
export function formatHTML(rawHTMLString: string): string {
|
||||
const removeComments = rawHTMLString.replaceAll(/<!--.*?-->/gs, '')
|
||||
const pretty = prettyPrint(removeComments, { indent_size: 2 })
|
||||
const removeEmptyLines = `${pretty}\n`.replaceAll(/\n{2,}/g, '\n')
|
||||
return removeEmptyLines
|
||||
}
|
||||
|
||||
export function formatJSON(json: string | object): string {
|
||||
const jsonObject = typeof json === 'string' ? JSON.parse(json) as object : json
|
||||
return JSON.stringify(jsonObject, null, 2)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<ul class="pagination">
|
||||
<ul data-testid="pagination" class="pagination">
|
||||
<li
|
||||
v-for="pageNumber in pagesCount"
|
||||
:key="pageNumber"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="article-preview">
|
||||
<div data-testid="article-preview" class="article-preview">
|
||||
<div class="article-meta">
|
||||
<AppLink
|
||||
name="profile"
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
class="preview-link"
|
||||
>
|
||||
<h1>{{ article.title }}</h1>
|
||||
<p>{{ article.description }}</p>
|
||||
<p data-testid="article-description">{{ article.description }}</p>
|
||||
<span>Read more...</span>
|
||||
<ul class="tag-list">
|
||||
<li
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
exports[`# ArticleDetail > should filter the xss content in Markdown body 1`] = `
|
||||
<div
|
||||
class="col-md-12"
|
||||
data-testid="article-body"
|
||||
id="article-content"
|
||||
>
|
||||
|
||||
|
|
@ -17,6 +18,7 @@ exports[`# ArticleDetail > should filter the xss content in Markdown body 1`] =
|
|||
exports[`# ArticleDetail > should render markdown (zh-CN) body correctly 1`] = `
|
||||
<div
|
||||
class="col-md-12"
|
||||
data-testid="article-body"
|
||||
id="article-content"
|
||||
>
|
||||
<h1>
|
||||
|
|
@ -1201,6 +1203,7 @@ D-->>A: Dashed open arrow
|
|||
exports[`# ArticleDetail > should render markdown body correctly 1`] = `
|
||||
<div
|
||||
class="col-md-12"
|
||||
data-testid="article-body"
|
||||
id="article-content"
|
||||
>
|
||||
<h1>
|
||||
|
|
|
|||
Loading…
Reference in New Issue