test: add basic frontend tests

This commit is contained in:
2026-01-05 18:10:09 +02:00
parent 3e7cebc8c3
commit 18a2918446
7 changed files with 1107 additions and 4 deletions

96
.github/workflows/vitest.yml vendored Normal file
View File

@@ -0,0 +1,96 @@
# SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
# SPDX-License-Identifier: AGPL-3.0-or-later
name: Vitest
on: pull_request
permissions:
contents: read
concurrency:
group: vitest-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
src: ${{ steps.changes.outputs.src }}
steps:
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
continue-on-error: true
with:
filters: |
src:
- '.github/workflows/**'
- 'src/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'tsconfig.json'
- 'tsconfig.*.json'
- 'vite.config.ts'
- 'vitest.config.ts'
vitest:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.src != 'false'
name: Vitest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
- name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
with:
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests
run: pnpm test:run
summary:
permissions:
contents: none
runs-on: ubuntu-latest
needs: [changes, vitest]
if: always()
name: vitest-summary
steps:
- name: Summary status
run: if ${{ needs.changes.outputs.src != 'false' && needs.vitest.result != 'success' }}; then exit 1; fi

View File

@@ -13,7 +13,9 @@
"lint": "eslint src",
"format": "eslint --fix src && prettier --write {vite.config.ts,src/,README.md}",
"prepare": "husky",
"gen": "simple-scaffold -c . -k"
"gen": "simple-scaffold -c . -k",
"test": "vitest",
"test:run": "vitest run"
},
"browserslist": [
"extends @nextcloud/browserslist-config"
@@ -36,8 +38,11 @@
"@nextcloud/browserslist-config": "^3.1.2",
"@nextcloud/eslint-config": "^8.4.2",
"@nextcloud/stylelint-config": "^3.1.1",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"eslint": "^9.39.2",
"happy-dom": "^20.0.11",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"prettier": "^2.8.8",
@@ -49,6 +54,7 @@
"typescript-eslint": "^8.51.0",
"vite": "^6.4.1",
"vite-plugin-checker": "^0.12.0",
"vitest": "^4.0.16",
"vue-router": "^4.6.4",
"vue-tsc": "^2.2.12"
}

487
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,178 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import Pagination from './Pagination.vue'
// Mock @nextcloud/l10n
vi.mock('@nextcloud/l10n', () => ({
t: (app: string, text: string, vars?: Record<string, unknown>) => {
if (vars) {
return Object.entries(vars).reduce(
(acc, [key, value]) => acc.replace(`{${key}}`, String(value)),
text,
)
}
return text
},
}))
// Mock @nextcloud/vue/components/NcButton
vi.mock('@nextcloud/vue/components/NcButton', () => ({
default: {
name: 'NcButton',
template:
'<button :disabled="disabled" @click="$emit(\'click\')"><slot /><slot name="icon" /></button>',
props: ['variant', 'disabled', 'ariaLabel', 'title'],
},
}))
// Mock icon components
vi.mock('@icons/PageFirst.vue', () => ({
default: { name: 'PageFirstIcon', template: '<span>«</span>', props: ['size'] },
}))
vi.mock('@icons/PageLast.vue', () => ({
default: { name: 'PageLastIcon', template: '<span>»</span>', props: ['size'] },
}))
vi.mock('@icons/ChevronLeft.vue', () => ({
default: { name: 'ChevronLeftIcon', template: '<span></span>', props: ['size'] },
}))
vi.mock('@icons/ChevronRight.vue', () => ({
default: { name: 'ChevronRightIcon', template: '<span></span>', props: ['size'] },
}))
describe('Pagination', () => {
describe('visibility', () => {
it('should not render when maxPages is 1', () => {
const wrapper = mount(Pagination, {
props: { currentPage: 1, maxPages: 1 },
})
expect(wrapper.find('nav').exists()).toBe(false)
})
it('should render when maxPages is greater than 1', () => {
const wrapper = mount(Pagination, {
props: { currentPage: 1, maxPages: 2 },
})
expect(wrapper.find('nav').exists()).toBe(true)
})
})
describe('pageItems calculation', () => {
it('should show all pages when maxPages <= 10', () => {
const wrapper = mount(Pagination, {
props: { currentPage: 1, maxPages: 5 },
})
// Access the computed property via vm
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
expect(pageItems).toEqual([1, 2, 3, 4, 5])
})
it('should show all pages when maxPages is exactly 10', () => {
const wrapper = mount(Pagination, {
props: { currentPage: 5, maxPages: 10 },
})
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
expect(pageItems).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
})
it('should add ellipsis for pages > 10 when on first page', () => {
const wrapper = mount(Pagination, {
props: { currentPage: 1, maxPages: 20 },
})
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
// Should show: 1, 2, 3 (first 3) + ellipsis + 18, 19, 20 (last 3)
expect(pageItems).toEqual([1, 2, 3, 'ellipsis', 18, 19, 20])
})
it('should add ellipsis for pages > 10 when on last page', () => {
const wrapper = mount(Pagination, {
props: { currentPage: 20, maxPages: 20 },
})
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
// Should show: 1, 2, 3 (first 3) + ellipsis + 18, 19, 20 (last 3)
expect(pageItems).toEqual([1, 2, 3, 'ellipsis', 18, 19, 20])
})
it('should show pages around current page in the middle', () => {
const wrapper = mount(Pagination, {
props: { currentPage: 10, maxPages: 20 },
})
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
// Should show: 1, 2, 3 + ellipsis + 8, 9, 10, 11, 12 + ellipsis + 18, 19, 20
expect(pageItems).toEqual([1, 2, 3, 'ellipsis', 8, 9, 10, 11, 12, 'ellipsis', 18, 19, 20])
})
it('should handle edge case where current page is near the start', () => {
const wrapper = mount(Pagination, {
props: { currentPage: 4, maxPages: 20 },
})
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
// Current=4, so around: 2,3,4,5,6, combined with first 3 (1,2,3): 1,2,3,4,5,6
expect(pageItems).toContain(1)
expect(pageItems).toContain(4)
expect(pageItems).toContain(6)
})
it('should handle edge case where current page is near the end', () => {
const wrapper = mount(Pagination, {
props: { currentPage: 17, maxPages: 20 },
})
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
// Current=17, so around: 15,16,17,18,19, combined with last 3 (18,19,20): 15,16,17,18,19,20
expect(pageItems).toContain(15)
expect(pageItems).toContain(17)
expect(pageItems).toContain(20)
})
})
describe('navigation', () => {
it('should emit update:currentPage when going to a page', async () => {
const wrapper = mount(Pagination, {
props: { currentPage: 5, maxPages: 10 },
})
// Find all buttons and click the one for page 3
const buttons = wrapper.findAll('button')
const page3Button = buttons.find((btn) => btn.text() === '3')
expect(page3Button).toBeDefined()
await page3Button!.trigger('click')
expect(wrapper.emitted('update:currentPage')).toBeTruthy()
expect(wrapper.emitted('update:currentPage')![0]).toEqual([3])
})
it('should not emit when clicking current page', async () => {
const wrapper = mount(Pagination, {
props: { currentPage: 5, maxPages: 10 },
})
const buttons = wrapper.findAll('button')
const page5Button = buttons.find((btn) => btn.text() === '5')
await page5Button!.trigger('click')
expect(wrapper.emitted('update:currentPage')).toBeFalsy()
})
it('should disable first/previous buttons on first page', () => {
const wrapper = mount(Pagination, {
props: { currentPage: 1, maxPages: 10 },
})
const buttons = wrapper.findAll('button')
// First two buttons are first page and previous page
expect(buttons[0].attributes('disabled')).toBeDefined()
expect(buttons[1].attributes('disabled')).toBeDefined()
})
it('should disable next/last buttons on last page', () => {
const wrapper = mount(Pagination, {
props: { currentPage: 10, maxPages: 10 },
})
const buttons = wrapper.findAll('button')
// Last two buttons are next page and last page
const lastIdx = buttons.length - 1
expect(buttons[lastIdx].attributes('disabled')).toBeDefined()
expect(buttons[lastIdx - 1].attributes('disabled')).toBeDefined()
})
})
})

View File

@@ -0,0 +1,188 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import RoleBadge from './RoleBadge.vue'
import type { Role } from '@/types/models'
// Mock isDarkTheme - default to light theme
vi.mock('@nextcloud/vue/functions/isDarkTheme', () => ({
isDarkTheme: false,
}))
// Helper to create a mock role
function createMockRole(overrides: Partial<Role> = {}): Role {
return {
id: 100,
name: 'Test Role',
description: null,
colorLight: null,
colorDark: null,
canAccessAdminTools: false,
canEditRoles: false,
canEditCategories: false,
isSystemRole: false,
roleType: 'custom',
createdAt: Date.now(),
...overrides,
}
}
describe('RoleBadge', () => {
describe('rendering', () => {
it('should display the role name', () => {
const role = createMockRole({ name: 'Super Admin' })
const wrapper = mount(RoleBadge, {
props: { role },
})
expect(wrapper.text()).toBe('Super Admin')
})
it('should apply normal density class by default', () => {
const role = createMockRole()
const wrapper = mount(RoleBadge, {
props: { role },
})
expect(wrapper.find('.role-badge').classes()).toContain('density-normal')
})
it('should apply compact density class when specified', () => {
const role = createMockRole()
const wrapper = mount(RoleBadge, {
props: { role, density: 'compact' },
})
expect(wrapper.find('.role-badge').classes()).toContain('density-compact')
})
})
describe('color calculation', () => {
it('should use colorLight when provided (light theme)', () => {
const role = createMockRole({ colorLight: '#ff5500' })
const wrapper = mount(RoleBadge, {
props: { role },
})
const style = wrapper.find('.role-badge').attributes('style')
expect(style).toContain('background-color: #ff5500')
})
it('should use fallback color for Admin role (id=1)', () => {
const role = createMockRole({ id: 1, name: 'Admin', roleType: 'admin' })
const wrapper = mount(RoleBadge, {
props: { role },
})
const style = wrapper.find('.role-badge').attributes('style')
// Fallback light color for Admin is #dc2626
expect(style).toContain('background-color: #dc2626')
})
it('should use fallback color for Moderator role (id=2)', () => {
const role = createMockRole({ id: 2, name: 'Moderator', roleType: 'moderator' })
const wrapper = mount(RoleBadge, {
props: { role },
})
const style = wrapper.find('.role-badge').attributes('style')
// Fallback light color for Moderator is #2563eb
expect(style).toContain('background-color: #2563eb')
})
it('should use fallback color for User role (id=3)', () => {
const role = createMockRole({ id: 3, name: 'User', roleType: 'default' })
const wrapper = mount(RoleBadge, {
props: { role },
})
const style = wrapper.find('.role-badge').attributes('style')
// Fallback light color for User is #059669
expect(style).toContain('background-color: #059669')
})
it('should use default fallback for custom roles without colors', () => {
const role = createMockRole({ id: 999, name: 'Custom' })
const wrapper = mount(RoleBadge, {
props: { role },
})
const style = wrapper.find('.role-badge').attributes('style')
// Default light fallback is #000000
expect(style).toContain('background-color: #000000')
})
})
describe('text color calculation (contrast)', () => {
it('should use dark text on light backgrounds', () => {
// White background should have black text
const role = createMockRole({ colorLight: '#ffffff' })
const wrapper = mount(RoleBadge, {
props: { role },
})
const style = wrapper.find('.role-badge').attributes('style')
expect(style).toContain('color: #000000')
})
it('should use light text on dark backgrounds', () => {
// Black background should have white text
const role = createMockRole({ colorLight: '#000000' })
const wrapper = mount(RoleBadge, {
props: { role },
})
const style = wrapper.find('.role-badge').attributes('style')
expect(style).toContain('color: #ffffff')
})
it('should use light text on moderately dark backgrounds', () => {
// Dark blue should have white text
const role = createMockRole({ colorLight: '#1e3a5f' })
const wrapper = mount(RoleBadge, {
props: { role },
})
const style = wrapper.find('.role-badge').attributes('style')
expect(style).toContain('color: #ffffff')
})
it('should use dark text on moderately light backgrounds', () => {
// Light yellow should have black text
const role = createMockRole({ colorLight: '#ffeb3b' })
const wrapper = mount(RoleBadge, {
props: { role },
})
const style = wrapper.find('.role-badge').attributes('style')
expect(style).toContain('color: #000000')
})
})
describe('hexToRgb method', () => {
it('should correctly parse 6-digit hex colors', () => {
const role = createMockRole({ colorLight: '#ff5500' })
const wrapper = mount(RoleBadge, {
props: { role },
})
// Access the method via vm
const vm = wrapper.vm as unknown as {
hexToRgb: (hex: string) => { r: number; g: number; b: number } | null
}
const result = vm.hexToRgb('#ff5500')
expect(result).toEqual({ r: 255, g: 85, b: 0 })
})
it('should correctly parse 3-digit shorthand hex colors', () => {
const role = createMockRole({ colorLight: '#f00' })
const wrapper = mount(RoleBadge, {
props: { role },
})
const vm = wrapper.vm as unknown as {
hexToRgb: (hex: string) => { r: number; g: number; b: number } | null
}
// #f00 expands to #ff0000
const result = vm.hexToRgb('#f00')
expect(result).toEqual({ r: 255, g: 0, b: 0 })
})
it('should handle hex without # prefix', () => {
const role = createMockRole({ colorLight: '#00ff00' })
const wrapper = mount(RoleBadge, {
props: { role },
})
const vm = wrapper.vm as unknown as {
hexToRgb: (hex: string) => { r: number; g: number; b: number } | null
}
const result = vm.hexToRgb('00ff00')
expect(result).toEqual({ r: 0, g: 255, b: 0 })
})
})
})

136
src/constants.test.ts Normal file
View File

@@ -0,0 +1,136 @@
import { describe, it, expect } from 'vitest'
import {
RoleType,
isSystemRole,
isAdminRole,
isModeratorRole,
isDefaultRole,
isGuestRole,
isCustomRole,
} from './constants'
import type { Role } from './types/models'
// Helper to create a mock role
function createMockRole(overrides: Partial<Role> = {}): Role {
return {
id: 1,
name: 'Test Role',
description: null,
colorLight: null,
colorDark: null,
canAccessAdminTools: false,
canEditRoles: false,
canEditCategories: false,
isSystemRole: false,
roleType: 'custom',
createdAt: Date.now(),
...overrides,
}
}
describe('RoleType constants', () => {
it('should have correct values', () => {
expect(RoleType.ADMIN).toBe('admin')
expect(RoleType.MODERATOR).toBe('moderator')
expect(RoleType.DEFAULT).toBe('default')
expect(RoleType.GUEST).toBe('guest')
expect(RoleType.CUSTOM).toBe('custom')
})
})
describe('isSystemRole', () => {
it('should return true for system roles', () => {
const role = createMockRole({ isSystemRole: true })
expect(isSystemRole(role)).toBe(true)
})
it('should return false for non-system roles', () => {
const role = createMockRole({ isSystemRole: false })
expect(isSystemRole(role)).toBe(false)
})
})
describe('isAdminRole', () => {
it('should return true for admin roles', () => {
const role = createMockRole({ roleType: 'admin' })
expect(isAdminRole(role)).toBe(true)
})
it('should return false for non-admin roles', () => {
const role = createMockRole({ roleType: 'moderator' })
expect(isAdminRole(role)).toBe(false)
})
it('should return false for null/undefined', () => {
expect(isAdminRole(null)).toBe(false)
expect(isAdminRole(undefined)).toBe(false)
})
})
describe('isModeratorRole', () => {
it('should return true for moderator roles', () => {
const role = createMockRole({ roleType: 'moderator' })
expect(isModeratorRole(role)).toBe(true)
})
it('should return false for non-moderator roles', () => {
const role = createMockRole({ roleType: 'admin' })
expect(isModeratorRole(role)).toBe(false)
})
it('should return false for null/undefined', () => {
expect(isModeratorRole(null)).toBe(false)
expect(isModeratorRole(undefined)).toBe(false)
})
})
describe('isDefaultRole', () => {
it('should return true for default roles', () => {
const role = createMockRole({ roleType: 'default' })
expect(isDefaultRole(role)).toBe(true)
})
it('should return false for non-default roles', () => {
const role = createMockRole({ roleType: 'admin' })
expect(isDefaultRole(role)).toBe(false)
})
it('should return false for null/undefined', () => {
expect(isDefaultRole(null)).toBe(false)
expect(isDefaultRole(undefined)).toBe(false)
})
})
describe('isGuestRole', () => {
it('should return true for guest roles', () => {
const role = createMockRole({ roleType: 'guest' })
expect(isGuestRole(role)).toBe(true)
})
it('should return false for non-guest roles', () => {
const role = createMockRole({ roleType: 'admin' })
expect(isGuestRole(role)).toBe(false)
})
it('should return false for null/undefined', () => {
expect(isGuestRole(null)).toBe(false)
expect(isGuestRole(undefined)).toBe(false)
})
})
describe('isCustomRole', () => {
it('should return true for custom roles', () => {
const role = createMockRole({ roleType: 'custom' })
expect(isCustomRole(role)).toBe(true)
})
it('should return false for non-custom roles', () => {
const role = createMockRole({ roleType: 'admin' })
expect(isCustomRole(role)).toBe(false)
})
it('should return false for null/undefined', () => {
expect(isCustomRole(null)).toBe(false)
expect(isCustomRole(undefined)).toBe(false)
})
})

18
vitest.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'@icons': path.resolve(__dirname, 'node_modules/vue-material-design-icons'),
},
},
test: {
environment: 'happy-dom',
include: ['src/**/*.{test,spec}.{js,ts}'],
globals: true,
},
})