Skip to content
Draft
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
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"build": "tsc -b && vite build",
"lint": "eslint firmware-droid-client",
"preview": "vite preview",
Expand Down
65 changes: 65 additions & 0 deletions src/hooks/use-mobile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useIsMobile } from '@/hooks/use-mobile';

const MOBILE_BREAKPOINT = 768;

describe('useIsMobile', () => {
let addEventListenerSpy: ReturnType<typeof vi.fn>;
let removeEventListenerSpy: ReturnType<typeof vi.fn>;
let mediaQueryListeners: Array<(e: Partial<MediaQueryListEvent>) => void>;

beforeEach(() => {
mediaQueryListeners = [];
addEventListenerSpy = vi.fn((_event: string, handler: (e: Partial<MediaQueryListEvent>) => void) => {
mediaQueryListeners.push(handler);
});
removeEventListenerSpy = vi.fn();

Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockReturnValue({
matches: false,
addEventListener: addEventListenerSpy,
removeEventListener: removeEventListenerSpy,
}),
});
});

afterEach(() => {
vi.restoreAllMocks();
});

it('returns false when window width is >= breakpoint', () => {
Object.defineProperty(window, 'innerWidth', { writable: true, value: MOBILE_BREAKPOINT });
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
});

it('returns true when window width is below the breakpoint', () => {
Object.defineProperty(window, 'innerWidth', { writable: true, value: MOBILE_BREAKPOINT - 1 });
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(true);
});

it('updates when a media-query change event fires', () => {
Object.defineProperty(window, 'innerWidth', { writable: true, value: MOBILE_BREAKPOINT });
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);

// Simulate window becoming mobile-sized
act(() => {
Object.defineProperty(window, 'innerWidth', { writable: true, value: 375 });
mediaQueryListeners.forEach((handler) => handler({}));
});

expect(result.current).toBe(true);
});

it('removes the event listener on unmount', () => {
Object.defineProperty(window, 'innerWidth', { writable: true, value: 1024 });
const { unmount } = renderHook(() => useIsMobile());
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('change', expect.any(Function));
});
});
36 changes: 36 additions & 0 deletions src/lib/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { cn } from '@/lib/utils';

describe('cn', () => {
it('returns an empty string when called with no arguments', () => {
expect(cn()).toBe('');
});

it('returns a single class unchanged', () => {
expect(cn('foo')).toBe('foo');
});

it('merges multiple classes', () => {
expect(cn('foo', 'bar')).toBe('foo bar');
});

it('deduplicates conflicting Tailwind classes (last wins)', () => {
expect(cn('p-2', 'p-4')).toBe('p-4');
});

it('ignores falsy values', () => {
expect(cn('foo', undefined, null, false, 'bar')).toBe('foo bar');
});

it('supports conditional object syntax', () => {
expect(cn({ 'text-red-500': true, 'text-blue-500': false })).toBe('text-red-500');
});

it('supports array syntax', () => {
expect(cn(['foo', 'bar'])).toBe('foo bar');
});

it('merges tailwind background colors correctly (last wins)', () => {
expect(cn('bg-red-500', 'bg-blue-500')).toBe('bg-blue-500');
});
});
79 changes: 79 additions & 0 deletions src/services/adb-streamer/credentials.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
setCredentials,
getCredentials,
getAuthToken,
clearCredentials,
} from '@/services/adb-streamer/credentials';

// Clear module-level in-memory state and session storage before each test
beforeEach(() => {
clearCredentials();
sessionStorage.clear();
localStorage.clear();
});

describe('setCredentials', () => {
it('returns a Base64 encoded token for provided username/password', () => {
const token = setCredentials('user', 'pass');
expect(token).toBe(btoa('user:pass'));
});

it('stores token in session storage by default', () => {
setCredentials('alice', 'secret');
// After setting, getAuthToken should return the in-memory token
expect(getAuthToken()).toBe(btoa('alice:secret'));
});

it('calls clearCredentials when both username and password are absent', () => {
setCredentials('user', 'pass');
const result = setCredentials(undefined, undefined);
expect(result).toBeUndefined();
expect(getCredentials()).toBeNull();
});

it('returns undefined when both username and password are empty strings (treated as absent)', () => {
const result = setCredentials('', '');
expect(result).toBeUndefined();
});
});

describe('getCredentials', () => {
it('returns null when nothing has been stored', () => {
expect(getCredentials()).toBeNull();
});

it('returns in-memory credentials after setCredentials', () => {
setCredentials('bob', 'pw123');
const creds = getCredentials();
expect(creds).not.toBeNull();
expect(creds!.username).toBe('bob');
expect(creds!.token).toBe(btoa('bob:pw123'));
});
});

describe('getAuthToken', () => {
it('returns undefined when no credentials are set', () => {
expect(getAuthToken()).toBeUndefined();
});

it('returns the token from in-memory storage', () => {
setCredentials('carol', 'p@ssw0rd');
expect(getAuthToken()).toBe(btoa('carol:p@ssw0rd'));
});
});

describe('clearCredentials', () => {
it('clears in-memory credentials', () => {
setCredentials('dave', 'xyz');
clearCredentials();
expect(getCredentials()).toBeNull();
});

it('clears session storage entries', () => {
setCredentials('eve', 'abc');
clearCredentials();
// After clearing, getCredentials should return null even if session is checked
expect(getCredentials()).toBeNull();
});
});
71 changes: 71 additions & 0 deletions src/services/adb-streamer/http-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { setBackendBaseUrl, setAuthToken, apiClient } from '@/services/adb-streamer/http-client';

const originalBaseURL = apiClient.defaults.baseURL;

beforeEach(() => {
// restore base URL and remove auth header between tests
apiClient.defaults.baseURL = originalBaseURL;
delete (apiClient.defaults.headers as Record<string, unknown>)['Authorization'];
});

describe('setBackendBaseUrl', () => {
it('sets the base URL with /api suffix', () => {
setBackendBaseUrl('http://example.com');
expect(apiClient.defaults.baseURL).toBe('http://example.com/api');
});

it('handles a URL with trailing slash', () => {
setBackendBaseUrl('http://example.com/');
expect(apiClient.defaults.baseURL).toBe('http://example.com/api');
});

it('handles https scheme', () => {
setBackendBaseUrl('https://secure.host:8443');
expect(apiClient.defaults.baseURL).toBe('https://secure.host:8443/api');
});

it('converts ws:// to http://', () => {
setBackendBaseUrl('ws://ws-host:1234');
expect(apiClient.defaults.baseURL).toBe('http://ws-host:1234/api');
});

it('converts wss:// to https://', () => {
setBackendBaseUrl('wss://ws-host:1234');
expect(apiClient.defaults.baseURL).toBe('https://ws-host:1234/api');
});

it('sets Authorization header when credentials are embedded in URL', () => {
setBackendBaseUrl('http://user:pass@example.com');
const auth = (apiClient.defaults.headers as Record<string, unknown>)['Authorization'] as string;
expect(auth).toBe(`Basic ${btoa('user:pass')}`);
});

it('does not set Authorization header when no credentials in URL', () => {
setBackendBaseUrl('http://example.com');
expect((apiClient.defaults.headers as Record<string, unknown>)['Authorization']).toBeUndefined();
});

it('throws on an invalid URL', () => {
expect(() => setBackendBaseUrl('not-a-url')).toThrow('Invalid URL');
});

it('is a no-op for empty string', () => {
const before = apiClient.defaults.baseURL;
setBackendBaseUrl('');
expect(apiClient.defaults.baseURL).toBe(before);
});
});

describe('setAuthToken', () => {
it('sets Authorization header when token is provided', () => {
setAuthToken('Bearer mytoken');
expect((apiClient.defaults.headers as Record<string, unknown>)['Authorization']).toBe('Bearer mytoken');
});

it('removes Authorization header when token is undefined', () => {
(apiClient.defaults.headers as Record<string, unknown>)['Authorization'] = 'Bearer old';
setAuthToken(undefined);
expect((apiClient.defaults.headers as Record<string, unknown>)['Authorization']).toBeUndefined();
});
});
119 changes: 119 additions & 0 deletions src/services/adb-streamer/storage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { Storage } from '@/services/adb-streamer/storage';

describe('Storage (in-memory fallback, no window)', () => {
let store: Storage;

beforeEach(() => {
store = new Storage();
// Clear jsdom storage between tests (Storage constructor uses real
// localStorage/sessionStorage when window is defined)
localStorage.clear();
sessionStorage.clear();
});

describe('local storage', () => {
it('setLocal / getLocal round-trips a value', () => {
store.setLocal('myKey', 'myValue');
expect(store.getLocal('myKey')).toBe('myValue');
});

it('getLocal returns null for unknown key', () => {
expect(store.getLocal('missing')).toBeNull();
});

it('removeLocal deletes the key', () => {
store.setLocal('k', 'v');
store.removeLocal('k');
expect(store.getLocal('k')).toBeNull();
});
});

describe('session storage', () => {
it('setSession / getSession round-trips a value', () => {
store.setSession('sk', 'sv');
expect(store.getSession('sk')).toBe('sv');
});

it('getSession returns null for unknown key', () => {
expect(store.getSession('unknown')).toBeNull();
});

it('removeSession deletes the key', () => {
store.setSession('sk', 'sv');
store.removeSession('sk');
expect(store.getSession('sk')).toBeNull();
});
});

describe('get() priority: cookie > session > local', () => {
it('returns session value when no cookie is set', () => {
store.setSession('key', 'session-val');
store.setLocal('key', 'local-val');
expect(store.get('key')).toBe('session-val');
});

it('returns local value when neither cookie nor session has the key', () => {
store.setLocal('key', 'local-only');
expect(store.get('key')).toBe('local-only');
});

it('returns null when no storage has the key', () => {
expect(store.get('no-such-key')).toBeNull();
});
});

describe('cookie operations (no document)', () => {
it('getCookie returns null when no cookie source is available', () => {
// In the in-memory fallback, cookieSource is undefined so no cookies
expect(store.getCookie('anything')).toBeNull();
});

it('setCookie is a no-op when no cookie source is available', () => {
// Should not throw
expect(() => store.setCookie('key', 'val', 7)).not.toThrow();
});
});
});

describe('Storage with real localStorage/sessionStorage (jsdom)', () => {
let store: Storage;

beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
store = new Storage();
});

it('setLocal persists to window.localStorage', () => {
store.setLocal('lkey', 'lval');
expect(localStorage.getItem('lkey')).toBe('lval');
});

it('getLocal reads from window.localStorage', () => {
localStorage.setItem('lkey2', 'lval2');
expect(store.getLocal('lkey2')).toBe('lval2');
});

it('removeLocal removes from window.localStorage', () => {
localStorage.setItem('rmKey', 'rmVal');
store.removeLocal('rmKey');
expect(localStorage.getItem('rmKey')).toBeNull();
});

it('setSession persists to window.sessionStorage', () => {
store.setSession('skey', 'sval');
expect(sessionStorage.getItem('skey')).toBe('sval');
});

it('getSession reads from window.sessionStorage', () => {
sessionStorage.setItem('skey2', 'sval2');
expect(store.getSession('skey2')).toBe('sval2');
});

it('removeSession removes from window.sessionStorage', () => {
sessionStorage.setItem('rmS', 'v');
store.removeSession('rmS');
expect(sessionStorage.getItem('rmS')).toBeNull();
});
});
Loading