0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-20 22:42:53 -05:00

Added experimental Cypress tests

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

This commit adds some Cypress tests as a POC to the project. If we decide to go with Cypress, we can add more tests to cover the rest of the codebase. The main reason to have a E2E framework is that some editor related UI is hard to test with the React tests because it uses JSDOM.
This commit is contained in:
Simon Backx 2022-09-09 17:27:32 +02:00
parent ddbc2db76d
commit 24af5ad0dd
22 changed files with 1091 additions and 90 deletions

View file

@ -3,7 +3,8 @@ module.exports = {
root: true,
extends: [
'react-app',
'plugin:ghost/browser'
'plugin:ghost/browser',
'plugin:cypress/recommended'
],
plugins: [
'ghost',

View file

@ -83,4 +83,6 @@ build/
# CRA also suggests `.env` files should be checked into source control
# Ref: https://create-react-app.dev/docs/adding-custom-environment-variables/#adding-development-environment-variables-in-env
public/main.css
public/main.css
cypress/videos

View file

@ -0,0 +1,17 @@
const {defineConfig} = require('cypress');
module.exports = defineConfig({
component: {
devServer: {
framework: 'create-react-app',
bundler: 'webpack'
}
},
e2e: {
baseUrl: 'http://localhost:4000',
setupNodeEvents(on, config) {
// implement node event listeners here
}
}
});

View file

@ -0,0 +1,64 @@
describe('Forms', () => {
it('Asks to fill in member name', () => {
cy.login({name: ''}).as('login');
cy.mockComments(10).as('getComments');
cy.mockAddComments().as('getComments');
cy.visit(`/?ghostComments=${encodeURIComponent('/')}&styles=${encodeURIComponent('/main.css')}`);
cy.wait(['@login', '@getComments', '@getCounts']);
let mainForm = cy.iframe().find('[data-testid="main-form"]').should('exist');
// Check name not visible
mainForm.find('[data-testid="member-name"]').should('not.exist');
mainForm = cy.iframe().find('[data-testid="main-form"]').should('exist');
mainForm.click();
// Check name not visible
mainForm.find('[data-testid="member-name"]').should('not.exist');
cy.popup('addDetailsPopup').should('exist');
});
it('Can open main form and post a comment', () => {
cy.login().as('login');
cy.mockComments(10).as('getComments');
cy.mockAddComments().as('getComments');
cy.visit(`/?ghostComments=${encodeURIComponent('/')}&styles=${encodeURIComponent('/main.css')}`);
cy.wait(['@login', '@getComments', '@getCounts']);
let mainForm = cy.iframe().find('[data-testid="main-form"]').should('exist');
// Check name not visible
mainForm.find('[data-testid="member-name"]').should('not.exist');
mainForm = cy.iframe().find('[data-testid="main-form"]').should('exist');
mainForm.click();
// Check name visible
mainForm.find('[data-testid="member-name"]').should('exist');
const form = cy.iframe().find('[data-testid="main-form"]').find('[contenteditable="true"]');
form.type('Hello world')
.type('{cmd}{enter}');
});
it('Hides MainForm when replying', () => {
cy.login().as('login');
cy.mockComments(1).as('getComments');
cy.mockAddComments().as('getComments');
cy.visit(`/?ghostComments=${encodeURIComponent('/')}&styles=${encodeURIComponent('/main.css')}`);
cy.wait(['@login', '@getComments', '@getCounts']);
cy.iframe().find('[data-testid="main-form"]').should('exist').as('mainForm');
cy.iframe()
.find('[data-testid="comment-component"]').should('exist')
.find('[data-testid="reply-button"]').click();
cy.iframe().find('[data-testid="main-form"]').should('not.exist');
});
});

View file

@ -0,0 +1,60 @@
describe('Pagination', () => {
it('does not show pagination button for 0 comments', () => {
cy.login().as('login');
cy.mockComments(0).as('getComments');
cy.visit(`/?ghostComments=${encodeURIComponent('/')}&styles=${encodeURIComponent('/main.css')}`);
cy.wait(['@login', '@getComments', '@getCounts']);
cy.iframe().find('[data-testid="pagination-component"]').should('not.exist');
});
it('does show pagination plural', () => {
cy.login().as('login');
cy.mockComments(12).as('getComments');
cy.visit(`/?ghostComments=${encodeURIComponent('/')}&styles=${encodeURIComponent('/main.css')}`);
cy.wait(['@login', '@getComments', '@getCounts']);
const button = cy.iframe().find('[data-testid="pagination-component"]').should('exist');
button.contains('Show 7 previous comments');
// Should show 5 comments
cy.iframe().find('[data-testid="comment-component"]').should('have.length', 5);
});
it('does show pagination singular', () => {
cy.login().as('login');
cy.mockComments(6).as('getComments');
cy.visit(`/?ghostComments=${encodeURIComponent('/')}&styles=${encodeURIComponent('/main.css')}`);
cy.wait(['@login', '@getComments', '@getCounts']);
cy.iframe().contains('Show 1 previous comment');
// Should show 5 comments
cy.iframe().find('[data-testid="comment-component"]').should('have.length', 5);
});
it('can load next page', () => {
cy.login().as('login');
cy.mockComments(6).as('getComments');
cy.visit(`/?ghostComments=${encodeURIComponent('/')}&styles=${encodeURIComponent('/main.css')}`);
cy.wait(['@login', '@getComments', '@getCounts']);
const button = cy.iframe().contains('Show 1 previous comment');
// Should show 5 comments
cy.iframe().find('[data-testid="comment-component"]').should('have.length', 5);
button.click();
cy.wait(['@getCommentsPage2']);
// Button should be gone
button.should('not.exist');
// Should show 6 comments now, instead of 5
cy.iframe().find('[data-testid="comment-component"]').should('have.length', 6);
});
});

View file

@ -0,0 +1,106 @@
import {buildComment, buildMember} from '../../src/utils/test-utils';
let loggedInMember = null;
Cypress.Commands.add('login', (memberData) => {
loggedInMember = buildMember(memberData);
return cy.intercept(
{
method: 'GET',
url: '/members/api/member/'
},
loggedInMember
);
});
Cypress.Commands.add('mockAddComments', () => {
cy.intercept(
{
method: 'POST',
url: '/members/api/comments/'
},
[] // and force the response to be: []
).as('getCounts'); // and assign an alias
return cy.intercept(
{
method: 'POST',
url: '/members/api/comments/'
},
(req) => {
const commentData = req.body;
req.reply({
body: {
comments: [
buildComment({
...commentData?.comments[0],
member: loggedInMember
})
]
}
});
}
).as('getCounts');
});
Cypress.Commands.add('mockComments', (count, override = {}) => {
const limit = 5;
const pages = Math.max(Math.ceil(count / limit), 1);
cy.intercept(
{
method: 'POST',
url: '/members/api/comments/counts/'
},
[]
).as('getCounts');
return cy.intercept('GET', '/members/api/comments/*',
(req) => {
const page = parseInt(req.query.page ?? '1');
if (!page || page > pages) {
throw new Error('Invalid page');
}
if (page == 1) {
req.alias = 'getComments';
} else {
req.alias = 'getCommentsPage' + page;
}
req.reply({
body: {
comments: new Array(Math.min(count - (page - 1) * limit, limit)).fill(null).map(() => buildComment(override)),
meta: {
pagination: {
limit: limit,
total: count,
next: page + 1 <= pages ? page + 1 : null,
prev: page > 1 ? page - 1 : null,
page: page
}
}
}
});
}
);
});
const getIframeDocument = (title) => {
return cy
.get('iframe[title="' + title + '"]')
.its('0.contentDocument');
};
const getIframeBody = (title) => {
return getIframeDocument(title)
.its('body')
.then(cy.wrap);
};
Cypress.Commands.add('iframe', () => {
return getIframeBody('comments-frame');
});
Cypress.Commands.add('popup', (name) => {
return getIframeBody(name);
});

View file

@ -0,0 +1,20 @@
// ***********************************************************
// This example support/e2e.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands';
// Alternatively you can use CommonJS syntax:
// require('./commands')

View file

@ -31,8 +31,8 @@
"react-scripts": "4.0.3"
},
"scripts": {
"start": "BROWSER=none react-scripts start",
"start:combined": "BROWSER=none node ./scripts/start-combined.js",
"start": "PORT=4000 BROWSER=none react-scripts start",
"start:combined": "PORT=4000 BROWSER=none node ./scripts/start-combined.js",
"start:dev": "PORT=4000 node ./scripts/start-mode.js",
"dev": "node ./scripts/dev-mode.js",
"build": "npm run build:combined",
@ -40,7 +40,7 @@
"build:combined": "node ./scripts/build-combined.js",
"build:bundle": "webpack --config webpack.config.js",
"test:ui": "react-scripts test",
"test": "yarn test:ui --watchAll=false --coverage",
"test": "yarn test:ui --watchAll=false --coverage && yarn cypress:with-server",
"eject": "react-scripts eject",
"lint": "eslint src --ext .js --cache",
"preship": "yarn lint",
@ -48,7 +48,10 @@
"posttest": "yarn lint",
"analyze": "source-map-explorer 'umd/*.js'",
"prepublishOnly": "yarn build",
"tailwind": "npx tailwindcss -i ./src/index.css -o ./public/main.css --watch --minify"
"tailwind": "npx tailwindcss -i ./src/index.css -o ./public/main.css --watch --minify",
"cypress:open": "cypress open",
"cypress:with-server": "yarn start-server-and-test 'yarn start &>/dev/null' http://localhost:4000 cypress",
"cypress": "cypress run"
},
"browserslist": {
"production": [
@ -71,7 +74,9 @@
"chalk": "4.1.2",
"chokidar": "3.5.2",
"copy-webpack-plugin": "6.4.1",
"cypress": "^10.7.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-cypress": "^2.12.1",
"eslint-plugin-ghost": "2.12.0",
"eslint-plugin-tailwindcss": "^3.6.0",
"minimist": "1.2.5",
@ -80,6 +85,7 @@
"rewire": "6.0.0",
"serve-handler": "6.1.3",
"source-map-explorer": "2.5.2",
"start-server-and-test": "^1.14.0",
"tailwindcss": "^3.1.4",
"webpack-cli": "3.3.12"
},

View file

@ -9,6 +9,10 @@ import ContentBox from './components/ContentBox';
import PopupBox from './components/PopupBox';
function AuthFrame({adminUrl, onLoad}) {
if (!adminUrl) {
return null;
}
const iframeStyle = {
display: 'none'
};
@ -31,7 +35,6 @@ export default class App extends React.Component {
constructor(props) {
super(props);
// Todo: this state is work in progress
this.state = {
action: 'init:running',
initStatus: 'running',
@ -83,6 +86,10 @@ export default class App extends React.Component {
}
async initAdminAuth() {
if (this.adminApi) {
return;
}
try {
this.adminApi = this.setupAdminAPI();

View file

@ -72,7 +72,7 @@ function renderApp({member = null, documentStyles = {}, props = {}} = {}) {
};
// In tests, we currently don't wait for the styles to have loaded. In the app we check if the styles url is set or not.
const stylesUrl = '';
const {container} = render(<div style={documentStyles}><div id={ROOT_DIV_ID}><App api={api} stylesUrl={stylesUrl} {...props}/></div></div>);
const {container} = render(<div style={documentStyles}><div id={ROOT_DIV_ID}><App api={api} adminUrl="https://admin.example/" stylesUrl={stylesUrl} {...props}/></div></div>);
const iframeElement = container.querySelector('iframe[title="comments-frame"]');
expect(iframeElement).toBeInTheDocument();
const iframeDocument = iframeElement.contentDocument;

View file

@ -68,7 +68,7 @@ export const CommentsFrame = ({children}) => {
);
};
export const PopupFrame = ({children}) => {
export const PopupFrame = ({children, title}) => {
const style = {
zIndex: '3999999',
position: 'fixed',
@ -80,7 +80,7 @@ export const PopupFrame = ({children}) => {
};
return (
<TailwindFrame style={style} title="popup-frame">
<TailwindFrame style={style} title={title}>
{children}
</TailwindFrame>
);

View file

@ -46,7 +46,7 @@ export default function PopupBox() {
return (
<>
<GenericPopup show={show} callback={popupProps.callback}>
<GenericPopup show={show} callback={popupProps.callback} title={type}>
<PageComponent {...popupProps}/>
</GenericPopup>
</>

View file

@ -19,7 +19,7 @@ const Pagination = () => {
}
return (
<button type="button" className="group mb-10 flex w-full items-center px-0 pt-0 pb-2 text-left font-sans text-md font-semibold text-neutral-700 dark:text-white " onClick={loadMore}>
<button data-testid="pagination-component" type="button" className="group mb-10 flex w-full items-center px-0 pt-0 pb-2 text-left font-sans text-md font-semibold text-neutral-700 dark:text-white " onClick={loadMore}>
<span className="flex h-[39px] w-full items-center justify-center whitespace-nowrap rounded-[6px] bg-[rgb(229,229,229,0.4)] py-2 px-3 text-center font-sans text-sm font-semibold text-neutral-700 outline-0 transition-[opacity,background] duration-150 hover:bg-[rgb(229,229,229,0.7)] dark:bg-[rgba(255,255,255,0.08)] dark:text-neutral-100 dark:hover:bg-[rgba(255,255,255,0.1)]"> Show {left} previous {left === 1 ? 'comment' : 'comments'}</span>
</button>
);

View file

@ -26,7 +26,7 @@ const MoreButton = ({comment, toggleEdit}) => {
}
return (
<div className="relative">
<div className="relative" data-testid="more-button">
{show ? <button type="button" onClick={toggleContextMenu} className="outline-0"><MoreIcon className='duration-50 gh-comments-icon gh-comments-icon-more fill-[rgba(0,0,0,0.5)] outline-0 transition ease-linear hover:fill-[rgba(0,0,0,0.75)] dark:fill-[rgba(255,255,255,0.5)] dark:hover:fill-[rgba(255,255,255,0.25)]' /></button> : null}
{isContextMenuOpen ? <CommentContextMenu comment={comment} close={closeContextMenu} toggleEdit={toggleEdit} /> : null}
</div>

View file

@ -11,7 +11,7 @@ const AuthorContextMenu = ({comment, close, toggleEdit}) => {
return (
<div className="flex flex-col">
<button type="button" className="mb-3 w-full text-left text-[14px]" onClick={toggleEdit}>
<button type="button" className="mb-3 w-full text-left text-[14px]" onClick={toggleEdit} data-testid="edit">
Edit
</button>
<button type="button" className="w-full text-left text-[14px] text-red-600" onClick={deleteComment}>

View file

@ -129,6 +129,7 @@ const FormHeader = ({show, name, expertise, editName, editExpertise}) => {
<div
className="font-sans text-[17px] font-bold tracking-tight text-[rgb(23,23,23)] dark:text-[rgba(255,255,255,0.85)]"
onClick={editName}
data-testid="member-name"
>
{name ? name : 'Anonymous'}
</div>

View file

@ -85,7 +85,7 @@ const MainForm = ({commentsCount}) => {
const isOpen = editor?.isFocused ?? false;
return (
<div className='-mt-[4px]' ref={formEl}>
<div className='-mt-[4px]' ref={formEl} data-testid="main-form">
<Form editor={editor} reduced={false} isOpen={isOpen} {...submitProps} />
</div>
);

View file

@ -3,14 +3,14 @@ import {Transition} from '@headlessui/react';
import {PopupFrame} from '../Frame';
import AppContext from '../../AppContext';
const GenericPopup = (props) => {
const GenericPopup = ({show, children, title, callback}) => {
// The modal will cover the whole screen, so while it is hidden, we need to disable pointer events
const {dispatchAction} = useContext(AppContext);
const close = (event) => {
dispatchAction('closePopup');
if (props.callback) {
props.callback(false);
if (callback) {
callback(false);
}
};
@ -28,8 +28,8 @@ const GenericPopup = (props) => {
});
return (
<Transition show={props.show} appear={true}>
<PopupFrame>
<Transition show={show} appear={true}>
<PopupFrame title={title}>
<div>
<Transition.Child
enter="transition duration-200 linear"
@ -48,7 +48,7 @@ const GenericPopup = (props) => {
leaveFrom="translate-y-0 opacity-100"
leaveTo="translate-y-4 opacity-0"
>
{props.children}
{children}
</Transition.Child>
</div>
</Transition.Child>

View file

@ -26,26 +26,32 @@ function getSiteData() {
* @type {HTMLElement}
*/
const scriptTag = document.querySelector('script[data-ghost-comments]');
if (scriptTag) {
const siteUrl = scriptTag.dataset.ghostComments;
const apiKey = scriptTag.dataset.key;
const apiUrl = scriptTag.dataset.api;
const adminUrl = scriptTag.dataset.admin;
const sentryDsn = scriptTag.dataset.sentryDsn;
const postId = scriptTag.dataset.postId;
const colorScheme = scriptTag.dataset.colorScheme;
const avatarSaturation = scriptTag.dataset.avatarSaturation;
const accentColor = scriptTag.dataset.accentColor;
const appVersion = scriptTag.dataset.appVersion;
const commentsEnabled = scriptTag.dataset.commentsEnabled;
const stylesUrl = scriptTag.dataset.styles;
const title = scriptTag.dataset.title === 'null' ? null : scriptTag.dataset.title;
const showCount = scriptTag.dataset.count === 'true';
const publication = scriptTag.dataset.publication ?? ''; // TODO: replace with dynamic data from script
let dataset = scriptTag?.dataset;
return {siteUrl, stylesUrl, apiKey, apiUrl, sentryDsn, postId, adminUrl, colorScheme, avatarSaturation, accentColor, appVersion, commentsEnabled, title, showCount, publication};
if (!scriptTag && process.env.NODE_ENV === 'development') {
// Use queryparams in test mode
dataset = Object.fromEntries(new URLSearchParams(window.location.search).entries());
} else if (!scriptTag) {
return {};
}
return {};
const siteUrl = dataset.ghostComments;
const apiKey = dataset.key;
const apiUrl = dataset.api;
const adminUrl = dataset.admin;
const sentryDsn = dataset.sentryDsn;
const postId = dataset.postId;
const colorScheme = dataset.colorScheme;
const avatarSaturation = dataset.avatarSaturation;
const accentColor = dataset.accentColor;
const appVersion = dataset.appVersion;
const commentsEnabled = dataset.commentsEnabled;
const stylesUrl = dataset.styles;
const title = dataset.title === 'null' ? null : dataset.title;
const showCount = dataset.count === 'true';
const publication = dataset.publication ?? ''; // TODO: replace with dynamic data from script
return {siteUrl, stylesUrl, apiKey, apiUrl, sentryDsn, postId, adminUrl, colorScheme, avatarSaturation, accentColor, appVersion, commentsEnabled, title, showCount, publication};
}
function handleTokenUrl() {
@ -65,14 +71,20 @@ function init() {
// const customSiteUrl = getSiteUrl();
const {siteUrl: customSiteUrl, ...siteData} = getSiteData();
const siteUrl = customSiteUrl || window.location.origin;
setup({siteUrl});
ReactDOM.render(
<React.StrictMode>
{<App siteUrl={siteUrl} customSiteUrl={customSiteUrl} {...siteData} />}
</React.StrictMode>,
document.getElementById(ROOT_DIV_ID)
);
try {
setup({siteUrl});
ReactDOM.render(
<React.StrictMode>
{<App siteUrl={siteUrl} customSiteUrl={customSiteUrl} {...siteData} />}
</React.StrictMode>,
document.getElementById(ROOT_DIV_ID)
);
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
}
}
init();

View file

@ -1,12 +1,16 @@
const ObjectId = require('bson-objectid').default;
let memberCounter = 0;
export function buildMember(override) {
memberCounter += 1;
return {
avatar_image: '',
avatar_image: 'https://www.gravatar.com/avatar/7a68f69cc9c9e9b45d97ecad6f24184a?s=250&r=g&d=blank',
expertise: 'Head of Testing',
id: ObjectId(),
name: 'Test Member',
uuid: '613e9667-4fa2-4ff4-aa62-507220103d41',
name: 'Test Member ' + memberCounter,
uuid: ObjectId(),
paid: false,
...override
};
}

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="Mocha Tests" time="2.6730" tests="4" failures="0">
<testsuite name="Root Suite" timestamp="2022-09-09T15:27:21" tests="0" file="cypress/e2e/pagination.cy.js" time="0.0000" failures="0">
</testsuite>
<testsuite name="Pagination" timestamp="2022-09-09T15:27:21" tests="4" time="2.6730" failures="0">
<testcase name="Pagination does not show pagination button for 0 comments" time="0.7780" classname="does not show pagination button for 0 comments">
</testcase>
<testcase name="Pagination does show pagination plural" time="0.6270" classname="does show pagination plural">
</testcase>
<testcase name="Pagination does show pagination singular" time="0.5910" classname="does show pagination singular">
</testcase>
<testcase name="Pagination can load next page" time="0.6770" classname="can load next page">
</testcase>
</testsuite>
</testsuites>

File diff suppressed because it is too large Load diff