mirror of
https://github.com/chenasraf/nextcloud-app-template.git
synced 2026-05-17 17:28:09 +00:00
test: add frontend tests
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
templates/
|
||||
scaffolds/
|
||||
gen/
|
||||
|
||||
266
README.md
266
README.md
@@ -36,6 +36,7 @@ Once you have it cloned on your machine:
|
||||
- [Tips & gotchas](#tips--gotchas)
|
||||
- [GitHub Workflows](#github-workflows)
|
||||
- [Project layout](#project-layout)
|
||||
- [Testing](#testing)
|
||||
- [Release Please (automated versioning & releases)](#release-please-automated-versioning--releases)
|
||||
- [Resources](#resources)
|
||||
|
||||
@@ -296,6 +297,271 @@ them to `.github/workflows/`** so Actions start running automatically.
|
||||
└─ rename-template.sh # One-time renamer script for template cloning
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Frontend Testing
|
||||
|
||||
This template includes a complete frontend testing setup using [Vitest](https://vitest.dev/) and
|
||||
[Vue Test Utils](https://test-utils.vuejs.org/).
|
||||
|
||||
#### Running tests
|
||||
|
||||
```bash
|
||||
# Run tests in watch mode (recommended during development)
|
||||
pnpm test
|
||||
|
||||
# Run tests once (useful for CI)
|
||||
pnpm test:run
|
||||
```
|
||||
|
||||
#### Test file structure
|
||||
|
||||
Test files are placed next to the files they test, using the `.test.ts` suffix:
|
||||
|
||||
```
|
||||
src/
|
||||
├─ utils/
|
||||
│ ├─ string.ts # Utility functions
|
||||
│ └─ string.test.ts # Tests for string.ts
|
||||
├─ components/
|
||||
│ ├─ StatusBadge.vue # Vue component
|
||||
│ └─ StatusBadge.test.ts # Tests for StatusBadge.vue
|
||||
```
|
||||
|
||||
#### Writing tests
|
||||
|
||||
##### Pure TypeScript/utility functions
|
||||
|
||||
For utility functions, use the standard `describe`/`it`/`expect` pattern:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { myFunction } from './myModule'
|
||||
|
||||
describe('myFunction', () => {
|
||||
it('handles normal input', () => {
|
||||
expect(myFunction('hello')).toBe('HELLO')
|
||||
})
|
||||
|
||||
it('handles null input', () => {
|
||||
expect(myFunction(null)).toBe('')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
##### Vue components
|
||||
|
||||
For Vue components, you'll need to mock Nextcloud dependencies. Here's a typical pattern:
|
||||
|
||||
```typescript
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import MyComponent from './MyComponent.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
|
||||
},
|
||||
n: (app: string, singular: string, plural: string, count: number) => {
|
||||
return count === 1 ? singular : plural
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock Nextcloud Vue components
|
||||
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/Check.vue', () => ({
|
||||
default: { name: 'CheckIcon', template: '<span />', props: ['size'] },
|
||||
}))
|
||||
|
||||
describe('MyComponent', () => {
|
||||
it('renders with props', () => {
|
||||
const wrapper = mount(MyComponent, {
|
||||
props: { title: 'Hello' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Hello')
|
||||
})
|
||||
|
||||
it('emits events', async () => {
|
||||
const wrapper = mount(MyComponent, {
|
||||
props: { clickable: true },
|
||||
})
|
||||
await wrapper.trigger('click')
|
||||
expect(wrapper.emitted('click')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('computes values correctly', () => {
|
||||
const wrapper = mount(MyComponent, {
|
||||
props: { count: 5 },
|
||||
})
|
||||
// Access computed properties via wrapper.vm
|
||||
expect((wrapper.vm as InstanceType<typeof MyComponent>).doubleCount).toBe(10)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
#### Tips
|
||||
|
||||
- **Test file location**: Place test files next to the files they test (e.g., `Component.test.ts`
|
||||
next to `Component.vue`)
|
||||
- **TypeScript errors**: You may see "Cannot find module './Component.vue'" errors in test files.
|
||||
These can be ignored as Vitest handles Vue files correctly at runtime
|
||||
- **Mocking**: Keep mocks minimal - only mock what's necessary for the test to run
|
||||
- **happy-dom**: This template uses happy-dom instead of jsdom for faster test execution. Note that
|
||||
happy-dom preserves hex colors (e.g., `#ff5500`) rather than converting to RGB
|
||||
- **Globals**: The vitest config enables globals, so you don't need to import `describe`, `it`,
|
||||
`expect` in every file (though explicit imports are recommended for clarity)
|
||||
|
||||
#### Resources
|
||||
|
||||
- [Vitest documentation](https://vitest.dev/)
|
||||
- [Vue Test Utils documentation](https://test-utils.vuejs.org/)
|
||||
- [Testing Vue 3 components](https://test-utils.vuejs.org/guide/)
|
||||
|
||||
### Backend Testing (PHP)
|
||||
|
||||
This template uses [PHPUnit](https://phpunit.de/) for PHP unit testing, integrated with the
|
||||
Nextcloud testing framework.
|
||||
|
||||
#### Running PHP tests
|
||||
|
||||
There are two ways to run PHP tests:
|
||||
|
||||
**Option 1: Docker (recommended)**
|
||||
|
||||
```bash
|
||||
make test-docker
|
||||
```
|
||||
|
||||
This automatically finds a running Nextcloud container and runs tests inside it. Works with
|
||||
[nextcloud-docker-dev](https://github.com/juliushaertl/nextcloud-docker-dev) and similar setups.
|
||||
|
||||
**Option 2: Local Nextcloud installation**
|
||||
|
||||
```bash
|
||||
# Set NEXTCLOUD_ROOT to your Nextcloud server path
|
||||
NEXTCLOUD_ROOT=~/path/to/nextcloud make test
|
||||
|
||||
# Or set it in the Makefile (line 47) for convenience
|
||||
make test
|
||||
```
|
||||
|
||||
#### Test file structure
|
||||
|
||||
PHP tests live in the `tests/` directory:
|
||||
|
||||
```
|
||||
tests/
|
||||
├─ unit/
|
||||
│ └─ Controller/
|
||||
│ └─ ApiTest.php # Unit tests for ApiController
|
||||
├─ bootstrap.php # Test bootstrap (loads Nextcloud environment)
|
||||
├─ phpunit.xml # PHPUnit config for local testing
|
||||
└─ phpunit.docker.xml # PHPUnit config for Docker testing
|
||||
```
|
||||
|
||||
#### Writing PHP tests
|
||||
|
||||
Here's an example test showing how to mock Nextcloud dependencies:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Controller;
|
||||
|
||||
use OCA\YourApp\AppInfo\Application;
|
||||
use OCA\YourApp\Controller\ApiController;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\IRequest;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ApiTest extends TestCase {
|
||||
private ApiController $controller;
|
||||
/** @var IRequest&MockObject */
|
||||
private IRequest $request;
|
||||
/** @var IAppConfig&MockObject */
|
||||
private IAppConfig $config;
|
||||
/** @var IL10N&MockObject */
|
||||
private IL10N $l10n;
|
||||
|
||||
protected function setUp(): void {
|
||||
$this->request = $this->createMock(IRequest::class);
|
||||
$this->config = $this->createMock(IAppConfig::class);
|
||||
$this->l10n = $this->createMock(IL10N::class);
|
||||
|
||||
// Mock translation to return the input string
|
||||
$this->l10n->method('t')
|
||||
->willReturnCallback(function ($text, $params = []) {
|
||||
if (empty($params)) {
|
||||
return $text;
|
||||
}
|
||||
return vsprintf($text, $params);
|
||||
});
|
||||
|
||||
$this->controller = new ApiController(
|
||||
Application::APP_ID,
|
||||
$this->request,
|
||||
$this->config,
|
||||
$this->l10n
|
||||
);
|
||||
}
|
||||
|
||||
public function testGetHello(): void {
|
||||
$this->config->method('getValueString')
|
||||
->willReturn('');
|
||||
|
||||
$resp = $this->controller->getHello()->getData();
|
||||
|
||||
$this->assertIsArray($resp);
|
||||
$this->assertArrayHasKey('message', $resp);
|
||||
}
|
||||
|
||||
public function testPostHello(): void {
|
||||
$this->config->expects($this->once())
|
||||
->method('setValueString');
|
||||
|
||||
$resp = $this->controller->postHello([
|
||||
'name' => 'World',
|
||||
])->getData();
|
||||
|
||||
$this->assertStringContainsString('World', $resp['message']);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Tips
|
||||
|
||||
- **Mocking**: Use `$this->createMock()` for Nextcloud interfaces like `IRequest`, `IAppConfig`,
|
||||
`IL10N`, etc.
|
||||
- **Test isolation**: Each test should be independent; use `setUp()` to create fresh mocks
|
||||
- **Naming convention**: Test files should end with `Test.php` (e.g., `ApiTest.php`)
|
||||
- **Docker vs local**: Docker testing is more reliable as it uses a fully configured Nextcloud
|
||||
environment
|
||||
|
||||
#### Resources
|
||||
|
||||
- [PHPUnit documentation](https://docs.phpunit.de/)
|
||||
- [Nextcloud app testing guide](https://docs.nextcloud.com/server/latest/developer_manual/digging_deeper/testing.html)
|
||||
|
||||
## Release Please (automated versioning & releases)
|
||||
|
||||
This template includes **[Release Please](https://github.com/googleapis/release-please)** to
|
||||
|
||||
89
gen/component/{{pascalCase name}}.test.ts
Normal file
89
gen/component/{{pascalCase name}}.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// SPDX-FileCopyrightText: Your Name <your@email.com>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
/**
|
||||
* Unit tests for {{pascalCase name}} component.
|
||||
*
|
||||
* Run tests:
|
||||
* pnpm test # watch mode
|
||||
* pnpm test:run # single run
|
||||
*
|
||||
* See src/components/StatusBadge.test.ts for a complete example.
|
||||
*/
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createIconMock, nextcloudL10nMock } from '@/test-utils'
|
||||
import {{ pascalCase name }} from './{{pascalCase name}}.vue'
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Mocks - uncomment as needed
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Mock @nextcloud/l10n (if your component uses t() or n())
|
||||
// vi.mock('@nextcloud/l10n', () => nextcloudL10nMock)
|
||||
|
||||
// Mock icon components (adjust path and name as needed)
|
||||
// vi.mock('@icons/Check.vue', () => createIconMock('CheckIcon'))
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('{{pascalCase name}}', () => {
|
||||
// Example: Basic rendering
|
||||
// it('renders correctly', () => {
|
||||
// const wrapper = mount({{pascalCase name}})
|
||||
// expect(wrapper.exists()).toBe(true)
|
||||
// })
|
||||
|
||||
// Example: Testing with props
|
||||
// it('renders with props', () => {
|
||||
// const wrapper = mount({{pascalCase name}}, {
|
||||
// props: { title: 'Hello' },
|
||||
// })
|
||||
// expect(wrapper.text()).toContain('Hello')
|
||||
// })
|
||||
|
||||
// Example: Testing CSS classes
|
||||
// it('applies correct CSS class', () => {
|
||||
// const wrapper = mount({{pascalCase name}}, {
|
||||
// props: { variant: 'primary' },
|
||||
// })
|
||||
// expect(wrapper.classes()).toContain('is-primary')
|
||||
// })
|
||||
|
||||
// Example: Testing emitted events
|
||||
// it('emits click event', async () => {
|
||||
// const wrapper = mount({{pascalCase name}})
|
||||
// await wrapper.trigger('click')
|
||||
// expect(wrapper.emitted('click')).toBeTruthy()
|
||||
// })
|
||||
|
||||
// Example: Testing computed properties
|
||||
// it('computes derived value', () => {
|
||||
// const wrapper = mount({{pascalCase name}}, {
|
||||
// props: { count: 5 },
|
||||
// })
|
||||
// const vm = wrapper.vm as InstanceType<typeof {{pascalCase name}}>
|
||||
// expect(vm.doubleCount).toBe(10)
|
||||
// })
|
||||
|
||||
// Example: Testing conditional rendering
|
||||
// it('shows content when condition is met', () => {
|
||||
// const wrapper = mount({{pascalCase name}}, {
|
||||
// props: { showDetails: true },
|
||||
// })
|
||||
// expect(wrapper.find('.details').exists()).toBe(true)
|
||||
// })
|
||||
|
||||
// Example: Testing slots
|
||||
// it('renders slot content', () => {
|
||||
// const wrapper = mount({{pascalCase name}}, {
|
||||
// slots: { default: 'Slot content' },
|
||||
// })
|
||||
// expect(wrapper.text()).toContain('Slot content')
|
||||
// })
|
||||
|
||||
it.todo('add your tests here')
|
||||
})
|
||||
@@ -1,10 +1,14 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: Your Name <your@email.com>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<div>{{ startCase name }}</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComopnent, type PropType } from 'vue'
|
||||
// import NcComponentExample from '@nextcloud/vue/dist/Components/NcComponentExample.js'
|
||||
// import NcComponentExample from '@nextcloud/vue/components/NcComponentExample'
|
||||
|
||||
// import IconExample from '@icons/Example.vue'
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComopnent, type PropType } from 'vue'
|
||||
// import NcComponentExample from '@nextcloud/vue/dist/Components/NcComponentExample.js'
|
||||
// import NcComponentExample from '@nextcloud/vue/components/NcComponentExample'
|
||||
|
||||
// import IconExample from '@icons/Example.vue'
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -34,8 +36,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",
|
||||
@@ -47,6 +52,7 @@
|
||||
"typescript-eslint": "^8.50.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
487
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,6 @@ module.exports = () => {
|
||||
component: {
|
||||
templates: ['gen/component'],
|
||||
output: 'src/components',
|
||||
subDir: false,
|
||||
},
|
||||
view: {
|
||||
templates: ['gen/view'],
|
||||
|
||||
270
src/components/StatusBadge.test.ts
Normal file
270
src/components/StatusBadge.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Example test file demonstrating Vue component testing with mocked dependencies.
|
||||
*
|
||||
* This shows how to:
|
||||
* - Mock @nextcloud/l10n translation functions
|
||||
* - Mock icon components from vue-material-design-icons
|
||||
* - Mount components with props
|
||||
* - Test computed properties via wrapper.vm
|
||||
* - Test emitted events
|
||||
* - Test conditional rendering
|
||||
* - Test CSS classes and inline styles
|
||||
*/
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createIconMock, nextcloudL10nMock } from '@/test-utils'
|
||||
import StatusBadge from './StatusBadge.vue'
|
||||
|
||||
// Mock @nextcloud/l10n
|
||||
vi.mock('@nextcloud/l10n', () => nextcloudL10nMock)
|
||||
|
||||
// Mock icon components
|
||||
vi.mock('@icons/Check.vue', () => createIconMock('CheckIcon'))
|
||||
vi.mock('@icons/Alert.vue', () => createIconMock('AlertIcon'))
|
||||
vi.mock('@icons/ClockOutline.vue', () => createIconMock('ClockIcon'))
|
||||
|
||||
describe('StatusBadge', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders with required status prop', () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'success' },
|
||||
})
|
||||
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
expect(wrapper.classes()).toContain('status-badge')
|
||||
})
|
||||
|
||||
it('renders the label when provided', () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'success', label: 'Completed' },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.status-label').text()).toBe('Completed')
|
||||
})
|
||||
|
||||
it('renders without label when not provided', () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'success' },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.status-label').text()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CSS classes', () => {
|
||||
it('applies correct class for success status', () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'success' },
|
||||
})
|
||||
|
||||
expect(wrapper.classes()).toContain('status-success')
|
||||
})
|
||||
|
||||
it('applies correct class for warning status', () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'warning' },
|
||||
})
|
||||
|
||||
expect(wrapper.classes()).toContain('status-warning')
|
||||
})
|
||||
|
||||
it('applies correct class for pending status', () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'pending' },
|
||||
})
|
||||
|
||||
expect(wrapper.classes()).toContain('status-pending')
|
||||
})
|
||||
|
||||
it('applies correct class for error status', () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'error' },
|
||||
})
|
||||
|
||||
expect(wrapper.classes()).toContain('status-error')
|
||||
})
|
||||
|
||||
it('applies clickable class when clickable prop is true', () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'success', clickable: true },
|
||||
})
|
||||
|
||||
expect(wrapper.classes()).toContain('status-clickable')
|
||||
})
|
||||
|
||||
it('does not apply clickable class when clickable prop is false', () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'success', clickable: false },
|
||||
})
|
||||
|
||||
expect(wrapper.classes()).not.toContain('status-clickable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('inline styles', () => {
|
||||
it('applies custom color style when customColor is provided', () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'success', customColor: '#ff5500' },
|
||||
})
|
||||
|
||||
const style = wrapper.attributes('style')
|
||||
// Note: happy-dom preserves hex colors instead of converting to RGB
|
||||
expect(style).toContain('background-color: #ff5500')
|
||||
})
|
||||
|
||||
it('does not apply custom style when customColor is not provided', () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'success' },
|
||||
})
|
||||
|
||||
const style = wrapper.attributes('style')
|
||||
expect(style).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('icon rendering', () => {
|
||||
it('renders CheckIcon for success status', () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'success', showIcon: true },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.mock-check-icon').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders AlertIcon for warning status', () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'warning', showIcon: true },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.mock-alert-icon').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders ClockIcon for pending status', () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'pending', showIcon: true },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.mock-clock-icon').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders AlertIcon for error status', () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'error', showIcon: true },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.mock-alert-icon').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render icon when showIcon is false', () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'success', showIcon: false },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.mock-check-icon').exists()).toBe(false)
|
||||
expect(wrapper.find('.mock-alert-icon').exists()).toBe(false)
|
||||
expect(wrapper.find('.mock-clock-icon').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computed properties', () => {
|
||||
it('computes correct tooltipText for success status', () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'success' },
|
||||
})
|
||||
|
||||
// Access computed property via wrapper.vm
|
||||
expect((wrapper.vm as InstanceType<typeof StatusBadge>).tooltipText).toBe(
|
||||
'Completed successfully',
|
||||
)
|
||||
})
|
||||
|
||||
it('computes correct tooltipText for error status', () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'error' },
|
||||
})
|
||||
|
||||
expect((wrapper.vm as InstanceType<typeof StatusBadge>).tooltipText).toBe('Failed')
|
||||
})
|
||||
|
||||
it('computes statusClass correctly', () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'warning', clickable: true },
|
||||
})
|
||||
|
||||
const statusClass = (wrapper.vm as InstanceType<typeof StatusBadge>).statusClass
|
||||
expect(statusClass).toEqual({
|
||||
'status-warning': true,
|
||||
'status-clickable': true,
|
||||
})
|
||||
})
|
||||
|
||||
it('computes customStyle as null when no customColor', () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'success' },
|
||||
})
|
||||
|
||||
expect((wrapper.vm as InstanceType<typeof StatusBadge>).customStyle).toBeNull()
|
||||
})
|
||||
|
||||
it('computes customStyle correctly when customColor is set', () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'success', customColor: '#123456' },
|
||||
})
|
||||
|
||||
expect((wrapper.vm as InstanceType<typeof StatusBadge>).customStyle).toEqual({
|
||||
'--status-color': '#123456',
|
||||
backgroundColor: '#123456',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('events', () => {
|
||||
it('emits click event when clickable and clicked', async () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'success', clickable: true },
|
||||
})
|
||||
|
||||
await wrapper.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('click')).toBeTruthy()
|
||||
expect(wrapper.emitted('click')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not emit click event when not clickable', async () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'success', clickable: false },
|
||||
})
|
||||
|
||||
await wrapper.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('click')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('passes the MouseEvent to the click handler', async () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'success', clickable: true },
|
||||
})
|
||||
|
||||
await wrapper.trigger('click')
|
||||
|
||||
const emittedEvents = wrapper.emitted('click')
|
||||
expect(emittedEvents).toBeTruthy()
|
||||
expect(emittedEvents![0][0]).toBeInstanceOf(MouseEvent)
|
||||
})
|
||||
})
|
||||
|
||||
describe('title attribute', () => {
|
||||
it('sets title attribute based on status', () => {
|
||||
const wrapper = mount(StatusBadge, {
|
||||
props: { status: 'pending' },
|
||||
})
|
||||
|
||||
expect(wrapper.attributes('title')).toBe('In progress')
|
||||
})
|
||||
})
|
||||
})
|
||||
148
src/components/StatusBadge.vue
Normal file
148
src/components/StatusBadge.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<span
|
||||
class="status-badge"
|
||||
:class="statusClass"
|
||||
:style="customStyle"
|
||||
:title="tooltipText"
|
||||
@click="handleClick"
|
||||
>
|
||||
<component :is="iconComponent" v-if="iconComponent" :size="16" class="status-icon" />
|
||||
<span class="status-label">{{ label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import CheckIcon from '@icons/Check.vue'
|
||||
import AlertIcon from '@icons/Alert.vue'
|
||||
import ClockIcon from '@icons/ClockOutline.vue'
|
||||
|
||||
export type StatusType = 'success' | 'warning' | 'pending' | 'error'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'StatusBadge',
|
||||
components: {
|
||||
CheckIcon,
|
||||
AlertIcon,
|
||||
ClockIcon,
|
||||
},
|
||||
props: {
|
||||
status: {
|
||||
type: String as PropType<StatusType>,
|
||||
required: true,
|
||||
validator: (value: string) => ['success', 'warning', 'pending', 'error'].includes(value),
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showIcon: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
clickable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
customColor: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ['click'],
|
||||
computed: {
|
||||
statusClass(): Record<string, boolean> {
|
||||
return {
|
||||
[`status-${this.status}`]: true,
|
||||
'status-clickable': this.clickable,
|
||||
}
|
||||
},
|
||||
customStyle(): Record<string, string> | null {
|
||||
if (!this.customColor) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
'--status-color': this.customColor,
|
||||
backgroundColor: this.customColor,
|
||||
}
|
||||
},
|
||||
tooltipText(): string {
|
||||
const statusLabels: Record<StatusType, string> = {
|
||||
success: t('nextcloudapptemplate', 'Completed successfully'),
|
||||
warning: t('nextcloudapptemplate', 'Completed with warnings'),
|
||||
pending: t('nextcloudapptemplate', 'In progress'),
|
||||
error: t('nextcloudapptemplate', 'Failed'),
|
||||
}
|
||||
return statusLabels[this.status] || ''
|
||||
},
|
||||
iconComponent(): typeof CheckIcon | typeof AlertIcon | typeof ClockIcon | null {
|
||||
if (!this.showIcon) {
|
||||
return null
|
||||
}
|
||||
const icons: Record<StatusType, typeof CheckIcon | typeof AlertIcon | typeof ClockIcon> = {
|
||||
success: CheckIcon,
|
||||
warning: AlertIcon,
|
||||
pending: ClockIcon,
|
||||
error: AlertIcon,
|
||||
}
|
||||
return icons[this.status]
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleClick(event: MouseEvent): void {
|
||||
if (this.clickable) {
|
||||
this.$emit('click', event)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
&.status-success {
|
||||
background-color: var(--color-success-light, #e8f5e9);
|
||||
color: var(--color-success, #4caf50);
|
||||
}
|
||||
|
||||
&.status-warning {
|
||||
background-color: var(--color-warning-light, #fff3e0);
|
||||
color: var(--color-warning, #ff9800);
|
||||
}
|
||||
|
||||
&.status-pending {
|
||||
background-color: var(--color-info-light, #e3f2fd);
|
||||
color: var(--color-info, #2196f3);
|
||||
}
|
||||
|
||||
&.status-error {
|
||||
background-color: var(--color-error-light, #ffebee);
|
||||
color: var(--color-error, #f44336);
|
||||
}
|
||||
|
||||
&.status-clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
60
src/test-utils.ts
Normal file
60
src/test-utils.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Test utilities and mock factories for Nextcloud dependencies.
|
||||
*
|
||||
* Usage in test files:
|
||||
*
|
||||
* import { vi } from 'vitest'
|
||||
* import { nextcloudL10nMock, createIconMock } from '@/test-utils'
|
||||
*
|
||||
* vi.mock('@nextcloud/l10n', () => nextcloudL10nMock)
|
||||
* vi.mock('@icons/Check.vue', () => createIconMock('CheckIcon'))
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mock implementation for @nextcloud/l10n.
|
||||
*
|
||||
* - `t()` returns the text as-is, with variable substitution for {key} patterns
|
||||
* - `n()` returns singular or plural based on count
|
||||
*
|
||||
* @example
|
||||
* vi.mock('@nextcloud/l10n', () => nextcloudL10nMock)
|
||||
*/
|
||||
export const nextcloudL10nMock = {
|
||||
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
|
||||
},
|
||||
n: (_app: string, singular: string, plural: string, count: number) => {
|
||||
return count === 1 ? singular : plural
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock for an icon component from vue-material-design-icons.
|
||||
*
|
||||
* @param name - Component name (e.g., 'CheckIcon')
|
||||
* @param className - Optional CSS class (defaults to 'mock-{kebab-case-name}')
|
||||
* @returns Mock factory object for vi.mock()
|
||||
*
|
||||
* @example
|
||||
* vi.mock('@icons/Check.vue', () => createIconMock('CheckIcon'))
|
||||
* // Creates: <span class="mock-check-icon" />
|
||||
*
|
||||
* vi.mock('@icons/Alert.vue', () => createIconMock('AlertIcon', 'my-alert'))
|
||||
* // Creates: <span class="my-alert" />
|
||||
*/
|
||||
export function createIconMock(name: string, className?: string) {
|
||||
const cssClass = className ?? `mock-${name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()}`
|
||||
return {
|
||||
default: {
|
||||
name,
|
||||
template: `<span class="${cssClass}" data-icon="${name}" />`,
|
||||
props: ['size'],
|
||||
},
|
||||
}
|
||||
}
|
||||
137
src/utils/string.test.ts
Normal file
137
src/utils/string.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Example test file demonstrating pure TypeScript/utility function testing.
|
||||
*
|
||||
* This shows how to:
|
||||
* - Use describe/it/expect structure
|
||||
* - Test various inputs including edge cases
|
||||
* - Handle null/undefined values
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { capitalize, formatFileSize, truncate } from './string'
|
||||
|
||||
describe('truncate', () => {
|
||||
it('returns empty string for null input', () => {
|
||||
expect(truncate(null, 10)).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string for undefined input', () => {
|
||||
expect(truncate(undefined, 10)).toBe('')
|
||||
})
|
||||
|
||||
it('returns the original string if shorter than maxLength', () => {
|
||||
expect(truncate('hello', 10)).toBe('hello')
|
||||
})
|
||||
|
||||
it('returns the original string if equal to maxLength', () => {
|
||||
expect(truncate('hello', 5)).toBe('hello')
|
||||
})
|
||||
|
||||
it('truncates and adds ellipsis when string exceeds maxLength', () => {
|
||||
expect(truncate('hello world', 8)).toBe('hello...')
|
||||
})
|
||||
|
||||
it('uses custom ellipsis when provided', () => {
|
||||
expect(truncate('hello world', 8, '…')).toBe('hello w…')
|
||||
})
|
||||
|
||||
it('returns empty string for negative maxLength', () => {
|
||||
expect(truncate('hello', -1)).toBe('')
|
||||
})
|
||||
|
||||
it('handles zero maxLength', () => {
|
||||
expect(truncate('hello', 0)).toBe('')
|
||||
})
|
||||
|
||||
it('handles maxLength smaller than ellipsis', () => {
|
||||
expect(truncate('hello world', 2)).toBe('..')
|
||||
})
|
||||
|
||||
it('handles empty string input', () => {
|
||||
expect(truncate('', 10)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('capitalize', () => {
|
||||
it('returns empty string for null input', () => {
|
||||
expect(capitalize(null)).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string for undefined input', () => {
|
||||
expect(capitalize(undefined)).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string for empty string input', () => {
|
||||
expect(capitalize('')).toBe('')
|
||||
})
|
||||
|
||||
it('capitalizes a lowercase string', () => {
|
||||
expect(capitalize('hello')).toBe('Hello')
|
||||
})
|
||||
|
||||
it('keeps already capitalized string unchanged', () => {
|
||||
expect(capitalize('Hello')).toBe('Hello')
|
||||
})
|
||||
|
||||
it('capitalizes single character', () => {
|
||||
expect(capitalize('a')).toBe('A')
|
||||
})
|
||||
|
||||
it('handles strings starting with numbers', () => {
|
||||
expect(capitalize('123abc')).toBe('123abc')
|
||||
})
|
||||
|
||||
it('only capitalizes the first character', () => {
|
||||
expect(capitalize('hELLO')).toBe('HELLO')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatFileSize', () => {
|
||||
it('returns "0 B" for null input', () => {
|
||||
expect(formatFileSize(null)).toBe('0 B')
|
||||
})
|
||||
|
||||
it('returns "0 B" for undefined input', () => {
|
||||
expect(formatFileSize(undefined)).toBe('0 B')
|
||||
})
|
||||
|
||||
it('returns "0 B" for zero bytes', () => {
|
||||
expect(formatFileSize(0)).toBe('0 B')
|
||||
})
|
||||
|
||||
it('returns "0 B" for negative bytes', () => {
|
||||
expect(formatFileSize(-100)).toBe('0 B')
|
||||
})
|
||||
|
||||
it('formats bytes correctly', () => {
|
||||
expect(formatFileSize(500)).toBe('500 B')
|
||||
})
|
||||
|
||||
it('formats kilobytes correctly', () => {
|
||||
expect(formatFileSize(1024)).toBe('1 KB')
|
||||
expect(formatFileSize(1536)).toBe('1.5 KB')
|
||||
})
|
||||
|
||||
it('formats megabytes correctly', () => {
|
||||
expect(formatFileSize(1048576)).toBe('1 MB')
|
||||
expect(formatFileSize(1572864)).toBe('1.5 MB')
|
||||
})
|
||||
|
||||
it('formats gigabytes correctly', () => {
|
||||
expect(formatFileSize(1073741824)).toBe('1 GB')
|
||||
})
|
||||
|
||||
it('respects custom decimal places', () => {
|
||||
expect(formatFileSize(1536, 0)).toBe('2 KB')
|
||||
expect(formatFileSize(1536, 1)).toBe('1.5 KB')
|
||||
expect(formatFileSize(1536, 3)).toBe('1.5 KB')
|
||||
})
|
||||
|
||||
it('handles Infinity', () => {
|
||||
expect(formatFileSize(Infinity)).toBe('0 B')
|
||||
})
|
||||
|
||||
it('handles NaN', () => {
|
||||
expect(formatFileSize(NaN)).toBe('0 B')
|
||||
})
|
||||
})
|
||||
70
src/utils/string.ts
Normal file
70
src/utils/string.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Truncates a string to a specified length, adding an ellipsis if truncated.
|
||||
*
|
||||
* @param str - The string to truncate
|
||||
* @param maxLength - Maximum length of the resulting string (including ellipsis)
|
||||
* @param ellipsis - The ellipsis string to append (default: '...')
|
||||
* @returns The truncated string
|
||||
*/
|
||||
export function truncate(
|
||||
str: string | null | undefined,
|
||||
maxLength: number,
|
||||
ellipsis: string = '...',
|
||||
): string {
|
||||
if (str == null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (maxLength < 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (str.length <= maxLength) {
|
||||
return str
|
||||
}
|
||||
|
||||
const truncatedLength = maxLength - ellipsis.length
|
||||
if (truncatedLength <= 0) {
|
||||
return ellipsis.slice(0, maxLength)
|
||||
}
|
||||
|
||||
return str.slice(0, truncatedLength) + ellipsis
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalizes the first letter of a string.
|
||||
*
|
||||
* @param str - The string to capitalize
|
||||
* @returns The string with its first letter capitalized
|
||||
*/
|
||||
export function capitalize(str: string | null | undefined): string {
|
||||
if (str == null || str.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a number as a human-readable file size.
|
||||
*
|
||||
* @param bytes - The number of bytes
|
||||
* @param decimals - Number of decimal places (default: 2)
|
||||
* @returns Formatted string like "1.5 KB" or "2.3 MB"
|
||||
*/
|
||||
export function formatFileSize(bytes: number | null | undefined, decimals: number = 2): string {
|
||||
if (bytes == null || bytes < 0 || !Number.isFinite(bytes)) {
|
||||
return '0 B'
|
||||
}
|
||||
|
||||
if (bytes === 0) {
|
||||
return '0 B'
|
||||
}
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
const index = Math.min(i, sizes.length - 1)
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, index)).toFixed(decimals))} ${sizes[index]}`
|
||||
}
|
||||
@@ -86,8 +86,9 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50%">{{ strings.colMessage }}</th>
|
||||
<th style="width: 30%">{{ strings.colAt }}</th>
|
||||
<th style="width: 40%">{{ strings.colMessage }}</th>
|
||||
<th style="width: 15%">{{ strings.colStatus }}</th>
|
||||
<th style="width: 25%">{{ strings.colAt }}</th>
|
||||
<th style="width: 20%">{{ strings.colActions }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -96,6 +97,12 @@
|
||||
<td class="ellipsis">
|
||||
<span class="mono">{{ hello.message }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge
|
||||
:status="hello.synced ? 'success' : 'pending'"
|
||||
:label="hello.synced ? strings.statusSynced : strings.statusLocal"
|
||||
/>
|
||||
</td>
|
||||
<td class="nowrap">
|
||||
<NcDateTime v-if="hello.at" :timestamp="new Date(hello.at).valueOf()" />
|
||||
<span v-else class="muted">{{ strings.never }}</span>
|
||||
@@ -136,6 +143,7 @@ import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
|
||||
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { ocs } from '@/axios'
|
||||
import { t, n } from '@nextcloud/l10n'
|
||||
|
||||
@@ -149,6 +157,7 @@ export default {
|
||||
NcEmptyContent,
|
||||
NcLoadingIcon,
|
||||
NcDateTime,
|
||||
StatusBadge,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -205,8 +214,11 @@ export default {
|
||||
emptyDesc: t('nextcloudapptemplate', 'Try adding one using the form above.'),
|
||||
addExample: t('nextcloudapptemplate', 'Add example'),
|
||||
colMessage: t('nextcloudapptemplate', 'Message'),
|
||||
colStatus: t('nextcloudapptemplate', 'Status'),
|
||||
colAt: t('nextcloudapptemplate', 'Time'),
|
||||
colActions: t('nextcloudapptemplate', 'Actions'),
|
||||
statusSynced: t('nextcloudapptemplate', 'Synced'),
|
||||
statusLocal: t('nextcloudapptemplate', 'Local'),
|
||||
duplicate: t('nextcloudapptemplate', 'Duplicate'),
|
||||
remove: t('nextcloudapptemplate', 'Remove'),
|
||||
clearAll: t('nextcloudapptemplate', 'Clear all'),
|
||||
@@ -257,6 +269,7 @@ export default {
|
||||
id: genId(),
|
||||
message: data.message,
|
||||
at: data.at ?? null,
|
||||
synced: true,
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -282,7 +295,7 @@ export default {
|
||||
const data = resp.data
|
||||
const message = data?.message ?? `Hello, ${name}!`
|
||||
const at = data?.at ?? new Date().toISOString()
|
||||
this.hellos.unshift({ id: genId(), message, at })
|
||||
this.hellos.unshift({ id: genId(), message, at, synced: true })
|
||||
this.clearForm()
|
||||
this.formOpen = false
|
||||
} catch (e) {
|
||||
@@ -295,7 +308,8 @@ export default {
|
||||
duplicate(index) {
|
||||
const src = this.hellos[index]
|
||||
if (!src) return
|
||||
this.hellos.splice(index + 1, 0, { ...src, id: genId() })
|
||||
// Duplicated items are local-only until synced
|
||||
this.hellos.splice(index + 1, 0, { ...src, id: genId(), synced: false })
|
||||
},
|
||||
|
||||
remove(index) {
|
||||
@@ -307,10 +321,12 @@ export default {
|
||||
},
|
||||
|
||||
seedOne() {
|
||||
// Seeded examples are local-only
|
||||
this.hellos.push({
|
||||
id: genId(),
|
||||
message: '👋 Hello example',
|
||||
at: new Date().toISOString(),
|
||||
synced: false,
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
18
vitest.config.ts
Normal file
18
vitest.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
'@icons': path.resolve(__dirname, 'node_modules/vue-material-design-icons'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'happy-dom',
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user