feat: migrate cypress e2e tests to playwright (#1739)
This commit is contained in:
parent
23a6ae19ea
commit
51512c1cb4
|
|
@ -13,8 +13,9 @@ runs:
|
||||||
- if: inputs.install-e2e-binaries == 'false'
|
- if: inputs.install-e2e-binaries == 'false'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "CYPRESS_INSTALL_BINARY=0" >> $GITHUB_ENV
|
echo "CYPRESS_INSTALL_BINARY=0" >> $GITHUB_ENV
|
||||||
echo "PUPPETEER_SKIP_DOWNLOAD=true" >> $GITHUB_ENV
|
echo "PUPPETEER_SKIP_DOWNLOAD=true" >> $GITHUB_ENV
|
||||||
|
echo "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1" >> $GITHUB_ENV
|
||||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
|
||||||
|
|
@ -346,7 +346,81 @@ jobs:
|
||||||
name: frontend_dist
|
name: frontend_dist
|
||||||
path: ./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
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
- api-build
|
- api-build
|
||||||
|
|
@ -356,15 +430,6 @@ jobs:
|
||||||
image: ghcr.io/go-vikunja/dex-testing:main@sha256:d401c06a9f8fd36ece446a07499b827232af7f21eb36872a76c9eac4d0c77bab
|
image: ghcr.io/go-vikunja/dex-testing:main@sha256:d401c06a9f8fd36ece446a07499b827232af7f21eb36872a76c9eac4d0c77bab
|
||||||
ports:
|
ports:
|
||||||
- 5556:5556
|
- 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:
|
container:
|
||||||
image: cypress/browsers:latest@sha256:7331c596894429c9809c9a8bf92158224d151d5fa9736d14cf8e0268805a37ab
|
image: cypress/browsers:latest@sha256:7331c596894429c9809c9a8bf92158224d151d5fa9736d14cf8e0268805a37ab
|
||||||
options: --user 1001
|
options: --user 1001
|
||||||
|
|
@ -387,7 +452,6 @@ jobs:
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
|
||||||
CYPRESS_API_URL: http://127.0.0.1:3456/api/v1
|
CYPRESS_API_URL: http://127.0.0.1:3456/api/v1
|
||||||
CYPRESS_TEST_SECRET: averyLongSecretToSe33dtheDB
|
CYPRESS_TEST_SECRET: averyLongSecretToSe33dtheDB
|
||||||
CYPRESS_DEFAULT_COMMAND_TIMEOUT: 60000
|
CYPRESS_DEFAULT_COMMAND_TIMEOUT: 60000
|
||||||
|
|
@ -408,19 +472,10 @@ jobs:
|
||||||
install: false
|
install: false
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
browser: chrome
|
browser: chrome
|
||||||
record: ${{ github.event.pull_request.head.repo.fork != true }}
|
record: false
|
||||||
parallel: ${{ github.event.pull_request.head.repo.fork != true }}
|
parallel: false
|
||||||
start: |
|
start: |
|
||||||
pnpm run preview:vikunja
|
pnpm run preview:vikunja
|
||||||
pnpm run preview
|
pnpm run preview
|
||||||
wait-on: http://127.0.0.1:4173,http://127.0.0.1:3456/api/v1/info
|
wait-on: http://127.0.0.1:4173,http://127.0.0.1:3456/api/v1/info
|
||||||
wait-on-timeout: 10
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -466,15 +466,6 @@ packages:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
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:
|
debug@4.4.0:
|
||||||
resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==}
|
resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
|
|
@ -1340,11 +1331,6 @@ packages:
|
||||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
semver@7.6.3:
|
|
||||||
resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==}
|
|
||||||
engines: {node: '>=10'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
semver@7.7.2:
|
semver@7.7.2:
|
||||||
resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
|
resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -1951,7 +1937,7 @@ snapshots:
|
||||||
builder-util-runtime: 9.3.1
|
builder-util-runtime: 9.3.1
|
||||||
chromium-pickle-js: 0.2.0
|
chromium-pickle-js: 0.2.0
|
||||||
config-file-ts: 0.2.8-rc1
|
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)
|
dmg-builder: 26.0.12(electron-builder-squirrel-windows@24.13.3)
|
||||||
dotenv: 16.4.5
|
dotenv: 16.4.5
|
||||||
dotenv-expand: 11.0.6
|
dotenv-expand: 11.0.6
|
||||||
|
|
@ -1968,7 +1954,7 @@ snapshots:
|
||||||
minimatch: 10.0.1
|
minimatch: 10.0.1
|
||||||
plist: 3.1.0
|
plist: 3.1.0
|
||||||
resedit: 1.7.2
|
resedit: 1.7.2
|
||||||
semver: 7.6.3
|
semver: 7.7.2
|
||||||
tar: 6.2.1
|
tar: 6.2.1
|
||||||
temp-file: 3.4.0
|
temp-file: 3.4.0
|
||||||
tiny-async-pool: 1.3.0
|
tiny-async-pool: 1.3.0
|
||||||
|
|
@ -2048,7 +2034,7 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
bytes: 3.1.2
|
bytes: 3.1.2
|
||||||
content-type: 1.0.5
|
content-type: 1.0.5
|
||||||
debug: 4.4.0
|
debug: 4.4.1
|
||||||
http-errors: 2.0.0
|
http-errors: 2.0.0
|
||||||
iconv-lite: 0.6.3
|
iconv-lite: 0.6.3
|
||||||
on-finished: 2.4.1
|
on-finished: 2.4.1
|
||||||
|
|
@ -2090,7 +2076,7 @@ snapshots:
|
||||||
|
|
||||||
builder-util-runtime@9.3.1:
|
builder-util-runtime@9.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.3.7
|
debug: 4.4.1
|
||||||
sax: 1.4.1
|
sax: 1.4.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
@ -2124,7 +2110,7 @@ snapshots:
|
||||||
builder-util-runtime: 9.3.1
|
builder-util-runtime: 9.3.1
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
debug: 4.3.7
|
debug: 4.4.1
|
||||||
fs-extra: 10.1.0
|
fs-extra: 10.1.0
|
||||||
http-proxy-agent: 7.0.2
|
http-proxy-agent: 7.0.2
|
||||||
https-proxy-agent: 7.0.5
|
https-proxy-agent: 7.0.5
|
||||||
|
|
@ -2288,10 +2274,6 @@ snapshots:
|
||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
debug@4.3.7:
|
|
||||||
dependencies:
|
|
||||||
ms: 2.1.3
|
|
||||||
|
|
||||||
debug@4.4.0:
|
debug@4.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
@ -2565,7 +2547,7 @@ snapshots:
|
||||||
|
|
||||||
finalhandler@2.1.0:
|
finalhandler@2.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.0
|
debug: 4.4.1
|
||||||
encodeurl: 2.0.0
|
encodeurl: 2.0.0
|
||||||
escape-html: 1.0.3
|
escape-html: 1.0.3
|
||||||
on-finished: 2.4.1
|
on-finished: 2.4.1
|
||||||
|
|
@ -3228,7 +3210,7 @@ snapshots:
|
||||||
|
|
||||||
router@2.2.0:
|
router@2.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.0
|
debug: 4.4.1
|
||||||
depd: 2.0.0
|
depd: 2.0.0
|
||||||
is-promise: 4.0.0
|
is-promise: 4.0.0
|
||||||
parseurl: 1.3.3
|
parseurl: 1.3.3
|
||||||
|
|
@ -3255,13 +3237,11 @@ snapshots:
|
||||||
|
|
||||||
semver@6.3.1: {}
|
semver@6.3.1: {}
|
||||||
|
|
||||||
semver@7.6.3: {}
|
|
||||||
|
|
||||||
semver@7.7.2: {}
|
semver@7.7.2: {}
|
||||||
|
|
||||||
send@1.2.0:
|
send@1.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.0
|
debug: 4.4.1
|
||||||
encodeurl: 2.0.0
|
encodeurl: 2.0.0
|
||||||
escape-html: 1.0.3
|
escape-html: 1.0.3
|
||||||
etag: 1.8.1
|
etag: 1.8.1
|
||||||
|
|
@ -3331,7 +3311,7 @@ snapshots:
|
||||||
|
|
||||||
simple-update-notifier@2.0.0:
|
simple-update-notifier@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
semver: 7.6.3
|
semver: 7.7.2
|
||||||
|
|
||||||
slice-ansi@3.0.0:
|
slice-ansi@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
||||||
11
devenv.nix
11
devenv.nix
|
|
@ -23,9 +23,7 @@ in {
|
||||||
python3Packages.pip
|
python3Packages.pip
|
||||||
python3Packages.fonttools
|
python3Packages.fonttools
|
||||||
python3Packages.brotli
|
python3Packages.brotli
|
||||||
] ++ lib.optionals (!pkgs.stdenv.isDarwin) [
|
nodejs
|
||||||
# Frontend tools (exclude on Darwin)
|
|
||||||
pkgs-unstable.cypress
|
|
||||||
];
|
];
|
||||||
|
|
||||||
languages = {
|
languages = {
|
||||||
|
|
@ -49,6 +47,13 @@ in {
|
||||||
enable = true;
|
enable = true;
|
||||||
package = pkgs-unstable.mailpit;
|
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 = {
|
devcontainer = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ coverage
|
||||||
# Test files
|
# Test files
|
||||||
cypress/screenshots
|
cypress/screenshots
|
||||||
cypress/videos
|
cypress/videos
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
.env.local
|
.env.local
|
||||||
|
|
@ -41,4 +43,4 @@ cypress/videos
|
||||||
|
|
||||||
# histoire
|
# histoire
|
||||||
.histoire
|
.histoire
|
||||||
TYPECHECK_ISSUES.md
|
package-lock.json
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
|
||||||
|
|
||||||
describe('The Menu', () => {
|
|
||||||
createFakeUserAndLogin()
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.visit('/')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Is visible by default on desktop', () => {
|
|
||||||
cy.get('.menu-container')
|
|
||||||
.should('have.class', 'is-active')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Can be hidden on desktop', () => {
|
|
||||||
cy.get('button.menu-show-button:visible')
|
|
||||||
.click()
|
|
||||||
cy.get('.menu-container')
|
|
||||||
.should('not.have.class', 'is-active')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Is hidden by default on mobile', () => {
|
|
||||||
cy.viewport('iphone-8')
|
|
||||||
cy.get('.menu-container')
|
|
||||||
.should('not.have.class', 'is-active')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Is can be shown on mobile', () => {
|
|
||||||
cy.viewport('iphone-8')
|
|
||||||
cy.get('button.menu-show-button:visible')
|
|
||||||
.click()
|
|
||||||
cy.get('.menu-container')
|
|
||||||
.should('have.class', 'is-active')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
|
||||||
import {TaskFactory} from '../../factories/task'
|
|
||||||
import {ProjectFactory} from '../../factories/project'
|
|
||||||
import { createProjects } from './prepareProjects'
|
|
||||||
|
|
||||||
describe('Filter Persistence Across Views', () => {
|
|
||||||
createFakeUserAndLogin()
|
|
||||||
|
|
||||||
const openAndSetFilters = () => {
|
|
||||||
cy.get('.filter-container button')
|
|
||||||
.contains('Filters')
|
|
||||||
.click()
|
|
||||||
cy.get('.filter-popup')
|
|
||||||
.should('be.visible')
|
|
||||||
cy.get('.filter-popup .filter-input')
|
|
||||||
.type('done = true')
|
|
||||||
cy.get('.filter-popup button')
|
|
||||||
.contains('Show results')
|
|
||||||
.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
createProjects()
|
|
||||||
TaskFactory.create(5, {
|
|
||||||
id: '{increment}',
|
|
||||||
project_id: 1,
|
|
||||||
title: 'Test Task {increment}'
|
|
||||||
})
|
|
||||||
cy.visit('/projects/1/1')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should persist filters in List view after page refresh', () => {
|
|
||||||
openAndSetFilters()
|
|
||||||
|
|
||||||
cy.url().should('include', 'filter=')
|
|
||||||
|
|
||||||
cy.reload()
|
|
||||||
|
|
||||||
cy.url().should('include', 'filter=')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should persist filters in Table view after page refresh', () => {
|
|
||||||
cy.visit('/projects/1/3')
|
|
||||||
|
|
||||||
openAndSetFilters()
|
|
||||||
|
|
||||||
cy.url().should('include', 'filter=')
|
|
||||||
|
|
||||||
cy.reload()
|
|
||||||
|
|
||||||
cy.url().should('include', 'filter=')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should persist filters in Kanban view after page refresh', () => {
|
|
||||||
cy.visit('/projects/1/4')
|
|
||||||
|
|
||||||
openAndSetFilters()
|
|
||||||
|
|
||||||
cy.url().should('include', 'filter=')
|
|
||||||
|
|
||||||
cy.reload()
|
|
||||||
|
|
||||||
cy.url().should('include', 'filter=')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle URL sharing with filters', () => {
|
|
||||||
// Visit URL with pre-existing filter parameters
|
|
||||||
cy.visit('/projects/1/4?filter=done%3Dtrue&s=Test')
|
|
||||||
|
|
||||||
// Verify URL parameters are preserved
|
|
||||||
cy.url().should('include', 'filter=done%3Dtrue')
|
|
||||||
cy.url().should('include', 's=Test')
|
|
||||||
|
|
||||||
// Switch views and verify parameters persist
|
|
||||||
cy.visit('/projects/1/3?filter=done%3Dtrue&s=Test')
|
|
||||||
cy.url().should('include', 'filter=done%3Dtrue')
|
|
||||||
cy.url().should('include', 's=Test')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
|
||||||
|
|
||||||
import {ProjectFactory} from '../../factories/project'
|
|
||||||
import {prepareProjects} from './prepareProjects'
|
|
||||||
import {ProjectViewFactory} from '../../factories/project_view'
|
|
||||||
|
|
||||||
describe('Project History', () => {
|
|
||||||
createFakeUserAndLogin()
|
|
||||||
prepareProjects()
|
|
||||||
|
|
||||||
it('should show a project history on the home page', () => {
|
|
||||||
cy.intercept(Cypress.env('API_URL') + '/projects*').as('loadProjectArray')
|
|
||||||
cy.intercept(Cypress.env('API_URL') + '/projects/*').as('loadProject')
|
|
||||||
|
|
||||||
const projects = ProjectFactory.create(7)
|
|
||||||
ProjectViewFactory.truncate()
|
|
||||||
projects.forEach(p => ProjectViewFactory.create(1, {
|
|
||||||
id: p.id,
|
|
||||||
project_id: p.id,
|
|
||||||
}, false))
|
|
||||||
|
|
||||||
cy.visit('/')
|
|
||||||
cy.wait('@loadProjectArray')
|
|
||||||
cy.get('body')
|
|
||||||
.should('not.contain', 'Last viewed')
|
|
||||||
|
|
||||||
cy.visit(`/projects/${projects[0].id}/${projects[0].id}`)
|
|
||||||
cy.wait('@loadProject')
|
|
||||||
cy.visit(`/projects/${projects[1].id}/${projects[1].id}`)
|
|
||||||
cy.wait('@loadProject')
|
|
||||||
cy.visit(`/projects/${projects[2].id}/${projects[2].id}`)
|
|
||||||
cy.wait('@loadProject')
|
|
||||||
cy.visit(`/projects/${projects[3].id}/${projects[3].id}`)
|
|
||||||
cy.wait('@loadProject')
|
|
||||||
cy.visit(`/projects/${projects[4].id}/${projects[4].id}`)
|
|
||||||
cy.wait('@loadProject')
|
|
||||||
cy.visit(`/projects/${projects[5].id}/${projects[5].id}`)
|
|
||||||
cy.wait('@loadProject')
|
|
||||||
cy.visit(`/projects/${projects[6].id}/${projects[6].id}`)
|
|
||||||
cy.wait('@loadProject')
|
|
||||||
|
|
||||||
// cy.visit('/')
|
|
||||||
// Not using cy.visit here to work around the redirect issue fixed in #1337
|
|
||||||
cy.get('nav.menu.top-menu a')
|
|
||||||
.contains('Overview')
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.get('body')
|
|
||||||
.should('contain', 'Last viewed')
|
|
||||||
cy.get('[data-cy="projectCardGrid"]')
|
|
||||||
.should('not.contain', projects[0].title)
|
|
||||||
.should('contain', projects[1].title)
|
|
||||||
.should('contain', projects[2].title)
|
|
||||||
.should('contain', projects[3].title)
|
|
||||||
.should('contain', projects[4].title)
|
|
||||||
.should('contain', projects[5].title)
|
|
||||||
.should('contain', projects[6].title)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
import dayjs from 'dayjs'
|
|
||||||
|
|
||||||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
|
||||||
|
|
||||||
import {TaskFactory} from '../../factories/task'
|
|
||||||
import {prepareProjects} from './prepareProjects'
|
|
||||||
|
|
||||||
describe('Project View Gantt', () => {
|
|
||||||
createFakeUserAndLogin()
|
|
||||||
prepareProjects()
|
|
||||||
|
|
||||||
it('Hides tasks with no dates', () => {
|
|
||||||
const tasks = TaskFactory.create(1)
|
|
||||||
cy.visit('/projects/1/2')
|
|
||||||
|
|
||||||
cy.get('.gantt-rows')
|
|
||||||
.should('not.contain', tasks[0].title)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Shows tasks from the current and next month', () => {
|
|
||||||
const now = Date.UTC(2022, 8, 25)
|
|
||||||
cy.clock(now, ['Date'])
|
|
||||||
|
|
||||||
const nextMonth = new Date(now)
|
|
||||||
nextMonth.setDate(1)
|
|
||||||
nextMonth.setMonth(9)
|
|
||||||
|
|
||||||
cy.visit('/projects/1/2')
|
|
||||||
|
|
||||||
cy.get('.gantt-timeline-months')
|
|
||||||
.should('contain', dayjs(now).format('MMMM YYYY'))
|
|
||||||
.should('contain', dayjs(nextMonth).format('MMMM YYYY'))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Shows tasks with dates', () => {
|
|
||||||
const now = new Date()
|
|
||||||
const tasks = TaskFactory.create(1, {
|
|
||||||
start_date: now.toISOString(),
|
|
||||||
end_date: new Date(new Date(now).setDate(now.getDate() + 4)).toISOString(),
|
|
||||||
})
|
|
||||||
cy.visit('/projects/1/2')
|
|
||||||
|
|
||||||
cy.get('.gantt-rows')
|
|
||||||
.should('not.be.empty')
|
|
||||||
.should('contain', tasks[0].title)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Shows tasks with no dates after enabling them', () => {
|
|
||||||
const tasks = TaskFactory.create(1, {
|
|
||||||
start_date: null,
|
|
||||||
end_date: null,
|
|
||||||
})
|
|
||||||
cy.visit('/projects/1/2')
|
|
||||||
|
|
||||||
cy.get('.gantt-options .fancy-checkbox')
|
|
||||||
.contains('Show tasks without date')
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.get('.gantt-rows')
|
|
||||||
.should('not.be.empty')
|
|
||||||
.should('contain', tasks[0].title)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Drags a task around', () => {
|
|
||||||
cy.intercept(Cypress.env('API_URL') + '/tasks/*').as('taskUpdate')
|
|
||||||
|
|
||||||
const now = new Date()
|
|
||||||
TaskFactory.create(1, {
|
|
||||||
start_date: now.toISOString(),
|
|
||||||
end_date: new Date(new Date(now).setDate(now.getDate() + 4)).toISOString(),
|
|
||||||
})
|
|
||||||
cy.visit('/projects/1/2')
|
|
||||||
|
|
||||||
cy.get('.gantt-rows .gantt-row-bars .gantt-bar')
|
|
||||||
.first()
|
|
||||||
.then($bar => {
|
|
||||||
// Get the current position of the bar
|
|
||||||
const rect = $bar[0].getBoundingClientRect()
|
|
||||||
const startX = rect.left + rect.width / 2
|
|
||||||
const startY = rect.top + rect.height / 2
|
|
||||||
|
|
||||||
// Trigger pointer events with proper coordinates and delays
|
|
||||||
cy.wrap($bar)
|
|
||||||
.trigger('pointerdown', {
|
|
||||||
clientX: startX,
|
|
||||||
clientY: startY,
|
|
||||||
pointerId: 1,
|
|
||||||
which: 1
|
|
||||||
})
|
|
||||||
.wait(100) // Wait to ensure double-click detection doesn't interfere
|
|
||||||
.trigger('pointermove', {
|
|
||||||
clientX: startX + 10, // Small initial movement to trigger drag
|
|
||||||
clientY: startY,
|
|
||||||
pointerId: 1
|
|
||||||
})
|
|
||||||
.trigger('pointermove', {
|
|
||||||
clientX: startX + 150, // Move 150px to the right (about 5 days)
|
|
||||||
clientY: startY,
|
|
||||||
pointerId: 1
|
|
||||||
})
|
|
||||||
.trigger('pointerup', {
|
|
||||||
clientX: startX + 150,
|
|
||||||
clientY: startY,
|
|
||||||
pointerId: 1,
|
|
||||||
force: true
|
|
||||||
})
|
|
||||||
})
|
|
||||||
cy.wait('@taskUpdate')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should change the query parameters when selecting a date range', () => {
|
|
||||||
const now = Date.UTC(2022, 10, 9)
|
|
||||||
cy.clock(now, ['Date'])
|
|
||||||
|
|
||||||
cy.visit('/projects/1/2')
|
|
||||||
|
|
||||||
cy.get('.project-gantt .gantt-options .field .control input.input.form-control')
|
|
||||||
.click()
|
|
||||||
cy.get('.flatpickr-calendar .flatpickr-innerContainer .dayContainer .flatpickr-day')
|
|
||||||
.first()
|
|
||||||
.click()
|
|
||||||
cy.get('.flatpickr-calendar .flatpickr-innerContainer .dayContainer .flatpickr-day')
|
|
||||||
.last()
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.url().should('contain', 'dateFrom=2022-09-25')
|
|
||||||
cy.url().should('contain', 'dateTo=2022-11-05')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should change the date range based on date query parameters', () => {
|
|
||||||
cy.visit('/projects/1/2?dateFrom=2022-09-25&dateTo=2022-11-05')
|
|
||||||
|
|
||||||
cy.get('.gantt-timeline-months')
|
|
||||||
.should('contain', 'September 2022')
|
|
||||||
.should('contain', 'October 2022')
|
|
||||||
.should('contain', 'November 2022')
|
|
||||||
cy.get('.project-gantt .gantt-options .field .control input.input.form-control')
|
|
||||||
.should('have.value', '25 Sep 2022 to 5 Nov 2022')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should open a task when double clicked on it', () => {
|
|
||||||
const now = new Date()
|
|
||||||
const tasks = TaskFactory.create(1, {
|
|
||||||
start_date: dayjs(now).format(),
|
|
||||||
end_date: dayjs(now.setDate(now.getDate() + 4)).format(),
|
|
||||||
})
|
|
||||||
cy.visit('/projects/1/2')
|
|
||||||
|
|
||||||
cy.get('.gantt-container .gantt-row-bars .gantt-bar')
|
|
||||||
.dblclick()
|
|
||||||
|
|
||||||
cy.url()
|
|
||||||
.should('contain', `/tasks/${tasks[0].id}`)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,349 +0,0 @@
|
||||||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
|
||||||
|
|
||||||
import {BucketFactory} from '../../factories/bucket'
|
|
||||||
import {ProjectFactory} from '../../factories/project'
|
|
||||||
import {TaskFactory} from '../../factories/task'
|
|
||||||
import {prepareProjects} from './prepareProjects'
|
|
||||||
import {ProjectViewFactory} from "../../factories/project_view";
|
|
||||||
import {TaskBucketFactory} from "../../factories/task_buckets";
|
|
||||||
import {
|
|
||||||
createTasksWithPriorities,
|
|
||||||
createTasksWithSearch,
|
|
||||||
} from '../../support/filterTestHelpers'
|
|
||||||
|
|
||||||
function createSingleTaskInBucket(count = 1, attrs = {}) {
|
|
||||||
const projects = ProjectFactory.create(1)
|
|
||||||
const views = ProjectViewFactory.create(1, {
|
|
||||||
id: 1,
|
|
||||||
project_id: projects[0].id,
|
|
||||||
view_kind: 3,
|
|
||||||
bucket_configuration_mode: 1,
|
|
||||||
})
|
|
||||||
const buckets = BucketFactory.create(2, {
|
|
||||||
project_view_id: views[0].id,
|
|
||||||
})
|
|
||||||
const tasks = TaskFactory.create(count, {
|
|
||||||
project_id: projects[0].id,
|
|
||||||
...attrs,
|
|
||||||
})
|
|
||||||
TaskBucketFactory.create(1, {
|
|
||||||
task_id: tasks[0].id,
|
|
||||||
bucket_id: buckets[0].id,
|
|
||||||
project_view_id: views[0].id,
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
task: tasks[0],
|
|
||||||
view: views[0],
|
|
||||||
project: projects[0],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTaskWithBuckets(buckets, count = 1) {
|
|
||||||
const data = TaskFactory.create(count, {
|
|
||||||
project_id: 1,
|
|
||||||
})
|
|
||||||
TaskBucketFactory.truncate()
|
|
||||||
data.forEach(t => TaskBucketFactory.create(1, {
|
|
||||||
task_id: t.id,
|
|
||||||
bucket_id: buckets[0].id,
|
|
||||||
project_view_id: buckets[0].project_view_id,
|
|
||||||
}, false))
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Project View Kanban', () => {
|
|
||||||
createFakeUserAndLogin()
|
|
||||||
prepareProjects()
|
|
||||||
|
|
||||||
let buckets
|
|
||||||
beforeEach(() => {
|
|
||||||
buckets = BucketFactory.create(2, {
|
|
||||||
project_view_id: 4,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Shows all buckets with their tasks', () => {
|
|
||||||
const data = createTaskWithBuckets(buckets, 10)
|
|
||||||
cy.visit('/projects/1/4')
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket .title')
|
|
||||||
.contains(buckets[0].title)
|
|
||||||
.should('exist')
|
|
||||||
cy.get('.kanban .bucket .title')
|
|
||||||
.contains(buckets[1].title)
|
|
||||||
.should('exist')
|
|
||||||
cy.get('.kanban .bucket')
|
|
||||||
.first()
|
|
||||||
.should('contain', data[0].title)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Can add a new task to a bucket', () => {
|
|
||||||
createTaskWithBuckets(buckets, 2)
|
|
||||||
cy.visit('/projects/1/4')
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket')
|
|
||||||
.contains(buckets[0].title)
|
|
||||||
.get('.bucket-footer .button')
|
|
||||||
.contains('Add another task')
|
|
||||||
.click()
|
|
||||||
cy.get('.kanban .bucket')
|
|
||||||
.contains(buckets[0].title)
|
|
||||||
.get('.bucket-footer .field .control input.input')
|
|
||||||
.type('New Task{enter}')
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket')
|
|
||||||
.first()
|
|
||||||
.should('contain', 'New Task')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Can create a new bucket', () => {
|
|
||||||
cy.visit('/projects/1/4')
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket.new-bucket .button')
|
|
||||||
.click()
|
|
||||||
cy.get('.kanban .bucket.new-bucket input.input')
|
|
||||||
.type('New Bucket{enter}')
|
|
||||||
|
|
||||||
cy.wait(1000) // Wait for the request to finish
|
|
||||||
cy.get('.kanban .bucket .title')
|
|
||||||
.contains('New Bucket')
|
|
||||||
.should('exist')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Can set a bucket limit', () => {
|
|
||||||
cy.visit('/projects/1/4')
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
|
|
||||||
.first()
|
|
||||||
.click()
|
|
||||||
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
|
|
||||||
.contains('Limit: Not Set')
|
|
||||||
.click()
|
|
||||||
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .field input.input')
|
|
||||||
.first()
|
|
||||||
.type('3')
|
|
||||||
cy.get('[data-cy="setBucketLimit"]')
|
|
||||||
.first()
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket .bucket-header span.limit')
|
|
||||||
.contains('0/3')
|
|
||||||
.should('exist')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Can rename a bucket', () => {
|
|
||||||
cy.visit('/projects/1/4')
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket .bucket-header .title')
|
|
||||||
.first()
|
|
||||||
.type('{selectall}New Bucket Title{enter}')
|
|
||||||
cy.get('.kanban .bucket .bucket-header .title')
|
|
||||||
.first()
|
|
||||||
.should('contain', 'New Bucket Title')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Can delete a bucket', () => {
|
|
||||||
cy.visit('/projects/1/4')
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
|
|
||||||
.first()
|
|
||||||
.click()
|
|
||||||
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
|
|
||||||
.contains('Delete')
|
|
||||||
.click()
|
|
||||||
cy.get('.modal-mask .modal-container .modal-content .modal-header')
|
|
||||||
.should('contain', 'Delete the bucket')
|
|
||||||
cy.get('.modal-mask .modal-container .modal-content .actions .button')
|
|
||||||
.contains('Do it!')
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket .title')
|
|
||||||
.contains(buckets[0].title)
|
|
||||||
.should('not.exist')
|
|
||||||
cy.get('.kanban .bucket .title')
|
|
||||||
.contains(buckets[1].title)
|
|
||||||
.should('exist')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Can drag tasks around', () => {
|
|
||||||
const tasks = createTaskWithBuckets(buckets, 2)
|
|
||||||
cy.visit('/projects/1/4')
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket .tasks .task')
|
|
||||||
.contains(tasks[0].title)
|
|
||||||
.first()
|
|
||||||
.drag('.kanban .bucket:nth-child(2) .tasks')
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket:nth-child(2) .tasks')
|
|
||||||
.should('contain', tasks[0].title)
|
|
||||||
cy.get('.kanban .bucket:nth-child(1) .tasks')
|
|
||||||
.should('not.contain', tasks[0].title)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should navigate to the task when the task card is clicked', () => {
|
|
||||||
const tasks = createTaskWithBuckets(buckets, 5)
|
|
||||||
cy.visit('/projects/1/4')
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket .tasks .task')
|
|
||||||
.contains(tasks[0].title)
|
|
||||||
.should('be.visible')
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.url()
|
|
||||||
.should('contain', `/tasks/${tasks[0].id}`, {timeout: 1000})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should remove a task from the kanban board when moving it to another project', () => {
|
|
||||||
const projects = ProjectFactory.create(2)
|
|
||||||
const views = ProjectViewFactory.create(2, {
|
|
||||||
project_id: '{increment}',
|
|
||||||
view_kind: 3,
|
|
||||||
bucket_configuration_mode: 1,
|
|
||||||
})
|
|
||||||
BucketFactory.create(2)
|
|
||||||
const tasks = TaskFactory.create(5, {
|
|
||||||
id: '{increment}',
|
|
||||||
project_id: 1,
|
|
||||||
})
|
|
||||||
TaskBucketFactory.create(5, {
|
|
||||||
project_view_id: 1,
|
|
||||||
})
|
|
||||||
const task = tasks[0]
|
|
||||||
cy.visit('/projects/1/'+views[0].id)
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket .tasks .task')
|
|
||||||
.contains(task.title)
|
|
||||||
.should('be.visible')
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.get('.task-view .action-buttons .button', {timeout: 3000})
|
|
||||||
.contains('Move')
|
|
||||||
.click()
|
|
||||||
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
|
|
||||||
.type(`${projects[1].title}{enter}`)
|
|
||||||
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
|
|
||||||
// presses enter and we can't simulate pressing on enter to select the item.
|
|
||||||
cy.get('.task-view .content.details .field .multiselect.control .search-results')
|
|
||||||
.children()
|
|
||||||
.first()
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.get('.global-notification', {timeout: 1000})
|
|
||||||
.should('contain', 'Success')
|
|
||||||
cy.go('back')
|
|
||||||
cy.get('.kanban .bucket')
|
|
||||||
.should('not.contain', task.title)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Shows a button to filter the kanban board', () => {
|
|
||||||
cy.visit('/projects/1/4')
|
|
||||||
|
|
||||||
cy.get('.project-kanban .filter-container .base-button')
|
|
||||||
.should('exist')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should remove a task from the board when deleting it', () => {
|
|
||||||
const {task, view} = createSingleTaskInBucket(5)
|
|
||||||
cy.visit(`/projects/1/${view.id}`)
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket .tasks .task')
|
|
||||||
.contains(task.title)
|
|
||||||
.should('be.visible')
|
|
||||||
.click()
|
|
||||||
cy.get('.task-view .action-buttons .button')
|
|
||||||
.should('be.visible')
|
|
||||||
.contains('Delete')
|
|
||||||
.click()
|
|
||||||
cy.get('.modal-mask .modal-container .modal-content .modal-header')
|
|
||||||
.should('contain', 'Delete this task')
|
|
||||||
cy.get('.modal-mask .modal-container .modal-content .actions .button')
|
|
||||||
.contains('Do it!')
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.get('.global-notification')
|
|
||||||
.should('contain', 'Success')
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket .tasks')
|
|
||||||
.should('not.contain', task.title)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should show a task description icon if the task has a description', () => {
|
|
||||||
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
|
|
||||||
const {task, view} = createSingleTaskInBucket(1, {
|
|
||||||
description: 'Lorem Ipsum',
|
|
||||||
})
|
|
||||||
|
|
||||||
cy.visit(`/projects/${task.project_id}/${view.id}`)
|
|
||||||
cy.wait('@loadTasks')
|
|
||||||
|
|
||||||
cy.get('.bucket .tasks .task .footer .icon svg')
|
|
||||||
.should('exist')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should not show a task description icon if the task has an empty description', () => {
|
|
||||||
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
|
|
||||||
const {task, view} = createSingleTaskInBucket(1, {
|
|
||||||
description: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
cy.visit(`/projects/${task.project_id}/${view.id}`)
|
|
||||||
cy.wait('@loadTasks')
|
|
||||||
|
|
||||||
cy.get('.bucket .tasks .task .footer .icon svg')
|
|
||||||
.should('not.exist')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should not show a task description icon if the task has a description containing only an empty p tag', () => {
|
|
||||||
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
|
|
||||||
const {task, view} = createSingleTaskInBucket(1, {
|
|
||||||
description: '<p></p>',
|
|
||||||
})
|
|
||||||
|
|
||||||
cy.visit(`/projects/${task.project_id}/${view.id}`)
|
|
||||||
cy.wait('@loadTasks')
|
|
||||||
|
|
||||||
cy.get('.bucket .tasks .task .footer .icon svg')
|
|
||||||
.should('not.exist')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should respect filter query parameter from URL', () => {
|
|
||||||
const {highPriorityTasks, lowPriorityTasks} = createTasksWithPriorities(buckets)
|
|
||||||
|
|
||||||
cy.visit('/projects/1/4?filter=priority%20>=%204')
|
|
||||||
|
|
||||||
cy.url()
|
|
||||||
.should('include', 'filter=priority')
|
|
||||||
|
|
||||||
cy.contains('.kanban .bucket', highPriorityTasks[0].title, {timeout: 10000})
|
|
||||||
.should('exist')
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket')
|
|
||||||
.should('contain', highPriorityTasks[0].title)
|
|
||||||
cy.get('.kanban .bucket')
|
|
||||||
.should('contain', highPriorityTasks[1].title)
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket')
|
|
||||||
.should('not.contain', lowPriorityTasks[0].title)
|
|
||||||
cy.get('.kanban .bucket')
|
|
||||||
.should('not.contain', lowPriorityTasks[1].title)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should respect search query parameter from URL', () => {
|
|
||||||
const {searchableTask} = createTasksWithSearch(buckets)
|
|
||||||
|
|
||||||
cy.visit('/projects/1/4?s=meeting')
|
|
||||||
|
|
||||||
cy.url()
|
|
||||||
.should('include', 's=meeting')
|
|
||||||
|
|
||||||
cy.contains('.kanban .bucket', searchableTask.title, {timeout: 10000})
|
|
||||||
.should('exist')
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket')
|
|
||||||
.should('contain', searchableTask.title)
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket .tasks .task')
|
|
||||||
.should('have.length', 1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -16,188 +16,6 @@ describe('Project View List', () => {
|
||||||
createFakeUserAndLogin()
|
createFakeUserAndLogin()
|
||||||
prepareProjects()
|
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', () => {
|
it('Should respect filter query parameter from URL', () => {
|
||||||
const {highPriorityTasks, lowPriorityTasks} = createTasksWithPriorities()
|
const {highPriorityTasks, lowPriorityTasks} = createTasksWithPriorities()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
|
||||||
|
|
||||||
import {TaskFactory} from '../../factories/task'
|
|
||||||
import {prepareProjects} from './prepareProjects'
|
|
||||||
import {
|
|
||||||
createTasksWithPriorities,
|
|
||||||
createTasksWithSearch,
|
|
||||||
} from '../../support/filterTestHelpers'
|
|
||||||
|
|
||||||
describe('Project View Table', () => {
|
|
||||||
createFakeUserAndLogin()
|
|
||||||
prepareProjects()
|
|
||||||
|
|
||||||
it('Should show a table with tasks', () => {
|
|
||||||
const tasks = TaskFactory.create(1)
|
|
||||||
cy.visit('/projects/1/3')
|
|
||||||
|
|
||||||
cy.get('.project-table table.table')
|
|
||||||
.should('exist')
|
|
||||||
cy.get('.project-table table.table')
|
|
||||||
.should('contain', tasks[0].title)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should have working column switches', () => {
|
|
||||||
TaskFactory.create(1)
|
|
||||||
cy.visit('/projects/1/3')
|
|
||||||
|
|
||||||
cy.get('.project-table .filter-container .button')
|
|
||||||
.contains('Columns')
|
|
||||||
.click()
|
|
||||||
cy.get('.project-table .filter-container .card.columns-filter .card-content .fancy-checkbox')
|
|
||||||
.contains('Priority')
|
|
||||||
.click()
|
|
||||||
cy.get('.project-table .filter-container .card.columns-filter .card-content .fancy-checkbox')
|
|
||||||
.contains('Done')
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.get('.project-table table.table th')
|
|
||||||
.contains('Priority')
|
|
||||||
.should('exist')
|
|
||||||
cy.get('.project-table table.table th')
|
|
||||||
.contains('Done')
|
|
||||||
.should('not.exist')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should navigate to the task when the title is clicked', () => {
|
|
||||||
const tasks = TaskFactory.create(5, {
|
|
||||||
id: '{increment}',
|
|
||||||
project_id: 1,
|
|
||||||
})
|
|
||||||
cy.visit('/projects/1/3')
|
|
||||||
|
|
||||||
cy.get('.project-table table.table')
|
|
||||||
.contains(tasks[0].title)
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.url()
|
|
||||||
.should('contain', `/tasks/${tasks[0].id}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should respect filter query parameter from URL', () => {
|
|
||||||
const {highPriorityTasks, lowPriorityTasks} = createTasksWithPriorities()
|
|
||||||
|
|
||||||
cy.visit('/projects/1/3?filter=priority%20>=%204')
|
|
||||||
|
|
||||||
cy.url()
|
|
||||||
.should('include', 'filter=priority')
|
|
||||||
|
|
||||||
cy.contains('.project-table table.table', highPriorityTasks[0].title, {timeout: 10000})
|
|
||||||
.should('exist')
|
|
||||||
|
|
||||||
cy.get('.project-table table.table')
|
|
||||||
.should('contain', highPriorityTasks[0].title)
|
|
||||||
cy.get('.project-table table.table')
|
|
||||||
.should('contain', highPriorityTasks[1].title)
|
|
||||||
|
|
||||||
cy.get('.project-table table.table')
|
|
||||||
.should('not.contain', lowPriorityTasks[0].title)
|
|
||||||
cy.get('.project-table table.table')
|
|
||||||
.should('not.contain', lowPriorityTasks[1].title)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should respect search query parameter from URL', () => {
|
|
||||||
const {searchableTask} = createTasksWithSearch()
|
|
||||||
|
|
||||||
cy.visit('/projects/1/3?s=meeting')
|
|
||||||
|
|
||||||
cy.url()
|
|
||||||
.should('include', 's=meeting')
|
|
||||||
|
|
||||||
cy.contains('.project-table table.table', searchableTask.title, {timeout: 10000})
|
|
||||||
.should('exist')
|
|
||||||
|
|
||||||
cy.get('.project-table table.table')
|
|
||||||
.should('contain', searchableTask.title)
|
|
||||||
|
|
||||||
cy.get('.project-table table.table tbody tr')
|
|
||||||
.should('have.length', 1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
|
||||||
|
|
||||||
import {TaskFactory} from '../../factories/task'
|
|
||||||
import {ProjectFactory} from '../../factories/project'
|
|
||||||
import {prepareProjects} from './prepareProjects'
|
|
||||||
|
|
||||||
describe('Projects', () => {
|
|
||||||
createFakeUserAndLogin()
|
|
||||||
|
|
||||||
let projects
|
|
||||||
prepareProjects((newProjects) => (projects = newProjects))
|
|
||||||
|
|
||||||
it('Should create a new project', () => {
|
|
||||||
cy.visit('/projects')
|
|
||||||
cy.get('.project-header [data-cy=new-project]')
|
|
||||||
.click()
|
|
||||||
cy.url()
|
|
||||||
.should('contain', '/projects/new')
|
|
||||||
cy.get('.card-header-title')
|
|
||||||
.contains('New project')
|
|
||||||
cy.get('input[name=projectTitle]')
|
|
||||||
.type('New Project')
|
|
||||||
cy.get('.button')
|
|
||||||
.contains('Create')
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.get('.global-notification', {timeout: 1000}) // Waiting until the request to create the new project is done
|
|
||||||
.should('contain', 'Success')
|
|
||||||
cy.url()
|
|
||||||
.should('contain', '/projects/')
|
|
||||||
cy.get('.project-title')
|
|
||||||
.should('contain', 'New Project')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should redirect to a specific project view after visited', () => {
|
|
||||||
cy.intercept(Cypress.env('API_URL') + '/projects/*/views/*/tasks**').as('loadBuckets')
|
|
||||||
cy.visit('/projects/1/4')
|
|
||||||
cy.url()
|
|
||||||
.should('contain', '/projects/1/4')
|
|
||||||
cy.wait('@loadBuckets')
|
|
||||||
cy.visit('/projects/1')
|
|
||||||
cy.url()
|
|
||||||
.should('contain', '/projects/1/4')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should rename the project in all places', () => {
|
|
||||||
TaskFactory.create(5, {
|
|
||||||
id: '{increment}',
|
|
||||||
project_id: 1,
|
|
||||||
})
|
|
||||||
const newProjectName = 'New project name'
|
|
||||||
|
|
||||||
cy.visit('/projects/1')
|
|
||||||
cy.get('.project-title')
|
|
||||||
.should('contain', 'First Project')
|
|
||||||
|
|
||||||
cy.get('.menu-container .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
|
|
||||||
.click()
|
|
||||||
cy.get('.menu-container .menu-list li:first-child .dropdown .dropdown-content')
|
|
||||||
.contains('Edit')
|
|
||||||
.click()
|
|
||||||
cy.get('#title:not(:disabled)')
|
|
||||||
.type(`{selectall}${newProjectName}`)
|
|
||||||
cy.get('footer.card-footer .button')
|
|
||||||
.contains('Save')
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.get('.global-notification')
|
|
||||||
.should('contain', 'Success')
|
|
||||||
cy.get('.project-title')
|
|
||||||
.should('contain', newProjectName)
|
|
||||||
.should('not.contain', projects[0].title)
|
|
||||||
cy.get('.menu-container .menu-list li:first-child')
|
|
||||||
.should('contain', newProjectName)
|
|
||||||
.should('not.contain', projects[0].title)
|
|
||||||
cy.visit('/')
|
|
||||||
cy.get('.project-grid')
|
|
||||||
.should('contain', newProjectName)
|
|
||||||
.should('not.contain', projects[0].title)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should remove a project when deleting it', () => {
|
|
||||||
cy.visit(`/projects/${projects[0].id}`)
|
|
||||||
|
|
||||||
cy.get('.menu-container .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
|
|
||||||
.click()
|
|
||||||
cy.get('.menu-container .menu-list li:first-child .dropdown .dropdown-content')
|
|
||||||
.contains('Delete')
|
|
||||||
.click()
|
|
||||||
cy.url()
|
|
||||||
.should('contain', '/settings/delete')
|
|
||||||
cy.get('[data-cy="modalPrimary"]')
|
|
||||||
.contains('Do it')
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.get('.global-notification')
|
|
||||||
.should('contain', 'Success')
|
|
||||||
cy.get('.menu-container .menu-list')
|
|
||||||
.should('not.contain', projects[0].title)
|
|
||||||
cy.location('pathname')
|
|
||||||
.should('equal', '/')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should archive a project', () => {
|
|
||||||
cy.visit(`/projects/${projects[0].id}`)
|
|
||||||
|
|
||||||
cy.get('.project-title-dropdown')
|
|
||||||
.click()
|
|
||||||
cy.get('.project-title-dropdown .dropdown-menu .dropdown-item')
|
|
||||||
.contains('Archive')
|
|
||||||
.click()
|
|
||||||
cy.get('.modal-content')
|
|
||||||
.should('contain.text', 'Archive this project')
|
|
||||||
cy.get('.modal-content [data-cy=modalPrimary]')
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.get('.menu-container .menu-list')
|
|
||||||
.should('not.contain', projects[0].title)
|
|
||||||
cy.get('main.app-content')
|
|
||||||
.should('contain.text', 'This project is archived. It is not possible to create new or edit tasks for it.')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should show all projects on the projects page', () => {
|
|
||||||
const projects = ProjectFactory.create(10)
|
|
||||||
|
|
||||||
cy.visit('/projects')
|
|
||||||
|
|
||||||
projects.forEach(p => {
|
|
||||||
cy.get('[data-cy="projects-list"]')
|
|
||||||
.should('contain', p.title)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should not show archived projects if the filter is not checked', () => {
|
|
||||||
ProjectFactory.create(1, {
|
|
||||||
id: 2,
|
|
||||||
}, false)
|
|
||||||
ProjectFactory.create(1, {
|
|
||||||
id: 3,
|
|
||||||
is_archived: true,
|
|
||||||
}, false)
|
|
||||||
|
|
||||||
// Initial
|
|
||||||
cy.visit('/projects')
|
|
||||||
cy.get('.project-grid')
|
|
||||||
.should('not.contain', 'Archived')
|
|
||||||
|
|
||||||
// Show archived
|
|
||||||
cy.get('[data-cy="show-archived-check"] label span')
|
|
||||||
.should('be.visible')
|
|
||||||
.click()
|
|
||||||
cy.get('[data-cy="show-archived-check"] input')
|
|
||||||
.should('be.checked')
|
|
||||||
cy.get('.project-grid')
|
|
||||||
.should('contain', 'Archived')
|
|
||||||
|
|
||||||
// Don't show archived
|
|
||||||
cy.get('[data-cy="show-archived-check"] label span')
|
|
||||||
.should('be.visible')
|
|
||||||
.click()
|
|
||||||
cy.get('[data-cy="show-archived-check"] input')
|
|
||||||
.should('not.be.checked')
|
|
||||||
|
|
||||||
// Second time visiting after unchecking
|
|
||||||
cy.visit('/projects')
|
|
||||||
cy.get('[data-cy="show-archived-check"] input')
|
|
||||||
.should('not.be.checked')
|
|
||||||
cy.get('.project-grid')
|
|
||||||
.should('not.contain', 'Archived')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
import {LinkShareFactory} from '../../factories/link_sharing'
|
|
||||||
import {TaskFactory} from '../../factories/task'
|
|
||||||
import {UserFactory} from '../../factories/user'
|
|
||||||
import {createProjects} from '../project/prepareProjects'
|
|
||||||
|
|
||||||
function prepareLinkShare() {
|
|
||||||
UserFactory.create()
|
|
||||||
const projects = createProjects()
|
|
||||||
const tasks = TaskFactory.create(10, {
|
|
||||||
project_id: projects[0].id,
|
|
||||||
})
|
|
||||||
const linkShares = LinkShareFactory.create(1, {
|
|
||||||
project_id: projects[0].id,
|
|
||||||
permission: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
share: linkShares[0],
|
|
||||||
project: projects[0],
|
|
||||||
tasks,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Link shares', () => {
|
|
||||||
it('Can view a link share', () => {
|
|
||||||
const {share, project, tasks} = prepareLinkShare()
|
|
||||||
|
|
||||||
cy.visit(`/share/${share.hash}/auth`)
|
|
||||||
|
|
||||||
cy.get('h1.title')
|
|
||||||
.should('contain', project.title)
|
|
||||||
cy.get('input.input[placeholder="Add a task…"]')
|
|
||||||
.should('not.exist')
|
|
||||||
cy.get('.tasks')
|
|
||||||
.should('contain', tasks[0].title)
|
|
||||||
|
|
||||||
cy.url().should('contain', `/projects/${project.id}/1#share-auth-token=${share.hash}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should work when directly viewing a project with share hash present', () => {
|
|
||||||
const {share, project, tasks} = prepareLinkShare()
|
|
||||||
|
|
||||||
cy.visit(`/projects/${project.id}/1#share-auth-token=${share.hash}`)
|
|
||||||
|
|
||||||
cy.get('h1.title')
|
|
||||||
.should('contain', project.title)
|
|
||||||
cy.get('input.input[placeholder="Add a task…"]')
|
|
||||||
.should('not.exist')
|
|
||||||
cy.get('.tasks')
|
|
||||||
.should('contain', tasks[0].title)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should work when directly viewing a task with share hash present', () => {
|
|
||||||
const {share, project, tasks} = prepareLinkShare()
|
|
||||||
|
|
||||||
cy.visit(`/tasks/${tasks[0].id}#share-auth-token=${share.hash}`)
|
|
||||||
|
|
||||||
cy.get('h1.title')
|
|
||||||
.should('contain', tasks[0].title)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
|
||||||
|
|
||||||
import {TeamFactory} from '../../factories/team'
|
|
||||||
import {TeamMemberFactory} from '../../factories/team_member'
|
|
||||||
import {UserFactory} from '../../factories/user'
|
|
||||||
|
|
||||||
describe('Team', () => {
|
|
||||||
createFakeUserAndLogin()
|
|
||||||
|
|
||||||
it('Creates a new team', () => {
|
|
||||||
TeamFactory.truncate()
|
|
||||||
cy.visit('/teams')
|
|
||||||
|
|
||||||
const newTeamName = 'New Team'
|
|
||||||
|
|
||||||
cy.get('a.button')
|
|
||||||
.contains('Create a team')
|
|
||||||
.click()
|
|
||||||
cy.url()
|
|
||||||
.should('contain', '/teams/new')
|
|
||||||
cy.get('.card-header-title')
|
|
||||||
.contains('Create a team')
|
|
||||||
cy.get('input.input')
|
|
||||||
.type(newTeamName)
|
|
||||||
cy.get('.button')
|
|
||||||
.contains('Create')
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.url()
|
|
||||||
.should('contain', '/edit')
|
|
||||||
cy.get('input#teamtext')
|
|
||||||
.should('have.value', newTeamName)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Shows all teams', () => {
|
|
||||||
TeamMemberFactory.create(10, {
|
|
||||||
team_id: '{increment}',
|
|
||||||
})
|
|
||||||
const teams = TeamFactory.create(10, {
|
|
||||||
id: '{increment}',
|
|
||||||
})
|
|
||||||
|
|
||||||
cy.visit('/teams')
|
|
||||||
|
|
||||||
cy.get('.teams.box')
|
|
||||||
.should('not.be.empty')
|
|
||||||
teams.forEach(t => {
|
|
||||||
cy.get('.teams.box')
|
|
||||||
.should('contain', t.name)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Allows an admin to edit the team', () => {
|
|
||||||
TeamMemberFactory.create(1, {
|
|
||||||
team_id: 1,
|
|
||||||
admin: true,
|
|
||||||
})
|
|
||||||
const teams = TeamFactory.create(1, {
|
|
||||||
id: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
cy.visit('/teams/1/edit')
|
|
||||||
cy.get('.card input.input')
|
|
||||||
.first()
|
|
||||||
.type('{selectall}New Team Name')
|
|
||||||
|
|
||||||
cy.get('.card .button')
|
|
||||||
.contains('Save')
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.get('table.table td')
|
|
||||||
.contains('Admin')
|
|
||||||
.should('exist')
|
|
||||||
cy.get('.global-notification')
|
|
||||||
.should('contain', 'Success')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Does not allow a normal user to edit the team', () => {
|
|
||||||
TeamMemberFactory.create(1, {
|
|
||||||
team_id: 1,
|
|
||||||
admin: false,
|
|
||||||
})
|
|
||||||
const teams = TeamFactory.create(1, {
|
|
||||||
id: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
cy.visit('/teams/1/edit')
|
|
||||||
cy.get('.card input.input')
|
|
||||||
.should('not.exist')
|
|
||||||
cy.get('table.table td')
|
|
||||||
.contains('Member')
|
|
||||||
.should('exist')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Allows an admin to add members to the team', () => {
|
|
||||||
TeamMemberFactory.create(1, {
|
|
||||||
team_id: 1,
|
|
||||||
admin: true,
|
|
||||||
})
|
|
||||||
TeamFactory.create(1, {
|
|
||||||
id: 1,
|
|
||||||
})
|
|
||||||
const users = UserFactory.create(5)
|
|
||||||
|
|
||||||
cy.visit('/teams/1/edit')
|
|
||||||
cy.get('.card')
|
|
||||||
.contains('Team Members')
|
|
||||||
.get('.card-content .multiselect .input-wrapper input')
|
|
||||||
.type(users[1].username)
|
|
||||||
cy.get('.card')
|
|
||||||
.contains('Team Members')
|
|
||||||
.get('.card-content .multiselect .search-results')
|
|
||||||
.children()
|
|
||||||
.first()
|
|
||||||
.click()
|
|
||||||
cy.get('.card')
|
|
||||||
.contains('Team Members')
|
|
||||||
.get('.card-content .button')
|
|
||||||
.contains('Add to team')
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.get('table.table td')
|
|
||||||
.contains('Admin')
|
|
||||||
.should('exist')
|
|
||||||
cy.get('table.table tr')
|
|
||||||
.should('contain', users[1].username)
|
|
||||||
.should('contain', 'Member')
|
|
||||||
cy.get('.global-notification')
|
|
||||||
.should('contain', 'Success')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
|
||||||
import {ProjectFactory} from '../../factories/project'
|
|
||||||
import {TaskFactory} from '../../factories/task'
|
|
||||||
import {TaskCommentFactory} from '../../factories/task_comment'
|
|
||||||
import {createDefaultViews} from '../project/prepareProjects'
|
|
||||||
|
|
||||||
describe('Task comment pagination', () => {
|
|
||||||
createFakeUserAndLogin()
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
ProjectFactory.create(1)
|
|
||||||
createDefaultViews(1)
|
|
||||||
TaskFactory.create(1, {id: 1})
|
|
||||||
TaskCommentFactory.truncate()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows pagination when more comments than configured page size', () => {
|
|
||||||
cy.request(`${Cypress.env('API_URL')}/info`).then((response) => {
|
|
||||||
const pageSize = response.body.max_items_per_page
|
|
||||||
TaskCommentFactory.create(pageSize + 10)
|
|
||||||
cy.visit('/tasks/1')
|
|
||||||
cy.get('.task-view .comments nav.pagination').should('exist')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hides pagination when comments equal or fewer than configured page size', () => {
|
|
||||||
cy.request(`${Cypress.env('API_URL')}/info`).then((response) => {
|
|
||||||
const pageSize = response.body.max_items_per_page
|
|
||||||
TaskCommentFactory.create(Math.max(1, pageSize - 10))
|
|
||||||
cy.visit('/tasks/1')
|
|
||||||
cy.get('.task-view .comments nav.pagination').should('not.exist')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -37,30 +37,6 @@ function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
|
||||||
describe('Home Page Task Overview', () => {
|
describe('Home Page Task Overview', () => {
|
||||||
createFakeUserAndLogin()
|
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', () => {
|
it('Should show a new task with a very soon due date at the top', () => {
|
||||||
const {tasks} = seedTasks(49)
|
const {tasks} = seedTasks(49)
|
||||||
const newTaskTitle = 'New Task'
|
const newTaskTitle = 'New Task'
|
||||||
|
|
@ -130,22 +106,4 @@ describe('Home Page Task Overview', () => {
|
||||||
.should('contain.text', newTaskTitle)
|
.should('contain.text', newTaskTitle)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should show the cta buttons for new project when there are no tasks', () => {
|
|
||||||
TaskFactory.truncate()
|
|
||||||
|
|
||||||
cy.visit('/')
|
|
||||||
|
|
||||||
cy.get('.home.app-content .content')
|
|
||||||
.should('contain.text', 'Import your projects and tasks from other services into Vikunja:')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should not show the cta buttons for new project when there are tasks', () => {
|
|
||||||
seedTasks()
|
|
||||||
|
|
||||||
cy.visit('/')
|
|
||||||
|
|
||||||
cy.get('.home.app-content .content')
|
|
||||||
.should('not.contain.text', 'You can create a new project for your new tasks:')
|
|
||||||
.should('not.contain.text', 'Or import your projects and tasks from other services into Vikunja:')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
|
||||||
import {ProjectFactory} from '../../factories/project'
|
|
||||||
import {TaskFactory} from '../../factories/task'
|
|
||||||
import {ProjectViewFactory} from '../../factories/project_view'
|
|
||||||
import {TaskRelationFactory} from '../../factories/task_relation'
|
|
||||||
|
|
||||||
function createViews(projectId: number, projectViewId: number) {
|
|
||||||
return ProjectViewFactory.create(1, {
|
|
||||||
id: projectViewId,
|
|
||||||
project_id: projectId,
|
|
||||||
view_kind: 0,
|
|
||||||
}, false)[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Subtask duplicate handling', () => {
|
|
||||||
createFakeUserAndLogin()
|
|
||||||
|
|
||||||
let projectA
|
|
||||||
let projectB
|
|
||||||
let parentA
|
|
||||||
let parentB
|
|
||||||
let subtask
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
ProjectFactory.truncate()
|
|
||||||
ProjectViewFactory.truncate()
|
|
||||||
TaskFactory.truncate()
|
|
||||||
TaskRelationFactory.truncate()
|
|
||||||
|
|
||||||
projectA = ProjectFactory.create(1, {id: 1, title: 'Project A'})[0]
|
|
||||||
createViews(projectA.id, 1)
|
|
||||||
projectB = ProjectFactory.create(1, {id: 2, title: 'Project B'}, false)[0]
|
|
||||||
createViews(projectB.id, 2)
|
|
||||||
|
|
||||||
parentA = TaskFactory.create(1, {id: 10, title: 'Parent A', project_id: projectA.id}, false)[0]
|
|
||||||
parentB = TaskFactory.create(1, {id: 11, title: 'Parent B', project_id: projectB.id}, false)[0]
|
|
||||||
subtask = TaskFactory.create(1, {id: 12, title: 'Shared subtask', project_id: projectA.id}, false)[0]
|
|
||||||
|
|
||||||
cy.request({
|
|
||||||
method: 'PUT',
|
|
||||||
url: `${Cypress.env('API_URL')}/tasks/${parentA.id}/relations`,
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${window.localStorage.getItem('token')}`,
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
other_task_id: subtask.id,
|
|
||||||
relation_kind: 'subtask',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
cy.request({
|
|
||||||
method: 'PUT',
|
|
||||||
url: `${Cypress.env('API_URL')}/tasks/${parentB.id}/relations`,
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${window.localStorage.getItem('token')}`,
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
other_task_id: subtask.id,
|
|
||||||
relation_kind: 'subtask',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows subtask only once in each project list', () => {
|
|
||||||
cy.visit(`/projects/${projectA.id}/1`)
|
|
||||||
cy.get('.subtask-nested .task-link').contains(subtask.title).should('exist')
|
|
||||||
cy.get('.tasks .task-link').contains(subtask.title).should('have.length', 1)
|
|
||||||
|
|
||||||
cy.visit(`/projects/${projectB.id}/1`)
|
|
||||||
cy.get('.subtask-nested .task-link').contains(subtask.title).should('exist')
|
|
||||||
cy.get('.tasks .task-link').contains(subtask.title).should('have.length', 1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,149 +0,0 @@
|
||||||
import {UserFactory} from '../../factories/user'
|
|
||||||
import {TokenFactory} from '../../factories/token'
|
|
||||||
|
|
||||||
context('Email Confirmation', () => {
|
|
||||||
let user
|
|
||||||
let confirmationToken
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
UserFactory.truncate()
|
|
||||||
TokenFactory.truncate()
|
|
||||||
|
|
||||||
// Create a user with status = 1 (StatusEmailConfirmationRequired)
|
|
||||||
user = UserFactory.create(1, {
|
|
||||||
username: 'unconfirmeduser',
|
|
||||||
email: 'unconfirmed@example.com',
|
|
||||||
password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.', // 1234
|
|
||||||
status: 1, // StatusEmailConfirmationRequired
|
|
||||||
})[0]
|
|
||||||
|
|
||||||
// Create an email confirmation token for this user
|
|
||||||
// kind: 2 = TokenEmailConfirm
|
|
||||||
confirmationToken = 'test-email-confirm-token-12345678901234567890123456789012'
|
|
||||||
TokenFactory.create(1, {
|
|
||||||
user_id: user.id,
|
|
||||||
kind: 2,
|
|
||||||
token: confirmationToken,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should fail login before email is confirmed', () => {
|
|
||||||
cy.visit('/login')
|
|
||||||
cy.get('input[id=username]').type(user.username)
|
|
||||||
cy.get('input[id=password]').type('1234')
|
|
||||||
cy.get('.button').contains('Login').click()
|
|
||||||
|
|
||||||
cy.get('div.message.danger').contains('Email address of the user not confirmed')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should confirm email and allow login', () => {
|
|
||||||
// Intercept the confirmation API call
|
|
||||||
cy.intercept('POST', '**/user/confirm').as('confirmEmail')
|
|
||||||
|
|
||||||
// Manually set the token in localStorage before visiting the page
|
|
||||||
// This simulates what happens when the user clicks the email link
|
|
||||||
cy.visit('/login', {
|
|
||||||
onBeforeLoad(win) {
|
|
||||||
win.localStorage.setItem('emailConfirmToken', confirmationToken)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wait for the confirmation API call to complete
|
|
||||||
cy.wait('@confirmEmail', {timeout: 10000}).its('response.statusCode').should('eq', 200)
|
|
||||||
|
|
||||||
// Should show success message
|
|
||||||
cy.get('.message.success', {timeout: 10000}).should('be.visible')
|
|
||||||
cy.get('.message.success').contains('You successfully confirmed your email')
|
|
||||||
|
|
||||||
// Now login should work
|
|
||||||
cy.get('input[id=username]').type(user.username)
|
|
||||||
cy.get('input[id=password]').type('1234')
|
|
||||||
cy.get('.button').contains('Login').click()
|
|
||||||
|
|
||||||
// Should successfully log in
|
|
||||||
cy.url().should('include', '/')
|
|
||||||
cy.url().should('not.include', '/login')
|
|
||||||
// Check that the username appears in the greeting
|
|
||||||
cy.contains(user.username)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should fail with invalid confirmation token', () => {
|
|
||||||
// Intercept the confirmation API call
|
|
||||||
cy.intercept('POST', '**/user/confirm').as('confirmEmail')
|
|
||||||
|
|
||||||
// Try to confirm with an invalid token
|
|
||||||
const invalidToken = 'invalid-token-that-does-not-exist-in-database'
|
|
||||||
cy.visit('/login', {
|
|
||||||
onBeforeLoad(win) {
|
|
||||||
win.localStorage.setItem('emailConfirmToken', invalidToken)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wait for the confirmation API call to fail
|
|
||||||
cy.wait('@confirmEmail', {timeout: 10000})
|
|
||||||
|
|
||||||
// Should show error message
|
|
||||||
cy.get('.message.danger', {timeout: 10000}).should('be.visible')
|
|
||||||
|
|
||||||
// Login should still fail
|
|
||||||
cy.get('input[id=username]').type(user.username)
|
|
||||||
cy.get('input[id=password]').type('1234')
|
|
||||||
cy.get('.button').contains('Login').click()
|
|
||||||
|
|
||||||
cy.get('div.message.danger').contains('Email address of the user not confirmed')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should not allow using the same token twice', () => {
|
|
||||||
// Intercept the confirmation API call
|
|
||||||
cy.intercept('POST', '**/user/confirm').as('confirmEmail')
|
|
||||||
|
|
||||||
// First confirmation - should work
|
|
||||||
cy.visit('/login', {
|
|
||||||
onBeforeLoad(win) {
|
|
||||||
win.localStorage.setItem('emailConfirmToken', confirmationToken)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
cy.wait('@confirmEmail', {timeout: 10000}).its('response.statusCode').should('eq', 200)
|
|
||||||
cy.get('.message.success', {timeout: 10000}).should('be.visible')
|
|
||||||
cy.get('.message.success').contains('You successfully confirmed your email')
|
|
||||||
|
|
||||||
// Try to use the same token again - should fail
|
|
||||||
cy.visit('/login', {
|
|
||||||
onBeforeLoad(win) {
|
|
||||||
win.localStorage.setItem('emailConfirmToken', confirmationToken)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
cy.wait('@confirmEmail', {timeout: 10000})
|
|
||||||
cy.get('.message.danger', {timeout: 10000}).should('be.visible')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should confirm email when clicking link from email (via query parameter)', () => {
|
|
||||||
// Intercept the confirmation API call
|
|
||||||
cy.intercept('POST', '**/user/confirm').as('confirmEmail')
|
|
||||||
|
|
||||||
// Simulate clicking the email confirmation link with query parameter
|
|
||||||
// This is what happens when a user clicks the link in their email
|
|
||||||
cy.visit(`/?userEmailConfirm=${confirmationToken}`)
|
|
||||||
|
|
||||||
// Should redirect to login page
|
|
||||||
cy.url().should('include', '/login')
|
|
||||||
|
|
||||||
// Wait for the confirmation API call to complete
|
|
||||||
cy.wait('@confirmEmail', {timeout: 10000}).its('response.statusCode').should('eq', 200)
|
|
||||||
|
|
||||||
// Should show success message
|
|
||||||
cy.get('.message.success', {timeout: 10000}).should('be.visible')
|
|
||||||
cy.get('.message.success').contains('You successfully confirmed your email')
|
|
||||||
|
|
||||||
// Now login should work
|
|
||||||
cy.get('input[id=username]').type(user.username)
|
|
||||||
cy.get('input[id=password]').type('1234')
|
|
||||||
cy.get('.button').contains('Login').click()
|
|
||||||
|
|
||||||
// Should successfully log in
|
|
||||||
cy.url().should('include', '/')
|
|
||||||
cy.url().should('not.include', '/login')
|
|
||||||
// Check that the username appears in the greeting
|
|
||||||
cy.contains(user.username)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -31,13 +31,6 @@ context('Login', () => {
|
||||||
UserFactory.create(1, {username: credentials.username})
|
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', () => {
|
it('Should fail with a bad password', () => {
|
||||||
const fixture = {
|
const fixture = {
|
||||||
username: 'test',
|
username: 'test',
|
||||||
|
|
@ -47,20 +40,6 @@ context('Login', () => {
|
||||||
testAndAssertFailed(fixture)
|
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', () => {
|
it('Should redirect to the previous route after logging in', () => {
|
||||||
const projects = ProjectFactory.create(1)
|
const projects = ProjectFactory.create(1)
|
||||||
cy.visit(`/projects/${projects[0].id}/1`)
|
cy.visit(`/projects/${projects[0].id}/1`)
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
|
||||||
import {createProjects} from '../project/prepareProjects'
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
cy.get('.navbar .username-dropdown-trigger')
|
|
||||||
.click()
|
|
||||||
cy.get('.navbar .dropdown-item')
|
|
||||||
.contains('Logout')
|
|
||||||
.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Log out', () => {
|
|
||||||
createFakeUserAndLogin()
|
|
||||||
|
|
||||||
it('Logs the user out', () => {
|
|
||||||
cy.visit('/')
|
|
||||||
|
|
||||||
expect(localStorage.getItem('token')).to.not.eq(null)
|
|
||||||
|
|
||||||
logout()
|
|
||||||
|
|
||||||
cy.url()
|
|
||||||
.should('contain', '/login')
|
|
||||||
.then(() => {
|
|
||||||
expect(localStorage.getItem('token')).to.eq(null)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it.skip('Should clear the project history after logging the user out', () => {
|
|
||||||
const projects = createProjects()
|
|
||||||
cy.visit(`/projects/${projects[0].id}`)
|
|
||||||
.then(() => {
|
|
||||||
expect(localStorage.getItem('projectHistory')).to.not.eq(null)
|
|
||||||
})
|
|
||||||
|
|
||||||
logout()
|
|
||||||
|
|
||||||
cy.wait(1000) // This makes re-loading of the project and associated entities (and the resulting error) visible
|
|
||||||
|
|
||||||
cy.url()
|
|
||||||
.should('contain', '/login')
|
|
||||||
.then(() => {
|
|
||||||
expect(localStorage.getItem('projectHistory')).to.eq(null)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
context('OpenID Login', () => {
|
|
||||||
it('logs in via Dex provider', () => {
|
|
||||||
cy.visit('/login')
|
|
||||||
cy.contains('Dex').click()
|
|
||||||
cy.origin('http://dex:5556', () => {
|
|
||||||
cy.get('#login').type('test@example.com')
|
|
||||||
cy.get('#password').type('12345678')
|
|
||||||
cy.get('#submit-login').click()
|
|
||||||
})
|
|
||||||
cy.url().should('include', '/')
|
|
||||||
cy.get('main.app-content .content h2')
|
|
||||||
.should('contain', 'test!')
|
|
||||||
cy.get('.show-tasks h3')
|
|
||||||
.should('contain', 'Current Tasks')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
import {UserFactory, type UserAttributes} from '../../factories/user'
|
|
||||||
import {TokenFactory, type TokenAttributes} from '../../factories/token'
|
|
||||||
|
|
||||||
context('Password Reset', () => {
|
|
||||||
let user: UserAttributes
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
UserFactory.truncate()
|
|
||||||
TokenFactory.truncate()
|
|
||||||
user = UserFactory.create(1)[0] as UserAttributes
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should allow a user to reset their password with a valid token', () => {
|
|
||||||
const tokenArray = TokenFactory.create(1, {user_id: user.id as number, kind: 1})
|
|
||||||
const token: TokenAttributes = tokenArray[0] as TokenAttributes
|
|
||||||
|
|
||||||
cy.visit(`/?userPasswordReset=${token.token}`)
|
|
||||||
cy.url().should('include', `/password-reset?userPasswordReset=${token.token}`)
|
|
||||||
|
|
||||||
const newPassword = 'newSecurePassword123'
|
|
||||||
cy.get('input[id=password]').type(newPassword)
|
|
||||||
cy.get('button').contains('Reset your password').click()
|
|
||||||
|
|
||||||
cy.get('.message.success').should('contain', 'The password was updated successfully.')
|
|
||||||
cy.get('.button').contains('Login').click()
|
|
||||||
cy.url().should('include', '/login')
|
|
||||||
|
|
||||||
// Try to login with the new password
|
|
||||||
cy.get('input[id=username]').type(user.username)
|
|
||||||
cy.get('input[id=password]').type(newPassword)
|
|
||||||
cy.get('.button').contains('Login').click()
|
|
||||||
cy.url().should('not.include', '/login')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should show an error for an invalid token', () => {
|
|
||||||
cy.visit('/?userPasswordReset=invalidtoken123')
|
|
||||||
cy.url().should('include', '/password-reset?userPasswordReset=invalidtoken123')
|
|
||||||
|
|
||||||
// Attempt to reset password
|
|
||||||
const newPassword = 'newSecurePassword123'
|
|
||||||
cy.get('input[id=password]').type(newPassword)
|
|
||||||
cy.get('button').contains('Reset your password').click()
|
|
||||||
|
|
||||||
cy.get('.message').should('contain', 'Invalid token')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should redirect to login if no token is present in query param when visiting /password-reset directly', () => {
|
|
||||||
cy.visit('/password-reset')
|
|
||||||
cy.url().should('not.include', '/password-reset')
|
|
||||||
cy.wait(1000) // Wait for the redirect to happen - this seems to be flaky in CI
|
|
||||||
cy.url().should('include', '/login')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should redirect to login if userPasswordReset token is not present in query param when visiting root', () => {
|
|
||||||
cy.visit('/')
|
|
||||||
cy.url().should('include', '/login')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
// This test assumes no mailer is set up and all users are activated immediately.
|
|
||||||
|
|
||||||
import {UserFactory} from '../../factories/user'
|
|
||||||
|
|
||||||
context('Registration', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
UserFactory.create(1, {
|
|
||||||
username: 'test',
|
|
||||||
})
|
|
||||||
cy.visit('/', {
|
|
||||||
onBeforeLoad(win) {
|
|
||||||
win.localStorage.removeItem('token')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should work without issues', () => {
|
|
||||||
const fixture = {
|
|
||||||
username: 'testuser',
|
|
||||||
password: '12345678',
|
|
||||||
email: 'testuser@example.com',
|
|
||||||
}
|
|
||||||
|
|
||||||
cy.visit('/register')
|
|
||||||
cy.get('#username').type(fixture.username)
|
|
||||||
cy.get('#email').type(fixture.email)
|
|
||||||
cy.get('#password').type(fixture.password)
|
|
||||||
cy.get('#register-submit').click()
|
|
||||||
cy.url().should('include', '/')
|
|
||||||
cy.clock(1625656161057) // 13:00
|
|
||||||
cy.get('h2').should('contain', `Hi ${fixture.username}!`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should fail', () => {
|
|
||||||
const fixture = {
|
|
||||||
username: 'test',
|
|
||||||
password: '12345678',
|
|
||||||
email: 'testuser@example.com',
|
|
||||||
}
|
|
||||||
|
|
||||||
cy.visit('/register')
|
|
||||||
cy.get('#username').type(fixture.username)
|
|
||||||
cy.get('#email').type(fixture.email)
|
|
||||||
cy.get('#password').type(fixture.password)
|
|
||||||
cy.get('#register-submit').click()
|
|
||||||
cy.get('div.message.danger').contains('A user with this username already exists.')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -35,11 +35,15 @@
|
||||||
"lint:fix": "pnpm run lint --fix",
|
"lint:fix": "pnpm run lint --fix",
|
||||||
"lint:styles": "stylelint 'src/**/*.{css,scss,vue}'",
|
"lint:styles": "stylelint 'src/**/*.{css,scss,vue}'",
|
||||||
"lint:styles:fix": "pnpm run lint:styles --fix",
|
"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": "playwright test",
|
||||||
"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:headed": "playwright test --headed",
|
||||||
"test:e2e-record-test": "start-server-and-test preview:test http://127.0.0.1:4173 'cypress run --e2e --browser chrome --record'",
|
"test:e2e:ui": "playwright test --ui-host=0.0.0.0",
|
||||||
"test:e2e-dev-dev": "start-server-and-test preview:dev http://127.0.0.1:4173 'cypress open --e2e'",
|
"test:cypress:headed": "start-server-and-test preview 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: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",
|
"test:unit": "vitest --dir ./src",
|
||||||
"typecheck": "vue-tsc --build --force",
|
"typecheck": "vue-tsc --build --force",
|
||||||
"fonts:update": "pnpm fonts:download && pnpm fonts:subset",
|
"fonts:update": "pnpm fonts:download && pnpm fonts:subset",
|
||||||
|
|
@ -111,6 +115,7 @@
|
||||||
"@faker-js/faker": "9.9.0",
|
"@faker-js/faker": "9.9.0",
|
||||||
"@histoire/plugin-screenshot": "1.0.0-alpha.5",
|
"@histoire/plugin-screenshot": "1.0.0-alpha.5",
|
||||||
"@histoire/plugin-vue": "1.0.0-alpha.5",
|
"@histoire/plugin-vue": "1.0.0-alpha.5",
|
||||||
|
"@playwright/test": "1.57.0",
|
||||||
"@tsconfig/node22": "22.0.5",
|
"@tsconfig/node22": "22.0.5",
|
||||||
"@types/codemirror": "5.60.17",
|
"@types/codemirror": "5.60.17",
|
||||||
"@types/is-touch-device": "1.0.3",
|
"@types/is-touch-device": "1.0.3",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
})
|
||||||
|
|
@ -194,6 +194,9 @@ importers:
|
||||||
'@histoire/plugin-vue':
|
'@histoire/plugin-vue':
|
||||||
specifier: 1.0.0-alpha.5
|
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))
|
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':
|
'@tsconfig/node22':
|
||||||
specifier: 22.0.5
|
specifier: 22.0.5
|
||||||
version: 22.0.5
|
version: 22.0.5
|
||||||
|
|
@ -1931,6 +1934,11 @@ packages:
|
||||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||||
engines: {node: '>=14'}
|
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':
|
'@pnpm/config.env-replace@1.1.0':
|
||||||
resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==}
|
resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==}
|
||||||
engines: {node: '>=12.22.0'}
|
engines: {node: '>=12.22.0'}
|
||||||
|
|
@ -4045,6 +4053,11 @@ packages:
|
||||||
resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
|
resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
|
||||||
engines: {node: '>=10'}
|
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:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
|
@ -4316,6 +4329,9 @@ packages:
|
||||||
immutable@5.0.2:
|
immutable@5.0.2:
|
||||||
resolution: {integrity: sha512-1NU7hWZDkV7hJ4PJ9dur9gTNQ4ePNPN4k9/0YhwjzykTi/+3Q5pF93YU5QoVj8BuOnhLgaY8gs0U2pj4kSYVcw==}
|
resolution: {integrity: sha512-1NU7hWZDkV7hJ4PJ9dur9gTNQ4ePNPN4k9/0YhwjzykTi/+3Q5pF93YU5QoVj8BuOnhLgaY8gs0U2pj4kSYVcw==}
|
||||||
|
|
||||||
|
immutable@5.1.4:
|
||||||
|
resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==}
|
||||||
|
|
||||||
import-fresh@3.3.0:
|
import-fresh@3.3.0:
|
||||||
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
|
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
@ -5239,6 +5255,16 @@ packages:
|
||||||
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
|
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
|
||||||
engines: {node: '>= 6'}
|
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:
|
possible-typed-array-names@1.0.0:
|
||||||
resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==}
|
resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -8812,6 +8838,10 @@ snapshots:
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@playwright/test@1.57.0':
|
||||||
|
dependencies:
|
||||||
|
playwright: 1.57.0
|
||||||
|
|
||||||
'@pnpm/config.env-replace@1.1.0': {}
|
'@pnpm/config.env-replace@1.1.0': {}
|
||||||
|
|
||||||
'@pnpm/network.ca-file@1.0.2':
|
'@pnpm/network.ca-file@1.0.2':
|
||||||
|
|
@ -11212,6 +11242,9 @@ snapshots:
|
||||||
jsonfile: 6.1.0
|
jsonfile: 6.1.0
|
||||||
universalify: 2.0.1
|
universalify: 2.0.1
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -11547,6 +11580,9 @@ snapshots:
|
||||||
|
|
||||||
immutable@5.0.2: {}
|
immutable@5.0.2: {}
|
||||||
|
|
||||||
|
immutable@5.1.4:
|
||||||
|
optional: true
|
||||||
|
|
||||||
import-fresh@3.3.0:
|
import-fresh@3.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
parent-module: 1.0.1
|
parent-module: 1.0.1
|
||||||
|
|
@ -12409,6 +12445,14 @@ snapshots:
|
||||||
|
|
||||||
pirates@4.0.6: {}
|
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: {}
|
possible-typed-array-names@1.0.0: {}
|
||||||
|
|
||||||
postcss-attribute-case-insensitive@7.0.1(postcss@8.5.6):
|
postcss-attribute-case-insensitive@7.0.1(postcss@8.5.6):
|
||||||
|
|
@ -13203,7 +13247,7 @@ snapshots:
|
||||||
sass@1.93.3:
|
sass@1.93.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
chokidar: 4.0.3
|
chokidar: 4.0.3
|
||||||
immutable: 5.0.2
|
immutable: 5.1.4
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@parcel/watcher': 2.5.1
|
'@parcel/watcher': 2.5.1
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,12 @@ watch(
|
||||||
redirectToDefaultViewIfNecessary,
|
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(() => saveProjectView(props.projectId, props.viewId))
|
||||||
|
|
||||||
watchEffect(() => baseStore.setCurrentProjectViewId(props.viewId))
|
watchEffect(() => baseStore.setCurrentProjectViewId(props.viewId))
|
||||||
|
|
|
||||||
|
|
@ -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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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}`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,315 @@
|
||||||
|
import {test, expect} from '../../support/fixtures'
|
||||||
|
import {BucketFactory} from '../../factories/bucket'
|
||||||
|
import {ProjectFactory} from '../../factories/project'
|
||||||
|
import {TaskFactory} from '../../factories/task'
|
||||||
|
import {ProjectViewFactory} from '../../factories/project_view'
|
||||||
|
import {TaskBucketFactory} from '../../factories/task_buckets'
|
||||||
|
import {createTasksWithPriorities, createTasksWithSearch} from '../../support/filterTestHelpers'
|
||||||
|
|
||||||
|
async function createSingleTaskInBucket(count = 1, attrs = {}) {
|
||||||
|
const projects = await ProjectFactory.create(1)
|
||||||
|
const views = await ProjectViewFactory.create(1, {
|
||||||
|
id: 1,
|
||||||
|
project_id: projects[0].id,
|
||||||
|
view_kind: 3,
|
||||||
|
bucket_configuration_mode: 1,
|
||||||
|
})
|
||||||
|
const buckets = await BucketFactory.create(2, {
|
||||||
|
project_view_id: views[0].id,
|
||||||
|
})
|
||||||
|
const tasks = await TaskFactory.create(count, {
|
||||||
|
project_id: projects[0].id,
|
||||||
|
...attrs,
|
||||||
|
})
|
||||||
|
await TaskBucketFactory.create(1, {
|
||||||
|
task_id: tasks[0].id,
|
||||||
|
bucket_id: buckets[0].id,
|
||||||
|
project_view_id: views[0].id,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
task: tasks[0],
|
||||||
|
view: views[0],
|
||||||
|
project: projects[0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTaskWithBuckets(buckets, count = 1) {
|
||||||
|
const data = await TaskFactory.create(count, {
|
||||||
|
project_id: 1,
|
||||||
|
})
|
||||||
|
await TaskBucketFactory.truncate()
|
||||||
|
for (const t of data) {
|
||||||
|
await TaskBucketFactory.create(1, {
|
||||||
|
task_id: t.id,
|
||||||
|
bucket_id: buckets[0].id,
|
||||||
|
project_view_id: buckets[0].project_view_id,
|
||||||
|
}, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Project View Kanban', () => {
|
||||||
|
let buckets
|
||||||
|
|
||||||
|
test.beforeEach(async ({authenticatedPage: page}) => {
|
||||||
|
const projects = await ProjectFactory.create(1)
|
||||||
|
await ProjectViewFactory.create(1, {
|
||||||
|
id: 4,
|
||||||
|
project_id: projects[0].id,
|
||||||
|
view_kind: 3,
|
||||||
|
bucket_configuration_mode: 1,
|
||||||
|
})
|
||||||
|
buckets = await BucketFactory.create(2, {
|
||||||
|
project_view_id: 4,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Shows all buckets with their tasks', async ({authenticatedPage: page}) => {
|
||||||
|
const data = await createTaskWithBuckets(buckets, 10)
|
||||||
|
await page.goto('/projects/1/4')
|
||||||
|
|
||||||
|
await expect(page.locator('.kanban .bucket .title').filter({hasText: buckets[0].title})).toBeVisible()
|
||||||
|
await expect(page.locator('.kanban .bucket .title').filter({hasText: buckets[1].title})).toBeVisible()
|
||||||
|
await expect(page.locator('.kanban .bucket').first()).toContainText(data[0].title)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can add a new task to a bucket', async ({authenticatedPage: page}) => {
|
||||||
|
await createTaskWithBuckets(buckets, 2)
|
||||||
|
await page.goto('/projects/1/4')
|
||||||
|
|
||||||
|
await page.locator('.kanban .bucket').filter({hasText: buckets[0].title}).locator('.bucket-footer .button').filter({hasText: 'Add another task'}).click()
|
||||||
|
await page.locator('.kanban .bucket').filter({hasText: buckets[0].title}).locator('.bucket-footer .field .control input.input').fill('New Task')
|
||||||
|
await page.locator('.kanban .bucket').filter({hasText: buckets[0].title}).locator('.bucket-footer .field .control input.input').press('Enter')
|
||||||
|
|
||||||
|
await expect(page.locator('.kanban .bucket').first()).toContainText('New Task')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can create a new bucket', async ({authenticatedPage: page}) => {
|
||||||
|
await page.goto('/projects/1/4')
|
||||||
|
|
||||||
|
await page.locator('.kanban .bucket.new-bucket .button').click()
|
||||||
|
await page.locator('.kanban .bucket.new-bucket input.input').fill('New Bucket')
|
||||||
|
await page.locator('.kanban .bucket.new-bucket input.input').press('Enter')
|
||||||
|
|
||||||
|
await expect(page.locator('.kanban .bucket .title').filter({hasText: 'New Bucket'})).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can set a bucket limit', async ({authenticatedPage: page}) => {
|
||||||
|
await page.goto('/projects/1/4')
|
||||||
|
|
||||||
|
const bucketDropdown = page.locator('.kanban .bucket .bucket-header .dropdown.options').first()
|
||||||
|
await bucketDropdown.locator('.dropdown-trigger').click()
|
||||||
|
await bucketDropdown.locator('.dropdown-menu .dropdown-item').filter({hasText: 'Limit: Not Set'}).click()
|
||||||
|
await bucketDropdown.locator('.dropdown-menu .field input.input').fill('3')
|
||||||
|
await bucketDropdown.locator('.dropdown-menu .field .control .button').click()
|
||||||
|
|
||||||
|
// Wait for the limit to be saved - the dropdown closes and limit is shown
|
||||||
|
await expect(page.locator('.global-notification')).toContainText('Success')
|
||||||
|
await expect(page.locator('.kanban .bucket .bucket-header span.limit').first()).toBeVisible()
|
||||||
|
await expect(page.locator('.kanban .bucket .bucket-header span.limit').first()).toContainText('/3')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can rename a bucket', async ({authenticatedPage: page}) => {
|
||||||
|
await page.goto('/projects/1/4')
|
||||||
|
|
||||||
|
const titleElement = page.locator('.kanban .bucket .bucket-header .title').first()
|
||||||
|
await titleElement.click()
|
||||||
|
await titleElement.fill('New Bucket Title')
|
||||||
|
await titleElement.press('Enter')
|
||||||
|
await expect(titleElement).toContainText('New Bucket Title')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can delete a bucket', async ({authenticatedPage: page}) => {
|
||||||
|
await page.goto('/projects/1/4')
|
||||||
|
|
||||||
|
await page.locator('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger').first().click()
|
||||||
|
await page.locator('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item').filter({hasText: 'Delete'}).click()
|
||||||
|
await expect(page.locator('.modal-mask .modal-container .modal-content .modal-header')).toContainText('Delete the bucket')
|
||||||
|
await page.locator('.modal-mask .modal-container .modal-content .actions .button').filter({hasText: 'Do it!'}).click()
|
||||||
|
|
||||||
|
await expect(page.locator('.kanban .bucket .title').filter({hasText: buckets[0].title})).not.toBeVisible()
|
||||||
|
await expect(page.locator('.kanban .bucket .title').filter({hasText: buckets[1].title})).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can drag tasks around', async ({authenticatedPage: page}) => {
|
||||||
|
const tasks = await createTaskWithBuckets(buckets, 2)
|
||||||
|
await page.goto('/projects/1/4')
|
||||||
|
|
||||||
|
const sourceTask = page.locator('.kanban .bucket .tasks .task').filter({hasText: tasks[0].title}).first()
|
||||||
|
const targetBucket = page.locator('.kanban .bucket:nth-child(2) .tasks')
|
||||||
|
await sourceTask.dragTo(targetBucket)
|
||||||
|
|
||||||
|
await expect(page.locator('.kanban .bucket:nth-child(2) .tasks')).toContainText(tasks[0].title)
|
||||||
|
await expect(page.locator('.kanban .bucket:nth-child(1) .tasks')).not.toContainText(tasks[0].title)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Should navigate to the task when the task card is clicked', async ({authenticatedPage: page}) => {
|
||||||
|
const tasks = await createTaskWithBuckets(buckets, 5)
|
||||||
|
await page.goto('/projects/1/4')
|
||||||
|
|
||||||
|
await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: tasks[0].title})).toBeVisible()
|
||||||
|
await page.locator('.kanban .bucket .tasks .task').filter({hasText: tasks[0].title}).click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/tasks/${tasks[0].id}`), {timeout: 1000})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Should remove a task from the kanban board when moving it to another project', async ({authenticatedPage: page}) => {
|
||||||
|
const projects = await ProjectFactory.create(2)
|
||||||
|
const views = await ProjectViewFactory.create(2, {
|
||||||
|
project_id: '{increment}',
|
||||||
|
view_kind: 3,
|
||||||
|
bucket_configuration_mode: 1,
|
||||||
|
})
|
||||||
|
await BucketFactory.create(2)
|
||||||
|
const tasks = await TaskFactory.create(5, {
|
||||||
|
id: '{increment}',
|
||||||
|
project_id: 1,
|
||||||
|
})
|
||||||
|
await TaskBucketFactory.create(5, {
|
||||||
|
project_view_id: 1,
|
||||||
|
})
|
||||||
|
const task = tasks[0]
|
||||||
|
await page.goto('/projects/1/' + views[0].id)
|
||||||
|
|
||||||
|
await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title})).toBeVisible()
|
||||||
|
await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click()
|
||||||
|
|
||||||
|
await page.locator('.task-view .action-buttons .button', {timeout: 3000}).filter({hasText: /^Move$/}).click()
|
||||||
|
const multiselectInput = page.locator('.task-view .content.details .field .multiselect.control .input-wrapper input')
|
||||||
|
await expect(multiselectInput).toBeVisible({timeout: 5000})
|
||||||
|
await multiselectInput.click()
|
||||||
|
await multiselectInput.pressSequentially(projects[1].title)
|
||||||
|
// Wait for search results to appear before clicking
|
||||||
|
const searchResults = page.locator('.task-view .content.details .field .multiselect.control .search-results')
|
||||||
|
await searchResults.waitFor({state: 'visible'})
|
||||||
|
await searchResults.locator('> *').first().click()
|
||||||
|
|
||||||
|
await expect(page.locator('.global-notification')).toContainText('Success', {timeout: 1000})
|
||||||
|
await page.goBack()
|
||||||
|
const bucketCount = await page.locator('.kanban .bucket').count()
|
||||||
|
for (let i = 0; i < bucketCount; i++) {
|
||||||
|
await expect(page.locator('.kanban .bucket').nth(i)).not.toContainText(task.title)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Shows a button to filter the kanban board', async ({authenticatedPage: page}) => {
|
||||||
|
await page.goto('/projects/1/4')
|
||||||
|
|
||||||
|
await expect(page.locator('.project-kanban .filter-container .base-button')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Should remove a task from the board when deleting it', async ({authenticatedPage: page}) => {
|
||||||
|
const {task, view} = await createSingleTaskInBucket(5)
|
||||||
|
await page.goto(`/projects/1/${view.id}`)
|
||||||
|
|
||||||
|
await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title})).toBeVisible()
|
||||||
|
await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click()
|
||||||
|
await expect(page.locator('.task-view .action-buttons .button').filter({hasText: 'Delete'})).toBeVisible()
|
||||||
|
await page.locator('.task-view .action-buttons .button').filter({hasText: 'Delete'}).click()
|
||||||
|
await expect(page.locator('.modal-mask .modal-container .modal-content .modal-header')).toContainText('Delete this task')
|
||||||
|
await page.locator('.modal-mask .modal-container .modal-content .actions .button').filter({hasText: 'Do it!'}).click()
|
||||||
|
|
||||||
|
await expect(page.locator('.global-notification')).toContainText('Success')
|
||||||
|
|
||||||
|
await page.goBack()
|
||||||
|
const bucketCount = await page.locator('.kanban .bucket').count()
|
||||||
|
for (let i = 0; i < bucketCount; i++) {
|
||||||
|
await expect(page.locator('.kanban .bucket').nth(i)).not.toContainText(task.title)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Should show a task description icon if the task has a description', async ({authenticatedPage: page}) => {
|
||||||
|
const {task, view} = await createSingleTaskInBucket(1, {
|
||||||
|
description: 'Lorem Ipsum',
|
||||||
|
})
|
||||||
|
const loadTasksPromise = page.waitForResponse(response =>
|
||||||
|
response.url().includes('/projects/1/views/') && response.url().includes('/tasks'),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/projects/${task.project_id}/${view.id}`)
|
||||||
|
await loadTasksPromise
|
||||||
|
|
||||||
|
await expect(page.locator('.bucket .tasks .task .footer .icon svg')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Should not show a task description icon if the task has an empty description', async ({authenticatedPage: page}) => {
|
||||||
|
const {task, view} = await createSingleTaskInBucket(1, {
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
const loadTasksPromise = page.waitForResponse(response =>
|
||||||
|
response.url().includes('/projects/1/views/') && response.url().includes('/tasks'),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/projects/${task.project_id}/${view.id}`)
|
||||||
|
await loadTasksPromise
|
||||||
|
|
||||||
|
await expect(page.locator('.bucket .tasks .task .footer .icon svg')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Should not show a task description icon if the task has a description containing only an empty p tag', async ({authenticatedPage: page}) => {
|
||||||
|
const {task, view} = await createSingleTaskInBucket(1, {
|
||||||
|
description: '<p></p>',
|
||||||
|
})
|
||||||
|
const loadTasksPromise = page.waitForResponse(response =>
|
||||||
|
response.url().includes('/projects/1/views/') && response.url().includes('/tasks'),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/projects/${task.project_id}/${view.id}`)
|
||||||
|
await loadTasksPromise
|
||||||
|
|
||||||
|
await expect(page.locator('.bucket .tasks .task .footer .icon svg')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Should respect filter query parameter from URL', async ({authenticatedPage: page}) => {
|
||||||
|
// Create buckets first
|
||||||
|
const projects = await ProjectFactory.create(1)
|
||||||
|
const views = await ProjectViewFactory.create(1, {
|
||||||
|
id: 4,
|
||||||
|
project_id: 1,
|
||||||
|
view_kind: 3,
|
||||||
|
})
|
||||||
|
const buckets = await BucketFactory.create(2, {
|
||||||
|
project_view_id: 4,
|
||||||
|
})
|
||||||
|
|
||||||
|
const {highPriorityTasks, lowPriorityTasks} = await createTasksWithPriorities(buckets)
|
||||||
|
|
||||||
|
await page.goto('/projects/1/4?filter=priority%20>=%204')
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/filter=priority/)
|
||||||
|
|
||||||
|
// Wait for tasks to load and verify high priority tasks are visible
|
||||||
|
await expect(page.locator('.kanban')).toContainText(highPriorityTasks[0].title, {timeout: 10000})
|
||||||
|
await expect(page.locator('.kanban')).toContainText(highPriorityTasks[1].title)
|
||||||
|
|
||||||
|
// Verify low priority tasks are not visible
|
||||||
|
await expect(page.locator('.kanban')).not.toContainText(lowPriorityTasks[0].title)
|
||||||
|
await expect(page.locator('.kanban')).not.toContainText(lowPriorityTasks[1].title)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Should respect search query parameter from URL', async ({authenticatedPage: page}) => {
|
||||||
|
// Create buckets first
|
||||||
|
const projects = await ProjectFactory.create(1)
|
||||||
|
const views = await ProjectViewFactory.create(1, {
|
||||||
|
id: 4,
|
||||||
|
project_id: 1,
|
||||||
|
view_kind: 3,
|
||||||
|
})
|
||||||
|
const buckets = await BucketFactory.create(2, {
|
||||||
|
project_view_id: 4,
|
||||||
|
})
|
||||||
|
|
||||||
|
const {searchableTask} = await createTasksWithSearch(buckets)
|
||||||
|
|
||||||
|
await page.goto('/projects/1/4?s=meeting')
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/s=meeting/)
|
||||||
|
|
||||||
|
// Wait for search results to load and verify searchable task is visible
|
||||||
|
await expect(page.locator('.kanban')).toContainText(searchableTask.title, {timeout: 10000})
|
||||||
|
|
||||||
|
// Verify only one task is shown (the search result) - count task headings
|
||||||
|
await expect(page.locator('main h2')).toHaveCount(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import {test, expect} from '../../support/fixtures'
|
||||||
|
import {ProjectFactory} from '../../factories/project'
|
||||||
|
import {TaskFactory} from '../../factories/task'
|
||||||
|
import {TaskCommentFactory} from '../../factories/task_comment'
|
||||||
|
import {createDefaultViews} from '../project/prepareProjects'
|
||||||
|
|
||||||
|
test.describe('Task comment pagination', () => {
|
||||||
|
test.beforeEach(async ({authenticatedPage: page}) => {
|
||||||
|
await ProjectFactory.create(1)
|
||||||
|
await createDefaultViews(1)
|
||||||
|
await TaskFactory.create(1, {id: 1})
|
||||||
|
await TaskCommentFactory.truncate()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows pagination when more comments than configured page size', async ({authenticatedPage: page, apiContext}) => {
|
||||||
|
const response = await apiContext.get('info')
|
||||||
|
const body = await response.json()
|
||||||
|
const pageSize = body.max_items_per_page
|
||||||
|
await TaskCommentFactory.create(pageSize + 10)
|
||||||
|
await page.goto('/tasks/1')
|
||||||
|
await expect(page.locator('.task-view .comments nav.pagination')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('hides pagination when comments equal or fewer than configured page size', async ({authenticatedPage: page, apiContext}) => {
|
||||||
|
const response = await apiContext.get('info')
|
||||||
|
const body = await response.json()
|
||||||
|
const pageSize = body.max_items_per_page
|
||||||
|
await TaskCommentFactory.create(Math.max(1, pageSize - 10))
|
||||||
|
await page.goto('/tasks/1')
|
||||||
|
await expect(page.locator('.task-view .comments nav.pagination')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import {test, expect} from '../../support/fixtures'
|
||||||
import {UserFactory} from '../../factories/user'
|
import {UserFactory} from '../../factories/user'
|
||||||
import {ProjectFactory} from '../../factories/project'
|
import {ProjectFactory} from '../../factories/project'
|
||||||
import {TaskFactory} from '../../factories/task'
|
import {TaskFactory} from '../../factories/task'
|
||||||
|
|
@ -5,14 +6,14 @@ import {login} from '../../support/authenticateUser'
|
||||||
import {DATE_DISPLAY} from '../../../src/constants/dateDisplay'
|
import {DATE_DISPLAY} from '../../../src/constants/dateDisplay'
|
||||||
import {TIME_FORMAT} from '../../../src/constants/timeFormat'
|
import {TIME_FORMAT} from '../../../src/constants/timeFormat'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
import relativeTime from 'dayjs/plugin/relativeTime.js'
|
||||||
|
|
||||||
dayjs.extend(relativeTime)
|
dayjs.extend(relativeTime)
|
||||||
|
|
||||||
const createdDate = new Date(Date.UTC(2022, 6, 25, 12))
|
const createdDate = new Date(Date.UTC(2022, 6, 25, 12))
|
||||||
const now = new Date(Date.UTC(2022, 6, 30, 12))
|
const now = new Date(Date.UTC(2022, 6, 30, 12))
|
||||||
|
|
||||||
const expectedFormats = {
|
const expectedFormats12h = {
|
||||||
[DATE_DISPLAY.RELATIVE]: dayjs(createdDate).from(now),
|
[DATE_DISPLAY.RELATIVE]: dayjs(createdDate).from(now),
|
||||||
[DATE_DISPLAY.MM_DD_YYYY]: dayjs(createdDate).format('MM-DD-YYYY hh:mm A'),
|
[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'),
|
[DATE_DISPLAY.DD_MM_YYYY]: dayjs(createdDate).format('DD-MM-YYYY hh:mm A'),
|
||||||
|
|
@ -66,48 +67,48 @@ const expectedFormats24h = {
|
||||||
}).format(createdDate),
|
}).format(createdDate),
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Date display setting', () => {
|
test.describe('Date display setting', () => {
|
||||||
Object.entries(expectedFormats).forEach(([format, expected]) => {
|
Object.entries(expectedFormats12h).forEach(([format, expected]) => {
|
||||||
it(`shows ${format} with 12h time format`, () => {
|
test(`shows ${format} with 12h time format`, async ({page, apiContext}) => {
|
||||||
const user = UserFactory.create(1, {
|
const user = (await UserFactory.create(1, {
|
||||||
frontend_settings: JSON.stringify({dateDisplay: format, timeFormat: TIME_FORMAT.HOURS_12}),
|
frontend_settings: JSON.stringify({dateDisplay: format, timeFormat: TIME_FORMAT.HOURS_12}),
|
||||||
})[0]
|
}))[0]
|
||||||
const project = ProjectFactory.create(1, {owner_id: user.id})[0]
|
const project = (await ProjectFactory.create(1, {owner_id: user.id}))[0]
|
||||||
TaskFactory.truncate()
|
await TaskFactory.truncate()
|
||||||
const task = TaskFactory.create(1, {
|
const task = (await TaskFactory.create(1, {
|
||||||
id: 1,
|
id: 1,
|
||||||
project_id: project.id,
|
project_id: project.id,
|
||||||
created_by_id: user.id,
|
created_by_id: user.id,
|
||||||
created: createdDate.toISOString(),
|
created: createdDate.toISOString(),
|
||||||
updated: createdDate.toISOString(),
|
updated: createdDate.toISOString(),
|
||||||
})[0]
|
}))[0]
|
||||||
|
|
||||||
cy.clock(now, ['Date'])
|
await page.clock.install({time: now})
|
||||||
login(user)
|
await login(page, apiContext, user)
|
||||||
cy.visit(`/tasks/${task.id}`)
|
await page.goto(`/tasks/${task.id}`)
|
||||||
cy.get('.task-view .created time span').should('contain', expected)
|
await expect(page.locator('.task-view .created time span')).toContainText(expected)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Object.entries(expectedFormats24h).forEach(([format, expected]) => {
|
Object.entries(expectedFormats24h).forEach(([format, expected]) => {
|
||||||
it(`shows ${format} with 24h time format`, () => {
|
test(`shows ${format} with 24h time format`, async ({page, apiContext}) => {
|
||||||
const user = UserFactory.create(1, {
|
const user = (await UserFactory.create(1, {
|
||||||
frontend_settings: JSON.stringify({dateDisplay: format, timeFormat: TIME_FORMAT.HOURS_24}),
|
frontend_settings: JSON.stringify({dateDisplay: format, timeFormat: TIME_FORMAT.HOURS_24}),
|
||||||
})[0]
|
}))[0]
|
||||||
const project = ProjectFactory.create(1, {owner_id: user.id})[0]
|
const project = (await ProjectFactory.create(1, {owner_id: user.id}))[0]
|
||||||
TaskFactory.truncate()
|
await TaskFactory.truncate()
|
||||||
const task = TaskFactory.create(1, {
|
const task = (await TaskFactory.create(1, {
|
||||||
id: 1,
|
id: 1,
|
||||||
project_id: project.id,
|
project_id: project.id,
|
||||||
created_by_id: user.id,
|
created_by_id: user.id,
|
||||||
created: createdDate.toISOString(),
|
created: createdDate.toISOString(),
|
||||||
updated: createdDate.toISOString(),
|
updated: createdDate.toISOString(),
|
||||||
})[0]
|
}))[0]
|
||||||
|
|
||||||
cy.clock(now, ['Date'])
|
await page.clock.install({time: now})
|
||||||
login(user)
|
await login(page, apiContext, user)
|
||||||
cy.visit(`/tasks/${task.id}`)
|
await page.goto(`/tasks/${task.id}`)
|
||||||
cy.get('.task-view .created time span').should('contain', expected)
|
await expect(page.locator('.task-view .created time span')).toContainText(expected)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -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:')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import {test, expect} from '../../support/fixtures'
|
||||||
|
import {ProjectFactory} from '../../factories/project'
|
||||||
|
import {TaskFactory} from '../../factories/task'
|
||||||
|
import {ProjectViewFactory} from '../../factories/project_view'
|
||||||
|
import {TaskRelationFactory} from '../../factories/task_relation'
|
||||||
|
|
||||||
|
async function createViews(projectId: number, projectViewId: number) {
|
||||||
|
return (await ProjectViewFactory.create(1, {
|
||||||
|
id: projectViewId,
|
||||||
|
project_id: projectId,
|
||||||
|
view_kind: 0,
|
||||||
|
}, false))[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Subtask duplicate handling', () => {
|
||||||
|
let projectA
|
||||||
|
let projectB
|
||||||
|
let parentA
|
||||||
|
let parentB
|
||||||
|
let subtask
|
||||||
|
|
||||||
|
test.beforeEach(async ({authenticatedPage: page, apiContext}) => {
|
||||||
|
await Promise.all([
|
||||||
|
ProjectFactory.truncate(),
|
||||||
|
ProjectViewFactory.truncate(),
|
||||||
|
TaskFactory.truncate(),
|
||||||
|
TaskRelationFactory.truncate(),
|
||||||
|
])
|
||||||
|
|
||||||
|
projectA = (await ProjectFactory.create(1, {id: 1, title: 'Project A'}))[0]
|
||||||
|
await createViews(projectA.id, 1)
|
||||||
|
projectB = (await ProjectFactory.create(1, {id: 2, title: 'Project B'}, false))[0]
|
||||||
|
await createViews(projectB.id, 2)
|
||||||
|
|
||||||
|
parentA = (await TaskFactory.create(1, {id: 10, title: 'Parent A', project_id: projectA.id}, false))[0]
|
||||||
|
parentB = (await TaskFactory.create(1, {id: 11, title: 'Parent B', project_id: projectB.id}, false))[0]
|
||||||
|
subtask = (await TaskFactory.create(1, {id: 12, title: 'Shared subtask', project_id: projectA.id}, false))[0]
|
||||||
|
|
||||||
|
// Navigate to a page first to establish context for localStorage access
|
||||||
|
await page.goto('/')
|
||||||
|
const token = await page.evaluate(() => localStorage.getItem('token'))
|
||||||
|
|
||||||
|
await apiContext.put(`tasks/${parentA.id}/relations`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
other_task_id: subtask.id,
|
||||||
|
relation_kind: 'subtask',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await apiContext.put(`tasks/${parentB.id}/relations`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
other_task_id: subtask.id,
|
||||||
|
relation_kind: 'subtask',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows subtask only once in each project list', async ({authenticatedPage: page}) => {
|
||||||
|
await page.goto(`/projects/${projectA.id}/1`)
|
||||||
|
await expect(page.locator('.subtask-nested .task-link').filter({hasText: subtask.title})).toBeVisible()
|
||||||
|
await expect(page.locator('.tasks .task-link').filter({hasText: subtask.title})).toHaveCount(1)
|
||||||
|
|
||||||
|
await page.goto(`/projects/${projectB.id}/1`)
|
||||||
|
await expect(page.locator('.subtask-nested .task-link').filter({hasText: subtask.title})).toBeVisible()
|
||||||
|
await expect(page.locator('.tasks .task-link').filter({hasText: subtask.title})).toHaveCount(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import type {Page} from '@playwright/test'
|
||||||
|
import {test, expect} from '../../support/fixtures'
|
||||||
|
import {UserFactory} from '../../factories/user'
|
||||||
|
import {ProjectFactory} from '../../factories/project'
|
||||||
|
import {TEST_PASSWORD} from '../../support/constants'
|
||||||
|
|
||||||
|
interface LoginCredentials {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const testAndAssertFailed = async (page: Page, fixture: LoginCredentials): Promise<void> => {
|
||||||
|
const loginPromise = page.waitForResponse(response =>
|
||||||
|
response.url().includes('/login') && response.request().method() === 'POST',
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto('/login')
|
||||||
|
await page.locator('input[id=username]').fill(fixture.username)
|
||||||
|
await page.locator('input[id=password]').fill(fixture.password)
|
||||||
|
await page.locator('.button').filter({hasText: 'Login'}).click()
|
||||||
|
|
||||||
|
await loginPromise
|
||||||
|
await expect(page).toHaveURL('/login')
|
||||||
|
await expect(page.locator('div.message.danger')).toContainText('Wrong username or password.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials: LoginCredentials = {
|
||||||
|
username: 'test',
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(page: Page): Promise<void> {
|
||||||
|
await page.locator('input[id=username]').fill(credentials.username)
|
||||||
|
await page.locator('input[id=password]').fill(credentials.password)
|
||||||
|
await page.locator('.button').filter({hasText: 'Login'}).click()
|
||||||
|
await expect(page).toHaveURL('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Login', () => {
|
||||||
|
test.beforeEach(async ({apiContext}) => {
|
||||||
|
await UserFactory.create(1, {username: credentials.username})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Should log in with the right credentials', async ({page}) => {
|
||||||
|
await page.goto('/login')
|
||||||
|
await login(page)
|
||||||
|
await page.clock.install({time: new Date(1625656161057)}) // 13:00
|
||||||
|
// Use more specific selector to avoid strict mode violation
|
||||||
|
await expect(page.locator('main h2')).toContainText(`Hi ${credentials.username}!`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// FIXME: request timeout for the request that's awaited
|
||||||
|
test.skip('Should fail with a bad password', async ({page}) => {
|
||||||
|
const fixture = {
|
||||||
|
username: 'test',
|
||||||
|
password: '123456',
|
||||||
|
}
|
||||||
|
|
||||||
|
await testAndAssertFailed(page, fixture)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Should fail with a bad username', async ({page}) => {
|
||||||
|
const fixture = {
|
||||||
|
username: 'loremipsum',
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
}
|
||||||
|
|
||||||
|
await testAndAssertFailed(page, fixture)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Should redirect to /login when no user is logged in', async ({page}) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page).toHaveURL(/\/login/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// FIXME: request timeout
|
||||||
|
test.skip('Should redirect to the previous route after logging in', async ({page}) => {
|
||||||
|
const projects = await ProjectFactory.create(1)
|
||||||
|
await page.goto(`/projects/${projects[0].id}/1`)
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login/)
|
||||||
|
|
||||||
|
await login(page)
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/projects/${projects[0].id}/1`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import {faker} from '@faker-js/faker'
|
||||||
|
import {Factory} from '../support/factory'
|
||||||
|
|
||||||
|
export interface TokenAttributes {
|
||||||
|
id: number | '{increment}';
|
||||||
|
user_id: number;
|
||||||
|
token: string;
|
||||||
|
kind: number;
|
||||||
|
created: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TokenFactory extends Factory {
|
||||||
|
static table = 'user_tokens'
|
||||||
|
|
||||||
|
// The factory method itself produces an object where id is '{increment}' (a string)
|
||||||
|
// before it gets processed by the main create() method in the base Factory class.
|
||||||
|
static factory(attrs?: Partial<Omit<TokenAttributes, 'id'>>): Omit<TokenAttributes, 'id'> & { id: string } {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: '{increment}', // This is a string
|
||||||
|
user_id: 1, // Default user_id
|
||||||
|
token: faker.string.alphanumeric(64),
|
||||||
|
kind: 1, // TokenPasswordReset
|
||||||
|
created: now.toISOString(),
|
||||||
|
...(attrs ?? {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 872 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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.'
|
||||||
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import type {APIRequestContext} from '@playwright/test'
|
||||||
|
|
||||||
|
export async function updateUserSettings(apiContext: APIRequestContext, token: string, settings: any) {
|
||||||
|
const apiUrl = process.env.API_URL || 'http://localhost:3456/api/v1'
|
||||||
|
|
||||||
|
const userResponse = await apiContext.get(`${apiUrl}/user`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const oldSettings = await userResponse.json()
|
||||||
|
|
||||||
|
await apiContext.post(`${apiUrl}/user/settings/general`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
...oldSettings,
|
||||||
|
...settings,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -76,5 +76,17 @@ func HandleTesting(c echo.Context) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusCreated, nil)
|
s := db.NewSession()
|
||||||
|
defer s.Close()
|
||||||
|
data := []map[string]interface{}{}
|
||||||
|
err = s.Table(table).Find(&data)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Error fetching table data: %v", err)
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
|
||||||
|
"error": true,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusCreated, data)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue