diff --git a/.drone.yml b/.drone.yml index f0355b7fa9..da6575adbc 100644 --- a/.drone.yml +++ b/.drone.yml @@ -1,5 +1,5 @@ kind: pipeline -name: testing +name: build trigger: branch: @@ -10,6 +10,13 @@ trigger: - push - pull_request +services: + - name: api + image: vikunja/api + environment: + VIKUNJA_SERVICE_TESTINGTOKEN: averyLongSecretToSe33dtheDB + VIKUNJA_LOG_LEVEL: DEBUG + steps: - name: restore-cache image: meltwater/drone-cache:dev @@ -30,11 +37,11 @@ steps: - '.cache' - name: dependencies - image: node:13 + image: node:12 pull: true - group: build-static environment: - YARN_CACHE_FOLDER: .cache + YARN_CACHE_FOLDER: .cache/yarn/ + CYPRESS_CACHE_FOLDER: .cache/cypress/ commands: - yarn --frozen-lockfile --network-timeout 100000 depends_on: @@ -61,26 +68,62 @@ steps: - dependencies - name: build - image: node:13 + image: node:12 pull: true - group: build-static environment: - YARN_CACHE_FOLDER: .cache + YARN_CACHE_FOLDER: .cache/yarn/ + CYPRESS_CACHE_FOLDER: .cache/cypress/ commands: - yarn run lint - yarn run build depends_on: - dependencies - - name: test - image: node:13 + - name: test-unit + image: node:12 pull: true - group: build-static commands: - - yarn test + - yarn test:unit depends_on: - dependencies + - name: test-frontend + image: cypress/browsers:node12.18.3-chrome87-ff82 + pull: true + environment: + CYPRESS_API_URL: http://api:3456/api/v1 + CYPRESS_TEST_SECRET: averyLongSecretToSe33dtheDB + YARN_CACHE_FOLDER: .cache/yarn/ + CYPRESS_CACHE_FOLDER: .cache/cypress/ + commands: + - sed -i 's/localhost/api/g' public/index.html + - yarn serve & npx wait-on http://localhost:8080 + - yarn test:frontend --browser chrome + depends_on: + - dependencies + + - name: upload-test-results + image: plugins/s3:1 + pull: true + settings: + bucket: drone-test-results + access_key: + from_secret: test_results_aws_access_key_id + secret_key: + from_secret: test_results_aws_secret_access_key + endpoint: https://s3.fr-par.scw.cloud + region: fr-par + path_style: true + source: cypress/screenshots/**/**/* + strip_prefix: cypress/screenshots/ + target: /${DRONE_REPO}/${DRONE_PULL_REQUEST}_${DRONE_BRANCH}/${DRONE_BUILD_NUMBER}/ + depends_on: + - test-frontend + when: + status: + - failure + - success + --- kind: pipeline name: release-latest @@ -116,7 +159,7 @@ steps: - '.cache' - name: build - image: node:13 + image: node:12 pull: true group: build-static environment: @@ -186,7 +229,7 @@ steps: - '.cache' - name: build - image: node:13 + image: node:12 pull: true group: build-static environment: diff --git a/.gitignore b/.gitignore index 59b5c1cf2d..aad7d28569 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,7 @@ yarn-error.log* *.njsproj *.sln *.sw* + +# Test files +cypress/screenshots +cypress/videos diff --git a/cypress.json b/cypress.json new file mode 100644 index 0000000000..9497a81eaa --- /dev/null +++ b/cypress.json @@ -0,0 +1,8 @@ +{ + "baseUrl": "http://localhost:8080", + "env": { + "API_URL": "http://localhost:3456/api/v1", + "TEST_SECRET": "testingS3cr3et" + }, + "video": false +} diff --git a/cypress/README.md b/cypress/README.md new file mode 100644 index 0000000000..97c6469f17 --- /dev/null +++ b/cypress/README.md @@ -0,0 +1,48 @@ +# Frontend Testing With Cypress + +## Setup + +* Enable the [seeder api endpoint](https://vikunja.io/docs/config-options/#testingtoken). You'll then need to add the testingtoken in `cypress.json` or set the `CYPRESS_TEST_SECRET` environment variable. +* Basic configuration happens in the `cypress.json` file +* Overridable with [env](https://docs.cypress.io/guides/guides/environment-variables.html#Option-3-CYPRESS) +* Override base url with `CYPRESS_BASE_URL` + +## Fixtures + +We're using the [test endpoint](https://vikunja.io/docs/config-options/#testingtoken) of the vikunja api to +seed the database with test data before running the tests. +This ensures better reproducability of tests. + +## Running The Tests Locally + +### Using Docker + +The easiest way to run all frontend tests locally is by using the `docker-compose` file in this repository. +It uses the same configuration as the CI. + +To use it, run + +``` +docker-compose up -d +``` + +Then, once all containers are started, run + +``` +docker-composer run cypress bash +``` + +to get a shell inside the cypress container. +In that shell you can then execute the tests with + +``` +yarn test:frontend +``` + +### Using The Cypress Dashboard + +To open the Cypress Dashboard and run tests from there, run + +``` +yarn cypress:open +``` diff --git a/cypress/docker-compose.yml b/cypress/docker-compose.yml new file mode 100644 index 0000000000..1c496de491 --- /dev/null +++ b/cypress/docker-compose.yml @@ -0,0 +1,18 @@ +version: '3' + +services: + api: + image: vikunja/api + environment: + VIKUNJA_LOG_LEVEL: DEBUG + VIKUNJA_SERVICE_TESTINGTOKEN: averyLongSecretToSe33dtheDB + cypress: + image: cypress/browsers:node12.18.3-chrome87-ff82 + volumes: + - ..:/project + - $HOME/.cache:/home/node/.cache/ + user: node + working_dir: /project + environment: + CYPRESS_API_URL: http://api:3456/api/v1 + CYPRESS_TEST_SECRET: averyLongSecretToSe33dtheDB diff --git a/cypress/factories/bucket.js b/cypress/factories/bucket.js new file mode 100644 index 0000000000..be90cca990 --- /dev/null +++ b/cypress/factories/bucket.js @@ -0,0 +1,20 @@ +import faker from 'faker' +import {Factory} from '../support/factory' +import {formatISO} from 'date-fns' + +export class BucketFactory extends Factory { + static table = 'buckets' + + static factory() { + const now = new Date() + + return { + id: '{increment}', + title: faker.lorem.words(3), + list_id: 1, + created_by_id: 1, + created: formatISO(now), + updated: formatISO(now) + } + } +} diff --git a/cypress/factories/link_sharing.js b/cypress/factories/link_sharing.js new file mode 100644 index 0000000000..66102b1b90 --- /dev/null +++ b/cypress/factories/link_sharing.js @@ -0,0 +1,22 @@ +import {Factory} from '../support/factory' +import {formatISO} from "date-fns" +import faker from 'faker' + +export class LinkShareFactory extends Factory { + static table = 'link_sharing' + + static factory() { + const now = new Date() + + return { + id: '{increment}', + hash: faker.random.word(32), + list_id: 1, + right: 0, + sharing_type: 0, + shared_by_id: 1, + created: formatISO(now), + updated: formatISO(now) + } + } +} diff --git a/cypress/factories/list.js b/cypress/factories/list.js new file mode 100644 index 0000000000..784d63b500 --- /dev/null +++ b/cypress/factories/list.js @@ -0,0 +1,20 @@ +import {Factory} from '../support/factory' +import {formatISO} from "date-fns" +import faker from 'faker' + +export class ListFactory extends Factory { + static table = 'list' + + static factory() { + const now = new Date() + + return { + id: '{increment}', + title: faker.lorem.words(3), + owner_id: 1, + namespace_id: 1, + created: formatISO(now), + updated: formatISO(now) + } + } +} \ No newline at end of file diff --git a/cypress/factories/namespace.js b/cypress/factories/namespace.js new file mode 100644 index 0000000000..89096d2dd9 --- /dev/null +++ b/cypress/factories/namespace.js @@ -0,0 +1,19 @@ +import faker from 'faker' +import {Factory} from '../support/factory' +import {formatISO} from 'date-fns' + +export class NamespaceFactory extends Factory { + static table = 'namespaces' + + static factory() { + const now = new Date() + + return { + id: '{increment}', + title: faker.lorem.words(3), + owner_id: 1, + created: formatISO(now), + updated: formatISO(now) + } + } +} diff --git a/cypress/factories/task.js b/cypress/factories/task.js new file mode 100644 index 0000000000..8e0d7b59d7 --- /dev/null +++ b/cypress/factories/task.js @@ -0,0 +1,23 @@ +import faker from 'faker' +import {Factory} from '../support/factory' +import {formatISO} from 'date-fns' + +export class TaskFactory extends Factory { + static table = 'tasks' + + static factory() { + const now = new Date() + + return { + id: '{increment}', + title: faker.lorem.words(3), + done: false, + list_id: 1, + created_by_id: 1, + is_favorite: false, + index: '{increment}', + created: formatISO(now), + updated: formatISO(now) + } + } +} diff --git a/cypress/factories/task_comment.js b/cypress/factories/task_comment.js new file mode 100644 index 0000000000..b0b200d008 --- /dev/null +++ b/cypress/factories/task_comment.js @@ -0,0 +1,19 @@ +import faker from 'faker' + +import {Factory} from '../support/factory' +import {formatISO} from "date-fns" + +export class TaskCommentFactory extends Factory { + static table = 'task_comments' + + static factory() { + return { + id: '{increment}', + comment: faker.lorem.text(3), + author_id: 1, + task_id: 1, + created: formatISO(now), + updated: formatISO(now) + } + } +} diff --git a/cypress/factories/team.js b/cypress/factories/team.js new file mode 100644 index 0000000000..928b8ce422 --- /dev/null +++ b/cypress/factories/team.js @@ -0,0 +1,18 @@ +import faker from 'faker' +import {Factory} from '../support/factory' +import {formatISO} from 'date-fns' + +export class TeamFactory extends Factory { + static table = 'teams' + + static factory() { + const now = new Date() + + return { + name: faker.lorem.words(3), + created_by_id: 1, + created: formatISO(now), + updated: formatISO(now) + } + } +} diff --git a/cypress/factories/team_member.js b/cypress/factories/team_member.js new file mode 100644 index 0000000000..08da679d3b --- /dev/null +++ b/cypress/factories/team_member.js @@ -0,0 +1,15 @@ +import {Factory} from '../support/factory' +import {formatISO} from 'date-fns' + +export class TeamMemberFactory extends Factory { + static table = 'team_members' + + static factory() { + return { + team_id: 1, + user_id: 1, + admin: false, + created: formatISO(new Date()), + } + } +} \ No newline at end of file diff --git a/cypress/factories/user.js b/cypress/factories/user.js new file mode 100644 index 0000000000..ca8bb896e8 --- /dev/null +++ b/cypress/factories/user.js @@ -0,0 +1,21 @@ +import faker from 'faker' + +import {Factory} from '../support/factory' +import {formatISO} from "date-fns" + +export class UserFactory extends Factory { + static table = 'users' + + static factory() { + const now = new Date() + + return { + id: '{increment}', + username: faker.lorem.word(10) + faker.random.uuid(), + password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.', // 1234 + is_active: true, + created: formatISO(now), + updated: formatISO(now) + } + } +} \ No newline at end of file diff --git a/cypress/factories/users_list.js b/cypress/factories/users_list.js new file mode 100644 index 0000000000..e583cbed72 --- /dev/null +++ b/cypress/factories/users_list.js @@ -0,0 +1,19 @@ +import {Factory} from '../support/factory' +import {formatISO} from "date-fns" + +export class UserListFactory extends Factory { + static table = 'users_list' + + static factory() { + const now = new Date() + + return { + id: '{increment}', + list_id: 1, + user_id: 1, + right: 0, + created: formatISO(now), + updated: formatISO(now) + } + } +} \ No newline at end of file diff --git a/cypress/fixtures/image.jpg b/cypress/fixtures/image.jpg new file mode 100644 index 0000000000..93910582df Binary files /dev/null and b/cypress/fixtures/image.jpg differ diff --git a/cypress/integration/list/list.spec.js b/cypress/integration/list/list.spec.js new file mode 100644 index 0000000000..79c882477d --- /dev/null +++ b/cypress/integration/list/list.spec.js @@ -0,0 +1,370 @@ +import {formatISO, format} from 'date-fns' + +import {TaskFactory} from '../../factories/task' +import {ListFactory} from '../../factories/list' +import {UserListFactory} from '../../factories/users_list' +import {UserFactory} from '../../factories/user' +import {NamespaceFactory} from '../../factories/namespace' +import {BucketFactory} from '../../factories/bucket' + +import '../../support/authenticateUser' + +describe('Lists', () => { + beforeEach(() => { + UserFactory.create(1) + NamespaceFactory.create(1) + const lists = ListFactory.create(1, { + title: 'First List' + }) + TaskFactory.truncate() + }) + + it('Should create a new list', () => { + cy.visit('/') + cy.get('a.nsettings[href="/namespaces/1/list"]') + .click() + cy.url() + .should('contain', '/namespaces/1/list') + cy.get('h3') + .contains('Create a new list') + cy.get('input.input') + .type('New List') + cy.get('button.is-success') + .contains('Add') + .click() + + cy.wait(3000) // Waiting until the request to create the new list is done + cy.get('.global-notification') + .should('contain', 'Success') + cy.url() + .should('contain', '/lists/') + cy.get('.list-title h1') + .should('contain', 'New List') + }) + + it('Should redirect to a specific list view after visited', () => { + cy.visit('/lists/1/kanban') + cy.url() + .should('contain', '/lists/1/kanban') + cy.visit('/lists/1') + cy.url() + .should('contain', '/lists/1/kanban') + }) + + describe('List View', () => { + it('Should be an empty list', () => { + cy.visit('/lists/1') + cy.url() + .should('contain', '/lists/1/list') + cy.get('.list-title h1') + .should('contain', 'First List') + cy.get('.list-title a.icon') + .should('have.attr', 'href') + .and('include', '/lists/1/edit') + cy.get('.list-is-empty-notice') + .should('contain', 'This list is currently empty.') + }) + + it('Should navigate to the task when the title is clicked', () => { + const tasks = TaskFactory.create(5, { + id: '{increment}', + list_id: 1, + }) + cy.visit('/lists/1/list') + + cy.get('.tasks .task .tasktext') + .contains(tasks[0].title) + .first() + .click() + + cy.url() + .should('contain', `/tasks/${tasks[0].id}`) + }) + + it('Should not see any elements for a list which is shared read only', () => { + UserFactory.create(2) + UserListFactory.create(1, { + list_id: 2, + user_id: 1, + right: 0, + }) + const lists = ListFactory.create(2, { + owner_id: '{increment}', + namespace_id: '{increment}', + }) + cy.visit(`/lists/${lists[1].id}/`) + + cy.get('.list-title a.icon') + .should('not.exist') + cy.get('input.input[placeholder="Add a new task..."') + .should('not.exist') + }) + }) + + describe('Table View', () => { + it('Should show a table with tasks', () => { + const tasks = TaskFactory.create(1) + cy.visit('/lists/1/table') + + cy.get('.table-view table.table') + .should('exist') + cy.get('.table-view table.table') + .should('contain', tasks[0].title) + }) + + it('Should have working column switches', () => { + TaskFactory.create(1) + cy.visit('/lists/1/table') + + cy.get('.table-view .filter-container .items .button') + .contains('Columns') + .click() + cy.get('.table-view .filter-container .card .card-content .fancycheckbox .check') + .contains('Priority') + .click() + cy.get('.table-view .filter-container .card .card-content .fancycheckbox .check') + .contains('Done') + .click() + + cy.get('.table-view table.table th') + .contains('Priority') + .should('exist') + cy.get('.table-view table.table th') + .contains('Done') + .should('not.exist') + }) + + it('Should navigate to the task when the title is clicked', () => { + const tasks = TaskFactory.create(5, { + id: '{increment}', + list_id: 1, + }) + cy.visit('/lists/1/table') + + cy.get('.table-view table.table a') + .contains(tasks[0].title) + .first() + .click() + + cy.url() + .should('contain', `/tasks/${tasks[0].id}`) + }) + }) + + describe('Gantt View', () => { + it('Hides tasks with no dates', () => { + TaskFactory.create(1) + cy.visit('/lists/1/gantt') + + cy.get('.gantt-chart-container .gantt-chart.box .tasks') + .should('be.empty') + }) + + it('Shows tasks from the current and next month', () => { + const now = new Date() + cy.visit('/lists/1/gantt') + + cy.get('.gantt-chart-container .gantt-chart.box .months') + .should('contain', format(now, 'MMMM')) + .should('contain', format(now.setMonth(now.getMonth() + 1), 'MMMM')) + }) + + it('Shows tasks with dates', () => { + const now = new Date() + const tasks = TaskFactory.create(1, { + start_date: formatISO(now), + end_date: formatISO(now.setDate(now.getDate() + 4)) + }) + cy.visit('/lists/1/gantt') + + cy.get('.gantt-chart-container .gantt-chart.box .tasks') + .should('not.be.empty') + cy.get('.gantt-chart-container .gantt-chart.box .tasks') + .should('contain', tasks[0].title) + }) + + it('Shows tasks with no dates after enabling them', () => { + TaskFactory.create(1, { + start_date: null, + end_date: null, + }) + cy.visit('/lists/1/gantt') + + cy.get('.gantt-chart-container .gantt-options .fancycheckbox') + .contains('Show tasks which don\'t have dates set') + .click() + + cy.get('.gantt-chart-container .gantt-chart.box .tasks') + .should('not.be.empty') + cy.get('.gantt-chart-container .gantt-chart.box .tasks .task.nodate') + .should('exist') + }) + + it('Drags a task around', () => { + const now = new Date() + TaskFactory.create(1, { + start_date: formatISO(now), + end_date: formatISO(now.setDate(now.getDate() + 4)) + }) + cy.visit('/lists/1/gantt') + + cy.get('.gantt-chart-container .gantt-chart.box .tasks .task') + .first() + .trigger('mousedown', {which: 1}) + .trigger('mousemove', {clientX: 500, clientY: 0}) + .trigger('mouseup', {force: true}) + }) + }) + + describe('Kanban', () => { + let buckets + + beforeEach(() => { + buckets = BucketFactory.create(2) + }) + + it('Shows all buckets with their tasks', () => { + const data = TaskFactory.create(10, { + list_id: 1, + bucket_id: 1, + }) + cy.visit('/lists/1/kanban') + + cy.get('.kanban .bucket .title') + .contains(buckets[0].title) + .should('exist') + cy.get('.kanban .bucket .title') + .contains(buckets[1].title) + .should('exist') + cy.get('.kanban .bucket') + .first() + .should('contain', data[0].title) + }) + + it('Can add a new task to a bucket', () => { + const data = TaskFactory.create(2, { + list_id: 1, + bucket_id: 1, + }) + cy.visit('/lists/1/kanban') + + cy.get('.kanban .bucket') + .contains(buckets[0].title) + .get('.bucket-footer .button') + .contains('Add another task') + .click() + cy.get('.kanban .bucket') + .contains(buckets[0].title) + .get('.bucket-footer .field .control input.input') + .type('New Task{enter}') + + cy.get('.kanban .bucket') + .first() + .should('contain', 'New Task') + }) + + it('Can create a new bucket', () => { + cy.visit('/lists/1/kanban') + + cy.get('.kanban .bucket.new-bucket .button') + .click() + cy.get('.kanban .bucket.new-bucket input.input') + .type('New Bucket{enter}') + + cy.wait(1000) // Wait for the request to finish + cy.get('.kanban .bucket .title') + .contains('New Bucket') + .should('exist') + }) + + it('Can set a bucket limit', () => { + cy.visit('/lists/1/kanban') + + cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger') + .first() + .click() + cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item') + .contains('Limit: Not set') + .click() + cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item .field input.input') + .first() + .type(3) + cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item .field a.button.is-primary') + .first() + .click() + + cy.get('.kanban .bucket .bucket-header span.limit') + .contains('0/3') + .should('exist') + }) + + it('Can rename a bucket', () => { + cy.visit('/lists/1/kanban') + + cy.get('.kanban .bucket .bucket-header .title') + .first() + .type('{selectall}New Bucket Title{enter}') + cy.get('.kanban .bucket .bucket-header .title') + .first() + .should('contain', 'New Bucket Title') + }) + + it('Can delete a bucket', () => { + cy.visit('/lists/1/kanban') + + cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger') + .first() + .click() + cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item') + .contains('Delete') + .click() + cy.get('.modal-mask .modal-container .modal-content .header') + .should('contain', 'Delete the bucket') + cy.get('.modal-mask .modal-container .modal-content .actions .button') + .contains('Do it!') + .click() + + cy.get('.kanban .bucket .title') + .contains(buckets[0].title) + .should('not.exist') + }) + + + // The following test does not work. It seems like vue-smooth-dnd does not use either mousemove or dragstart + // (not sure why this actually works at all?) and as I'm planning to swap that out for vuedraggable/sortable.js + // anyway, I figured it wouldn't be worth the hassle right now. + +// it('Can drag tasks around', () => { +// const tasks = TaskFactory.create(2, { +// list_id: 1, +// bucket_id: 1, +// }) +// cy.visit('/lists/1/kanban') +// +// cy.get('.kanban .bucket .tasks .task') +// .contains(tasks[0].title) +// .first() +// .drag('.kanban .bucket:nth-child(2) .tasks .smooth-dnd-container.vertical') +// .trigger('mousedown', {which: 1}) +// .trigger('mousemove', {clientX: 500, clientY: 0}) +// .trigger('mouseup', {force: true}) +// }) + + it('Should navigate to the task when the task card is clicked', () => { + const tasks = TaskFactory.create(5, { + id: '{increment}', + list_id: 1, + bucket_id: 1, + }) + cy.visit('/lists/1/kanban') + + cy.get('.kanban .bucket .tasks .task') + .contains(tasks[0].title) + .first() + .click() + + cy.url() + .should('contain', `/tasks/${tasks[0].id}`) + }) + }) +}) diff --git a/cypress/integration/list/namespaces.spec.js b/cypress/integration/list/namespaces.spec.js new file mode 100644 index 0000000000..9735166b91 --- /dev/null +++ b/cypress/integration/list/namespaces.spec.js @@ -0,0 +1,39 @@ +import {UserFactory} from '../../factories/user' + +import '../../support/authenticateUser' +import {ListFactory} from '../../factories/list' +import {NamespaceFactory} from '../../factories/namespace' + +describe('Namepaces', () => { + let namespaces + + beforeEach(() => { + UserFactory.create(1) + namespaces = NamespaceFactory.create(1) + ListFactory.create(1) + }) + + it('Should be all there', () => { + cy.visit('/namespaces') + cy.get('.namespace h1 span') + .should('contain', namespaces[0].title) + }) + + it('Should create a new Namespace', () => { + cy.visit('/namespaces') + cy.get('a.button') + .contains('Create new namespace') + .click() + cy.url() + .should('contain', '/namespaces/new') + cy.get('h3') + .should('contain', 'Create a new namespace') + cy.get('input.input') + .type('New Namespace') + cy.get('button.is-success') + .contains('Add') + .click() + cy.url() + .should('contain', '/namespaces') + }) +}) diff --git a/cypress/integration/misc/editor.spec.js b/cypress/integration/misc/editor.spec.js new file mode 100644 index 0000000000..253e64dcc5 --- /dev/null +++ b/cypress/integration/misc/editor.spec.js @@ -0,0 +1,37 @@ +import {TaskFactory} from '../../factories/task' +import {ListFactory} from '../../factories/list' +import {NamespaceFactory} from '../../factories/namespace' +import {UserListFactory} from '../../factories/users_list' + +import '../../support/authenticateUser' + +describe('Editor', () => { + beforeEach(() => { + NamespaceFactory.create(1) + const lists = ListFactory.create(1) + TaskFactory.truncate() + UserListFactory.truncate() + }) + + it('Has a preview with checkable checkboxes', () => { + const tasks = TaskFactory.create(1, { + description: `# Test Heading +* Bullet 1 +* Bullet 2 + +* [ ] Checklist +* [x] Checklist checked +`, + }) + + cy.visit(`/tasks/${tasks[0].id}`) + cy.get('input[type=checkbox][data-checkbox-num=0]') + .click() + + cy.get('.task-view .details.content.description h3 span.is-small.has-text-success') + .contains('Saved!') + .should('exist') + cy.get('.preview.content') + .should('contain', 'Test Heading') + }) +}) \ No newline at end of file diff --git a/cypress/integration/misc/menu.spec.js b/cypress/integration/misc/menu.spec.js new file mode 100644 index 0000000000..98e228f2f6 --- /dev/null +++ b/cypress/integration/misc/menu.spec.js @@ -0,0 +1,29 @@ +import '../../support/authenticateUser' + +describe('The Menu', () => { + it('Is visible by default on desktop', () => { + cy.get('.namespace-container') + .should('have.class', 'is-active') + }) + + it('Can be hidden on desktop', () => { + cy.get('a.menu-show-button:visible') + .click() + cy.get('.namespace-container') + .should('not.have.class', 'is-active') + }) + + it('Is hidden by default on mobile', () => { + cy.viewport('iphone-8') + cy.get('.namespace-container') + .should('not.have.class', 'is-active') + }) + + it('Is can be shown on mobile', () => { + cy.viewport('iphone-8') + cy.get('a.menu-show-button:visible') + .click() + cy.get('.namespace-container') + .should('have.class', 'is-active') + }) +}) diff --git a/cypress/integration/sharing/linkShare.spec.js b/cypress/integration/sharing/linkShare.spec.js new file mode 100644 index 0000000000..bc616cc6f0 --- /dev/null +++ b/cypress/integration/sharing/linkShare.spec.js @@ -0,0 +1,25 @@ +import {LinkShareFactory} from '../../factories/link_sharing' +import {ListFactory} from '../../factories/list' +import {TaskFactory} from '../../factories/task' + +describe('Link shares', () => { + it('Can view a link share', () => { + const lists = ListFactory.create(1) + const tasks = TaskFactory.create(10, { + list_id: lists[0].id + }) + const linkShares = LinkShareFactory.create(1, { + list_id: lists[0].id, + right: 0, + }) + + cy.visit(`/share/${linkShares[0].hash}/auth`) + + cy.get('h1.title') + .should('contain', lists[0].title) + cy.get('input.input[placeholder="Add a new task..."') + .should('not.exist') + cy.get('.tasks') + .should('contain', tasks[0].title) + }) +}) diff --git a/cypress/integration/sharing/team.spec.js b/cypress/integration/sharing/team.spec.js new file mode 100644 index 0000000000..1ec4b11f3f --- /dev/null +++ b/cypress/integration/sharing/team.spec.js @@ -0,0 +1,91 @@ +import {TeamFactory} from '../../factories/team' +import {TeamMemberFactory} from '../../factories/team_member' +import '../../support/authenticateUser' + +describe('Team', () => { + it('Creates a new team', () => { + TeamFactory.truncate() + cy.visit('/teams') + + cy.get('a.button') + .contains('New Team') + .click() + cy.url() + .should('contain', '/teams/new') + cy.get('h3') + .contains('Create a new team') + cy.get('input.input') + .type('New Team') + cy.get('button.is-success') + .contains('Add') + .click() + + cy.get('.fullpage') + .should('not.exist') + cy.url() + .should('contain', '/edit') + cy.get('.card-header .card-header-title') + .first() + .should('contain', 'Edit Team') + }) + + it('Shows all teams', () => { + TeamMemberFactory.create(10, { + team_id: '{increment}', + }) + const teams = TeamFactory.create(10, { + id: '{increment}', + }) + + cy.visit('/teams') + + cy.get('.teams.box') + .should('not.be.empty') + teams.forEach(t => { + cy.get('.teams.box') + .should('contain', t.name) + }) + }) + + it('Allows an admin to edit the team', () => { + TeamMemberFactory.create(1, { + team_id: 1, + admin: true, + }) + const teams = TeamFactory.create(1, { + id: 1, + }) + + cy.visit('/teams/1/edit') + cy.get('.card input.input') + .first() + .type('{selectall}New Team Name') + + cy.get('.card .button') + .contains('Save') + .click() + + cy.get('table.table td') + .contains('Admin') + .should('exist') + cy.get('.global-notification') + .should('contain', 'Success') + }) + + it('Does not allow a normal user to edit the team', () => { + TeamMemberFactory.create(1, { + team_id: 1, + admin: false, + }) + const teams = TeamFactory.create(1, { + id: 1, + }) + + cy.visit('/teams/1/edit') + cy.get('.card input.input') + .should('not.exist') + cy.get('table.table td') + .contains('Member') + .should('exist') + }) +}) diff --git a/cypress/integration/task/task.spec.js b/cypress/integration/task/task.spec.js new file mode 100644 index 0000000000..8f8dbbe64a --- /dev/null +++ b/cypress/integration/task/task.spec.js @@ -0,0 +1,237 @@ +import {formatISO} from 'date-fns' + +import {TaskFactory} from '../../factories/task' +import {ListFactory} from '../../factories/list' +import {TaskCommentFactory} from '../../factories/task_comment' +import {UserFactory} from '../../factories/user' +import {NamespaceFactory} from '../../factories/namespace' +import {UserListFactory} from '../../factories/users_list' + +import '../../support/authenticateUser' + +describe('Task', () => { + let namespaces + let lists + + beforeEach(() => { + UserFactory.create(1) + namespaces = NamespaceFactory.create(1) + lists = ListFactory.create(1) + TaskFactory.truncate() + UserListFactory.truncate() + }) + + it('Should be created new', () => { + cy.visit('/lists/1/list') + cy.get('input.input[placeholder="Add a new task..."') + .type('New Task') + cy.get('button.button.is-success') + .contains('Add') + .click() + cy.get('.tasks .task .tasktext') + .first() + .should('contain', 'New Task') + }) + + it('Inserts new tasks at the top of the list', () => { + TaskFactory.create(1) + + cy.visit('/lists/1/list') + cy.get('.list-is-empty-notice') + .should('not.exist') + cy.get('input.input[placeholder="Add a new task..."') + .type('New Task') + cy.get('button.button.is-success') + .contains('Add') + .click() + + cy.wait(1000) // Wait for the request + cy.get('.tasks .task .tasktext') + .first() + .should('contain', 'New Task') + }) + + it('Marks a task as done', () => { + TaskFactory.create(1) + + cy.visit('/lists/1/list') + cy.get('.tasks .task .fancycheckbox label.check') + .first() + .click() + cy.get('.global-notification') + .should('contain', 'Success') + }) + + it('Can add a task to favorites', () => { + TaskFactory.create(1) + + cy.visit('/lists/1/list') + cy.get('.tasks .task .favorite') + .first() + .click() + cy.get('.menu.namespaces-lists') + .should('contain', 'Favorites') + }) + + describe('Task Detail View', () => { + beforeEach(() => { + TaskCommentFactory.truncate() + }) + + it('Shows all task details', () => { + const tasks = TaskFactory.create(1, { + id: 1, + index: 1, + description: 'Lorem ipsum dolor sit amet.' + }) + cy.visit(`/tasks/${tasks[0].id}`) + + cy.get('.task-view h1.title.input') + .should('contain', tasks[0].title) + cy.get('.task-view h1.title.task-id') + .should('contain', '#1') + cy.get('.task-view h6.subtitle') + .should('contain', namespaces[0].title) + .should('contain', lists[0].title) + cy.get('.task-view .details.content.description') + .should('contain', tasks[0].description) + cy.get('.task-view .action-buttons p.created') + .should('contain', 'Created') + }) + + it('Shows a done label for done tasks', () => { + const tasks = TaskFactory.create(1, { + id: 1, + index: 1, + done: true, + done_at: formatISO(new Date()) + }) + cy.visit(`/tasks/${tasks[0].id}`) + + cy.get('.task-view .heading .is-done') + .should('exist') + .should('contain', 'Done') + cy.get('.task-view .action-buttons p.created') + .should('contain', 'Done') + }) + + it('Can mark a task as done', () => { + const tasks = TaskFactory.create(1, { + id: 1, + done: false, + }) + cy.visit(`/tasks/${tasks[0].id}`) + + cy.get('.task-view .action-buttons .button') + .contains('Done!') + .click() + + cy.get('.task-view .heading .is-done') + .should('exist') + .should('contain', 'Done') + cy.get('.global-notification') + .should('contain', 'Success') + cy.get('.task-view .action-buttons .button') + .should('contain', 'Mark as undone') + }) + + it('Shows a task identifier since the list has one', () => { + const lists = ListFactory.create(1, { + id: 1, + identifier: 'TEST', + }) + const tasks = TaskFactory.create(1, { + id: 1, + list_id: lists[0].id, + index: 1, + }) + + cy.visit(`/tasks/${tasks[0].id}`) + + cy.get('.task-view h1.title.task-id') + .should('contain', `${lists[0].identifier}-${tasks[0].index}`) + }) + + it('Can edit the description', () => { + const tasks = TaskFactory.create(1, { + id: 1, + description: 'Lorem ipsum dolor sit amet.' + }) + cy.visit(`/tasks/${tasks[0].id}`) + + cy.get('.task-view .details.content.description .editor a') + .contains('Edit') + .click() + cy.get('.task-view .details.content.description .editor .vue-easymde .EasyMDEContainer .CodeMirror-scroll') + .type('{selectall}New Description') + cy.get('.task-view .details.content.description .editor a') + .contains('Preview') + .click() + + cy.get('.task-view .details.content.description h3 span.is-small.has-text-success') + .contains('Saved!') + .should('exist') + }) + + it('Can add a new comment', () => { + const tasks = TaskFactory.create(1, { + id: 1, + }) + cy.visit(`/tasks/${tasks[0].id}`) + + cy.get('.task-view .comments .media.comment .editor .vue-easymde .EasyMDEContainer .CodeMirror-scroll') + .type('{selectall}New Comment') + cy.get('.task-view .comments .media.comment .button.is-primary') + .contains('Comment') + .click() + + cy.get('.task-view .comments .media.comment .editor') + .should('contain', 'New Comment') + cy.get('.global-notification') + .should('contain', 'Success') + }) + + it('Can move a task to another list', () => { + const lists = ListFactory.create(2) + const tasks = TaskFactory.create(1, { + id: 1, + list_id: lists[0].id, + }) + cy.visit(`/tasks/${tasks[0].id}`) + + cy.get('.task-view .action-buttons .button') + .contains('Move task') + .click() + cy.get('.task-view .content.details .field .multiselect.control .multiselect__tags .multiselect__input') + .type(`${lists[1].title}{enter}`) + + cy.get('.task-view h6.subtitle') + .should('contain', namespaces[0].title) + .should('contain', lists[1].title) + cy.get('.global-notification') + .should('contain', 'Success') + }) + + it('Can delete a task', () => { + const tasks = TaskFactory.create(1, { + id: 1, + list_id: 1, + }) + cy.visit(`/tasks/${tasks[0].id}`) + + cy.get('.task-view .action-buttons .button') + .contains('Delete task') + .click() + cy.get('.modal-mask .modal-container .modal-content .header') + .should('contain', 'Delete this task') + cy.get('.modal-mask .modal-container .modal-content .actions .button') + .contains('Do it!') + .click() + + cy.get('.global-notification') + .should('contain', 'Success') + cy.url() + .should('contain', `/lists/${tasks[0].list_id}/`) + }) + }) +}) diff --git a/cypress/integration/user/login.spec.js b/cypress/integration/user/login.spec.js new file mode 100644 index 0000000000..f89aa6cc6b --- /dev/null +++ b/cypress/integration/user/login.spec.js @@ -0,0 +1,57 @@ +import {UserFactory} from '../../factories/user' + +const testAndAssertFailed = fixture => { + cy.visit('/login') + cy.get('input[id=username]').type(fixture.username) + cy.get('input[id=password]').type(fixture.password) + cy.get('button').contains('Login').click() + + cy.wait(5000) // It can take waaaayy too long to log the user in + cy.url().should('include', '/') + cy.get('div.notification.is-danger').contains('Wrong username or password.') +} + +context('Login', () => { + beforeEach(() => { + UserFactory.create(1, { + username: 'test', + }) + cy.visit('/', { + onBeforeLoad(win) { + win.localStorage.removeItem('token') + }, + }) + }) + + it('Should log in with the right credentials', () => { + const fixture = { + username: 'test', + password: '1234', + } + + cy.visit('/login') + cy.get('input[id=username]').type(fixture.username) + cy.get('input[id=password]').type(fixture.password) + cy.get('button').contains('Login').click() + cy.url().should('include', '/') + cy.get('h2').should('contain', `Hi ${fixture.username}!`) + }) + + it('Should fail with a bad password', () => { + const fixture = { + username: 'test', + password: '123456', + } + + testAndAssertFailed(fixture) + }) + + it('Should fail with a bad username', () => { + const fixture = { + username: 'loremipsum', + password: '1234', + } + + testAndAssertFailed(fixture) + }) +}) diff --git a/cypress/integration/user/logout.spec.js b/cypress/integration/user/logout.spec.js new file mode 100644 index 0000000000..fbbc7088c5 --- /dev/null +++ b/cypress/integration/user/logout.spec.js @@ -0,0 +1,16 @@ +import '../../support/authenticateUser' + +describe('Log out', () => { + it('Logs the user out', () => { + cy.visit('/') + + cy.get('.navbar .user .username') + .click() + cy.get('.navbar .user .dropdown-menu a.dropdown-item') + .contains('Logout') + .click() + + cy.url() + .should('contain', '/login') + }) +}) diff --git a/cypress/integration/user/registration.spec.js b/cypress/integration/user/registration.spec.js new file mode 100644 index 0000000000..b61b93c5e1 --- /dev/null +++ b/cypress/integration/user/registration.spec.js @@ -0,0 +1,49 @@ +// This test assumes no mailer is set up and all users are activated immediately. + +import {UserFactory} from '../../factories/user' + +context('Registration', () => { + beforeEach(() => { + UserFactory.create(1, { + username: 'test', + }) + cy.visit('/', { + onBeforeLoad(win) { + win.localStorage.removeItem('token') + }, + }) + }) + + it('Should work without issues', () => { + const fixture = { + username: 'testuser', + password: '123456', + email: 'testuser@example.com', + } + + cy.visit('/register') + cy.get('#username').type(fixture.username) + cy.get('#email').type(fixture.email) + cy.get('#password1').type(fixture.password) + cy.get('#password2').type(fixture.password) + cy.get('button#register-submit').click() + cy.url().should('include', '/') + cy.get('h2').should('contain', `Hi ${fixture.username}!`) + }) + + it('Should fail', () => { + const fixture = { + username: 'test', + password: '123456', + email: 'testuser@example.com', + } + + cy.visit('/register') + cy.get('#username').type(fixture.username) + cy.get('#email').type(fixture.email) + cy.get('#password1').type(fixture.password) + cy.get('#password2').type(fixture.password) + cy.get('button#register-submit').click() + cy.get('div.notification.is-danger').contains('A user with this username already exists.') + }) +}) \ No newline at end of file diff --git a/cypress/integration/user/settings.spec.js b/cypress/integration/user/settings.spec.js new file mode 100644 index 0000000000..bcc5cd9aee --- /dev/null +++ b/cypress/integration/user/settings.spec.js @@ -0,0 +1,43 @@ +import {UserFactory} from '../../factories/user' + +import '../../support/authenticateUser' + +describe('User Settings', () => { + beforeEach(() => { + UserFactory.create(1) + }) + + it('Changes the user avatar', () => { + cy.visit('/user/settings') + + cy.get('input[name=avatarProvider][value=upload]') + .click() + cy.get('input[type=file]') + .attachFile('image.jpg') + cy.get('.vue-handler-wrapper.vue-handler-wrapper--south .vue-simple-handler.vue-simple-handler--south') + .trigger('mousedown', {which: 1}) + .trigger('mousemove', {clientY: 100}) + .trigger('mouseup') + cy.get('a.button.is-primary') + .contains('Upload Avatar') + .click() + + cy.get('.global-notification') + .should('contain', 'Success') + }) + + it('Updates the name', () => { + cy.visit('/user/settings') + + cy.get('input#newName') + .type('Lorem Ipsum') + cy.get('.card.update-name button.button.is-primary') + .contains('Save') + .click() + + cy.get('.global-notification') + .should('contain', 'Success') + cy.get('.navbar .user .username') + .should('contain', 'Lorem Ipsum') + }) +}) diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js new file mode 100644 index 0000000000..aa9918d215 --- /dev/null +++ b/cypress/plugins/index.js @@ -0,0 +1,21 @@ +/// +// *********************************************************** +// This example plugins/index.js can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) + +/** + * @type {Cypress.PluginConfig} + */ +module.exports = (on, config) => { + // `on` is used to hook into various events Cypress emits + // `config` is the resolved Cypress config +} diff --git a/cypress/support/authenticateUser.js b/cypress/support/authenticateUser.js new file mode 100644 index 0000000000..5f2423a465 --- /dev/null +++ b/cypress/support/authenticateUser.js @@ -0,0 +1,29 @@ + +// This authenticates a user and puts the token in local storage which allows us to perform authenticated requests. +// Built after https://github.com/cypress-io/cypress-example-recipes/tree/bd2d6ffb33214884cab343d38e7f9e6ebffb323f/examples/logging-in__jwt + +import {UserFactory} from '../factories/user' + +let token + +before(() => { + const users = UserFactory.create(1) + + cy.request('POST', `${Cypress.env('API_URL')}/login`, { + username: users[0].username, + password: '1234', + }) + .its('body') + .then(r => { + token = r.token + }) +}) + +beforeEach(() => { + cy.log(`Using token ${token} to make authenticated requests`) + cy.visit('/', { + onBeforeLoad(win) { + win.localStorage.setItem('token', token) + }, + }) +}) diff --git a/cypress/support/factory.js b/cypress/support/factory.js new file mode 100644 index 0000000000..7dc1f71897 --- /dev/null +++ b/cypress/support/factory.js @@ -0,0 +1,46 @@ +import {seed} from './seed' +import merge from 'lodash/merge' + +/** + * A factory makes it easy to seed the database with data. + */ +export class Factory { + static table = null + + static factory() { + return {} + } + + /** + * Seeds a bunch of fake data into the database. + * + * Takes an override object as its single argument which will override the data from the factory. + * If the value of one of the override fields is `{increment}` that value will be replaced with an incrementing + * number through all created entities. + * + * @param override + * @returns {[]} + */ + static create(count = 1, override = {}) { + const data = [] + + for (let i = 1; i <= count; i++) { + const entry = merge(this.factory(), override) + for (const e in entry) { + if (entry[e] === '{increment}') { + entry[e] = i + } + } + data.push(entry) + } + + seed(this.table, data) + + return data + } + + static truncate() { + seed(this.table, null) + } +} + diff --git a/cypress/support/index.js b/cypress/support/index.js new file mode 100644 index 0000000000..a51369df05 --- /dev/null +++ b/cypress/support/index.js @@ -0,0 +1,2 @@ + +import 'cypress-file-upload' diff --git a/cypress/support/seed.js b/cypress/support/seed.js new file mode 100644 index 0000000000..acb1b2df4c --- /dev/null +++ b/cypress/support/seed.js @@ -0,0 +1,24 @@ +/** + * Seeds a db table with data. If a data object is provided as the second argument, it will load the fixtures + * file for the table and merge the data from it with the passed data. This allows you to override specific + * fields of the fixtures without having to redeclare the whole fixture. + * + * Passing null as the second argument empties the table. + * + * @param table + * @param data + */ +export function seed(table, data = {}) { + if(data === null) { + data = [] + } + + cy.request({ + method: 'PATCH', + url: `${Cypress.env('API_URL')}/test/${table}`, + headers: { + 'Authorization': Cypress.env('TEST_SECRET'), + }, + body: data, + }) +} diff --git a/package.json b/package.json index f916f7a458..022ba1d516 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,12 @@ "private": true, "scripts": { "serve": "vue-cli-service serve", + "serve:dist": "node scripts/serve-dist.js", "build": "vue-cli-service build --modern", "lint": "vue-cli-service lint --ignore-pattern '*.test.*'", - "test": "jest" + "cypress:open": "cypress open", + "test:unit": "jest", + "test:frontend": "cypress run" }, "dependencies": { "bulma": "0.9.1", @@ -39,8 +42,11 @@ "@vue/cli-service": "4.5.9", "axios": "0.21.0", "babel-eslint": "10.1.0", + "cypress": "6.0.0", + "cypress-file-upload": "^4.1.1", "eslint": "7.15.0", "eslint-plugin-vue": "7.2.0", + "faker": "5.1.0", "jest": "26.6.3", "node-sass": "5.0.0", "sass-loader": "10.1.0", @@ -48,7 +54,8 @@ "vue-multiselect": "2.1.6", "vue-notification": "1.3.20", "vue-router": "3.4.9", - "vue-template-compiler": "2.6.12" + "vue-template-compiler": "2.6.12", + "wait-on": "^5.2.0" }, "eslintConfig": { "root": true, @@ -62,7 +69,11 @@ "rules": {}, "parserOptions": { "parser": "babel-eslint" - } + }, + "ignorePatterns": [ + "*.test.js", + "cypress/*" + ] }, "postcss": { "plugins": { @@ -74,5 +85,8 @@ "last 2 versions", "not ie <= 8" ], - "license": "LGPL-3.0-or-later" + "license": "LGPL-3.0-or-later", + "jest": { + "testPathIgnorePatterns": ["cypress"] + } } diff --git a/scripts/serve-dist.js b/scripts/serve-dist.js new file mode 100644 index 0000000000..842f634f1d --- /dev/null +++ b/scripts/serve-dist.js @@ -0,0 +1,16 @@ +const path = require('path') +const express = require('express') +const app = express() + +const p = path.join(__dirname, '..', 'dist') +const port = 8080 + +app.use(express.static(p)) +// Handle urls set by the frontend +app.get('*', (request, response, next) => { + response.sendFile(`${p}/index.html`) +}) +app.listen(port, '127.0.0.1', () => { + console.log(`Serving files from ${p}`) + console.log(`Server started on port ${port}`) +}) diff --git a/src/components/home/navigation.vue b/src/components/home/navigation.vue index 314c194f69..d94d27bb6b 100644 --- a/src/components/home/navigation.vue +++ b/src/components/home/navigation.vue @@ -151,10 +151,7 @@ export default { this.$store.dispatch('namespaces/loadNamespaces') }, created() { - // Hide the menu by default on mobile - if (window.innerWidth < 770) { - this.$store.commit(MENU_ACTIVE, false) - } + window.addEventListener('resize', this.resize) }, methods: { toggleFavoriteList(list) { @@ -166,6 +163,14 @@ export default { this.$store.dispatch('lists/toggleListFavorite', list) .catch(e => this.error(e, this)) }, + resize() { + // Hide the menu by default on mobile + if (window.innerWidth < 770) { + this.$store.commit(MENU_ACTIVE, false) + } else { + this.$store.commit(MENU_ACTIVE, true) + } + }, }, } diff --git a/src/components/misc/notification.vue b/src/components/misc/notification.vue index 1b000cc9c3..4ad3f2bcca 100644 --- a/src/components/misc/notification.vue +++ b/src/components/misc/notification.vue @@ -1,5 +1,5 @@