chore(tests): remove Cypress, use Playwright exclusively (#1976)

- Removes Cypress test framework entirely, using only Playwright for E2E tests
- All Cypress tests were already covered by Playwright; added 2 missing tests for URL filter/search parameters
- Removes ~2000 lines of Cypress code and configuration
- Updated ESLint and Stylelint configurations to reflect testing changes

🐰 Farewell to Cypress, dear and bright,
We hop to Playwright's testing light,
Factories cleared, the config gone,
Our tests now march in Playwright's song!
~ The Testing Rabbit
This commit is contained in:
kolaente 2025-12-12 21:07:18 +01:00 committed by GitHub
parent 62f291c9a8
commit ad1a5f9b5c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 83 additions and 2227 deletions

View File

@ -422,66 +422,3 @@ jobs:
name: playwright-test-results-${{ matrix.shard }}
path: frontend/test-results/
retention-days: 30
test-frontend-e2e-cypress:
runs-on: ubuntu-latest
needs:
- api-build
- frontend-build
services:
dex:
image: ghcr.io/go-vikunja/dex-testing:main@sha256:d401c06a9f8fd36ece446a07499b827232af7f21eb36872a76c9eac4d0c77bab
ports:
- 5556:5556
container:
image: cypress/browsers:latest@sha256:c03803eed8a1c80a1cfe38672e8c6f661439fcff5cfa2c5ed424ffa502b0b0a1
options: --user 1001
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Vikunja Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: vikunja_bin
- uses: ./.github/actions/setup-frontend
with:
install-e2e-binaries: true
- name: Download Frontend
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: frontend_dist
path: ./frontend/dist
- name: Inject testing flag into index.html
run: |
sed -i 's/<head>/<head><script>window.TESTING=true;<\/script>/' ./frontend/dist/index.html
- run: chmod +x ./vikunja
- uses: cypress-io/github-action@v6
timeout-minutes: 20
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CYPRESS_API_URL: http://127.0.0.1:3456/api/v1
CYPRESS_TEST_SECRET: averyLongSecretToSe33dtheDB
CYPRESS_DEFAULT_COMMAND_TIMEOUT: 60000
CYPRESS_CI_BUILD_ID: "${{ github.workflow }}-${{ github.run_id }}-${{ github.run_attempt }}" # see https://github.com/cypress-io/github-action/issues/431
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
with:
install: false
working-directory: frontend
browser: chrome
record: false
parallel: false
start: |
pnpm run preview:vikunja
pnpm run preview
wait-on: http://127.0.0.1:4173,http://127.0.0.1:3456/api/v1/info
wait-on-timeout: 10

2
frontend/.gitignore vendored
View File

@ -18,8 +18,6 @@ coverage
.vite/
# Test files
cypress/screenshots
cypress/videos
playwright-report/
test-results/

View File

@ -1,6 +1,2 @@
# https://github.com/pnpm/pnpm/issues/8378#issuecomment-2636152421
public-hoist-pattern[]=*eslint*
# Make sure to install Cypress binary
# https://github.com/cypress-io/github-action/blob/108b8684ae52e735ff7891524cbffbcd4be5b19f/README.md#pnpm
side-effects-cache=false

View File

@ -84,7 +84,6 @@
"ignoreFiles": [
"node_modules/**/*",
"dist/**/*",
"cypress/**/*",
"**/*.js",
"**/*.ts"
]

View File

@ -1,28 +0,0 @@
import {defineConfig} from 'cypress'
export default defineConfig({
env: {
API_URL: 'http://localhost:3456/api/v1',
TEST_SECRET: 'averyLongSecretToSe33dtheDB',
},
video: false,
retries: {
runMode: 2,
},
projectId: '181c7x',
e2e: {
specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}',
baseUrl: 'http://127.0.0.1:4173',
experimentalRunAllSpecs: true,
// testIsolation: false,
},
component: {
devServer: {
framework: 'vue',
bundler: 'vite',
},
},
viewportWidth: 1600,
viewportHeight: 900,
experimentalMemoryManagement: true,
})

View File

@ -1,48 +0,0 @@
# Frontend Testing With Cypress
## Setup
* Enable the [seeder api endpoint](https://vikunja.io/docs/config-options/#testingtoken). You'll then need to add the testingtoken in `cypress.json` or set the `CYPRESS_TEST_SECRET` environment variable.
* Basic configuration happens in the `cypress.json` file
* Overridable with [env](https://docs.cypress.io/guides/guides/environment-variables.html#Option-3-CYPRESS)
* Override base url with `CYPRESS_BASE_URL`
## Fixtures
We're using the [test endpoint](https://vikunja.io/docs/config-options/#testingtoken) of the vikunja api to
seed the database with test data before running the tests.
This ensures better reproducibility of tests.
## Running The Tests Locally
### Using Docker
The easiest way to run all frontend tests locally is by using the `docker-compose` file in this repository.
It uses the same configuration as the CI.
To use it, run
```shell
docker-compose up -d
```
Then, once all containers are started, run
```shell
docker-compose run cypress bash
```
to get a shell inside the cypress container.
In that shell you can then execute the tests with
```shell
pnpm run test:e2e
```
### Using The Cypress Dashboard
To open the Cypress Dashboard and run tests from there, run
```shell
pnpm run test:e2e:dev
```

View File

@ -1,15 +0,0 @@
services:
dex:
image: ghcr.io/go-vikunja/dex-testing:main@sha256:d401c06a9f8fd36ece446a07499b827232af7f21eb36872a76c9eac4d0c77bab
ports:
- 5556:5556
cypress:
image: cypress/browsers:latest@sha256:c03803eed8a1c80a1cfe38672e8c6f661439fcff5cfa2c5ed424ffa502b0b0a1
volumes:
- ..:/project
- $HOME/.cache:/home/node/.cache/
user: node
working_dir: /project
environment:
CYPRESS_API_URL: http://api:3456/api/v1
CYPRESS_TEST_SECRET: averyLongSecretToSe33dtheDB

View File

@ -1,61 +0,0 @@
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {ProjectViewFactory} from "../../factories/project_view";
export function createDefaultViews(projectId: number, startViewId = 1, truncate: boolean = true) {
if (truncate) {
ProjectViewFactory.truncate()
}
const list = ProjectViewFactory.create(1, {
id: startViewId,
project_id: projectId,
view_kind: 0,
}, false)
const gantt = ProjectViewFactory.create(1, {
id: startViewId + 1,
project_id: projectId,
view_kind: 1,
}, false)
const table = ProjectViewFactory.create(1, {
id: startViewId + 2,
project_id: projectId,
view_kind: 2,
}, false)
const kanban = 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 function createProjects(count: number = 1) {
const projects = ProjectFactory.create(count, {
title: i => count === 1 ? 'First Project' : `Project ${i + 1}`,
})
TaskFactory.truncate()
ProjectViewFactory.truncate()
for (let i = 0; i < projects.length; i++) {
const views = createDefaultViews(projects[i].id, i * 4 + 1, false)
projects[i].views = views
}
return projects
}
export function prepareProjects(setProjects = (...args: any[]) => {
}) {
beforeEach(() => {
const projects = createProjects()
setProjects(projects)
})
}

View File

@ -1,58 +0,0 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
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 {prepareProjects, createProjects} from './prepareProjects'
import {BucketFactory} from '../../factories/bucket'
import {
createTasksWithPriorities,
createTasksWithSearch,
} from '../../support/filterTestHelpers'
describe('Project View List', () => {
createFakeUserAndLogin()
prepareProjects()
it('Should respect filter query parameter from URL', () => {
const {highPriorityTasks, lowPriorityTasks} = createTasksWithPriorities()
cy.visit('/projects/1/1?filter=priority%20>=%204')
cy.url()
.should('include', 'filter=priority')
cy.contains('.tasks', highPriorityTasks[0].title, {timeout: 10000})
.should('exist')
cy.get('.tasks')
.should('contain', highPriorityTasks[0].title)
cy.get('.tasks')
.should('contain', highPriorityTasks[1].title)
cy.get('.tasks')
.should('not.contain', lowPriorityTasks[0].title)
cy.get('.tasks')
.should('not.contain', lowPriorityTasks[1].title)
})
it('Should respect search query parameter from URL', () => {
const {searchableTask} = createTasksWithSearch()
cy.visit('/projects/1/1?s=meeting')
cy.url()
.should('include', 's=meeting')
cy.contains('.tasks', searchableTask.title, {timeout: 10000})
.should('exist')
cy.get('.tasks')
.should('contain', searchableTask.title)
cy.get('.tasks .task')
.should('have.length', 1)
})
})

View File

@ -1,109 +0,0 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
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";
function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
const project = ProjectFactory.create()[0]
const views = createDefaultViews(project.id)
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(),
})
}
seed(TaskFactory.table, tasks)
return {tasks, project}
}
describe('Home Page Task Overview', () => {
createFakeUserAndLogin()
it('Should show a new task with a very soon due date at the top', () => {
const {tasks} = seedTasks(49)
const newTaskTitle = 'New Task'
cy.visit('/')
TaskFactory.create(1, {
id: 999,
title: newTaskTitle,
due_date: new Date().toISOString(),
}, false)
cy.visit(`/projects/${tasks[0].project_id}/1`)
cy.get('.tasks .task')
.should('contain.text', newTaskTitle)
cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task')
.first()
.should('contain.text', newTaskTitle)
})
it('Should not show a new task without a date at the bottom when there are > 50 tasks', () => {
// We're not using the api here to create the task in order to verify the flow
const {tasks} = seedTasks(100)
const newTaskTitle = 'New Task'
cy.visit('/')
cy.visit(`/projects/${tasks[0].project_id}/1`)
cy.get('.task-add textarea')
.type(newTaskTitle+'{enter}')
cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task')
.last()
.should('not.contain.text', newTaskTitle)
})
it('Should show a new task without a date at the bottom when there are < 50 tasks', () => {
seedTasks(40)
const newTaskTitle = 'New Task'
TaskFactory.create(1, {
id: 999,
title: newTaskTitle,
}, false)
cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task')
.last()
.should('contain.text', newTaskTitle)
})
it('Should show a task without a due date added via default project at the bottom', () => {
const {project} = seedTasks(40)
updateUserSettings({
default_project_id: project.id,
overdue_tasks_reminders_time: '9:00',
})
const newTaskTitle = 'New Task'
cy.visit('/')
cy.get('.add-task-textarea')
.type(`${newTaskTitle}{enter}`)
cy.get('[data-cy="showTasks"] .card .task')
.last()
.should('contain.text', newTaskTitle)
})
})

View File

@ -1,11 +0,0 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["./**/*", "../support/**/*", "../factories/**/*"],
"compilerOptions": {
"baseUrl": ".",
"isolatedModules": false,
"target": "ES2015",
"lib": ["ESNext", "dom"],
"types": ["cypress"],
}
}

View File

@ -1,53 +0,0 @@
import {UserFactory} from '../../factories/user'
import {ProjectFactory} from '../../factories/project'
const testAndAssertFailed = fixture => {
cy.intercept(Cypress.env('API_URL') + '/login*').as('login')
cy.visit('/login')
cy.get('input[id=username]').type(fixture.username)
cy.get('input[id=password]').type(fixture.password)
cy.get('.button').contains('Login').click()
cy.wait('@login')
cy.url().should('include', '/')
cy.get('div.message.danger').contains('Wrong username or password.')
}
const credentials = {
username: 'test',
password: '1234',
}
function login() {
cy.get('input[id=username]').type(credentials.username)
cy.get('input[id=password]').type(credentials.password)
cy.get('.button').contains('Login').click()
cy.url().should('include', '/')
}
context('Login', () => {
beforeEach(() => {
UserFactory.create(1, {username: credentials.username})
})
it('Should fail with a bad password', () => {
const fixture = {
username: 'test',
password: '123456',
}
testAndAssertFailed(fixture)
})
it('Should redirect to the previous route after logging in', () => {
const projects = ProjectFactory.create(1)
cy.visit(`/projects/${projects[0].id}/1`)
cy.url().should('include', '/login')
login()
cy.url().should('include', `/projects/${projects[0].id}/1`)
})
})

View File

@ -1,43 +0,0 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
describe('User Settings', () => {
createFakeUserAndLogin()
it('Changes the user avatar', () => {
cy.intercept(`${Cypress.env('API_URL')}/user/settings/avatar/upload`).as('uploadAvatar')
cy.visit('/user/settings/avatar')
cy.get('input[name=avatarProvider][value=upload]')
.click()
cy.get('input[type=file]', {timeout: 1000})
.selectFile('cypress/fixtures/image.jpg', {force: true}) // The input is not visible, but on purpose
cy.get('.vue-handler-wrapper.vue-handler-wrapper--south .vue-simple-handler.vue-simple-handler--south')
.trigger('mousedown', {which: 1})
.trigger('mousemove', {clientY: 100})
.trigger('mouseup')
cy.get('[data-cy="uploadAvatar"]')
.contains('Upload Avatar')
.click()
cy.wait('@uploadAvatar')
cy.get('.global-notification')
.should('contain', 'Success')
})
it('Updates the name', () => {
cy.visit('/user/settings/general')
cy.get('.general-settings input.input')
.first()
.type('Lorem Ipsum')
cy.get('[data-cy="saveGeneralSettings"]')
.contains('Save')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.navbar .username-dropdown-trigger .username')
.should('contain', 'Lorem Ipsum')
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,17 +0,0 @@
import {faker} from '@faker-js/faker'
import {Factory} from '../support/factory'
export class TeamFactory extends Factory {
static table = 'teams'
static factory() {
const now = new Date()
return {
name: faker.lorem.words(3),
created_by_id: 1,
created: now.toISOString(),
updated: now.toISOString(),
}
}
}

View File

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

View File

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

View File

@ -1,33 +0,0 @@
import {faker} from '@faker-js/faker'
import {Factory} from '../support/factory'
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: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.', // 1234
status: 0,
issuer: 'local',
language: 'en',
created: now.toISOString(),
updated: now.toISOString(),
}
}
}

View File

@ -1,18 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 872 KiB

View File

@ -1,39 +0,0 @@
// This authenticates a user and puts the token in local storage which allows us to perform authenticated requests.
// Built after https://github.com/cypress-io/cypress-example-recipes/tree/bd2d6ffb33214884cab343d38e7f9e6ebffb323f/examples/logging-in__jwt
import {UserFactory} from '../factories/user'
export function login(user, cacheAcrossSpecs = false) {
if (!user) {
throw new Error('Needs user')
}
// Caching session when logging in via page visit
cy.session(`user__${user.username}`, () => {
cy.request('POST', `${Cypress.env('API_URL')}/login`, {
username: user.username,
password: '1234',
}).then(({ body }) => {
window.localStorage.setItem('token', body.token)
})
}, {
cacheAcrossSpecs,
})
}
export function createFakeUser() {
return UserFactory.create(1)[0]
}
export function createFakeUserAndLogin() {
let user
before(() => {
user = createFakeUser()
})
beforeEach(() => {
login(user, true)
})
return user
}

View File

@ -1,61 +0,0 @@
/// <reference types="cypress" />
// ***********************************************
// This example commands.ts shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
//
// declare global {
// namespace Cypress {
// interface Chainable {
// login(email: string, password: string): Chainable<void>
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
// }
// }
// }
Cypress.Commands.add('pasteFile', {prevSubject: true}, (subject, fileName, fileType = 'image/png') => {
// Load the file fixture as base64
cy.fixture(fileName, 'base64').then((fileContent) => {
// Convert base64 to a Blob
const blob = Cypress.Blob.base64StringToBlob(fileContent, fileType)
// Create a File object
const testFile = new File([blob], fileName, {type: fileType})
// Create a DataTransfer and add the file
const dataTransfer = new DataTransfer()
dataTransfer.items.add(testFile)
// Create the paste event with clipboardData containing the file
const pasteEvent = new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: dataTransfer,
})
// Dispatch the paste event on the target element
subject[0].dispatchEvent(pasteEvent)
})
})

View File

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Components App</title>
</head>
<body>
<div data-cy-root></div>
</body>
</html>

View File

@ -1,29 +0,0 @@
// ***********************************************************
// This example support/component.ts is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')
import { mount } from 'cypress/vue'
// Ensure global styles are loaded
import '../../src/styles/global.scss';
Cypress.Commands.add('mount', mount)
// Example use:
// cy.mount(MyComponent)

View File

@ -1,10 +0,0 @@
import './commands'
import '@4tw/cypress-drag-drop'
// see https://github.com/cypress-io/cypress/issues/702#issuecomment-587127275
Cypress.on('window:before:load', (win) => {
// disable service workers
// @ts-ignore
delete win.navigator.__proto__.ServiceWorker
})

View File

@ -1,71 +0,0 @@
import {seed} from './seed'
/**
* A factory makes it easy to seed the database with data.
*/
export class Factory {
static table: string | null = null
static factory() {
return {}
}
/**
* Seeds a bunch of fake data into the database.
*
* Takes an override object as its single argument which will override the data from the factory.
* If the value of one of the override fields is `{increment}` that value will be replaced with an incrementing
* number through all created entities.
*
* @param override
* @returns {[]}
*/
static create(count = 1, override = {}, 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
})
seed(this.table, flatData, truncate)
return data
}
static truncate() {
seed(this.table, null)
}
}

View File

@ -1,119 +0,0 @@
import {TaskFactory} from '../factories/task'
import {TaskBucketFactory} from '../factories/task_buckets'
export function createTasksWithPriorities(buckets?: any[]) {
TaskFactory.truncate()
const highPriorityTask1 = TaskFactory.create(1, {
id: 1,
project_id: 1,
priority: 4,
title: 'High Priority Task 1',
}, false)[0]
const highPriorityTask2 = TaskFactory.create(1, {
id: 2,
project_id: 1,
priority: 4,
title: 'High Priority Task 2',
}, false)[0]
const lowPriorityTask1 = TaskFactory.create(1, {
id: 3,
project_id: 1,
priority: 1,
title: 'Low Priority Task 1',
}, false)[0]
const lowPriorityTask2 = 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) {
TaskBucketFactory.truncate()
TaskBucketFactory.create(1, {
task_id: highPriorityTask1.id,
bucket_id: buckets[0].id,
project_view_id: buckets[0].project_view_id,
}, false)
TaskBucketFactory.create(1, {
task_id: highPriorityTask2.id,
bucket_id: buckets[0].id,
project_view_id: buckets[0].project_view_id,
}, false)
TaskBucketFactory.create(1, {
task_id: lowPriorityTask1.id,
bucket_id: buckets[0].id,
project_view_id: buckets[0].project_view_id,
}, false)
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 function createTasksWithSearch(buckets?: any[]) {
TaskFactory.truncate()
const task1 = TaskFactory.create(1, {
id: 1,
project_id: 1,
title: 'Regular task 1',
}, false)[0]
const task2 = TaskFactory.create(1, {
id: 2,
project_id: 1,
title: 'Regular task 2',
}, false)[0]
const task3 = TaskFactory.create(1, {
id: 3,
project_id: 1,
title: 'Regular task 3',
}, false)[0]
const searchableTask = 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) {
TaskBucketFactory.truncate()
TaskBucketFactory.create(1, {
task_id: task1.id,
bucket_id: buckets[0].id,
project_view_id: buckets[0].project_view_id,
}, false)
TaskBucketFactory.create(1, {
task_id: task2.id,
bucket_id: buckets[0].id,
project_view_id: buckets[0].project_view_id,
}, false)
TaskBucketFactory.create(1, {
task_id: task3.id,
bucket_id: buckets[0].id,
project_view_id: buckets[0].project_view_id,
}, false)
TaskBucketFactory.create(1, {
task_id: searchableTask.id,
bucket_id: buckets[0].id,
project_view_id: buckets[0].project_view_id,
}, false)
}
return { searchableTask }
}

View File

@ -1,12 +0,0 @@
/// <reference types="cypress" />
declare namespace Cypress {
interface Chainable<Subject = any> {
/**
* Pastes a file onto the subject element.
* @param fileName The name of the file to paste
* @param fileType The MIME type of the file (defaults to 'image/png')
*/
pasteFile(fileName: string, fileType?: string): Chainable<Subject>;
}
}

View File

@ -1,24 +0,0 @@
/**
* Seeds a db table with data. If a data object is provided as the second argument, it will load the fixtures
* file for the table and merge the data from it with the passed data. This allows you to override specific
* fields of the fixtures without having to redeclare the whole fixture.
*
* Passing null as the second argument empties the table.
*
* @param table
* @param data
*/
export function seed(table, data = {}, truncate = true) {
if (data === null) {
data = []
}
cy.request({
method: 'PATCH',
url: `${Cypress.env('API_URL')}/test/${table}?truncate=${truncate ? 'true' : 'false'}`,
headers: {
'Authorization': Cypress.env('TEST_SECRET'),
},
body: data,
})
}

View File

@ -1,26 +0,0 @@
export function updateUserSettings(settings) {
const token = `Bearer ${window.localStorage.getItem('token')}`
return cy.request({
method: 'GET',
url: `${Cypress.env('API_URL')}/user`,
headers: {
'Authorization': token,
},
})
.its('body')
.then(oldSettings => {
return cy.request({
method: 'POST',
url: `${Cypress.env('API_URL')}/user/settings/general`,
headers: {
'Authorization': token,
},
body: {
...oldSettings,
...settings,
},
})
})
}

1
frontend/env.d.ts vendored
View File

@ -1,6 +1,5 @@
/// <reference types="vite/client" />
/// <reference types="vite-svg-loader" />
/// <reference types="cypress" />
/// <reference types="@histoire/plugin-vue/components" />
interface ImportMetaEnv {

View File

@ -13,7 +13,6 @@ export default [
{
ignores: [
'**/*.test.ts',
'./cypress',
],
},
{
@ -78,15 +77,6 @@ export default [
},
// 'parser': 'vue-eslint-parser',
// 'parserOptions': {
// 'parser': '@typescript-eslint/parser',
// 'ecmaVersion': 'latest',
// 'tsconfigRootDir': __dirname,
// },
// 'ignorePatterns': [
// 'cypress/*',
// ],
},
]

View File

@ -38,12 +38,6 @@
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui-host=0.0.0.0",
"test:cypress:headed": "start-server-and-test preview http://127.0.0.1:4173 'cypress open --e2e'",
"test:cypress:e2e": "start-server-and-test preview http://127.0.0.1:4173 'cypress run --e2e --browser chrome'",
"test:cypress:e2e-nix": "CYPRESS_RUN_BINARY=`which Cypress` CYPRESS_API_URL=http://127.0.0.1:3456/api/v1 start-server-and-test preview http://127.0.0.1:4173 'cypress run --e2e --browser chromium'",
"test:cypress:e2e-record-test": "start-server-and-test preview:test http://127.0.0.1:4173 'cypress run --e2e --browser chrome --record'",
"test:cypress:e2e-dev-dev": "start-server-and-test preview:dev http://127.0.0.1:4173 'cypress open --e2e'",
"test:cypress:e2e-dev": "start-server-and-test preview http://127.0.0.1:4173 'cypress open --e2e'",
"test:unit": "vitest --dir ./src",
"typecheck": "vue-tsc --build --force",
"fonts:update": "pnpm fonts:download && pnpm fonts:subset",
@ -109,9 +103,6 @@
"zhyswan-vuedraggable": "4.1.3"
},
"devDependencies": {
"@4tw/cypress-drag-drop": "2.3.1",
"@cypress/vite-dev-server": "7.0.1",
"@cypress/vue": "6.0.2",
"@faker-js/faker": "9.9.0",
"@histoire/plugin-screenshot": "1.0.0-alpha.5",
"@histoire/plugin-vue": "1.0.0-alpha.5",
@ -132,7 +123,6 @@
"browserslist": "4.28.1",
"caniuse-lite": "1.0.30001759",
"csstype": "3.2.3",
"cypress": "14.5.4",
"esbuild": "0.27.1",
"eslint": "9.39.1",
"eslint-plugin-vue": "10.6.2",
@ -144,7 +134,6 @@
"rollup": "4.53.3",
"rollup-plugin-visualizer": "6.0.5",
"sass-embedded": "1.93.3",
"start-server-and-test": "2.1.3",
"stylelint": "16.26.1",
"stylelint-config-property-sort-order-smacss": "10.0.0",
"stylelint-config-recommended-vue": "1.6.1",
@ -170,7 +159,6 @@
"onlyBuiltDependencies": [
"@parcel/watcher",
"@sentry/cli",
"cypress",
"esbuild",
"puppeteer",
"vue-demi"

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,6 @@ import type {Directive} from 'vue'
declare global {
interface Window {
Cypress: object;
TESTING?: boolean;
}
}
@ -14,7 +13,7 @@ function isTestingEnabled(): boolean {
return import.meta.env.DEV || window.TESTING === true
}
const cypressDirective = <Directive<HTMLElement,string>>{
const testIdDirective = <Directive<HTMLElement,string>>{
mounted(el, {arg, value}) {
if (!isTestingEnabled()) {
return
@ -31,4 +30,4 @@ const cypressDirective = <Directive<HTMLElement,string>>{
},
}
export default cypressDirective
export default testIdDirective

View File

@ -6,7 +6,7 @@ import './styles/global.scss'
import {createPinia} from 'pinia'
import cypress from '@/directives/cypress'
import testid from '@/directives/testid'
import FontAwesomeIcon from '@/components/misc/Icon'
import XButton from '@/components/input/button.vue'
@ -19,7 +19,7 @@ export const setupVue3 = defineSetupVue3(({ app }) => {
app.use(pinia)
app.use(i18n)
app.directive('cy', cypress)
app.directive('cy', testid)
app.component('Icon', FontAwesomeIcon)
app.component('XButton', XButton)

View File

@ -41,7 +41,7 @@ import focus from '@/directives/focus'
import {vTooltip} from 'floating-vue'
import 'floating-vue/dist/style.css'
import shortcut from '@/directives/shortcut'
import cypress from '@/directives/cypress'
import testid from '@/directives/testid'
// global components
import FontAwesomeIcon from '@/components/misc/Icon'
@ -60,7 +60,7 @@ setLanguage(browserLanguage).then(() => {
app.directive('focus', focus)
app.directive('tooltip', vTooltip)
app.directive('shortcut', shortcut)
app.directive('cy', cypress)
app.directive('cy', testid)
app.component('Icon', FontAwesomeIcon)
app.component('XButton', Button)

View File

@ -1,12 +0,0 @@
import { mount } from 'cypress/vue'
type MountParams = Parameters<typeof mount>;
type OptionsParam = MountParams[1];
declare global {
namespace Cypress {
interface Chainable {
mount: typeof mount;
}
}
}

View File

@ -6,6 +6,7 @@ import {UserFactory} from '../../factories/user'
import {ProjectFactory} from '../../factories/project'
import {createProjects} from './prepareProjects'
import {BucketFactory} from '../../factories/bucket'
import {createTasksWithPriorities, createTasksWithSearch} from '../../support/filterTestHelpers'
test.describe('Project View List', () => {
test('Should be an empty project', async ({authenticatedPage: page}) => {
@ -164,4 +165,36 @@ test.describe('Project View List', () => {
await expect(page.locator('ul.tasks > div > .single-task')).toBeVisible()
await expect(page.locator('ul.tasks > div > .subtask-nested')).toBeVisible()
})
test('Should respect filter query parameter from URL', async ({authenticatedPage: page}) => {
await createProjects(1)
const {highPriorityTasks, lowPriorityTasks} = await createTasksWithPriorities()
await page.goto('/projects/1/1?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('.tasks')).toContainText(highPriorityTasks[0].title, {timeout: 10000})
await expect(page.locator('.tasks')).toContainText(highPriorityTasks[1].title)
// Verify low priority tasks are NOT visible
await expect(page.locator('.tasks')).not.toContainText(lowPriorityTasks[0].title)
await expect(page.locator('.tasks')).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/1?s=meeting')
await expect(page).toHaveURL(/s=meeting/)
// Wait for tasks to load and verify searchable task is visible
await expect(page.locator('.tasks')).toContainText(searchableTask.title, {timeout: 10000})
// Only one task should be visible (the searchable one)
await expect(page.locator('.tasks .task')).toHaveCount(1)
})
})

View File

@ -7,7 +7,7 @@
"env.config.d.ts",
"vite.config.*",
"vitest.config.*",
"cypress.config.*"
"playwright.config.*"
],
"compilerOptions": {
"composite": true,