Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test: add cypress #167

Merged
merged 3 commits into from
Mar 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 }}
spaenleh marked this conversation as resolved.
Show resolved Hide resolved
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
24 changes: 24 additions & 0 deletions cypress.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import setupCoverage from '@cypress/code-coverage/task';
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
setupCoverage(on, config);
return config;
},
baseUrl: `http://localhost:${process.env.VITE_PORT || 3333}`,
},
});
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
pyphilia marked this conversation as resolved.
Show resolved Hide resolved
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);
},
);
21 changes: 21 additions & 0 deletions cypress/support/e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// ***********************************************************
// 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 '@cypress/code-coverage/support';

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 };