test: add Vitest + Testing Library unit tests for running-apps feature
Set up Vitest (jsdom environment) and @testing-library/react. api.test.ts — fetchRunningApps: - returns parsed JSON on 2xx - returns [] on non-ok HTTP status - returns [] on network error (non-throwing contract) KnobCard.test.tsx — app_volume: - datalist is rendered with all supplied runningApps as options - no datalist / list attr when runningApps is empty or omitted - onChange called with the new value on input change KnobCard.test.tsx — group_volume: - app-picker select rendered with runningApps options - no picker when runningApps is empty or omitted - Add button disabled until an app is selected - Add button enabled after selection - clicking Add appends app to targets - clicking Add skips duplicates (onChange not called) - picker resets to placeholder after a successful Addmain
parent
88216187d4
commit
2a13248446
File diff suppressed because it is too large
Load Diff
|
|
@ -7,7 +7,9 @@
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
|
|
@ -15,16 +17,22 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.48.0",
|
"typescript-eslint": "^8.48.0",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
/**
|
||||||
|
* Tests for KnobCard — running-apps dropdown feature.
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* app_volume — datalist rendered when runningApps provided
|
||||||
|
* app_volume — no datalist when runningApps is empty / not passed
|
||||||
|
* app_volume — picking a suggestion calls onChange with the chosen value
|
||||||
|
* group_volume — app-picker select rendered when runningApps provided
|
||||||
|
* group_volume — no app-picker when runningApps is empty / not passed
|
||||||
|
* group_volume — Add button is disabled until an app is selected
|
||||||
|
* group_volume — clicking Add appends the app to targets
|
||||||
|
* group_volume — clicking Add does NOT add a duplicate app
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { KnobCard } from '../components/KnobCard';
|
||||||
|
import type { KnobConfig } from '../types';
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderCard(
|
||||||
|
overrides: Partial<KnobConfig> = {},
|
||||||
|
runningApps?: string[],
|
||||||
|
onChange = vi.fn(),
|
||||||
|
) {
|
||||||
|
const knob: KnobConfig = {
|
||||||
|
action: 'app_volume',
|
||||||
|
target: 'default',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
onChange,
|
||||||
|
...render(
|
||||||
|
<KnobCard index={1} knob={knob} runningApps={runningApps} onChange={onChange} />,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── app_volume tests ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('KnobCard — app_volume', () => {
|
||||||
|
it('renders a datalist with the supplied running apps', () => {
|
||||||
|
renderCard({ action: 'app_volume' }, ['spotify', 'vlc', 'brave']);
|
||||||
|
|
||||||
|
// An input with a list attribute is promoted to combobox by jsdom; query
|
||||||
|
// by label text instead to avoid coupling to the ARIA role inference.
|
||||||
|
const input = screen.getByLabelText<HTMLInputElement>('Target');
|
||||||
|
const datalistId = input.getAttribute('list');
|
||||||
|
expect(datalistId).toBeTruthy();
|
||||||
|
|
||||||
|
const datalist = document.getElementById(datalistId!);
|
||||||
|
expect(datalist).toBeInTheDocument();
|
||||||
|
expect(datalist!.querySelectorAll('option')).toHaveLength(3);
|
||||||
|
const values = Array.from(datalist!.querySelectorAll('option')).map(
|
||||||
|
(o) => (o as HTMLOptionElement).value,
|
||||||
|
);
|
||||||
|
expect(values).toEqual(['spotify', 'vlc', 'brave']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not attach a datalist when runningApps is empty', () => {
|
||||||
|
renderCard({ action: 'app_volume' }, []);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText<HTMLInputElement>('Target');
|
||||||
|
expect(input.getAttribute('list')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not attach a datalist when runningApps is not provided', () => {
|
||||||
|
renderCard({ action: 'app_volume' }, undefined);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText<HTMLInputElement>('Target');
|
||||||
|
expect(input.getAttribute('list')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onChange with the typed/selected value when the input changes', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
renderCard({ action: 'app_volume', target: 'default' }, ['spotify'], onChange);
|
||||||
|
|
||||||
|
// fireEvent.change is appropriate for a controlled input: we simulate
|
||||||
|
// the browser firing a change event with the full desired value.
|
||||||
|
fireEvent.change(screen.getByLabelText('Target'), {
|
||||||
|
target: { value: 'brave' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastCall = onChange.mock.calls.at(-1)![0] as KnobConfig;
|
||||||
|
expect(lastCall.target).toBe('brave');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── group_volume tests ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('KnobCard — group_volume', () => {
|
||||||
|
it('renders the app-picker select with running apps', () => {
|
||||||
|
renderCard({ action: 'group_volume', targets: [] }, ['vlc', 'brave']);
|
||||||
|
|
||||||
|
// The placeholder option + 2 apps
|
||||||
|
const select = screen.getByRole<HTMLSelectElement>('combobox', {
|
||||||
|
name: /pick a running app/i,
|
||||||
|
});
|
||||||
|
expect(select).toBeInTheDocument();
|
||||||
|
const options = Array.from(select.options).slice(1); // skip placeholder
|
||||||
|
expect(options.map((o) => o.value)).toEqual(['vlc', 'brave']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render the app-picker when runningApps is empty', () => {
|
||||||
|
renderCard({ action: 'group_volume', targets: [] }, []);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('combobox', { name: /pick a running app/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render the app-picker when runningApps is not provided', () => {
|
||||||
|
renderCard({ action: 'group_volume', targets: [] }, undefined);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('combobox', { name: /pick a running app/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Add button is disabled until an app is selected from the dropdown', () => {
|
||||||
|
renderCard({ action: 'group_volume', targets: [] }, ['spotify']);
|
||||||
|
|
||||||
|
const addBtn = screen.getByRole('button', { name: /add/i });
|
||||||
|
expect(addBtn).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Add button becomes enabled after selecting an app', async () => {
|
||||||
|
renderCard({ action: 'group_volume', targets: [] }, ['spotify']);
|
||||||
|
|
||||||
|
const select = screen.getByRole('combobox', { name: /pick a running app/i });
|
||||||
|
await userEvent.selectOptions(select, 'spotify');
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /add/i })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking Add appends the selected app to targets', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
renderCard({ action: 'group_volume', targets: ['vlc'] }, ['spotify', 'brave'], onChange);
|
||||||
|
|
||||||
|
const select = screen.getByRole('combobox', { name: /pick a running app/i });
|
||||||
|
await userEvent.selectOptions(select, 'brave');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /add/i }));
|
||||||
|
|
||||||
|
const lastCall = onChange.mock.calls.at(-1)![0] as KnobConfig;
|
||||||
|
expect(lastCall.targets).toEqual(['vlc', 'brave']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking Add does not add a duplicate app', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
renderCard({ action: 'group_volume', targets: ['brave'] }, ['spotify', 'brave'], onChange);
|
||||||
|
|
||||||
|
const select = screen.getByRole('combobox', { name: /pick a running app/i });
|
||||||
|
await userEvent.selectOptions(select, 'brave');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /add/i }));
|
||||||
|
|
||||||
|
const lastCall = onChange.mock.calls.at(-1);
|
||||||
|
// onChange should not have been called at all (duplicate guard)
|
||||||
|
expect(lastCall).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets the picker select back to placeholder after a successful Add', async () => {
|
||||||
|
renderCard({ action: 'group_volume', targets: [] }, ['spotify']);
|
||||||
|
|
||||||
|
const select = screen.getByRole<HTMLSelectElement>('combobox', {
|
||||||
|
name: /pick a running app/i,
|
||||||
|
});
|
||||||
|
await userEvent.selectOptions(select, 'spotify');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /add/i }));
|
||||||
|
|
||||||
|
expect(select.value).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
/**
|
||||||
|
* Tests for the fetchRunningApps API helper.
|
||||||
|
*
|
||||||
|
* fetchRunningApps wraps GET /api/apps and is intentionally non-throwing:
|
||||||
|
* - returns the parsed JSON array on success
|
||||||
|
* - returns [] when the server responds with a non-2xx status
|
||||||
|
* - returns [] when the network call itself throws (e.g. server not reachable)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetchRunningApps } from '../api';
|
||||||
|
|
||||||
|
const mockFetch = vi.fn<typeof fetch>();
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeResponse(body: unknown, ok = true, status = 200): Response {
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
json: () => Promise.resolve(body),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('fetchRunningApps', () => {
|
||||||
|
it('returns a sorted list of app names on a successful response', async () => {
|
||||||
|
mockFetch.mockResolvedValue(makeResponse(['vlc', 'brave', 'spotify']));
|
||||||
|
|
||||||
|
const result = await fetchRunningApps();
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/apps');
|
||||||
|
expect(result).toEqual(['vlc', 'brave', 'spotify']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array when the server responds with a non-ok status', async () => {
|
||||||
|
mockFetch.mockResolvedValue(makeResponse(null, false, 500));
|
||||||
|
|
||||||
|
const result = await fetchRunningApps();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array when fetch throws (network error)', async () => {
|
||||||
|
mockFetch.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||||
|
|
||||||
|
const result = await fetchRunningApps();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client", "vitest/globals"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
|
|
|
||||||
|
|
@ -14,4 +14,9 @@ export default defineConfig({
|
||||||
'/api': 'http://localhost:5173',
|
'/api': 'http://localhost:5173',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./src/test-setup.ts'],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue