Added Playwright tests to comments-ui

refs https://github.com/TryGhost/Team/issues/3504

Not complete yet, but contains the basic structure and a few tests that work and should run in CI.
This commit is contained in:
Simon Backx 2023-06-22 13:55:05 +02:00 committed by Simon Backx
parent ab86e0d901
commit 8d6fb51908
16 changed files with 541 additions and 16 deletions

View File

@ -32,6 +32,9 @@ jobs:
- run: yarn --prefer-offline
- name: Install Playwright
run: npx playwright install --with-deps
- run: yarn workspace @tryghost/comments-ui run test
- name: Upload test results

3
.gitignore vendored
View File

@ -127,6 +127,9 @@ Caddyfile
# Comments-UI
/apps/comments-ui/umd
/apps/comments-ui/playwright-report
/ghost/comments-ui/playwright/.cache/
/ghost/comments-ui/test-results/
# Portal
!/apps/portal/.env

View File

@ -16,12 +16,14 @@
},
"scripts": {
"dev": "concurrently \"yarn preview -l silent\" \"yarn build:watch\"",
"dev:test": "vite build && vite preview --port 7175",
"build": "vite build",
"build:watch": "vite build --watch",
"preview": "vite preview",
"test": "vitest run",
"test:ci": "yarn test --coverage",
"test:unit": "yarn build",
"test": "vitest run --coverage && yarn test:e2e",
"test:e2e": "NODE_OPTIONS='--experimental-specifier-resolution=node --no-warnings' VITE_TEST=true playwright test",
"test:slowmo": "TIMEOUT=100000 PLAYWRIGHT_SLOWMO=1000 yarn test:e2e --headed",
"test:e2e:full": "ALL_BROWSERS=1 yarn test:e2e",
"lint": "eslint src --ext .js --cache",
"preship": "yarn lint",
"ship": "STATUS=$(git status --porcelain); echo $STATUS; if [ -z \"$STATUS\" ]; then yarn version; fi",
@ -55,6 +57,7 @@
"react-dom": "17.0.2"
},
"devDependencies": {
"@playwright/test": "1.35.1",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "12.1.5",
"@testing-library/user-event": "14.4.3",

View File

@ -0,0 +1,62 @@
import {defineConfig, devices} from '@playwright/test';
export const E2E_PORT = 7175;
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './test/e2e',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
timeout: process.env.PLAYWRIGHT_SLOWMO ? 100000 : 10000,
expect: {
timeout: process.env.PLAYWRIGHT_SLOWMO ? 100000 : 5000
},
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
launchOptions: {
slowMo: parseInt(process.env.PLAYWRIGHT_SLOWMO ?? '') || 0,
// force GPU hardware acceleration
// (even in headless mode)
args: ['--use-gl=egl']
}
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {...devices['Desktop Chrome']}
},
...(process.env.ALL_BROWSERS ? [{
name: 'firefox',
use: {...devices['Desktop Firefox']}
},
{
name: 'webkit',
use: {...devices['Desktop Safari']}
}] : [])
],
/* Run local dev server before starting the tests */
webServer: {
command: `yarn dev:test`,
url: `http://localhost:${E2E_PORT}/comments-ui.min.js`,
reuseExistingServer: !process.env.CI,
timeout: 10000
}
});

View File

@ -23,7 +23,7 @@ const CTABox = ({isFirst, isPaid}) => {
};
return (
<section className={`flex flex-col items-center pt-[40px] ${member ? 'pb-[32px]' : 'pb-[48px]'} ${!isFirst && 'mt-4'} border-y border-[rgba(0,0,0,0.075)] dark:border-[rgba(255,255,255,0.1)] sm:px-8`}>
<section className={`flex flex-col items-center pt-[40px] ${member ? 'pb-[32px]' : 'pb-[48px]'} ${!isFirst && 'mt-4'} border-y border-[rgba(0,0,0,0.075)] dark:border-[rgba(255,255,255,0.1)] sm:px-8`} data-testid="cta-box">
<h1 className={`mb-[8px] text-center font-sans text-[24px] tracking-tight text-black dark:text-[rgba(255,255,255,0.85)] ${isFirst ? 'font-semibold' : 'font-bold'}`}>
{titleText}
</h1>

View File

@ -7,12 +7,12 @@ const Count = ({showCount, count}) => {
if (count === 1) {
return (
<div className="text-[1.6rem] text-[rgba(0,0,0,0.5)] dark:text-[rgba(255,255,255,0.5)]">1 comment</div>
<div className="text-[1.6rem] text-[rgba(0,0,0,0.5)] dark:text-[rgba(255,255,255,0.5)]" data-testid="count">1 comment</div>
);
}
return (
<div className="text-[1.6rem] text-[rgba(0,0,0,0.5)] dark:text-[rgba(255,255,255,0.5)]">{formatNumber(count)} comments</div>
<div className="text-[1.6rem] text-[rgba(0,0,0,0.5)] dark:text-[rgba(255,255,255,0.5)]" data-testid="count">{formatNumber(count)} comments</div>
);
};
@ -34,7 +34,7 @@ const ContentTitle = ({title, showCount, count}) => {
return (
<div className="mb-8 flex w-full items-baseline justify-between font-sans">
<h2 className="text-[2.8rem] font-bold tracking-tight dark:text-[rgba(255,255,255,0.85)]">
<h2 className="text-[2.8rem] font-bold tracking-tight dark:text-[rgba(255,255,255,0.85)]" data-testid="title">
<Title title={title}/>
</h2>
<Count count={count} showCount={showCount} />

View File

@ -0,0 +1,62 @@
import {MockedApi, initialize} from '../utils/e2e';
import {expect, test} from '@playwright/test';
test.describe('CTA', async () => {
test('Shows CTA when not logged in', async ({page}) => {
const mockedApi = new MockedApi({});
mockedApi.addComments(2);
const {frame} = await initialize({
mockedApi,
page,
publication: 'Publisher Weekly'
});
const ctaBox = await frame.getByTestId('cta-box');
await expect(ctaBox).toBeVisible();
await expect(ctaBox).toContainText('Join the discussion');
await expect(ctaBox).toContainText('Become a member of Publisher Weekly to start commenting');
await expect(ctaBox).toContainText('Sign in');
});
test('Shows different CTA if no comments', async ({page}) => {
const mockedApi = new MockedApi({});
const {frame} = await initialize({
mockedApi,
page,
publication: 'Publisher Weekly'
});
const ctaBox = await frame.getByTestId('cta-box');
await expect(ctaBox).toBeVisible();
await expect(ctaBox).toContainText('Start the conversation');
});
test('Shows CTA when logged in, but not a paid member and comments are paid only', async ({page}) => {
const mockedApi = new MockedApi({});
mockedApi.addComments(2);
mockedApi.setMember({
status: 'free'
});
const {frame} = await initialize({
mockedApi,
page,
publication: 'Publisher Weekly',
commentsEnabled: 'paid'
});
const ctaBox = await frame.getByTestId('cta-box');
await expect(ctaBox).toBeVisible();
await expect(ctaBox).toContainText('Join the discussion');
await expect(ctaBox).toContainText('Become a paid member of Publisher Weekly to start commenting');
// Don't show sign in button
await expect(ctaBox).not.toContainText('Sign in');
});
});

View File

@ -0,0 +1,72 @@
import {MockedApi, initialize} from '../utils/e2e';
import {expect, test} from '@playwright/test';
test.describe('Options', async () => {
test('Shows the title and count', async ({page}) => {
const mockedApi = new MockedApi({});
mockedApi.addComments(2);
const {frame} = await initialize({
mockedApi,
page,
title: 'Leave a comment',
publication: 'Publisher Weekly',
count: true
});
// Check text 'Leave a comment' is present
await expect(frame.getByTestId('title')).toHaveText('Leave a comment');
await expect(frame.getByTestId('count')).toHaveText('2 comments');
});
test('Shows the title and singular count', async ({page}) => {
const mockedApi = new MockedApi({});
mockedApi.addComments(1);
const {frame} = await initialize({
mockedApi,
page,
title: 'Leave a comment',
publication: 'Publisher Weekly',
count: true
});
// Check text 'Leave a comment' is present
await expect(frame.getByTestId('title')).toHaveText('Leave a comment');
await expect(frame.getByTestId('count')).toHaveText('1 comment');
});
test('Shows the title but hides the count', async ({page}) => {
const mockedApi = new MockedApi({});
mockedApi.addComments(2);
const {frame} = await initialize({
mockedApi,
page,
title: 'Leave a comment',
publication: 'Publisher Weekly',
count: false
});
// Check text 'Leave a comment' is present
await expect(frame.getByTestId('title')).toHaveText('Leave a comment');
// Check count is hidden
await expect(frame.getByTestId('count')).not.toBeVisible();
});
test('Hides title and count', async ({page}) => {
const mockedApi = new MockedApi({});
mockedApi.addComments(2);
const {frame} = await initialize({
mockedApi,
page,
publication: 'Publisher Weekly'
});
await expect(frame.getByTestId('title')).not.toBeVisible();
await expect(frame.getByTestId('count')).not.toBeVisible();
});
});

View File

@ -0,0 +1,62 @@
import {MockedApi, initialize} from '../utils/e2e';
import {expect, test} from '@playwright/test';
test.describe('Pagination', async () => {
test('Shows pagination button on top if more than 5 comments', async ({page}) => {
const mockedApi = new MockedApi({});
mockedApi.addComment({
html: '<p>This is comment 1</p>'
});
mockedApi.addComment({
html: '<p>This is comment 2</p>'
});
mockedApi.addComment({
html: '<p>This is comment 3</p>'
});
mockedApi.addComment({
html: '<p>This is comment 4</p>'
});
mockedApi.addComment({
html: '<p>This is comment 5</p>'
});
mockedApi.addComment({
html: '<p>This is comment 6</p>'
});
const {frame} = await initialize({
mockedApi,
page,
publication: 'Publisher Weekly'
});
await expect(frame.getByTestId('pagination-component')).toBeVisible();
// Check text in pagination button
await expect(frame.getByTestId('pagination-component')).toContainText('Show 1 previous comment');
// Test total comments with test-id comment-component is 5
await expect(frame.getByTestId('comment-component')).toHaveCount(5);
// Check only the first 5 comments are visible
await expect(frame.getByText('This is comment 1')).toBeVisible();
await expect(frame.getByText('This is comment 2')).toBeVisible();
await expect(frame.getByText('This is comment 3')).toBeVisible();
await expect(frame.getByText('This is comment 4')).toBeVisible();
await expect(frame.getByText('This is comment 5')).toBeVisible();
await expect(frame.getByText('This is comment 6')).not.toBeVisible();
// Click the pagination button
await frame.getByTestId('pagination-component').click();
// Check only 6 visible (not more than that)
await expect(frame.getByTestId('comment-component')).toHaveCount(6);
// Check comments 6 is visible
await expect(frame.getByText('This is comment 6')).toBeVisible();
// Check the pagination button is not visible
await expect(frame.getByTestId('pagination-component')).not.toBeVisible();
});
});

View File

@ -0,0 +1,8 @@
const assert = require('assert/strict');
describe('Hello world', function () {
it('Runs a test', function () {
// TODO: Write me!
assert.ok(require('../../index'));
});
});

View File

@ -0,0 +1,128 @@
import nql from '@tryghost/nql';
import {buildComment, buildMember} from './fixtures';
export class MockedApi {
comments: any[];
postId: string;
member: any;
#lastCommentDate = new Date('2021-01-01T00:00:00.000Z');
constructor({postId = 'ABC', comments = [], member = undefined}: {postId?: string, comments?: any[], member?: any}) {
this.postId = postId;
this.comments = comments;
this.member = member;
}
addComment(overrides: any = {}) {
if (!overrides.created_at) {
overrides.created_at = this.#lastCommentDate.toISOString();
this.#lastCommentDate = new Date(this.#lastCommentDate.getTime() + 1);
}
const fixture = buildComment({
...overrides,
post_id: this.postId
});
this.comments.push(fixture);
}
addComments(count, overrides = {}) {
for (let i = 0; i < count; i += 1) {
this.addComment(overrides);
}
}
setMember(overrides) {
this.member = buildMember(overrides);
}
commentsCounts() {
return {
[this.postId]: this.comments.length
};
}
browseComments({limit = 5, order, filter, page}: {limit?: number, order?: string, filter?: string, page: number}) {
// Sort comments on created at + id
this.comments.sort((a, b) => {
const aDate = new Date(a.created_at).getTime();
const bDate = new Date(b.created_at).getTime();
if (aDate === bDate) {
return a.id > b.id ? 1 : -1;
}
return aDate > bDate ? 1 : -1;
});
let filteredComments = this.comments;
// Parse NQL filter
if (filter) {
const parsed = nql(filter);
filteredComments = this.comments.filter((comment) => {
return parsed.queryJSON(comment);
});
}
// Splice based on page and limit
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const comments = filteredComments.slice(startIndex, endIndex);
return {
comments,
meta: {
pagination: {
pages: Math.ceil(filteredComments.length / limit),
total: filteredComments.length,
page,
limit
}
}
};
}
async listen({page, path}: {page: any, path: string}) {
await page.route(`${path}/members/api/member/`, async (route) => {
if (!this.member) {
return await route.fulfill({
status: 401,
body: 'Not authenticated'
});
}
await route.fulfill({
status: 200,
body: JSON.stringify(this.member)
});
});
await page.route(`${path}/members/api/comments/*`, async (route) => {
const url = new URL(route.request().url());
const p = parseInt(url.searchParams.get('page') ?? '1');
const limit = parseInt(url.searchParams.get('limit') ?? '5');
const order = url.searchParams.get('order') ?? '';
await route.fulfill({
status: 200,
body: JSON.stringify(this.browseComments({
page: p,
limit,
order
}))
});
});
await page.route(`${path}/members/api/comments/counts/*`, async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(
this.commentsCounts()
)
});
});
}
}

View File

@ -0,0 +1,62 @@
import {E2E_PORT} from '../../playwright.config';
import {MockedApi} from './MockedApi';
import {Page} from '@playwright/test';
export const MOCKED_SITE_URL = 'https://localhost:1234';
export {MockedApi};
export async function initialize({mockedApi, page, ...options}: {
mockedApi: MockedApi,
page: Page,
path?: string;
ghostComments?: string,
key?: string,
api?: string,
admin?: string,
colorScheme?: string,
avatarSaturation?: string,
accentColor?: string,
commentsEnabled?: string,
title?: string,
count?: boolean,
publication?: string,
postId?: string
}) {
const sitePath = MOCKED_SITE_URL;
await page.route(sitePath, async (route) => {
await route.fulfill({
status: 200,
body: '<html><head><meta charset="UTF-8" /></head><body></body></html>'
});
});
const url = `http://localhost:${E2E_PORT}/comments-ui.min.js`;
await page.setViewportSize({width: 1000, height: 1000});
await page.goto(sitePath);
await mockedApi.listen({page, path: sitePath});
if (!options.ghostComments) {
options.ghostComments = MOCKED_SITE_URL;
}
if (!options.postId) {
options.postId = mockedApi.postId;
}
await page.evaluate((data) => {
const scriptTag = document.createElement('script');
scriptTag.src = data.url;
for (const option of Object.keys(data.options)) {
scriptTag.dataset[option] = data.options[option];
}
document.body.appendChild(scriptTag);
}, {url, options});
await page.waitForSelector('iframe');
return {
frame: page.frameLocator('iframe')
};
}

View File

@ -0,0 +1,65 @@
const ObjectId = require('bson-objectid').default;
let memberCounter = 0;
export function buildMember(override: any = {}) {
memberCounter += 1;
return {
avatar_image: 'https://www.gravatar.com/avatar/7a68f69cc9c9e9b45d97ecad6f24184a?s=250&r=g&d=blank',
expertise: 'Head of Testing',
id: ObjectId(),
name: 'Test Member ' + memberCounter,
uuid: ObjectId(),
paid: override.status === 'paid',
status: 'free',
...override
};
}
export function buildComment(override: any = {}) {
return {
id: ObjectId(),
html: '<p>Empty</p>',
replies: [],
count: {
replies: 0,
likes: 0
},
liked: false,
created_at: '2022-08-11T09:26:34.000Z',
edited_at: null,
member: buildMember(),
status: 'published',
...override
};
}
export function buildReply(override: any = {}) {
return {
id: ObjectId(),
html: '<p>Empty</p>',
count: {
likes: 0
},
liked: false,
created_at: '2022-08-11T09:26:34.000Z',
edited_at: null,
member: buildMember(),
status: 'published',
...override
};
}
export function buildCommentsReply(override: any = {}) {
return {
comments: [],
meta: {
pagination: {
pages: 1,
total: 0,
page: 1,
limit: 5
}
}
};
}

View File

@ -1,10 +1,10 @@
import {resolve} from 'path';
import fs from 'fs/promises';
import {resolve} from 'path';
import {defineConfig} from 'vitest/config';
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';
import reactPlugin from '@vitejs/plugin-react';
import svgrPlugin from 'vite-plugin-svgr';
import {defineConfig} from 'vitest/config';
import pkg from './package.json';
@ -75,6 +75,7 @@ export default defineConfig((config) => {
globals: true,
environment: 'jsdom',
setupFiles: './src/setupTests.js',
include: ['src/**/*.test.js'],
testTimeout: 10000
}
};

View File

@ -12,7 +12,7 @@ export async function initialize({page, path, apiStatus, embeddedOnUrl, ...optio
await page.route(sitePath, async (route) => {
await route.fulfill({
status: 200,
body: '<html><body></body></html>'
body: '<html><head><meta charset="UTF-8" /></head><body></body></html>'
});
});

View File

@ -60,15 +60,12 @@ describe('{{comments}} helper', function () {
rendered.string.should.containEql('data-api="http://127.0.0.1:2369/ghost/api/content/"');
rendered.string.should.containEql('data-admin="http://127.0.0.1:2369/ghost/"');
rendered.string.should.containEql('data-key="xyz"');
rendered.string.should.containEql('data-styles="https://cdn.jsdelivr.net/ghost/comments-ui@~test.version/umd/main.css"');
rendered.string.should.containEql('data-title="null"');
rendered.string.should.containEql('data-count="true"');
rendered.string.should.containEql('data-post-id="post_id_123"');
rendered.string.should.containEql('data-sentry-dsn=""');
rendered.string.should.containEql('data-color-scheme="auto"');
rendered.string.should.containEql('data-avatar-saturation="60"');
rendered.string.should.containEql('data-accent-color=""');
rendered.string.should.containEql('data-app-version="test.version"');
rendered.string.should.containEql('data-comments-enabled="all"');
});
@ -92,15 +89,12 @@ describe('{{comments}} helper', function () {
rendered.string.should.containEql('data-api="http://127.0.0.1:2369/ghost/api/content/"');
rendered.string.should.containEql('data-admin="http://127.0.0.1:2369/ghost/"');
rendered.string.should.containEql('data-key="xyz"');
rendered.string.should.containEql('data-styles="https://cdn.jsdelivr.net/ghost/comments-ui@~test.version/umd/main.css"');
rendered.string.should.containEql('data-title="null"');
rendered.string.should.containEql('data-count="true"');
rendered.string.should.containEql('data-post-id="post_id_123"');
rendered.string.should.containEql('data-sentry-dsn=""');
rendered.string.should.containEql('data-color-scheme="auto"');
rendered.string.should.containEql('data-avatar-saturation="60"');
rendered.string.should.containEql('data-accent-color=""');
rendered.string.should.containEql('data-app-version="test.version"');
rendered.string.should.containEql('data-comments-enabled="paid"');
});