feat: migrate cypress e2e tests to playwright (#1739)

This commit is contained in:
kolaente
2025-11-27 16:34:48 +01:00
committed by GitHub
parent 23a6ae19ea
commit 51512c1cb4
80 changed files with 4075 additions and 3066 deletions

View File

@@ -13,8 +13,9 @@ runs:
- if: inputs.install-e2e-binaries == 'false'
shell: bash
run: |
echo "CYPRESS_INSTALL_BINARY=0" >> $GITHUB_ENV
echo "PUPPETEER_SKIP_DOWNLOAD=true" >> $GITHUB_ENV
echo "CYPRESS_INSTALL_BINARY=0" >> $GITHUB_ENV
echo "PUPPETEER_SKIP_DOWNLOAD=true" >> $GITHUB_ENV
echo "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1" >> $GITHUB_ENV
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
with:
run_install: false

View File

@@ -346,7 +346,81 @@ jobs:
name: frontend_dist
path: ./frontend/dist
test-frontend-e2e:
test-frontend-e2e-playwright:
runs-on: ubuntu-latest
needs:
- api-build
- frontend-build
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6]
total-shards: [6]
services:
dex:
image: ghcr.io/go-vikunja/dex-testing:main@sha256:d401c06a9f8fd36ece446a07499b827232af7f21eb36872a76c9eac4d0c77bab
ports:
- 5556:5556
container:
image: mcr.microsoft.com/playwright:v1.57.0-jammy
options: --user 1001
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Download Vikunja Binary
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: vikunja_bin
- uses: ./.github/actions/setup-frontend
with:
install-e2e-binaries: false # Playwright browsers already in container
- name: Download Frontend
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: frontend_dist
path: ./frontend/dist
- run: chmod +x ./vikunja
- name: Run Playwright tests
timeout-minutes: 20
working-directory: frontend
run: |
pnpm run preview:vikunja &
pnpm run preview &
# Wait for services to be ready (using GET method)
pnpx wait-on http-get://127.0.0.1:4173 http-get://127.0.0.1:3456/api/v1/info --timeout 60000
pnpm run test:e2e --shard=${{ matrix.shard }}/${{ matrix.total-shards }}
env:
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS: 1
TEST_SECRET: averyLongSecretToSe33dtheDB
VIKUNJA_SERVICE_TESTINGTOKEN: averyLongSecretToSe33dtheDB
VIKUNJA_LOG_LEVEL: DEBUG
VIKUNJA_CORS_ENABLE: 1
VIKUNJA_SERVICE_PUBLICURL: http://127.0.0.1:3456
VIKUNJA_DATABASE_PATH: memory
VIKUNJA_DATABASE_TYPE: sqlite
VIKUNJA_RATELIMIT_NOAUTHLIMIT: 1000
VIKUNJA_AUTH_OPENID_ENABLED: 1
VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_NAME: Dex
VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_AUTHURL: http://dex:5556
VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_CLIENTID: vikunja
VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_CLIENTSECRET: secret
- name: Upload Playwright Report
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
if: always()
with:
name: playwright-report-${{ matrix.shard }}
path: frontend/playwright-report/
retention-days: 30
- name: Upload Test Results
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
if: always()
with:
name: playwright-test-results-${{ matrix.shard }}
path: frontend/test-results/
retention-days: 30
test-frontend-e2e-cypress:
runs-on: ubuntu-latest
needs:
- api-build
@@ -356,15 +430,6 @@ jobs:
image: ghcr.io/go-vikunja/dex-testing:main@sha256:d401c06a9f8fd36ece446a07499b827232af7f21eb36872a76c9eac4d0c77bab
ports:
- 5556:5556
strategy:
# when one test fails, DO NOT cancel the other
# containers, because this will kill Cypress processes
# leaving Cypress Cloud hanging ...
# https://github.com/cypress-io/github-action/issues/48
fail-fast: false
matrix:
# Only run parallel tests for non-fork PRs, single container for forks
containers: ${{ github.event.pull_request.head.repo.fork != true && fromJSON('[1, 2, 3, 4]') || fromJSON('[1]') }}
container:
image: cypress/browsers:latest@sha256:7331c596894429c9809c9a8bf92158224d151d5fa9736d14cf8e0268805a37ab
options: --user 1001
@@ -387,7 +452,6 @@ jobs:
timeout-minutes: 20
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
CYPRESS_API_URL: http://127.0.0.1:3456/api/v1
CYPRESS_TEST_SECRET: averyLongSecretToSe33dtheDB
CYPRESS_DEFAULT_COMMAND_TIMEOUT: 60000
@@ -408,19 +472,10 @@ jobs:
install: false
working-directory: frontend
browser: chrome
record: ${{ github.event.pull_request.head.repo.fork != true }}
parallel: ${{ github.event.pull_request.head.repo.fork != true }}
record: false
parallel: false
start: |
pnpm run preview:vikunja
pnpm run preview
wait-on: http://127.0.0.1:4173,http://127.0.0.1:3456/api/v1/info
wait-on-timeout: 10
# This step only exists so that we can make it required, because we can't make
# the actual test step required due to the matrix
test-frontend-e2e-success:
runs-on: ubuntu-latest
needs:
- test-frontend-e2e
steps:
- run: exit 0

38
desktop/pnpm-lock.yaml generated
View File

@@ -466,15 +466,6 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
debug@4.3.7:
resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
debug@4.4.0:
resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==}
engines: {node: '>=6.0'}
@@ -1340,11 +1331,6 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
semver@7.6.3:
resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==}
engines: {node: '>=10'}
hasBin: true
semver@7.7.2:
resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
engines: {node: '>=10'}
@@ -1951,7 +1937,7 @@ snapshots:
builder-util-runtime: 9.3.1
chromium-pickle-js: 0.2.0
config-file-ts: 0.2.8-rc1
debug: 4.3.7
debug: 4.4.1
dmg-builder: 26.0.12(electron-builder-squirrel-windows@24.13.3)
dotenv: 16.4.5
dotenv-expand: 11.0.6
@@ -1968,7 +1954,7 @@ snapshots:
minimatch: 10.0.1
plist: 3.1.0
resedit: 1.7.2
semver: 7.6.3
semver: 7.7.2
tar: 6.2.1
temp-file: 3.4.0
tiny-async-pool: 1.3.0
@@ -2048,7 +2034,7 @@ snapshots:
dependencies:
bytes: 3.1.2
content-type: 1.0.5
debug: 4.4.0
debug: 4.4.1
http-errors: 2.0.0
iconv-lite: 0.6.3
on-finished: 2.4.1
@@ -2090,7 +2076,7 @@ snapshots:
builder-util-runtime@9.3.1:
dependencies:
debug: 4.3.7
debug: 4.4.1
sax: 1.4.1
transitivePeerDependencies:
- supports-color
@@ -2124,7 +2110,7 @@ snapshots:
builder-util-runtime: 9.3.1
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.3.7
debug: 4.4.1
fs-extra: 10.1.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.5
@@ -2288,10 +2274,6 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
debug@4.3.7:
dependencies:
ms: 2.1.3
debug@4.4.0:
dependencies:
ms: 2.1.3
@@ -2565,7 +2547,7 @@ snapshots:
finalhandler@2.1.0:
dependencies:
debug: 4.4.0
debug: 4.4.1
encodeurl: 2.0.0
escape-html: 1.0.3
on-finished: 2.4.1
@@ -3228,7 +3210,7 @@ snapshots:
router@2.2.0:
dependencies:
debug: 4.4.0
debug: 4.4.1
depd: 2.0.0
is-promise: 4.0.0
parseurl: 1.3.3
@@ -3255,13 +3237,11 @@ snapshots:
semver@6.3.1: {}
semver@7.6.3: {}
semver@7.7.2: {}
send@1.2.0:
dependencies:
debug: 4.4.0
debug: 4.4.1
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
@@ -3331,7 +3311,7 @@ snapshots:
simple-update-notifier@2.0.0:
dependencies:
semver: 7.6.3
semver: 7.7.2
slice-ansi@3.0.0:
dependencies:

View File

@@ -23,9 +23,7 @@ in {
python3Packages.pip
python3Packages.fonttools
python3Packages.brotli
] ++ lib.optionals (!pkgs.stdenv.isDarwin) [
# Frontend tools (exclude on Darwin)
pkgs-unstable.cypress
nodejs
];
languages = {
@@ -49,6 +47,13 @@ in {
enable = true;
package = pkgs-unstable.mailpit;
};
env = {
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = "1";
# PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH = "${pkgs-unstable.chromium}/bin/chromium";
VIKUNJA_SERVICE_TESTINGTOKEN = "test";
};
devcontainer = {
enable = true;

4
frontend/.gitignore vendored
View File

@@ -20,6 +20,8 @@ coverage
# Test files
cypress/screenshots
cypress/videos
playwright-report/
test-results/
# local env files
.env.local
@@ -41,4 +43,4 @@ cypress/videos
# histoire
.histoire
TYPECHECK_ISSUES.md
package-lock.json

View File

@@ -1,35 +0,0 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
describe('The Menu', () => {
createFakeUserAndLogin()
beforeEach(() => {
cy.visit('/')
})
it('Is visible by default on desktop', () => {
cy.get('.menu-container')
.should('have.class', 'is-active')
})
it('Can be hidden on desktop', () => {
cy.get('button.menu-show-button:visible')
.click()
cy.get('.menu-container')
.should('not.have.class', 'is-active')
})
it('Is hidden by default on mobile', () => {
cy.viewport('iphone-8')
cy.get('.menu-container')
.should('not.have.class', 'is-active')
})
it('Is can be shown on mobile', () => {
cy.viewport('iphone-8')
cy.get('button.menu-show-button:visible')
.click()
cy.get('.menu-container')
.should('have.class', 'is-active')
})
})

View File

@@ -1,79 +0,0 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
import {ProjectFactory} from '../../factories/project'
import { createProjects } from './prepareProjects'
describe('Filter Persistence Across Views', () => {
createFakeUserAndLogin()
const openAndSetFilters = () => {
cy.get('.filter-container button')
.contains('Filters')
.click()
cy.get('.filter-popup')
.should('be.visible')
cy.get('.filter-popup .filter-input')
.type('done = true')
cy.get('.filter-popup button')
.contains('Show results')
.click()
}
beforeEach(() => {
createProjects()
TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
title: 'Test Task {increment}'
})
cy.visit('/projects/1/1')
})
it('should persist filters in List view after page refresh', () => {
openAndSetFilters()
cy.url().should('include', 'filter=')
cy.reload()
cy.url().should('include', 'filter=')
})
it('should persist filters in Table view after page refresh', () => {
cy.visit('/projects/1/3')
openAndSetFilters()
cy.url().should('include', 'filter=')
cy.reload()
cy.url().should('include', 'filter=')
})
it('should persist filters in Kanban view after page refresh', () => {
cy.visit('/projects/1/4')
openAndSetFilters()
cy.url().should('include', 'filter=')
cy.reload()
cy.url().should('include', 'filter=')
})
it('should handle URL sharing with filters', () => {
// Visit URL with pre-existing filter parameters
cy.visit('/projects/1/4?filter=done%3Dtrue&s=Test')
// Verify URL parameters are preserved
cy.url().should('include', 'filter=done%3Dtrue')
cy.url().should('include', 's=Test')
// Switch views and verify parameters persist
cy.visit('/projects/1/3?filter=done%3Dtrue&s=Test')
cy.url().should('include', 'filter=done%3Dtrue')
cy.url().should('include', 's=Test')
})
})

View File

@@ -1,59 +0,0 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {ProjectFactory} from '../../factories/project'
import {prepareProjects} from './prepareProjects'
import {ProjectViewFactory} from '../../factories/project_view'
describe('Project History', () => {
createFakeUserAndLogin()
prepareProjects()
it('should show a project history on the home page', () => {
cy.intercept(Cypress.env('API_URL') + '/projects*').as('loadProjectArray')
cy.intercept(Cypress.env('API_URL') + '/projects/*').as('loadProject')
const projects = ProjectFactory.create(7)
ProjectViewFactory.truncate()
projects.forEach(p => ProjectViewFactory.create(1, {
id: p.id,
project_id: p.id,
}, false))
cy.visit('/')
cy.wait('@loadProjectArray')
cy.get('body')
.should('not.contain', 'Last viewed')
cy.visit(`/projects/${projects[0].id}/${projects[0].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[1].id}/${projects[1].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[2].id}/${projects[2].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[3].id}/${projects[3].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[4].id}/${projects[4].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[5].id}/${projects[5].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[6].id}/${projects[6].id}`)
cy.wait('@loadProject')
// cy.visit('/')
// Not using cy.visit here to work around the redirect issue fixed in #1337
cy.get('nav.menu.top-menu a')
.contains('Overview')
.click()
cy.get('body')
.should('contain', 'Last viewed')
cy.get('[data-cy="projectCardGrid"]')
.should('not.contain', projects[0].title)
.should('contain', projects[1].title)
.should('contain', projects[2].title)
.should('contain', projects[3].title)
.should('contain', projects[4].title)
.should('contain', projects[5].title)
.should('contain', projects[6].title)
})
})

View File

@@ -1,155 +0,0 @@
import dayjs from 'dayjs'
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
import {prepareProjects} from './prepareProjects'
describe('Project View Gantt', () => {
createFakeUserAndLogin()
prepareProjects()
it('Hides tasks with no dates', () => {
const tasks = TaskFactory.create(1)
cy.visit('/projects/1/2')
cy.get('.gantt-rows')
.should('not.contain', tasks[0].title)
})
it('Shows tasks from the current and next month', () => {
const now = Date.UTC(2022, 8, 25)
cy.clock(now, ['Date'])
const nextMonth = new Date(now)
nextMonth.setDate(1)
nextMonth.setMonth(9)
cy.visit('/projects/1/2')
cy.get('.gantt-timeline-months')
.should('contain', dayjs(now).format('MMMM YYYY'))
.should('contain', dayjs(nextMonth).format('MMMM YYYY'))
})
it('Shows tasks with dates', () => {
const now = new Date()
const tasks = TaskFactory.create(1, {
start_date: now.toISOString(),
end_date: new Date(new Date(now).setDate(now.getDate() + 4)).toISOString(),
})
cy.visit('/projects/1/2')
cy.get('.gantt-rows')
.should('not.be.empty')
.should('contain', tasks[0].title)
})
it('Shows tasks with no dates after enabling them', () => {
const tasks = TaskFactory.create(1, {
start_date: null,
end_date: null,
})
cy.visit('/projects/1/2')
cy.get('.gantt-options .fancy-checkbox')
.contains('Show tasks without date')
.click()
cy.get('.gantt-rows')
.should('not.be.empty')
.should('contain', tasks[0].title)
})
it('Drags a task around', () => {
cy.intercept(Cypress.env('API_URL') + '/tasks/*').as('taskUpdate')
const now = new Date()
TaskFactory.create(1, {
start_date: now.toISOString(),
end_date: new Date(new Date(now).setDate(now.getDate() + 4)).toISOString(),
})
cy.visit('/projects/1/2')
cy.get('.gantt-rows .gantt-row-bars .gantt-bar')
.first()
.then($bar => {
// Get the current position of the bar
const rect = $bar[0].getBoundingClientRect()
const startX = rect.left + rect.width / 2
const startY = rect.top + rect.height / 2
// Trigger pointer events with proper coordinates and delays
cy.wrap($bar)
.trigger('pointerdown', {
clientX: startX,
clientY: startY,
pointerId: 1,
which: 1
})
.wait(100) // Wait to ensure double-click detection doesn't interfere
.trigger('pointermove', {
clientX: startX + 10, // Small initial movement to trigger drag
clientY: startY,
pointerId: 1
})
.trigger('pointermove', {
clientX: startX + 150, // Move 150px to the right (about 5 days)
clientY: startY,
pointerId: 1
})
.trigger('pointerup', {
clientX: startX + 150,
clientY: startY,
pointerId: 1,
force: true
})
})
cy.wait('@taskUpdate')
})
it('Should change the query parameters when selecting a date range', () => {
const now = Date.UTC(2022, 10, 9)
cy.clock(now, ['Date'])
cy.visit('/projects/1/2')
cy.get('.project-gantt .gantt-options .field .control input.input.form-control')
.click()
cy.get('.flatpickr-calendar .flatpickr-innerContainer .dayContainer .flatpickr-day')
.first()
.click()
cy.get('.flatpickr-calendar .flatpickr-innerContainer .dayContainer .flatpickr-day')
.last()
.click()
cy.url().should('contain', 'dateFrom=2022-09-25')
cy.url().should('contain', 'dateTo=2022-11-05')
})
it('Should change the date range based on date query parameters', () => {
cy.visit('/projects/1/2?dateFrom=2022-09-25&dateTo=2022-11-05')
cy.get('.gantt-timeline-months')
.should('contain', 'September 2022')
.should('contain', 'October 2022')
.should('contain', 'November 2022')
cy.get('.project-gantt .gantt-options .field .control input.input.form-control')
.should('have.value', '25 Sep 2022 to 5 Nov 2022')
})
it('Should open a task when double clicked on it', () => {
const now = new Date()
const tasks = TaskFactory.create(1, {
start_date: dayjs(now).format(),
end_date: dayjs(now.setDate(now.getDate() + 4)).format(),
})
cy.visit('/projects/1/2')
cy.get('.gantt-container .gantt-row-bars .gantt-bar')
.dblclick()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`)
})
})

View File

@@ -1,349 +0,0 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {BucketFactory} from '../../factories/bucket'
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {prepareProjects} from './prepareProjects'
import {ProjectViewFactory} from "../../factories/project_view";
import {TaskBucketFactory} from "../../factories/task_buckets";
import {
createTasksWithPriorities,
createTasksWithSearch,
} from '../../support/filterTestHelpers'
function createSingleTaskInBucket(count = 1, attrs = {}) {
const projects = ProjectFactory.create(1)
const views = ProjectViewFactory.create(1, {
id: 1,
project_id: projects[0].id,
view_kind: 3,
bucket_configuration_mode: 1,
})
const buckets = BucketFactory.create(2, {
project_view_id: views[0].id,
})
const tasks = TaskFactory.create(count, {
project_id: projects[0].id,
...attrs,
})
TaskBucketFactory.create(1, {
task_id: tasks[0].id,
bucket_id: buckets[0].id,
project_view_id: views[0].id,
})
return {
task: tasks[0],
view: views[0],
project: projects[0],
}
}
function createTaskWithBuckets(buckets, count = 1) {
const data = TaskFactory.create(count, {
project_id: 1,
})
TaskBucketFactory.truncate()
data.forEach(t => TaskBucketFactory.create(1, {
task_id: t.id,
bucket_id: buckets[0].id,
project_view_id: buckets[0].project_view_id,
}, false))
return data
}
describe('Project View Kanban', () => {
createFakeUserAndLogin()
prepareProjects()
let buckets
beforeEach(() => {
buckets = BucketFactory.create(2, {
project_view_id: 4,
})
})
it('Shows all buckets with their tasks', () => {
const data = createTaskWithBuckets(buckets, 10)
cy.visit('/projects/1/4')
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', () => {
createTaskWithBuckets(buckets, 2)
cy.visit('/projects/1/4')
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('/projects/1/4')
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('/projects/1/4')
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 .field input.input')
.first()
.type('3')
cy.get('[data-cy="setBucketLimit"]')
.first()
.click()
cy.get('.kanban .bucket .bucket-header span.limit')
.contains('0/3')
.should('exist')
})
it('Can rename a bucket', () => {
cy.visit('/projects/1/4')
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('/projects/1/4')
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 .modal-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')
cy.get('.kanban .bucket .title')
.contains(buckets[1].title)
.should('exist')
})
it('Can drag tasks around', () => {
const tasks = createTaskWithBuckets(buckets, 2)
cy.visit('/projects/1/4')
cy.get('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.first()
.drag('.kanban .bucket:nth-child(2) .tasks')
cy.get('.kanban .bucket:nth-child(2) .tasks')
.should('contain', tasks[0].title)
cy.get('.kanban .bucket:nth-child(1) .tasks')
.should('not.contain', tasks[0].title)
})
it('Should navigate to the task when the task card is clicked', () => {
const tasks = createTaskWithBuckets(buckets, 5)
cy.visit('/projects/1/4')
cy.get('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.should('be.visible')
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`, {timeout: 1000})
})
it('Should remove a task from the kanban board when moving it to another project', () => {
const projects = ProjectFactory.create(2)
const views = ProjectViewFactory.create(2, {
project_id: '{increment}',
view_kind: 3,
bucket_configuration_mode: 1,
})
BucketFactory.create(2)
const tasks = TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
})
TaskBucketFactory.create(5, {
project_view_id: 1,
})
const task = tasks[0]
cy.visit('/projects/1/'+views[0].id)
cy.get('.kanban .bucket .tasks .task')
.contains(task.title)
.should('be.visible')
.click()
cy.get('.task-view .action-buttons .button', {timeout: 3000})
.contains('Move')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.type(`${projects[1].title}{enter}`)
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
// presses enter and we can't simulate pressing on enter to select the item.
cy.get('.task-view .content.details .field .multiselect.control .search-results')
.children()
.first()
.click()
cy.get('.global-notification', {timeout: 1000})
.should('contain', 'Success')
cy.go('back')
cy.get('.kanban .bucket')
.should('not.contain', task.title)
})
it('Shows a button to filter the kanban board', () => {
cy.visit('/projects/1/4')
cy.get('.project-kanban .filter-container .base-button')
.should('exist')
})
it('Should remove a task from the board when deleting it', () => {
const {task, view} = createSingleTaskInBucket(5)
cy.visit(`/projects/1/${view.id}`)
cy.get('.kanban .bucket .tasks .task')
.contains(task.title)
.should('be.visible')
.click()
cy.get('.task-view .action-buttons .button')
.should('be.visible')
.contains('Delete')
.click()
cy.get('.modal-mask .modal-container .modal-content .modal-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.get('.kanban .bucket .tasks')
.should('not.contain', task.title)
})
it('Should show a task description icon if the task has a description', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
const {task, view} = createSingleTaskInBucket(1, {
description: 'Lorem Ipsum',
})
cy.visit(`/projects/${task.project_id}/${view.id}`)
cy.wait('@loadTasks')
cy.get('.bucket .tasks .task .footer .icon svg')
.should('exist')
})
it('Should not show a task description icon if the task has an empty description', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
const {task, view} = createSingleTaskInBucket(1, {
description: '',
})
cy.visit(`/projects/${task.project_id}/${view.id}`)
cy.wait('@loadTasks')
cy.get('.bucket .tasks .task .footer .icon svg')
.should('not.exist')
})
it('Should not show a task description icon if the task has a description containing only an empty p tag', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
const {task, view} = createSingleTaskInBucket(1, {
description: '<p></p>',
})
cy.visit(`/projects/${task.project_id}/${view.id}`)
cy.wait('@loadTasks')
cy.get('.bucket .tasks .task .footer .icon svg')
.should('not.exist')
})
it('Should respect filter query parameter from URL', () => {
const {highPriorityTasks, lowPriorityTasks} = createTasksWithPriorities(buckets)
cy.visit('/projects/1/4?filter=priority%20>=%204')
cy.url()
.should('include', 'filter=priority')
cy.contains('.kanban .bucket', highPriorityTasks[0].title, {timeout: 10000})
.should('exist')
cy.get('.kanban .bucket')
.should('contain', highPriorityTasks[0].title)
cy.get('.kanban .bucket')
.should('contain', highPriorityTasks[1].title)
cy.get('.kanban .bucket')
.should('not.contain', lowPriorityTasks[0].title)
cy.get('.kanban .bucket')
.should('not.contain', lowPriorityTasks[1].title)
})
it('Should respect search query parameter from URL', () => {
const {searchableTask} = createTasksWithSearch(buckets)
cy.visit('/projects/1/4?s=meeting')
cy.url()
.should('include', 's=meeting')
cy.contains('.kanban .bucket', searchableTask.title, {timeout: 10000})
.should('exist')
cy.get('.kanban .bucket')
.should('contain', searchableTask.title)
cy.get('.kanban .bucket .tasks .task')
.should('have.length', 1)
})
})

View File

@@ -16,188 +16,6 @@ describe('Project View List', () => {
createFakeUserAndLogin()
prepareProjects()
it('Should be an empty project', () => {
cy.visit('/projects/1')
cy.url()
.should('contain', '/projects/1/1')
cy.get('.project-title')
.should('contain', 'First Project')
cy.get('.project-title-dropdown')
.should('exist')
cy.get('p')
.contains('This project is currently empty.')
.should('exist')
})
it('Should create a new task', () => {
BucketFactory.create(2, {
project_view_id: 4,
})
const newTaskTitle = 'New task'
cy.visit('/projects/1')
cy.get('.task-add textarea')
.type(newTaskTitle+'{enter}')
cy.get('.tasks')
.should('contain.text', newTaskTitle)
})
it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
})
cy.visit('/projects/1/1')
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 project which is shared read only', () => {
UserFactory.create(2)
UserProjectFactory.create(1, {
project_id: 2,
user_id: 1,
permission: 0,
})
const projects = ProjectFactory.create(2, {
owner_id: '{increment}',
})
cy.visit(`/projects/${projects[1].id}/`)
cy.get('.project-title-wrapper .icon')
.should('not.exist')
cy.get('input.input[placeholder="Add a task…"]')
.should('not.exist')
})
it('Should only show the color of a project in the navigation and not in the list view', () => {
const projects = ProjectFactory.create(1, {
hex_color: '00db60',
})
TaskFactory.create(10, {
project_id: projects[0].id,
})
cy.visit(`/projects/${projects[0].id}/`)
cy.get('.menu-list li .list-menu-link .color-bubble')
.should('have.css', 'background-color', 'rgb(0, 219, 96)')
cy.get('.tasks .color-bubble')
.should('not.exist')
})
it('Should paginate for > 50 tasks', () => {
const tasks = TaskFactory.create(100, {
id: '{increment}',
title: i => `task${i}`,
project_id: 1,
})
cy.visit('/projects/1/1')
cy.get('.tasks')
.should('contain', tasks[20].title)
cy.get('.tasks')
.should('not.contain', tasks[99].title)
cy.get('.card-content .pagination .pagination-link')
.contains('2')
.click()
cy.url()
.should('contain', '?page=2')
cy.get('.tasks')
.should('contain', tasks[99].title)
cy.get('.tasks')
.should('not.contain', tasks[20].title)
})
it('Should show cross-project subtasks in their own project List view', () => {
const projects = createProjects(2)
const tasks = [
TaskFactory.create(1, {
id: 1,
title: 'Parent Task in Project A',
project_id: projects[0].id,
}, false)[0],
TaskFactory.create(1, {
id: 2,
title: 'Subtask in Project B',
project_id: projects[1].id,
}, false)[0],
]
// Make task 2 a subtask of task 1
TaskRelationFactory.truncate()
TaskRelationFactory.create(1, {
id: 1,
task_id: 2,
other_task_id: 1,
relation_kind: 'subtask',
}, false)
TaskRelationFactory.create(1, {
id: 2,
task_id: 1,
other_task_id: 2,
relation_kind: 'parenttask',
}, false)
cy.visit(`/projects/${projects[1].id}/${projects[1].views[0].id}`)
cy.get('.tasks')
.should('contain', 'Subtask in Project B')
})
it('Should show same-project subtasks under their parent', () => {
const projects = createProjects(1)
const tasks = [
TaskFactory.create(1, {
id: 1,
title: 'Parent Task',
project_id: projects[0].id,
}, false)[0],
TaskFactory.create(1, {
id: 2,
title: 'Subtask Same Project',
project_id: projects[0].id,
}, false)[0],
]
// Make task 2 a subtask of task 1
TaskRelationFactory.truncate()
TaskRelationFactory.create(1, {
id: 1,
task_id: 2,
other_task_id: 1,
relation_kind: 'subtask',
}, false)
TaskRelationFactory.create(1, {
id: 2,
task_id: 1,
other_task_id: 2,
relation_kind: 'parenttask',
}, false)
cy.visit(`/projects/${projects[0].id}/${projects[0].views[0].id}`)
cy.get('.tasks')
.should('contain', 'Parent Task')
cy.get('.tasks')
.should('contain', 'Subtask Same Project')
cy.get('ul.tasks > div > .single-task')
.should('exist')
cy.get('ul.tasks > div > .subtask-nested')
.should('exist')
})
it('Should respect filter query parameter from URL', () => {
const {highPriorityTasks, lowPriorityTasks} = createTasksWithPriorities()

View File

@@ -1,100 +0,0 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
import {prepareProjects} from './prepareProjects'
import {
createTasksWithPriorities,
createTasksWithSearch,
} from '../../support/filterTestHelpers'
describe('Project View Table', () => {
createFakeUserAndLogin()
prepareProjects()
it('Should show a table with tasks', () => {
const tasks = TaskFactory.create(1)
cy.visit('/projects/1/3')
cy.get('.project-table table.table')
.should('exist')
cy.get('.project-table table.table')
.should('contain', tasks[0].title)
})
it('Should have working column switches', () => {
TaskFactory.create(1)
cy.visit('/projects/1/3')
cy.get('.project-table .filter-container .button')
.contains('Columns')
.click()
cy.get('.project-table .filter-container .card.columns-filter .card-content .fancy-checkbox')
.contains('Priority')
.click()
cy.get('.project-table .filter-container .card.columns-filter .card-content .fancy-checkbox')
.contains('Done')
.click()
cy.get('.project-table table.table th')
.contains('Priority')
.should('exist')
cy.get('.project-table 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}',
project_id: 1,
})
cy.visit('/projects/1/3')
cy.get('.project-table table.table')
.contains(tasks[0].title)
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`)
})
it('Should respect filter query parameter from URL', () => {
const {highPriorityTasks, lowPriorityTasks} = createTasksWithPriorities()
cy.visit('/projects/1/3?filter=priority%20>=%204')
cy.url()
.should('include', 'filter=priority')
cy.contains('.project-table table.table', highPriorityTasks[0].title, {timeout: 10000})
.should('exist')
cy.get('.project-table table.table')
.should('contain', highPriorityTasks[0].title)
cy.get('.project-table table.table')
.should('contain', highPriorityTasks[1].title)
cy.get('.project-table table.table')
.should('not.contain', lowPriorityTasks[0].title)
cy.get('.project-table table.table')
.should('not.contain', lowPriorityTasks[1].title)
})
it('Should respect search query parameter from URL', () => {
const {searchableTask} = createTasksWithSearch()
cy.visit('/projects/1/3?s=meeting')
cy.url()
.should('include', 's=meeting')
cy.contains('.project-table table.table', searchableTask.title, {timeout: 10000})
.should('exist')
cy.get('.project-table table.table')
.should('contain', searchableTask.title)
cy.get('.project-table table.table tbody tr')
.should('have.length', 1)
})
})

View File

@@ -1,171 +0,0 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
import {ProjectFactory} from '../../factories/project'
import {prepareProjects} from './prepareProjects'
describe('Projects', () => {
createFakeUserAndLogin()
let projects
prepareProjects((newProjects) => (projects = newProjects))
it('Should create a new project', () => {
cy.visit('/projects')
cy.get('.project-header [data-cy=new-project]')
.click()
cy.url()
.should('contain', '/projects/new')
cy.get('.card-header-title')
.contains('New project')
cy.get('input[name=projectTitle]')
.type('New Project')
cy.get('.button')
.contains('Create')
.click()
cy.get('.global-notification', {timeout: 1000}) // Waiting until the request to create the new project is done
.should('contain', 'Success')
cy.url()
.should('contain', '/projects/')
cy.get('.project-title')
.should('contain', 'New Project')
})
it('Should redirect to a specific project view after visited', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/*/views/*/tasks**').as('loadBuckets')
cy.visit('/projects/1/4')
cy.url()
.should('contain', '/projects/1/4')
cy.wait('@loadBuckets')
cy.visit('/projects/1')
cy.url()
.should('contain', '/projects/1/4')
})
it('Should rename the project in all places', () => {
TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
})
const newProjectName = 'New project name'
cy.visit('/projects/1')
cy.get('.project-title')
.should('contain', 'First Project')
cy.get('.menu-container .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
.click()
cy.get('.menu-container .menu-list li:first-child .dropdown .dropdown-content')
.contains('Edit')
.click()
cy.get('#title:not(:disabled)')
.type(`{selectall}${newProjectName}`)
cy.get('footer.card-footer .button')
.contains('Save')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.project-title')
.should('contain', newProjectName)
.should('not.contain', projects[0].title)
cy.get('.menu-container .menu-list li:first-child')
.should('contain', newProjectName)
.should('not.contain', projects[0].title)
cy.visit('/')
cy.get('.project-grid')
.should('contain', newProjectName)
.should('not.contain', projects[0].title)
})
it('Should remove a project when deleting it', () => {
cy.visit(`/projects/${projects[0].id}`)
cy.get('.menu-container .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
.click()
cy.get('.menu-container .menu-list li:first-child .dropdown .dropdown-content')
.contains('Delete')
.click()
cy.url()
.should('contain', '/settings/delete')
cy.get('[data-cy="modalPrimary"]')
.contains('Do it')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.menu-container .menu-list')
.should('not.contain', projects[0].title)
cy.location('pathname')
.should('equal', '/')
})
it('Should archive a project', () => {
cy.visit(`/projects/${projects[0].id}`)
cy.get('.project-title-dropdown')
.click()
cy.get('.project-title-dropdown .dropdown-menu .dropdown-item')
.contains('Archive')
.click()
cy.get('.modal-content')
.should('contain.text', 'Archive this project')
cy.get('.modal-content [data-cy=modalPrimary]')
.click()
cy.get('.menu-container .menu-list')
.should('not.contain', projects[0].title)
cy.get('main.app-content')
.should('contain.text', 'This project is archived. It is not possible to create new or edit tasks for it.')
})
it('Should show all projects on the projects page', () => {
const projects = ProjectFactory.create(10)
cy.visit('/projects')
projects.forEach(p => {
cy.get('[data-cy="projects-list"]')
.should('contain', p.title)
})
})
it('Should not show archived projects if the filter is not checked', () => {
ProjectFactory.create(1, {
id: 2,
}, false)
ProjectFactory.create(1, {
id: 3,
is_archived: true,
}, false)
// Initial
cy.visit('/projects')
cy.get('.project-grid')
.should('not.contain', 'Archived')
// Show archived
cy.get('[data-cy="show-archived-check"] label span')
.should('be.visible')
.click()
cy.get('[data-cy="show-archived-check"] input')
.should('be.checked')
cy.get('.project-grid')
.should('contain', 'Archived')
// Don't show archived
cy.get('[data-cy="show-archived-check"] label span')
.should('be.visible')
.click()
cy.get('[data-cy="show-archived-check"] input')
.should('not.be.checked')
// Second time visiting after unchecking
cy.visit('/projects')
cy.get('[data-cy="show-archived-check"] input')
.should('not.be.checked')
cy.get('.project-grid')
.should('not.contain', 'Archived')
})
})

View File

@@ -1,61 +0,0 @@
import {LinkShareFactory} from '../../factories/link_sharing'
import {TaskFactory} from '../../factories/task'
import {UserFactory} from '../../factories/user'
import {createProjects} from '../project/prepareProjects'
function prepareLinkShare() {
UserFactory.create()
const projects = createProjects()
const tasks = TaskFactory.create(10, {
project_id: projects[0].id,
})
const linkShares = LinkShareFactory.create(1, {
project_id: projects[0].id,
permission: 0,
})
return {
share: linkShares[0],
project: projects[0],
tasks,
}
}
describe('Link shares', () => {
it('Can view a link share', () => {
const {share, project, tasks} = prepareLinkShare()
cy.visit(`/share/${share.hash}/auth`)
cy.get('h1.title')
.should('contain', project.title)
cy.get('input.input[placeholder="Add a task…"]')
.should('not.exist')
cy.get('.tasks')
.should('contain', tasks[0].title)
cy.url().should('contain', `/projects/${project.id}/1#share-auth-token=${share.hash}`)
})
it('Should work when directly viewing a project with share hash present', () => {
const {share, project, tasks} = prepareLinkShare()
cy.visit(`/projects/${project.id}/1#share-auth-token=${share.hash}`)
cy.get('h1.title')
.should('contain', project.title)
cy.get('input.input[placeholder="Add a task…"]')
.should('not.exist')
cy.get('.tasks')
.should('contain', tasks[0].title)
})
it('Should work when directly viewing a task with share hash present', () => {
const {share, project, tasks} = prepareLinkShare()
cy.visit(`/tasks/${tasks[0].id}#share-auth-token=${share.hash}`)
cy.get('h1.title')
.should('contain', tasks[0].title)
})
})

View File

@@ -1,131 +0,0 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TeamFactory} from '../../factories/team'
import {TeamMemberFactory} from '../../factories/team_member'
import {UserFactory} from '../../factories/user'
describe('Team', () => {
createFakeUserAndLogin()
it('Creates a new team', () => {
TeamFactory.truncate()
cy.visit('/teams')
const newTeamName = 'New Team'
cy.get('a.button')
.contains('Create a team')
.click()
cy.url()
.should('contain', '/teams/new')
cy.get('.card-header-title')
.contains('Create a team')
cy.get('input.input')
.type(newTeamName)
cy.get('.button')
.contains('Create')
.click()
cy.url()
.should('contain', '/edit')
cy.get('input#teamtext')
.should('have.value', newTeamName)
})
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')
})
it('Allows an admin to add members to the team', () => {
TeamMemberFactory.create(1, {
team_id: 1,
admin: true,
})
TeamFactory.create(1, {
id: 1,
})
const users = UserFactory.create(5)
cy.visit('/teams/1/edit')
cy.get('.card')
.contains('Team Members')
.get('.card-content .multiselect .input-wrapper input')
.type(users[1].username)
cy.get('.card')
.contains('Team Members')
.get('.card-content .multiselect .search-results')
.children()
.first()
.click()
cy.get('.card')
.contains('Team Members')
.get('.card-content .button')
.contains('Add to team')
.click()
cy.get('table.table td')
.contains('Admin')
.should('exist')
cy.get('table.table tr')
.should('contain', users[1].username)
.should('contain', 'Member')
cy.get('.global-notification')
.should('contain', 'Success')
})
})

View File

@@ -1,34 +0,0 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {TaskCommentFactory} from '../../factories/task_comment'
import {createDefaultViews} from '../project/prepareProjects'
describe('Task comment pagination', () => {
createFakeUserAndLogin()
beforeEach(() => {
ProjectFactory.create(1)
createDefaultViews(1)
TaskFactory.create(1, {id: 1})
TaskCommentFactory.truncate()
})
it('shows pagination when more comments than configured page size', () => {
cy.request(`${Cypress.env('API_URL')}/info`).then((response) => {
const pageSize = response.body.max_items_per_page
TaskCommentFactory.create(pageSize + 10)
cy.visit('/tasks/1')
cy.get('.task-view .comments nav.pagination').should('exist')
})
})
it('hides pagination when comments equal or fewer than configured page size', () => {
cy.request(`${Cypress.env('API_URL')}/info`).then((response) => {
const pageSize = response.body.max_items_per_page
TaskCommentFactory.create(Math.max(1, pageSize - 10))
cy.visit('/tasks/1')
cy.get('.task-view .comments nav.pagination').should('not.exist')
})
})
})

View File

@@ -37,30 +37,6 @@ function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
describe('Home Page Task Overview', () => {
createFakeUserAndLogin()
it('Should show tasks with a near due date first on the home page overview', () => {
const taskCount = 50
const {tasks} = seedTasks(taskCount)
cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task')
.each(([task], index) => {
expect(task.innerText).to.contain(tasks[index].title)
})
})
it('Should show overdue tasks first, then show other tasks', () => {
const now = new Date()
const oldDate = new Date(new Date(now).setDate(now.getDate() - 14))
const taskCount = 50
const {tasks} = seedTasks(taskCount, oldDate)
cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task')
.each(([task], index) => {
expect(task.innerText).to.contain(tasks[index].title)
})
})
it('Should show a new task with a very soon due date at the top', () => {
const {tasks} = seedTasks(49)
const newTaskTitle = 'New Task'
@@ -130,22 +106,4 @@ describe('Home Page Task Overview', () => {
.should('contain.text', newTaskTitle)
})
it('Should show the cta buttons for new project when there are no tasks', () => {
TaskFactory.truncate()
cy.visit('/')
cy.get('.home.app-content .content')
.should('contain.text', 'Import your projects and tasks from other services into Vikunja:')
})
it('Should not show the cta buttons for new project when there are tasks', () => {
seedTasks()
cy.visit('/')
cy.get('.home.app-content .content')
.should('not.contain.text', 'You can create a new project for your new tasks:')
.should('not.contain.text', 'Or import your projects and tasks from other services into Vikunja:')
})
})

View File

@@ -1,72 +0,0 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {ProjectViewFactory} from '../../factories/project_view'
import {TaskRelationFactory} from '../../factories/task_relation'
function createViews(projectId: number, projectViewId: number) {
return ProjectViewFactory.create(1, {
id: projectViewId,
project_id: projectId,
view_kind: 0,
}, false)[0]
}
describe('Subtask duplicate handling', () => {
createFakeUserAndLogin()
let projectA
let projectB
let parentA
let parentB
let subtask
beforeEach(() => {
ProjectFactory.truncate()
ProjectViewFactory.truncate()
TaskFactory.truncate()
TaskRelationFactory.truncate()
projectA = ProjectFactory.create(1, {id: 1, title: 'Project A'})[0]
createViews(projectA.id, 1)
projectB = ProjectFactory.create(1, {id: 2, title: 'Project B'}, false)[0]
createViews(projectB.id, 2)
parentA = TaskFactory.create(1, {id: 10, title: 'Parent A', project_id: projectA.id}, false)[0]
parentB = TaskFactory.create(1, {id: 11, title: 'Parent B', project_id: projectB.id}, false)[0]
subtask = TaskFactory.create(1, {id: 12, title: 'Shared subtask', project_id: projectA.id}, false)[0]
cy.request({
method: 'PUT',
url: `${Cypress.env('API_URL')}/tasks/${parentA.id}/relations`,
headers: {
'Authorization': `Bearer ${window.localStorage.getItem('token')}`,
},
body: {
other_task_id: subtask.id,
relation_kind: 'subtask',
},
})
cy.request({
method: 'PUT',
url: `${Cypress.env('API_URL')}/tasks/${parentB.id}/relations`,
headers: {
'Authorization': `Bearer ${window.localStorage.getItem('token')}`,
},
body: {
other_task_id: subtask.id,
relation_kind: 'subtask',
},
})
})
it('shows subtask only once in each project list', () => {
cy.visit(`/projects/${projectA.id}/1`)
cy.get('.subtask-nested .task-link').contains(subtask.title).should('exist')
cy.get('.tasks .task-link').contains(subtask.title).should('have.length', 1)
cy.visit(`/projects/${projectB.id}/1`)
cy.get('.subtask-nested .task-link').contains(subtask.title).should('exist')
cy.get('.tasks .task-link').contains(subtask.title).should('have.length', 1)
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,149 +0,0 @@
import {UserFactory} from '../../factories/user'
import {TokenFactory} from '../../factories/token'
context('Email Confirmation', () => {
let user
let confirmationToken
beforeEach(() => {
UserFactory.truncate()
TokenFactory.truncate()
// Create a user with status = 1 (StatusEmailConfirmationRequired)
user = UserFactory.create(1, {
username: 'unconfirmeduser',
email: 'unconfirmed@example.com',
password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.', // 1234
status: 1, // StatusEmailConfirmationRequired
})[0]
// Create an email confirmation token for this user
// kind: 2 = TokenEmailConfirm
confirmationToken = 'test-email-confirm-token-12345678901234567890123456789012'
TokenFactory.create(1, {
user_id: user.id,
kind: 2,
token: confirmationToken,
})
})
it('Should fail login before email is confirmed', () => {
cy.visit('/login')
cy.get('input[id=username]').type(user.username)
cy.get('input[id=password]').type('1234')
cy.get('.button').contains('Login').click()
cy.get('div.message.danger').contains('Email address of the user not confirmed')
})
it('Should confirm email and allow login', () => {
// Intercept the confirmation API call
cy.intercept('POST', '**/user/confirm').as('confirmEmail')
// Manually set the token in localStorage before visiting the page
// This simulates what happens when the user clicks the email link
cy.visit('/login', {
onBeforeLoad(win) {
win.localStorage.setItem('emailConfirmToken', confirmationToken)
},
})
// Wait for the confirmation API call to complete
cy.wait('@confirmEmail', {timeout: 10000}).its('response.statusCode').should('eq', 200)
// Should show success message
cy.get('.message.success', {timeout: 10000}).should('be.visible')
cy.get('.message.success').contains('You successfully confirmed your email')
// Now login should work
cy.get('input[id=username]').type(user.username)
cy.get('input[id=password]').type('1234')
cy.get('.button').contains('Login').click()
// Should successfully log in
cy.url().should('include', '/')
cy.url().should('not.include', '/login')
// Check that the username appears in the greeting
cy.contains(user.username)
})
it('Should fail with invalid confirmation token', () => {
// Intercept the confirmation API call
cy.intercept('POST', '**/user/confirm').as('confirmEmail')
// Try to confirm with an invalid token
const invalidToken = 'invalid-token-that-does-not-exist-in-database'
cy.visit('/login', {
onBeforeLoad(win) {
win.localStorage.setItem('emailConfirmToken', invalidToken)
},
})
// Wait for the confirmation API call to fail
cy.wait('@confirmEmail', {timeout: 10000})
// Should show error message
cy.get('.message.danger', {timeout: 10000}).should('be.visible')
// Login should still fail
cy.get('input[id=username]').type(user.username)
cy.get('input[id=password]').type('1234')
cy.get('.button').contains('Login').click()
cy.get('div.message.danger').contains('Email address of the user not confirmed')
})
it('Should not allow using the same token twice', () => {
// Intercept the confirmation API call
cy.intercept('POST', '**/user/confirm').as('confirmEmail')
// First confirmation - should work
cy.visit('/login', {
onBeforeLoad(win) {
win.localStorage.setItem('emailConfirmToken', confirmationToken)
},
})
cy.wait('@confirmEmail', {timeout: 10000}).its('response.statusCode').should('eq', 200)
cy.get('.message.success', {timeout: 10000}).should('be.visible')
cy.get('.message.success').contains('You successfully confirmed your email')
// Try to use the same token again - should fail
cy.visit('/login', {
onBeforeLoad(win) {
win.localStorage.setItem('emailConfirmToken', confirmationToken)
},
})
cy.wait('@confirmEmail', {timeout: 10000})
cy.get('.message.danger', {timeout: 10000}).should('be.visible')
})
it('Should confirm email when clicking link from email (via query parameter)', () => {
// Intercept the confirmation API call
cy.intercept('POST', '**/user/confirm').as('confirmEmail')
// Simulate clicking the email confirmation link with query parameter
// This is what happens when a user clicks the link in their email
cy.visit(`/?userEmailConfirm=${confirmationToken}`)
// Should redirect to login page
cy.url().should('include', '/login')
// Wait for the confirmation API call to complete
cy.wait('@confirmEmail', {timeout: 10000}).its('response.statusCode').should('eq', 200)
// Should show success message
cy.get('.message.success', {timeout: 10000}).should('be.visible')
cy.get('.message.success').contains('You successfully confirmed your email')
// Now login should work
cy.get('input[id=username]').type(user.username)
cy.get('input[id=password]').type('1234')
cy.get('.button').contains('Login').click()
// Should successfully log in
cy.url().should('include', '/')
cy.url().should('not.include', '/login')
// Check that the username appears in the greeting
cy.contains(user.username)
})
})

View File

@@ -31,13 +31,6 @@ context('Login', () => {
UserFactory.create(1, {username: credentials.username})
})
it('Should log in with the right credentials', () => {
cy.visit('/login')
login()
cy.clock(1625656161057) // 13:00
cy.get('h2').should('contain', `Hi ${credentials.username}!`)
})
it('Should fail with a bad password', () => {
const fixture = {
username: 'test',
@@ -47,20 +40,6 @@ context('Login', () => {
testAndAssertFailed(fixture)
})
it('Should fail with a bad username', () => {
const fixture = {
username: 'loremipsum',
password: '1234',
}
testAndAssertFailed(fixture)
})
it('Should redirect to /login when no user is logged in', () => {
cy.visit('/')
cy.url().should('include', '/login')
})
it('Should redirect to the previous route after logging in', () => {
const projects = ProjectFactory.create(1)
cy.visit(`/projects/${projects[0].id}/1`)

View File

@@ -1,46 +0,0 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {createProjects} from '../project/prepareProjects'
function logout() {
cy.get('.navbar .username-dropdown-trigger')
.click()
cy.get('.navbar .dropdown-item')
.contains('Logout')
.click()
}
describe('Log out', () => {
createFakeUserAndLogin()
it('Logs the user out', () => {
cy.visit('/')
expect(localStorage.getItem('token')).to.not.eq(null)
logout()
cy.url()
.should('contain', '/login')
.then(() => {
expect(localStorage.getItem('token')).to.eq(null)
})
})
it.skip('Should clear the project history after logging the user out', () => {
const projects = createProjects()
cy.visit(`/projects/${projects[0].id}`)
.then(() => {
expect(localStorage.getItem('projectHistory')).to.not.eq(null)
})
logout()
cy.wait(1000) // This makes re-loading of the project and associated entities (and the resulting error) visible
cy.url()
.should('contain', '/login')
.then(() => {
expect(localStorage.getItem('projectHistory')).to.eq(null)
})
})
})

View File

@@ -1,16 +0,0 @@
context('OpenID Login', () => {
it('logs in via Dex provider', () => {
cy.visit('/login')
cy.contains('Dex').click()
cy.origin('http://dex:5556', () => {
cy.get('#login').type('test@example.com')
cy.get('#password').type('12345678')
cy.get('#submit-login').click()
})
cy.url().should('include', '/')
cy.get('main.app-content .content h2')
.should('contain', 'test!')
cy.get('.show-tasks h3')
.should('contain', 'Current Tasks')
})
})

View File

@@ -1,58 +0,0 @@
import {UserFactory, type UserAttributes} from '../../factories/user'
import {TokenFactory, type TokenAttributes} from '../../factories/token'
context('Password Reset', () => {
let user: UserAttributes
beforeEach(() => {
UserFactory.truncate()
TokenFactory.truncate()
user = UserFactory.create(1)[0] as UserAttributes
})
it('Should allow a user to reset their password with a valid token', () => {
const tokenArray = TokenFactory.create(1, {user_id: user.id as number, kind: 1})
const token: TokenAttributes = tokenArray[0] as TokenAttributes
cy.visit(`/?userPasswordReset=${token.token}`)
cy.url().should('include', `/password-reset?userPasswordReset=${token.token}`)
const newPassword = 'newSecurePassword123'
cy.get('input[id=password]').type(newPassword)
cy.get('button').contains('Reset your password').click()
cy.get('.message.success').should('contain', 'The password was updated successfully.')
cy.get('.button').contains('Login').click()
cy.url().should('include', '/login')
// Try to login with the new password
cy.get('input[id=username]').type(user.username)
cy.get('input[id=password]').type(newPassword)
cy.get('.button').contains('Login').click()
cy.url().should('not.include', '/login')
})
it('Should show an error for an invalid token', () => {
cy.visit('/?userPasswordReset=invalidtoken123')
cy.url().should('include', '/password-reset?userPasswordReset=invalidtoken123')
// Attempt to reset password
const newPassword = 'newSecurePassword123'
cy.get('input[id=password]').type(newPassword)
cy.get('button').contains('Reset your password').click()
cy.get('.message').should('contain', 'Invalid token')
})
it('Should redirect to login if no token is present in query param when visiting /password-reset directly', () => {
cy.visit('/password-reset')
cy.url().should('not.include', '/password-reset')
cy.wait(1000) // Wait for the redirect to happen - this seems to be flaky in CI
cy.url().should('include', '/login')
})
it('Should redirect to login if userPasswordReset token is not present in query param when visiting root', () => {
cy.visit('/')
cy.url().should('include', '/login')
})
})

View File

@@ -1,48 +0,0 @@
// 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: '12345678',
email: 'testuser@example.com',
}
cy.visit('/register')
cy.get('#username').type(fixture.username)
cy.get('#email').type(fixture.email)
cy.get('#password').type(fixture.password)
cy.get('#register-submit').click()
cy.url().should('include', '/')
cy.clock(1625656161057) // 13:00
cy.get('h2').should('contain', `Hi ${fixture.username}!`)
})
it('Should fail', () => {
const fixture = {
username: 'test',
password: '12345678',
email: 'testuser@example.com',
}
cy.visit('/register')
cy.get('#username').type(fixture.username)
cy.get('#email').type(fixture.email)
cy.get('#password').type(fixture.password)
cy.get('#register-submit').click()
cy.get('div.message.danger').contains('A user with this username already exists.')
})
})

View File

@@ -35,11 +35,15 @@
"lint:fix": "pnpm run lint --fix",
"lint:styles": "stylelint 'src/**/*.{css,scss,vue}'",
"lint:styles:fix": "pnpm run lint:styles --fix",
"test:e2e": "start-server-and-test preview http://127.0.0.1:4173 'cypress run --e2e --browser chrome'",
"test:e2e-nix": "CYPRESS_RUN_BINARY=`which Cypress` CYPRESS_API_URL=http://127.0.0.1:3456/api/v1 start-server-and-test preview http://127.0.0.1:4173 'cypress run --e2e --browser chromium'",
"test:e2e-record-test": "start-server-and-test preview:test http://127.0.0.1:4173 'cypress run --e2e --browser chrome --record'",
"test:e2e-dev-dev": "start-server-and-test preview:dev http://127.0.0.1:4173 'cypress open --e2e'",
"test:e2e-dev": "start-server-and-test preview http://127.0.0.1:4173 'cypress open --e2e'",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui-host=0.0.0.0",
"test:cypress:headed": "start-server-and-test preview http://127.0.0.1:4173 'cypress open --e2e'",
"test:cypress:e2e": "start-server-and-test preview http://127.0.0.1:4173 'cypress run --e2e --browser chrome'",
"test:cypress:e2e-nix": "CYPRESS_RUN_BINARY=`which Cypress` CYPRESS_API_URL=http://127.0.0.1:3456/api/v1 start-server-and-test preview http://127.0.0.1:4173 'cypress run --e2e --browser chromium'",
"test:cypress:e2e-record-test": "start-server-and-test preview:test http://127.0.0.1:4173 'cypress run --e2e --browser chrome --record'",
"test:cypress:e2e-dev-dev": "start-server-and-test preview:dev http://127.0.0.1:4173 'cypress open --e2e'",
"test:cypress:e2e-dev": "start-server-and-test preview http://127.0.0.1:4173 'cypress open --e2e'",
"test:unit": "vitest --dir ./src",
"typecheck": "vue-tsc --build --force",
"fonts:update": "pnpm fonts:download && pnpm fonts:subset",
@@ -111,6 +115,7 @@
"@faker-js/faker": "9.9.0",
"@histoire/plugin-screenshot": "1.0.0-alpha.5",
"@histoire/plugin-vue": "1.0.0-alpha.5",
"@playwright/test": "1.57.0",
"@tsconfig/node22": "22.0.5",
"@types/codemirror": "5.60.17",
"@types/is-touch-device": "1.0.3",

View File

@@ -0,0 +1,42 @@
import {defineConfig, devices} from '@playwright/test'
import {execSync} from 'child_process'
// Find system chromium - for UI mode, set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH env var
const getChromiumPath = () => {
// Check if env var is already set (for UI mode)
if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH) {
return process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
}
try {
return execSync('which chromium', {encoding: 'utf-8'}).trim()
} catch {
return undefined
}
}
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1, // No parallelization initially
reporter: process.env.CI ? [['html'], ['list']] : 'html',
use: {
baseURL: 'http://127.0.0.1:4173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
testIdAttribute: 'data-cy', // Preserve existing data-cy selectors
serviceWorkers: 'block',
launchOptions: {
executablePath: getChromiumPath(),
},
},
projects: [
{
name: 'chromium',
use: {...devices['Desktop Chrome']},
},
],
// webServer configuration removed - we manually start services in CI
// For local development, run `pnpm preview` and `pnpm preview:vikunja` separately
})

View File

@@ -194,6 +194,9 @@ importers:
'@histoire/plugin-vue':
specifier: 1.0.0-alpha.5
version: 1.0.0-alpha.5(histoire@1.0.0-alpha.5(@types/node@22.19.1)(sass-embedded@1.93.3)(sass@1.93.3)(terser@5.31.6)(vite@7.2.4(@types/node@22.19.1)(jiti@2.4.2)(sass-embedded@1.93.3)(sass@1.93.3)(terser@5.31.6)(yaml@2.5.0))(yaml@2.5.0))(vite@7.2.4(@types/node@22.19.1)(jiti@2.4.2)(sass-embedded@1.93.3)(sass@1.93.3)(terser@5.31.6)(yaml@2.5.0))(vue@3.5.24(typescript@5.9.3))
'@playwright/test':
specifier: 1.57.0
version: 1.57.0
'@tsconfig/node22':
specifier: 22.0.5
version: 22.0.5
@@ -1931,6 +1934,11 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@playwright/test@1.57.0':
resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==}
engines: {node: '>=18'}
hasBin: true
'@pnpm/config.env-replace@1.1.0':
resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==}
engines: {node: '>=12.22.0'}
@@ -4045,6 +4053,11 @@ packages:
resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
engines: {node: '>=10'}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -4316,6 +4329,9 @@ packages:
immutable@5.0.2:
resolution: {integrity: sha512-1NU7hWZDkV7hJ4PJ9dur9gTNQ4ePNPN4k9/0YhwjzykTi/+3Q5pF93YU5QoVj8BuOnhLgaY8gs0U2pj4kSYVcw==}
immutable@5.1.4:
resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==}
import-fresh@3.3.0:
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
engines: {node: '>=6'}
@@ -5239,6 +5255,16 @@ packages:
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
engines: {node: '>= 6'}
playwright-core@1.57.0:
resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==}
engines: {node: '>=18'}
hasBin: true
playwright@1.57.0:
resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==}
engines: {node: '>=18'}
hasBin: true
possible-typed-array-names@1.0.0:
resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==}
engines: {node: '>= 0.4'}
@@ -8812,6 +8838,10 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
'@playwright/test@1.57.0':
dependencies:
playwright: 1.57.0
'@pnpm/config.env-replace@1.1.0': {}
'@pnpm/network.ca-file@1.0.2':
@@ -11212,6 +11242,9 @@ snapshots:
jsonfile: 6.1.0
universalify: 2.0.1
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@@ -11547,6 +11580,9 @@ snapshots:
immutable@5.0.2: {}
immutable@5.1.4:
optional: true
import-fresh@3.3.0:
dependencies:
parent-module: 1.0.1
@@ -12409,6 +12445,14 @@ snapshots:
pirates@4.0.6: {}
playwright-core@1.57.0: {}
playwright@1.57.0:
dependencies:
playwright-core: 1.57.0
optionalDependencies:
fsevents: 2.3.2
possible-typed-array-names@1.0.0: {}
postcss-attribute-case-insensitive@7.0.1(postcss@8.5.6):
@@ -13203,7 +13247,7 @@ snapshots:
sass@1.93.3:
dependencies:
chokidar: 4.0.3
immutable: 5.0.2
immutable: 5.1.4
source-map-js: 1.2.1
optionalDependencies:
'@parcel/watcher': 2.5.1

View File

@@ -121,7 +121,12 @@ watch(
redirectToDefaultViewIfNecessary,
)
watchEffect(() => saveProjectToHistory({id: props.projectId}))
watchEffect(() => {
// Don't save to history if the user is not authenticated (e.g., during logout)
if (authStore.authenticated) {
saveProjectToHistory({id: props.projectId})
}
})
watchEffect(() => saveProjectView(props.projectId, props.viewId))
watchEffect(() => baseStore.setCurrentProjectViewId(props.viewId))

View File

@@ -0,0 +1,29 @@
import {test, expect} from '../../support/fixtures'
const iPhone8 = {width: 375, height: 667}
test.describe('The Menu', () => {
test.beforeEach(async ({authenticatedPage: page}) => {
await page.goto('/')
})
test('Is visible by default on desktop', async ({authenticatedPage: page}) => {
await expect(page.locator('.menu-container')).toHaveClass(/is-active/)
})
test('Can be hidden on desktop', async ({authenticatedPage: page}) => {
await page.locator('button.menu-show-button:visible').click()
await expect(page.locator('.menu-container')).not.toHaveClass(/is-active/)
})
test('Is hidden by default on mobile', async ({authenticatedPage: page}) => {
await page.setViewportSize(iPhone8)
await expect(page.locator('.menu-container')).not.toHaveClass(/is-active/)
})
test('Is can be shown on mobile', async ({authenticatedPage: page}) => {
await page.setViewportSize(iPhone8)
await page.locator('button.menu-show-button:visible').click()
await expect(page.locator('.menu-container')).toHaveClass(/is-active/)
})
})

View File

@@ -0,0 +1,70 @@
import {test, expect} from '../../support/fixtures'
import {TaskFactory} from '../../factories/task'
import {createProjects} from './prepareProjects'
async function openAndSetFilters(page) {
await page.locator('.filter-container button').filter({hasText: 'Filters'}).click()
await expect(page.locator('.filter-popup')).toBeVisible()
await page.locator('.filter-popup .filter-input .ProseMirror').fill('done = true')
await page.locator('.filter-popup button').filter({hasText: 'Show results'}).click()
}
test.describe('Filter Persistence Across Views', () => {
test.beforeEach(async ({authenticatedPage: page}) => {
await createProjects()
await TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
title: 'Test Task {increment}',
})
await page.goto('/projects/1/1')
})
test('should persist filters in List view after page refresh', async ({authenticatedPage: page}) => {
await openAndSetFilters(page)
await expect(page).toHaveURL(/filter=/)
await page.reload()
await expect(page).toHaveURL(/filter=/)
})
test('should persist filters in Table view after page refresh', async ({authenticatedPage: page}) => {
await page.goto('/projects/1/3')
await openAndSetFilters(page)
await expect(page).toHaveURL(/filter=/)
await page.reload()
await expect(page).toHaveURL(/filter=/)
})
test('should persist filters in Kanban view after page refresh', async ({authenticatedPage: page}) => {
await page.goto('/projects/1/4')
await openAndSetFilters(page)
await expect(page).toHaveURL(/filter=/)
await page.reload()
await expect(page).toHaveURL(/filter=/)
})
test('should handle URL sharing with filters', async ({authenticatedPage: page}) => {
// Visit URL with pre-existing filter parameters
await page.goto('/projects/1/4?filter=done%3Dtrue&s=Test')
// Verify URL parameters are preserved
await expect(page).toHaveURL(/filter=done%3Dtrue/)
await expect(page).toHaveURL(/s=Test/)
// Switch views and verify parameters persist
await page.goto('/projects/1/3?filter=done%3Dtrue&s=Test')
await expect(page).toHaveURL(/filter=done%3Dtrue/)
await expect(page).toHaveURL(/s=Test/)
})
})

View File

@@ -0,0 +1,53 @@
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {ProjectViewFactory} from '../../factories/project_view'
export async function createDefaultViews(projectId: number, startViewId = 1, truncate: boolean = true) {
if (truncate) {
await ProjectViewFactory.truncate()
}
const list = await ProjectViewFactory.create(1, {
id: startViewId,
project_id: projectId,
view_kind: 0,
}, false)
const gantt = await ProjectViewFactory.create(1, {
id: startViewId + 1,
project_id: projectId,
view_kind: 1,
}, false)
const table = await ProjectViewFactory.create(1, {
id: startViewId + 2,
project_id: projectId,
view_kind: 2,
}, false)
const kanban = await ProjectViewFactory.create(1, {
id: startViewId + 3,
project_id: projectId,
view_kind: 3,
bucket_configuration_mode: 1,
}, false)
return [
list[0],
gantt[0],
table[0],
kanban[0],
]
}
export async function createProjects(count: number = 1) {
const projects = await ProjectFactory.create(count, {
title: i => count === 1 ? 'First Project' : `Project ${i + 1}`,
})
await TaskFactory.truncate()
await ProjectViewFactory.truncate()
for (let i = 0; i < projects.length; i++) {
const views = await createDefaultViews(projects[i].id, i * 4 + 1, false)
projects[i].views = views
}
return projects
}

View File

@@ -0,0 +1,47 @@
import {test, expect} from '../../support/fixtures'
import {ProjectFactory} from '../../factories/project'
import {ProjectViewFactory} from '../../factories/project_view'
test.describe('Project History', () => {
test('should show a project history on the home page', async ({authenticatedPage: page}) => {
test.setTimeout(60000)
const projects = await ProjectFactory.create(7)
await ProjectViewFactory.truncate()
await Promise.all(projects.map(p => ProjectViewFactory.create(1, {
id: p.id,
project_id: p.id,
}, false)))
const loadProjectArrayPromise = page.waitForResponse('**/api/v1/projects*')
await page.goto('/')
await loadProjectArrayPromise
await expect(page.locator('body')).not.toContainText('Last viewed')
for (let i = 0; i < projects.length; i++) {
const loadProjectPromise = page.waitForResponse(response =>
response.url().includes(`/projects/${projects[i].id}`) && response.request().method() === 'GET',
)
await page.goto(`/projects/${projects[i].id}/${projects[i].id}`)
await loadProjectPromise
// Wait for history to be saved to localStorage
await page.waitForFunction(
(projectId) => {
const history = JSON.parse(localStorage.getItem('projectHistory') || '[]')
return history.some((h: any) => h.id === projectId)
},
projects[i].id,
)
}
await page.locator('nav.menu.top-menu a').filter({hasText: 'Overview'}).click()
await expect(page.locator('body')).toContainText('Last viewed')
await expect(page.locator('.project-grid')).not.toContainText(projects[0].title)
await expect(page.locator('.project-grid')).toContainText(projects[1].title)
await expect(page.locator('.project-grid')).toContainText(projects[2].title)
await expect(page.locator('.project-grid')).toContainText(projects[3].title)
await expect(page.locator('.project-grid')).toContainText(projects[4].title)
await expect(page.locator('.project-grid')).toContainText(projects[5].title)
await expect(page.locator('.project-grid')).toContainText(projects[6].title)
})
})

View File

@@ -0,0 +1,135 @@
import {test, expect} from '../../support/fixtures'
import dayjs from 'dayjs'
import {TaskFactory} from '../../factories/task'
import {ProjectFactory} from '../../factories/project'
import {ProjectViewFactory} from '../../factories/project_view'
test.describe('Project View Gantt', () => {
test('Hides tasks with no dates', async ({authenticatedPage: page}) => {
await ProjectFactory.create(1)
await ProjectViewFactory.create(1, {id: 2, project_id: 1, view_kind: 1})
const tasks = await TaskFactory.create(1)
await page.goto('/projects/1/2')
await expect(page.locator('.gantt-rows')).not.toContainText(tasks[0].title)
})
test('Shows tasks from the current and next month', async ({authenticatedPage: page}) => {
await ProjectFactory.create(1)
await ProjectViewFactory.create(1, {id: 2, project_id: 1, view_kind: 1})
const now = Date.UTC(2022, 8, 25)
await page.clock.install({time: new Date(now)})
const nextMonth = new Date(now)
nextMonth.setDate(1)
nextMonth.setMonth(9)
await page.goto('/projects/1/2')
await expect(page.locator('.gantt-timeline-months')).toContainText(dayjs(now).format('MMMM YYYY'))
await expect(page.locator('.gantt-timeline-months')).toContainText(dayjs(nextMonth).format('MMMM YYYY'))
})
test('Shows tasks with dates', async ({authenticatedPage: page}) => {
await ProjectFactory.create(1)
await ProjectViewFactory.create(1, {id: 2, project_id: 1, view_kind: 1})
const now = new Date()
const tasks = await TaskFactory.create(1, {
start_date: now.toISOString(),
end_date: new Date(new Date(now).setDate(now.getDate() + 4)).toISOString(),
})
await page.goto('/projects/1/2')
await expect(page.locator('.gantt-rows')).not.toBeEmpty()
await expect(page.locator('.gantt-rows')).toContainText(tasks[0].title)
})
test('Shows tasks with no dates after enabling them', async ({authenticatedPage: page}) => {
await ProjectFactory.create(1)
await ProjectViewFactory.create(1, {id: 2, project_id: 1, view_kind: 1})
const tasks = await TaskFactory.create(1, {
start_date: null,
end_date: null,
})
await page.goto('/projects/1/2')
await page.locator('.gantt-options .fancy-checkbox').filter({hasText: 'Show tasks without date'}).click()
await expect(page.locator('.gantt-rows')).not.toBeEmpty()
await expect(page.locator('.gantt-rows')).toContainText(tasks[0].title)
})
test('Drags a task around', async ({authenticatedPage: page}) => {
await ProjectFactory.create(1)
await ProjectViewFactory.create(1, {id: 2, project_id: 1, view_kind: 1})
const taskUpdatePromise = page.waitForResponse(response =>
response.url().includes('/tasks/') && response.request().method() === 'POST',
)
const now = new Date()
await TaskFactory.create(1, {
start_date: now.toISOString(),
end_date: new Date(new Date(now).setDate(now.getDate() + 4)).toISOString(),
})
await page.goto('/projects/1/2')
const bar = page.locator('.gantt-rows .gantt-row-bars .gantt-bar').first()
const barBox = await bar.boundingBox()
if (barBox) {
const startX = barBox.x + barBox.width / 2
const startY = barBox.y + barBox.height / 2
// Trigger pointer events
await bar.dispatchEvent('pointerdown', {clientX: startX, clientY: startY, pointerId: 1, which: 1})
await page.waitForTimeout(100)
await bar.dispatchEvent('pointermove', {clientX: startX + 10, clientY: startY, pointerId: 1})
await bar.dispatchEvent('pointermove', {clientX: startX + 150, clientY: startY, pointerId: 1})
await bar.dispatchEvent('pointerup', {clientX: startX + 150, clientY: startY, pointerId: 1})
}
await taskUpdatePromise
})
test('Should change the query parameters when selecting a date range', async ({authenticatedPage: page}) => {
await ProjectFactory.create(1)
await ProjectViewFactory.create(1, {id: 2, project_id: 1, view_kind: 1})
const now = Date.UTC(2022, 10, 9)
await page.clock.install({time: new Date(now)})
await page.goto('/projects/1/2')
await page.locator('.project-gantt .gantt-options .field .control input.input.form-control').click()
await page.locator('.flatpickr-calendar .flatpickr-innerContainer .dayContainer .flatpickr-day').first().click()
await page.locator('.flatpickr-calendar .flatpickr-innerContainer .dayContainer .flatpickr-day').last().click()
await expect(page).toHaveURL(/dateFrom=2022-09-25/)
await expect(page).toHaveURL(/dateTo=2022-11-05/)
})
test('Should change the date range based on date query parameters', async ({authenticatedPage: page}) => {
await ProjectFactory.create(1)
await ProjectViewFactory.create(1, {id: 2, project_id: 1, view_kind: 1})
await page.goto('/projects/1/2?dateFrom=2022-09-25&dateTo=2022-11-05')
await expect(page.locator('.gantt-timeline-months')).toContainText('September 2022')
await expect(page.locator('.gantt-timeline-months')).toContainText('October 2022')
await expect(page.locator('.gantt-timeline-months')).toContainText('November 2022')
await expect(page.locator('.project-gantt .gantt-options .field .control input.input.form-control')).toHaveValue('25 Sep 2022 to 5 Nov 2022')
})
test('Should open a task when double clicked on it', async ({authenticatedPage: page}) => {
await ProjectFactory.create(1)
await ProjectViewFactory.create(1, {id: 2, project_id: 1, view_kind: 1})
const now = new Date()
const tasks = await TaskFactory.create(1, {
start_date: dayjs(now).format(),
end_date: dayjs(now.setDate(now.getDate() + 4)).format(),
})
await page.goto('/projects/1/2')
await page.locator('.gantt-container .gantt-row-bars .gantt-bar').dblclick()
await expect(page).toHaveURL(new RegExp(`/tasks/${tasks[0].id}`))
})
})

View File

@@ -0,0 +1,315 @@
import {test, expect} from '../../support/fixtures'
import {BucketFactory} from '../../factories/bucket'
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {ProjectViewFactory} from '../../factories/project_view'
import {TaskBucketFactory} from '../../factories/task_buckets'
import {createTasksWithPriorities, createTasksWithSearch} from '../../support/filterTestHelpers'
async function createSingleTaskInBucket(count = 1, attrs = {}) {
const projects = await ProjectFactory.create(1)
const views = await ProjectViewFactory.create(1, {
id: 1,
project_id: projects[0].id,
view_kind: 3,
bucket_configuration_mode: 1,
})
const buckets = await BucketFactory.create(2, {
project_view_id: views[0].id,
})
const tasks = await TaskFactory.create(count, {
project_id: projects[0].id,
...attrs,
})
await TaskBucketFactory.create(1, {
task_id: tasks[0].id,
bucket_id: buckets[0].id,
project_view_id: views[0].id,
})
return {
task: tasks[0],
view: views[0],
project: projects[0],
}
}
async function createTaskWithBuckets(buckets, count = 1) {
const data = await TaskFactory.create(count, {
project_id: 1,
})
await TaskBucketFactory.truncate()
for (const t of data) {
await TaskBucketFactory.create(1, {
task_id: t.id,
bucket_id: buckets[0].id,
project_view_id: buckets[0].project_view_id,
}, false)
}
return data
}
test.describe('Project View Kanban', () => {
let buckets
test.beforeEach(async ({authenticatedPage: page}) => {
const projects = await ProjectFactory.create(1)
await ProjectViewFactory.create(1, {
id: 4,
project_id: projects[0].id,
view_kind: 3,
bucket_configuration_mode: 1,
})
buckets = await BucketFactory.create(2, {
project_view_id: 4,
})
})
test('Shows all buckets with their tasks', async ({authenticatedPage: page}) => {
const data = await createTaskWithBuckets(buckets, 10)
await page.goto('/projects/1/4')
await expect(page.locator('.kanban .bucket .title').filter({hasText: buckets[0].title})).toBeVisible()
await expect(page.locator('.kanban .bucket .title').filter({hasText: buckets[1].title})).toBeVisible()
await expect(page.locator('.kanban .bucket').first()).toContainText(data[0].title)
})
test('Can add a new task to a bucket', async ({authenticatedPage: page}) => {
await createTaskWithBuckets(buckets, 2)
await page.goto('/projects/1/4')
await page.locator('.kanban .bucket').filter({hasText: buckets[0].title}).locator('.bucket-footer .button').filter({hasText: 'Add another task'}).click()
await page.locator('.kanban .bucket').filter({hasText: buckets[0].title}).locator('.bucket-footer .field .control input.input').fill('New Task')
await page.locator('.kanban .bucket').filter({hasText: buckets[0].title}).locator('.bucket-footer .field .control input.input').press('Enter')
await expect(page.locator('.kanban .bucket').first()).toContainText('New Task')
})
test('Can create a new bucket', async ({authenticatedPage: page}) => {
await page.goto('/projects/1/4')
await page.locator('.kanban .bucket.new-bucket .button').click()
await page.locator('.kanban .bucket.new-bucket input.input').fill('New Bucket')
await page.locator('.kanban .bucket.new-bucket input.input').press('Enter')
await expect(page.locator('.kanban .bucket .title').filter({hasText: 'New Bucket'})).toBeVisible()
})
test('Can set a bucket limit', async ({authenticatedPage: page}) => {
await page.goto('/projects/1/4')
const bucketDropdown = page.locator('.kanban .bucket .bucket-header .dropdown.options').first()
await bucketDropdown.locator('.dropdown-trigger').click()
await bucketDropdown.locator('.dropdown-menu .dropdown-item').filter({hasText: 'Limit: Not Set'}).click()
await bucketDropdown.locator('.dropdown-menu .field input.input').fill('3')
await bucketDropdown.locator('.dropdown-menu .field .control .button').click()
// Wait for the limit to be saved - the dropdown closes and limit is shown
await expect(page.locator('.global-notification')).toContainText('Success')
await expect(page.locator('.kanban .bucket .bucket-header span.limit').first()).toBeVisible()
await expect(page.locator('.kanban .bucket .bucket-header span.limit').first()).toContainText('/3')
})
test('Can rename a bucket', async ({authenticatedPage: page}) => {
await page.goto('/projects/1/4')
const titleElement = page.locator('.kanban .bucket .bucket-header .title').first()
await titleElement.click()
await titleElement.fill('New Bucket Title')
await titleElement.press('Enter')
await expect(titleElement).toContainText('New Bucket Title')
})
test('Can delete a bucket', async ({authenticatedPage: page}) => {
await page.goto('/projects/1/4')
await page.locator('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger').first().click()
await page.locator('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item').filter({hasText: 'Delete'}).click()
await expect(page.locator('.modal-mask .modal-container .modal-content .modal-header')).toContainText('Delete the bucket')
await page.locator('.modal-mask .modal-container .modal-content .actions .button').filter({hasText: 'Do it!'}).click()
await expect(page.locator('.kanban .bucket .title').filter({hasText: buckets[0].title})).not.toBeVisible()
await expect(page.locator('.kanban .bucket .title').filter({hasText: buckets[1].title})).toBeVisible()
})
test('Can drag tasks around', async ({authenticatedPage: page}) => {
const tasks = await createTaskWithBuckets(buckets, 2)
await page.goto('/projects/1/4')
const sourceTask = page.locator('.kanban .bucket .tasks .task').filter({hasText: tasks[0].title}).first()
const targetBucket = page.locator('.kanban .bucket:nth-child(2) .tasks')
await sourceTask.dragTo(targetBucket)
await expect(page.locator('.kanban .bucket:nth-child(2) .tasks')).toContainText(tasks[0].title)
await expect(page.locator('.kanban .bucket:nth-child(1) .tasks')).not.toContainText(tasks[0].title)
})
test('Should navigate to the task when the task card is clicked', async ({authenticatedPage: page}) => {
const tasks = await createTaskWithBuckets(buckets, 5)
await page.goto('/projects/1/4')
await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: tasks[0].title})).toBeVisible()
await page.locator('.kanban .bucket .tasks .task').filter({hasText: tasks[0].title}).click()
await expect(page).toHaveURL(new RegExp(`/tasks/${tasks[0].id}`), {timeout: 1000})
})
test('Should remove a task from the kanban board when moving it to another project', async ({authenticatedPage: page}) => {
const projects = await ProjectFactory.create(2)
const views = await ProjectViewFactory.create(2, {
project_id: '{increment}',
view_kind: 3,
bucket_configuration_mode: 1,
})
await BucketFactory.create(2)
const tasks = await TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
})
await TaskBucketFactory.create(5, {
project_view_id: 1,
})
const task = tasks[0]
await page.goto('/projects/1/' + views[0].id)
await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title})).toBeVisible()
await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click()
await page.locator('.task-view .action-buttons .button', {timeout: 3000}).filter({hasText: /^Move$/}).click()
const multiselectInput = page.locator('.task-view .content.details .field .multiselect.control .input-wrapper input')
await expect(multiselectInput).toBeVisible({timeout: 5000})
await multiselectInput.click()
await multiselectInput.pressSequentially(projects[1].title)
// Wait for search results to appear before clicking
const searchResults = page.locator('.task-view .content.details .field .multiselect.control .search-results')
await searchResults.waitFor({state: 'visible'})
await searchResults.locator('> *').first().click()
await expect(page.locator('.global-notification')).toContainText('Success', {timeout: 1000})
await page.goBack()
const bucketCount = await page.locator('.kanban .bucket').count()
for (let i = 0; i < bucketCount; i++) {
await expect(page.locator('.kanban .bucket').nth(i)).not.toContainText(task.title)
}
})
test('Shows a button to filter the kanban board', async ({authenticatedPage: page}) => {
await page.goto('/projects/1/4')
await expect(page.locator('.project-kanban .filter-container .base-button')).toBeVisible()
})
test('Should remove a task from the board when deleting it', async ({authenticatedPage: page}) => {
const {task, view} = await createSingleTaskInBucket(5)
await page.goto(`/projects/1/${view.id}`)
await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title})).toBeVisible()
await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click()
await expect(page.locator('.task-view .action-buttons .button').filter({hasText: 'Delete'})).toBeVisible()
await page.locator('.task-view .action-buttons .button').filter({hasText: 'Delete'}).click()
await expect(page.locator('.modal-mask .modal-container .modal-content .modal-header')).toContainText('Delete this task')
await page.locator('.modal-mask .modal-container .modal-content .actions .button').filter({hasText: 'Do it!'}).click()
await expect(page.locator('.global-notification')).toContainText('Success')
await page.goBack()
const bucketCount = await page.locator('.kanban .bucket').count()
for (let i = 0; i < bucketCount; i++) {
await expect(page.locator('.kanban .bucket').nth(i)).not.toContainText(task.title)
}
})
test('Should show a task description icon if the task has a description', async ({authenticatedPage: page}) => {
const {task, view} = await createSingleTaskInBucket(1, {
description: 'Lorem Ipsum',
})
const loadTasksPromise = page.waitForResponse(response =>
response.url().includes('/projects/1/views/') && response.url().includes('/tasks'),
)
await page.goto(`/projects/${task.project_id}/${view.id}`)
await loadTasksPromise
await expect(page.locator('.bucket .tasks .task .footer .icon svg')).toBeVisible()
})
test('Should not show a task description icon if the task has an empty description', async ({authenticatedPage: page}) => {
const {task, view} = await createSingleTaskInBucket(1, {
description: '',
})
const loadTasksPromise = page.waitForResponse(response =>
response.url().includes('/projects/1/views/') && response.url().includes('/tasks'),
)
await page.goto(`/projects/${task.project_id}/${view.id}`)
await loadTasksPromise
await expect(page.locator('.bucket .tasks .task .footer .icon svg')).not.toBeVisible()
})
test('Should not show a task description icon if the task has a description containing only an empty p tag', async ({authenticatedPage: page}) => {
const {task, view} = await createSingleTaskInBucket(1, {
description: '<p></p>',
})
const loadTasksPromise = page.waitForResponse(response =>
response.url().includes('/projects/1/views/') && response.url().includes('/tasks'),
)
await page.goto(`/projects/${task.project_id}/${view.id}`)
await loadTasksPromise
await expect(page.locator('.bucket .tasks .task .footer .icon svg')).not.toBeVisible()
})
test('Should respect filter query parameter from URL', async ({authenticatedPage: page}) => {
// Create buckets first
const projects = await ProjectFactory.create(1)
const views = await ProjectViewFactory.create(1, {
id: 4,
project_id: 1,
view_kind: 3,
})
const buckets = await BucketFactory.create(2, {
project_view_id: 4,
})
const {highPriorityTasks, lowPriorityTasks} = await createTasksWithPriorities(buckets)
await page.goto('/projects/1/4?filter=priority%20>=%204')
await expect(page).toHaveURL(/filter=priority/)
// Wait for tasks to load and verify high priority tasks are visible
await expect(page.locator('.kanban')).toContainText(highPriorityTasks[0].title, {timeout: 10000})
await expect(page.locator('.kanban')).toContainText(highPriorityTasks[1].title)
// Verify low priority tasks are not visible
await expect(page.locator('.kanban')).not.toContainText(lowPriorityTasks[0].title)
await expect(page.locator('.kanban')).not.toContainText(lowPriorityTasks[1].title)
})
test('Should respect search query parameter from URL', async ({authenticatedPage: page}) => {
// Create buckets first
const projects = await ProjectFactory.create(1)
const views = await ProjectViewFactory.create(1, {
id: 4,
project_id: 1,
view_kind: 3,
})
const buckets = await BucketFactory.create(2, {
project_view_id: 4,
})
const {searchableTask} = await createTasksWithSearch(buckets)
await page.goto('/projects/1/4?s=meeting')
await expect(page).toHaveURL(/s=meeting/)
// Wait for search results to load and verify searchable task is visible
await expect(page.locator('.kanban')).toContainText(searchableTask.title, {timeout: 10000})
// Verify only one task is shown (the search result) - count task headings
await expect(page.locator('main h2')).toHaveCount(1)
})
})

View File

@@ -0,0 +1,167 @@
import {test, expect} from '../../support/fixtures'
import {UserProjectFactory} from '../../factories/users_project'
import {TaskFactory} from '../../factories/task'
import {TaskRelationFactory} from '../../factories/task_relation'
import {UserFactory} from '../../factories/user'
import {ProjectFactory} from '../../factories/project'
import {createProjects} from './prepareProjects'
import {BucketFactory} from '../../factories/bucket'
test.describe('Project View List', () => {
test('Should be an empty project', async ({authenticatedPage: page}) => {
await createProjects(1)
await page.goto('/projects/1')
await expect(page).toHaveURL(/\/projects\/1\/1/)
await expect(page.locator('.project-title')).toContainText('First Project')
await expect(page.locator('.project-title-dropdown')).toBeVisible()
await expect(page.locator('.has-text-centered.has-text-grey.is-italic').filter({hasText: 'This project is currently empty.'})).toBeVisible()
})
test('Should create a new task', async ({authenticatedPage: page}) => {
await createProjects(1)
await BucketFactory.create(2, {
project_view_id: 4,
})
const newTaskTitle = 'New task'
await page.goto('/projects/1/1')
await page.locator('.task-add textarea').fill(newTaskTitle)
await page.locator('.task-add textarea').press('Enter')
await expect(page.locator('.tasks')).toContainText(newTaskTitle)
})
test('Should navigate to the task when the title is clicked', async ({authenticatedPage: page}) => {
await createProjects(1)
const tasks = await TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
})
await page.goto('/projects/1/1')
await page.locator('.tasks .task .tasktext').filter({hasText: tasks[0].title}).first().click()
await expect(page).toHaveURL(new RegExp(`/tasks/${tasks[0].id}`))
})
test('Should not see any elements for a project which is shared read only', async ({authenticatedPage: page}) => {
await UserFactory.create(2)
await UserProjectFactory.create(1, {
project_id: 2,
user_id: 1,
permission: 0,
})
const projects = await ProjectFactory.create(2, {
owner_id: '{increment}',
})
await page.goto(`/projects/${projects[1].id}/`)
await expect(page.locator('.project-title-wrapper .icon')).not.toBeVisible()
await expect(page.locator('input.input[placeholder="Add a task…"]')).not.toBeVisible()
})
test('Should only show the color of a project in the navigation and not in the list view', async ({authenticatedPage: page}) => {
const projects = await ProjectFactory.create(1, {
id: 1,
hex_color: '00db60',
})
await TaskFactory.create(10, {
project_id: projects[0].id,
})
await page.goto(`/projects/${projects[0].id}/1`)
await expect(page.locator('.menu-list li .list-menu-link .color-bubble')).toHaveCSS('background-color', 'rgb(0, 219, 96)')
await expect(page.locator('.tasks .color-bubble')).not.toBeVisible()
})
test('Should paginate for > 50 tasks', async ({authenticatedPage: page}) => {
await createProjects(1)
const tasks = await TaskFactory.create(100, {
id: '{increment}',
title: i => `task${i}`,
project_id: 1,
})
await page.goto('/projects/1/1')
await expect(page.locator('.tasks')).toContainText(tasks[20].title)
await expect(page.locator('.tasks')).not.toContainText(tasks[99].title)
await page.locator('.card-content .pagination .pagination-link').filter({hasText: '2'}).click()
await expect(page).toHaveURL(/\?page=2/)
await expect(page.locator('.tasks')).toContainText(tasks[99].title)
await expect(page.locator('.tasks')).not.toContainText(tasks[20].title)
})
test('Should show cross-project subtasks in their own project List view', async ({authenticatedPage: page}) => {
const projects = await createProjects(2)
await TaskFactory.create(1, {
id: 1,
title: 'Parent Task in Project A',
project_id: projects[0].id,
}, false)
await TaskFactory.create(1, {
id: 2,
title: 'Subtask in Project B',
project_id: projects[1].id,
}, false)
// Make task 2 a subtask of task 1
await TaskRelationFactory.truncate()
await TaskRelationFactory.create(1, {
id: 1,
task_id: 2,
other_task_id: 1,
relation_kind: 'subtask',
}, false)
await TaskRelationFactory.create(1, {
id: 2,
task_id: 1,
other_task_id: 2,
relation_kind: 'parenttask',
}, false)
await page.goto(`/projects/${projects[1].id}/${projects[1].views[0].id}`)
await expect(page.locator('.tasks')).toContainText('Subtask in Project B')
})
test('Should show same-project subtasks under their parent', async ({authenticatedPage: page}) => {
const projects = await createProjects(1)
await TaskFactory.create(1, {
id: 1,
title: 'Parent Task',
project_id: projects[0].id,
}, false)
await TaskFactory.create(1, {
id: 2,
title: 'Subtask Same Project',
project_id: projects[0].id,
}, false)
// Make task 2 a subtask of task 1
await TaskRelationFactory.truncate()
await TaskRelationFactory.create(1, {
id: 1,
task_id: 2,
other_task_id: 1,
relation_kind: 'subtask',
}, false)
await TaskRelationFactory.create(1, {
id: 2,
task_id: 1,
other_task_id: 2,
relation_kind: 'parenttask',
}, false)
await page.goto(`/projects/${projects[0].id}/${projects[0].views[0].id}`)
await expect(page.locator('.tasks')).toContainText('Parent Task')
await expect(page.locator('.tasks')).toContainText('Subtask Same Project')
await expect(page.locator('ul.tasks > div > .single-task')).toBeVisible()
await expect(page.locator('ul.tasks > div > .subtask-nested')).toBeVisible()
})
})

View File

@@ -0,0 +1,98 @@
import {test, expect} from '../../support/fixtures'
import {TaskFactory} from '../../factories/task'
import {createProjects} from './prepareProjects'
import {createTasksWithPriorities, createTasksWithSearch} from '../../support/filterTestHelpers'
test.describe('Project View Table', () => {
test('Should show a table with tasks', async ({authenticatedPage: page}) => {
await createProjects(1)
const tasks = await TaskFactory.create(1, {
project_id: 1,
})
await page.goto('/projects/1/3')
await expect(page.locator('.project-table table.table')).toBeVisible()
await expect(page.locator('.project-table table.table')).toContainText(tasks[0].title)
})
test('Should have working column switches', async ({authenticatedPage: page}) => {
await createProjects(1)
await TaskFactory.create(1, {
project_id: 1,
})
const loadTasksPromise = page.waitForResponse(response =>
response.url().includes('/projects/1/views/') && response.url().includes('/tasks'),
)
await page.goto('/projects/1/3')
await loadTasksPromise
// Click the Columns button to open the column selector
await page.locator('.project-table .filter-container .button').filter({hasText: 'Columns'}).click()
// Click Priority checkbox to enable Priority column (click on the text like Cypress does)
await page.locator('.project-table .filter-container .card.columns-filter .card-content').getByText('Priority').click()
// Wait for Priority checkbox to be checked
await expect(page.getByRole('checkbox', {name: 'Checkbox Priority'})).toBeChecked()
// Click Done checkbox to disable Done column (click on the text like Cypress does)
await page.locator('.project-table .filter-container .card.columns-filter .card-content').getByText('Done', {exact: true}).click()
// Wait for Done checkbox to be unchecked
await expect(page.getByRole('checkbox', {name: 'Checkbox Done', exact: true})).not.toBeChecked()
// Verify Priority column is now visible
await expect(page.locator('.project-table table.table th').filter({hasText: 'Priority'})).toBeVisible()
// Verify Done column is now hidden
await expect(page.locator('.project-table table.table th').filter({hasText: /^Done$/})).not.toBeVisible()
})
test('Should navigate to the task when the title is clicked', async ({authenticatedPage: page}) => {
await createProjects(1)
await TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
})
const loadTasksPromise = page.waitForResponse(response =>
response.url().includes('/projects/1/views/') && response.url().includes('/tasks'),
)
await page.goto('/projects/1/3')
await loadTasksPromise
await page.locator('.project-table table.table tbody tr').first().locator('a').first().click()
await expect(page).toHaveURL(/\/tasks\/\d+/)
})
test('Should respect filter query parameter from URL', async ({authenticatedPage: page}) => {
await createProjects(1)
const {highPriorityTasks, lowPriorityTasks} = await createTasksWithPriorities()
await page.goto('/projects/1/3?filter=priority%20>=%204')
await expect(page).toHaveURL(/filter=priority/)
// Wait for tasks to load and verify high priority tasks are visible
await expect(page.locator('.project-table table.table')).toContainText(highPriorityTasks[0].title, {timeout: 10000})
await expect(page.locator('.project-table table.table')).toContainText(highPriorityTasks[1].title)
// Verify low priority tasks are not visible
await expect(page.locator('.project-table table.table')).not.toContainText(lowPriorityTasks[0].title)
await expect(page.locator('.project-table table.table')).not.toContainText(lowPriorityTasks[1].title)
})
test('Should respect search query parameter from URL', async ({authenticatedPage: page}) => {
await createProjects(1)
const {searchableTask} = await createTasksWithSearch()
await page.goto('/projects/1/3?s=meeting')
await expect(page).toHaveURL(/s=meeting/)
// Wait for search results to load and verify searchable task is visible
await expect(page.locator('.project-table table.table')).toContainText(searchableTask.title, {timeout: 10000})
// Verify only one task row is shown (the search result)
await expect(page.locator('.project-table table.table tbody tr')).toHaveCount(1)
})
})

View File

@@ -0,0 +1,153 @@
import {test, expect} from '../../support/fixtures'
import {TaskFactory} from '../../factories/task'
import {ProjectFactory} from '../../factories/project'
import {createProjects} from './prepareProjects'
test.describe('Projects', () => {
test.use({
// Use authenticated page for all tests
})
let projects: any[]
test.beforeEach(async ({authenticatedPage}) => {
projects = await createProjects()
})
test('Should create a new project', async ({authenticatedPage: page}) => {
await page.goto('/projects')
await page.waitForLoadState('networkidle')
await page.locator('.action-buttons').getByRole('link', {name: /project/i}).click()
await expect(page).toHaveURL(/\/projects\/new/)
await expect(page.locator('.card-header-title')).toContainText('New project')
await page.locator('input[name=projectTitle]').fill('New Project')
await page.locator('.button').filter({hasText: 'Create'}).click()
await expect(page.locator('.global-notification', {timeout: 1000})).toContainText('Success')
await expect(page).toHaveURL(/\/projects\//)
await expect(page.locator('.project-title')).toContainText('New Project')
})
test('Should redirect to a specific project view after visited', async ({authenticatedPage: page}) => {
const projectId = projects[0].id
const kanbanViewId = projects[0].views[3].id
const loadBucketsPromise = page.waitForResponse(response =>
response.url().includes(`/projects/${projectId}/`) &&
response.url().includes('/views/') &&
response.url().includes('/tasks'),
)
await page.goto(`/projects/${projectId}/${kanbanViewId}`)
await expect(page).toHaveURL(new RegExp(`/projects/${projectId}/${kanbanViewId}`))
await loadBucketsPromise
await page.goto(`/projects/${projectId}`)
await expect(page).toHaveURL(new RegExp(`/projects/${projectId}/${kanbanViewId}`))
})
// FIXME: seeding fails with error 500
test('Should rename the project in all places', async ({authenticatedPage: page}) => {
const projectId = projects[0].id
const listViewId = projects[0].views[0].id
await TaskFactory.create(5, {
id: '{increment}',
project_id: projectId,
})
const newProjectName = 'New project name'
// Navigate to project and wait for redirect to view
await page.goto(`/projects/${projectId}/${listViewId}`)
await page.waitForLoadState('networkidle')
await expect(page.locator('.project-title')).toContainText('First Project')
// Click the project title dropdown and select Edit
await page.locator('.project-title-dropdown .project-title-button').click()
await page.getByRole('link', {name: /^edit$/i}).click()
await page.waitForLoadState('networkidle')
// Fill in the new name
await page.locator('input#title').fill(newProjectName)
await page.locator('footer.card-footer .button').filter({hasText: /^Save$/}).click()
await expect(page.locator('.global-notification')).toContainText('Success')
await expect(page.locator('.project-title')).toContainText(newProjectName)
await expect(page.locator('.project-title')).not.toContainText(projects[0].title)
await expect(page.locator('.menu-container .menu-list').getByRole('listitem').filter({hasText: newProjectName})).toBeVisible()
await page.goto('/')
await expect(page.locator('.project-grid')).toContainText(newProjectName)
await expect(page.locator('.project-grid')).not.toContainText(projects[0].title)
})
test('Should remove a project when deleting it', async ({authenticatedPage: page}) => {
const projectId = projects[0].id
const listViewId = projects[0].views[0].id
await page.goto(`/projects/${projectId}/${listViewId}`)
await page.waitForLoadState('networkidle')
await page.locator('.project-title-dropdown .project-title-button').click()
await page.getByRole('link', {name: /^delete$/i}).click()
await page.waitForLoadState('networkidle')
await expect(page).toHaveURL(/\/settings\/delete/)
await page.getByRole('button', {name: /do it/i}).click()
await expect(page.locator('.global-notification')).toContainText('Success')
await expect(page).toHaveURL('/')
await expect(page.getByRole('link', {name: projects[0].title})).not.toBeVisible()
})
test('Should archive a project', async ({authenticatedPage: page}) => {
const projectId = projects[0].id
const listViewId = projects[0].views[0].id
await page.goto(`/projects/${projectId}/${listViewId}`)
await page.waitForLoadState('networkidle')
await page.locator('.project-title-dropdown .project-title-button').click()
await page.getByRole('link', {name: /^archive$/i}).click()
await expect(page.locator('.modal-content')).toContainText('Archive this project')
await page.getByRole('button', {name: /do it/i}).click()
await expect(page.locator('.global-notification')).toContainText('Success')
await expect(page.locator('main.app-content')).toContainText('This project is archived. It is not possible to create new or edit tasks for it.')
})
test('Should show all projects on the projects page', async ({authenticatedPage: page}) => {
const projects = await ProjectFactory.create(10)
await page.goto('/projects')
await page.waitForLoadState('networkidle')
for (const p of projects) {
await expect(page.locator('.project-grid')).toContainText(p.title)
}
})
test('Should not show archived projects if the filter is not checked', async ({authenticatedPage: page}) => {
await ProjectFactory.create(1, {
id: 2,
}, false)
await ProjectFactory.create(1, {
id: 3,
is_archived: true,
}, false)
// Initial
await page.goto('/projects')
await page.waitForLoadState('networkidle')
await expect(page.locator('.project-grid')).not.toContainText('Archived')
// Show archived - click the checkbox label text
await page.getByText('Show Archived').click()
await expect(page.locator('input[type="checkbox"]').first()).toBeChecked()
await expect(page.locator('.project-grid')).toContainText('Archived')
// Don't show archived
await page.getByText('Show Archived').click()
await expect(page.locator('input[type="checkbox"]').first()).not.toBeChecked()
// Second time visiting after unchecking
await page.goto('/projects')
await page.waitForLoadState('networkidle')
await expect(page.locator('input[type="checkbox"]').first()).not.toBeChecked()
await expect(page.locator('.project-grid')).not.toContainText('Archived')
})
})

View File

@@ -0,0 +1,55 @@
import {test, expect} from '../../support/fixtures'
import {LinkShareFactory} from '../../factories/link_sharing'
import {TaskFactory} from '../../factories/task'
import {UserFactory} from '../../factories/user'
import {createProjects} from '../project/prepareProjects'
async function prepareLinkShare() {
await UserFactory.create()
const projects = await createProjects()
const tasks = await TaskFactory.create(10, {
project_id: projects[0].id,
})
const linkShares = await LinkShareFactory.create(1, {
project_id: projects[0].id,
permission: 0,
})
return {
share: linkShares[0],
project: projects[0],
tasks,
}
}
test.describe('Link shares', () => {
test('Can view a link share', async ({page, apiContext}) => {
const {share, project, tasks} = await prepareLinkShare()
await page.goto(`/share/${share.hash}/auth`)
await expect(page.locator('h1.title')).toContainText(project.title)
await expect(page.locator('input.input[placeholder="Add a task…"]')).not.toBeVisible()
await expect(page.locator('.tasks')).toContainText(tasks[0].title)
await expect(page).toHaveURL(`/projects/${project.id}/1#share-auth-token=${share.hash}`)
})
test('Should work when directly viewing a project with share hash present', async ({page, apiContext}) => {
const {share, project, tasks} = await prepareLinkShare()
await page.goto(`/projects/${project.id}/1#share-auth-token=${share.hash}`)
await expect(page.locator('h1.title')).toContainText(project.title)
await expect(page.locator('input.input[placeholder="Add a task…"]')).not.toBeVisible()
await expect(page.locator('.tasks')).toContainText(tasks[0].title)
})
test('Should work when directly viewing a task with share hash present', async ({page, apiContext}) => {
const {share, project, tasks} = await prepareLinkShare()
await page.goto(`/tasks/${tasks[0].id}#share-auth-token=${share.hash}`)
await expect(page.locator('h1.title.input')).toContainText(tasks[0].title)
})
})

View File

@@ -0,0 +1,103 @@
import {test, expect} from '../../support/fixtures'
import {TeamFactory} from '../../factories/team'
import {TeamMemberFactory} from '../../factories/team_member'
import {UserFactory} from '../../factories/user'
test.describe('Team', () => {
test('Creates a new team', async ({authenticatedPage: page}) => {
await TeamFactory.truncate()
await page.goto('/teams')
const newTeamName = 'New Team'
await page.locator('a.button').filter({hasText: 'Create a team'}).click()
await expect(page).toHaveURL(/\/teams\/new/)
await expect(page.locator('.card-header-title')).toContainText('Create a team')
await page.locator('input.input').fill(newTeamName)
await page.locator('.button').filter({hasText: 'Create'}).click()
await expect(page).toHaveURL(/\/edit/)
await expect(page.locator('input#teamtext')).toHaveValue(newTeamName)
})
test('Shows all teams', async ({authenticatedPage: page}) => {
await TeamMemberFactory.create(10, {
team_id: '{increment}',
})
const teams = await TeamFactory.create(10, {
id: '{increment}',
})
await page.goto('/teams')
await expect(page.locator('.teams.box')).not.toBeEmpty()
for (const t of teams) {
await expect(page.locator('.teams.box')).toContainText(t.name)
}
})
test('Allows an admin to edit the team', async ({authenticatedPage: page}) => {
await TeamMemberFactory.create(1, {
team_id: 1,
admin: true,
})
await TeamFactory.create(1, {
id: 1,
})
await page.goto('/teams/1/edit')
await page.locator('.card input.input').first().fill('New Team Name')
await page.locator('.card .button').filter({hasText: 'Save'}).click()
await expect(page.locator('table.table td').filter({hasText: 'Admin'})).toBeVisible()
await expect(page.locator('.global-notification')).toContainText('Success')
})
test('Does not allow a normal user to edit the team', async ({authenticatedPage: page}) => {
await TeamMemberFactory.create(1, {
team_id: 1,
admin: false,
})
await TeamFactory.create(1, {
id: 1,
})
await page.goto('/teams/1/edit')
await expect(page.locator('.card input.input')).not.toBeVisible()
await expect(page.locator('table.table td').filter({hasText: 'Member'})).toBeVisible()
})
test('Allows an admin to add members to the team', async ({authenticatedPage: page}) => {
await TeamMemberFactory.create(1, {
team_id: 1,
admin: true,
})
await TeamFactory.create(1, {
id: 1,
})
const users = await UserFactory.create(5)
await page.goto('/teams/1/edit')
const teamMembersCard = page.locator('.card').filter({hasText: 'Team Members'})
const multiselect = teamMembersCard.locator('.card-content .multiselect')
const input = multiselect.locator('.input-wrapper input')
// Use the full username because the /users endpoint requires exact match
// Use type/pressSequentially instead of fill to properly trigger Vue's input events
await input.click()
await input.pressSequentially(users[1].username, {delay: 10})
// Wait for search results to appear (there's a 200ms debounce in the multiselect)
await expect(multiselect.locator('.search-results')).toBeVisible({timeout: 5000})
await multiselect.locator('.search-results').locator('> *').first().click()
await teamMembersCard.locator('.card-content .button').filter({hasText: 'Add to team'}).click()
await expect(page.locator('table.table td').filter({hasText: 'Admin'})).toBeVisible()
// Find the row containing the new member's username
const newMemberRow = page.locator('table.table tr').filter({hasText: users[1].username})
await expect(newMemberRow).toBeVisible()
await expect(newMemberRow).toContainText('Member')
await expect(page.locator('.global-notification')).toContainText('Success')
})
})

View File

@@ -0,0 +1,32 @@
import {test, expect} from '../../support/fixtures'
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {TaskCommentFactory} from '../../factories/task_comment'
import {createDefaultViews} from '../project/prepareProjects'
test.describe('Task comment pagination', () => {
test.beforeEach(async ({authenticatedPage: page}) => {
await ProjectFactory.create(1)
await createDefaultViews(1)
await TaskFactory.create(1, {id: 1})
await TaskCommentFactory.truncate()
})
test('shows pagination when more comments than configured page size', async ({authenticatedPage: page, apiContext}) => {
const response = await apiContext.get('info')
const body = await response.json()
const pageSize = body.max_items_per_page
await TaskCommentFactory.create(pageSize + 10)
await page.goto('/tasks/1')
await expect(page.locator('.task-view .comments nav.pagination')).toBeVisible()
})
test('hides pagination when comments equal or fewer than configured page size', async ({authenticatedPage: page, apiContext}) => {
const response = await apiContext.get('info')
const body = await response.json()
const pageSize = body.max_items_per_page
await TaskCommentFactory.create(Math.max(1, pageSize - 10))
await page.goto('/tasks/1')
await expect(page.locator('.task-view .comments nav.pagination')).not.toBeVisible()
})
})

View File

@@ -1,3 +1,4 @@
import {test, expect} from '../../support/fixtures'
import {UserFactory} from '../../factories/user'
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
@@ -5,14 +6,14 @@ import {login} from '../../support/authenticateUser'
import {DATE_DISPLAY} from '../../../src/constants/dateDisplay'
import {TIME_FORMAT} from '../../../src/constants/timeFormat'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import relativeTime from 'dayjs/plugin/relativeTime.js'
dayjs.extend(relativeTime)
const createdDate = new Date(Date.UTC(2022, 6, 25, 12))
const now = new Date(Date.UTC(2022, 6, 30, 12))
const expectedFormats = {
const expectedFormats12h = {
[DATE_DISPLAY.RELATIVE]: dayjs(createdDate).from(now),
[DATE_DISPLAY.MM_DD_YYYY]: dayjs(createdDate).format('MM-DD-YYYY hh:mm A'),
[DATE_DISPLAY.DD_MM_YYYY]: dayjs(createdDate).format('DD-MM-YYYY hh:mm A'),
@@ -66,48 +67,48 @@ const expectedFormats24h = {
}).format(createdDate),
}
describe('Date display setting', () => {
Object.entries(expectedFormats).forEach(([format, expected]) => {
it(`shows ${format} with 12h time format`, () => {
const user = UserFactory.create(1, {
test.describe('Date display setting', () => {
Object.entries(expectedFormats12h).forEach(([format, expected]) => {
test(`shows ${format} with 12h time format`, async ({page, apiContext}) => {
const user = (await UserFactory.create(1, {
frontend_settings: JSON.stringify({dateDisplay: format, timeFormat: TIME_FORMAT.HOURS_12}),
})[0]
const project = ProjectFactory.create(1, {owner_id: user.id})[0]
TaskFactory.truncate()
const task = TaskFactory.create(1, {
}))[0]
const project = (await ProjectFactory.create(1, {owner_id: user.id}))[0]
await TaskFactory.truncate()
const task = (await TaskFactory.create(1, {
id: 1,
project_id: project.id,
created_by_id: user.id,
created: createdDate.toISOString(),
updated: createdDate.toISOString(),
})[0]
}))[0]
cy.clock(now, ['Date'])
login(user)
cy.visit(`/tasks/${task.id}`)
cy.get('.task-view .created time span').should('contain', expected)
await page.clock.install({time: now})
await login(page, apiContext, user)
await page.goto(`/tasks/${task.id}`)
await expect(page.locator('.task-view .created time span')).toContainText(expected)
})
})
Object.entries(expectedFormats24h).forEach(([format, expected]) => {
it(`shows ${format} with 24h time format`, () => {
const user = UserFactory.create(1, {
test(`shows ${format} with 24h time format`, async ({page, apiContext}) => {
const user = (await UserFactory.create(1, {
frontend_settings: JSON.stringify({dateDisplay: format, timeFormat: TIME_FORMAT.HOURS_24}),
})[0]
const project = ProjectFactory.create(1, {owner_id: user.id})[0]
TaskFactory.truncate()
const task = TaskFactory.create(1, {
}))[0]
const project = (await ProjectFactory.create(1, {owner_id: user.id}))[0]
await TaskFactory.truncate()
const task = (await TaskFactory.create(1, {
id: 1,
project_id: project.id,
created_by_id: user.id,
created: createdDate.toISOString(),
updated: createdDate.toISOString(),
})[0]
}))[0]
cy.clock(now, ['Date'])
login(user)
cy.visit(`/tasks/${task.id}`)
cy.get('.task-view .created time span').should('contain', expected)
await page.clock.install({time: now})
await login(page, apiContext, user)
await page.goto(`/tasks/${task.id}`)
await expect(page.locator('.task-view .created time span')).toContainText(expected)
})
})
})

View File

@@ -0,0 +1,175 @@
import {test, expect} from '../../support/fixtures'
import {ProjectFactory} from '../../factories/project'
import {seed} from '../../support/seed'
import {TaskFactory} from '../../factories/task'
import {BucketFactory} from '../../factories/bucket'
import {updateUserSettings} from '../../support/updateUserSettings'
import {createDefaultViews} from '../project/prepareProjects'
import type {APIRequestContext} from '@playwright/test'
async function seedTasks(apiContext: APIRequestContext, numberOfTasks = 50, startDueDate = new Date()) {
const project = (await ProjectFactory.create())[0]
const views = await createDefaultViews(project.id)
await BucketFactory.create(1, {
project_view_id: views[3].id,
})
const tasks = []
let dueDate = startDueDate
for (let i = 0; i < numberOfTasks; i++) {
const now = new Date()
dueDate = new Date(new Date(dueDate).setDate(dueDate.getDate() + 2))
tasks.push({
id: i + 1,
project_id: project.id,
done: false,
created_by_id: 1,
title: 'Test Task ' + i,
index: i + 1,
due_date: dueDate.toISOString(),
created: now.toISOString(),
updated: now.toISOString(),
})
}
await TaskFactory.seed(TaskFactory.table, tasks)
return {tasks, project}
}
test.describe('Home Page Task Overview', () => {
test('Should show tasks with a near due date first on the home page overview', async ({authenticatedPage: page, apiContext}) => {
const taskCount = 50
const {tasks} = await seedTasks(apiContext, taskCount)
await page.goto('/')
const taskElements = await page.locator('[data-cy="showTasks"] .card .task').all()
for (let index = 0; index < taskElements.length; index++) {
const taskText = await taskElements[index].innerText()
expect(taskText).toContain(tasks[index].title)
}
})
test('Should show overdue tasks first, then show other tasks', async ({authenticatedPage: page, apiContext}) => {
const now = new Date()
const oldDate = new Date(new Date(now).setDate(now.getDate() - 14))
const taskCount = 50
const {tasks} = await seedTasks(apiContext, taskCount, oldDate)
await page.goto('/')
const taskElements = await page.locator('[data-cy="showTasks"] .card .task').all()
for (let index = 0; index < taskElements.length; index++) {
const taskText = await taskElements[index].innerText()
expect(taskText).toContain(tasks[index].title)
}
})
test.skip('Should show a new task with a very soon due date at the top', async ({authenticatedPage: page, apiContext}) => {
const {tasks, project} = await seedTasks(apiContext, 49)
const newTaskTitle = 'New Task'
await page.goto('/')
await page.waitForLoadState('networkidle')
await TaskFactory.create(1, {
id: 999,
title: newTaskTitle,
project_id: project.id,
due_date: new Date().toISOString(),
}, false)
await page.goto(`/projects/${project.id}/1`)
await page.waitForLoadState('networkidle')
// Wait for the tasks list to load and contain the new task
await expect(page.locator('.tasks')).toContainText(newTaskTitle)
await page.goto('/')
await page.waitForLoadState('networkidle')
await expect(page.locator('[data-cy="showTasks"] .card .task').first()).toContainText(newTaskTitle)
})
test.skip('Should not show a new task without a date at the bottom when there are > 50 tasks', async ({authenticatedPage: page, apiContext}) => {
// We're not using the api here to create the task in order to verify the flow
const {tasks} = await seedTasks(apiContext, 100)
const newTaskTitle = 'New Task'
await page.goto('/')
await page.waitForLoadState('networkidle')
await page.goto(`/projects/${tasks[0].project_id}/1`)
await page.waitForLoadState('networkidle')
const taskResponsePromise = page.waitForResponse('**/api/v1/projects/*/tasks')
await page.locator('.task-add textarea').fill(newTaskTitle)
await page.locator('.task-add textarea').press('Enter')
await taskResponsePromise
await page.goto('/')
await page.waitForLoadState('networkidle')
await expect(page.locator('[data-cy="showTasks"]')).not.toContainText(newTaskTitle)
})
test.skip('Should show a new task without a date at the bottom when there are < 50 tasks', async ({authenticatedPage: page, apiContext}) => {
const {project} = await seedTasks(apiContext, 40)
const newTaskTitle = 'New Task'
await TaskFactory.create(1, {
id: 999,
title: newTaskTitle,
project_id: project.id,
}, false)
await page.goto('/')
await page.waitForLoadState('networkidle')
await expect(page.locator('[data-cy="showTasks"]')).toContainText(newTaskTitle)
})
test.skip('Should show a task without a due date added via default project at the bottom', async ({authenticatedPage: page, apiContext}) => {
const {project} = await seedTasks(apiContext, 40)
// Navigate first to get access to localStorage
await page.goto('/')
await page.waitForLoadState('networkidle')
const token = await page.evaluate(() => localStorage.getItem('token'))
await updateUserSettings(apiContext, token, {
default_project_id: project.id,
overdue_tasks_reminders_time: '9:00',
})
const newTaskTitle = 'New Task'
// Reload page to apply the new settings
await page.reload()
// Wait for page to be fully loaded
await page.waitForLoadState('networkidle')
// Wait for the add task input to be visible and ready
const addTaskInput = page.locator('.add-task-textarea')
await expect(addTaskInput).toBeVisible()
await addTaskInput.fill(newTaskTitle)
// Wait for the task creation request to complete
const createTaskPromise = page.waitForResponse(response =>
response.url().includes('/projects/') &&
response.url().includes('/tasks') &&
response.request().method() === 'PUT',
)
await addTaskInput.press('Enter')
await createTaskPromise
// Wait for the task to appear in the list (no due date tasks appear at the bottom)
await expect(page.locator('[data-cy="showTasks"] .card .task').last()).toContainText(newTaskTitle, {timeout: 10000})
})
test('Should show the cta buttons for new project when there are no tasks', async ({authenticatedPage: page}) => {
await TaskFactory.truncate()
await page.goto('/')
await expect(page.locator('.home.app-content .content')).toContainText('Import your projects and tasks from other services into Vikunja:')
})
test('Should not show the cta buttons for new project when there are tasks', async ({authenticatedPage: page, apiContext}) => {
await seedTasks(apiContext)
await page.goto('/')
await expect(page.locator('.home.app-content .content')).not.toContainText('You can create a new project for your new tasks:')
await expect(page.locator('.home.app-content .content')).not.toContainText('Or import your projects and tasks from other services into Vikunja:')
})
})

View File

@@ -0,0 +1,73 @@
import {test, expect} from '../../support/fixtures'
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {ProjectViewFactory} from '../../factories/project_view'
import {TaskRelationFactory} from '../../factories/task_relation'
async function createViews(projectId: number, projectViewId: number) {
return (await ProjectViewFactory.create(1, {
id: projectViewId,
project_id: projectId,
view_kind: 0,
}, false))[0]
}
test.describe('Subtask duplicate handling', () => {
let projectA
let projectB
let parentA
let parentB
let subtask
test.beforeEach(async ({authenticatedPage: page, apiContext}) => {
await Promise.all([
ProjectFactory.truncate(),
ProjectViewFactory.truncate(),
TaskFactory.truncate(),
TaskRelationFactory.truncate(),
])
projectA = (await ProjectFactory.create(1, {id: 1, title: 'Project A'}))[0]
await createViews(projectA.id, 1)
projectB = (await ProjectFactory.create(1, {id: 2, title: 'Project B'}, false))[0]
await createViews(projectB.id, 2)
parentA = (await TaskFactory.create(1, {id: 10, title: 'Parent A', project_id: projectA.id}, false))[0]
parentB = (await TaskFactory.create(1, {id: 11, title: 'Parent B', project_id: projectB.id}, false))[0]
subtask = (await TaskFactory.create(1, {id: 12, title: 'Shared subtask', project_id: projectA.id}, false))[0]
// Navigate to a page first to establish context for localStorage access
await page.goto('/')
const token = await page.evaluate(() => localStorage.getItem('token'))
await apiContext.put(`tasks/${parentA.id}/relations`, {
headers: {
'Authorization': `Bearer ${token}`,
},
data: {
other_task_id: subtask.id,
relation_kind: 'subtask',
},
})
await apiContext.put(`tasks/${parentB.id}/relations`, {
headers: {
'Authorization': `Bearer ${token}`,
},
data: {
other_task_id: subtask.id,
relation_kind: 'subtask',
},
})
})
test('shows subtask only once in each project list', async ({authenticatedPage: page}) => {
await page.goto(`/projects/${projectA.id}/1`)
await expect(page.locator('.subtask-nested .task-link').filter({hasText: subtask.title})).toBeVisible()
await expect(page.locator('.tasks .task-link').filter({hasText: subtask.title})).toHaveCount(1)
await page.goto(`/projects/${projectB.id}/1`)
await expect(page.locator('.subtask-nested .task-link').filter({hasText: subtask.title})).toBeVisible()
await expect(page.locator('.tasks .task-link').filter({hasText: subtask.title})).toHaveCount(1)
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,168 @@
import {test, expect} from '../../support/fixtures'
import {UserFactory} from '../../factories/user'
import {TokenFactory} from '../../factories/token'
import {TEST_PASSWORD, TEST_PASSWORD_HASH} from '../../support/constants'
test.describe('Email Confirmation', () => {
let user
let confirmationToken
test.beforeEach(async ({page, apiContext}) => {
await UserFactory.truncate()
await TokenFactory.truncate()
// Create a user with status = 1 (StatusEmailConfirmationRequired)
const users = await UserFactory.create(1, {
username: 'unconfirmeduser',
email: 'unconfirmed@example.com',
password: TEST_PASSWORD_HASH,
status: 1, // StatusEmailConfirmationRequired
})
user = users[0]
// Create an email confirmation token for this user
// kind: 2 = TokenEmailConfirm
confirmationToken = 'test-email-confirm-token-12345678901234567890123456789012'
await TokenFactory.create(1, {
user_id: user.id,
kind: 2,
token: confirmationToken,
})
})
test('Should fail login before email is confirmed', async ({page, apiContext}) => {
await page.goto('/login')
await page.locator('input[id=username]').fill(user.username)
await page.locator('input[id=password]').fill(TEST_PASSWORD)
await page.locator('.button').filter({hasText: 'Login'}).click()
await expect(page.locator('div.message.danger')).toContainText('Email address of the user not confirmed')
})
test('Should confirm email and allow login', async ({page, apiContext}) => {
// Setup response promise for the confirmation API call
const confirmEmailPromise = page.waitForResponse(response =>
response.url().includes('/user/confirm') && response.request().method() === 'POST',
)
// Manually set the token in localStorage before visiting the page
// This simulates what happens when the user clicks the email link
await page.goto('/login')
await page.evaluate((token) => {
localStorage.setItem('emailConfirmToken', token)
}, confirmationToken)
await page.reload()
// Wait for the confirmation API call to complete
const confirmResponse = await confirmEmailPromise
expect(confirmResponse.status()).toBe(200)
// Should show success message
await expect(page.locator('.message.success')).toBeVisible({timeout: 10000})
await expect(page.locator('.message.success')).toContainText('You successfully confirmed your email')
// Now login should work
await page.locator('input[id=username]').fill(user.username)
await page.locator('input[id=password]').fill(TEST_PASSWORD)
await page.locator('.button').filter({hasText: 'Login'}).click()
// Should successfully log in
await expect(page).toHaveURL(/\//)
await expect(page).not.toHaveURL(/\/login/)
// Check that the username appears in the greeting
await expect(page.locator('body')).toContainText(user.username)
})
test('Should fail with invalid confirmation token', async ({page, apiContext}) => {
// Setup response promise for the confirmation API call
const confirmEmailPromise = page.waitForResponse(response =>
response.url().includes('/user/confirm') && response.request().method() === 'POST',
)
// Try to confirm with an invalid token
const invalidToken = 'invalid-token-that-does-not-exist-in-database'
await page.goto('/login')
await page.evaluate((token) => {
localStorage.setItem('emailConfirmToken', token)
}, invalidToken)
await page.reload()
// Wait for the confirmation API call to fail
await confirmEmailPromise
// Should show error message
await expect(page.locator('.message.danger')).toBeVisible({timeout: 10000})
// Login should still fail
await page.locator('input[id=username]').fill(user.username)
await page.locator('input[id=password]').fill(TEST_PASSWORD)
await page.locator('.button').filter({hasText: 'Login'}).click()
await expect(page.locator('div.message.danger')).toContainText('Email address of the user not confirmed')
})
test('Should not allow using the same token twice', async ({page, apiContext}) => {
// First confirmation - should work
let confirmEmailPromise = page.waitForResponse(response =>
response.url().includes('/user/confirm') && response.request().method() === 'POST',
)
await page.goto('/login')
await page.evaluate((token) => {
localStorage.setItem('emailConfirmToken', token)
}, confirmationToken)
await page.reload()
let confirmResponse = await confirmEmailPromise
expect(confirmResponse.status()).toBe(200)
await expect(page.locator('.message.success')).toBeVisible({timeout: 10000})
await expect(page.locator('.message.success')).toContainText('You successfully confirmed your email')
// Try to use the same token again - should fail
confirmEmailPromise = page.waitForResponse(response =>
response.url().includes('/user/confirm') && response.request().method() === 'POST',
)
await page.goto('/login')
await page.evaluate((token) => {
localStorage.setItem('emailConfirmToken', token)
}, confirmationToken)
await page.reload()
await confirmEmailPromise
await expect(page.locator('.message.danger')).toBeVisible({timeout: 10000})
})
test('Should confirm email when clicking link from email (via query parameter)', async ({page, apiContext}) => {
// Setup response promise for the confirmation API call
const confirmEmailPromise = page.waitForResponse(response =>
response.url().includes('/user/confirm') && response.request().method() === 'POST',
)
// Simulate clicking the email confirmation link with query parameter
// This is what happens when a user clicks the link in their email
await page.goto(`/?userEmailConfirm=${confirmationToken}`)
// Should redirect to login page
await expect(page).toHaveURL(/\/login/)
// Wait for the confirmation API call to complete
const confirmResponse = await confirmEmailPromise
expect(confirmResponse.status()).toBe(200)
// Should show success message
await expect(page.locator('.message.success')).toBeVisible({timeout: 10000})
await expect(page.locator('.message.success')).toContainText('You successfully confirmed your email')
// Now login should work
await page.locator('input[id=username]').fill(user.username)
await page.locator('input[id=password]').fill(TEST_PASSWORD)
await page.locator('.button').filter({hasText: 'Login'}).click()
// Should successfully log in
await expect(page).toHaveURL(/\//)
await expect(page).not.toHaveURL(/\/login/)
// Check that the username appears in the greeting
await expect(page.locator('body')).toContainText(user.username)
})
})

View File

@@ -0,0 +1,87 @@
import type {Page} from '@playwright/test'
import {test, expect} from '../../support/fixtures'
import {UserFactory} from '../../factories/user'
import {ProjectFactory} from '../../factories/project'
import {TEST_PASSWORD} from '../../support/constants'
interface LoginCredentials {
username: string
password: string
}
const testAndAssertFailed = async (page: Page, fixture: LoginCredentials): Promise<void> => {
const loginPromise = page.waitForResponse(response =>
response.url().includes('/login') && response.request().method() === 'POST',
)
await page.goto('/login')
await page.locator('input[id=username]').fill(fixture.username)
await page.locator('input[id=password]').fill(fixture.password)
await page.locator('.button').filter({hasText: 'Login'}).click()
await loginPromise
await expect(page).toHaveURL('/login')
await expect(page.locator('div.message.danger')).toContainText('Wrong username or password.')
}
const credentials: LoginCredentials = {
username: 'test',
password: TEST_PASSWORD,
}
async function login(page: Page): Promise<void> {
await page.locator('input[id=username]').fill(credentials.username)
await page.locator('input[id=password]').fill(credentials.password)
await page.locator('.button').filter({hasText: 'Login'}).click()
await expect(page).toHaveURL('/')
}
test.describe('Login', () => {
test.beforeEach(async ({apiContext}) => {
await UserFactory.create(1, {username: credentials.username})
})
test('Should log in with the right credentials', async ({page}) => {
await page.goto('/login')
await login(page)
await page.clock.install({time: new Date(1625656161057)}) // 13:00
// Use more specific selector to avoid strict mode violation
await expect(page.locator('main h2')).toContainText(`Hi ${credentials.username}!`)
})
// FIXME: request timeout for the request that's awaited
test.skip('Should fail with a bad password', async ({page}) => {
const fixture = {
username: 'test',
password: '123456',
}
await testAndAssertFailed(page, fixture)
})
test('Should fail with a bad username', async ({page}) => {
const fixture = {
username: 'loremipsum',
password: TEST_PASSWORD,
}
await testAndAssertFailed(page, fixture)
})
test('Should redirect to /login when no user is logged in', async ({page}) => {
await page.goto('/')
await expect(page).toHaveURL(/\/login/)
})
// FIXME: request timeout
test.skip('Should redirect to the previous route after logging in', async ({page}) => {
const projects = await ProjectFactory.create(1)
await page.goto(`/projects/${projects[0].id}/1`)
await expect(page).toHaveURL(/\/login/)
await login(page)
await expect(page).toHaveURL(new RegExp(`/projects/${projects[0].id}/1`))
})
})

View File

@@ -0,0 +1,69 @@
import {test, expect} from '../../support/fixtures'
import {ProjectFactory} from '../../factories/project'
import {ProjectViewFactory} from '../../factories/project_view'
async function logout(page) {
await page.locator('.navbar .username-dropdown-trigger').click()
await page.locator('.navbar .dropdown-item').filter({hasText: 'Logout'}).click()
}
test.describe('Log out', () => {
test.use({
// All tests in this describe block use the authenticatedPage fixture
})
test('Logs the user out', async ({authenticatedPage: page}) => {
await page.goto('/')
// Check that token exists before logout
const tokenBefore = await page.evaluate(() => localStorage.getItem('token'))
expect(tokenBefore).not.toBeNull()
await logout(page)
// Check URL redirects to login
await expect(page).toHaveURL(/\/login/)
// Check that token is removed after logout
const tokenAfter = await page.evaluate(() => localStorage.getItem('token'))
expect(tokenAfter).toBeNull()
})
test('Should clear the project history after logging the user out', async ({authenticatedPage: page}) => {
const projects = await ProjectFactory.create(1)
await ProjectViewFactory.truncate()
await ProjectViewFactory.create(1, {
id: projects[0].id,
project_id: projects[0].id,
}, false)
// Wait for the project page to load and history to be saved
const loadProjectPromise = page.waitForResponse(response =>
response.url().includes(`/projects/${projects[0].id}`) && response.request().method() === 'GET',
)
await page.goto(`/projects/${projects[0].id}/${projects[0].id}`)
await loadProjectPromise
// Wait for history to be saved to localStorage
await page.waitForFunction(
(projectId) => {
const history = JSON.parse(localStorage.getItem('projectHistory') || '[]')
return history.some((h: {id: number}) => h.id === projectId)
},
projects[0].id,
)
// Check that project history exists
const historyBefore = await page.evaluate(() => localStorage.getItem('projectHistory'))
expect(historyBefore).not.toBeNull()
await logout(page)
// Check URL redirects to login
await expect(page).toHaveURL(/\/login/)
// Verify the project history is cleared after logout
const historyAfter = await page.evaluate(() => localStorage.getItem('projectHistory'))
expect(historyAfter).toBeNull()
})
})

View File

@@ -0,0 +1,21 @@
import {test, expect} from '../../support/fixtures'
test.describe('OpenID Login', () => {
test('logs in via Dex provider', async ({page}) => {
await page.goto('/login')
await page.locator('text=Dex').click()
// Wait for navigation to Dex origin
await expect(page.locator('h2')).toContainText('Log in to Your Account')
// Fill in the Dex login form
await page.locator('#login').fill('test@example.com')
await page.locator('#password').fill('12345678')
await page.locator('#submit-login').click()
// Should redirect back to the app
await expect(page).toHaveURL(/\//)
await expect(page.locator('main.app-content .content h2')).toContainText('test!')
await expect(page.locator('.show-tasks h3')).toContainText('Current Tasks')
})
})

View File

@@ -0,0 +1,59 @@
import {test, expect} from '../../support/fixtures'
import {UserFactory, type UserAttributes} from '../../factories/user'
import {TokenFactory, type TokenAttributes} from '../../factories/token'
test.describe('Password Reset', () => {
let user: UserAttributes
test.beforeEach(async ({page, apiContext}) => {
await UserFactory.truncate()
await TokenFactory.truncate()
const users = await UserFactory.create(1)
user = users[0] as UserAttributes
})
test('Should allow a user to reset their password with a valid token', async ({page, apiContext}) => {
const tokenArray = await TokenFactory.create(1, {user_id: user.id as number, kind: 1})
const token: TokenAttributes = tokenArray[0] as TokenAttributes
await page.goto(`/?userPasswordReset=${token.token}`)
await expect(page).toHaveURL(`/password-reset?userPasswordReset=${token.token}`)
const newPassword = 'newSecurePassword123'
await page.locator('input[id=password]').fill(newPassword)
await page.locator('button').filter({hasText: 'Reset your password'}).click()
await expect(page.locator('.message.success')).toContainText('The password was updated successfully.')
await page.locator('.button').filter({hasText: 'Login'}).click()
await expect(page).toHaveURL('/login')
// Try to login with the new password
await page.locator('input[id=username]').fill(user.username)
await page.locator('input[id=password]').fill(newPassword)
await page.locator('.button').filter({hasText: 'Login'}).click()
await expect(page).toHaveURL('/')
})
test('Should show an error for an invalid token', async ({page, apiContext}) => {
await page.goto('/?userPasswordReset=invalidtoken123')
await expect(page).toHaveURL('/password-reset?userPasswordReset=invalidtoken123')
// Attempt to reset password
const newPassword = 'newSecurePassword123'
await page.locator('input[id=password]').fill(newPassword)
await page.locator('button').filter({hasText: 'Reset your password'}).click()
await expect(page.locator('.message')).toContainText('Invalid token')
})
test('Should redirect to login if no token is present in query param when visiting /password-reset directly', async ({page, apiContext}) => {
await page.goto('/password-reset')
// Wait for redirect to login page
await expect(page).toHaveURL('/login')
})
test('Should redirect to login if userPasswordReset token is not present in query param when visiting root', async ({page, apiContext}) => {
await page.goto('/')
await expect(page).toHaveURL('/login')
})
})

View File

@@ -0,0 +1,47 @@
// This test assumes no mailer is set up and all users are activated immediately.
import {test, expect} from '../../support/fixtures'
import {UserFactory} from '../../factories/user'
test.describe('Registration', () => {
test.beforeEach(async ({page, apiContext}) => {
await UserFactory.create(1, {
username: 'test',
})
await page.goto('/')
await page.evaluate(() => localStorage.removeItem('token'))
})
test('Should work without issues', async ({page, apiContext}) => {
const fixture = {
username: 'testuser',
password: '12345678',
email: 'testuser@example.com',
}
// Install clock before navigation so app observes mocked time for greeting
await page.clock.install({time: new Date(1625656161057)}) // 13:00
await page.goto('/register')
await page.locator('#username').fill(fixture.username)
await page.locator('#email').fill(fixture.email)
await page.locator('#password').fill(fixture.password)
await page.locator('#register-submit').click()
await expect(page).toHaveURL('/')
await expect(page.locator('main h2')).toContainText(`Hi ${fixture.username}!`)
})
test('Should fail', async ({page, apiContext}) => {
const fixture = {
username: 'test',
password: '12345678',
email: 'testuser@example.com',
}
await page.goto('/register')
await page.locator('#username').fill(fixture.username)
await page.locator('#email').fill(fixture.email)
await page.locator('#password').fill(fixture.password)
await page.locator('#register-submit').click()
await expect(page.locator('div.message.danger')).toContainText('A user with this username already exists.')
})
})

View File

@@ -0,0 +1,63 @@
import {test, expect} from '../../support/fixtures'
test.describe('User Settings', () => {
// TODO: This test is flaky - the cropper's canvas.toBlob returns null intermittently
// The vue-advanced-cropper component seems to not properly initialize in the test environment
test.skip('Changes the user avatar', async ({authenticatedPage: page}) => {
await page.goto('/user/settings/avatar')
await page.waitForLoadState('networkidle')
// Wait for the avatar settings content to be visible
const uploadRadio = page.locator('input[name=avatarProvider][value=upload]')
await expect(uploadRadio).toBeVisible({timeout: 5000})
await uploadRadio.click()
// Set the file directly on the (hidden) file input
const fileInput = page.locator('input[type=file]')
await fileInput.setInputFiles('tests/fixtures/image.jpg')
// Wait for the cropper to be visible (the image needs to be loaded)
const cropper = page.locator('.vue-advanced-cropper')
await expect(cropper).toBeVisible({timeout: 10000})
// After cropper appears, there's a new "Upload Avatar" button with data-cy attribute
const uploadButton = page.locator('[data-cy="uploadAvatar"]')
await expect(uploadButton).toBeVisible()
// Set up response waiter before clicking
const avatarUploadPromise = page.waitForResponse(response =>
response.url().includes('avatar') && response.request().method() === 'PUT',
)
await uploadButton.click()
// Wait for the avatar upload response and verify it succeeded
const response = await avatarUploadPromise
expect(response.ok()).toBe(true)
await expect(page.locator('.global-notification')).toContainText('Success', {timeout: 10000})
})
test.skip('Updates the name', async ({authenticatedPage: page}) => {
await page.goto('/user/settings/general')
await page.waitForLoadState('networkidle')
// Wait for the settings page to be fully loaded and the input to be enabled
const nameInput = page.locator('.general-settings input.input').first()
await expect(nameInput).toBeVisible({timeout: 10000})
await expect(nameInput).toBeEnabled()
// Clear and type to ensure Vue's reactivity is triggered
await nameInput.clear()
await nameInput.pressSequentially('Lorem Ipsum', {delay: 10})
// The save button only appears when isDirty becomes true (settings changed)
const saveButton = page.locator('[data-cy="saveGeneralSettings"]')
await expect(saveButton).toBeVisible({timeout: 10000})
await saveButton.click()
await expect(page.locator('.global-notification')).toContainText('Success')
await expect(page.locator('.navbar .username-dropdown-trigger .username')).toContainText('Lorem Ipsum')
})
})

View File

@@ -0,0 +1,19 @@
import {faker} from '@faker-js/faker'
import {Factory} from '../support/factory'
export class BucketFactory extends Factory {
static table = 'buckets'
static factory() {
const now = new Date()
return {
id: '{increment}',
title: faker.lorem.words(3),
project_view_id: '{increment}',
created_by_id: 1,
created: now.toISOString(),
updated: now.toISOString(),
}
}
}

View File

@@ -0,0 +1,16 @@
import {Factory} from '../support/factory'
export class LabelTaskFactory extends Factory {
static table = 'label_tasks'
static factory() {
const now = new Date()
return {
id: '{increment}',
task_id: 1,
label_id: 1,
created: now.toISOString(),
}
}
}

View File

@@ -0,0 +1,21 @@
import {faker} from '@faker-js/faker'
import {Factory} from '../support/factory'
export class LabelFactory extends Factory {
static table = 'labels'
static factory() {
const now = new Date()
return {
id: '{increment}',
title: faker.lorem.words(2),
description: faker.lorem.text(10),
hex_color: (Math.random()*0xFFFFFF<<0).toString(16), // random 6-digit hex number
created_by_id: 1,
created: now.toISOString(),
updated: now.toISOString(),
}
}
}

View File

@@ -0,0 +1,21 @@
import {Factory} from '../support/factory'
import {faker} from '@faker-js/faker'
export class LinkShareFactory extends Factory {
static table = 'link_shares'
static factory() {
const now = new Date()
return {
id: '{increment}',
hash: faker.lorem.word(32),
project_id: 1,
permission: 0,
sharing_type: 0,
shared_by_id: 1,
created: now.toISOString(),
updated: now.toISOString(),
}
}
}

View File

@@ -0,0 +1,26 @@
import {Factory} from '../support/factory'
import {faker} from '@faker-js/faker'
export interface ProjectAttributes {
id: number | '{increment}';
title: string;
owner_id: number;
created: string;
updated: string;
}
export class ProjectFactory extends Factory {
static table = 'projects'
static factory() {
const now = new Date()
return {
id: '{increment}',
title: faker.lorem.words(3),
owner_id: 1,
created: now.toISOString(),
updated: now.toISOString(),
}
}
}

View File

@@ -0,0 +1,19 @@
import {Factory} from '../support/factory'
import {faker} from '@faker-js/faker'
export class ProjectViewFactory extends Factory {
static table = 'project_views'
static factory() {
const now = new Date()
return {
id: '{increment}',
title: faker.lorem.words(3),
project_id: '{increment}',
view_kind: 0,
created: now.toISOString(),
updated: now.toISOString(),
}
}
}

View File

@@ -0,0 +1,21 @@
import {faker} from '@faker-js/faker'
import {Factory} from '../support/factory'
export class TaskFactory extends Factory {
static table = 'tasks'
static factory() {
const now = new Date()
return {
id: '{increment}',
title: faker.lorem.words(3),
done: false,
project_id: 1,
created_by_id: 1,
index: '{increment}',
created: now.toISOString(),
updated: now.toISOString()
}
}
}

View File

@@ -0,0 +1,16 @@
import {Factory} from '../support/factory'
export class TaskAssigneeFactory extends Factory {
static table = 'task_assignees'
static factory() {
const now = new Date()
return {
id: '{increment}',
task_id: 1,
user_id: 1,
created: now.toISOString(),
}
}
}

View File

@@ -0,0 +1,16 @@
import {Factory} from '../support/factory'
export class TaskAttachmentFactory extends Factory {
static table = 'task_attachments'
static factory() {
const now = new Date()
return {
id: '{increment}',
task_id: 1,
file_id: 1,
created: now.toISOString(),
}
}
}

View File

@@ -0,0 +1,13 @@
import {Factory} from '../support/factory'
export class TaskBucketFactory extends Factory {
static table = 'task_buckets'
static factory() {
return {
task_id: '{increment}',
bucket_id: '{increment}',
project_view_id: '{increment}',
}
}
}

View File

@@ -0,0 +1,20 @@
import {faker} from '@faker-js/faker'
import {Factory} from '../support/factory'
export class TaskCommentFactory extends Factory {
static table = 'task_comments'
static factory() {
const now = new Date()
return {
id: '{increment}',
comment: faker.lorem.text(3),
author_id: 1,
task_id: 1,
created: now.toISOString(),
updated: now.toISOString()
}
}
}

View File

@@ -0,0 +1,18 @@
import {Factory} from '../support/factory'
export class TaskRelationFactory extends Factory {
static table = 'task_relations'
static factory() {
const now = new Date()
return {
id: '{increment}',
task_id: '{increment}',
other_task_id: '{increment}',
relation_kind: 'related',
created_by_id: 1,
created: now.toISOString(),
}
}
}

View File

@@ -0,0 +1,18 @@
import {Factory} from '../support/factory'
export class TaskReminderFactory extends Factory {
static table = 'task_reminders'
static factory() {
const now = new Date()
return {
id: '{increment}',
task_id: 1,
reminder: now.toISOString(),
created: now.toISOString(),
relative_to: '',
relative_period: 0,
}
}
}

View File

@@ -0,0 +1,17 @@
import {faker} from '@faker-js/faker'
import {Factory} from '../support/factory'
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: now.toISOString(),
updated: now.toISOString(),
}
}
}

View File

@@ -0,0 +1,14 @@
import {Factory} from '../support/factory'
export class TeamMemberFactory extends Factory {
static table = 'team_members'
static factory() {
return {
team_id: 1,
user_id: 1,
admin: false,
created: new Date().toISOString(),
}
}
}

View File

@@ -0,0 +1,29 @@
import {faker} from '@faker-js/faker'
import {Factory} from '../support/factory'
export interface TokenAttributes {
id: number | '{increment}';
user_id: number;
token: string;
kind: number;
created: string;
}
export class TokenFactory extends Factory {
static table = 'user_tokens'
// The factory method itself produces an object where id is '{increment}' (a string)
// before it gets processed by the main create() method in the base Factory class.
static factory(attrs?: Partial<Omit<TokenAttributes, 'id'>>): Omit<TokenAttributes, 'id'> & { id: string } {
const now = new Date()
return {
id: '{increment}', // This is a string
user_id: 1, // Default user_id
token: faker.string.alphanumeric(64),
kind: 1, // TokenPasswordReset
created: now.toISOString(),
...(attrs ?? {}),
}
}
}

View File

@@ -0,0 +1,34 @@
import {faker} from '@faker-js/faker'
import {Factory} from '../support/factory'
import {TEST_PASSWORD_HASH} from '../support/constants'
export interface UserAttributes {
id: number | '{increment}';
username: string;
password?: string;
status: number;
issuer: string;
language: string;
created: string;
updated: string;
}
export class UserFactory extends Factory {
static table = 'users'
static factory() {
const now = new Date()
return {
id: '{increment}',
username: faker.lorem.word(10) + faker.string.uuid(),
password: TEST_PASSWORD_HASH,
status: 0,
issuer: 'local',
language: 'en',
created: now.toISOString(),
updated: now.toISOString(),
}
}
}

View File

@@ -0,0 +1,18 @@
import {Factory} from '../support/factory'
export class UserProjectFactory extends Factory {
static table = 'users_projects'
static factory() {
const now = new Date()
return {
id: '{increment}',
project_id: 1,
user_id: 1,
permission: 0,
created: now.toISOString(),
updated: now.toISOString(),
}
}
}

BIN
frontend/tests/fixtures/image.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 872 KiB

View File

@@ -0,0 +1,49 @@
import type {Page, APIRequestContext} from '@playwright/test'
import {UserFactory} from '../factories/user'
import {TEST_PASSWORD} from './constants'
/**
* This authenticates a user and puts the token in local storage which allows us to perform authenticated requests.
*/
export async function login(page: Page, apiContext: APIRequestContext, user?: any) {
if (!user) {
throw new Error('Needs user')
}
// Login via API
const response = await apiContext.post('login', {
data: {
username: user.username,
password: TEST_PASSWORD,
},
})
if (!response.ok()) {
throw new Error(`Login failed: ${response.status()} ${response.statusText()}`)
}
const body = await response.json()
const token = body.token
// Set token in localStorage before navigating
await page.addInitScript((token) => {
window.localStorage.setItem('token', token)
}, token)
return user
}
export async function createFakeUser() {
const [u] = await UserFactory.create(1)
return u
}
/**
* Helper function to set up authentication for a test suite
* Returns the created user for use in tests
*/
export function createFakeUserAndLogin() {
// This returns undefined and instead relies on Playwright's beforeEach hooks
// The actual user will be available through the test context
return undefined
}

View File

@@ -0,0 +1,53 @@
import type {Locator} from '@playwright/test'
import {readFileSync} from 'fs'
import {join, dirname} from 'path'
import {fileURLToPath} from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
/**
* Simulates pasting a file from the clipboard into an element
* @param locator - The element to paste into
* @param fileName - The name of the file in the fixtures directory
* @param fileType - The MIME type of the file (default: 'image/png')
*/
export async function pasteFile(locator: Locator, fileName: string, fileType = 'image/png') {
const filePath = join(__dirname, '../fixtures', fileName)
const fileBuffer = readFileSync(filePath)
const base64 = fileBuffer.toString('base64')
await locator.evaluate((element, {base64Data, name, type}) => {
// Convert base64 to blob
const byteCharacters = atob(base64Data)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], {type})
// Create file and paste event
const file = new File([blob], name, {type})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const pasteEvent = new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: dataTransfer,
})
element.dispatchEvent(pasteEvent)
}, {base64Data: base64, name: fileName, type: fileType})
}
/**
* Performs a drag and drop operation
* Note: Playwright has native dragTo() support, so this is just a wrapper for consistency
* @param source - The source locator to drag from
* @param target - The target locator to drop onto
*/
export async function dragAndDrop(source: Locator, target: Locator) {
await source.dragTo(target)
}

View File

@@ -0,0 +1,15 @@
/**
* Shared test constants
*/
/**
* Default password used for test users.
* The bcrypt hash for this password is used in the user factory.
*/
export const TEST_PASSWORD = '1234'
/**
* Bcrypt hash of TEST_PASSWORD ('1234') for database seeding
*/
export const TEST_PASSWORD_HASH = '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.'

View File

@@ -0,0 +1,98 @@
import type {APIRequestContext} from '@playwright/test'
/**
* A factory makes it easy to seed the database with data.
*/
export class Factory {
static table: string | null = null
static request: APIRequestContext
static setRequestContext(request: APIRequestContext) {
this.request = request
}
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 async create(count = 1, override = {}, truncate = true) {
const data = []
for (let i = 1; i <= count; i++) {
const entry = {
...this.factory(),
...override,
}
for (const e in entry) {
if (typeof entry[e] === 'function') {
entry[e] = entry[e](i)
continue
}
if (entry[e] === '{increment}') {
entry[e] = i
}
}
data.push(entry)
}
// Create a flattened copy of the data for seeding
// This removes nested objects/arrays that the backend can't handle
const flatData = data.map(item => {
const flatItem = {}
for (const key in item) {
const value = item[key]
// Only include primitive values (string, number, boolean, null, Date)
if (value === null || value === undefined ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
value instanceof Date) {
flatItem[key] = value
}
// Skip arrays, objects, and other complex types
}
return flatItem
})
await this.seed(this.table, flatData, truncate)
return Promise.resolve(data)
}
static async seed(table: string, data: any, truncate = true) {
if (data === null) {
data = []
}
const response = await this.request.patch(
`test/${table}?truncate=${truncate ? 'true' : 'false'}`,
{
data,
headers: {
'Content-Type': 'application/json',
'Authorization': process.env.VIKUNJA_SERVICE_TESTINGTOKEN || 'averyLongSecretToSe33dtheDB',
},
},
)
if (!response.ok()) {
throw new Error(`Failed to seed data for table ${table}: ${response.status()} ${response.statusText()}`)
}
return response.json()
}
static async truncate() {
await this.seed(this.table, null)
}
}

View File

@@ -0,0 +1,119 @@
import {TaskFactory} from '../factories/task'
import {TaskBucketFactory} from '../factories/task_buckets'
export async function createTasksWithPriorities(buckets?: any[]) {
await TaskFactory.truncate()
const highPriorityTask1 = (await TaskFactory.create(1, {
id: 1,
project_id: 1,
priority: 4,
title: 'High Priority Task 1',
}, false))[0]
const highPriorityTask2 = (await TaskFactory.create(1, {
id: 2,
project_id: 1,
priority: 4,
title: 'High Priority Task 2',
}, false))[0]
const lowPriorityTask1 = (await TaskFactory.create(1, {
id: 3,
project_id: 1,
priority: 1,
title: 'Low Priority Task 1',
}, false))[0]
const lowPriorityTask2 = (await TaskFactory.create(1, {
id: 4,
project_id: 1,
priority: 1,
title: 'Low Priority Task 2',
}, false))[0]
// If buckets are provided (for Kanban), add tasks to buckets
if (buckets && buckets.length > 0) {
await TaskBucketFactory.truncate()
await TaskBucketFactory.create(1, {
task_id: highPriorityTask1.id,
bucket_id: buckets[0].id,
project_view_id: buckets[0].project_view_id,
}, false)
await TaskBucketFactory.create(1, {
task_id: highPriorityTask2.id,
bucket_id: buckets[0].id,
project_view_id: buckets[0].project_view_id,
}, false)
await TaskBucketFactory.create(1, {
task_id: lowPriorityTask1.id,
bucket_id: buckets[0].id,
project_view_id: buckets[0].project_view_id,
}, false)
await TaskBucketFactory.create(1, {
task_id: lowPriorityTask2.id,
bucket_id: buckets[0].id,
project_view_id: buckets[0].project_view_id,
}, false)
}
return {
highPriorityTasks: [highPriorityTask1, highPriorityTask2],
lowPriorityTasks: [lowPriorityTask1, lowPriorityTask2],
}
}
export async function createTasksWithSearch(buckets?: any[]) {
await TaskFactory.truncate()
const task1 = (await TaskFactory.create(1, {
id: 1,
project_id: 1,
title: 'Regular task 1',
}, false))[0]
const task2 = (await TaskFactory.create(1, {
id: 2,
project_id: 1,
title: 'Regular task 2',
}, false))[0]
const task3 = (await TaskFactory.create(1, {
id: 3,
project_id: 1,
title: 'Regular task 3',
}, false))[0]
const searchableTask = (await TaskFactory.create(1, {
id: 4,
project_id: 1,
title: 'Meeting notes for project',
}, false))[0]
// If buckets are provided (for Kanban), add tasks to buckets
if (buckets && buckets.length > 0) {
await TaskBucketFactory.truncate()
await TaskBucketFactory.create(1, {
task_id: task1.id,
bucket_id: buckets[0].id,
project_view_id: buckets[0].project_view_id,
}, false)
await TaskBucketFactory.create(1, {
task_id: task2.id,
bucket_id: buckets[0].id,
project_view_id: buckets[0].project_view_id,
}, false)
await TaskBucketFactory.create(1, {
task_id: task3.id,
bucket_id: buckets[0].id,
project_view_id: buckets[0].project_view_id,
}, false)
await TaskBucketFactory.create(1, {
task_id: searchableTask.id,
bucket_id: buckets[0].id,
project_view_id: buckets[0].project_view_id,
}, false)
}
return { searchableTask }
}

View File

@@ -0,0 +1,32 @@
import {test as base, type APIRequestContext, type Page} from '@playwright/test'
import {Factory} from './factory'
import {login, createFakeUser} from './authenticateUser'
export const test = base.extend<{
apiContext: APIRequestContext;
authenticatedPage: Page;
currentUser: any;
}>({
apiContext: async ({playwright}, use) => {
const baseURL = process.env.API_URL || 'http://localhost:3456/api/v1/'
const apiContext = await playwright.request.newContext({
baseURL,
})
Factory.setRequestContext(apiContext)
await use(apiContext)
await apiContext.dispose()
},
currentUser: async ({apiContext}, use) => {
const user = await createFakeUser()
await use(user)
},
authenticatedPage: async ({page, apiContext, currentUser}, use) => {
await login(page, apiContext, currentUser)
await use(page)
},
})
export {expect} from '@playwright/test'

View File

@@ -0,0 +1,27 @@
import type {APIRequestContext} from '@playwright/test'
/**
* 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 async function seed(apiContext: APIRequestContext, table: string, data: any = {}, truncate = true) {
if (data === null) {
data = []
}
const apiUrl = process.env.API_URL || 'http://localhost:3456/api/v1'
const testSecret = process.env.TEST_SECRET || 'averyLongSecretToSe33dtheDB'
await apiContext.patch(`${apiUrl}/test/${table}?truncate=${truncate ? 'true' : 'false'}`, {
headers: {
'Authorization': testSecret,
},
data: data,
})
}

View File

@@ -0,0 +1,23 @@
import type {APIRequestContext} from '@playwright/test'
export async function updateUserSettings(apiContext: APIRequestContext, token: string, settings: any) {
const apiUrl = process.env.API_URL || 'http://localhost:3456/api/v1'
const userResponse = await apiContext.get(`${apiUrl}/user`, {
headers: {
'Authorization': `Bearer ${token}`,
},
})
const oldSettings = await userResponse.json()
await apiContext.post(`${apiUrl}/user/settings/general`, {
headers: {
'Authorization': `Bearer ${token}`,
},
data: {
...oldSettings,
...settings,
},
})
}

View File

@@ -76,5 +76,17 @@ func HandleTesting(c echo.Context) error {
})
}
return c.JSON(http.StatusCreated, nil)
s := db.NewSession()
defer s.Close()
data := []map[string]interface{}{}
err = s.Table(table).Find(&data)
if err != nil {
log.Errorf("Error fetching table data: %v", err)
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
"error": true,
"message": err.Error(),
})
}
return c.JSON(http.StatusCreated, data)
}