diff --git a/.github/actions/setup-frontend/action.yml b/.github/actions/setup-frontend/action.yml index 95341df50..8893dcc26 100644 --- a/.github/actions/setup-frontend/action.yml +++ b/.github/actions/setup-frontend/action.yml @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d59aecfeb..3578370ef 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 8a851f44e..adbfe9334 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -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: diff --git a/devenv.nix b/devenv.nix index 54452e281..b5bfd2cc2 100644 --- a/devenv.nix +++ b/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; diff --git a/frontend/.gitignore b/frontend/.gitignore index 8ebaa362f..d4d441475 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -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 diff --git a/frontend/cypress/e2e/misc/menu.spec.ts b/frontend/cypress/e2e/misc/menu.spec.ts deleted file mode 100644 index cb37fc7f0..000000000 --- a/frontend/cypress/e2e/misc/menu.spec.ts +++ /dev/null @@ -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') - }) -}) diff --git a/frontend/cypress/e2e/project/filter-persistence.spec.ts b/frontend/cypress/e2e/project/filter-persistence.spec.ts deleted file mode 100644 index 064ee105d..000000000 --- a/frontend/cypress/e2e/project/filter-persistence.spec.ts +++ /dev/null @@ -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') - }) -}) \ No newline at end of file diff --git a/frontend/cypress/e2e/project/project-history.spec.ts b/frontend/cypress/e2e/project/project-history.spec.ts deleted file mode 100644 index a42f0d7fb..000000000 --- a/frontend/cypress/e2e/project/project-history.spec.ts +++ /dev/null @@ -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) - }) -}) diff --git a/frontend/cypress/e2e/project/project-view-gantt.spec.ts b/frontend/cypress/e2e/project/project-view-gantt.spec.ts deleted file mode 100644 index 0009562b0..000000000 --- a/frontend/cypress/e2e/project/project-view-gantt.spec.ts +++ /dev/null @@ -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}`) - }) -}) diff --git a/frontend/cypress/e2e/project/project-view-kanban.spec.ts b/frontend/cypress/e2e/project/project-view-kanban.spec.ts deleted file mode 100644 index 1cb1723e5..000000000 --- a/frontend/cypress/e2e/project/project-view-kanban.spec.ts +++ /dev/null @@ -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: '

', - }) - - 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) - }) -}) diff --git a/frontend/cypress/e2e/project/project-view-list.spec.ts b/frontend/cypress/e2e/project/project-view-list.spec.ts index a16983815..b400a0020 100644 --- a/frontend/cypress/e2e/project/project-view-list.spec.ts +++ b/frontend/cypress/e2e/project/project-view-list.spec.ts @@ -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() diff --git a/frontend/cypress/e2e/project/project-view-table.spec.ts b/frontend/cypress/e2e/project/project-view-table.spec.ts deleted file mode 100644 index 2f56f9b7b..000000000 --- a/frontend/cypress/e2e/project/project-view-table.spec.ts +++ /dev/null @@ -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) - }) -}) diff --git a/frontend/cypress/e2e/project/project.spec.ts b/frontend/cypress/e2e/project/project.spec.ts deleted file mode 100644 index 66fdcf589..000000000 --- a/frontend/cypress/e2e/project/project.spec.ts +++ /dev/null @@ -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') - }) -}) diff --git a/frontend/cypress/e2e/sharing/linkShare.spec.ts b/frontend/cypress/e2e/sharing/linkShare.spec.ts deleted file mode 100644 index 0792948f7..000000000 --- a/frontend/cypress/e2e/sharing/linkShare.spec.ts +++ /dev/null @@ -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) - }) -}) diff --git a/frontend/cypress/e2e/sharing/team.spec.ts b/frontend/cypress/e2e/sharing/team.spec.ts deleted file mode 100644 index f6284fca9..000000000 --- a/frontend/cypress/e2e/sharing/team.spec.ts +++ /dev/null @@ -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') - }) -}) diff --git a/frontend/cypress/e2e/task/comment-pagination.spec.ts b/frontend/cypress/e2e/task/comment-pagination.spec.ts deleted file mode 100644 index 42901ee40..000000000 --- a/frontend/cypress/e2e/task/comment-pagination.spec.ts +++ /dev/null @@ -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') - }) - }) -}) diff --git a/frontend/cypress/e2e/task/overview.spec.ts b/frontend/cypress/e2e/task/overview.spec.ts index ab10dc1b0..1d0d88d80 100644 --- a/frontend/cypress/e2e/task/overview.spec.ts +++ b/frontend/cypress/e2e/task/overview.spec.ts @@ -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:') - }) }) diff --git a/frontend/cypress/e2e/task/subtask-duplicates.spec.ts b/frontend/cypress/e2e/task/subtask-duplicates.spec.ts deleted file mode 100644 index 9d40c804c..000000000 --- a/frontend/cypress/e2e/task/subtask-duplicates.spec.ts +++ /dev/null @@ -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) - }) -}) diff --git a/frontend/cypress/e2e/task/task.spec.ts b/frontend/cypress/e2e/task/task.spec.ts deleted file mode 100644 index ac778f1ed..000000000 --- a/frontend/cypress/e2e/task/task.spec.ts +++ /dev/null @@ -1,1167 +0,0 @@ -import {createFakeUserAndLogin} from '../../support/authenticateUser' - -import dayjs from 'dayjs' -import relativeTime from 'dayjs/plugin/relativeTime' - -dayjs.extend(relativeTime) - -import {TaskFactory} from '../../factories/task' -import {ProjectFactory} from '../../factories/project' -import {TaskCommentFactory} from '../../factories/task_comment' -import {UserFactory} from '../../factories/user' -import {UserProjectFactory} from '../../factories/users_project' -import {TaskAssigneeFactory} from '../../factories/task_assignee' -import {LabelFactory} from '../../factories/labels' -import {LabelTaskFactory} from '../../factories/label_task' -import {BucketFactory} from '../../factories/bucket' - -import {TaskAttachmentFactory} from '../../factories/task_attachments' -import {TaskReminderFactory} from '../../factories/task_reminders' -import {createDefaultViews} from '../project/prepareProjects' -import {TaskBucketFactory} from '../../factories/task_buckets' - -// Type definitions to fix linting errors -interface Project { - id: number; - title: string; - identifier?: string; -} - -interface Task { - id: number; - title: string; - description: string; - project_id: number; - index: number; -} - -interface User { - id: number; - username: string; -} - -interface Label { - id: number; - title: string; -} - -interface Bucket { - id: number; -} - -function addLabelToTaskAndVerify(labelTitle: string) { - cy.get('.task-view .action-buttons .button') - .contains('Add Labels') - .click() - cy.get('.task-view .details.labels-list .multiselect input') - .type(labelTitle) - cy.get('.task-view .details.labels-list .multiselect .search-results') - .children() - .first() - .click() - - cy.get('.global-notification', {timeout: 4000}) - .should('contain', 'Success') - cy.get('.task-view .details.labels-list .multiselect .input-wrapper span.tag') - .should('exist') - .should('contain', labelTitle) -} - -function uploadAttachmentAndVerify(taskId: number) { - cy.intercept(`**/tasks/${taskId}/attachments`).as('uploadAttachment') - cy.get('.task-view .action-buttons .button') - .contains('Add Attachments') - .click() - cy.get('input[type=file]#files', {timeout: 1000}) - .selectFile('cypress/fixtures/image.jpg', {force: true}) // The input is not visible, but on purpose - cy.wait('@uploadAttachment') - - cy.get('.attachments .attachments .files button.attachment') - .should('exist') -} - -describe('Task', () => { - createFakeUserAndLogin() - - let projects: Project[] - let buckets: Bucket[] - - beforeEach(() => { - // UserFactory.create(1) - projects = ProjectFactory.create(1) as Project[] - const views = createDefaultViews(projects[0].id) - buckets = BucketFactory.create(1, { - project_view_id: views[3].id, - }) as Bucket[] - TaskFactory.truncate() - UserProjectFactory.truncate() - }) - - it('Should be created new', () => { - cy.visit('/projects/1/1') - cy.get('.input[placeholder="Add a task…"]') - .type('New Task') - cy.get('.button') - .contains('Add') - .click() - cy.get('.tasks .task .tasktext') - .first() - .should('contain', 'New Task') - }) - - it('Inserts new tasks at the top of the project', () => { - TaskFactory.create(1) - - cy.visit('/projects/1/1') - cy.get('.project-is-empty-notice') - .should('not.exist') - cy.get('.input[placeholder="Add a task…"]') - .type('New Task') - cy.get('.button') - .contains('Add') - .click() - - cy.wait(1000) // Wait for the request - cy.get('.tasks .task .tasktext') - .first() - .should('contain', 'New Task') - }) - - it('Marks a task as done', () => { - TaskFactory.create(1) - - cy.visit('/projects/1/1') - cy.get('.tasks .task .fancy-checkbox') - .first() - .click() - cy.get('.global-notification') - .should('contain', 'Success') - }) - - it('Can add a task to favorites', () => { - TaskFactory.create(1) - - cy.visit('/projects/1/1') - cy.get('.tasks .task .favorite') - .first() - .click() - cy.get('.menu-container') - .should('contain', 'Favorites') - }) - - it('Should show a task description icon if the task has a description', () => { - cy.intercept('**/projects/1/views/*/tasks**').as('loadTasks') - TaskFactory.create(1, { - description: 'Lorem Ipsum', - }) - - cy.visit('/projects/1/1') - cy.wait('@loadTasks') - - cy.get('.tasks .task .project-task-icon .fa-align-left') - .should('exist') - }) - - it('Should not show a task description icon if the task has an empty description', () => { - cy.intercept('**/projects/1/views/*/tasks**').as('loadTasks') - TaskFactory.create(1, { - description: '', - }) - - cy.visit('/projects/1/1') - cy.wait('@loadTasks') - - cy.get('.tasks .task .project-task-icon .fa-align-left') - .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('**/projects/1/views/*/tasks**').as('loadTasks') - TaskFactory.create(1, { - description: '

', - }) - - cy.visit('/projects/1/1') - cy.wait('@loadTasks') - - cy.get('.tasks .task .project-task-icon .fa-align-left') - .should('not.exist') - }) - - describe('Task Detail View', () => { - beforeEach(() => { - TaskCommentFactory.truncate() - LabelTaskFactory.truncate() - TaskAttachmentFactory.truncate() - }) - - it('provides back navigation to the project in the list view', () => { - const tasks = TaskFactory.create(1) - cy.intercept('**/projects/1/views/*/tasks**').as('loadTasks') - cy.visit('/projects/1/1') - cy.wait('@loadTasks') - cy.get('.list-view .task') - .first() - .find('a.task-link') - .click() - cy.get('.task-view .back-button') - .should('be.visible') - .click() - cy.location('pathname').should('match', /\/projects\/1\/\d+/) - }) - - it('provides back navigation to the project in the table view', () => { - const tasks = TaskFactory.create(1) - cy.intercept('**/projects/1/views/*/tasks**').as('loadTasks') - cy.visit('/projects/1/3') - cy.wait('@loadTasks') - cy.get('tbody tr') - .first() - .find('a') - .first() - .click() - cy.get('.task-view .back-button') - .should('be.visible') - .click() - cy.location('pathname').should('match', /\/projects\/1\/\d+/) - }) - - it('provides back navigation to the project in the kanban view on mobile', () => { - cy.viewport('iphone-8') - - const tasks = TaskFactory.create(1) - cy.intercept('**/projects/1/views/*/tasks**').as('loadTasks') - cy.visit('/projects/1/4') - cy.wait('@loadTasks') - cy.get('.kanban-view .tasks .task') - .first() - .click() - cy.get('.task-view .back-button') - .should('be.visible') - .click() - cy.location('pathname').should('match', /\/projects\/1\/\d+/) - }) - - it('does not provide back navigation to the project in the kanban view on desktop', () => { - cy.viewport('macbook-15') - - const tasks = TaskFactory.create(1) - cy.intercept('**/projects/1/views/*/tasks**').as('loadTasks') - cy.visit('/projects/1/4') - cy.wait('@loadTasks') - cy.get('.kanban-view .tasks .task') - .first() - .click() - cy.get('.task-view .back-button') - .should('not.exist') - }) - - it('Shows a 404 page for nonexisting tasks', () => { - - cy.visit('/tasks/9999') - - cy.contains('Not found') - .should('be.visible') - }) - - it('Shows all task details', () => { - const tasks = TaskFactory.create(1, { - id: 1, - index: 1, - description: 'Lorem ipsum dolor sit amet.', - }) - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view h1.title.input') - .should('contain', tasks[0].title) - cy.get('.task-view h1.title.task-id') - .should('contain', '#1') - cy.get('.task-view h6.subtitle') - .should('contain', projects[0].title) - cy.get('.task-view .details.content.description') - .should('contain', tasks[0].description) - cy.get('.task-view .action-buttons p.created') - .should('contain', 'Created') - }) - - it('Shows a done label for done tasks', () => { - const tasks = TaskFactory.create(1, { - id: 1, - index: 1, - done: true, - done_at: new Date().toISOString(), - }) - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view .heading .is-done') - .should('be.visible') - .should('contain', 'Done') - cy.get('.task-view .action-buttons p.created') - .scrollIntoView() - .should('be.visible') - .should('contain', 'Done') - }) - - it('Can mark a task as done', () => { - const tasks = TaskFactory.create(1, { - id: 1, - done: false, - }) - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view .action-buttons .button') - .contains('Mark task done!') - .click() - - cy.get('.task-view .heading .is-done') - .should('exist') - .should('contain', 'Done') - cy.get('.global-notification') - .should('contain', 'Success') - cy.get('.task-view .action-buttons .button') - .should('contain', 'Mark as undone') - }) - - it('Shows a task identifier since the project has one', () => { - const projects = ProjectFactory.create(1, { - id: 1, - identifier: 'TEST', - }) - const tasks = TaskFactory.create(1, { - id: 1, - project_id: projects[0].id, - index: 1, - }) - - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view h1.title.task-id') - .should('contain', `${projects[0].identifier}-${tasks[0].index}`) - }) - - it('Can edit the description', () => { - const tasks = TaskFactory.create(1, { - id: 1, - description: 'Lorem ipsum dolor sit amet.', - }) - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view .details.content.description .tiptap button.done-edit') - .click() - cy.get('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror') - .type('{selectall}New Description') - cy.get('[data-cy="saveEditor"]') - .contains('Save') - .click() - - cy.get('.task-view .details.content.description h3 span.is-small.has-text-success') - .contains('Saved!') - .should('exist') - }) - - it('autosaves the description when leaving the task view', () => { - TaskFactory.create(1, { - id: 1, - project_id: projects[0].id, - description: 'Old Description', - }) - - cy.visit('/tasks/1') - - cy.get('.task-view .details.content.description .tiptap button.done-edit', {timeout: 30_000}) - .click() - cy.get('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror') - .type('{selectall}New Description') - - cy.get('.task-view h6.subtitle a') - .first() - .click() - - cy.visit('/tasks/1') - cy.get('.task-view .details.content.description') - .should('contain.text', 'New Description') - }) - - it('Shows an empty editor when the description of a task is empty', () => { - const tasks = TaskFactory.create(1, { - id: 1, - description: '', - }) - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view .details.content.description .tiptap.ProseMirror p') - .should('have.attr', 'data-placeholder') - cy.get('.task-view .details.content.description .tiptap button.done-edit') - .should('not.exist') - }) - - it('Shows a preview editor when the description of a task is not empty', () => { - const tasks = TaskFactory.create(1, { - id: 1, - description: 'Lorem Ipsum dolor sit amet', - }) - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view .details.content.description .tiptap.ProseMirror p') - .should('not.have.attr', 'data-placeholder') - cy.get('.task-view .details.content.description .tiptap button.done-edit') - .should('exist') - }) - - it('Shows a preview editor when the description of a task contains html', () => { - const tasks = TaskFactory.create(1, { - id: 1, - description: '

Lorem Ipsum dolor sit amet

', - }) - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view .details.content.description .tiptap.ProseMirror p') - .should('not.have.attr', 'data-placeholder') - cy.get('.task-view .details.content.description .tiptap button.done-edit') - .should('exist') - }) - - it('Can add a new comment', () => { - const tasks = TaskFactory.create(1, { - id: 1, - }) - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view .comments .media.comment .tiptap__editor .tiptap.ProseMirror') - .should('be.visible') - .type('{selectall}New Comment') - cy.get('.task-view .comments .media.comment .button:not([disabled])') - .contains('Comment') - .should('be.visible') - .click() - - cy.get('.task-view .comments .media.comment .tiptap__editor') - .should('contain', 'New Comment') - cy.get('.global-notification') - .should('contain', 'Success') - }) - - it('Can move a task to another project', () => { - const projects = ProjectFactory.create(2) - const views = createDefaultViews(projects[0].id) - BucketFactory.create(2, { - project_view_id: views[3].id, - }) - const tasks = TaskFactory.create(1, { - id: 1, - project_id: projects[0].id, - }) - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view .action-buttons .button') - .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('.task-view h6.subtitle') - .should('contain', projects[1].title) - cy.get('.global-notification') - .should('contain', 'Success') - }) - - it('Can delete a task', () => { - const tasks = TaskFactory.create(1, { - id: 1, - project_id: 1, - }) - cy.visit(`/tasks/${tasks[0].id}`) - - 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.url() - .should('contain', `/projects/${tasks[0].project_id}/`) - }) - - it('Can add an assignee to a task', () => { - const users = UserFactory.create(5) - const tasks = TaskFactory.create(1, { - id: 1, - project_id: 1, - }) - UserProjectFactory.create(5, { - project_id: 1, - user_id: '{increment}', - }) - - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('[data-cy="taskDetail.assign"]') - .click() - cy.get('.task-view .column.assignees .multiselect input') - .type(users[1].username) - cy.get('.task-view .column.assignees .multiselect .search-results') - .should('be.visible') - .children() - .first() - .click() - - cy.get('.global-notification') - .should('contain', 'Success') - cy.get('.task-view .column.assignees .multiselect .input-wrapper span.assignee') - .should('exist') - }) - - it('Can remove an assignee from a task', () => { - const users = UserFactory.create(2) - const tasks = TaskFactory.create(1, { - id: 1, - project_id: 1, - }) - UserProjectFactory.create(5, { - project_id: 1, - user_id: '{increment}', - }) - TaskAssigneeFactory.create(1, { - task_id: tasks[0].id, - user_id: users[1].id, - }) - - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view .column.assignees .multiselect .input-wrapper span.assignee') - .get('.remove-assignee') - .click() - - cy.get('.global-notification') - .should('contain', 'Success') - cy.get('.task-view .column.assignees .multiselect .input-wrapper span.assignee') - .should('not.exist') - }) - - it('Can add a new label to a task', () => { - const tasks = TaskFactory.create(1, { - id: 1, - project_id: 1, - }) - LabelFactory.truncate() - const newLabelText = 'some new label' - - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view .action-buttons .button') - .contains('Add Labels') - .should('be.visible') - .click() - cy.get('.task-view .details.labels-list .multiselect input') - .type(newLabelText) - cy.get('.task-view .details.labels-list .multiselect .search-results') - .children() - .first() - .click() - - cy.get('.global-notification') - .should('contain', 'Success') - cy.get('.task-view .details.labels-list .multiselect .input-wrapper span.tag') - .should('exist') - .should('contain', newLabelText) - }) - - it('Can add an existing label to a task', () => { - const tasks = TaskFactory.create(1, { - id: 1, - project_id: 1, - }) - const labels = LabelFactory.create(1) - LabelTaskFactory.truncate() - - cy.visit(`/tasks/${tasks[0].id}`) - - addLabelToTaskAndVerify(labels[0].title) - }) - - it('Can add a label to a task and it shows up on the kanban board afterwards', () => { - const tasks = TaskFactory.create(1, { - id: 1, - project_id: projects[0].id, - }) - const labels = LabelFactory.create(1) - LabelTaskFactory.truncate() - TaskBucketFactory.create(1, { - task_id: tasks[0].id, - bucket_id: buckets[0].id, - project_view_id: buckets[0].project_view_id, - }) - - cy.visit(`/projects/${projects[0].id}/4`) - - cy.get('.bucket .task') - .contains(tasks[0].title) - .click() - - addLabelToTaskAndVerify(labels[0].title) - - cy.get('.modal-container > .close') - .click() - - cy.get('.bucket .task') - .should('contain.text', labels[0].title) - }) - - it('Can remove a label from a task', () => { - const tasks = TaskFactory.create(1, { - id: 1, - project_id: 1, - }) - const labels = LabelFactory.create(1) - LabelTaskFactory.create(1, { - task_id: tasks[0].id, - label_id: labels[0].id, - }) - - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view .details.labels-list .multiselect .input-wrapper') - .should('be.visible') - .should('contain', labels[0].title) - cy.get('.task-view .details.labels-list .multiselect .input-wrapper') - .children() - .first() - .get('[data-cy="taskDetail.removeLabel"]') - .click() - - cy.get('.global-notification') - .should('contain', 'Success') - cy.get('.task-view .details.labels-list .multiselect .input-wrapper') - .should('not.contain', labels[0].title) - }) - - it('Can set a due date for a task', () => { - const tasks = TaskFactory.create(1, { - id: 1, - done: false, - }) - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view .action-buttons .button') - .contains('Set Due Date') - .click() - cy.get('.task-view .columns.details .column') - .contains('Due Date') - .get('.date-input .datepicker .show') - .click() - cy.get('.datepicker .datepicker-popup button') - .contains('Tomorrow') - .click() - cy.get('[data-cy="closeDatepicker"]') - .contains('Confirm') - .click() - - cy.get('.task-view .columns.details .column') - .contains('Due Date') - .get('.date-input .datepicker-popup') - .should('not.exist') - cy.get('.global-notification') - .should('contain', 'Success') - }) - - it('Can set a due date to a specific date for a task', () => { - const tasks = TaskFactory.create(1, { - id: 1, - done: false, - }) - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view .action-buttons .button') - .contains('Set Due Date') - .click() - cy.get('.task-view .columns.details .column') - .contains('Due Date') - .get('.date-input .datepicker .show') - .click() - cy.get('.datepicker-popup .flatpickr-innerContainer .flatpickr-days .flatpickr-day.today') - .click() - cy.get('[data-cy="closeDatepicker"]') - .contains('Confirm') - .click() - - const today = new Date() - today.setHours(12) - today.setMinutes(0) - today.setSeconds(0) - cy.get('.task-view .columns.details .column') - .contains('Due Date') - .get('.date-input .datepicker-popup') - .should('not.exist') - cy.get('.task-view .columns.details .column') - .contains('Due Date') - .get('.date-input') - .should('contain.text', dayjs(today).fromNow()) - cy.get('.global-notification') - .should('contain', 'Success') - }) - - it('Can change a due date to a specific date for a task', () => { - const dueDate = new Date(2025, 2, 20) - dueDate.setHours(12) - dueDate.setMinutes(0) - dueDate.setSeconds(0) - dueDate.setDate(1) - const tasks = TaskFactory.create(1, { - id: 1, - done: false, - due_date: dueDate.toISOString(), - }) - - const today = new Date(2025, 2, 5) - today.setHours(12) - today.setMinutes(0) - today.setSeconds(0) - - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view .action-buttons .button') - .contains('Set Due Date') - .click() - cy.get('.task-view .columns.details .column') - .contains('Due Date') - .get('.date-input .datepicker .show') - .click() - cy.get(`.datepicker-popup .flatpickr-innerContainer .flatpickr-days [aria-label="${today.toLocaleString('en-US', {month: 'long'})} ${today.getDate()}, ${today.getFullYear()}"]`) - .click() - cy.get('[data-cy="closeDatepicker"]') - .contains('Confirm') - .click() - - cy.get('.task-view .columns.details .column') - .contains('Due Date') - .get('.date-input .datepicker-popup') - .should('not.exist') - cy.get('.task-view .columns.details .column') - .contains('Due Date') - .get('.date-input') - .should('contain.text', dayjs(today).fromNow()) - cy.get('.global-notification') - .should('contain', 'Success') - }) - - it('Can paste an image into the description editor which uploads it as an attachment', () => { - TaskAttachmentFactory.truncate() - const tasks = TaskFactory.create(1, { - id: 1, - }) as Task[] - cy.visit(`/tasks/${tasks[0].id}`) - - cy.intercept('**/tasks/*/attachments').as('uploadAttachment') - - cy.get('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror', {timeout: 30_000}) - .pasteFile('image.jpg', 'image/jpeg') - - cy.wait('@uploadAttachment') - cy.get('.attachments .attachments .files button.attachment') - .should('exist') - cy.get('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror img') - .should('be.visible') - .and(($img) => { - // "naturalWidth" and "naturalHeight" are set when the image loads - expect($img[0].naturalWidth).to.be.greaterThan(0) - }) - }) - - it('Can set a reminder', () => { - TaskReminderFactory.truncate() - const tasks = TaskFactory.create(1, { - id: 1, - done: false, - }) - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view .action-buttons .button') - .contains('Set Reminders') - .click() - cy.get('.task-view .columns.details .column button') - .contains('Add a reminder') - .click() - cy.get('.datepicker__quick-select-date') - .contains('Tomorrow') - .click() - - cy.get('.reminder-options-popup') - .should('not.be.visible') - cy.get('.global-notification') - .should('contain', 'Success') - }) - - it('Allows to set a relative reminder when the task already has a due date', () => { - TaskReminderFactory.truncate() - const tasks = TaskFactory.create(1, { - id: 1, - done: false, - due_date: (new Date()).toISOString(), - }) - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view .action-buttons .button') - .contains('Set Reminders') - .click() - cy.get('.task-view .columns.details .column button') - .contains('Add a reminder') - .click() - cy.get('.datepicker__quick-select-date') - .should('not.exist') - cy.get('.reminder-options-popup .card-content') - .should('contain', '1 day before Due Date') - cy.get('.reminder-options-popup .card-content') - .contains('1 day before Due Date') - .click() - - cy.get('.reminder-options-popup') - .should('not.be.visible') - cy.get('.global-notification') - .should('contain', 'Success') - }) - - it('Allows to set a relative reminder when the task already has a start date', () => { - TaskReminderFactory.truncate() - const tasks = TaskFactory.create(1, { - id: 1, - done: false, - start_date: (new Date()).toISOString(), - }) - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view .action-buttons .button') - .contains('Set Reminders') - .click() - cy.get('.task-view .columns.details .column button') - .contains('Add a reminder') - .click() - cy.get('.datepicker__quick-select-date') - .should('not.exist') - cy.get('.reminder-options-popup .card-content') - .should('contain', '1 day before Start Date') - cy.get('.reminder-options-popup .card-content') - .contains('1 day before Start Date') - .click() - - cy.get('.reminder-options-popup') - .should('not.be.visible') - cy.get('.global-notification') - .should('contain', 'Success') - }) - - it('Allows to set a custom relative reminder when the task already has a due date', () => { - TaskReminderFactory.truncate() - const tasks = TaskFactory.create(1, { - id: 1, - done: false, - due_date: (new Date()).toISOString(), - }) - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view .action-buttons .button') - .contains('Set Reminders') - .click() - cy.get('.task-view .columns.details .column button') - .contains('Add a reminder') - .click() - cy.get('.datepicker__quick-select-date') - .should('not.exist') - cy.get('.reminder-options-popup .card-content') - .contains('Custom') - .click() - cy.get('.reminder-options-popup .card-content .reminder-period input') - .first() - .type('{selectall}10') - cy.get('.reminder-options-popup .card-content .reminder-period select') - .first() - .select('days') - cy.get('.reminder-options-popup .card-content button') - .contains('Confirm') - .click() - - cy.get('.reminder-options-popup') - .should('not.be.visible') - cy.get('.global-notification') - .should('contain', 'Success') - }) - - it('Allows to set a fixed reminder when the task already has a due date', () => { - TaskReminderFactory.truncate() - const tasks = TaskFactory.create(1, { - id: 1, - done: false, - due_date: (new Date()).toISOString(), - }) - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view .action-buttons .button') - .contains('Set Reminders') - .click() - cy.get('.task-view .columns.details .column button') - .contains('Add a reminder') - .click() - cy.get('.datepicker__quick-select-date') - .should('not.exist') - cy.get('.reminder-options-popup .card-content') - .contains('Date and time') - .click() - cy.get('.datepicker__quick-select-date') - .contains('Tomorrow') - .click() - - cy.get('.reminder-options-popup') - .should('not.be.visible') - cy.get('.global-notification') - .should('contain', 'Success') - }) - - it('Can set a priority for a task', () => { - const tasks = TaskFactory.create(1, { - id: 1, - }) - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view .action-buttons .button') - .contains('Set Priority') - .click() - cy.get('.task-view .columns.details .column') - .contains('Priority') - .get('.select select') - .select('Urgent') - cy.get('.global-notification') - .should('contain', 'Success') - - cy.get('.task-view .columns.details .column') - .contains('Priority') - .get('.select select') - .should('have.value', '4') - }) - - it('Can set the progress for a task', () => { - const tasks = TaskFactory.create(1, { - id: 1, - }) - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view .action-buttons .button') - .contains('Set Progress') - .click() - cy.get('.task-view .columns.details .column') - .contains('Progress') - .get('.select select') - .select('50%') - cy.get('.global-notification') - .should('contain', 'Success') - - cy.wait(200) - - cy.get('.task-view .columns.details .column') - .contains('Progress') - .get('.select select') - .should('be.visible') - .should('have.value', '0.5') - }) - - it('Can add an attachment to a task', () => { - TaskAttachmentFactory.truncate() - const tasks = TaskFactory.create(1, { - id: 1, - }) - cy.visit(`/tasks/${tasks[0].id}`) - - uploadAttachmentAndVerify(tasks[0].id) - }) - - it('Can add an attachment to a task and see it appearing on kanban', () => { - TaskAttachmentFactory.truncate() - const tasks = TaskFactory.create(1, { - id: 1, - project_id: projects[0].id, - }) - const labels = LabelFactory.create(1) - LabelTaskFactory.truncate() - TaskBucketFactory.create(1, { - task_id: tasks[0].id, - bucket_id: buckets[0].id, - project_view_id: buckets[0].project_view_id, - }) - - cy.visit(`/projects/${projects[0].id}/4`) - - cy.get('.bucket .task') - .contains(tasks[0].title) - .click() - - uploadAttachmentAndVerify(tasks[0].id) - - cy.get('.modal-container > .close') - .click() - - cy.get('.bucket .task .footer .icon svg.fa-paperclip') - .should('exist') - }) - - it('Can check items off a checklist', () => { - const tasks = TaskFactory.create(1, { - id: 1, - description: ` -`, - }) - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view .checklist-summary') - .should('contain.text', '1 of 5 tasks') - cy.get('.tiptap__editor ul > li input[type=checkbox]') - .eq(2) - .click() - - cy.get('.task-view .details.content.description h3 span.is-small.has-text-success') - .contains('Saved!') - .should('exist') - cy.get('.tiptap__editor ul > li input[type=checkbox]') - .eq(2) - .should('be.checked') - cy.get('.tiptap__editor input[type=checkbox]') - .should('have.length', 5) - cy.get('.task-view .checklist-summary') - .should('contain.text', '2 of 5 tasks') - }) - - it('Persists checked checklist items after reload', () => { - const tasks = TaskFactory.create(1, { - id: 1, - description: ` -`, - }) - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.task-view .checklist-summary') - .should('contain.text', '0 of 2 tasks') - cy.get('.tiptap__editor ul > li input[type=checkbox]') - .first() - .click() - - cy.get('.task-view .details.content.description h3 span.is-small.has-text-success') - .contains('Saved!') - .should('exist') - - cy.get('.task-view .checklist-summary') - .should('contain.text', '1 of 2 tasks') - - cy.reload() - - cy.get('.task-view .checklist-summary') - .should('contain.text', '1 of 2 tasks') - cy.get('.tiptap__editor ul > li input[type=checkbox]') - .first() - .should('be.checked') - }) - - it('Should use the editor to render description', () => { - const tasks = TaskFactory.create(1, { - id: 1, - description: ` -

Lorem Ipsum

-

Dolor sit amet

-`, - }) - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.tiptap__editor ul > li input[type=checkbox]') - .should('exist') - cy.get('.tiptap__editor h1') - .contains('Lorem Ipsum') - .should('exist') - cy.get('.tiptap__editor p') - .contains('Dolor sit amet') - .should('exist') - }) - - it('Should render an image from attachment', async () => { - - TaskAttachmentFactory.truncate() - - const tasks = TaskFactory.create(1, { - id: 1, - description: '', - }) - - cy.readFile('cypress/fixtures/image.jpg', null).then(file => { - - const formData = new FormData() - formData.append('files', new Blob([file]), 'image.jpg') - - cy.request({ - method: 'PUT', - url: `${Cypress.env('API_URL')}/tasks/${tasks[0].id}/attachments`, - headers: { - 'Authorization': `Bearer ${window.localStorage.getItem('token')}`, - 'Content-Type': 'multipart/form-data', - }, - body: formData, - }) - .then(({body}) => { - const dec = new TextDecoder('utf-8') - const {success} = JSON.parse(dec.decode(body)) - - TaskFactory.create(1, { - id: 1, - description: `test image`, - }) - - cy.visit(`/tasks/${tasks[0].id}`) - - cy.get('.tiptap__editor img') - .should('be.visible') - .and(($img) => { - // "naturalWidth" and "naturalHeight" are set when the image loads - expect($img[0].naturalWidth).to.be.greaterThan(0) - }) - - }) - }) - }) - }) -}) diff --git a/frontend/cypress/e2e/user/email-confirmation.spec.ts b/frontend/cypress/e2e/user/email-confirmation.spec.ts deleted file mode 100644 index 22b293a0a..000000000 --- a/frontend/cypress/e2e/user/email-confirmation.spec.ts +++ /dev/null @@ -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) - }) -}) diff --git a/frontend/cypress/e2e/user/login.spec.ts b/frontend/cypress/e2e/user/login.spec.ts index 13df66163..82f102040 100644 --- a/frontend/cypress/e2e/user/login.spec.ts +++ b/frontend/cypress/e2e/user/login.spec.ts @@ -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`) diff --git a/frontend/cypress/e2e/user/logout.spec.ts b/frontend/cypress/e2e/user/logout.spec.ts deleted file mode 100644 index e363ef116..000000000 --- a/frontend/cypress/e2e/user/logout.spec.ts +++ /dev/null @@ -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) - }) - }) -}) diff --git a/frontend/cypress/e2e/user/openid-login.spec.ts b/frontend/cypress/e2e/user/openid-login.spec.ts deleted file mode 100644 index a52d6b51f..000000000 --- a/frontend/cypress/e2e/user/openid-login.spec.ts +++ /dev/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') - }) -}) diff --git a/frontend/cypress/e2e/user/password-reset.spec.ts b/frontend/cypress/e2e/user/password-reset.spec.ts deleted file mode 100644 index eb9a65d6d..000000000 --- a/frontend/cypress/e2e/user/password-reset.spec.ts +++ /dev/null @@ -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') - }) -}) diff --git a/frontend/cypress/e2e/user/registration.spec.ts b/frontend/cypress/e2e/user/registration.spec.ts deleted file mode 100644 index 20ac6518f..000000000 --- a/frontend/cypress/e2e/user/registration.spec.ts +++ /dev/null @@ -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.') - }) -}) diff --git a/frontend/package.json b/frontend/package.json index 39322fd37..d1ace1125 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 000000000..aab890ec7 --- /dev/null +++ b/frontend/playwright.config.ts @@ -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 +}) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 04530edaf..d7341fc3e 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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 diff --git a/frontend/src/views/project/ProjectView.vue b/frontend/src/views/project/ProjectView.vue index a81041807..9a845946e 100644 --- a/frontend/src/views/project/ProjectView.vue +++ b/frontend/src/views/project/ProjectView.vue @@ -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)) diff --git a/frontend/tests/e2e/misc/menu.spec.ts b/frontend/tests/e2e/misc/menu.spec.ts new file mode 100644 index 000000000..226971477 --- /dev/null +++ b/frontend/tests/e2e/misc/menu.spec.ts @@ -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/) + }) +}) diff --git a/frontend/tests/e2e/project/filter-persistence.spec.ts b/frontend/tests/e2e/project/filter-persistence.spec.ts new file mode 100644 index 000000000..b65665c9f --- /dev/null +++ b/frontend/tests/e2e/project/filter-persistence.spec.ts @@ -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/) + }) +}) diff --git a/frontend/tests/e2e/project/prepareProjects.ts b/frontend/tests/e2e/project/prepareProjects.ts new file mode 100644 index 000000000..e6e874ea6 --- /dev/null +++ b/frontend/tests/e2e/project/prepareProjects.ts @@ -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 +} diff --git a/frontend/tests/e2e/project/project-history.spec.ts b/frontend/tests/e2e/project/project-history.spec.ts new file mode 100644 index 000000000..37d2918a3 --- /dev/null +++ b/frontend/tests/e2e/project/project-history.spec.ts @@ -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) + }) +}) diff --git a/frontend/tests/e2e/project/project-view-gantt.spec.ts b/frontend/tests/e2e/project/project-view-gantt.spec.ts new file mode 100644 index 000000000..197861391 --- /dev/null +++ b/frontend/tests/e2e/project/project-view-gantt.spec.ts @@ -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}`)) + }) +}) diff --git a/frontend/tests/e2e/project/project-view-kanban.spec.ts b/frontend/tests/e2e/project/project-view-kanban.spec.ts new file mode 100644 index 000000000..ee291eb71 --- /dev/null +++ b/frontend/tests/e2e/project/project-view-kanban.spec.ts @@ -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: '

', + }) + 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) + }) +}) diff --git a/frontend/tests/e2e/project/project-view-list.spec.ts b/frontend/tests/e2e/project/project-view-list.spec.ts new file mode 100644 index 000000000..4efe639b8 --- /dev/null +++ b/frontend/tests/e2e/project/project-view-list.spec.ts @@ -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() + }) +}) diff --git a/frontend/tests/e2e/project/project-view-table.spec.ts b/frontend/tests/e2e/project/project-view-table.spec.ts new file mode 100644 index 000000000..5791f886a --- /dev/null +++ b/frontend/tests/e2e/project/project-view-table.spec.ts @@ -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) + }) +}) diff --git a/frontend/tests/e2e/project/project.spec.ts b/frontend/tests/e2e/project/project.spec.ts new file mode 100644 index 000000000..05e86bd43 --- /dev/null +++ b/frontend/tests/e2e/project/project.spec.ts @@ -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') + }) +}) diff --git a/frontend/tests/e2e/sharing/linkShare.spec.ts b/frontend/tests/e2e/sharing/linkShare.spec.ts new file mode 100644 index 000000000..1a3c5aa37 --- /dev/null +++ b/frontend/tests/e2e/sharing/linkShare.spec.ts @@ -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) + }) +}) diff --git a/frontend/tests/e2e/sharing/team.spec.ts b/frontend/tests/e2e/sharing/team.spec.ts new file mode 100644 index 000000000..022e6a977 --- /dev/null +++ b/frontend/tests/e2e/sharing/team.spec.ts @@ -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') + }) +}) diff --git a/frontend/tests/e2e/task/comment-pagination.spec.ts b/frontend/tests/e2e/task/comment-pagination.spec.ts new file mode 100644 index 000000000..2cf21f1cc --- /dev/null +++ b/frontend/tests/e2e/task/comment-pagination.spec.ts @@ -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() + }) +}) diff --git a/frontend/cypress/e2e/task/date-display.spec.ts b/frontend/tests/e2e/task/date-display.spec.ts similarity index 70% rename from frontend/cypress/e2e/task/date-display.spec.ts rename to frontend/tests/e2e/task/date-display.spec.ts index c25dd4c92..b8f6f5c68 100644 --- a/frontend/cypress/e2e/task/date-display.spec.ts +++ b/frontend/tests/e2e/task/date-display.spec.ts @@ -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) }) }) }) diff --git a/frontend/tests/e2e/task/overview.spec.ts b/frontend/tests/e2e/task/overview.spec.ts new file mode 100644 index 000000000..4b9ce944a --- /dev/null +++ b/frontend/tests/e2e/task/overview.spec.ts @@ -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:') + }) +}) diff --git a/frontend/tests/e2e/task/subtask-duplicates.spec.ts b/frontend/tests/e2e/task/subtask-duplicates.spec.ts new file mode 100644 index 000000000..3166d4d26 --- /dev/null +++ b/frontend/tests/e2e/task/subtask-duplicates.spec.ts @@ -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) + }) +}) diff --git a/frontend/tests/e2e/task/task.spec.ts b/frontend/tests/e2e/task/task.spec.ts new file mode 100644 index 000000000..e6cc5ae34 --- /dev/null +++ b/frontend/tests/e2e/task/task.spec.ts @@ -0,0 +1,1041 @@ +import {test, expect} from '../../support/fixtures' +import dayjs from 'dayjs' +import relativeTime from 'dayjs/plugin/relativeTime.js' + +dayjs.extend(relativeTime) + +import {TaskFactory} from '../../factories/task' +import {ProjectFactory} from '../../factories/project' +import {TaskCommentFactory} from '../../factories/task_comment' +import {UserFactory} from '../../factories/user' +import {UserProjectFactory} from '../../factories/users_project' +import {TaskAssigneeFactory} from '../../factories/task_assignee' +import {LabelFactory} from '../../factories/labels' +import {LabelTaskFactory} from '../../factories/label_task' +import {BucketFactory} from '../../factories/bucket' +import {TaskAttachmentFactory} from '../../factories/task_attachments' +import {TaskReminderFactory} from '../../factories/task_reminders' +import {createDefaultViews} from '../project/prepareProjects' +import {TaskBucketFactory} from '../../factories/task_buckets' +import {pasteFile} from '../../support/commands' +import type {Page} 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) + +// Type definitions to fix linting errors +interface Project { + id: number; + title: string; + identifier?: string; +} + +interface Task { + id: number; + title: string; + description: string; + project_id: number; + index: number; +} + +interface User { + id: number; + username: string; +} + +interface Label { + id: number; + title: string; +} + +interface Bucket { + id: number; + project_view_id: number; +} + +async function addLabelToTaskAndVerify(page: Page, labelTitle: string) { + await page.locator('.task-view .action-buttons .button').filter({hasText: 'Add Labels'}).click() + await page.locator('.task-view .details.labels-list .multiselect input').fill(labelTitle) + // Wait for search results to appear before clicking + const searchResults = page.locator('.task-view .details.labels-list .multiselect .search-results') + await searchResults.waitFor({state: 'visible'}) + await searchResults.locator('> *').first().click() + + await expect(page.locator('.global-notification')).toContainText('Success', {timeout: 4000}) + await expect(page.locator('.task-view .details.labels-list .multiselect .input-wrapper span.tag')).toBeVisible() + await expect(page.locator('.task-view .details.labels-list .multiselect .input-wrapper span.tag')).toContainText(labelTitle) +} + +async function uploadAttachmentAndVerify(page: Page, taskId: number) { + const uploadAttachmentPromise = page.waitForResponse(response => + response.url().includes(`/tasks/${taskId}/attachments`) && response.request().method() === 'PUT', + ) + await page.locator('.task-view .action-buttons .button').filter({hasText: 'Add Attachments'}).click() + await page.locator('input[type=file]#files').setInputFiles('tests/fixtures/image.jpg') + await uploadAttachmentPromise + + await expect(page.locator('.attachments .attachments .files button.attachment')).toBeVisible() +} + +test.describe('Task', () => { + let projects: Project[] + let buckets: Bucket[] + + test.beforeEach(async ({authenticatedPage: page}) => { + projects = await ProjectFactory.create(1) as Project[] + const views = await createDefaultViews(projects[0].id) + buckets = await BucketFactory.create(1, { + project_view_id: views[3].id, + }) as Bucket[] + await TaskFactory.truncate() + await UserProjectFactory.truncate() + }) + + test('Should be created new', async ({authenticatedPage: page}) => { + await page.goto('/projects/1/1') + await page.locator('.input[placeholder="Add a task…"]').fill('New Task') + await page.locator('.button').filter({hasText: 'Add'}).click() + await expect(page.locator('.tasks .task .tasktext').first()).toContainText('New Task') + }) + + test('Inserts new tasks at the top of the project', async ({authenticatedPage: page}) => { + await TaskFactory.create(1) + + await page.goto('/projects/1/1') + await expect(page.locator('.project-is-empty-notice')).not.toBeVisible() + await page.locator('.input[placeholder="Add a task…"]').fill('New Task') + await page.locator('.button').filter({hasText: 'Add'}).click() + + await page.waitForTimeout(1000) // Wait for the request + await expect(page.locator('.tasks .task .tasktext').first()).toContainText('New Task') + }) + + test('Marks a task as done', async ({authenticatedPage: page}) => { + await TaskFactory.create(1) + + await page.goto('/projects/1/1') + await page.locator('.tasks .task .fancy-checkbox').first().click() + await expect(page.locator('.global-notification')).toContainText('Success') + }) + + test('Can add a task to favorites', async ({authenticatedPage: page}) => { + await TaskFactory.create(1) + + await page.goto('/projects/1/1') + await page.waitForLoadState('networkidle') + + // Wait for tasks to be visible + const favoriteButton = page.locator('.tasks .task .favorite').first() + await expect(favoriteButton).toBeVisible({timeout: 10000}) + + // Wait for the favorite API response + const favoritePromise = page.waitForResponse(response => + response.url().includes('/tasks/') && response.request().method() === 'POST', + ) + await favoriteButton.click() + await favoritePromise + + // The Favorites menu item should appear after a task is favorited + await expect(page.locator('.menu-container')).toContainText('Favorites', {timeout: 10000}) + }) + + test('Should show a task description icon if the task has a description', async ({authenticatedPage: page}) => { + const loadTasksPromise = page.waitForResponse(response => + response.url().includes('/projects/1/views/') && response.url().includes('/tasks'), + ) + await TaskFactory.create(1, { + description: 'Lorem Ipsum', + }) + + await page.goto('/projects/1/1') + await loadTasksPromise + + await expect(page.locator('.tasks .task .project-task-icon .fa-align-left')).toBeVisible() + }) + + test('Should not show a task description icon if the task has an empty description', async ({authenticatedPage: page}) => { + const loadTasksPromise = page.waitForResponse(response => + response.url().includes('/projects/1/views/') && response.url().includes('/tasks'), + ) + await TaskFactory.create(1, { + description: '', + }) + + await page.goto('/projects/1/1') + await loadTasksPromise + + await expect(page.locator('.tasks .task .project-task-icon .fa-align-left')).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 loadTasksPromise = page.waitForResponse(response => + response.url().includes('/projects/1/views/') && response.url().includes('/tasks'), + ) + await TaskFactory.create(1, { + description: '

', + }) + + await page.goto('/projects/1/1') + await loadTasksPromise + + await expect(page.locator('.tasks .task .project-task-icon .fa-align-left')).not.toBeVisible() + }) + + test.describe('Task Detail View', () => { + test.beforeEach(async ({authenticatedPage: page}) => { + await TaskCommentFactory.truncate() + await LabelTaskFactory.truncate() + await TaskAttachmentFactory.truncate() + }) + + test('provides back navigation to the project in the list view', async ({authenticatedPage: page}) => { + const tasks = await TaskFactory.create(1) + const loadTasksPromise = page.waitForResponse(response => + response.url().includes('/projects/1/views/') && response.url().includes('/tasks'), + ) + await page.goto('/projects/1/1') + await loadTasksPromise + await page.locator('.list-view .task').first().locator('a.task-link').click() + await expect(page.locator('.task-view .back-button')).toBeVisible() + await page.locator('.task-view .back-button').click() + await expect(page).toHaveURL(/\/projects\/1\/\d+/) + }) + + test('provides back navigation to the project in the table view', async ({authenticatedPage: page}) => { + const tasks = await TaskFactory.create(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('tbody tr').first().locator('a').first().click() + await expect(page.locator('.task-view .back-button')).toBeVisible() + await page.locator('.task-view .back-button').click() + await expect(page).toHaveURL(/\/projects\/1\/\d+/) + }) + + test.skip('provides back navigation to the project in the kanban view on mobile', async ({authenticatedPage: page}) => { + await page.setViewportSize({width: 375, height: 667}) // iphone-8 + + const tasks = await TaskFactory.create(1) + await page.goto('/projects/1/4') + await page.waitForLoadState('networkidle') + + // Wait for kanban view and task to be visible + const taskLocator = page.locator('.kanban-view .tasks .task').first() + await expect(taskLocator).toBeVisible({timeout: 10000}) + await taskLocator.click() + await expect(page.locator('.task-view .back-button')).toBeVisible() + await page.locator('.task-view .back-button').click() + await expect(page).toHaveURL(/\/projects\/1\/\d+/) + }) + + test.skip('does not provide back navigation to the project in the kanban view on desktop', async ({authenticatedPage: page}) => { + await page.setViewportSize({width: 1440, height: 900}) // macbook-15 + + const tasks = await TaskFactory.create(1) + await page.goto('/projects/1/4') + await page.waitForLoadState('networkidle') + + // Wait for kanban view and task to be visible + const taskLocator = page.locator('.kanban-view .tasks .task').first() + await expect(taskLocator).toBeVisible({timeout: 10000}) + await taskLocator.click() + await expect(page.locator('.task-view .back-button')).not.toBeVisible() + }) + + test('Shows a 404 page for nonexisting tasks', async ({authenticatedPage: page}) => { + await page.goto('/tasks/9999') + await expect(page.locator('body')).toContainText('Not found') + }) + + test('Shows all task details', async ({authenticatedPage: page}) => { + const tasks = await TaskFactory.create(1, { + id: 1, + index: 1, + description: 'Lorem ipsum dolor sit amet.', + }) + await page.goto(`/tasks/${tasks[0].id}`) + + await expect(page.locator('.task-view h1.title.input')).toContainText(tasks[0].title) + await expect(page.locator('.task-view h1.title.task-id')).toContainText('#1') + await expect(page.locator('.task-view h6.subtitle')).toContainText(projects[0].title) + await expect(page.locator('.task-view .details.content.description')).toContainText(tasks[0].description) + await expect(page.locator('.task-view .action-buttons p.created')).toContainText('Created') + }) + + test('Shows a done label for done tasks', async ({authenticatedPage: page}) => { + const tasks = await TaskFactory.create(1, { + id: 1, + index: 1, + done: true, + done_at: new Date().toISOString(), + }) + await page.goto(`/tasks/${tasks[0].id}`) + + await expect(page.locator('.task-view .heading .is-done')).toBeVisible() + await expect(page.locator('.task-view .heading .is-done')).toContainText('Done') + await page.locator('.task-view .action-buttons p.created').scrollIntoViewIfNeeded() + await expect(page.locator('.task-view .action-buttons p.created')).toBeVisible() + await expect(page.locator('.task-view .action-buttons p.created')).toContainText('Done') + }) + + test('Can mark a task as done', async ({authenticatedPage: page}) => { + const tasks = await TaskFactory.create(1, { + id: 1, + done: false, + }) + await page.goto(`/tasks/${tasks[0].id}`) + + await page.locator('.task-view .action-buttons .button').filter({hasText: 'Mark task done!'}).click() + + await expect(page.locator('.task-view .heading .is-done')).toBeVisible() + await expect(page.locator('.task-view .heading .is-done')).toContainText('Done') + await expect(page.locator('.global-notification')).toContainText('Success') + await expect(page.locator('.task-view .action-buttons .button').filter({hasText: 'Mark as undone'})).toBeVisible() + }) + + test('Shows a task identifier since the project has one', async ({authenticatedPage: page}) => { + const projects = await ProjectFactory.create(1, { + id: 1, + identifier: 'TEST', + }) + const tasks = await TaskFactory.create(1, { + id: 1, + project_id: projects[0].id, + index: 1, + }) + + await page.goto(`/tasks/${tasks[0].id}`) + + await expect(page.locator('.task-view h1.title.task-id')).toContainText(`${projects[0].identifier}-${tasks[0].index}`) + }) + + test.skip('Can edit the description', async ({authenticatedPage: page}) => { + const tasks = await TaskFactory.create(1, { + id: 1, + description: 'Lorem ipsum dolor sit amet.', + }) + await page.goto(`/tasks/${tasks[0].id}`) + await page.waitForLoadState('networkidle') + + // Wait for the edit button to be visible + const editButton = page.locator('.task-view .details.content.description .tiptap button.done-edit') + await expect(editButton).toBeVisible({timeout: 10000}) + await editButton.click() + + const editor = page.locator('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror') + await expect(editor).toBeVisible() + await editor.fill('New Description') + + const saveButton = page.locator('[data-cy="saveEditor"]').filter({hasText: 'Save'}) + await expect(saveButton).toBeVisible() + await saveButton.click() + + await expect(page.locator('.task-view .details.content.description h3 span.is-small.has-text-success')).toContainText('Saved!') + }) + + test('autosaves the description when leaving the task view', async ({authenticatedPage: page}) => { + await TaskFactory.create(1, { + id: 1, + project_id: projects[0].id, + description: 'Old Description', + }) + + await page.goto('/tasks/1') + + await page.locator('.task-view .details.content.description .tiptap button.done-edit', {timeout: 30_000}).click() + await page.locator('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror').fill('New Description') + + await page.locator('.task-view h6.subtitle a').first().click() + + await page.goto('/tasks/1') + await expect(page.locator('.task-view .details.content.description')).toContainText('New Description') + }) + + test('Shows an empty editor when the description of a task is empty', async ({authenticatedPage: page}) => { + const tasks = await TaskFactory.create(1, { + id: 1, + description: '', + }) + await page.goto(`/tasks/${tasks[0].id}`) + + await expect(page.locator('.task-view .details.content.description .tiptap.ProseMirror p')).toHaveAttribute('data-placeholder') + await expect(page.locator('.task-view .details.content.description .tiptap button.done-edit')).not.toBeVisible() + }) + + test('Shows a preview editor when the description of a task is not empty', async ({authenticatedPage: page}) => { + const tasks = await TaskFactory.create(1, { + id: 1, + description: 'Lorem Ipsum dolor sit amet', + }) + await page.goto(`/tasks/${tasks[0].id}`) + + await expect(page.locator('.task-view .details.content.description .tiptap.ProseMirror p')).not.toHaveAttribute('data-placeholder') + await expect(page.locator('.task-view .details.content.description .tiptap button.done-edit')).toBeVisible() + }) + + test('Shows a preview editor when the description of a task contains html', async ({authenticatedPage: page}) => { + const tasks = await TaskFactory.create(1, { + id: 1, + description: '

Lorem Ipsum dolor sit amet

', + }) + await page.goto(`/tasks/${tasks[0].id}`) + + await expect(page.locator('.task-view .details.content.description .tiptap.ProseMirror p')).not.toHaveAttribute('data-placeholder') + await expect(page.locator('.task-view .details.content.description .tiptap button.done-edit')).toBeVisible() + }) + + test('Can add a new comment', async ({authenticatedPage: page}) => { + const tasks = await TaskFactory.create(1, { + id: 1, + }) + await page.goto(`/tasks/${tasks[0].id}`) + + await expect(page.locator('.task-view .comments .media.comment .tiptap__editor .tiptap.ProseMirror')).toBeVisible() + await page.locator('.task-view .comments .media.comment .tiptap__editor .tiptap.ProseMirror').fill('New Comment') + await page.locator('.task-view .comments .media.comment .button:not([disabled])').filter({hasText: 'Comment'}).click() + + await expect(page.locator('.task-view .comments .media.comment .tiptap__editor').first()).toContainText('New Comment') + await expect(page.locator('.global-notification')).toContainText('Success') + }) + + test('Can move a task to another project', async ({authenticatedPage: page}) => { + const projects = await ProjectFactory.create(2) + const views = await createDefaultViews(projects[0].id) + // Also create views for the target project + await createDefaultViews(projects[1].id) + await BucketFactory.create(2, { + project_view_id: views[3].id, + }) + const tasks = await TaskFactory.create(1, { + id: 1, + project_id: projects[0].id, + }) + await page.goto(`/tasks/${tasks[0].id}`) + + await page.locator('.task-view .action-buttons .button').filter({hasText: /^Move$/}).click() + const multiselectInput = page.locator('.task-view .content.details .field .multiselect.control .input-wrapper input') + // Use type/pressSequentially instead of fill to properly trigger Vue's input events + await multiselectInput.click() + await multiselectInput.pressSequentially(projects[1].title.substring(0, 10), {delay: 20}) + // Wait for the search results to appear (there's a 200ms debounce in the multiselect) + await expect(page.locator('.task-view .content.details .field .multiselect.control .search-results')).toBeVisible({timeout: 5000}) + await page.locator('.task-view .content.details .field .multiselect.control .search-results').locator('> *').first().click() + + await expect(page.locator('.task-view h6.subtitle')).toContainText(projects[1].title) + await expect(page.locator('.global-notification')).toContainText('Success') + }) + + test('Can delete a task', async ({authenticatedPage: page}) => { + const tasks = await TaskFactory.create(1, { + id: 1, + project_id: 1, + }) + await page.goto(`/tasks/${tasks[0].id}`) + + 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 expect(page).toHaveURL(new RegExp(`/projects/${tasks[0].project_id}/`)) + }) + + test.skip('Can add an assignee to a task', async ({authenticatedPage: page}) => { + await TaskAssigneeFactory.truncate() + + // Create users with IDs starting at 100 to avoid conflict with logged-in user (ID 1) + // Don't truncate to preserve the authenticated user from the fixture + const users = await UserFactory.create(5, { + id: (i: number) => 100 + i, + }, false) + const projects = await ProjectFactory.create(1) + const tasks = await TaskFactory.create(1, { + id: 1, + project_id: projects[0].id, + }) + // Create project membership for all users at once (to avoid truncate issue) + await UserProjectFactory.create(5, { + project_id: projects[0].id, + user_id: (i: number) => users[i - 1].id, + }) + + await page.goto(`/tasks/${tasks[0].id}`) + await page.waitForLoadState('networkidle') + + // Wait for the assign button to be visible + const assignButton = page.locator('[data-cy="taskDetail.assign"]') + await expect(assignButton).toBeVisible({timeout: 10000}) + await assignButton.click() + + const input = page.locator('.task-view .column.assignees .multiselect input') + const userToAssign = users[0] + // Use type/pressSequentially instead of fill to properly trigger Vue's input events + await input.click() + await input.pressSequentially(userToAssign.username.substring(0, 10), {delay: 20}) + // Wait for search results (200ms debounce + API request time) + await expect(page.locator('.task-view .column.assignees .multiselect .search-results')).toBeVisible({timeout: 5000}) + await page.locator('.task-view .column.assignees .multiselect .search-results').locator('> *').first().click() + + await expect(page.locator('.global-notification')).toContainText('Success') + await expect(page.locator('.task-view .column.assignees .multiselect .input-wrapper span.assignee')).toBeVisible() + }) + + test('Can remove an assignee from a task', async ({authenticatedPage: page}) => { + const users = await UserFactory.create(2) + const tasks = await TaskFactory.create(1, { + id: 1, + project_id: 1, + }) + await UserProjectFactory.create(5, { + project_id: 1, + user_id: '{increment}', + }) + await TaskAssigneeFactory.create(1, { + task_id: tasks[0].id, + user_id: users[1].id, + }) + + await page.goto(`/tasks/${tasks[0].id}`) + + await page.locator('.task-view .column.assignees .multiselect .input-wrapper span.assignee .remove-assignee').click() + + await expect(page.locator('.global-notification')).toContainText('Success') + await expect(page.locator('.task-view .column.assignees .multiselect .input-wrapper span.assignee')).not.toBeVisible() + }) + + test('Can add a new label to a task', async ({authenticatedPage: page}) => { + const tasks = await TaskFactory.create(1, { + id: 1, + project_id: 1, + }) + await LabelFactory.truncate() + const newLabelText = 'some new label' + + await page.goto(`/tasks/${tasks[0].id}`) + + await expect(page.locator('.task-view .action-buttons .button').filter({hasText: 'Add Labels'})).toBeVisible() + await page.locator('.task-view .action-buttons .button').filter({hasText: 'Add Labels'}).click() + await page.locator('.task-view .details.labels-list .multiselect input').fill(newLabelText) + await page.locator('.task-view .details.labels-list .multiselect .search-results').locator('> *').first().click() + + await expect(page.locator('.global-notification')).toContainText('Success') + await expect(page.locator('.task-view .details.labels-list .multiselect .input-wrapper span.tag')).toBeVisible() + await expect(page.locator('.task-view .details.labels-list .multiselect .input-wrapper span.tag')).toContainText(newLabelText) + }) + + test('Can add an existing label to a task', async ({authenticatedPage: page}) => { + const tasks = await TaskFactory.create(1, { + id: 1, + project_id: 1, + }) + const labels = await LabelFactory.create(1) + await LabelTaskFactory.truncate() + + await page.goto(`/tasks/${tasks[0].id}`) + + await addLabelToTaskAndVerify(page, labels[0].title) + }) + + test('Can add a label to a task and it shows up on the kanban board afterwards', async ({authenticatedPage: page}) => { + const tasks = await TaskFactory.create(1, { + id: 1, + project_id: projects[0].id, + }) + const labels = await LabelFactory.create(1) + await LabelTaskFactory.truncate() + await TaskBucketFactory.create(1, { + task_id: tasks[0].id, + bucket_id: buckets[0].id, + project_view_id: buckets[0].project_view_id, + }) + + await page.goto(`/projects/${projects[0].id}/4`) + + await page.locator('.bucket .task').filter({hasText: tasks[0].title}).click() + + await addLabelToTaskAndVerify(page, labels[0].title) + + await page.locator('.modal-container > .close').click() + + await expect(page.locator('.bucket .task')).toContainText(labels[0].title) + }) + + test.skip('Can remove a label from a task', async ({authenticatedPage: page}) => { + const tasks = await TaskFactory.create(1, { + id: 1, + project_id: 1, + }) + const labels = await LabelFactory.create(1) + await LabelTaskFactory.create(1, { + task_id: tasks[0].id, + label_id: labels[0].id, + }) + + await page.goto(`/tasks/${tasks[0].id}`) + await page.waitForLoadState('networkidle') + + const labelWrapper = page.locator('.task-view .details.labels-list .multiselect .input-wrapper') + await expect(labelWrapper).toBeVisible({timeout: 10000}) + await expect(labelWrapper).toContainText(labels[0].title) + + // Hover over the label to reveal the remove button + const labelItem = labelWrapper.locator('> *').first() + await labelItem.hover() + const removeButton = labelItem.locator('[data-cy="taskDetail.removeLabel"]') + await expect(removeButton).toBeVisible() + await removeButton.click() + + await expect(page.locator('.global-notification')).toContainText('Success') + await expect(labelWrapper).not.toContainText(labels[0].title) + }) + + test.skip('Can set a due date for a task', async ({authenticatedPage: page}) => { + const tasks = await TaskFactory.create(1, { + id: 1, + done: false, + }) + await page.goto(`/tasks/${tasks[0].id}`) + await page.waitForLoadState('networkidle') + + const setDueDateButton = page.locator('.task-view .action-buttons .button').filter({hasText: 'Set Due Date'}) + await expect(setDueDateButton).toBeVisible({timeout: 10000}) + await setDueDateButton.click() + + const datepickerShow = page.locator('.task-view .columns.details .column').filter({hasText: 'Due Date'}).locator('.date-input .datepicker .show') + await expect(datepickerShow).toBeVisible() + await datepickerShow.click() + + const tomorrowButton = page.locator('.datepicker .datepicker-popup button').filter({hasText: 'Tomorrow'}) + await expect(tomorrowButton).toBeVisible() + await tomorrowButton.click() + + const confirmButton = page.locator('[data-cy="closeDatepicker"]').filter({hasText: 'Confirm'}) + await expect(confirmButton).toBeVisible() + await confirmButton.click() + + await expect(page.locator('.task-view .columns.details .column').filter({hasText: 'Due Date'}).locator('.date-input .datepicker-popup')).not.toBeVisible() + await expect(page.locator('.global-notification')).toContainText('Success') + }) + + test.skip('Can set a due date to a specific date for a task', async ({authenticatedPage: page}) => { + const tasks = await TaskFactory.create(1, { + id: 1, + done: false, + }) + await page.goto(`/tasks/${tasks[0].id}`) + await page.waitForLoadState('networkidle') + + const setDueDateButton = page.locator('.task-view .action-buttons .button').filter({hasText: 'Set Due Date'}) + await expect(setDueDateButton).toBeVisible({timeout: 10000}) + await setDueDateButton.click() + + const datepickerShow = page.locator('.task-view .columns.details .column').filter({hasText: 'Due Date'}).locator('.date-input .datepicker .show') + await expect(datepickerShow).toBeVisible() + await datepickerShow.click() + + const todayButton = page.locator('.datepicker-popup .flatpickr-innerContainer .flatpickr-days .flatpickr-day.today') + await expect(todayButton).toBeVisible() + await todayButton.click() + + const confirmButton = page.locator('[data-cy="closeDatepicker"]').filter({hasText: 'Confirm'}) + await expect(confirmButton).toBeVisible() + await confirmButton.click() + + const today = new Date() + today.setHours(12) + today.setMinutes(0) + today.setSeconds(0) + await expect(page.locator('.task-view .columns.details .column').filter({hasText: 'Due Date'}).locator('.date-input .datepicker-popup')).not.toBeVisible() + await expect(page.locator('.task-view .columns.details .column').filter({hasText: 'Due Date'}).locator('.date-input')).toContainText(dayjs(today).fromNow()) + await expect(page.locator('.global-notification')).toContainText('Success') + }) + + test.skip('Can change a due date to a specific date for a task', async ({authenticatedPage: page}) => { + const dueDate = new Date(2025, 2, 20) + dueDate.setHours(12) + dueDate.setMinutes(0) + dueDate.setSeconds(0) + dueDate.setDate(1) + const tasks = await TaskFactory.create(1, { + id: 1, + done: false, + due_date: dueDate.toISOString(), + }) + + const today = new Date(2025, 2, 5) + today.setHours(12) + today.setMinutes(0) + today.setSeconds(0) + + await page.goto(`/tasks/${tasks[0].id}`) + await page.waitForLoadState('networkidle') + + const setDueDateButton = page.locator('.task-view .action-buttons .button').filter({hasText: 'Set Due Date'}) + await expect(setDueDateButton).toBeVisible({timeout: 10000}) + await setDueDateButton.click() + + const datepickerShow = page.locator('.task-view .columns.details .column').filter({hasText: 'Due Date'}).locator('.date-input .datepicker .show') + await expect(datepickerShow).toBeVisible() + await datepickerShow.click() + + const dateButton = page.locator(`.datepicker-popup .flatpickr-innerContainer .flatpickr-days [aria-label="${today.toLocaleString('en-US', {month: 'long'})} ${today.getDate()}, ${today.getFullYear()}"]`) + await expect(dateButton).toBeVisible() + await dateButton.click() + + const confirmButton = page.locator('[data-cy="closeDatepicker"]').filter({hasText: 'Confirm'}) + await expect(confirmButton).toBeVisible() + await confirmButton.click() + + await expect(page.locator('.task-view .columns.details .column').filter({hasText: 'Due Date'}).locator('.date-input .datepicker-popup')).not.toBeVisible() + await expect(page.locator('.task-view .columns.details .column').filter({hasText: 'Due Date'}).locator('.date-input')).toContainText(dayjs(today).fromNow()) + await expect(page.locator('.global-notification')).toContainText('Success') + }) + + test('Can paste an image into the description editor which uploads it as an attachment', async ({authenticatedPage: page}) => { + await TaskAttachmentFactory.truncate() + const tasks = await TaskFactory.create(1, { + id: 1, + }) as Task[] + await page.goto(`/tasks/${tasks[0].id}`) + + const uploadAttachmentPromise = page.waitForResponse(response => + response.url().includes(`/tasks/${tasks[0].id}/attachments`) && response.request().method() === 'PUT', + ) + + const editor = page.locator('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror') + await expect(editor).toBeVisible({timeout: 30_000}) + await pasteFile(editor, 'image.jpg', 'image/jpeg') + + await uploadAttachmentPromise + await expect(page.locator('.attachments .attachments .files button.attachment')).toBeVisible() + const img = page.locator('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror img') + await expect(img).toBeVisible() + const naturalWidth = await img.evaluate((el: HTMLImageElement) => el.naturalWidth) + expect(naturalWidth).toBeGreaterThan(0) + }) + + test('Can set a reminder', async ({authenticatedPage: page}) => { + await TaskReminderFactory.truncate() + const tasks = await TaskFactory.create(1, { + id: 1, + done: false, + }) + await page.goto(`/tasks/${tasks[0].id}`) + + await page.locator('.task-view .action-buttons .button').filter({hasText: 'Set Reminders'}).click() + await page.locator('.task-view .columns.details .column button').filter({hasText: 'Add a reminder'}).click() + await page.locator('.datepicker__quick-select-date').filter({hasText: 'Tomorrow'}).click() + + await expect(page.locator('.reminder-options-popup.is-open')).not.toBeVisible() + await expect(page.locator('.global-notification')).toContainText('Success') + }) + + test('Allows to set a relative reminder when the task already has a due date', async ({authenticatedPage: page}) => { + await TaskReminderFactory.truncate() + const tasks = await TaskFactory.create(1, { + id: 1, + done: false, + due_date: (new Date()).toISOString(), + }) + await page.goto(`/tasks/${tasks[0].id}`) + + await page.locator('.task-view .action-buttons .button').filter({hasText: 'Set Reminders'}).click() + await page.locator('.task-view .columns.details .column button').filter({hasText: 'Add a reminder'}).click() + await expect(page.locator('.datepicker__quick-select-date')).not.toBeVisible() + // Use .is-open to target the currently open popup + const openPopup = page.locator('.reminder-options-popup.is-open') + await expect(openPopup.locator('.card-content')).toContainText('1 day before Due Date') + await openPopup.locator('.card-content button').filter({hasText: '1 day before Due Date'}).click() + + await expect(page.locator('.reminder-options-popup.is-open')).not.toBeVisible() + await expect(page.locator('.global-notification')).toContainText('Success') + }) + + test('Allows to set a relative reminder when the task already has a start date', async ({authenticatedPage: page}) => { + await TaskReminderFactory.truncate() + const tasks = await TaskFactory.create(1, { + id: 1, + done: false, + start_date: (new Date()).toISOString(), + }) + await page.goto(`/tasks/${tasks[0].id}`) + + await page.locator('.task-view .action-buttons .button').filter({hasText: 'Set Reminders'}).click() + await page.locator('.task-view .columns.details .column button').filter({hasText: 'Add a reminder'}).click() + await expect(page.locator('.datepicker__quick-select-date')).not.toBeVisible() + // Use .is-open to target the currently open popup + const openPopup = page.locator('.reminder-options-popup.is-open') + await expect(openPopup.locator('.card-content')).toContainText('1 day before Start Date') + await openPopup.locator('.card-content').filter({hasText: '1 day before Start Date'}).click() + + await expect(page.locator('.reminder-options-popup.is-open')).not.toBeVisible() + await expect(page.locator('.global-notification')).toContainText('Success') + }) + + test('Allows to set a custom relative reminder when the task already has a due date', async ({authenticatedPage: page}) => { + await TaskReminderFactory.truncate() + const tasks = await TaskFactory.create(1, { + id: 1, + done: false, + due_date: (new Date()).toISOString(), + }) + await page.goto(`/tasks/${tasks[0].id}`) + + await page.locator('.task-view .action-buttons .button').filter({hasText: 'Set Reminders'}).click() + await page.locator('.task-view .columns.details .column button').filter({hasText: 'Add a reminder'}).click() + await expect(page.locator('.datepicker__quick-select-date')).not.toBeVisible() + // Use .is-open to target the currently open popup + const openPopup = page.locator('.reminder-options-popup.is-open') + await openPopup.locator('.option-button').filter({hasText: 'Custom'}).click() + // Wait for the custom form to appear + await expect(openPopup.locator('.reminder-period')).toBeVisible() + await openPopup.locator('.reminder-period input').fill('10') + await openPopup.locator('.reminder-period select').first().selectOption('days') + await openPopup.locator('button').filter({hasText: 'Confirm'}).click() + + await expect(page.locator('.reminder-options-popup.is-open')).not.toBeVisible() + await expect(page.locator('.global-notification')).toContainText('Success') + }) + + test('Allows to set a fixed reminder when the task already has a due date', async ({authenticatedPage: page}) => { + await TaskReminderFactory.truncate() + const tasks = await TaskFactory.create(1, { + id: 1, + done: false, + due_date: (new Date()).toISOString(), + }) + await page.goto(`/tasks/${tasks[0].id}`) + + await page.locator('.task-view .action-buttons .button').filter({hasText: 'Set Reminders'}).click() + await page.locator('.task-view .columns.details .column button').filter({hasText: 'Add a reminder'}).click() + await expect(page.locator('.datepicker__quick-select-date')).not.toBeVisible() + // Use .is-open to target the currently open popup + const openPopup = page.locator('.reminder-options-popup.is-open') + await openPopup.locator('.option-button').filter({hasText: 'Date and time'}).click() + // Wait for the datepicker to appear within the popup + await expect(openPopup.locator('.datepicker__quick-select-date').first()).toBeVisible() + await openPopup.locator('.datepicker__quick-select-date').filter({hasText: 'Tomorrow'}).click() + + await expect(page.locator('.reminder-options-popup.is-open')).not.toBeVisible() + await expect(page.locator('.global-notification')).toContainText('Success') + }) + + test('Can set a priority for a task', async ({authenticatedPage: page}) => { + const tasks = await TaskFactory.create(1, { + id: 1, + }) + await page.goto(`/tasks/${tasks[0].id}`) + + await page.locator('.task-view .action-buttons .button').filter({hasText: 'Set Priority'}).click() + await page.locator('.task-view .columns.details .column').filter({hasText: 'Priority'}).locator('.select select').selectOption('Urgent') + await expect(page.locator('.global-notification')).toContainText('Success') + + await expect(page.locator('.task-view .columns.details .column').filter({hasText: 'Priority'}).locator('.select select')).toHaveValue('4') + }) + + test('Can set the progress for a task', async ({authenticatedPage: page}) => { + const tasks = await TaskFactory.create(1, { + id: 1, + }) + await page.goto(`/tasks/${tasks[0].id}`) + + await page.locator('.task-view .action-buttons .button').filter({hasText: 'Set Progress'}).click() + await page.locator('.task-view .columns.details .column').filter({hasText: 'Progress'}).locator('.select select').selectOption('50%') + await expect(page.locator('.global-notification')).toContainText('Success') + + await page.waitForTimeout(200) + + await expect(page.locator('.task-view .columns.details .column').filter({hasText: 'Progress'}).locator('.select select')).toBeVisible() + await expect(page.locator('.task-view .columns.details .column').filter({hasText: 'Progress'}).locator('.select select')).toHaveValue('0.5') + }) + + test('Can add an attachment to a task', async ({authenticatedPage: page}) => { + await TaskAttachmentFactory.truncate() + const tasks = await TaskFactory.create(1, { + id: 1, + }) + await page.goto(`/tasks/${tasks[0].id}`) + + await uploadAttachmentAndVerify(page, tasks[0].id) + }) + + test('Can add an attachment to a task and see it appearing on kanban', async ({authenticatedPage: page}) => { + await TaskAttachmentFactory.truncate() + const tasks = await TaskFactory.create(1, { + id: 1, + project_id: projects[0].id, + }) + const labels = await LabelFactory.create(1) + await LabelTaskFactory.truncate() + await TaskBucketFactory.create(1, { + task_id: tasks[0].id, + bucket_id: buckets[0].id, + project_view_id: buckets[0].project_view_id, + }) + + await page.goto(`/projects/${projects[0].id}/4`) + + await page.locator('.bucket .task').filter({hasText: tasks[0].title}).click() + + await uploadAttachmentAndVerify(page, tasks[0].id) + + await page.locator('.modal-container > .close').click() + + await expect(page.locator('.bucket .task .footer .icon svg.fa-paperclip')).toBeVisible() + }) + + test('Can check items off a checklist', async ({authenticatedPage: page}) => { + const tasks = await TaskFactory.create(1, { + id: 1, + description: ` +`, + }) + await page.goto(`/tasks/${tasks[0].id}`) + + await expect(page.locator('.task-view .checklist-summary')).toContainText('1 of 5 tasks') + await page.locator('.tiptap__editor ul > li input[type=checkbox]').nth(2).click() + + await expect(page.locator('.task-view .details.content.description h3 span.is-small.has-text-success')).toContainText('Saved!') + await expect(page.locator('.tiptap__editor ul > li input[type=checkbox]').nth(2)).toBeChecked() + await expect(page.locator('.tiptap__editor input[type=checkbox]')).toHaveCount(5) + await expect(page.locator('.task-view .checklist-summary')).toContainText('2 of 5 tasks') + }) + + test('Persists checked checklist items after reload', async ({authenticatedPage: page}) => { + const tasks = await TaskFactory.create(1, { + id: 1, + description: ` +`, + }) + await page.goto(`/tasks/${tasks[0].id}`) + + await expect(page.locator('.task-view .checklist-summary')).toContainText('0 of 2 tasks') + await page.locator('.tiptap__editor ul > li input[type=checkbox]').first().click() + + await expect(page.locator('.task-view .details.content.description h3 span.is-small.has-text-success')).toContainText('Saved!') + + await expect(page.locator('.task-view .checklist-summary')).toContainText('1 of 2 tasks') + + await page.reload() + + await expect(page.locator('.task-view .checklist-summary')).toContainText('1 of 2 tasks') + await expect(page.locator('.tiptap__editor ul > li input[type=checkbox]').first()).toBeChecked() + }) + + test('Should use the editor to render description', async ({authenticatedPage: page}) => { + const tasks = await TaskFactory.create(1, { + id: 1, + description: ` +

Lorem Ipsum

+

Dolor sit amet

+`, + }) + await page.goto(`/tasks/${tasks[0].id}`) + + await expect(page.locator('.tiptap__editor ul > li input[type=checkbox]').first()).toBeVisible() + await expect(page.locator('.tiptap__editor h1').filter({hasText: 'Lorem Ipsum'})).toBeVisible() + await expect(page.locator('.tiptap__editor p').filter({hasText: 'Dolor sit amet'})).toBeVisible() + }) + + test('Should render an image from attachment', async ({authenticatedPage: page, apiContext}) => { + await TaskAttachmentFactory.truncate() + + const tasks = await TaskFactory.create(1, { + id: 1, + description: '', + }) + + const filePath = join(__dirname, '../../fixtures/image.jpg') + const fileBuffer = readFileSync(filePath) + + // Navigate to a page first to establish context for localStorage access + await page.goto('/') + const token = await page.evaluate(() => localStorage.getItem('token')) + + // Get the window.API_URL from the page - this is what the TipTap CustomImage extension checks against + const apiUrl = await page.evaluate(() => window.API_URL) + + const response = await apiContext.put(`tasks/${tasks[0].id}/attachments`, { + multipart: { + files: { + name: 'image.jpg', + mimeType: 'image/jpeg', + buffer: fileBuffer, + }, + }, + headers: { + 'Authorization': `Bearer ${token}`, + }, + }) + + const {success} = await response.json() + + // The URL format MUST match window.API_URL for the CustomImage extension to + // recognize it as an attachment URL and load it with authentication + await TaskFactory.create(1, { + id: 1, + description: `test image`, + }) + + await page.goto(`/tasks/${tasks[0].id}`) + + // Wait for the page to load + await page.waitForLoadState('networkidle') + + // Get the description editor (first tiptap editor, not comments) + const descriptionEditor = page.locator('.tiptap__editor').first() + const img = descriptionEditor.locator('img') + await expect(img).toBeVisible() + + // Wait for the image to be loaded (the editor loads images asynchronously via blob URL) + await page.waitForFunction( + () => { + // Get the first tiptap editor (description) + const editor = document.querySelector('.tiptap__editor') + const img = editor?.querySelector('img') as HTMLImageElement + return img && img.naturalWidth > 0 + }, + {timeout: 10000}, + ) + + const naturalWidth = await img.evaluate((el: HTMLImageElement) => el.naturalWidth) + expect(naturalWidth).toBeGreaterThan(0) + }) + }) +}) diff --git a/frontend/tests/e2e/user/email-confirmation.spec.ts b/frontend/tests/e2e/user/email-confirmation.spec.ts new file mode 100644 index 000000000..62752c8f9 --- /dev/null +++ b/frontend/tests/e2e/user/email-confirmation.spec.ts @@ -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) + }) +}) diff --git a/frontend/tests/e2e/user/login.spec.ts b/frontend/tests/e2e/user/login.spec.ts new file mode 100644 index 000000000..75088bf57 --- /dev/null +++ b/frontend/tests/e2e/user/login.spec.ts @@ -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 => { + 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 { + 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`)) + }) +}) diff --git a/frontend/tests/e2e/user/logout.spec.ts b/frontend/tests/e2e/user/logout.spec.ts new file mode 100644 index 000000000..c20c60bb5 --- /dev/null +++ b/frontend/tests/e2e/user/logout.spec.ts @@ -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() + }) +}) diff --git a/frontend/tests/e2e/user/openid-login.spec.ts b/frontend/tests/e2e/user/openid-login.spec.ts new file mode 100644 index 000000000..d5015a9cf --- /dev/null +++ b/frontend/tests/e2e/user/openid-login.spec.ts @@ -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') + }) +}) diff --git a/frontend/tests/e2e/user/password-reset.spec.ts b/frontend/tests/e2e/user/password-reset.spec.ts new file mode 100644 index 000000000..22b835806 --- /dev/null +++ b/frontend/tests/e2e/user/password-reset.spec.ts @@ -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') + }) +}) diff --git a/frontend/tests/e2e/user/registration.spec.ts b/frontend/tests/e2e/user/registration.spec.ts new file mode 100644 index 000000000..84e998ff0 --- /dev/null +++ b/frontend/tests/e2e/user/registration.spec.ts @@ -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.') + }) +}) diff --git a/frontend/tests/e2e/user/settings.spec.ts b/frontend/tests/e2e/user/settings.spec.ts new file mode 100644 index 000000000..4176db5af --- /dev/null +++ b/frontend/tests/e2e/user/settings.spec.ts @@ -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') + }) +}) diff --git a/frontend/tests/factories/bucket.ts b/frontend/tests/factories/bucket.ts new file mode 100644 index 000000000..23b1cfe60 --- /dev/null +++ b/frontend/tests/factories/bucket.ts @@ -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(), + } + } +} diff --git a/frontend/tests/factories/label_task.ts b/frontend/tests/factories/label_task.ts new file mode 100644 index 000000000..47cec7f67 --- /dev/null +++ b/frontend/tests/factories/label_task.ts @@ -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(), + } + } +} diff --git a/frontend/tests/factories/labels.ts b/frontend/tests/factories/labels.ts new file mode 100644 index 000000000..69e382329 --- /dev/null +++ b/frontend/tests/factories/labels.ts @@ -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(), + } + } +} diff --git a/frontend/tests/factories/link_sharing.ts b/frontend/tests/factories/link_sharing.ts new file mode 100644 index 000000000..b09ddfca2 --- /dev/null +++ b/frontend/tests/factories/link_sharing.ts @@ -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(), + } + } +} diff --git a/frontend/tests/factories/project.ts b/frontend/tests/factories/project.ts new file mode 100644 index 000000000..ce31d2308 --- /dev/null +++ b/frontend/tests/factories/project.ts @@ -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(), + } + } +} diff --git a/frontend/tests/factories/project_view.ts b/frontend/tests/factories/project_view.ts new file mode 100644 index 000000000..deeda817e --- /dev/null +++ b/frontend/tests/factories/project_view.ts @@ -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(), + } + } +} diff --git a/frontend/tests/factories/task.ts b/frontend/tests/factories/task.ts new file mode 100644 index 000000000..9c37ad0f7 --- /dev/null +++ b/frontend/tests/factories/task.ts @@ -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() + } + } +} diff --git a/frontend/tests/factories/task_assignee.ts b/frontend/tests/factories/task_assignee.ts new file mode 100644 index 000000000..580905f00 --- /dev/null +++ b/frontend/tests/factories/task_assignee.ts @@ -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(), + } + } +} diff --git a/frontend/tests/factories/task_attachments.ts b/frontend/tests/factories/task_attachments.ts new file mode 100644 index 000000000..fc3775e34 --- /dev/null +++ b/frontend/tests/factories/task_attachments.ts @@ -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(), + } + } +} diff --git a/frontend/tests/factories/task_buckets.ts b/frontend/tests/factories/task_buckets.ts new file mode 100644 index 000000000..78347f238 --- /dev/null +++ b/frontend/tests/factories/task_buckets.ts @@ -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}', + } + } +} diff --git a/frontend/tests/factories/task_comment.ts b/frontend/tests/factories/task_comment.ts new file mode 100644 index 000000000..362316e92 --- /dev/null +++ b/frontend/tests/factories/task_comment.ts @@ -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() + } + } +} diff --git a/frontend/tests/factories/task_relation.ts b/frontend/tests/factories/task_relation.ts new file mode 100644 index 000000000..2c1007ada --- /dev/null +++ b/frontend/tests/factories/task_relation.ts @@ -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(), + } + } +} diff --git a/frontend/tests/factories/task_reminders.ts b/frontend/tests/factories/task_reminders.ts new file mode 100644 index 000000000..a0d460a39 --- /dev/null +++ b/frontend/tests/factories/task_reminders.ts @@ -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, + } + } +} diff --git a/frontend/tests/factories/team.ts b/frontend/tests/factories/team.ts new file mode 100644 index 000000000..57abadc66 --- /dev/null +++ b/frontend/tests/factories/team.ts @@ -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(), + } + } +} diff --git a/frontend/tests/factories/team_member.ts b/frontend/tests/factories/team_member.ts new file mode 100644 index 000000000..231c5bdaf --- /dev/null +++ b/frontend/tests/factories/team_member.ts @@ -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(), + } + } +} diff --git a/frontend/tests/factories/token.ts b/frontend/tests/factories/token.ts new file mode 100644 index 000000000..e15979654 --- /dev/null +++ b/frontend/tests/factories/token.ts @@ -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 & { 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 ?? {}), + } + } +} diff --git a/frontend/tests/factories/user.ts b/frontend/tests/factories/user.ts new file mode 100644 index 000000000..ee562cdd6 --- /dev/null +++ b/frontend/tests/factories/user.ts @@ -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(), + } + } +} diff --git a/frontend/tests/factories/users_project.ts b/frontend/tests/factories/users_project.ts new file mode 100644 index 000000000..b6bdde356 --- /dev/null +++ b/frontend/tests/factories/users_project.ts @@ -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(), + } + } +} diff --git a/frontend/tests/fixtures/image.jpg b/frontend/tests/fixtures/image.jpg new file mode 100644 index 000000000..93910582d Binary files /dev/null and b/frontend/tests/fixtures/image.jpg differ diff --git a/frontend/tests/support/authenticateUser.ts b/frontend/tests/support/authenticateUser.ts new file mode 100644 index 000000000..1322e4fcb --- /dev/null +++ b/frontend/tests/support/authenticateUser.ts @@ -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 +} diff --git a/frontend/tests/support/commands.ts b/frontend/tests/support/commands.ts new file mode 100644 index 000000000..a6757f0b6 --- /dev/null +++ b/frontend/tests/support/commands.ts @@ -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) +} diff --git a/frontend/tests/support/constants.ts b/frontend/tests/support/constants.ts new file mode 100644 index 000000000..baf71918a --- /dev/null +++ b/frontend/tests/support/constants.ts @@ -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.' + diff --git a/frontend/tests/support/factory.ts b/frontend/tests/support/factory.ts new file mode 100644 index 000000000..4ae11c6e0 --- /dev/null +++ b/frontend/tests/support/factory.ts @@ -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) + } +} diff --git a/frontend/tests/support/filterTestHelpers.ts b/frontend/tests/support/filterTestHelpers.ts new file mode 100644 index 000000000..35200a1cf --- /dev/null +++ b/frontend/tests/support/filterTestHelpers.ts @@ -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 } +} diff --git a/frontend/tests/support/fixtures.ts b/frontend/tests/support/fixtures.ts new file mode 100644 index 000000000..e87ddc1be --- /dev/null +++ b/frontend/tests/support/fixtures.ts @@ -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' diff --git a/frontend/tests/support/seed.ts b/frontend/tests/support/seed.ts new file mode 100644 index 000000000..247e9b148 --- /dev/null +++ b/frontend/tests/support/seed.ts @@ -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, + }) +} diff --git a/frontend/tests/support/updateUserSettings.ts b/frontend/tests/support/updateUserSettings.ts new file mode 100644 index 000000000..44422ef38 --- /dev/null +++ b/frontend/tests/support/updateUserSettings.ts @@ -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, + }, + }) +} diff --git a/pkg/routes/api/v1/testing.go b/pkg/routes/api/v1/testing.go index a0090684e..be695cd85 100644 --- a/pkg/routes/api/v1/testing.go +++ b/pkg/routes/api/v1/testing.go @@ -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) }