diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml new file mode 100644 index 00000000..65b195e9 --- /dev/null +++ b/.github/workflows/code_quality.yml @@ -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 }} diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml deleted file mode 100644 index 766de744..00000000 --- a/.github/workflows/pull-request.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ea03ded1..3a22c718 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,6 +4,9 @@ on: push: paths-ignore: - '**.md' + pull_request: + paths-ignore: + - '**.md' env: CI: true @@ -63,8 +66,8 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} - e2e_tests: - name: E2E test + cypress_e2e_tests: + name: Cypress E2E test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -99,4 +102,37 @@ jobs: run: pnpm cypress install - 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 diff --git a/.gitignore b/.gitignore index 38adffa6..7bfad8c6 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,11 @@ coverage /cypress/videos/ /cypress/screenshots/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/cypress/fixtures/article.json b/cypress/fixtures/article.json index 529cd94a..25bf52d8 100644 --- a/cypress/fixtures/article.json +++ b/cypress/fixtures/article.json @@ -5,7 +5,7 @@ "body": "# Article body\n\nThis is **Strong** text", "createdAt": "2020-11-01T14:59:39.404Z", "updatedAt": "2020-11-01T14:59:39.404Z", - "tagList": [], + "tagList": ["foo", "bar"], "description": "this is descripion", "author": { "username": "plumrx", diff --git a/eslint.config.js b/eslint.config.js index a1aaccbf..8d5fc8e3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,6 +6,7 @@ export default defineConfig({ 'tsconfig.json', 'tsconfig.node.json', 'cypress/e2e/tsconfig.json', + 'playwright/tsconfig.json', ], }, vue: { @@ -27,4 +28,12 @@ export default defineConfig({ rules: { 'ts/method-signature-style': 'off', }, +}, { + files: [ + '*.config.ts', + 'playwright/**/*', + ], + rules: { + 'node/prefer-global/process': 'off', + }, }) diff --git a/index.html b/index.html index 08e2c793..6142a68f 100644 --- a/index.html +++ b/index.html @@ -6,10 +6,10 @@ - - + + - +
diff --git a/package.json b/package.json index 8d7e4d0f..8ed3d2ce 100644 --- a/package.json +++ b/package.json @@ -5,16 +5,19 @@ "type": "module", "scripts": { "prepare": "simple-git-hooks", - "dev": "vite", + "dev": "vite --port 4173", "build": "vite build", "serve": "vite preview --port 4173", "type-check": "vue-tsc --noEmit", "lint": "eslint --fix .", - "test": "npm run test:unit && npm run test:e2e:ci", - "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:e2e:local": "cypress open --e2e -c baseUrl=http://localhost:5173", - "test:e2e:prod": "cypress run --e2e -c baseUrl=https://vue3-realworld-example-app-mutoe.vercel.app", + "test": "npm run test:unit && npm run test:playwright", + "test:cypress": "npm run build && concurrently -rk -s first \"npm run serve\" \"cypress run --e2e", + "test:cypress:ui": "cypress open --e2e", + "test:cyprsss: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", "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": { "@mutoe/eslint-config": "^2.8.3", "@pinia/testing": "^0.1.5", + "@playwright/test": "^1.46.0", "@testing-library/cypress": "^10.0.2", "@testing-library/user-event": "^14.5.2", "@testing-library/vue": "^8.1.0", + "@types/html": "^1.0.4", + "@types/node": "^22.1.0", "@vitejs/plugin-vue": "^5.1.2", "@vitest/coverage-v8": "^2.0.5", "concurrently": "^8.2.2", + "cross-env": "^7.0.3", "cypress": "^13.13.2", "eslint": "^8.57.0", "eslint-plugin-cypress": "^3.4.0", "eslint-plugin-vitest": "^0.5.4", "eslint-plugin-vue": "^9.27.0", "happy-dom": "^14.12.3", + "html": "^1.0.0", "lint-staged": "^15.2.8", "msw": "^2.3.5", "rollup-plugin-analyzer": "^4.0.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..738b6436 --- /dev/null +++ b/playwright.config.ts @@ -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, + }, +}) diff --git a/playwright/constant.ts b/playwright/constant.ts new file mode 100644 index 00000000..0f2a523f --- /dev/null +++ b/playwright/constant.ts @@ -0,0 +1,8 @@ +export enum Route { + Home = '/#/', + Login = '/#/login', + Register = '/#/register', + Settings = '/#/settings', + ArticleCreate = '/#/article/create', + ArticleDetail = '/#/article/article-title', +} diff --git a/playwright/extends.ts b/playwright/extends.ts new file mode 100644 index 00000000..c1113b2b --- /dev/null +++ b/playwright/extends.ts @@ -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 diff --git a/playwright/page-objects/article-detail.page-object.ts b/playwright/page-objects/article-detail.page-object.ts new file mode 100644 index 00000000..68e08b0a --- /dev/null +++ b/playwright/page-objects/article-detail.page-object.ts @@ -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') + } +} diff --git a/playwright/page-objects/conduit.page-object.ts b/playwright/page-objects/conduit.page-object.ts new file mode 100644 index 00000000..190693ef --- /dev/null +++ b/playwright/page-objects/conduit.page-object.ts @@ -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> { + 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(fixture: string): Promise { + 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) + } +} diff --git a/playwright/page-objects/edit-article.page-object.ts b/playwright/page-objects/edit-article.page-object.ts new file mode 100644 index 00000000..fed9cd4b --- /dev/null +++ b/playwright/page-objects/edit-article.page-object.ts @@ -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') + } +} diff --git a/playwright/page-objects/login.page-object.ts b/playwright/page-objects/login.page-object.ts new file mode 100644 index 00000000..ffd93711 --- /dev/null +++ b/playwright/page-objects/login.page-object.ts @@ -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') + } +} diff --git a/playwright/page-objects/register.page-object.ts b/playwright/page-objects/register.page-object.ts new file mode 100644 index 00000000..c6db96a7 --- /dev/null +++ b/playwright/page-objects/register.page-object.ts @@ -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') + } +} diff --git a/playwright/specs/article.spec.ts b/playwright/specs/article.spec.ts new file mode 100644 index 00000000..79f8d8e2 --- /dev/null +++ b/playwright/specs/article.spec.ts @@ -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') + }) +}) diff --git a/playwright/specs/article.spec.ts-snapshots/markdown-render-chromium-darwin.html b/playwright/specs/article.spec.ts-snapshots/markdown-render-chromium-darwin.html new file mode 100644 index 00000000..c4567bb8 --- /dev/null +++ b/playwright/specs/article.spec.ts-snapshots/markdown-render-chromium-darwin.html @@ -0,0 +1,8 @@ +
+

Article body

+

This is Strong text

+
+
    +
  • foo
  • +
  • bar
  • +
diff --git a/playwright/specs/article.spec.ts-snapshots/popular-tags-in-home-page-chromium-darwin.json b/playwright/specs/article.spec.ts-snapshots/popular-tags-in-home-page-chromium-darwin.json new file mode 100644 index 00000000..bc819d6e --- /dev/null +++ b/playwright/specs/article.spec.ts-snapshots/popular-tags-in-home-page-chromium-darwin.json @@ -0,0 +1,10 @@ +[ + "HuManIty", + "Gandhi", + "HITLER", + "SIDA", + "BlackLivesMatter", + "test", + "dragons", + "butt" +] \ No newline at end of file diff --git a/playwright/specs/article.spec.ts-snapshots/popular-tags-in-home-page-firefox-darwin.json b/playwright/specs/article.spec.ts-snapshots/popular-tags-in-home-page-firefox-darwin.json new file mode 100644 index 00000000..bc819d6e --- /dev/null +++ b/playwright/specs/article.spec.ts-snapshots/popular-tags-in-home-page-firefox-darwin.json @@ -0,0 +1,10 @@ +[ + "HuManIty", + "Gandhi", + "HITLER", + "SIDA", + "BlackLivesMatter", + "test", + "dragons", + "butt" +] \ No newline at end of file diff --git a/playwright/specs/article.spec.ts-snapshots/popular-tags-in-home-page-webkit-darwin.json b/playwright/specs/article.spec.ts-snapshots/popular-tags-in-home-page-webkit-darwin.json new file mode 100644 index 00000000..bc819d6e --- /dev/null +++ b/playwright/specs/article.spec.ts-snapshots/popular-tags-in-home-page-webkit-darwin.json @@ -0,0 +1,10 @@ +[ + "HuManIty", + "Gandhi", + "HITLER", + "SIDA", + "BlackLivesMatter", + "test", + "dragons", + "butt" +] \ No newline at end of file diff --git a/playwright/specs/auth.spec.ts b/playwright/specs/auth.spec.ts new file mode 100644 index 00000000..239653c6 --- /dev/null +++ b/playwright/specs/auth.spec.ts @@ -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) + }) +}) diff --git a/playwright/specs/home.spec.ts b/playwright/specs/home.spec.ts new file mode 100644 index 00000000..8c29b73b --- /dev/null +++ b/playwright/specs/home.spec.ts @@ -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') + }) +}) diff --git a/playwright/specs/user.spec.ts b/playwright/specs/user.spec.ts new file mode 100644 index 00000000..15cf42f7 --- /dev/null +++ b/playwright/specs/user.spec.ts @@ -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() + }) +}) diff --git a/playwright/tsconfig.json b/playwright/tsconfig.json new file mode 100644 index 00000000..cbf4ba7f --- /dev/null +++ b/playwright/tsconfig.json @@ -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": [ + "./**/*" + ] +} diff --git a/playwright/utils/prettify.ts b/playwright/utils/prettify.ts new file mode 100644 index 00000000..f178b4cf --- /dev/null +++ b/playwright/utils/prettify.ts @@ -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) +} diff --git a/playwright/utils/test-decorators.ts b/playwright/utils/test-decorators.ts new file mode 100644 index 00000000..ecab02b1 --- /dev/null +++ b/playwright/utils/test-decorators.ts @@ -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 }) + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e516158..e0f7c7f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,9 @@ importers: '@pinia/testing': 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)) + '@playwright/test': + specifier: ^1.46.0 + version: 1.46.0 '@testing-library/cypress': specifier: ^10.0.2 version: 10.0.2(cypress@13.13.2) @@ -39,6 +42,12 @@ importers: '@testing-library/vue': 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)) + '@types/html': + specifier: ^1.0.4 + version: 1.0.4 + '@types/node': + specifier: ^22.1.0 + version: 22.1.0 '@vitejs/plugin-vue': 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)) @@ -48,6 +57,9 @@ importers: concurrently: specifier: ^8.2.2 version: 8.2.2 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 cypress: specifier: ^13.13.2 version: 13.13.2 @@ -66,6 +78,9 @@ importers: happy-dom: specifier: ^14.12.3 version: 14.12.3 + html: + specifier: ^1.0.0 + version: 1.0.0 lint-staged: specifier: ^15.2.8 version: 15.2.8 @@ -518,6 +533,11 @@ packages: resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} 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': resolution: {integrity: sha512-TSpWzflCc4VGAUJZlPpgAJE1+V60MePDQnBd7PPkpuEmOy8i87aL6tinFGKBFKuEDikYpig72QzdT3QPYIi+oA==} cpu: [arm] @@ -673,6 +693,9 @@ packages: '@types/estree@1.0.5': 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': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1093,6 +1116,9 @@ packages: buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -1258,6 +1284,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concat-stream@1.6.2: + resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} + engines: {'0': node >= 0.8} + concurrently@8.2.2: resolution: {integrity: sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==} engines: {node: ^14.13.0 || >=16.0.0} @@ -1285,6 +1315,11 @@ packages: typescript: 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: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} @@ -1809,6 +1844,11 @@ packages: fs.realpath@1.0.0: 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: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1955,6 +1995,10 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html@1.0.0: + resolution: {integrity: sha512-lw/7YsdKiP3kk5PnR1INY17iJuzdAtJewxr14ozKJWbbR97znovZ0mh+WEMZ8rjc3lgTK+ID/htTjuyGKB52Kw==} + hasBin: true + http-signature@1.3.6: resolution: {integrity: sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==} engines: {node: '>=0.10'} @@ -2150,6 +2194,9 @@ packages: is-weakset@2.0.2: resolution: {integrity: sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -2697,6 +2744,16 @@ packages: pkg-types@1.0.3: 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: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -2726,6 +2783,9 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} 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: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} @@ -2771,6 +2831,9 @@ packages: resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==} engines: {node: '>=8'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + redent@4.0.0: resolution: {integrity: sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==} engines: {node: '>=12'} @@ -2851,6 +2914,9 @@ packages: rxjs@7.8.1: 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: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -3015,6 +3081,9 @@ packages: resolution: {integrity: sha512-GPQHj7row82Hjo9hKZieKcHIhaAIKOJvFSIZXuCU9OASVZrMNUaZuz++SPVrBjnLsnk4k+z9f2EIypgxf2vNFw==} engines: {node: '>=18'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -3179,6 +3248,9 @@ packages: resolution: {integrity: sha512-spAaHzc6qre0TlZQQ2aA/nGMe+2Z/wyGk5Z+Ru2VUfdNwT6kWO6TjevOlpebsATEG1EIQ2sOiDszud3lO5mt/Q==} engines: {node: '>=16'} + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typescript@5.5.4: resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==} engines: {node: '>=14.17'} @@ -3815,6 +3887,10 @@ snapshots: '@pkgr/core@0.1.1': {} + '@playwright/test@1.46.0': + dependencies: + playwright: 1.46.0 + '@rollup/rollup-android-arm-eabi@4.20.0': optional: true @@ -3967,6 +4043,8 @@ snapshots: '@types/estree@1.0.5': {} + '@types/html@1.0.4': {} + '@types/json-schema@7.0.15': {} '@types/mdast@3.0.15': @@ -4470,6 +4548,8 @@ snapshots: buffer-crc32@0.2.13: {} + buffer-from@1.1.2: {} + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -4608,6 +4688,13 @@ snapshots: 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: dependencies: chalk: 4.1.2 @@ -4642,6 +4729,10 @@ snapshots: optionalDependencies: typescript: 5.5.4 + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.3 + cross-spawn@7.0.3: dependencies: path-key: 3.1.1 @@ -5320,6 +5411,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -5463,6 +5557,10 @@ snapshots: html-escaper@2.0.2: {} + html@1.0.0: + dependencies: + concat-stream: 1.6.2 + http-signature@1.3.6: dependencies: assert-plus: 1.0.0 @@ -5634,6 +5732,8 @@ snapshots: call-bind: 1.0.5 get-intrinsic: 1.2.2 + isarray@1.0.0: {} + isarray@2.0.5: {} isexe@2.0.0: {} @@ -6198,6 +6298,14 @@ snapshots: mlly: 1.4.2 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: {} postcss-selector-parser@6.1.1: @@ -6223,6 +6331,8 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + process-nextick-args@2.0.1: {} + process@0.11.10: {} prompts@2.4.2: @@ -6266,6 +6376,16 @@ snapshots: parse-json: 5.2.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: dependencies: indent-string: 5.0.0 @@ -6357,6 +6477,8 @@ snapshots: dependencies: tslib: 2.6.2 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safer-buffer@2.1.2: {} @@ -6529,6 +6651,10 @@ snapshots: get-east-asian-width: 1.2.0 strip-ansi: 7.1.0 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -6683,6 +6809,8 @@ snapshots: type-fest@4.24.0: {} + typedarray@0.0.6: {} + typescript@5.5.4: {} ufo@1.3.2: {} diff --git a/qodana.yaml b/qodana.yaml new file mode 100644 index 00000000..535cee4e --- /dev/null +++ b/qodana.yaml @@ -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: + +# Disable inspections +# exclude: +# - name: +# paths: +# - + +# 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 can be found at https://plugins.jetbrains.com) + +# Specify Qodana linter for analysis (Applied in CI/CD pipeline) +linter: jetbrains/qodana-jvm:latest diff --git a/src/components/AppPagination.vue b/src/components/AppPagination.vue index a2a545a0..852d0243 100644 --- a/src/components/AppPagination.vue +++ b/src/components/AppPagination.vue @@ -1,5 +1,5 @@