Compare commits
4 Commits
master
...
playwright
| Author | SHA1 | Date |
|---|---|---|
|
|
4a0222e26f | |
|
|
6b314c4aa7 | |
|
|
7db05d7f91 | |
|
|
82dd7a4ae3 |
|
|
@ -0,0 +1,24 @@
|
||||||
|
name: Qodana
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
qodana:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
checks: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Qodana Scan
|
||||||
|
uses: JetBrains/qodana-action@v2024.1.9
|
||||||
|
env:
|
||||||
|
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
name: Pull request
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
paths-ignore:
|
|
||||||
- '**.md'
|
|
||||||
|
|
||||||
env:
|
|
||||||
CI: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
name: Lint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 9
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- name: Use Node.js 22.x
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22.x
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --no-frozen-lockfile
|
|
||||||
|
|
||||||
- name: TypeScript check
|
|
||||||
run: pnpm lint
|
|
||||||
|
|
||||||
- name: Eslint check
|
|
||||||
run: pnpm lint
|
|
||||||
|
|
||||||
unit_test:
|
|
||||||
name: Unit test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 9
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- name: Use Node.js 22.x
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22.x
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --no-frozen-lockfile
|
|
||||||
|
|
||||||
- name: Unit test
|
|
||||||
run: pnpm test:unit
|
|
||||||
|
|
||||||
- name: Update coverage report
|
|
||||||
uses: codecov/codecov-action@v4
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
|
|
||||||
e2e_tests:
|
|
||||||
name: E2E test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 9
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- name: Use Node.js 22.x
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22.x
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --no-frozen-lockfile
|
|
||||||
|
|
||||||
- name: Get cypress version
|
|
||||||
id: cypress-version
|
|
||||||
run: echo "version=$(pnpm info cypress version)" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Cache cypress binary
|
|
||||||
id: cache-cypress-binary
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.cache/Cypress
|
|
||||||
key: cypress-binary-${{ runner.os }}-${{ steps.cypress-version.outputs.version }}
|
|
||||||
|
|
||||||
- name: Install cypress binary
|
|
||||||
if: steps.cache-cypress-binary.outputs.cache-hit != 'true'
|
|
||||||
run: pnpm cypress install
|
|
||||||
|
|
||||||
- name: E2E test
|
|
||||||
run: pnpm test:e2e:ci
|
|
||||||
|
|
@ -4,6 +4,9 @@ on:
|
||||||
push:
|
push:
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- '**.md'
|
- '**.md'
|
||||||
|
pull_request:
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
|
|
@ -63,8 +66,8 @@ jobs:
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
e2e_tests:
|
cypress_e2e_tests:
|
||||||
name: E2E test
|
name: Cypress E2E test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
@ -99,4 +102,37 @@ jobs:
|
||||||
run: pnpm cypress install
|
run: pnpm cypress install
|
||||||
|
|
||||||
- name: E2E test
|
- name: E2E test
|
||||||
run: pnpm test:e2e:ci
|
run: pnpm test:cypress
|
||||||
|
|
||||||
|
playwright_e2e_tests:
|
||||||
|
name: Playwright E2E test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
run_install: false
|
||||||
|
|
||||||
|
- name: Use Node.js 22.x
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22.x
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Install playwright binary
|
||||||
|
run: pnpm playwright install --with-deps
|
||||||
|
|
||||||
|
- name: E2E test
|
||||||
|
run: pnpm test:playwright
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 30
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ coverage
|
||||||
/cypress/videos/
|
/cypress/videos/
|
||||||
/cypress/screenshots/
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/blob-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
"body": "# Article body\n\nThis is **Strong** text",
|
"body": "# Article body\n\nThis is **Strong** text",
|
||||||
"createdAt": "2020-11-01T14:59:39.404Z",
|
"createdAt": "2020-11-01T14:59:39.404Z",
|
||||||
"updatedAt": "2020-11-01T14:59:39.404Z",
|
"updatedAt": "2020-11-01T14:59:39.404Z",
|
||||||
"tagList": [],
|
"tagList": ["foo", "bar"],
|
||||||
"description": "this is descripion",
|
"description": "this is descripion",
|
||||||
"author": {
|
"author": {
|
||||||
"username": "plumrx",
|
"username": "plumrx",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export default defineConfig({
|
||||||
'tsconfig.json',
|
'tsconfig.json',
|
||||||
'tsconfig.node.json',
|
'tsconfig.node.json',
|
||||||
'cypress/e2e/tsconfig.json',
|
'cypress/e2e/tsconfig.json',
|
||||||
|
'playwright/tsconfig.json',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
vue: {
|
vue: {
|
||||||
|
|
@ -27,4 +28,12 @@ export default defineConfig({
|
||||||
rules: {
|
rules: {
|
||||||
'ts/method-signature-style': 'off',
|
'ts/method-signature-style': 'off',
|
||||||
},
|
},
|
||||||
|
}, {
|
||||||
|
files: [
|
||||||
|
'*.config.ts',
|
||||||
|
'playwright/**/*',
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'node/prefer-global/process': 'off',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,10 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<link href="//code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css" rel="stylesheet" type="text/css">
|
<link href="https://code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css" rel="stylesheet" type="text/css">
|
||||||
<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">
|
<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">
|
||||||
|
|
||||||
<link rel="stylesheet" href="//demo.realworld.io/main.css">
|
<link rel="stylesheet" href="https://demo.realworld.io/main.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
20
package.json
20
package.json
|
|
@ -5,16 +5,19 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "simple-git-hooks",
|
"prepare": "simple-git-hooks",
|
||||||
"dev": "vite",
|
"dev": "vite --port 4173",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"serve": "vite preview --port 4173",
|
"serve": "vite preview --port 4173",
|
||||||
"type-check": "vue-tsc --noEmit",
|
"type-check": "vue-tsc --noEmit",
|
||||||
"lint": "eslint --fix .",
|
"lint": "eslint --fix .",
|
||||||
"test": "npm run test:unit && npm run test:e2e:ci",
|
"test": "npm run test:unit && npm run test:playwright",
|
||||||
"test:e2e": "npm run build && concurrently -rk -s first \"npm run serve\" \"cypress open --e2e -c baseUrl=http://localhost:4173\"",
|
"test:cypress": "npm run build && concurrently -rk -s first \"npm run serve\" \"cypress run --e2e",
|
||||||
"test:e2e:ci": "npm run build && concurrently -rk -s first \"npm run serve\" \"cypress run --e2e -c baseUrl=http://localhost:4173\"",
|
"test:cypress:ui": "cypress open --e2e",
|
||||||
"test:e2e: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:prod": "cypress run --e2e -c baseUrl=https://vue3-realworld-example-app-mutoe.vercel.app",
|
"test:playwright": "npm run build && cross-env CI=true playwright test",
|
||||||
|
"test:playwright:prod": "cross-env E2E_BASE_URL='https://vue3-realworld-example-app-mutoe.vercel.app' playwright test",
|
||||||
|
"test:playwright:ui": "playwright test --ui",
|
||||||
|
"test:playwright:ui:debug": "playwright test --ui --headed --debug",
|
||||||
"test:unit": "vitest run",
|
"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"
|
"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"
|
||||||
},
|
},
|
||||||
|
|
@ -28,18 +31,23 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@mutoe/eslint-config": "^2.8.3",
|
"@mutoe/eslint-config": "^2.8.3",
|
||||||
"@pinia/testing": "^0.1.5",
|
"@pinia/testing": "^0.1.5",
|
||||||
|
"@playwright/test": "^1.46.0",
|
||||||
"@testing-library/cypress": "^10.0.2",
|
"@testing-library/cypress": "^10.0.2",
|
||||||
"@testing-library/user-event": "^14.5.2",
|
"@testing-library/user-event": "^14.5.2",
|
||||||
"@testing-library/vue": "^8.1.0",
|
"@testing-library/vue": "^8.1.0",
|
||||||
|
"@types/html": "^1.0.4",
|
||||||
|
"@types/node": "^22.1.0",
|
||||||
"@vitejs/plugin-vue": "^5.1.2",
|
"@vitejs/plugin-vue": "^5.1.2",
|
||||||
"@vitest/coverage-v8": "^2.0.5",
|
"@vitest/coverage-v8": "^2.0.5",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
"cypress": "^13.13.2",
|
"cypress": "^13.13.2",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-plugin-cypress": "^3.4.0",
|
"eslint-plugin-cypress": "^3.4.0",
|
||||||
"eslint-plugin-vitest": "^0.5.4",
|
"eslint-plugin-vitest": "^0.5.4",
|
||||||
"eslint-plugin-vue": "^9.27.0",
|
"eslint-plugin-vue": "^9.27.0",
|
||||||
"happy-dom": "^14.12.3",
|
"happy-dom": "^14.12.3",
|
||||||
|
"html": "^1.0.0",
|
||||||
"lint-staged": "^15.2.8",
|
"lint-staged": "^15.2.8",
|
||||||
"msw": "^2.3.5",
|
"msw": "^2.3.5",
|
||||||
"rollup-plugin-analyzer": "^4.0.0",
|
"rollup-plugin-analyzer": "^4.0.0",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { defineConfig, devices } 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 isCI = process.env.CI
|
||||||
|
const baseURL = process.env.E2E_BASE_URL || 'http://localhost:4173'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './playwright',
|
||||||
|
/* Run tests in files in parallel */
|
||||||
|
fullyParallel: false,
|
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
|
forbidOnly: !!isCI,
|
||||||
|
/* Retry on CI only */
|
||||||
|
retries: isCI ? 1 : 0,
|
||||||
|
/* Opt out of parallel tests on CI. */
|
||||||
|
workers: isCI ? 1 : undefined,
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: [
|
||||||
|
['html', { open: 'never' }],
|
||||||
|
isCI ? ['github'] : ['list'],
|
||||||
|
],
|
||||||
|
/* 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: isCI ? 10_000 : 4000,
|
||||||
|
actionTimeout: isCI ? 10_000 : 4000,
|
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
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 */
|
||||||
|
projects: isCI
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: { ...devices['Desktop Firefox'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'webkit',
|
||||||
|
use: { ...devices['Desktop Safari'] },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
webServer: {
|
||||||
|
command: isCI ? 'pnpm serve' : 'npm run dev',
|
||||||
|
url: baseURL,
|
||||||
|
reuseExistingServer: !isCI,
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
export enum Route {
|
||||||
|
Home = '/#/',
|
||||||
|
Login = '/#/login',
|
||||||
|
Register = '/#/register',
|
||||||
|
Settings = '/#/settings',
|
||||||
|
ArticleCreate = '/#/article/create',
|
||||||
|
ArticleDetail = '/#/article/article-title',
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { test as base } from '@playwright/test'
|
||||||
|
import { ConduitPageObject } from 'page-objects/conduit.page-object'
|
||||||
|
|
||||||
|
export const test = base.extend<{
|
||||||
|
conduit: ConduitPageObject
|
||||||
|
}>({
|
||||||
|
conduit: async ({ page }, use) => {
|
||||||
|
const buyscoutPageObject = new ConduitPageObject(page)
|
||||||
|
await use(buyscoutPageObject)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterEach(async ({ page }, testInfo) => {
|
||||||
|
if (!process.env.CI && testInfo.status !== testInfo.expectedStatus) {
|
||||||
|
// eslint-disable-next-line ts/restrict-template-expressions
|
||||||
|
process.stderr.write(`❌ ❌ PLAYWRIGHT TEST FAILURE ❌ ❌\n${testInfo.error?.stack || testInfo.error}\n`)
|
||||||
|
testInfo.setTimeout(0)
|
||||||
|
await page.pause()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const expect = test.expect
|
||||||
|
|
@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
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'
|
||||||
|
|
||||||
|
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
|
||||||
|
const fixtureDir = path.join(__dirname, '../../cypress/fixtures')
|
||||||
|
|
||||||
|
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
|
||||||
|
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,
|
||||||
|
path: options.fixture ? path.join(fixtureDir, options.fixture) : undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => this.page.waitForResponse(response => {
|
||||||
|
const request = response.request()
|
||||||
|
if (request.method() !== method)
|
||||||
|
return false
|
||||||
|
|
||||||
|
if (typeof url === 'string')
|
||||||
|
return request.url().includes(url)
|
||||||
|
|
||||||
|
return url.test(request.url())
|
||||||
|
}, { timeout: options.timeout ?? 4000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFixture<T = unknown>(fixture: string): Promise<T> {
|
||||||
|
const file = path.join(fixtureDir, fixture)
|
||||||
|
return JSON.parse(await fs.readFile(file, 'utf-8')) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
async goto(route: 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.goto(Route.Login)
|
||||||
|
|
||||||
|
await this.page.getByPlaceholder('Email').fill('foo@example.com')
|
||||||
|
await this.page.getByPlaceholder('Password').fill('12345678')
|
||||||
|
|
||||||
|
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,8 @@
|
||||||
|
<div id="article-content" data-testid="article-body" class="col-md-12">
|
||||||
|
<h1>Article body</h1>
|
||||||
|
<p>This is <strong>Strong</strong> text</p>
|
||||||
|
</div>
|
||||||
|
<ul class="tag-list">
|
||||||
|
<li class="tag-default tag-pill tag-outline" data-testid="article-tag">foo</li>
|
||||||
|
<li class="tag-default tag-pill tag-outline" data-testid="article-tag">bar</li>
|
||||||
|
</ul>
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"baseUrl": "..",
|
||||||
|
"paths": {
|
||||||
|
"src/*": ["./src/*"],
|
||||||
|
"page-objects/*": ["./playwright/page-objects/*"]
|
||||||
|
},
|
||||||
|
"noEmit": true,
|
||||||
|
"isolatedModules": false
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"./**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { prettyPrint } from 'html'
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { test } from '../extends'
|
||||||
|
|
||||||
|
export function step(target: Function, context: ClassMethodDecoratorContext) {
|
||||||
|
return function replacementMethod(this: Function, ...args: unknown[]) {
|
||||||
|
const className = this.constructor.name
|
||||||
|
const name = `${className.replace(/PageObject$/, '')}.${context.name as string}`
|
||||||
|
return test.step(name, async () => {
|
||||||
|
// eslint-disable-next-line ts/no-unsafe-return
|
||||||
|
return await target.call(this, ...args)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function boxedStep(target: Function, context: ClassMethodDecoratorContext) {
|
||||||
|
return function replacementMethod(this: Function, ...args: unknown[]) {
|
||||||
|
const className = this.constructor.name
|
||||||
|
const name = `${className.replace(/PageObject$/, '')}.${context.name as string}`
|
||||||
|
return test.step(name, async () => {
|
||||||
|
// eslint-disable-next-line ts/no-unsafe-return
|
||||||
|
return await target.call(this, ...args)
|
||||||
|
}, { box: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
128
pnpm-lock.yaml
128
pnpm-lock.yaml
|
|
@ -30,6 +30,9 @@ importers:
|
||||||
'@pinia/testing':
|
'@pinia/testing':
|
||||||
specifier: ^0.1.5
|
specifier: ^0.1.5
|
||||||
version: 0.1.5(pinia@2.2.1(typescript@5.5.4)(vue@3.4.37(typescript@5.5.4)))(vue@3.4.37(typescript@5.5.4))
|
version: 0.1.5(pinia@2.2.1(typescript@5.5.4)(vue@3.4.37(typescript@5.5.4)))(vue@3.4.37(typescript@5.5.4))
|
||||||
|
'@playwright/test':
|
||||||
|
specifier: ^1.46.0
|
||||||
|
version: 1.46.0
|
||||||
'@testing-library/cypress':
|
'@testing-library/cypress':
|
||||||
specifier: ^10.0.2
|
specifier: ^10.0.2
|
||||||
version: 10.0.2(cypress@13.13.2)
|
version: 10.0.2(cypress@13.13.2)
|
||||||
|
|
@ -39,6 +42,12 @@ importers:
|
||||||
'@testing-library/vue':
|
'@testing-library/vue':
|
||||||
specifier: ^8.1.0
|
specifier: ^8.1.0
|
||||||
version: 8.1.0(@vue/compiler-sfc@3.4.37)(@vue/server-renderer@3.4.37(vue@3.4.37(typescript@5.5.4)))(vue@3.4.37(typescript@5.5.4))
|
version: 8.1.0(@vue/compiler-sfc@3.4.37)(@vue/server-renderer@3.4.37(vue@3.4.37(typescript@5.5.4)))(vue@3.4.37(typescript@5.5.4))
|
||||||
|
'@types/html':
|
||||||
|
specifier: ^1.0.4
|
||||||
|
version: 1.0.4
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.1.0
|
||||||
|
version: 22.1.0
|
||||||
'@vitejs/plugin-vue':
|
'@vitejs/plugin-vue':
|
||||||
specifier: ^5.1.2
|
specifier: ^5.1.2
|
||||||
version: 5.1.2(vite@5.4.0(@types/node@22.1.0))(vue@3.4.37(typescript@5.5.4))
|
version: 5.1.2(vite@5.4.0(@types/node@22.1.0))(vue@3.4.37(typescript@5.5.4))
|
||||||
|
|
@ -48,6 +57,9 @@ importers:
|
||||||
concurrently:
|
concurrently:
|
||||||
specifier: ^8.2.2
|
specifier: ^8.2.2
|
||||||
version: 8.2.2
|
version: 8.2.2
|
||||||
|
cross-env:
|
||||||
|
specifier: ^7.0.3
|
||||||
|
version: 7.0.3
|
||||||
cypress:
|
cypress:
|
||||||
specifier: ^13.13.2
|
specifier: ^13.13.2
|
||||||
version: 13.13.2
|
version: 13.13.2
|
||||||
|
|
@ -66,6 +78,9 @@ importers:
|
||||||
happy-dom:
|
happy-dom:
|
||||||
specifier: ^14.12.3
|
specifier: ^14.12.3
|
||||||
version: 14.12.3
|
version: 14.12.3
|
||||||
|
html:
|
||||||
|
specifier: ^1.0.0
|
||||||
|
version: 1.0.0
|
||||||
lint-staged:
|
lint-staged:
|
||||||
specifier: ^15.2.8
|
specifier: ^15.2.8
|
||||||
version: 15.2.8
|
version: 15.2.8
|
||||||
|
|
@ -518,6 +533,11 @@ packages:
|
||||||
resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==}
|
resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==}
|
||||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||||
|
|
||||||
|
'@playwright/test@1.46.0':
|
||||||
|
resolution: {integrity: sha512-/QYft5VArOrGRP5pgkrfKksqsKA6CEFyGQ/gjNe6q0y4tZ1aaPfq4gIjudr1s3D+pXyrPRdsy4opKDrjBabE5w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.20.0':
|
'@rollup/rollup-android-arm-eabi@4.20.0':
|
||||||
resolution: {integrity: sha512-TSpWzflCc4VGAUJZlPpgAJE1+V60MePDQnBd7PPkpuEmOy8i87aL6tinFGKBFKuEDikYpig72QzdT3QPYIi+oA==}
|
resolution: {integrity: sha512-TSpWzflCc4VGAUJZlPpgAJE1+V60MePDQnBd7PPkpuEmOy8i87aL6tinFGKBFKuEDikYpig72QzdT3QPYIi+oA==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
|
|
@ -673,6 +693,9 @@ packages:
|
||||||
'@types/estree@1.0.5':
|
'@types/estree@1.0.5':
|
||||||
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
|
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
|
||||||
|
|
||||||
|
'@types/html@1.0.4':
|
||||||
|
resolution: {integrity: sha512-Wb1ymSAftCLxhc3D6vS0Ike/0xg7W6c+DQxAkerU6pD7C8CMzTYwvrwnlcrTfsVO/nMelB9KOKIT7+N5lOeQUg==}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
|
|
@ -1093,6 +1116,9 @@ packages:
|
||||||
buffer-crc32@0.2.13:
|
buffer-crc32@0.2.13:
|
||||||
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
||||||
|
|
||||||
|
buffer-from@1.1.2:
|
||||||
|
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||||
|
|
||||||
buffer@5.7.1:
|
buffer@5.7.1:
|
||||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||||
|
|
||||||
|
|
@ -1258,6 +1284,10 @@ packages:
|
||||||
concat-map@0.0.1:
|
concat-map@0.0.1:
|
||||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||||
|
|
||||||
|
concat-stream@1.6.2:
|
||||||
|
resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==}
|
||||||
|
engines: {'0': node >= 0.8}
|
||||||
|
|
||||||
concurrently@8.2.2:
|
concurrently@8.2.2:
|
||||||
resolution: {integrity: sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==}
|
resolution: {integrity: sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==}
|
||||||
engines: {node: ^14.13.0 || >=16.0.0}
|
engines: {node: ^14.13.0 || >=16.0.0}
|
||||||
|
|
@ -1285,6 +1315,11 @@ packages:
|
||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
cross-env@7.0.3:
|
||||||
|
resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
|
||||||
|
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
cross-spawn@7.0.3:
|
cross-spawn@7.0.3:
|
||||||
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
|
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
@ -1809,6 +1844,11 @@ packages:
|
||||||
fs.realpath@1.0.0:
|
fs.realpath@1.0.0:
|
||||||
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
|
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
|
@ -1955,6 +1995,10 @@ packages:
|
||||||
html-escaper@2.0.2:
|
html-escaper@2.0.2:
|
||||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||||
|
|
||||||
|
html@1.0.0:
|
||||||
|
resolution: {integrity: sha512-lw/7YsdKiP3kk5PnR1INY17iJuzdAtJewxr14ozKJWbbR97znovZ0mh+WEMZ8rjc3lgTK+ID/htTjuyGKB52Kw==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
http-signature@1.3.6:
|
http-signature@1.3.6:
|
||||||
resolution: {integrity: sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==}
|
resolution: {integrity: sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==}
|
||||||
engines: {node: '>=0.10'}
|
engines: {node: '>=0.10'}
|
||||||
|
|
@ -2150,6 +2194,9 @@ packages:
|
||||||
is-weakset@2.0.2:
|
is-weakset@2.0.2:
|
||||||
resolution: {integrity: sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==}
|
resolution: {integrity: sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==}
|
||||||
|
|
||||||
|
isarray@1.0.0:
|
||||||
|
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||||
|
|
||||||
isarray@2.0.5:
|
isarray@2.0.5:
|
||||||
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
||||||
|
|
||||||
|
|
@ -2697,6 +2744,16 @@ packages:
|
||||||
pkg-types@1.0.3:
|
pkg-types@1.0.3:
|
||||||
resolution: {integrity: sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==}
|
resolution: {integrity: sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==}
|
||||||
|
|
||||||
|
playwright-core@1.46.0:
|
||||||
|
resolution: {integrity: sha512-9Y/d5UIwuJk8t3+lhmMSAJyNP1BUC/DqP3cQJDQQL/oWqAiuPTLgy7Q5dzglmTLwcBRdetzgNM/gni7ckfTr6A==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
playwright@1.46.0:
|
||||||
|
resolution: {integrity: sha512-XYJ5WvfefWONh1uPAUAi0H2xXV5S3vrtcnXe6uAOgdGi3aSpqOSXX08IAjXW34xitfuOJsvXU5anXZxPSEQiJw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
pluralize@8.0.0:
|
pluralize@8.0.0:
|
||||||
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
|
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
@ -2726,6 +2783,9 @@ packages:
|
||||||
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
||||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||||
|
|
||||||
|
process-nextick-args@2.0.1:
|
||||||
|
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||||
|
|
||||||
process@0.11.10:
|
process@0.11.10:
|
||||||
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
||||||
engines: {node: '>= 0.6.0'}
|
engines: {node: '>= 0.6.0'}
|
||||||
|
|
@ -2771,6 +2831,9 @@ packages:
|
||||||
resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==}
|
resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
readable-stream@2.3.8:
|
||||||
|
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||||
|
|
||||||
redent@4.0.0:
|
redent@4.0.0:
|
||||||
resolution: {integrity: sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==}
|
resolution: {integrity: sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
@ -2851,6 +2914,9 @@ packages:
|
||||||
rxjs@7.8.1:
|
rxjs@7.8.1:
|
||||||
resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==}
|
resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==}
|
||||||
|
|
||||||
|
safe-buffer@5.1.2:
|
||||||
|
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||||
|
|
||||||
safe-buffer@5.2.1:
|
safe-buffer@5.2.1:
|
||||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||||
|
|
||||||
|
|
@ -3015,6 +3081,9 @@ packages:
|
||||||
resolution: {integrity: sha512-GPQHj7row82Hjo9hKZieKcHIhaAIKOJvFSIZXuCU9OASVZrMNUaZuz++SPVrBjnLsnk4k+z9f2EIypgxf2vNFw==}
|
resolution: {integrity: sha512-GPQHj7row82Hjo9hKZieKcHIhaAIKOJvFSIZXuCU9OASVZrMNUaZuz++SPVrBjnLsnk4k+z9f2EIypgxf2vNFw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
string_decoder@1.1.1:
|
||||||
|
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||||
|
|
||||||
strip-ansi@6.0.1:
|
strip-ansi@6.0.1:
|
||||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
@ -3179,6 +3248,9 @@ packages:
|
||||||
resolution: {integrity: sha512-spAaHzc6qre0TlZQQ2aA/nGMe+2Z/wyGk5Z+Ru2VUfdNwT6kWO6TjevOlpebsATEG1EIQ2sOiDszud3lO5mt/Q==}
|
resolution: {integrity: sha512-spAaHzc6qre0TlZQQ2aA/nGMe+2Z/wyGk5Z+Ru2VUfdNwT6kWO6TjevOlpebsATEG1EIQ2sOiDszud3lO5mt/Q==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
|
typedarray@0.0.6:
|
||||||
|
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
|
||||||
|
|
||||||
typescript@5.5.4:
|
typescript@5.5.4:
|
||||||
resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==}
|
resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==}
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
|
|
@ -3815,6 +3887,10 @@ snapshots:
|
||||||
|
|
||||||
'@pkgr/core@0.1.1': {}
|
'@pkgr/core@0.1.1': {}
|
||||||
|
|
||||||
|
'@playwright/test@1.46.0':
|
||||||
|
dependencies:
|
||||||
|
playwright: 1.46.0
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.20.0':
|
'@rollup/rollup-android-arm-eabi@4.20.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3967,6 +4043,8 @@ snapshots:
|
||||||
|
|
||||||
'@types/estree@1.0.5': {}
|
'@types/estree@1.0.5': {}
|
||||||
|
|
||||||
|
'@types/html@1.0.4': {}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
'@types/mdast@3.0.15':
|
'@types/mdast@3.0.15':
|
||||||
|
|
@ -4470,6 +4548,8 @@ snapshots:
|
||||||
|
|
||||||
buffer-crc32@0.2.13: {}
|
buffer-crc32@0.2.13: {}
|
||||||
|
|
||||||
|
buffer-from@1.1.2: {}
|
||||||
|
|
||||||
buffer@5.7.1:
|
buffer@5.7.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
base64-js: 1.5.1
|
base64-js: 1.5.1
|
||||||
|
|
@ -4608,6 +4688,13 @@ snapshots:
|
||||||
|
|
||||||
concat-map@0.0.1: {}
|
concat-map@0.0.1: {}
|
||||||
|
|
||||||
|
concat-stream@1.6.2:
|
||||||
|
dependencies:
|
||||||
|
buffer-from: 1.1.2
|
||||||
|
inherits: 2.0.4
|
||||||
|
readable-stream: 2.3.8
|
||||||
|
typedarray: 0.0.6
|
||||||
|
|
||||||
concurrently@8.2.2:
|
concurrently@8.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
|
|
@ -4642,6 +4729,10 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.5.4
|
typescript: 5.5.4
|
||||||
|
|
||||||
|
cross-env@7.0.3:
|
||||||
|
dependencies:
|
||||||
|
cross-spawn: 7.0.3
|
||||||
|
|
||||||
cross-spawn@7.0.3:
|
cross-spawn@7.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
|
|
@ -5320,6 +5411,9 @@ snapshots:
|
||||||
|
|
||||||
fs.realpath@1.0.0: {}
|
fs.realpath@1.0.0: {}
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -5463,6 +5557,10 @@ snapshots:
|
||||||
|
|
||||||
html-escaper@2.0.2: {}
|
html-escaper@2.0.2: {}
|
||||||
|
|
||||||
|
html@1.0.0:
|
||||||
|
dependencies:
|
||||||
|
concat-stream: 1.6.2
|
||||||
|
|
||||||
http-signature@1.3.6:
|
http-signature@1.3.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
assert-plus: 1.0.0
|
assert-plus: 1.0.0
|
||||||
|
|
@ -5634,6 +5732,8 @@ snapshots:
|
||||||
call-bind: 1.0.5
|
call-bind: 1.0.5
|
||||||
get-intrinsic: 1.2.2
|
get-intrinsic: 1.2.2
|
||||||
|
|
||||||
|
isarray@1.0.0: {}
|
||||||
|
|
||||||
isarray@2.0.5: {}
|
isarray@2.0.5: {}
|
||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
|
|
@ -6198,6 +6298,14 @@ snapshots:
|
||||||
mlly: 1.4.2
|
mlly: 1.4.2
|
||||||
pathe: 1.1.1
|
pathe: 1.1.1
|
||||||
|
|
||||||
|
playwright-core@1.46.0: {}
|
||||||
|
|
||||||
|
playwright@1.46.0:
|
||||||
|
dependencies:
|
||||||
|
playwright-core: 1.46.0
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.2
|
||||||
|
|
||||||
pluralize@8.0.0: {}
|
pluralize@8.0.0: {}
|
||||||
|
|
||||||
postcss-selector-parser@6.1.1:
|
postcss-selector-parser@6.1.1:
|
||||||
|
|
@ -6223,6 +6331,8 @@ snapshots:
|
||||||
ansi-styles: 5.2.0
|
ansi-styles: 5.2.0
|
||||||
react-is: 17.0.2
|
react-is: 17.0.2
|
||||||
|
|
||||||
|
process-nextick-args@2.0.1: {}
|
||||||
|
|
||||||
process@0.11.10: {}
|
process@0.11.10: {}
|
||||||
|
|
||||||
prompts@2.4.2:
|
prompts@2.4.2:
|
||||||
|
|
@ -6266,6 +6376,16 @@ snapshots:
|
||||||
parse-json: 5.2.0
|
parse-json: 5.2.0
|
||||||
type-fest: 0.6.0
|
type-fest: 0.6.0
|
||||||
|
|
||||||
|
readable-stream@2.3.8:
|
||||||
|
dependencies:
|
||||||
|
core-util-is: 1.0.2
|
||||||
|
inherits: 2.0.4
|
||||||
|
isarray: 1.0.0
|
||||||
|
process-nextick-args: 2.0.1
|
||||||
|
safe-buffer: 5.1.2
|
||||||
|
string_decoder: 1.1.1
|
||||||
|
util-deprecate: 1.0.2
|
||||||
|
|
||||||
redent@4.0.0:
|
redent@4.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
indent-string: 5.0.0
|
indent-string: 5.0.0
|
||||||
|
|
@ -6357,6 +6477,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.6.2
|
tslib: 2.6.2
|
||||||
|
|
||||||
|
safe-buffer@5.1.2: {}
|
||||||
|
|
||||||
safe-buffer@5.2.1: {}
|
safe-buffer@5.2.1: {}
|
||||||
|
|
||||||
safer-buffer@2.1.2: {}
|
safer-buffer@2.1.2: {}
|
||||||
|
|
@ -6529,6 +6651,10 @@ snapshots:
|
||||||
get-east-asian-width: 1.2.0
|
get-east-asian-width: 1.2.0
|
||||||
strip-ansi: 7.1.0
|
strip-ansi: 7.1.0
|
||||||
|
|
||||||
|
string_decoder@1.1.1:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.1.2
|
||||||
|
|
||||||
strip-ansi@6.0.1:
|
strip-ansi@6.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-regex: 5.0.1
|
ansi-regex: 5.0.1
|
||||||
|
|
@ -6683,6 +6809,8 @@ snapshots:
|
||||||
|
|
||||||
type-fest@4.24.0: {}
|
type-fest@4.24.0: {}
|
||||||
|
|
||||||
|
typedarray@0.0.6: {}
|
||||||
|
|
||||||
typescript@5.5.4: {}
|
typescript@5.5.4: {}
|
||||||
|
|
||||||
ufo@1.3.2: {}
|
ufo@1.3.2: {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
# -------------------------------------------------------------------------------#
|
||||||
|
# Qodana analysis is configured by qodana.yaml file #
|
||||||
|
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
|
||||||
|
# -------------------------------------------------------------------------------#
|
||||||
|
version: '1.0'
|
||||||
|
|
||||||
|
# Specify inspection profile for code analysis
|
||||||
|
profile:
|
||||||
|
name: qodana.starter
|
||||||
|
|
||||||
|
# Enable inspections
|
||||||
|
# include:
|
||||||
|
# - name: <SomeEnabledInspectionId>
|
||||||
|
|
||||||
|
# Disable inspections
|
||||||
|
# exclude:
|
||||||
|
# - name: <SomeDisabledInspectionId>
|
||||||
|
# paths:
|
||||||
|
# - <path/where/not/run/inspection>
|
||||||
|
|
||||||
|
# Execute shell command before Qodana execution (Applied in CI/CD pipeline)
|
||||||
|
# bootstrap: sh ./prepare-qodana.sh
|
||||||
|
|
||||||
|
# Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
|
||||||
|
# plugins:
|
||||||
|
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
|
||||||
|
|
||||||
|
# Specify Qodana linter for analysis (Applied in CI/CD pipeline)
|
||||||
|
linter: jetbrains/qodana-jvm:latest
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<ul class="pagination">
|
<ul data-testid="pagination" class="pagination">
|
||||||
<li
|
<li
|
||||||
v-for="pageNumber in pagesCount"
|
v-for="pageNumber in pagesCount"
|
||||||
:key="pageNumber"
|
:key="pageNumber"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="banner">
|
<div class="banner" data-testid="article-banner">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>{{ article.title }}</h1>
|
<h1>{{ article.title }}</h1>
|
||||||
|
|
||||||
|
|
@ -14,7 +14,12 @@
|
||||||
<div class="container page">
|
<div class="container page">
|
||||||
<div class="row article-content">
|
<div class="row article-content">
|
||||||
<!-- eslint-disable vue/no-v-html -->
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
<div id="article-content" class="col-md-12" v-html="articleHandledBody" />
|
<div
|
||||||
|
id="article-content"
|
||||||
|
data-testid="article-body"
|
||||||
|
class="col-md-12"
|
||||||
|
v-html="articleHandledBody"
|
||||||
|
/>
|
||||||
<!-- eslint-enable vue/no-v-html -->
|
<!-- eslint-enable vue/no-v-html -->
|
||||||
|
|
||||||
<!-- TODO: abstract tag list component -->
|
<!-- TODO: abstract tag list component -->
|
||||||
|
|
@ -23,6 +28,7 @@
|
||||||
v-for="tag in article.tagList"
|
v-for="tag in article.tagList"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
class="tag-default tag-pill tag-outline"
|
class="tag-default tag-pill tag-outline"
|
||||||
|
data-testid="article-tag"
|
||||||
>
|
>
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -31,7 +37,7 @@
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<div class="article-actions">
|
<div class="article-actions" data-testid="article-actions">
|
||||||
<ArticleDetailMeta
|
<ArticleDetailMeta
|
||||||
v-if="article"
|
v-if="article"
|
||||||
:article="article"
|
:article="article"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="article-preview">
|
<div data-testid="article-preview" class="article-preview">
|
||||||
<div class="article-meta">
|
<div class="article-meta">
|
||||||
<AppLink
|
<AppLink
|
||||||
name="profile"
|
name="profile"
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
class="preview-link"
|
class="preview-link"
|
||||||
>
|
>
|
||||||
<h1>{{ article.title }}</h1>
|
<h1>{{ article.title }}</h1>
|
||||||
<p>{{ article.description }}</p>
|
<p data-testid="article-description">{{ article.description }}</p>
|
||||||
<span>Read more...</span>
|
<span>Read more...</span>
|
||||||
<ul class="tag-list">
|
<ul class="tag-list">
|
||||||
<li
|
<li
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
exports[`# ArticleDetail > should filter the xss content in Markdown body 1`] = `
|
exports[`# ArticleDetail > should filter the xss content in Markdown body 1`] = `
|
||||||
<div
|
<div
|
||||||
class="col-md-12"
|
class="col-md-12"
|
||||||
|
data-testid="article-body"
|
||||||
id="article-content"
|
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`] = `
|
exports[`# ArticleDetail > should render markdown (zh-CN) body correctly 1`] = `
|
||||||
<div
|
<div
|
||||||
class="col-md-12"
|
class="col-md-12"
|
||||||
|
data-testid="article-body"
|
||||||
id="article-content"
|
id="article-content"
|
||||||
>
|
>
|
||||||
<h1>
|
<h1>
|
||||||
|
|
@ -1201,6 +1203,7 @@ D-->>A: Dashed open arrow
|
||||||
exports[`# ArticleDetail > should render markdown body correctly 1`] = `
|
exports[`# ArticleDetail > should render markdown body correctly 1`] = `
|
||||||
<div
|
<div
|
||||||
class="col-md-12"
|
class="col-md-12"
|
||||||
|
data-testid="article-body"
|
||||||
id="article-content"
|
id="article-content"
|
||||||
>
|
>
|
||||||
<h1>
|
<h1>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
"include": [
|
"include": [
|
||||||
"vite.config.ts",
|
"vite.config.ts",
|
||||||
"cypress.config.ts",
|
"cypress.config.ts",
|
||||||
|
"playwright.config.ts",
|
||||||
"eslint.config.js"
|
"eslint.config.js"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ export default defineConfig({
|
||||||
analyzer({ summaryOnly: true }),
|
analyzer({ summaryOnly: true }),
|
||||||
],
|
],
|
||||||
test: {
|
test: {
|
||||||
|
include: [
|
||||||
|
'src/**/*.spec.ts',
|
||||||
|
],
|
||||||
environment: 'happy-dom',
|
environment: 'happy-dom',
|
||||||
setupFiles: './src/setupTests.ts',
|
setupFiles: './src/setupTests.ts',
|
||||||
globals: true,
|
globals: true,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue