Skip to content

Commit 82dd7a4

Browse files
committed
chore: setup playwright as the e2e test framework
1 parent 6dbfbe9 commit 82dd7a4

19 files changed

+451
-11
lines changed

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ coverage
1717
/cypress/videos/
1818
/cypress/screenshots/
1919

20+
/test-results/
21+
/playwright-report/
22+
/blob-report/
23+
/playwright/.cache/
24+
2025
# Editor directories and files
2126
.vscode/*
2227
!.vscode/extensions.json

cypress/fixtures/article.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"body": "# Article body\n\nThis is **Strong** text",
66
"createdAt": "2020-11-01T14:59:39.404Z",
77
"updatedAt": "2020-11-01T14:59:39.404Z",
8-
"tagList": [],
8+
"tagList": ["foo", "bar"],
99
"description": "this is descripion",
1010
"author": {
1111
"username": "plumrx",

eslint.config.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export default defineConfig({
66
'tsconfig.json',
77
'tsconfig.node.json',
88
'cypress/e2e/tsconfig.json',
9+
'playwright/tsconfig.json',
910
],
1011
},
1112
vue: {
@@ -27,4 +28,12 @@ export default defineConfig({
2728
rules: {
2829
'ts/method-signature-style': 'off',
2930
},
31+
}, {
32+
files: [
33+
'*.config.ts',
34+
'playwright/**/*',
35+
],
36+
rules: {
37+
'node/prefer-global/process': 'off',
38+
},
3039
})

index.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66
<meta name="viewport" content="width=device-width, initial-scale=1.0">
77

88
<link rel="icon" href="/favicon.ico" />
9-
<link href="//code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css" rel="stylesheet" type="text/css">
10-
<link href="//fonts.googleapis.com/css?family=Titillium+Web:700|Source+Serif+Pro:400,700|Merriweather+Sans:400,700|Source+Sans+Pro:400,300,600,700,300italic,400italic,600italic,700italic" rel="stylesheet" type="text/css">
9+
<link href="https://code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css" rel="stylesheet" type="text/css">
10+
<link href="https://fonts.googleapis.com/css?family=Titillium+Web:700|Source+Serif+Pro:400,700|Merriweather+Sans:400,700|Source+Sans+Pro:400,300,600,700,300italic,400italic,600italic,700italic" rel="stylesheet" type="text/css">
1111

12-
<link rel="stylesheet" href="//demo.realworld.io/main.css">
12+
<link rel="stylesheet" href="https://demo.realworld.io/main.css">
1313
</head>
1414
<body>
1515
<div id="app"></div>

package.json

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,19 @@
55
"type": "module",
66
"scripts": {
77
"prepare": "simple-git-hooks",
8-
"dev": "vite",
8+
"dev": "vite --port 5173",
99
"build": "vite build",
1010
"serve": "vite preview --port 4173",
1111
"type-check": "vue-tsc --noEmit",
1212
"lint": "eslint --fix .",
1313
"test": "npm run test:unit && npm run test:e2e:ci",
14+
"test:cypress:local": "cypress open --e2e -c baseUrl=http://localhost:5173",
15+
"test:cyprsss:prod": "cypress run --e2e -c baseUrl=https://vue3-realworld-example-app-mutoe.vercel.app",
1416
"test:e2e": "npm run build && concurrently -rk -s first \"npm run serve\" \"cypress open --e2e -c baseUrl=http://localhost:4173\"",
1517
"test:e2e:ci": "npm run build && concurrently -rk -s first \"npm run serve\" \"cypress run --e2e -c baseUrl=http://localhost:4173\"",
16-
"test:e2e:local": "cypress open --e2e -c baseUrl=http://localhost:5173",
17-
"test:e2e:prod": "cypress run --e2e -c baseUrl=https://vue3-realworld-example-app-mutoe.vercel.app",
18+
"test:playwright:ci": "playwright test",
19+
"test:playwright:local": "playwright test --ui",
20+
"test:playwright:local:debug": "playwright test --ui --debug",
1821
"test:unit": "vitest run",
1922
"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"
2023
},
@@ -28,9 +31,12 @@
2831
"devDependencies": {
2932
"@mutoe/eslint-config": "^2.8.3",
3033
"@pinia/testing": "^0.1.5",
34+
"@playwright/test": "^1.46.0",
3135
"@testing-library/cypress": "^10.0.2",
3236
"@testing-library/user-event": "^14.5.2",
3337
"@testing-library/vue": "^8.1.0",
38+
"@types/html": "^1.0.4",
39+
"@types/node": "^22.1.0",
3440
"@vitejs/plugin-vue": "^5.1.2",
3541
"@vitest/coverage-v8": "^2.0.5",
3642
"concurrently": "^8.2.2",
@@ -40,6 +46,7 @@
4046
"eslint-plugin-vitest": "^0.5.4",
4147
"eslint-plugin-vue": "^9.27.0",
4248
"happy-dom": "^14.12.3",
49+
"html": "^1.0.0",
4350
"lint-staged": "^15.2.8",
4451
"msw": "^2.3.5",
4552
"rollup-plugin-analyzer": "^4.0.0",

playwright.config.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { defineConfig, devices } from '@playwright/test'
2+
3+
/**
4+
* Read environment variables from file.
5+
* https://github.com/motdotla/dotenv
6+
*/
7+
// import dotenv from 'dotenv';
8+
// dotenv.config({ path: path.resolve(__dirname, '.env') });
9+
10+
const baseURL = 'http://localhost:5173'
11+
12+
/**
13+
* See https://playwright.dev/docs/test-configuration.
14+
*/
15+
export default defineConfig({
16+
testDir: './playwright',
17+
/* Run tests in files in parallel */
18+
fullyParallel: true,
19+
/* Fail the build on CI if you accidentally left test.only in the source code. */
20+
forbidOnly: !!process.env.CI,
21+
/* Retry on CI only */
22+
retries: process.env.CI ? 1 : 0,
23+
/* Opt out of parallel tests on CI. */
24+
workers: process.env.CI ? 1 : undefined,
25+
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
26+
reporter: 'html',
27+
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
28+
use: {
29+
/* Base URL to use in actions like `await page.goto('/')`. */
30+
baseURL,
31+
32+
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
33+
trace: 'on-first-retry',
34+
},
35+
36+
/* Configure projects for major browsers */
37+
projects: [
38+
{
39+
name: 'chromium',
40+
use: { ...devices['Desktop Chrome'] },
41+
},
42+
43+
{
44+
name: 'firefox',
45+
use: { ...devices['Desktop Firefox'] },
46+
},
47+
48+
{
49+
name: 'webkit',
50+
use: { ...devices['Desktop Safari'] },
51+
},
52+
53+
/* Test against mobile viewports. */
54+
// {
55+
// name: 'Mobile Chrome',
56+
// use: { ...devices['Pixel 5'] },
57+
// },
58+
// {
59+
// name: 'Mobile Safari',
60+
// use: { ...devices['iPhone 12'] },
61+
// },
62+
63+
/* Test against branded browsers. */
64+
// {
65+
// name: 'Microsoft Edge',
66+
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
67+
// },
68+
// {
69+
// name: 'Google Chrome',
70+
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
71+
// },
72+
],
73+
74+
/* Run your local dev server before starting the tests */
75+
webServer: {
76+
command: 'npm run dev',
77+
url: baseURL,
78+
reuseExistingServer: !process.env.CI,
79+
ignoreHTTPSErrors: true,
80+
},
81+
})

playwright/article/article.spec.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { Article } from 'src/services/api.ts'
2+
import { Route } from '../constant.ts'
3+
import { expect, test } from '../extends'
4+
import { formatHTML } from '../utils/formatHTML.ts'
5+
6+
test.describe('article', () => {
7+
test.beforeEach(async ({ conduct }) => {
8+
await conduct.intercept('GET', /articles\?limit/, { fixture: 'articles.json' })
9+
await conduct.intercept('GET', /tags/, { fixture: 'articles-of-tag.json' })
10+
await conduct.intercept('GET', /profiles\/.+/, { fixture: 'profile.json' })
11+
12+
await conduct.login()
13+
})
14+
15+
test.describe('post article', () => {
16+
test('jump to post detail page when submit create article form', async ({ page, conduct }) => {
17+
await conduct.goto(Route.ArticleCreate)
18+
19+
const articleFixture = await conduct.getFixture<{ article: Article }>('article.json')
20+
const waitForPostArticle = await conduct.intercept('POST', /articles$/, { body: articleFixture })
21+
22+
await page.getByPlaceholder('Article Title').fill(articleFixture.article.title)
23+
await page.getByPlaceholder("What's this article about?").fill(articleFixture.article.description)
24+
await page.getByPlaceholder('Write your article (in markdown)').fill(articleFixture.article.body)
25+
for (const tag of articleFixture.article.tagList) {
26+
await page.getByPlaceholder('Enter tags').fill(tag)
27+
await page.getByPlaceholder('Enter tags').press('Enter')
28+
}
29+
30+
await page.getByRole('button', { name: 'Publish Article' }).dispatchEvent('click')
31+
await waitForPostArticle()
32+
33+
await conduct.intercept('GET', /articles\/.+/, { fixture: 'article.json' })
34+
await page.waitForURL(/article\/article-title/)
35+
await expect (page.getByRole('heading', { name: 'Article title' })).toContainText('Article title')
36+
})
37+
38+
test('should render markdown correctly', async ({ browserName, page, conduct }) => {
39+
test.skip(browserName !== 'chromium')
40+
await conduct.goto(Route.ArticleDetail)
41+
42+
const waitForArticle = await conduct.intercept('GET', /articles\/.+/, { fixture: 'article.json' })
43+
await waitForArticle()
44+
const innerHTML = await page.locator('.article-content').innerHTML()
45+
expect(formatHTML(innerHTML)).toMatchSnapshot('markdown-render.html')
46+
})
47+
})
48+
49+
test.describe('delete article', () => {
50+
for (const [index, position] of ['banner', 'article footer'].entries()) {
51+
test(`delete article from ${position}`, async ({ page, conduct }) => {
52+
const waitForArticle = await conduct.intercept('GET', /articles\/.+/, { fixture: 'article.json' })
53+
await conduct.goto(Route.ArticleDetail)
54+
await waitForArticle()
55+
56+
await conduct.intercept('DELETE', /articles\/.+/)
57+
await page.getByRole('button', { name: 'Delete Article' }).nth(index).click()
58+
59+
await expect(page).toHaveURL(Route.Home)
60+
})
61+
}
62+
})
63+
})
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<div id="article-content" data-testid="article-body" class="col-md-12">
2+
<h1>Article body</h1>
3+
<p>This is <strong>Strong</strong> text</p>
4+
</div>
5+
<ul class="tag-list">
6+
<li class="tag-default tag-pill tag-outline" data-testid="article-tag">foo</li>
7+
<li class="tag-default tag-pill tag-outline" data-testid="article-tag">bar</li>
8+
</ul>

playwright/conduct.page-object.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import fs from 'node:fs/promises'
2+
import path from 'node:path'
3+
import url from 'node:url'
4+
import type { Page, Response } from '@playwright/test'
5+
import type { User } from '../src/services/api.ts'
6+
import { Route } from './constant'
7+
import { expect } from './extends.ts'
8+
import { boxedStep } from './utils/test-decorators.ts'
9+
10+
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
11+
const fixtureDir = path.join(__dirname, '../cypress/fixtures')
12+
13+
export class ConductPageObject {
14+
constructor(
15+
public readonly page: Page,
16+
) {}
17+
18+
async intercept(method: 'POST' | 'GET' | 'PATCH' | 'DELETE' | 'PUT', url: string | RegExp, options: {
19+
fixture?: string
20+
statusCode?: number
21+
body?: unknown
22+
} = {}): Promise<(timeout?: number) => Promise<Response>> {
23+
await this.page.route(url, async route => {
24+
if (route.request().method() !== method)
25+
return route.continue()
26+
27+
return await route.fulfill({
28+
status: options.statusCode || undefined,
29+
json: options.body ?? undefined,
30+
path: options.fixture ? path.join(fixtureDir, options.fixture) : undefined,
31+
})
32+
})
33+
34+
return (timeout: number = 1000) => this.page.waitForResponse(response => {
35+
const request = response.request()
36+
if (request.method() !== method)
37+
return false
38+
39+
if (typeof url === 'string')
40+
return request.url().includes(url)
41+
42+
return url.test(request.url())
43+
}, { timeout })
44+
}
45+
46+
async getFixture<T = unknown>(fixture: string): Promise<T> {
47+
const file = path.join(fixtureDir, fixture)
48+
return JSON.parse(await fs.readFile(file, 'utf-8')) as T
49+
}
50+
51+
async goto(route: Route) {
52+
await this.page.goto(route)
53+
}
54+
55+
@boxedStep
56+
async login(username = 'plumrx') {
57+
const userFixture = await this.getFixture<{ user: User }>('user.json')
58+
userFixture.user.username = username
59+
await this.intercept('POST', /users\/login$/, { statusCode: 200, body: userFixture })
60+
61+
await this.goto(Route.Login)
62+
63+
await this.page.getByPlaceholder('Email').fill('foo@example.com')
64+
await this.page.getByPlaceholder('Password').fill('12345678')
65+
await this.page.getByRole('button', { name: 'Sign in' }).click()
66+
67+
await expect(this.page).toHaveURL(Route.Home)
68+
}
69+
}

playwright/constant.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export enum Route {
2+
Home = '/#/',
3+
Login = '/#/login',
4+
Register = '/#/register',
5+
Settings = '/#/settings',
6+
ArticleCreate = '/#/article/create',
7+
ArticleDetail = '/#/article/article-title',
8+
}

0 commit comments

Comments
 (0)