Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions .cursor/rules/web-testing-rules.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
---
description:
globs: **/*.test.ts,**/__test__/components/**/*.ts,**/__test__/store/**/*.ts,**/__test__/mocks/**/*.ts
alwaysApply: false
---

## Vue Component Testing Best Practices

- Use pnpm when running termical commands and stay within the web directory.
- The directory for tests is located under `web/test`

### Setup
- Use `mount` from Vue Test Utils for component testing
- Stub complex child components that aren't the focus of the test
- Mock external dependencies and services

```typescript
import { mount } from '@vue/test-utils';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import YourComponent from '~/components/YourComponent.vue';

// Mock dependencies
vi.mock('~/helpers/someHelper', () => ({
SOME_CONSTANT: 'mocked-value',
}));

describe('YourComponent', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('renders correctly', () => {
const wrapper = mount(YourComponent, {
global: {
stubs: {
// Stub child components when needed
ChildComponent: true,
},
},
});

// Assertions
expect(wrapper.text()).toContain('Expected content');
});
});
```

### Testing Patterns
- Test component behavior and output, not implementation details
- Verify that the expected elements are rendered
- Test component interactions (clicks, inputs, etc.)
- Check for expected prop handling and event emissions

### Finding Elements
- Use semantic queries like `find('button')` or `find('[data-test="id"]')` but prefer not to use data test ID's
- Find components with `findComponent(ComponentName)`
- Use `findAll` to check for multiple elements

### Assertions
- Assert on rendered text content with `wrapper.text()`
- Assert on element attributes with `element.attributes()`
- Verify element existence with `expect(element.exists()).toBe(true)`
- Check component state through rendered output

### Component Interaction
- Trigger events with `await element.trigger('click')`
- Set input values with `await input.setValue('value')`
- Test emitted events with `wrapper.emitted()`

### Mocking
- Mock external services and API calls
- Use `vi.mock()` for module-level mocks
- Specify return values for component methods with `vi.spyOn()`
- Reset mocks between tests with `vi.clearAllMocks()`
- Frequently used mocks are stored under `web/test/mocks`

### Async Testing
- Use `await nextTick()` for DOM updates
- Use `flushPromises()` for more complex promise chains
- Always await async operations before making assertions

## Store Testing with Pinia

### Setup
- Use `createTestingPinia()` to create a test Pinia instance
- Set `createSpy: vi.fn` to automatically spy on actions

```typescript
import { createTestingPinia } from '@pinia/testing';
import { useYourStore } from '~/store/yourStore';

const pinia = createTestingPinia({
createSpy: vi.fn,
});
const store = useYourStore(pinia);
```

### Testing Actions
- Verify actions are called with the right parameters
- Test action side effects if not stubbed
- Override specific action implementations when needed

```typescript
// Test action calls
store.yourAction(params);
expect(store.yourAction).toHaveBeenCalledWith(params);

// Test with real implementation
const pinia = createTestingPinia({
createSpy: vi.fn,
stubActions: false,
});
```

### Testing State & Getters
- Set initial state for focused testing
- Test computed properties by accessing them directly
- Verify state changes by updating the store

128 changes: 128 additions & 0 deletions web/__test__/components/Auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* Auth Component Test Coverage
*/

import { ref } from 'vue';
import { mount } from '@vue/test-utils';

import { describe, expect, it, vi } from 'vitest';

import Auth from '~/components/Auth.ce.vue';

// Define types for our mocks
interface AuthAction {
text: string;
icon: string;
click?: () => void;
disabled?: boolean;
title?: string;
}

interface StateData {
error: boolean;
heading?: string;
message?: string;
}

// Mock vue-i18n
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}));

// Mock the useServerStore composable
const mockServerStore = {
authAction: ref<AuthAction | undefined>(undefined),
stateData: ref<StateData>({ error: false }),
};

vi.mock('~/store/server', () => ({
useServerStore: () => mockServerStore,
}));

// Mock pinia's storeToRefs to simply return the store
vi.mock('pinia', () => ({
storeToRefs: (store: unknown) => store,
}));

describe('Auth Component', () => {
it('displays an authentication button when authAction is available', () => {
// Configure auth action
mockServerStore.authAction.value = {
text: 'Sign in to Unraid',
icon: 'key',
click: vi.fn(),
};
mockServerStore.stateData.value = { error: false };

// Mount component
const wrapper = mount(Auth);

// Verify button exists
const button = wrapper.findComponent({ name: 'BrandButton' });
expect(button.exists()).toBe(true);
// Check props passed to button
expect(button.props('text')).toBe('Sign in to Unraid');
expect(button.props('icon')).toBe('key');
});

it('displays error messages when stateData.error is true', () => {
// Configure with error state
mockServerStore.authAction.value = {
text: 'Sign in to Unraid',
icon: 'key',
};
mockServerStore.stateData.value = {
error: true,
heading: 'Error Title',
message: 'Error Message Content',
};

// Mount component
const wrapper = mount(Auth);

// Verify error message is displayed
const errorHeading = wrapper.find('h3');

expect(errorHeading.exists()).toBe(true);
expect(errorHeading.text()).toBe('Error Title');
expect(wrapper.text()).toContain('Error Message Content');
});

it('calls the click handler when button is clicked', async () => {
// Create mock click handler
const clickHandler = vi.fn();

// Configure with click handler
mockServerStore.authAction.value = {
text: 'Sign in to Unraid',
icon: 'key',
click: clickHandler,
};
mockServerStore.stateData.value = { error: false };

// Mount component
const wrapper = mount(Auth);

// Click the button
await wrapper.findComponent({ name: 'BrandButton' }).vm.$emit('click');

// Verify click handler was called
expect(clickHandler).toHaveBeenCalledTimes(1);
});

it('does not render button when authAction is undefined', () => {
// Configure with undefined auth action
mockServerStore.authAction.value = undefined;
mockServerStore.stateData.value = { error: false };

// Mount component
const wrapper = mount(Auth);

// Verify button doesn't exist
const button = wrapper.findComponent({ name: 'BrandButton' });

expect(button.exists()).toBe(false);
});
});
110 changes: 110 additions & 0 deletions web/__test__/components/DownloadApiLogs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* DownloadApiLogs Component Test Coverage
*/

import { mount } from '@vue/test-utils';

import { BrandButton } from '@unraid/ui';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import DownloadApiLogs from '~/components/DownloadApiLogs.ce.vue';

// Mock the urls helper with a predictable mock URL
vi.mock('~/helpers/urls', () => ({
CONNECT_FORUMS: new URL('http://mock-forums.local'),
CONTACT: new URL('http://mock-contact.local'),
DISCORD: new URL('http://mock-discord.local'),
WEBGUI_GRAPHQL: new URL('http://mock-webgui.local'),
}));

// Mock vue-i18n with a simple implementation
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}));

describe('DownloadApiLogs', () => {
beforeEach(() => {
vi.clearAllMocks();
// Mock global csrf_token
globalThis.csrf_token = 'mock-csrf-token';
});

it('provides a download button with the correct URL', () => {
const wrapper = mount(DownloadApiLogs, {
global: {
stubs: {
ArrowDownTrayIcon: true,
ArrowTopRightOnSquareIcon: true,
},
},
});

// Expected download URL
const expectedUrl = new URL('/graphql/api/logs', 'http://mock-webgui.local');
expectedUrl.searchParams.append('csrf_token', 'mock-csrf-token');

// Find the download button
const downloadButton = wrapper.findComponent(BrandButton);

// Verify download button exists and has correct attributes
expect(downloadButton.exists()).toBe(true);
expect(downloadButton.attributes('href')).toBe(expectedUrl.toString());
expect(downloadButton.attributes('download')).toBe('');
expect(downloadButton.attributes('target')).toBe('_blank');
expect(downloadButton.attributes('rel')).toBe('noopener noreferrer');
expect(downloadButton.text()).toContain('Download unraid-api Logs');
});

it('displays support links to documentation and help resources', () => {
const wrapper = mount(DownloadApiLogs, {
global: {
stubs: {
ArrowDownTrayIcon: true,
ArrowTopRightOnSquareIcon: true,
},
},
});

// Find all support links
const links = wrapper.findAll('a');
expect(links.length).toBe(4);

// Verify each link has correct href and text
expect(links[1].attributes('href')).toBe('http://mock-forums.local/');
expect(links[1].text()).toContain('Unraid Connect Forums');

expect(links[2].attributes('href')).toBe('http://mock-discord.local/');
expect(links[2].text()).toContain('Unraid Discord');

expect(links[3].attributes('href')).toBe('http://mock-contact.local/');
expect(links[3].text()).toContain('Unraid Contact Page');

// Verify all links open in new tab
links.slice(1).forEach((link) => {
expect(link.attributes('target')).toBe('_blank');
expect(link.attributes('rel')).toBe('noopener noreferrer');
});
});

it('displays instructions about log usage and privacy', () => {
const wrapper = mount(DownloadApiLogs, {
global: {
stubs: {
ArrowDownTrayIcon: true,
ArrowTopRightOnSquareIcon: true,
},
},
});

const text = wrapper.text();

// Verify key instructional text is present
expect(text).toContain(
'The primary method of support for Unraid Connect is through our forums and Discord'
);
expect(text).toContain('If you are asked to supply logs');
expect(text).toContain('The logs may contain sensitive information so do not post them publicly');
});
});
Loading
Loading