Skip to content

Commit

Permalink
test: add cypress
Browse files Browse the repository at this point in the history
  • Loading branch information
pyphilia committed Mar 20, 2024
1 parent b19e33a commit 8c126e3
Show file tree
Hide file tree
Showing 9 changed files with 381 additions and 0 deletions.
84 changes: 84 additions & 0 deletions .github/workflows/cypress.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
name: Cypress CI

on:
push:
branches:
- 'main'
merge_group:
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: false

jobs:
cypress:
name: Cypress
runs-on: ubuntu-latest
timeout-minutes: 50
steps:
- name: Check out code
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4

- name: Yarn Install and Cache
uses: graasp/graasp-deploy/.github/actions/yarn-install-and-cache@v1
with:
cypress: true

# type check
- name: Type-check code
run: tsc --noEmit

- name: Build App
run: NODE_OPTIONS=--max-old-space-size=8192 yarn build:test
shell: bash
env:
VITE_PORT: ${{ vars.VITE_PORT }}
VITE_VERSION: ${{ vars.VITE_VERSION }}
VITE_GRAASP_DOMAIN: ${{ vars.VITE_GRAASP_DOMAIN }}
VITE_GRAASP_API_HOST: ${{ vars.VITE_GRAASP_API_HOST }}
VITE_GRAASP_AUTH_HOST: ${{ vars.VITE_GRAASP_AUTH_HOST }}
VITE_GRAASP_PLAYER_HOST: ${{ vars.VITE_GRAASP_PLAYER_HOST }}
VITE_GRAASP_LIBRARY_HOST: ${{ vars.VITE_GRAASP_LIBRARY_HOST }}
VITE_GRAASP_ANALYZER_HOST: ${{ vars.VITE_GRAASP_ANALYZER_HOST }}
VITE_SHOW_NOTIFICATIONS: ${{ vars.VITE_SHOW_NOTIFICATIONS }}
VITE_GRAASP_REDIRECTION_HOST: ${{ vars.VITE_GRAASP_REDIRECTION_HOST }}

# use the Cypress GitHub Action to run Cypress tests within the chrome browser
- name: Cypress run
uses: cypress-io/github-action@v6
with:
install: false
# we launch the app in preview mode to avoid issues with hmr websockets from vite polluting the mocks
start: yarn preview:test
browser: chrome
quiet: true
config-file: cypress.config.ts
cache-key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
env:
VITE_PORT: ${{ vars.VITE_PORT }}
VITE_VERSION: ${{ vars.VITE_VERSION }}
VITE_GRAASP_DOMAIN: ${{ vars.VITE_GRAASP_DOMAIN }}
VITE_GRAASP_API_HOST: ${{ vars.VITE_GRAASP_API_HOST }}
VITE_GRAASP_AUTH_HOST: ${{ vars.VITE_GRAASP_AUTH_HOST }}
VITE_GRAASP_PLAYER_HOST: ${{ vars.VITE_GRAASP_PLAYER_HOST }}
VITE_GRAASP_LIBRARY_HOST: ${{ vars.VITE_GRAASP_LIBRARY_HOST }}
VITE_GRAASP_ANALYZER_HOST: ${{ vars.VITE_GRAASP_ANALYZER_HOST }}
VITE_SHOW_NOTIFICATIONS: ${{ vars.VITE_SHOW_NOTIFICATIONS }}
VITE_GRAASP_REDIRECTION_HOST: ${{ vars.VITE_GRAASP_REDIRECTION_HOST }}

# after the test run completes
# store any screenshots
# NOTE: screenshots will be generated only if E2E test failed
# thus we store screenshots only on failures
- uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress-screenshots
path: cypress/screenshots

- name: coverage report
run: npx nyc report --reporter=text-summary
21 changes: 21 additions & 0 deletions cypress.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { defineConfig } from 'cypress';

export default defineConfig({
e2e: {
env: {
VITE_GRAASP_REDIRECTION_HOST: process.env.VITE_GRAASP_REDIRECTION_HOST,
VITE_GRAASP_DOMAIN: process.env.VITE_GRAASP_DOMAIN,
VITE_GRAASP_API_HOST: process.env.VITE_GRAASP_API_HOST,
VITE_SHOW_NOTIFICATIONS: false,
VITE_GRAASP_AUTH_HOST: process.env.VITE_GRAASP_AUTH_HOST,
VITE_GRAASP_PLAYER_HOST: process.env.VITE_GRAASP_PLAYER_HOST,
VITE_GRAASP_ANALYZER_HOST: process.env.VITE_GRAASP_ANALYZER_HOST,
VITE_GRAASP_LIBRARY_HOST: process.env.VITE_GRAASP_LIBRARY_HOST,
VITE_GRAASP_ACCOUNT_HOST: process.env.VITE_GRAASP_ACCOUNT_HOST,
},
setupNodeEvents(_on, _config) {
// implement node event listeners here
},
baseUrl: 'http://localhost:3114',
},
});
6 changes: 6 additions & 0 deletions cypress/cypress.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
declare namespace Cypress {
interface Chainable {
// todo: improve types
setUpApi(args: any): Chainable;
}
}
6 changes: 6 additions & 0 deletions cypress/e2e/profile.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
describe('template spec', () => {
it('passes', () => {
cy.setUpApi();
cy.visit('/');
});
});
6 changes: 6 additions & 0 deletions cypress/fixtures/members.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { MemberFactory } from '@graasp/sdk';

// eslint-disable-next-line import/prefer-default-export
export const CURRENT_MEMBER = MemberFactory();

export const MEMBERS = [];
48 changes: 48 additions & 0 deletions cypress/support/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/// <reference types="cypress" />
import { CookieKeys } from '@graasp/sdk';

import { CURRENT_MEMBER, MEMBERS } from '../fixtures/members';
import {
mockEditMember,
mockGetAvatarUrl,
mockGetCurrentMember,
mockGetMember,
mockPostAvatar,
mockSignInRedirection,
mockSignOut,
mockUpdatePassword,
} from './server';

Cypress.Commands.add(
'setUpApi',
({
members = Object.values(MEMBERS),
currentMember = CURRENT_MEMBER,
getCurrentMemberError = false,
editMemberError = false,
getAvatarUrlError = false,
postAvatarError = false,
updatePasswordError = false,
} = {}) => {
const cachedMembers = JSON.parse(JSON.stringify(members));

// hide cookie banner by default
cy.setCookie(CookieKeys.AcceptCookies, 'true');

mockGetMember(cachedMembers);

mockGetCurrentMember(currentMember, getCurrentMemberError);

mockSignInRedirection();

mockSignOut();

mockEditMember(members, editMemberError);

mockGetAvatarUrl(members, getAvatarUrlError);

mockPostAvatar(postAvatarError);

mockUpdatePassword(members, updatePasswordError);
},
);
20 changes: 20 additions & 0 deletions cypress/support/e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// ***********************************************************
// This example support/e2e.ts 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')
184 changes: 184 additions & 0 deletions cypress/support/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { API_ROUTES } from '@graasp/query-client';
import { HttpMethod, Member, buildSignInPath } from '@graasp/sdk';

import { StatusCodes } from 'http-status-codes';

import { CURRENT_MEMBER } from '../fixtures/members';
import { ID_FORMAT, MemberForTest } from './utils';

const {
buildGetMember,
GET_CURRENT_MEMBER_ROUTE,
SIGN_OUT_ROUTE,
buildPatchMember,
buildUploadAvatarRoute,
buildUpdateMemberPasswordRoute,
} = API_ROUTES;

export const SIGN_IN_PATH = buildSignInPath({
host: Cypress.env('VITE_GRAASP_AUTH_HOST'),
});
const API_HOST = Cypress.env('VITE_GRAASP_API_HOST');
export const AVATAR_LINK = 'https://picsum.photos/200/200';

export const redirectionReply = {
headers: { 'content-type': 'application/json' },
statusCode: StatusCodes.OK,
};

export const mockGetCurrentMember = (
currentMember = CURRENT_MEMBER,
shouldThrowError = false,
): void => {
cy.intercept(
{
method: HttpMethod.Get,
url: `${API_HOST}/${GET_CURRENT_MEMBER_ROUTE}`,
},
({ reply }) => {
if (shouldThrowError) {
return reply({
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
body: null,
});
}

// might reply empty user when signed out
return reply({ statusCode: StatusCodes.OK, body: currentMember });
},
).as('getCurrentMember');
};

export const mockGetMember = (members: Member[]): void => {
cy.intercept(
{
method: HttpMethod.Get,
url: new RegExp(`${API_HOST}/${buildGetMember(ID_FORMAT)}$`),
},
({ url, reply }) => {
const memberId = url.slice(API_HOST.length).split('/')[2];
const member = members.find((m) => m.id === memberId);

// member does not exist in db
if (!member) {
return reply({
statusCode: StatusCodes.NOT_FOUND,
});
}

return reply({
body: member,
statusCode: StatusCodes.OK,
});
},
).as('getMember');
};

export const mockEditMember = (
_members: Member[],
shouldThrowError: boolean,
): void => {
cy.intercept(
{
method: HttpMethod.Patch,
url: new RegExp(`${API_HOST}/${buildPatchMember(ID_FORMAT)}`),
},
({ reply }) => {
if (shouldThrowError) {
return reply({ statusCode: StatusCodes.BAD_REQUEST });
}

return reply('edit member');
},
).as('editMember');
};

export const mockSignInRedirection = (): void => {
cy.intercept(
{
method: HttpMethod.Get,
url: SIGN_IN_PATH,
},
({ reply }) => {
reply(redirectionReply);
},
).as('signInRedirection');
};

export const mockSignOut = (): void => {
cy.intercept(
{
method: HttpMethod.Get,
url: new RegExp(SIGN_OUT_ROUTE),
},
({ reply }) => {
reply(redirectionReply);
},
).as('signOut');
};

export const mockGetAvatarUrl = (
members: MemberForTest[],
shouldThrowError: boolean,
): void => {
cy.intercept(
{
method: HttpMethod.Get,
// TODO: include all sizes
url: new RegExp(
`${API_HOST}/members/${ID_FORMAT}/avatar/small\\?replyUrl\\=true`,
),
},
({ reply, url }) => {
if (shouldThrowError) {
return reply({ statusCode: StatusCodes.BAD_REQUEST });
}

const [link] = url.split('?');
const id = link.slice(API_HOST.length).split('/')[2];

const { thumbnails } =
members.find(({ id: thisId }) => id === thisId) ?? {};
if (!thumbnails) {
return reply({ statusCode: StatusCodes.NOT_FOUND });
}
// TODO: REPLY URL
return reply(AVATAR_LINK);
},
).as('downloadAvatarUrl');
};

export const mockPostAvatar = (shouldThrowError: boolean): void => {
cy.intercept(
{
method: HttpMethod.Post,
url: new RegExp(`${buildUploadAvatarRoute()}`),
},
({ reply }) => {
if (shouldThrowError) {
return reply({ statusCode: StatusCodes.BAD_REQUEST });
}

return reply({ statusCode: StatusCodes.OK });
},
).as('uploadAvatar');
};

export const mockUpdatePassword = (
_members: Member[],
shouldThrowError: boolean,
): void => {
cy.intercept(
{
method: HttpMethod.Patch,
url: new RegExp(`${API_HOST}/${buildUpdateMemberPasswordRoute()}`),
},
({ reply }) => {
if (shouldThrowError) {
return reply({ statusCode: StatusCodes.BAD_REQUEST });
}

return reply('update password');
},
).as('updatePassword');
};
6 changes: 6 additions & 0 deletions cypress/support/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { CompleteMember } from '@graasp/sdk';

export const ID_FORMAT = '(?=.*[0-9])(?=.*[a-zA-Z])([a-z0-9-]+)';

// TODO: not ideal, to change?
export type MemberForTest = CompleteMember & { thumbnails?: string };

0 comments on commit 8c126e3

Please sign in to comment.