mirror of
https://github.com/go-vikunja/vikunja.git
synced 2025-12-05 18:57:47 -06:00
feat: migrate cypress e2e tests to playwright (#1739)
This commit is contained in:
5
.github/actions/setup-frontend/action.yml
vendored
5
.github/actions/setup-frontend/action.yml
vendored
@@ -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
|
||||
|
||||
99
.github/workflows/test.yml
vendored
99
.github/workflows/test.yml
vendored
@@ -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
38
desktop/pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
11
devenv.nix
11
devenv.nix
@@ -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
4
frontend/.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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}`)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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:')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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`)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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.')
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
42
frontend/playwright.config.ts
Normal file
42
frontend/playwright.config.ts
Normal 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
|
||||
})
|
||||
46
frontend/pnpm-lock.yaml
generated
46
frontend/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
29
frontend/tests/e2e/misc/menu.spec.ts
Normal file
29
frontend/tests/e2e/misc/menu.spec.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
70
frontend/tests/e2e/project/filter-persistence.spec.ts
Normal file
70
frontend/tests/e2e/project/filter-persistence.spec.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
53
frontend/tests/e2e/project/prepareProjects.ts
Normal file
53
frontend/tests/e2e/project/prepareProjects.ts
Normal 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
|
||||
}
|
||||
47
frontend/tests/e2e/project/project-history.spec.ts
Normal file
47
frontend/tests/e2e/project/project-history.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
135
frontend/tests/e2e/project/project-view-gantt.spec.ts
Normal file
135
frontend/tests/e2e/project/project-view-gantt.spec.ts
Normal 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}`))
|
||||
})
|
||||
})
|
||||
315
frontend/tests/e2e/project/project-view-kanban.spec.ts
Normal file
315
frontend/tests/e2e/project/project-view-kanban.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
167
frontend/tests/e2e/project/project-view-list.spec.ts
Normal file
167
frontend/tests/e2e/project/project-view-list.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
98
frontend/tests/e2e/project/project-view-table.spec.ts
Normal file
98
frontend/tests/e2e/project/project-view-table.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
153
frontend/tests/e2e/project/project.spec.ts
Normal file
153
frontend/tests/e2e/project/project.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
55
frontend/tests/e2e/sharing/linkShare.spec.ts
Normal file
55
frontend/tests/e2e/sharing/linkShare.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
103
frontend/tests/e2e/sharing/team.spec.ts
Normal file
103
frontend/tests/e2e/sharing/team.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
32
frontend/tests/e2e/task/comment-pagination.spec.ts
Normal file
32
frontend/tests/e2e/task/comment-pagination.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
175
frontend/tests/e2e/task/overview.spec.ts
Normal file
175
frontend/tests/e2e/task/overview.spec.ts
Normal 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:')
|
||||
})
|
||||
})
|
||||
73
frontend/tests/e2e/task/subtask-duplicates.spec.ts
Normal file
73
frontend/tests/e2e/task/subtask-duplicates.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
1041
frontend/tests/e2e/task/task.spec.ts
Normal file
1041
frontend/tests/e2e/task/task.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
168
frontend/tests/e2e/user/email-confirmation.spec.ts
Normal file
168
frontend/tests/e2e/user/email-confirmation.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
87
frontend/tests/e2e/user/login.spec.ts
Normal file
87
frontend/tests/e2e/user/login.spec.ts
Normal 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`))
|
||||
})
|
||||
})
|
||||
69
frontend/tests/e2e/user/logout.spec.ts
Normal file
69
frontend/tests/e2e/user/logout.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
21
frontend/tests/e2e/user/openid-login.spec.ts
Normal file
21
frontend/tests/e2e/user/openid-login.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
59
frontend/tests/e2e/user/password-reset.spec.ts
Normal file
59
frontend/tests/e2e/user/password-reset.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
47
frontend/tests/e2e/user/registration.spec.ts
Normal file
47
frontend/tests/e2e/user/registration.spec.ts
Normal 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.')
|
||||
})
|
||||
})
|
||||
63
frontend/tests/e2e/user/settings.spec.ts
Normal file
63
frontend/tests/e2e/user/settings.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
19
frontend/tests/factories/bucket.ts
Normal file
19
frontend/tests/factories/bucket.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
16
frontend/tests/factories/label_task.ts
Normal file
16
frontend/tests/factories/label_task.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
21
frontend/tests/factories/labels.ts
Normal file
21
frontend/tests/factories/labels.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
21
frontend/tests/factories/link_sharing.ts
Normal file
21
frontend/tests/factories/link_sharing.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
26
frontend/tests/factories/project.ts
Normal file
26
frontend/tests/factories/project.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
19
frontend/tests/factories/project_view.ts
Normal file
19
frontend/tests/factories/project_view.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
21
frontend/tests/factories/task.ts
Normal file
21
frontend/tests/factories/task.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
16
frontend/tests/factories/task_assignee.ts
Normal file
16
frontend/tests/factories/task_assignee.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
16
frontend/tests/factories/task_attachments.ts
Normal file
16
frontend/tests/factories/task_attachments.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
13
frontend/tests/factories/task_buckets.ts
Normal file
13
frontend/tests/factories/task_buckets.ts
Normal 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}',
|
||||
}
|
||||
}
|
||||
}
|
||||
20
frontend/tests/factories/task_comment.ts
Normal file
20
frontend/tests/factories/task_comment.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
18
frontend/tests/factories/task_relation.ts
Normal file
18
frontend/tests/factories/task_relation.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
18
frontend/tests/factories/task_reminders.ts
Normal file
18
frontend/tests/factories/task_reminders.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
17
frontend/tests/factories/team.ts
Normal file
17
frontend/tests/factories/team.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
14
frontend/tests/factories/team_member.ts
Normal file
14
frontend/tests/factories/team_member.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
29
frontend/tests/factories/token.ts
Normal file
29
frontend/tests/factories/token.ts
Normal 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 ?? {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
34
frontend/tests/factories/user.ts
Normal file
34
frontend/tests/factories/user.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
18
frontend/tests/factories/users_project.ts
Normal file
18
frontend/tests/factories/users_project.ts
Normal 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
BIN
frontend/tests/fixtures/image.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 872 KiB |
49
frontend/tests/support/authenticateUser.ts
Normal file
49
frontend/tests/support/authenticateUser.ts
Normal 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
|
||||
}
|
||||
53
frontend/tests/support/commands.ts
Normal file
53
frontend/tests/support/commands.ts
Normal 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)
|
||||
}
|
||||
15
frontend/tests/support/constants.ts
Normal file
15
frontend/tests/support/constants.ts
Normal 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.'
|
||||
|
||||
98
frontend/tests/support/factory.ts
Normal file
98
frontend/tests/support/factory.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
119
frontend/tests/support/filterTestHelpers.ts
Normal file
119
frontend/tests/support/filterTestHelpers.ts
Normal 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 }
|
||||
}
|
||||
32
frontend/tests/support/fixtures.ts
Normal file
32
frontend/tests/support/fixtures.ts
Normal 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'
|
||||
27
frontend/tests/support/seed.ts
Normal file
27
frontend/tests/support/seed.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
23
frontend/tests/support/updateUserSettings.ts
Normal file
23
frontend/tests/support/updateUserSettings.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user