test: migrate to playwright tests

This commit is contained in:
mutoe 2024-08-15 13:33:19 +08:00
parent 82dd7a4ae3
commit 7db05d7f91
No known key found for this signature in database
22 changed files with 613 additions and 95 deletions

View File

@ -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"
},

View File

@ -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,
},
})

View File

@ -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)
})
}
})
})

View File

@ -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)
},
})

View File

@ -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')
}
}

View File

@ -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)
}
}

View File

@ -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')
}
}

View File

@ -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')
}
}

View File

@ -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')
}
}

View File

@ -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')
})
})

View File

@ -0,0 +1,10 @@
[
"HuManIty",
"Gandhi",
"HITLER",
"SIDA",
"BlackLivesMatter",
"test",
"dragons",
"butt"
]

View File

@ -0,0 +1,10 @@
[
"HuManIty",
"Gandhi",
"HITLER",
"SIDA",
"BlackLivesMatter",
"test",
"dragons",
"butt"
]

View File

@ -0,0 +1,10 @@
[
"HuManIty",
"Gandhi",
"HITLER",
"SIDA",
"BlackLivesMatter",
"test",
"dragons",
"butt"
]

View File

@ -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)
})
})

View File

@ -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')
})
})

View File

@ -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()
})
})

View File

@ -3,6 +3,12 @@
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext", "DOM"],
"baseUrl": "..",
"paths": {
"src/*": ["./src/*"],
"page-objects/*": ["./playwright/page-objects/*"]
},
"noEmit": true,
"isolatedModules": false
},
"include": [

View File

@ -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)
}

View File

@ -1,5 +1,5 @@
<template>
<ul class="pagination">
<ul data-testid="pagination" class="pagination">
<li
v-for="pageNumber in pagesCount"
:key="pageNumber"

View File

@ -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

View File

@ -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--&gt;&gt;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>