Browse Source

Frontend Testing With Cypress (#313)

Wait until the request is finished

Wait for the newly created task exists in the dom

Wait until the login request is done

Wait until the list request is done

Make sure no user token is in local storage when trying to register

Make sure to always upload test results

Disable capturing videos of test runs in CI

Add uploading test result screenshots from ci

Assert a success notification is shown after creating a new list

Change input element locators

Fix testing for favorite lists

Make sure faked usernames are always random

Make sure the tests work

Make sure to use node 12 everywhere in ci

Add docs

Fix setting api url for running tests

Use a working node version

Ignore cypress screenshots and videos

Set cache folders

Explicitly ignore cypress files when running unit tests

Trigger Drone

Only run unit tests with yarn test:unit

Add serve dist command to serve built static files

Trigger Drone

Fix cypress image

Change cypress image

Unify test & build step back again to prevent double installation of dependencies

Add cache location config

Move test steps to separate pipeline

Run cypress tests in drone

Fix all tests

Make all factory methods static

Use factories everywhere

Cleanup

Add tests for the editor

Add tests for viewing link shares

Fix seed

Add test to make sure settings elements are hidden if the user does not have the right to edit the current list

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: #313
Co-Authored-By: konrad <konrad@kola-entertainments.de>
Co-Committed-By: konrad <konrad@kola-entertainments.de>
pull/325/head
konrad 7 months ago
parent
commit
b822b3616b
  1. 69
      .drone.yml
  2. 4
      .gitignore
  3. 8
      cypress.json
  4. 48
      cypress/README.md
  5. 18
      cypress/docker-compose.yml
  6. 20
      cypress/factories/bucket.js
  7. 22
      cypress/factories/link_sharing.js
  8. 20
      cypress/factories/list.js
  9. 19
      cypress/factories/namespace.js
  10. 23
      cypress/factories/task.js
  11. 19
      cypress/factories/task_comment.js
  12. 18
      cypress/factories/team.js
  13. 15
      cypress/factories/team_member.js
  14. 21
      cypress/factories/user.js
  15. 19
      cypress/factories/users_list.js
  16. BIN
      cypress/fixtures/image.jpg
  17. 370
      cypress/integration/list/list.spec.js
  18. 39
      cypress/integration/list/namespaces.spec.js
  19. 37
      cypress/integration/misc/editor.spec.js
  20. 29
      cypress/integration/misc/menu.spec.js
  21. 25
      cypress/integration/sharing/linkShare.spec.js
  22. 91
      cypress/integration/sharing/team.spec.js
  23. 237
      cypress/integration/task/task.spec.js
  24. 57
      cypress/integration/user/login.spec.js
  25. 16
      cypress/integration/user/logout.spec.js
  26. 49
      cypress/integration/user/registration.spec.js
  27. 43
      cypress/integration/user/settings.spec.js
  28. 21
      cypress/plugins/index.js
  29. 29
      cypress/support/authenticateUser.js
  30. 46
      cypress/support/factory.js
  31. 2
      cypress/support/index.js
  32. 24
      cypress/support/seed.js
  33. 22
      package.json
  34. 16
      scripts/serve-dist.js
  35. 13
      src/components/home/navigation.vue
  36. 2
      src/components/misc/notification.vue
  37. 6
      src/views/tasks/TaskDetailView.vue
  38. 2
      src/views/user/Login.vue
  39. 2
      src/views/user/Register.vue
  40. 6
      src/views/user/Settings.vue
  41. 419
      yarn.lock

69
.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:

4
.gitignore

@ -20,3 +20,7 @@ yarn-error.log*
*.njsproj
*.sln
*.sw*
# Test files
cypress/screenshots
cypress/videos

8
cypress.json

@ -0,0 +1,8 @@
{
"baseUrl": "http://localhost:8080",
"env": {
"API_URL": "http://localhost:3456/api/v1",
"TEST_SECRET": "testingS3cr3et"
},
"video": false
}

48
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
```

18
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

20
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)
}
}
}

22
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)
}
}
}

20
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)
}
}
}

19
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)
}
}
}

23
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)
}
}
}

19
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)
}
}
}

18
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)
}
}
}

15
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()),
}
}
}

21
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)
}
}
}

19
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)
}
}
}

BIN
cypress/fixtures/image.jpg

After

Width: 1982  |  Height: 2973  |  Size: 872 KiB

370
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}`)
})
})
})

39
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')
})
})

37
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')
})
})

29
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')
})
})

25
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)
})
})

91
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')
})
})

237
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}/`)
})
})
})

57
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)
})
})

16
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')
})
})

49
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.')
})
})

43
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')
})
})

21
cypress/plugins/index.js

@ -0,0 +1,21 @@
/// <reference types="cypress" />
// ***********************************************************
// 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
}

29
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)
},
})
})

46
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)
}
}

2
cypress/support/index.js

@ -0,0 +1,2 @@
import 'cypress-file-upload'

24
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,
})
}

22
package.json

@ -4,9 +4,12 @@
"private": true,
"scripts": {
"serve": "vue-cli-service serve",