, not a heading role
+ await expect(page.getByText('Welcome back')).toBeVisible();
+ await expect(page.getByLabel(/email/i)).toBeVisible();
+ await expect(page.getByLabel(/password/i)).toBeVisible();
+ await expect(page.getByRole('button', { name: /login/i })).toBeVisible();
+ });
+
+ test('login button is disabled when fields are empty', async ({ page }) => {
+ await page.goto('/login');
+ await page.waitForLoadState('networkidle');
+ // Button requires email, password, and turnstile — all empty means disabled
+ await expect(page.getByRole('button', { name: /login/i })).toBeDisabled();
+ });
+
+ test('does not log in with wrong credentials', async ({ page }) => {
+ await page.goto('/login');
+ await page.waitForLoadState('networkidle');
+ await page.getByLabel(/email/i).fill('nonexistent@e2e.openshock.test');
+ await page.getByLabel(/password/i).fill('WrongPass1!');
+ // Wait for turnstile dev-bypass to fire and button to enable
+ await expect(page.getByRole('button', { name: /login/i })).toBeEnabled({ timeout: 5000 });
+ await page.getByRole('button', { name: /login/i }).click();
+ // Wrong credentials must not redirect to /home. Error UI varies (toast,
+ // inline aria-invalid, etc.) — the load-bearing invariant is "still on /login".
+ await page.waitForTimeout(2500);
+ expect(page.url()).toMatch(/\/login/);
+ });
+
+ test('successful login redirects to /home', async ({ page, user }) => {
+ await page.goto('/login');
+ await page.waitForLoadState('networkidle');
+ await page.getByLabel(/email/i).fill(user.credentials.email);
+ await page.getByLabel(/password/i).fill(user.credentials.password);
+ // Wait for turnstile dev-bypass to fire and button to enable
+ await expect(page.getByRole('button', { name: /login/i })).toBeEnabled({ timeout: 5000 });
+ await page.getByRole('button', { name: /login/i }).click();
+ await page.waitForURL(/\/home/, { timeout: 10000 });
+ expect(page.url()).toMatch(/\/home/);
+ });
+
+ test('unauthenticated access to /home redirects to login', async ({ page }) => {
+ await page.goto('/home');
+ await page.waitForURL(/\/login/, { timeout: 8000 });
+ expect(page.url()).toMatch(/\/login/);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Signup page
+// ---------------------------------------------------------------------------
+
+test.describe('signup page', () => {
+ test('renders the signup form', async ({ page }) => {
+ await page.goto('/signup');
+ await page.waitForLoadState('networkidle');
+ // Card.Title renders as a
, not a heading role
+ await expect(page.getByText('Create your account')).toBeVisible();
+ // Form appears after backendMetadata loads (useEmail becomes true if no OAuth providers)
+ await expect(page.getByLabel(/username/i)).toBeVisible({ timeout: 5000 });
+ await expect(page.getByLabel(/email/i)).toBeVisible({ timeout: 5000 });
+ await expect(page.getByLabel(/password/i).first()).toBeVisible({ timeout: 5000 });
+ });
+
+ test('shows link to login page', async ({ page }) => {
+ await page.goto('/signup');
+ await page.waitForLoadState('networkidle');
+ // Wait for form to appear
+ await expect(page.getByLabel(/username/i)).toBeVisible({ timeout: 5000 });
+ await expect(page.getByRole('link', { name: /sign in|log in|already have/i })).toBeVisible();
+ });
+
+ test('does not navigate away on incomplete form submission', async ({ page }) => {
+ await page.goto('/signup');
+ await page.waitForLoadState('networkidle');
+ // Wait for the form to appear
+ await expect(page.getByRole('button', { name: /create account/i })).toBeVisible({
+ timeout: 5000,
+ });
+ // Button is disabled for empty form; force-click to verify no navigation occurs
+ await page.getByRole('button', { name: /create account/i }).click({ force: true });
+ // Still on signup page
+ expect(page.url()).toMatch(/\/signup/);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Logout
+// ---------------------------------------------------------------------------
+
+test.describe('logout', () => {
+ test('authenticated user can log out via UI', async ({ authedPage }) => {
+ await authedPage.goto('/home');
+ await authedPage.waitForURL(/\/home/);
+
+ // Look for a logout button/menu item (may require opening a user menu first)
+ const logoutBtn = authedPage.getByRole('button', { name: /log out|sign out/i });
+ const logoutLink = authedPage.getByRole('link', { name: /log out|sign out/i });
+
+ const hasBtn = await logoutBtn.count();
+ const hasLink = await logoutLink.count();
+
+ if (hasBtn === 0 && hasLink === 0) {
+ // Look for a user/avatar menu to open first
+ const avatar = authedPage.getByRole('button', { name: /account|profile|menu/i }).first();
+ if (await avatar.count()) {
+ await avatar.click();
+ await authedPage.waitForTimeout(300);
+ }
+ }
+
+ const logoutEl = authedPage
+ .getByRole('button', { name: /log out|sign out/i })
+ .or(authedPage.getByRole('link', { name: /log out|sign out/i }))
+ .first();
+
+ if (await logoutEl.count()) {
+ await logoutEl.click();
+ // After logout, should land on login page or home (public)
+ await authedPage.waitForURL(/\/login|\/$/i, { timeout: 5000 });
+ }
+ });
+
+ test('API logout invalidates the session cookie', async ({ user }) => {
+ // Login via API to get cookies, then logout via API
+ const freshCookies = await apiLogin(user.credentials.email, user.credentials.password);
+ await apiLogout(freshCookies);
+
+ // Attempt to access protected endpoint should fail
+ const res = await fetch(`${BACKEND_URL}/1/users/self`, {
+ headers: {
+ Cookie: freshCookies.map((c) => c.split(';', 1)[0]).join('; '),
+ },
+ });
+ // Should be 401 after logout
+ expect(res.status).toBe(401);
+ });
+});
diff --git a/e2e/integration/cross-cutting.spec.ts b/e2e/integration/cross-cutting.spec.ts
new file mode 100644
index 00000000..1a5ae2c2
--- /dev/null
+++ b/e2e/integration/cross-cutting.spec.ts
@@ -0,0 +1,120 @@
+import { expect, test } from './lib/test-fixtures';
+
+// ---------------------------------------------------------------------------
+// Error handling
+// ---------------------------------------------------------------------------
+
+test.describe('error handling', () => {
+ test('visiting a non-existent route returns a 404 page, not a 500', async ({ page }) => {
+ const res = await page.goto('/this-route-does-not-exist-xyz');
+ expect(res?.status()).not.toBe(500);
+ // Should show a 404 or redirect
+ const is404 = res?.status() === 404;
+ const isRedirected = (res?.status() ?? 500) < 400;
+ expect(is404 || isRedirected).toBe(true);
+ });
+
+ test('accessing a deep unknown path returns a clean response', async ({ page }) => {
+ const res = await page.goto('/settings/completely/nonexistent/nested/route');
+ expect(res?.status()).not.toBe(500);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Security headers
+// ---------------------------------------------------------------------------
+
+test.describe('security headers', () => {
+ test('response includes X-Frame-Options or CSP frame-ancestors', async ({ page }) => {
+ const res = await page.goto('/login');
+ const headers = res?.headers() ?? {};
+ const hasFrameOptions = 'x-frame-options' in headers;
+ const hasCSP = 'content-security-policy' in headers;
+ const cspHasFrameAncestors = (headers['content-security-policy'] ?? '').includes(
+ 'frame-ancestors'
+ );
+ expect(hasFrameOptions || (hasCSP && cspHasFrameAncestors)).toBe(true);
+ });
+
+ test('X-Content-Type-Options is set to nosniff', async ({ page }) => {
+ const res = await page.goto('/login');
+ const headers = res?.headers() ?? {};
+ // nosniff prevents MIME-type sniffing attacks
+ expect(headers['x-content-type-options']).toBe('nosniff');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Navigation and breadcrumbs
+// ---------------------------------------------------------------------------
+
+test.describe('navigation', () => {
+ test('home page renders the main navigation sidebar/navbar', async ({ authedPage }) => {
+ await authedPage.goto('/home');
+ await authedPage.waitForLoadState('networkidle');
+ // Navigation should have links to major sections
+ await expect(authedPage.getByRole('navigation').first()).toBeVisible({ timeout: 5000 });
+ });
+
+ test('sidebar/nav links to shockers section', async ({ authedPage }) => {
+ await authedPage.goto('/home');
+ await authedPage.waitForLoadState('networkidle');
+ await expect(authedPage.getByRole('link', { name: /shocker/i }).first())
+ .toBeVisible({ timeout: 5000 })
+ .catch(() => {
+ // May be in a collapsed menu
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Responsive behaviour
+// ---------------------------------------------------------------------------
+
+test.describe('responsive layout', () => {
+ test('login page renders correctly at mobile viewport', async ({ page }) => {
+ await page.setViewportSize({ width: 375, height: 812 });
+ await page.goto('/login');
+ await expect(page.getByLabel(/email/i)).toBeVisible();
+ await expect(page.getByLabel(/password/i)).toBeVisible();
+ await expect(page.getByRole('button', { name: /login/i })).toBeVisible();
+ });
+
+ test('home page renders at tablet viewport without horizontal overflow', async ({
+ authedPage,
+ }) => {
+ await authedPage.setViewportSize({ width: 768, height: 1024 });
+ await authedPage.goto('/home');
+ await authedPage.waitForLoadState('networkidle');
+ const bodyWidth = await authedPage.evaluate(() => document.body.scrollWidth);
+ const viewportWidth = await authedPage.evaluate(() => window.innerWidth);
+ // Allow up to 20px tolerance for scrollbars
+ expect(bodyWidth).toBeLessThanOrEqual(viewportWidth + 20);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Console errors — page should not produce JS errors
+// ---------------------------------------------------------------------------
+
+test.describe('no JavaScript errors on page load', () => {
+ const PAGES_TO_CHECK = ['/login', '/signup'];
+
+ for (const route of PAGES_TO_CHECK) {
+ test(`${route} loads without uncaught JS errors`, async ({ page }) => {
+ const errors: string[] = [];
+ page.on('pageerror', (err) => errors.push(err.message));
+ await page.goto(route);
+ await page.waitForLoadState('networkidle');
+ expect(errors).toHaveLength(0);
+ });
+ }
+
+ test('/home loads without uncaught JS errors (authenticated)', async ({ authedPage }) => {
+ const errors: string[] = [];
+ authedPage.on('pageerror', (err) => errors.push(err.message));
+ await authedPage.goto('/home');
+ await authedPage.waitForLoadState('networkidle');
+ expect(errors).toHaveLength(0);
+ });
+});
diff --git a/e2e/integration/lib/api-client.ts b/e2e/integration/lib/api-client.ts
new file mode 100644
index 00000000..491df4f5
--- /dev/null
+++ b/e2e/integration/lib/api-client.ts
@@ -0,0 +1,102 @@
+import { BACKEND_URL, MAILPIT_URL, TURNSTILE_BYPASS } from './env';
+
+export type Credentials = {
+ username: string;
+ email: string;
+ password: string;
+};
+
+export type AuthCookies = string[];
+
+async function readBody(res: Response): Promise
{
+ try {
+ return await res.text();
+ } catch {
+ return '';
+ }
+}
+
+async function expectOk(res: Response, label: string): Promise {
+ if (res.ok) return;
+ throw new Error(`${label} failed: ${res.status} ${res.statusText} — ${await readBody(res)}`);
+}
+
+export async function signup(creds: Credentials): Promise {
+ const res = await fetch(`${BACKEND_URL}/2/account/signup`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ...creds, turnstileResponse: TURNSTILE_BYPASS }),
+ });
+ await expectOk(res, 'signup');
+}
+
+/** Fetches the activation token from Mailpit and calls the API to activate the account. */
+export async function activateAccount(email: string): Promise {
+ // Poll Mailpit for the activation email (up to 10s)
+ let token: string | null = null;
+ for (let attempt = 0; attempt < 20; attempt++) {
+ const search = await fetch(
+ `${MAILPIT_URL}/api/v1/search?query=${encodeURIComponent(`to:${email}`)}&limit=1`
+ );
+ if (search.ok) {
+ const data = (await search.json()) as { messages?: { ID: string }[] };
+ const msgId = data.messages?.[0]?.ID;
+ if (msgId) {
+ const msg = await fetch(`${MAILPIT_URL}/api/v1/message/${msgId}`);
+ if (msg.ok) {
+ const msgData = (await msg.json()) as { Text?: string };
+ const match = (msgData.Text ?? '').match(/[?&]token=([A-Za-z0-9]+)/);
+ if (match) {
+ token = match[1];
+ break;
+ }
+ }
+ }
+ }
+ await new Promise((r) => setTimeout(r, 500));
+ }
+
+ if (!token) throw new Error(`activateAccount: no activation email found for ${email} in Mailpit`);
+
+ const res = await fetch(`${BACKEND_URL}/1/account/activate?token=${token}`, { method: 'POST' });
+ await expectOk(res, 'activate');
+}
+
+export async function login(email: string, password: string): Promise {
+ const res = await fetch(`${BACKEND_URL}/2/account/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ usernameOrEmail: email, password, turnstileResponse: TURNSTILE_BYPASS }),
+ });
+ await expectOk(res, 'login');
+ const setCookies = res.headers.getSetCookie?.() ?? [];
+ if (setCookies.length === 0) {
+ throw new Error('login succeeded but returned no Set-Cookie header — auth not bootstrapped');
+ }
+ return setCookies;
+}
+
+function joinCookieHeader(cookies: AuthCookies): string {
+ return cookies.map((c) => c.split(';', 1)[0]).join('; ');
+}
+
+export async function deleteSelf(cookies: AuthCookies): Promise {
+ const res = await fetch(`${BACKEND_URL}/1/account`, {
+ method: 'DELETE',
+ headers: { Cookie: joinCookieHeader(cookies) },
+ });
+ // 404 is acceptable if the user was never persisted past signup-pending state
+ if (!res.ok && res.status !== 404) {
+ throw new Error(
+ `account-delete failed: ${res.status} ${res.statusText} — ${await readBody(res)}`
+ );
+ }
+}
+
+export async function logout(cookies: AuthCookies): Promise {
+ const res = await fetch(`${BACKEND_URL}/1/account/logout`, {
+ method: 'POST',
+ headers: { Cookie: joinCookieHeader(cookies) },
+ });
+ await expectOk(res, 'logout');
+}
diff --git a/e2e/integration/lib/env.ts b/e2e/integration/lib/env.ts
new file mode 100644
index 00000000..c66b6ab0
--- /dev/null
+++ b/e2e/integration/lib/env.ts
@@ -0,0 +1,4 @@
+export const FRONTEND_URL = process.env.TEST_FRONTEND_URL ?? 'https://localhost:5173';
+export const BACKEND_URL = process.env.TEST_BACKEND_URL ?? 'https://localhost:5001';
+export const MAILPIT_URL = process.env.TEST_MAILPIT_URL ?? 'http://localhost:8025';
+export const TURNSTILE_BYPASS = process.env.TEST_TURNSTILE_BYPASS ?? 'dev-bypass';
diff --git a/e2e/integration/lib/global-setup.ts b/e2e/integration/lib/global-setup.ts
new file mode 100644
index 00000000..e6926cfc
--- /dev/null
+++ b/e2e/integration/lib/global-setup.ts
@@ -0,0 +1,7 @@
+// The integration docker stack is brought up by `scripts/dev-integration.mjs`
+// (the webServer command) because Playwright starts the webServer before
+// globalSetup, so Vite's SSR fetches would race the API container coming up.
+// Keep this hook in place for future cross-test setup work.
+export default function globalSetup() {
+ // intentionally empty
+}
diff --git a/e2e/integration/lib/global-teardown.ts b/e2e/integration/lib/global-teardown.ts
new file mode 100644
index 00000000..d6f6df48
--- /dev/null
+++ b/e2e/integration/lib/global-teardown.ts
@@ -0,0 +1,12 @@
+import { execSync } from 'child_process';
+import path from 'path';
+
+const root = path.resolve(import.meta.dirname, '../../..');
+
+export default function globalTeardown() {
+ if (!process.env.CI) return;
+ execSync('docker compose -f docker-compose.integration.yml down', {
+ cwd: root,
+ stdio: 'inherit',
+ });
+}
diff --git a/e2e/integration/lib/test-fixtures.ts b/e2e/integration/lib/test-fixtures.ts
new file mode 100644
index 00000000..5f2e2057
--- /dev/null
+++ b/e2e/integration/lib/test-fixtures.ts
@@ -0,0 +1,98 @@
+import { test as base, type BrowserContext, type Page } from '@playwright/test';
+import {
+ activateAccount,
+ login as apiLogin,
+ signup as apiSignup,
+ deleteSelf,
+ type AuthCookies,
+ type Credentials,
+} from './api-client';
+import { FRONTEND_URL } from './env';
+
+function uniqueId(): string {
+ return `${Date.now().toString(36)}-${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}`;
+}
+
+export function makeCredentials(prefix = 'pw'): Credentials {
+ const id = uniqueId();
+ return {
+ username: `${prefix}_${id}`.slice(0, 32),
+ email: `${prefix}_${id}@e2e.openshock.test`,
+ password: `Password!${id}A1`,
+ };
+}
+
+async function applyCookiesToContext(context: BrowserContext, cookies: AuthCookies): Promise {
+ const url = new URL(FRONTEND_URL);
+ const apiHost = new URL(process.env.TEST_BACKEND_URL ?? 'https://api.openshock.dev').hostname;
+ const parsed = cookies.flatMap((raw) => {
+ const [pair, ...attrs] = raw.split(';').map((s) => s.trim());
+ const [name, ...rest] = pair.split('=');
+ if (!name || rest.length === 0) return [];
+ const value = rest.join('=');
+ const attrMap = Object.fromEntries(
+ attrs.map((a) => {
+ const [k, ...v] = a.split('=');
+ return [k.toLowerCase(), v.join('=')];
+ })
+ );
+ const sameSite =
+ attrMap['samesite']?.toLowerCase() === 'none'
+ ? 'None'
+ : attrMap['samesite']?.toLowerCase() === 'strict'
+ ? 'Strict'
+ : 'Lax';
+ return [
+ {
+ name,
+ value,
+ domain: attrMap['domain'] ?? apiHost,
+ path: attrMap['path'] ?? '/',
+ httpOnly: 'httponly' in attrMap,
+ secure: 'secure' in attrMap,
+ sameSite: sameSite as 'Lax' | 'None' | 'Strict',
+ },
+ ];
+ });
+ await context.addCookies(parsed);
+ // touch the URL so SvelteKit picks up state
+ void url;
+}
+
+export type TestUser = {
+ credentials: Credentials;
+ cookies: AuthCookies;
+};
+
+export const test = base.extend<{
+ user: TestUser;
+ authedPage: Page;
+}>({
+ user: async ({ browserName: _browserName }, use) => {
+ const credentials = makeCredentials();
+ await apiSignup(credentials);
+ await activateAccount(credentials.email);
+ const cookies = await apiLogin(credentials.email, credentials.password).catch((err) => {
+ throw new Error(
+ `login after signup+activation failed: ${err instanceof Error ? err.message : String(err)}`,
+ { cause: err }
+ );
+ });
+
+ await use({ credentials, cookies });
+
+ // teardown: delete the account regardless of test outcome
+ try {
+ await deleteSelf(cookies);
+ } catch (err) {
+ console.warn('user teardown failed:', err);
+ }
+ },
+
+ authedPage: async ({ context, page, user }, use) => {
+ await applyCookiesToContext(context, user.cookies);
+ await use(page);
+ },
+});
+
+export { expect } from '@playwright/test';
diff --git a/e2e/integration/live-control.spec.ts b/e2e/integration/live-control.spec.ts
new file mode 100644
index 00000000..30d845a9
--- /dev/null
+++ b/e2e/integration/live-control.spec.ts
@@ -0,0 +1,136 @@
+import { expect, test } from './lib/test-fixtures';
+
+// ---------------------------------------------------------------------------
+// Live control page — UI structure (WebSocket connection requires real hubs)
+// ---------------------------------------------------------------------------
+
+test.describe('own shockers / live control page', () => {
+ test.beforeEach(async ({ authedPage }) => {
+ await authedPage.goto('/shockers/own');
+ await authedPage.waitForLoadState('networkidle');
+ });
+
+ test('page renders the main content area', async ({ authedPage }) => {
+ await expect(authedPage.locator('main, [data-content], #app').first()).toBeVisible();
+ });
+
+ test('shows a heading or title for the shockers section', async ({ authedPage }) => {
+ await expect(authedPage.getByRole('heading', { name: /shocker|device|hub/i }).first())
+ .toBeVisible({ timeout: 5000 })
+ .catch(async () => {
+ // May not use a heading element — just ensure main content is visible
+ await expect(authedPage.locator('main').first()).toBeVisible();
+ });
+ });
+
+ test('empty state or shocker list is shown', async ({ authedPage }) => {
+ const emptyMsg = authedPage.getByText(/no shockers|no devices|no hubs|add a/i);
+ const shockerCard = authedPage.locator('[data-shocker], [data-hub], [class*="card"]').first();
+ const hasEmpty = (await emptyMsg.count()) > 0;
+ const hasCard = (await shockerCard.count()) > 0;
+ expect(hasEmpty || hasCard).toBe(true);
+ });
+
+ test('page does not produce a JS error on load', async ({ authedPage }) => {
+ const errors: string[] = [];
+ authedPage.on('pageerror', (e) => errors.push(e.message));
+ await authedPage.goto('/shockers/own');
+ await authedPage.waitForLoadState('networkidle');
+ expect(errors).toHaveLength(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Live control — add shocker button / dialog (UI only, no real device needed)
+// ---------------------------------------------------------------------------
+
+test.describe('add shocker dialog', () => {
+ test('add button or link is visible on the own shockers page', async ({ authedPage }) => {
+ await authedPage.goto('/shockers/own');
+ await authedPage.waitForLoadState('networkidle');
+ const addBtn = authedPage.getByRole('button', { name: /add|new|pair/i }).first();
+ const addLink = authedPage.getByRole('link', { name: /add|new|pair/i }).first();
+ const hasBtn = (await addBtn.count()) > 0;
+ const hasLink = (await addLink.count()) > 0;
+ if (hasBtn || hasLink) {
+ await expect(hasBtn ? addBtn : addLink).toBeVisible({ timeout: 5000 });
+ }
+ // If neither exists the page just shows empty state — acceptable for a fresh account
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Live control — module selector (Classic / Rich / Map / Live)
+// ---------------------------------------------------------------------------
+
+test.describe('control module UI', () => {
+ test('control module selector or tabs are present on the page', async ({ authedPage }) => {
+ await authedPage.goto('/shockers/own');
+ await authedPage.waitForLoadState('networkidle');
+ // Look for module type buttons or tabs
+ const moduleControls = authedPage.locator('[data-module], [role="tab"], button[aria-selected]');
+ const count = await moduleControls.count();
+ // Only meaningful if there are shockers; otherwise the selector may not render
+ if (count > 0) {
+ await expect(moduleControls.first()).toBeVisible({ timeout: 3000 });
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Live button — present only when shockers exist; cannot click without a hub
+// ---------------------------------------------------------------------------
+
+test.describe('live button', () => {
+ test('page does not throw when navigating to own shockers without a hub connected', async ({
+ authedPage,
+ }) => {
+ // This test validates the absence of unhandled errors when no hub is online
+ const errors: string[] = [];
+ authedPage.on('pageerror', (e) => errors.push(e.message));
+ await authedPage.goto('/shockers/own');
+ await authedPage.waitForLoadState('networkidle');
+ // A small wait to let any async effects settle
+ await authedPage.waitForTimeout(1000);
+ expect(errors).toHaveLength(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Shocker detail page — navigates to /shockers/
+// (Only accessible when a real shocker exists, so we test the route structure)
+// ---------------------------------------------------------------------------
+
+test.describe('shocker detail route', () => {
+ test('accessing a non-existent shocker UUID returns a non-500 response', async ({ page }) => {
+ const fakeId = '00000000-0000-0000-0000-000000000002';
+ const res = await page.goto(`/shockers/${fakeId}`);
+ expect(res?.status()).not.toBe(500);
+ });
+
+ test('accessing a non-existent shocker edit page returns a non-500 response', async ({
+ page,
+ }) => {
+ const fakeId = '00000000-0000-0000-0000-000000000002';
+ const res = await page.goto(`/shockers/${fakeId}/edit`);
+ expect(res?.status()).not.toBe(500);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Hub detail route
+// ---------------------------------------------------------------------------
+
+test.describe('hub detail route', () => {
+ test('accessing a non-existent hub UUID returns a non-500 response', async ({ page }) => {
+ const fakeId = '00000000-0000-0000-0000-000000000003';
+ const res = await page.goto(`/hubs/${fakeId}`);
+ expect(res?.status()).not.toBe(500);
+ });
+
+ test('accessing a non-existent hub update page returns a non-500 response', async ({ page }) => {
+ const fakeId = '00000000-0000-0000-0000-000000000003';
+ const res = await page.goto(`/hubs/${fakeId}/update`);
+ expect(res?.status()).not.toBe(500);
+ });
+});
diff --git a/e2e/integration/oauth-and-password-reset.spec.ts b/e2e/integration/oauth-and-password-reset.spec.ts
new file mode 100644
index 00000000..9b243c97
--- /dev/null
+++ b/e2e/integration/oauth-and-password-reset.spec.ts
@@ -0,0 +1,102 @@
+import { expect, test } from './lib/test-fixtures';
+
+// ---------------------------------------------------------------------------
+// OAuth — navigation / UI (full flow requires an OAuth provider mock)
+// ---------------------------------------------------------------------------
+
+test.describe('OAuth login UI', () => {
+ test('login page shows OAuth provider buttons', async ({ page }) => {
+ await page.goto('/login');
+ await page.waitForLoadState('networkidle');
+ // Look for OAuth buttons (Discord, GitHub, Google, etc.)
+ const oauthBtns = page.locator('a[href*="oauth"], button').filter({
+ hasText: /discord|github|google|oauth|continue with/i,
+ });
+ // On a dev backend there may or may not be OAuth providers configured
+ const count = await oauthBtns.count();
+ if (count > 0) {
+ // If providers exist, clicking one should navigate to an external URL
+ const href = await oauthBtns.first().getAttribute('href');
+ expect(href).toBeTruthy();
+ }
+ // Test is skipped silently if no OAuth buttons are present
+ });
+
+ test('signup page shows OAuth provider buttons', async ({ page }) => {
+ await page.goto('/signup');
+ await page.waitForLoadState('networkidle');
+ const oauthBtns = page.locator('a[href*="oauth"], button').filter({
+ hasText: /discord|github|google|oauth|continue with/i,
+ });
+ const count = await oauthBtns.count();
+ if (count > 0) {
+ await expect(oauthBtns.first()).toBeVisible();
+ }
+ });
+
+ test('OAuth error page renders gracefully', async ({ page }) => {
+ // Visit the OAuth error page directly — should render without crashing
+ const res = await page.goto('/oauth/error');
+ expect(res?.status()).not.toBe(500);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Password reset flow — UI-level tests
+// ---------------------------------------------------------------------------
+
+test.describe('forgot password page', () => {
+ test('forgot-password page renders the email form', async ({ page }) => {
+ await page.goto('/forgot-password');
+ await page.waitForLoadState('networkidle');
+ await expect(page.getByLabel(/email/i)).toBeVisible();
+ await expect(page.getByRole('button', { name: /reset|send|submit/i })).toBeVisible();
+ });
+
+ test('submitting the forgot-password form with a test email shows feedback', async ({ page }) => {
+ await page.goto('/forgot-password');
+ await page.waitForLoadState('networkidle');
+ await page.getByLabel(/email/i).fill('test.reset@e2e.openshock.test');
+ // Wait for button to enable (requires valid email + turnstile dev-bypass)
+ await expect(page.getByRole('button', { name: /reset|send|submit/i })).toBeEnabled({
+ timeout: 5000,
+ });
+ await page.getByRole('button', { name: /reset|send|submit/i }).click();
+ // Should show success or error feedback (but not crash)
+ await page.waitForTimeout(2000);
+ // Just ensure the page is still functional
+ expect(page.url()).toBeTruthy();
+ });
+
+ test('forgot-password button is disabled for empty email', async ({ page }) => {
+ await page.goto('/forgot-password');
+ await page.waitForLoadState('networkidle');
+ // Button requires valid email — it's disabled when email field is empty
+ await expect(page.getByRole('button', { name: /reset|send|submit/i })).toBeDisabled();
+ expect(page.url()).toMatch(/forgot-password/);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Verify-email and activate pages — structure tests only
+// (Full flow requires email delivery which isn't available in E2E)
+// ---------------------------------------------------------------------------
+
+test.describe('email verify and activate pages', () => {
+ test('verify-email page renders without crashing', async ({ page }) => {
+ const res = await page.goto('/verify-email');
+ expect(res?.status()).not.toBe(500);
+ });
+
+ test('activate page renders without crashing', async ({ page }) => {
+ const res = await page.goto('/activate');
+ expect(res?.status()).not.toBe(500);
+ });
+
+ test('activate page with invalid/missing token shows an error state', async ({ page }) => {
+ await page.goto('/activate?token=invalid-token');
+ await page.waitForLoadState('networkidle');
+ // Should show some feedback, not a blank or crashed page
+ await expect(page.locator('main').first()).toBeVisible();
+ });
+});
diff --git a/e2e/integration/public-pages.spec.ts b/e2e/integration/public-pages.spec.ts
new file mode 100644
index 00000000..ea5fdcee
--- /dev/null
+++ b/e2e/integration/public-pages.spec.ts
@@ -0,0 +1,105 @@
+import { expect, test } from './lib/test-fixtures';
+
+// ---------------------------------------------------------------------------
+// Auth routes — unauthenticated access
+// ---------------------------------------------------------------------------
+
+test.describe('public auth routes', () => {
+ test('GET /login returns 200', async ({ page }) => {
+ const res = await page.goto('/login');
+ expect(res!.status()).toBe(200);
+ });
+
+ test('GET /signup returns 200', async ({ page }) => {
+ const res = await page.goto('/signup');
+ expect(res!.status()).toBe(200);
+ });
+
+ test('GET /forgot-password returns 200', async ({ page }) => {
+ const res = await page.goto('/forgot-password');
+ expect(res!.status()).toBe(200);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// App routes — must redirect unauthenticated users to /login
+// ---------------------------------------------------------------------------
+
+const PROTECTED_ROUTES = [
+ '/home',
+ '/profile',
+ '/settings/account',
+ '/settings/api-tokens',
+ '/settings/sessions',
+ '/settings/connections',
+ '/hubs',
+ '/shockers/own',
+ '/shockers/shared',
+ '/shockers/logs',
+ '/shares/user/outgoing',
+ '/shares/user/incoming',
+ '/shares/user/invites',
+ '/shares/public',
+];
+
+for (const route of PROTECTED_ROUTES) {
+ test(`unauthenticated GET ${route} redirects to /login`, async ({ page }) => {
+ await page.goto(route);
+ await page.waitForURL(/\/login/, { timeout: 8000 });
+ expect(page.url()).toMatch(/\/login/);
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Authenticated redirects — logged-in users shouldn't see auth pages
+// ---------------------------------------------------------------------------
+
+test.describe('auth-page redirects for authenticated users', () => {
+ test('authenticated GET /login redirects away from login', async ({ authedPage }) => {
+ await authedPage.goto('/login');
+ await authedPage.waitForTimeout(1500);
+ // Should redirect to /home or dashboard, not stay on /login
+ expect(authedPage.url()).not.toMatch(/\/login(\?|$)/);
+ });
+
+ test('authenticated GET /signup redirects away from signup', async ({ authedPage }) => {
+ await authedPage.goto('/signup');
+ await authedPage.waitForTimeout(1500);
+ expect(authedPage.url()).not.toMatch(/\/signup(\?|$)/);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Terminal route (public)
+// ---------------------------------------------------------------------------
+
+test.describe('terminal page', () => {
+ test('GET /terminal returns 200', async ({ page }) => {
+ const res = await page.goto('/terminal');
+ expect(res?.status()).toBeLessThan(400);
+ });
+
+ test('terminal page has the expected UI structure', async ({ page }) => {
+ await page.goto('/terminal');
+ await page.waitForLoadState('networkidle');
+ // Should have some terminal-related element
+ await expect(page.locator('canvas, [data-terminal], .terminal, textarea, select').first())
+ .toBeVisible({ timeout: 5000 })
+ .catch(() => {
+ // Terminal may need WebSerial or only renders content elements
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Meta / utility routes
+// ---------------------------------------------------------------------------
+
+test.describe('meta routes', () => {
+ test('GET /llms.txt returns text content', async ({ page }) => {
+ const res = await page.goto('/llms.txt');
+ expect(res?.status()).toBe(200);
+ const contentType = res?.headers()['content-type'] ?? '';
+ expect(contentType).toMatch(/text/);
+ });
+});
diff --git a/e2e/integration/sessions-connections.spec.ts b/e2e/integration/sessions-connections.spec.ts
new file mode 100644
index 00000000..e3533489
--- /dev/null
+++ b/e2e/integration/sessions-connections.spec.ts
@@ -0,0 +1,62 @@
+import { expect, test } from './lib/test-fixtures';
+
+test.describe('sessions page', () => {
+ test('renders the sessions management page', async ({ authedPage }) => {
+ await authedPage.goto('/settings/sessions');
+ await authedPage.waitForLoadState('networkidle');
+ await expect(authedPage.locator('main').first()).toBeVisible();
+ });
+
+ test('shows at least one active session (the current one)', async ({ authedPage }) => {
+ await authedPage.goto('/settings/sessions');
+ await authedPage.waitForLoadState('networkidle');
+ // The current session must appear; look for a table row or card
+ await expect(authedPage.locator('tr, [data-session], [role="listitem"]').first())
+ .toBeVisible({ timeout: 5000 })
+ .catch(async () => {
+ await expect(authedPage.getByText(/current|active|session/i).first()).toBeVisible();
+ });
+ });
+
+ test('session list shows an IP address or device info', async ({ authedPage }) => {
+ await authedPage.goto('/settings/sessions');
+ await authedPage.waitForLoadState('networkidle');
+ // The current session must be identifiable — assert either layout-cell info
+ // or a recognizable IPv4 pattern is present somewhere on the page.
+ const cellVisible = await authedPage
+ .locator('td, [data-ip], [data-agent]')
+ .first()
+ .isVisible({ timeout: 5000 })
+ .catch(() => false);
+ if (!cellVisible) {
+ await expect(authedPage.getByText(/\b\d{1,3}(\.\d{1,3}){3}\b/).first()).toBeVisible({
+ timeout: 5000,
+ });
+ }
+ });
+});
+
+test.describe('connections / OAuth page', () => {
+ test('renders the connections settings page', async ({ authedPage }) => {
+ await authedPage.goto('/settings/connections');
+ await authedPage.waitForLoadState('networkidle');
+ await expect(authedPage.locator('main').first()).toBeVisible();
+ });
+
+ test('shows available OAuth providers or an empty state', async ({ authedPage }) => {
+ await authedPage.goto('/settings/connections');
+ await authedPage.waitForLoadState('networkidle');
+ // Either provider UI is present, or an empty/none-configured state is shown.
+ // Both are acceptable — what's not acceptable is rendering nothing at all.
+ const providerVisible = await authedPage
+ .getByText(/discord|github|google|oauth|provider/i)
+ .first()
+ .isVisible({ timeout: 5000 })
+ .catch(() => false);
+ if (!providerVisible) {
+ await expect(
+ authedPage.getByText(/no.*(provider|connection|configured)|none\s*(available|configured)/i).first()
+ ).toBeVisible({ timeout: 5000 });
+ }
+ });
+});
diff --git a/e2e/integration/shares.spec.ts b/e2e/integration/shares.spec.ts
new file mode 100644
index 00000000..3820a206
--- /dev/null
+++ b/e2e/integration/shares.spec.ts
@@ -0,0 +1,73 @@
+import { expect, test } from './lib/test-fixtures';
+
+// ---------------------------------------------------------------------------
+// Public share pages (no auth needed for viewing)
+// ---------------------------------------------------------------------------
+
+test.describe('public shares landing page', () => {
+ test('public share list page renders', async ({ authedPage }) => {
+ await authedPage.goto('/shares/public');
+ await authedPage.waitForLoadState('networkidle');
+ await expect(authedPage.locator('main').first()).toBeVisible();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// User shares — outgoing
+// ---------------------------------------------------------------------------
+
+test.describe('outgoing shares', () => {
+ test('outgoing shares page renders', async ({ authedPage }) => {
+ await authedPage.goto('/shares/user/outgoing');
+ await authedPage.waitForLoadState('networkidle');
+ await expect(authedPage.locator('main').first()).toBeVisible();
+ });
+
+ test('shows empty state or share list for new user', async ({ authedPage }) => {
+ await authedPage.goto('/shares/user/outgoing');
+ await authedPage.waitForLoadState('networkidle');
+ const emptyMsg = authedPage.getByText(/no shares|no outgoing|empty/i);
+ const shareList = authedPage.locator('[data-share], tr, [role="listitem"]');
+ const hasEmpty = (await emptyMsg.count()) > 0;
+ const hasList = (await shareList.count()) > 0;
+ expect(hasEmpty || hasList).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// User shares — incoming
+// ---------------------------------------------------------------------------
+
+test.describe('incoming shares', () => {
+ test('incoming shares page renders', async ({ authedPage }) => {
+ await authedPage.goto('/shares/user/incoming');
+ await authedPage.waitForLoadState('networkidle');
+ await expect(authedPage.locator('main').first()).toBeVisible();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Share invites
+// ---------------------------------------------------------------------------
+
+test.describe('share invites', () => {
+ test('invites page renders', async ({ authedPage }) => {
+ await authedPage.goto('/shares/user/invites');
+ await authedPage.waitForLoadState('networkidle');
+ await expect(authedPage.locator('main').first()).toBeVisible();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Public share link (accessed without auth)
+// ---------------------------------------------------------------------------
+
+test.describe('public share link', () => {
+ test('accessing a non-existent share link returns an error page, not 500', async ({ page }) => {
+ // Use a UUID that is very unlikely to exist
+ const fakeId = '00000000-0000-0000-0000-000000000001';
+ const res = await page.goto(`/shares/public/${fakeId}`);
+ // Should be 404 or a friendly error page, not a server crash
+ expect(res?.status()).not.toBe(500);
+ });
+});
diff --git a/e2e/integration/shockers.spec.ts b/e2e/integration/shockers.spec.ts
new file mode 100644
index 00000000..4d01d689
--- /dev/null
+++ b/e2e/integration/shockers.spec.ts
@@ -0,0 +1,67 @@
+import { expect, test } from './lib/test-fixtures';
+
+test.describe('own shockers page', () => {
+ test.beforeEach(async ({ authedPage }) => {
+ await authedPage.goto('/shockers/own');
+ await authedPage.waitForLoadState('networkidle');
+ });
+
+ test('renders the shockers page', async ({ authedPage }) => {
+ // Page should load without error
+ await expect(authedPage.getByRole('heading', { name: /shocker|device/i }).first())
+ .toBeVisible({ timeout: 5000 })
+ .catch(async () => {
+ // May use a different heading or layout
+ await expect(authedPage.locator('main, [data-content]').first()).toBeVisible();
+ });
+ });
+
+ test('shows empty state or shocker list', async ({ authedPage }) => {
+ // Either shows "no shockers" message or a list of shockers
+ const emptyMsg = authedPage.getByText(/no shockers|no devices|add a/i);
+ const shockerList = authedPage.locator('[data-shocker], tr, [role="listitem"]');
+ const hasEmpty = (await emptyMsg.count()) > 0;
+ const hasList = (await shockerList.count()) > 0;
+ expect(hasEmpty || hasList).toBe(true);
+ });
+});
+
+test.describe('shared shockers page', () => {
+ test('renders the shared shockers page', async ({ authedPage }) => {
+ await authedPage.goto('/shockers/shared');
+ await authedPage.waitForLoadState('networkidle');
+ await expect(authedPage.locator('main').first()).toBeVisible();
+ });
+});
+
+test.describe('shocker logs', () => {
+ test('logs page renders without error', async ({ authedPage }) => {
+ await authedPage.goto('/shockers/logs');
+ await authedPage.waitForLoadState('networkidle');
+ // Should not show a 500 or crash
+ await expect(authedPage.locator('main').first()).toBeVisible();
+ });
+});
+
+test.describe('hubs page', () => {
+ test.beforeEach(async ({ authedPage }) => {
+ await authedPage.goto('/hubs');
+ await authedPage.waitForLoadState('networkidle');
+ });
+
+ test('renders the hubs page', async ({ authedPage }) => {
+ await expect(authedPage.getByRole('heading', { name: /hub/i }).first())
+ .toBeVisible({ timeout: 5000 })
+ .catch(async () => {
+ await expect(authedPage.locator('main').first()).toBeVisible();
+ });
+ });
+
+ test('shows empty state or hub list', async ({ authedPage }) => {
+ const emptyMsg = authedPage.getByText(/no hubs|add a hub|pair/i);
+ const hubList = authedPage.locator('[data-hub], tr, [role="listitem"]');
+ const hasEmpty = (await emptyMsg.count()) > 0;
+ const hasList = (await hubList.count()) > 0;
+ expect(hasEmpty || hasList).toBe(true);
+ });
+});
diff --git a/e2e/integration/signalr.spec.ts b/e2e/integration/signalr.spec.ts
new file mode 100644
index 00000000..a5f9c012
--- /dev/null
+++ b/e2e/integration/signalr.spec.ts
@@ -0,0 +1,116 @@
+import { expect, test } from './lib/test-fixtures';
+
+// ---------------------------------------------------------------------------
+// SignalR / realtime — these tests validate that the frontend establishes a
+// SignalR connection when authenticated and that the UI reacts gracefully to
+// connection lifecycle events. Full end-to-end realtime messaging requires a
+// hub device, so most tests here focus on the connection-establishment path
+// and UI state rather than incoming messages.
+// ---------------------------------------------------------------------------
+
+test.describe('SignalR connection lifecycle', () => {
+ test('no WebSocket / SignalR errors appear on the home page', async ({ authedPage }) => {
+ const errors: string[] = [];
+ authedPage.on('pageerror', (e) => errors.push(e.message));
+
+ await authedPage.goto('/home');
+ await authedPage.waitForLoadState('networkidle');
+ // Allow a moment for async connection attempts to settle
+ await authedPage.waitForTimeout(2000);
+
+ // Filter out noise unrelated to SignalR
+ const signarErrors = errors.filter((e) => /signalr|websocket|hub|negotiate/i.test(e));
+ expect(signarErrors).toHaveLength(0);
+ });
+
+ test('no uncaught errors on shockers page (SignalR + live-control init)', async ({
+ authedPage,
+ }) => {
+ const errors: string[] = [];
+ authedPage.on('pageerror', (e) => errors.push(e.message));
+
+ await authedPage.goto('/shockers/own');
+ await authedPage.waitForLoadState('networkidle');
+ await authedPage.waitForTimeout(2000);
+
+ expect(errors).toHaveLength(0);
+ });
+
+ test('authenticated page makes a negotiate or WebSocket request', async ({ authedPage }) => {
+ const wsRequests: string[] = [];
+ authedPage.on('request', (req) => {
+ const url = req.url();
+ if (/negotiate|ws:|wss:|signalr/i.test(url)) {
+ wsRequests.push(url);
+ }
+ });
+
+ await authedPage.goto('/home');
+ await authedPage.waitForLoadState('networkidle');
+ await authedPage.waitForTimeout(3000);
+
+ // On an authenticated session the frontend must attempt to establish a
+ // SignalR connection — assert at least one negotiate/WebSocket request fired.
+ expect(wsRequests.length).toBeGreaterThan(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Hub status — visual indicators (requires no physical hub)
+// ---------------------------------------------------------------------------
+
+test.describe('hub status UI', () => {
+ test('hubs page renders without errors after SignalR init', async ({ authedPage }) => {
+ const errors: string[] = [];
+ authedPage.on('pageerror', (e) => errors.push(e.message));
+
+ await authedPage.goto('/hubs');
+ await authedPage.waitForLoadState('networkidle');
+ await authedPage.waitForTimeout(1500);
+
+ await expect(authedPage.locator('main').first()).toBeVisible();
+ expect(errors).toHaveLength(0);
+ });
+
+ test('shocker logs page renders without SignalR errors', async ({ authedPage }) => {
+ const errors: string[] = [];
+ authedPage.on('pageerror', (e) => errors.push(e.message));
+
+ await authedPage.goto('/shockers/logs');
+ await authedPage.waitForLoadState('networkidle');
+ await authedPage.waitForTimeout(1000);
+
+ await expect(authedPage.locator('main').first()).toBeVisible();
+ expect(errors).toHaveLength(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Realtime event listeners — cannot trigger without a device, but we can
+// verify that the page subscribes correctly (no duplicate / leaked listeners)
+// ---------------------------------------------------------------------------
+
+test.describe('realtime subscription cleanup', () => {
+ test('navigating between pages does not cause JS errors from stale listeners', async ({
+ authedPage,
+ }) => {
+ const errors: string[] = [];
+ authedPage.on('pageerror', (e) => errors.push(e.message));
+
+ // Navigate through several pages that set up realtime listeners
+ await authedPage.goto('/home');
+ await authedPage.waitForLoadState('networkidle');
+
+ await authedPage.goto('/shockers/own');
+ await authedPage.waitForLoadState('networkidle');
+
+ await authedPage.goto('/hubs');
+ await authedPage.waitForLoadState('networkidle');
+
+ await authedPage.goto('/home');
+ await authedPage.waitForLoadState('networkidle');
+
+ await authedPage.waitForTimeout(1000);
+ expect(errors).toHaveLength(0);
+ });
+});
diff --git a/e2e/integration/smoke.spec.ts b/e2e/integration/smoke.spec.ts
new file mode 100644
index 00000000..0ac4a298
--- /dev/null
+++ b/e2e/integration/smoke.spec.ts
@@ -0,0 +1,21 @@
+import { expect, test } from './lib/test-fixtures';
+
+test.describe('integration scaffold smoke', () => {
+ test('frontend baseURL responds', async ({ page }) => {
+ const response = await page.goto('/');
+ expect(response).not.toBeNull();
+ expect(response!.status()).toBeLessThan(500);
+ });
+
+ test('user fixture: signup + login + delete lifecycle', async ({ user }) => {
+ expect(user.credentials.email).toMatch(/@e2e\.openshock\.test$/);
+ expect(user.cookies.length).toBeGreaterThan(0);
+ });
+
+ test('authedPage fixture: cookies attached to browser context', async ({ authedPage }) => {
+ const response = await authedPage.goto('/home');
+ expect(response).not.toBeNull();
+ // /home is auth-gated; an unauthenticated request would 302 to /login
+ expect(authedPage.url()).not.toMatch(/\/login(\?|$)/);
+ });
+});
diff --git a/eslint.config.js b/eslint.config.js
index 742713d4..1556aeea 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -51,6 +51,12 @@ export default defineConfig(
},
},
},
+ {
+ files: ['**/*.test.ts', '**/*.test.svelte.ts', '**/*.spec.ts'],
+ rules: {
+ '@typescript-eslint/no-explicit-any': 'off',
+ },
+ },
{
ignores: [
'.DS_Store',
diff --git a/package.json b/package.json
index 673ce757..fbb1dcf3 100644
--- a/package.json
+++ b/package.json
@@ -13,10 +13,12 @@
"lint": "eslint .",
"format:check": "prettier --check .",
"format": "prettier --write .",
- "test": "npm run test:unit -- --run && npm run test:e2e",
- "test:e2e": "playwright test",
+ "test": "pnpm run test:unit -- --run && pnpm run test:integration",
+ "test:integration": "playwright test",
+ "test:e2e": "playwright test --config=playwright.e2e.config.ts",
"test:unit": "vitest",
- "regen-api": "node scripts/regenerate-api.js"
+ "regen-api": "node scripts/regenerate-api.js",
+ "dev:integration": "node scripts/dev-integration.mjs"
},
"devDependencies": {
"@eslint/compat": "^2.0.5",
@@ -31,6 +33,9 @@
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/table-core": "^8.21.3",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/svelte": "^5.3.1",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^25.6.0",
"@types/semver": "^7.7.1",
"@types/w3c-web-serial": "^1.0.8",
@@ -47,6 +52,7 @@
"formsnap": "^2.0.1",
"globals": "^17.5.0",
"husky": "^9.1.7",
+ "jsdom": "^29.0.2",
"prettier": "^3.8.3",
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-svelte": "^3.5.1",
diff --git a/playwright.config.ts b/playwright.config.ts
index dc000c70..f0001cd1 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -1,15 +1,46 @@
import { defineConfig } from '@playwright/test';
+// Playwright's test-runner Node process makes direct fetch() calls to the API
+// container (self-signed cert) and to the Vite dev server (mkcert local CA).
+// Neither is in the system trust store, so relax verification for this process.
+process.env.NODE_TLS_REJECT_UNAUTHORIZED ??= '0';
+
+const FRONTEND_URL = process.env.TEST_FRONTEND_URL ?? 'https://localhost:5173';
+const BACKEND_URL = process.env.TEST_BACKEND_URL ?? 'https://localhost:5001';
+const MAILPIT_URL = process.env.TEST_MAILPIT_URL ?? 'http://localhost:8025';
+const TURNSTILE_BYPASS = process.env.TEST_TURNSTILE_BYPASS ?? 'dev-bypass';
+
export default defineConfig({
+ testDir: 'e2e/integration',
+ testMatch: /.*\.spec\.ts$/,
reporter: process.env.CI ? 'github' : 'html',
+ fullyParallel: false,
+ workers: 1,
+ retries: process.env.CI ? 1 : 0,
+ globalSetup: './e2e/integration/lib/global-setup.ts',
+ globalTeardown: './e2e/integration/lib/global-teardown.ts',
+ webServer: {
+ command: 'pnpm dev:integration',
+ url: FRONTEND_URL,
+ reuseExistingServer: !process.env.CI,
+ ignoreHTTPSErrors: true,
+ // Cold CI runs pull docker images and wait for healthchecks before Vite
+ // starts (see scripts/dev-integration.mjs). 10 minutes covers worst-case
+ // cold pulls on the GitHub-hosted runner.
+ timeout: 10 * 60 * 1000,
+ },
use: {
- baseURL: 'https://local.openshock.app:4173',
+ baseURL: FRONTEND_URL,
+ ignoreHTTPSErrors: true,
trace: 'on-first-retry',
+ screenshot: 'only-on-failure',
+ video: 'retain-on-failure',
+ extraHTTPHeaders: { 'x-test-run': '1' },
},
- webServer: {
- command: 'pnpm run build && pnpm run preview',
- port: 4173,
- reuseExistingServer: !process.env.CI,
+ metadata: {
+ frontendUrl: FRONTEND_URL,
+ backendUrl: BACKEND_URL,
+ mailpitUrl: MAILPIT_URL,
+ turnstileBypass: TURNSTILE_BYPASS,
},
- testDir: 'e2e',
});
diff --git a/playwright.e2e.config.ts b/playwright.e2e.config.ts
new file mode 100644
index 00000000..50028dc8
--- /dev/null
+++ b/playwright.e2e.config.ts
@@ -0,0 +1,49 @@
+/**
+ * Full E2E test configuration — tests the complete browser user journey
+ * including signup, email verification, login, and logout.
+ *
+ * Target environment: next.openshock.dev (no captcha enforcement)
+ * Override via environment variables:
+ * TEST_FRONTEND_URL – frontend base URL (default: https://next.openshock.dev)
+ * TEST_BACKEND_URL – backend API URL (default: https://api.openshock.dev)
+ * TEST_MAILPIT_URL – MailPit HTTP URL (default: '' → email tests skipped)
+ *
+ * Email verification tests:
+ * Requires MailPit running locally and the backend configured to send mail
+ * to MailPit's SMTP port (default 1025).
+ *
+ * docker run -d -p 8025:8025 -p 1025:1025 axllent/mailpit
+ * TEST_MAILPIT_URL=http://localhost:8025 pnpm test:e2e:full
+ */
+
+import { defineConfig } from '@playwright/test';
+
+const FRONTEND_URL = process.env.TEST_FRONTEND_URL ?? 'https://next.openshock.dev';
+
+export default defineConfig({
+ testDir: 'e2e/e2e',
+ testMatch: /.*\.spec\.ts$/,
+
+ // Full E2E is slower — allow longer per-test timeouts
+ timeout: 60_000,
+ expect: { timeout: 10_000 },
+
+ reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'html',
+
+ // Run sequentially — each test creates a real account on the backend
+ fullyParallel: false,
+ workers: 1,
+
+ // Retry once in CI to absorb transient network flakiness
+ retries: process.env.CI ? 1 : 0,
+
+ use: {
+ baseURL: FRONTEND_URL,
+ trace: 'on-first-retry',
+ screenshot: 'only-on-failure',
+ video: 'retain-on-failure',
+ // Longer navigation timeout for real network round-trips
+ navigationTimeout: 20_000,
+ actionTimeout: 10_000,
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e0f2bd57..01c46cd9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -60,6 +60,15 @@ importers:
'@tanstack/table-core':
specifier: ^8.21.3
version: 8.21.3
+ '@testing-library/jest-dom':
+ specifier: ^6.9.1
+ version: 6.9.1
+ '@testing-library/svelte':
+ specifier: ^5.3.1
+ version: 5.3.1(svelte@5.55.5(@typescript-eslint/types@8.59.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1))(vitest@4.1.5(@types/node@25.6.0)(jsdom@29.0.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)))
+ '@testing-library/user-event':
+ specifier: ^14.6.1
+ version: 14.6.1(@testing-library/dom@10.4.1)
'@types/node':
specifier: ^25.6.0
version: 25.6.0
@@ -108,6 +117,9 @@ importers:
husky:
specifier: ^9.1.7
version: 9.1.7
+ jsdom:
+ specifier: ^29.0.2
+ version: 29.0.2
prettier:
specifier: ^3.8.3
version: 3.8.3
@@ -170,20 +182,50 @@ importers:
version: 2.0.0(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1))
vitest:
specifier: ^4.1.5
- version: 4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1))
+ version: 4.1.5(@types/node@25.6.0)(jsdom@29.0.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1))
packages:
+ '@adobe/css-tools@4.4.4':
+ resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==}
+
'@ark/schema@0.56.0':
resolution: {integrity: sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==}
'@ark/util@0.56.0':
resolution: {integrity: sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==}
+ '@asamuzakjp/css-color@5.1.11':
+ resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/dom-selector@7.1.1':
+ resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/generational-cache@1.0.1':
+ resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/nwsapi@2.3.9':
+ resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
+
+ '@babel/code-frame@7.29.0':
+ resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.28.5':
+ resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
+ engines: {node: '>=6.9.0'}
+
'@babel/runtime@7.29.2':
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
engines: {node: '>=6.9.0'}
+ '@bramus/specificity@2.4.2':
+ resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
+ hasBin: true
+
'@cloudflare/kv-asset-handler@0.4.2':
resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==}
engines: {node: '>=18.0.0'}
@@ -234,6 +276,42 @@ packages:
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
+ '@csstools/color-helpers@6.0.2':
+ resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
+ engines: {node: '>=20.19.0'}
+
+ '@csstools/css-calc@3.2.0':
+ resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-color-parser@4.1.0':
+ resolution: {integrity: sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0':
+ resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.3':
+ resolution: {integrity: sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==}
+ peerDependencies:
+ css-tree: ^3.2.1
+ peerDependenciesMeta:
+ css-tree:
+ optional: true
+
+ '@csstools/css-tokenizer@4.0.0':
+ resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
+ engines: {node: '>=20.19.0'}
+
'@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@@ -606,6 +684,15 @@ packages:
resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+ '@exodus/bytes@1.15.0':
+ resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ peerDependencies:
+ '@noble/hashes': ^1.8.0 || ^2.0.0
+ peerDependenciesMeta:
+ '@noble/hashes':
+ optional: true
+
'@exodus/schemasafe@1.3.0':
resolution: {integrity: sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==}
@@ -1293,9 +1380,45 @@ packages:
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
+ '@testing-library/dom@10.4.1':
+ resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
+ engines: {node: '>=18'}
+
+ '@testing-library/jest-dom@6.9.1':
+ resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
+ engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
+
+ '@testing-library/svelte-core@1.0.0':
+ resolution: {integrity: sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==}
+ engines: {node: '>=16'}
+ peerDependencies:
+ svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0
+
+ '@testing-library/svelte@5.3.1':
+ resolution: {integrity: sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==}
+ engines: {node: '>= 10'}
+ peerDependencies:
+ svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0
+ vite: '*'
+ vitest: '*'
+ peerDependenciesMeta:
+ vite:
+ optional: true
+ vitest:
+ optional: true
+
+ '@testing-library/user-event@14.6.1':
+ resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
+ engines: {node: '>=12', npm: '>=6'}
+ peerDependencies:
+ '@testing-library/dom': '>=7.21.4'
+
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
+ '@types/aria-query@5.0.4':
+ resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -1477,10 +1600,17 @@ packages:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
+ ansi-styles@5.2.0:
+ resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
+ engines: {node: '>=10'}
+
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
+ aria-query@5.3.0:
+ resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
+
aria-query@5.3.1:
resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==}
engines: {node: '>= 0.4'}
@@ -1518,6 +1648,9 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ bidi-js@1.0.3:
+ resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
+
bits-ui@2.18.0:
resolution: {integrity: sha512-GLOBZRVy3hxNHIQ2MpD/+5aK9KcBFZRhUJtZ1UDABXdlVR4K6zFpgt4T+Rwuhf2sQzlc6yK1q/DprHPjwT4Pjw==}
engines: {node: '>=20'}
@@ -1593,11 +1726,22 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
+ css-tree@3.2.1:
+ resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
+ engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
+
+ css.escape@1.5.1:
+ resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
+
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
hasBin: true
+ data-urls@7.0.0:
+ resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
dayjs@1.11.20:
resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
@@ -1610,6 +1754,9 @@ packages:
supports-color:
optional: true
+ decimal.js@10.6.0:
+ resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -1634,6 +1781,12 @@ packages:
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
+ dom-accessibility-api@0.5.16:
+ resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+
+ dom-accessibility-api@0.6.3:
+ resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
+
effect@3.21.2:
resolution: {integrity: sha512-rXd2FGDM8KdjSIrc+mqEELo7ScW7xTVxEf1iInmPSpIde9/nyGuFM710cjTo7/EreGXiUX2MOonPpprbz2XHCg==}
@@ -1650,6 +1803,10 @@ packages:
resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==}
engines: {node: '>=10.13.0'}
+ entities@8.0.0:
+ resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
+ engines: {node: '>=20.19.0'}
+
error-stack-parser-es@1.0.5:
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
@@ -1873,6 +2030,10 @@ packages:
resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
engines: {node: '>= 0.4'}
+ html-encoding-sniffer@6.0.0:
+ resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
husky@9.1.7:
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
engines: {node: '>=18'}
@@ -1890,6 +2051,10 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
+ indent-string@4.0.0:
+ resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
+ engines: {node: '>=8'}
+
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
@@ -1912,6 +2077,9 @@ packages:
is-module@1.0.0:
resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==}
+ is-potential-custom-element-name@1.0.1:
+ resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+
is-reference@1.2.1:
resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
@@ -1931,6 +2099,18 @@ packages:
joi@17.13.3:
resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==}
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ jsdom@29.0.2:
+ resolution: {integrity: sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
+ peerDependencies:
+ canvas: ^3.0.0
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
@@ -2052,6 +2232,10 @@ packages:
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
+ lru-cache@11.3.5:
+ resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==}
+ engines: {node: 20 || >=22}
+
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -2059,9 +2243,16 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+ mdn-data@2.27.1:
+ resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
+
memoize-weak@1.0.2:
resolution: {integrity: sha512-gj39xkrjEw7nCn4nJ1M5ms6+MyMlyiGmttzsqAUsAKn6bYKwuTHh/AO3cKPF8IBrTIYTxb0wWXFs3E//Y8VoWQ==}
+ min-indent@1.0.1:
+ resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
+ engines: {node: '>=4'}
+
miniflare@4.20260210.0:
resolution: {integrity: sha512-HXR6m53IOqEzq52DuGF1x7I1K6lSIqzhbCbQXv/cTmPnPJmNkr7EBtLDm4nfSkOvlDtnwDCLUjWII5fyGJI5Tw==}
engines: {node: '>=18.0.0'}
@@ -2131,6 +2322,9 @@ packages:
pako@2.1.0:
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
+ parse5@8.0.1:
+ resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
+
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@@ -2277,6 +2471,10 @@ packages:
engines: {node: '>=14'}
hasBin: true
+ pretty-format@27.5.1:
+ resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
+ engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+
property-expr@2.0.6:
resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==}
@@ -2293,14 +2491,25 @@ packages:
querystringify@2.2.0:
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
+ react-is@17.0.2:
+ resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
+ redent@3.0.0:
+ resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
+ engines: {node: '>=8'}
+
regexparam@3.0.0:
resolution: {integrity: sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==}
engines: {node: '>=8'}
+ require-from-string@2.0.2:
+ resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+ engines: {node: '>=0.10.0'}
+
requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
@@ -2348,6 +2557,10 @@ packages:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'}
+ saxes@6.0.0:
+ resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
+ engines: {node: '>=v12.22.7'}
+
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
@@ -2425,6 +2638,10 @@ packages:
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
engines: {node: '>=12'}
+ strip-indent@3.0.0:
+ resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
+ engines: {node: '>=8'}
+
style-to-object@1.0.14:
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
@@ -2490,6 +2707,9 @@ packages:
'@sveltejs/kit': 1.x || 2.x
svelte: 3.x || 4.x || >=5.0.0-next.51
+ symbol-tree@3.2.4:
+ resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+
tabbable@6.4.0:
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
@@ -2531,6 +2751,13 @@ packages:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
+ tldts-core@7.0.28:
+ resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==}
+
+ tldts@7.0.28:
+ resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==}
+ hasBin: true
+
toposort@2.0.2:
resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==}
@@ -2542,9 +2769,17 @@ packages:
resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==}
engines: {node: '>=6'}
+ tough-cookie@6.0.1:
+ resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
+ engines: {node: '>=16'}
+
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
+ tr46@6.0.0:
+ resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
+ engines: {node: '>=20'}
+
ts-algebra@2.0.0:
resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
@@ -2601,6 +2836,10 @@ packages:
undici-types@7.19.2:
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
+ undici@7.25.0:
+ resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==}
+ engines: {node: '>=20.18.1'}
+
undici@8.1.0:
resolution: {integrity: sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw==}
engines: {node: '>=22.19.0'}
@@ -2752,9 +2991,25 @@ packages:
jsdom:
optional: true
+ w3c-xmlserializer@5.0.0:
+ resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
+ engines: {node: '>=18'}
+
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
+ webidl-conversions@8.0.1:
+ resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
+ engines: {node: '>=20'}
+
+ whatwg-mimetype@5.0.0:
+ resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
+ engines: {node: '>=20'}
+
+ whatwg-url@16.0.1:
+ resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@@ -2823,6 +3078,13 @@ packages:
utf-8-validate:
optional: true
+ xml-name-validator@5.0.0:
+ resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
+ engines: {node: '>=18'}
+
+ xmlchars@2.2.0:
+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+
yaml@1.10.3:
resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==}
engines: {node: '>= 6'}
@@ -2853,6 +3115,8 @@ packages:
snapshots:
+ '@adobe/css-tools@4.4.4': {}
+
'@ark/schema@0.56.0':
dependencies:
'@ark/util': 0.56.0
@@ -2861,8 +3125,39 @@ snapshots:
'@ark/util@0.56.0':
optional: true
- '@babel/runtime@7.29.2':
- optional: true
+ '@asamuzakjp/css-color@5.1.11':
+ dependencies:
+ '@asamuzakjp/generational-cache': 1.0.1
+ '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-color-parser': 4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@asamuzakjp/dom-selector@7.1.1':
+ dependencies:
+ '@asamuzakjp/generational-cache': 1.0.1
+ '@asamuzakjp/nwsapi': 2.3.9
+ bidi-js: 1.0.3
+ css-tree: 3.2.1
+ is-potential-custom-element-name: 1.0.1
+
+ '@asamuzakjp/generational-cache@1.0.1': {}
+
+ '@asamuzakjp/nwsapi@2.3.9': {}
+
+ '@babel/code-frame@7.29.0':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.28.5
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/helper-validator-identifier@7.28.5': {}
+
+ '@babel/runtime@7.29.2': {}
+
+ '@bramus/specificity@2.4.2':
+ dependencies:
+ css-tree: 3.2.1
'@cloudflare/kv-asset-handler@0.4.2': {}
@@ -2893,6 +3188,30 @@ snapshots:
dependencies:
'@jridgewell/trace-mapping': 0.3.9
+ '@csstools/color-helpers@6.0.2': {}
+
+ '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-color-parser@4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/color-helpers': 6.0.2
+ '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.3(css-tree@3.2.1)':
+ optionalDependencies:
+ css-tree: 3.2.1
+
+ '@csstools/css-tokenizer@4.0.0': {}
+
'@emnapi/core@1.10.0':
dependencies:
'@emnapi/wasi-threads': 1.2.1
@@ -3107,6 +3426,8 @@ snapshots:
'@eslint/core': 1.2.1
levn: 0.4.1
+ '@exodus/bytes@1.15.0': {}
+
'@exodus/schemasafe@1.3.0':
optional: true
@@ -3620,11 +3941,50 @@ snapshots:
'@tanstack/table-core@8.21.3': {}
+ '@testing-library/dom@10.4.1':
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/runtime': 7.29.2
+ '@types/aria-query': 5.0.4
+ aria-query: 5.3.0
+ dom-accessibility-api: 0.5.16
+ lz-string: 1.5.0
+ picocolors: 1.1.1
+ pretty-format: 27.5.1
+
+ '@testing-library/jest-dom@6.9.1':
+ dependencies:
+ '@adobe/css-tools': 4.4.4
+ aria-query: 5.3.1
+ css.escape: 1.5.1
+ dom-accessibility-api: 0.6.3
+ picocolors: 1.1.1
+ redent: 3.0.0
+
+ '@testing-library/svelte-core@1.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.1))':
+ dependencies:
+ svelte: 5.55.5(@typescript-eslint/types@8.59.1)
+
+ '@testing-library/svelte@5.3.1(svelte@5.55.5(@typescript-eslint/types@8.59.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1))(vitest@4.1.5(@types/node@25.6.0)(jsdom@29.0.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)))':
+ dependencies:
+ '@testing-library/dom': 10.4.1
+ '@testing-library/svelte-core': 1.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.1))
+ svelte: 5.55.5(@typescript-eslint/types@8.59.1)
+ optionalDependencies:
+ vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)
+ vitest: 4.1.5(@types/node@25.6.0)(jsdom@29.0.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1))
+
+ '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
+ dependencies:
+ '@testing-library/dom': 10.4.1
+
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
optional: true
+ '@types/aria-query@5.0.4': {}
+
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@@ -3846,8 +4206,14 @@ snapshots:
ansi-regex@6.2.2: {}
+ ansi-styles@5.2.0: {}
+
ansi-styles@6.2.3: {}
+ aria-query@5.3.0:
+ dependencies:
+ dequal: 2.0.3
+
aria-query@5.3.1: {}
arkregex@0.0.5:
@@ -3878,6 +4244,10 @@ snapshots:
baseline-browser-mapping@2.10.24: {}
+ bidi-js@1.0.3:
+ dependencies:
+ require-from-string: 2.0.2
+
bits-ui@2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)))(svelte@5.55.5(@typescript-eslint/types@8.59.1))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)))(svelte@5.55.5(@typescript-eslint/types@8.59.1)):
dependencies:
'@floating-ui/core': 1.7.5
@@ -3958,8 +4328,22 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
+ css-tree@3.2.1:
+ dependencies:
+ mdn-data: 2.27.1
+ source-map-js: 1.2.1
+
+ css.escape@1.5.1: {}
+
cssesc@3.0.0: {}
+ data-urls@7.0.0:
+ dependencies:
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
dayjs@1.11.20:
optional: true
@@ -3969,6 +4353,8 @@ snapshots:
optionalDependencies:
supports-color: 10.2.2
+ decimal.js@10.6.0: {}
+
deep-is@0.1.4: {}
deepmerge@4.3.1: {}
@@ -3984,6 +4370,10 @@ snapshots:
dlv@1.1.3:
optional: true
+ dom-accessibility-api@0.5.16: {}
+
+ dom-accessibility-api@0.6.3: {}
+
effect@3.21.2:
dependencies:
'@standard-schema/spec': 1.1.0
@@ -4001,6 +4391,8 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.3
+ entities@8.0.0: {}
+
error-stack-parser-es@1.0.5: {}
es-errors@1.3.0: {}
@@ -4276,6 +4668,12 @@ snapshots:
dependencies:
function-bind: 1.1.2
+ html-encoding-sniffer@6.0.0:
+ dependencies:
+ '@exodus/bytes': 1.15.0
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
husky@9.1.7: {}
ignore@5.3.2: {}
@@ -4284,6 +4682,8 @@ snapshots:
imurmurhash@0.1.4: {}
+ indent-string@4.0.0: {}
+
inline-style-parser@0.2.7: {}
is-core-module@2.16.1:
@@ -4300,6 +4700,8 @@ snapshots:
is-module@1.0.0: {}
+ is-potential-custom-element-name@1.0.1: {}
+
is-reference@1.2.1:
dependencies:
'@types/estree': 1.0.8
@@ -4323,6 +4725,34 @@ snapshots:
'@sideway/pinpoint': 2.0.0
optional: true
+ js-tokens@4.0.0: {}
+
+ jsdom@29.0.2:
+ dependencies:
+ '@asamuzakjp/css-color': 5.1.11
+ '@asamuzakjp/dom-selector': 7.1.1
+ '@bramus/specificity': 2.4.2
+ '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1)
+ '@exodus/bytes': 1.15.0
+ css-tree: 3.2.1
+ data-urls: 7.0.0
+ decimal.js: 10.6.0
+ html-encoding-sniffer: 6.0.0
+ is-potential-custom-element-name: 1.0.1
+ lru-cache: 11.3.5
+ parse5: 8.0.1
+ saxes: 6.0.0
+ symbol-tree: 3.2.4
+ tough-cookie: 6.0.1
+ undici: 7.25.0
+ w3c-xmlserializer: 5.0.0
+ webidl-conversions: 8.0.1
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1
+ xml-name-validator: 5.0.0
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
json-buffer@3.0.1: {}
json-schema-to-ts@3.1.1:
@@ -4412,14 +4842,20 @@ snapshots:
lodash@4.18.1: {}
+ lru-cache@11.3.5: {}
+
lz-string@1.5.0: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
+ mdn-data@2.27.1: {}
+
memoize-weak@1.0.2: {}
+ min-indent@1.0.1: {}
+
miniflare@4.20260210.0:
dependencies:
'@cspotcode/source-map-support': 0.8.1
@@ -4480,6 +4916,10 @@ snapshots:
pako@2.1.0: {}
+ parse5@8.0.1:
+ dependencies:
+ entities: 8.0.0
+
path-exists@4.0.0: {}
path-key@3.1.1: {}
@@ -4549,6 +4989,12 @@ snapshots:
prettier@3.8.3: {}
+ pretty-format@27.5.1:
+ dependencies:
+ ansi-regex: 5.0.1
+ ansi-styles: 5.2.0
+ react-is: 17.0.2
+
property-expr@2.0.6:
optional: true
@@ -4563,10 +5009,19 @@ snapshots:
querystringify@2.2.0: {}
+ react-is@17.0.2: {}
+
readdirp@4.1.2: {}
+ redent@3.0.0:
+ dependencies:
+ indent-string: 4.0.0
+ strip-indent: 3.0.0
+
regexparam@3.0.0: {}
+ require-from-string@2.0.2: {}
+
requires-port@1.0.0: {}
resolve@1.22.12:
@@ -4665,6 +5120,10 @@ snapshots:
dependencies:
mri: 1.2.0
+ saxes@6.0.0:
+ dependencies:
+ xmlchars: 2.2.0
+
semver@7.7.4: {}
set-cookie-parser@2.7.2: {}
@@ -4769,6 +5228,10 @@ snapshots:
dependencies:
ansi-regex: 6.2.2
+ strip-indent@3.0.0:
+ dependencies:
+ min-indent: 1.0.1
+
style-to-object@1.0.14:
dependencies:
inline-style-parser: 0.2.7
@@ -4880,6 +5343,8 @@ snapshots:
- '@types/json-schema'
- typescript
+ symbol-tree@3.2.4: {}
+
tabbable@6.4.0: {}
tailwind-merge@3.5.0: {}
@@ -4908,6 +5373,12 @@ snapshots:
tinyrainbow@3.1.0: {}
+ tldts-core@7.0.28: {}
+
+ tldts@7.0.28:
+ dependencies:
+ tldts-core: 7.0.28
+
toposort@2.0.2:
optional: true
@@ -4920,8 +5391,16 @@ snapshots:
universalify: 0.2.0
url-parse: 1.5.10
+ tough-cookie@6.0.1:
+ dependencies:
+ tldts: 7.0.28
+
tr46@0.0.3: {}
+ tr46@6.0.0:
+ dependencies:
+ punycode: 2.3.1
+
ts-algebra@2.0.0:
optional: true
@@ -4970,6 +5449,8 @@ snapshots:
undici-types@7.19.2: {}
+ undici@7.25.0: {}
+
undici@8.1.0: {}
unenv@2.0.0-rc.24:
@@ -5040,7 +5521,7 @@ snapshots:
optionalDependencies:
vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)
- vitest@4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)):
+ vitest@4.1.5(@types/node@25.6.0)(jsdom@29.0.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)):
dependencies:
'@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1))
@@ -5064,11 +5545,28 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.6.0
+ jsdom: 29.0.2
transitivePeerDependencies:
- msw
+ w3c-xmlserializer@5.0.0:
+ dependencies:
+ xml-name-validator: 5.0.0
+
webidl-conversions@3.0.1: {}
+ webidl-conversions@8.0.1: {}
+
+ whatwg-mimetype@5.0.0: {}
+
+ whatwg-url@16.0.1:
+ dependencies:
+ '@exodus/bytes': 1.15.0
+ tr46: 6.0.0
+ webidl-conversions: 8.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
@@ -5129,6 +5627,10 @@ snapshots:
ws@8.18.0: {}
+ xml-name-validator@5.0.0: {}
+
+ xmlchars@2.2.0: {}
+
yaml@1.10.3: {}
yocto-queue@0.1.0: {}
diff --git a/scripts/dev-integration.mjs b/scripts/dev-integration.mjs
new file mode 100644
index 00000000..572be163
--- /dev/null
+++ b/scripts/dev-integration.mjs
@@ -0,0 +1,89 @@
+#!/usr/bin/env node
+// Brings up the integration backend (docker-compose) and waits for it to be
+// reachable before launching `vite dev --mode integration`. Playwright starts
+// the webServer command before globalSetup, so we cannot rely on globalSetup
+// to start docker — we have to do it here, otherwise Vite SSR fetches race
+// the API container coming up and Playwright times out the webServer probe.
+
+import { spawn, spawnSync } from 'node:child_process';
+import { request as httpRequest } from 'node:http';
+import { request as httpsRequest } from 'node:https';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const API_URL = process.env.VITE_API_PROXY_TARGET ?? 'https://localhost:5001';
+const TIMEOUT_MS = Number(process.env.INTEGRATION_BACKEND_TIMEOUT_MS ?? 10 * 60 * 1000);
+const POLL_INTERVAL_MS = 1500;
+
+function probe(url) {
+ return new Promise((resolve) => {
+ const req = (url.startsWith('https:') ? httpsRequest : httpRequest)(
+ url,
+ { method: 'GET', rejectUnauthorized: false, timeout: 2000 },
+ (res) => {
+ res.resume();
+ // Any HTTP response (even 404) means the server is accepting connections.
+ resolve(true);
+ }
+ );
+ req.on('error', () => resolve(false));
+ req.on('timeout', () => {
+ req.destroy();
+ resolve(false);
+ });
+ req.end();
+ });
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+async function waitForBackend() {
+ const deadline = Date.now() + TIMEOUT_MS;
+ let attempts = 0;
+ process.stdout.write(`[dev:integration] waiting for backend at ${API_URL} ...\n`);
+ while (Date.now() < deadline) {
+ if (await probe(API_URL)) {
+ process.stdout.write(`[dev:integration] backend reachable after ${attempts} attempt(s)\n`);
+ return;
+ }
+ attempts++;
+ await sleep(POLL_INTERVAL_MS);
+ }
+ process.stderr.write(
+ `[dev:integration] backend at ${API_URL} did not become reachable within ${TIMEOUT_MS}ms\n`
+ );
+ process.exit(1);
+}
+
+function startDockerStack() {
+ process.stdout.write('[dev:integration] starting docker-compose stack ...\n');
+ const result = spawnSync(
+ 'docker',
+ ['compose', '-f', 'docker-compose.integration.yml', 'up', '-d', '--wait'],
+ { cwd: ROOT, stdio: 'inherit' }
+ );
+ if (result.status !== 0) {
+ process.stderr.write(
+ '[dev:integration] failed to start docker stack — is Docker Desktop running?\n'
+ );
+ process.exit(result.status ?? 1);
+ }
+}
+
+startDockerStack();
+await waitForBackend();
+
+const child = spawn('pnpm', ['exec', 'vite', 'dev', '--mode', 'integration'], {
+ stdio: 'inherit',
+ shell: process.platform === 'win32',
+});
+
+child.on('exit', (code, signal) => {
+ if (signal) process.kill(process.pid, signal);
+ else process.exit(code ?? 0);
+});
+
+for (const sig of ['SIGINT', 'SIGTERM']) {
+ process.on(sig, () => child.kill(sig));
+}
diff --git a/src/hooks.client.ts b/src/hooks.client.ts
index c3144aa6..0405dab9 100644
--- a/src/hooks.client.ts
+++ b/src/hooks.client.ts
@@ -18,6 +18,8 @@ async function initBackendMetadata() {
if (isUserAuthenticated) {
// fire both requests in parallel
await Promise.all([userState.refreshSelf(), initializeSignalR()]);
+ } else {
+ userState.reset();
}
}
diff --git a/src/hooks.server.ts b/src/hooks.server.ts
new file mode 100644
index 00000000..f916b522
--- /dev/null
+++ b/src/hooks.server.ts
@@ -0,0 +1,8 @@
+import type { Handle } from '@sveltejs/kit';
+
+export const handle: Handle = async ({ event, resolve }) => {
+ const response = await resolve(event);
+ response.headers.set('X-Content-Type-Options', 'nosniff');
+ response.headers.set('X-Frame-Options', 'SAMEORIGIN');
+ return response;
+};
diff --git a/src/lib/api/firmwareCDN.test.ts b/src/lib/api/firmwareCDN.test.ts
new file mode 100644
index 00000000..7806d91e
--- /dev/null
+++ b/src/lib/api/firmwareCDN.test.ts
@@ -0,0 +1,210 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ DownloadAndVerifyBoardBinary,
+ FetchChannelVersion,
+ FetchVersionBoards,
+ GetBoardBinaryHash,
+ GetBoardBinaryHashes,
+} from './firmwareCDN';
+
+// Mock the crypto util so hash verification is controllable in tests
+vi.mock('$lib/utils/crypto', () => ({
+ HashBuffer: vi.fn(),
+ HashString: vi.fn(),
+}));
+
+import { HashBuffer } from '$lib/utils/crypto';
+
+beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn());
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.clearAllMocks();
+});
+
+function textResponse(body: string, status = 200): Response {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: status === 200 ? 'OK' : 'Not Found',
+ text: vi.fn().mockResolvedValue(body),
+ bytes: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3])),
+ } as unknown as Response;
+}
+
+function binaryResponse(bytes: Uint8Array, status = 200): Response {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: status === 200 ? 'OK' : 'Not Found',
+ text: vi.fn(),
+ bytes: vi.fn().mockResolvedValue(bytes),
+ } as unknown as Response;
+}
+
+// ---------------------------------------------------------------------------
+// FetchChannelVersion
+// ---------------------------------------------------------------------------
+
+describe('FetchChannelVersion', () => {
+ it('returns trimmed version string', async () => {
+ vi.mocked(fetch).mockResolvedValue(textResponse(' 1.2.3 '));
+ const version = await FetchChannelVersion('stable');
+ expect(version).toBe('1.2.3');
+ });
+
+ it('fetches from the correct URL', async () => {
+ vi.mocked(fetch).mockResolvedValue(textResponse('1.0.0'));
+ await FetchChannelVersion('beta');
+ expect(vi.mocked(fetch).mock.calls[0][0]).toContain('version-beta.txt');
+ });
+
+ it('throws when fetch returns non-ok status', async () => {
+ vi.mocked(fetch).mockResolvedValue(textResponse('', 404));
+ await expect(FetchChannelVersion('develop')).rejects.toThrow('404');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// FetchVersionBoards
+// ---------------------------------------------------------------------------
+
+describe('FetchVersionBoards', () => {
+ it('returns array of trimmed board names', async () => {
+ vi.mocked(fetch).mockResolvedValue(textResponse('board-A\n board-B \nboard-C'));
+ const boards = await FetchVersionBoards('1.0.0');
+ expect(boards).toEqual(['board-A', 'board-B', 'board-C']);
+ });
+
+ it('fetches from the correct URL', async () => {
+ vi.mocked(fetch).mockResolvedValue(textResponse('board-A'));
+ await FetchVersionBoards('2.0.0');
+ expect(vi.mocked(fetch).mock.calls[0][0]).toContain('2.0.0/boards.txt');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// GetBoardBinaryHashes
+// ---------------------------------------------------------------------------
+
+describe('GetBoardBinaryHashes', () => {
+ it('parses sha256 hash file into a map', async () => {
+ const hashContent = [
+ 'a'.repeat(64) + ' ./firmware.bin',
+ 'b'.repeat(64) + ' ./bootloader.bin',
+ ].join('\n');
+ vi.mocked(fetch).mockResolvedValue(textResponse(hashContent));
+
+ const hashes = await GetBoardBinaryHashes('1.0.0', 'esp32', 'sha256');
+ expect(hashes['firmware.bin']).toBe('a'.repeat(64));
+ expect(hashes['bootloader.bin']).toBe('b'.repeat(64));
+ });
+
+ it('strips leading "./" from filenames', async () => {
+ vi.mocked(fetch).mockResolvedValue(textResponse('a'.repeat(64) + ' ./firmware.bin'));
+ const hashes = await GetBoardBinaryHashes('1.0.0', 'esp32', 'sha256');
+ expect('firmware.bin' in hashes).toBe(true);
+ expect('./firmware.bin' in hashes).toBe(false);
+ });
+
+ it('parses md5 hash file (32-char hashes)', async () => {
+ vi.mocked(fetch).mockResolvedValue(textResponse('f'.repeat(32) + ' firmware.bin'));
+ const hashes = await GetBoardBinaryHashes('1.0.0', 'esp32', 'md5');
+ expect(hashes['firmware.bin']).toBe('f'.repeat(32));
+ });
+
+ it('throws for a line with no filename', async () => {
+ vi.mocked(fetch).mockResolvedValue(textResponse('a'.repeat(64)));
+ await expect(GetBoardBinaryHashes('1.0.0', 'esp32', 'sha256')).rejects.toThrow(
+ 'Invalid hash line'
+ );
+ });
+
+ it('throws for a hash with wrong length', async () => {
+ vi.mocked(fetch).mockResolvedValue(textResponse('abc firmware.bin'));
+ await expect(GetBoardBinaryHashes('1.0.0', 'esp32', 'sha256')).rejects.toThrow(
+ 'Invalid hash length'
+ );
+ });
+
+ it('throws for a hash with invalid characters', async () => {
+ vi.mocked(fetch).mockResolvedValue(textResponse('Z'.repeat(64) + ' firmware.bin'));
+ await expect(GetBoardBinaryHashes('1.0.0', 'esp32', 'sha256')).rejects.toThrow(
+ 'Invalid hash format'
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// GetBoardBinaryHash
+// ---------------------------------------------------------------------------
+
+describe('GetBoardBinaryHash', () => {
+ it('returns the hash for a known filename', async () => {
+ const expected = 'a'.repeat(64);
+ vi.mocked(fetch).mockResolvedValue(textResponse(`${expected} firmware.bin`));
+ const hash = await GetBoardBinaryHash('1.0.0', 'esp32', 'firmware.bin', 'sha256');
+ expect(hash).toBe(expected);
+ });
+
+ it('returns null for unknown filename', async () => {
+ vi.mocked(fetch).mockResolvedValue(textResponse('a'.repeat(64) + ' other.bin'));
+ const hash = await GetBoardBinaryHash('1.0.0', 'esp32', 'missing.bin', 'sha256');
+ expect(hash).toBeNull();
+ });
+
+ it('strips "./" prefix from filename before lookup', async () => {
+ const expected = 'b'.repeat(64);
+ vi.mocked(fetch).mockResolvedValue(textResponse(`${expected} firmware.bin`));
+ const hash = await GetBoardBinaryHash('1.0.0', 'esp32', './firmware.bin', 'sha256');
+ expect(hash).toBe(expected);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// DownloadAndVerifyBoardBinary
+// ---------------------------------------------------------------------------
+
+describe('DownloadAndVerifyBoardBinary', () => {
+ it('returns binary when hash matches', async () => {
+ const expectedHash = 'a'.repeat(64);
+ const binary = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
+
+ vi.mocked(fetch)
+ .mockResolvedValueOnce(binaryResponse(binary))
+ .mockResolvedValueOnce(textResponse(`${expectedHash} firmware.bin`));
+ vi.mocked(HashBuffer).mockResolvedValue(expectedHash);
+
+ const result = await DownloadAndVerifyBoardBinary('1.0.0', 'esp32', 'firmware.bin');
+ expect(result).toEqual(binary);
+ });
+
+ it('throws when calculated hash does not match', async () => {
+ const storedHash = 'a'.repeat(64);
+ const calculatedHash = 'b'.repeat(64);
+ const binary = new Uint8Array([1, 2, 3]);
+
+ vi.mocked(fetch)
+ .mockResolvedValueOnce(binaryResponse(binary))
+ .mockResolvedValueOnce(textResponse(`${storedHash} firmware.bin`));
+ vi.mocked(HashBuffer).mockResolvedValue(calculatedHash);
+
+ await expect(DownloadAndVerifyBoardBinary('1.0.0', 'esp32', 'firmware.bin')).rejects.toThrow(
+ 'Hash mismatch'
+ );
+ });
+
+ it('throws when no hash entry found for the filename', async () => {
+ const binary = new Uint8Array([1]);
+
+ vi.mocked(fetch)
+ .mockResolvedValueOnce(binaryResponse(binary))
+ .mockResolvedValueOnce(textResponse('a'.repeat(64) + ' other.bin'));
+
+ await expect(DownloadAndVerifyBoardBinary('1.0.0', 'esp32', 'firmware.bin')).rejects.toThrow(
+ 'No hash found'
+ );
+ });
+});
diff --git a/src/lib/api/pwnedPasswords.test.ts b/src/lib/api/pwnedPasswords.test.ts
new file mode 100644
index 00000000..59fc2a93
--- /dev/null
+++ b/src/lib/api/pwnedPasswords.test.ts
@@ -0,0 +1,67 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { checkPwnedCount } from './pwnedPasswords';
+
+beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn());
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+function makeTextResponse(text: string, status = 200): Response {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ text: vi.fn().mockResolvedValue(text),
+ } as unknown as Response;
+}
+
+describe('checkPwnedCount', () => {
+ it('throws for empty password', async () => {
+ await expect(checkPwnedCount('')).rejects.toThrow('Password cannot be empty');
+ });
+
+ it('returns 0 when password hash suffix is not in the response', async () => {
+ vi.mocked(fetch).mockResolvedValue(makeTextResponse('AABBCC:3\nDDEEFF:1'));
+ const count = await checkPwnedCount('not-pwned-password');
+ expect(count).toBe(0);
+ });
+
+ it('returns the breach count when hash suffix matches', async () => {
+ // SHA-1 of "password" = 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8
+ // Prefix: 5BAA6, suffix: 1E4C9B93F3F0682250B6CF8331B7EE68FD8
+ const suffix = '1E4C9B93F3F0682250B6CF8331B7EE68FD8';
+ vi.mocked(fetch).mockResolvedValue(makeTextResponse(`AAAAA:5\n${suffix}:9999\nBBBBB:1`));
+ const count = await checkPwnedCount('password');
+ expect(count).toBe(9999);
+ });
+
+ it('sends request to the correct HIBP range endpoint', async () => {
+ vi.mocked(fetch).mockResolvedValue(makeTextResponse(''));
+ await checkPwnedCount('password');
+ const url = vi.mocked(fetch).mock.calls[0][0] as string;
+ expect(url).toMatch(/^https:\/\/api\.pwnedpasswords\.com\/range\/[A-Fa-f0-9]{5}$/);
+ });
+
+ it('uses the first 5 chars of the SHA-1 hash as the prefix', async () => {
+ vi.mocked(fetch).mockResolvedValue(makeTextResponse(''));
+ await checkPwnedCount('password');
+ const url = vi.mocked(fetch).mock.calls[0][0] as string;
+ // SHA-1("password") = 5baa61e4c9b93f3f... → prefix is '5baa6' (lowercase)
+ expect(url.endsWith('5baa6')).toBe(true);
+ });
+
+ it('throws when fetch rejects (network error)', async () => {
+ vi.mocked(fetch).mockRejectedValue(new Error('Network failure'));
+ await expect(checkPwnedCount('mypassword')).rejects.toThrow(
+ 'Error while fetching pwned passwords range'
+ );
+ });
+
+ it('returns 0 for non-empty password with no pwned matches', async () => {
+ vi.mocked(fetch).mockResolvedValue(makeTextResponse('AAAAA:1\nBBBBB:2'));
+ const count = await checkPwnedCount('verylongandunlikelypwned42');
+ expect(count).toBe(0);
+ });
+});
diff --git a/src/lib/components/dialog-manager/dialog-store.test.svelte.ts b/src/lib/components/dialog-manager/dialog-store.test.svelte.ts
new file mode 100644
index 00000000..6a883193
--- /dev/null
+++ b/src/lib/components/dialog-manager/dialog-store.test.svelte.ts
@@ -0,0 +1,150 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// Mock the Svelte component imports — just need a non-null placeholder
+vi.mock('./dialog-alert-content.svelte', () => ({ default: { type: 'AlertContent' } }));
+vi.mock('./dialog-confirm-content.svelte', () => ({ default: { type: 'ConfirmContent' } }));
+vi.mock('./dialog-custom-content.svelte', () => ({ default: { type: 'CustomContent' } }));
+
+describe('dialog store', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('getOldestDialog returns null when no dialogs are open', async () => {
+ const { getOldestDialog } = await import('./dialog-store.svelte');
+ expect(getOldestDialog()).toBeNull();
+ });
+
+ it('createDialog registers a dialog accessible via getOldestDialog', async () => {
+ const { createDialog, getOldestDialog } = await import('./dialog-store.svelte');
+ createDialog((resolve) => ({
+ content: { type: 'TestContent' } as any,
+ props: { resolve },
+ resolve,
+ }));
+ const entry = getOldestDialog();
+ expect(entry).not.toBeNull();
+ expect(entry![1].content).toEqual({ type: 'TestContent' });
+ });
+
+ it('createDialog returns a promise that resolves when the callback fires', async () => {
+ const { createDialog } = await import('./dialog-store.svelte');
+ let capturedResolve: ((v: string) => void) | null = null;
+
+ const promise = createDialog((resolve) => {
+ capturedResolve = resolve;
+ return { content: {} as any, props: {}, resolve };
+ });
+
+ capturedResolve!('hello');
+ await expect(promise).resolves.toBe('hello');
+ });
+
+ it('createDialog removes the dialog after 150 ms', async () => {
+ const { createDialog, getOldestDialog } = await import('./dialog-store.svelte');
+ let capturedResolve: ((v: void) => void) | null = null;
+
+ createDialog((resolve) => {
+ capturedResolve = resolve;
+ return { content: {} as any, props: {}, resolve };
+ });
+
+ capturedResolve!(undefined);
+ expect(getOldestDialog()).not.toBeNull();
+
+ vi.advanceTimersByTime(150);
+ expect(getOldestDialog()).toBeNull();
+ });
+
+ it('createDialog resolve is idempotent — calling twice resolves only once', async () => {
+ const { createDialog } = await import('./dialog-store.svelte');
+ let capturedResolve: ((v: number) => void) | null = null;
+
+ const promise = createDialog((resolve) => {
+ capturedResolve = resolve;
+ return { content: {} as any, props: {}, resolve };
+ });
+
+ capturedResolve!(1);
+ capturedResolve!(2);
+ await expect(promise).resolves.toBe(1);
+ });
+
+ it('removeDialog immediately deletes a dialog by id', async () => {
+ const { createDialog, getOldestDialog, removeDialog } = await import('./dialog-store.svelte');
+ // Intercept the id by wrapping createDialog with a resolved-immediately dialog
+ const promise = createDialog((resolve) => ({
+ content: {} as any,
+ props: {},
+ resolve,
+ }));
+
+ const entry = getOldestDialog();
+ expect(entry).not.toBeNull();
+ const capturedId = entry![0];
+
+ removeDialog(capturedId);
+ expect(getOldestDialog()).toBeNull();
+
+ // Ensure promise doesn't reject
+ void promise;
+ });
+
+ it('multiple dialogs stack; getOldestDialog returns the first created', async () => {
+ const { createDialog, getOldestDialog } = await import('./dialog-store.svelte');
+
+ createDialog((resolve) => ({ content: { tag: 'first' } as any, props: {}, resolve }));
+ createDialog((resolve) => ({ content: { tag: 'second' } as any, props: {}, resolve }));
+
+ const oldest = getOldestDialog();
+ expect((oldest![1].content as any).tag).toBe('first');
+ });
+});
+
+describe('dialog.confirm / dialog.alert', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('confirm registers a dialog with ConfirmContent', async () => {
+ const { dialog, getOldestDialog } = await import('./dialog-store.svelte');
+ const { default: DialogConfirmContent } = await import('./dialog-confirm-content.svelte');
+
+ dialog.confirm({ title: 'Are you sure?' });
+
+ const entry = getOldestDialog();
+ expect(entry![1].content).toBe(DialogConfirmContent);
+ });
+
+ it('alert registers a dialog with AlertContent', async () => {
+ const { dialog, getOldestDialog } = await import('./dialog-store.svelte');
+ const { default: DialogAlertContent } = await import('./dialog-alert-content.svelte');
+
+ dialog.alert({ title: 'Info', desc: 'Something happened' });
+
+ const entry = getOldestDialog();
+ expect(entry![1].content).toBe(DialogAlertContent);
+ });
+
+ it('confirm close() callback resolves with confirmed=false', async () => {
+ const { dialog, getOldestDialog } = await import('./dialog-store.svelte');
+
+ const confirmPromise = dialog.confirm({ title: 'Delete?' });
+ const entry = getOldestDialog();
+ (entry![1].props as any).close();
+
+ vi.advanceTimersByTime(150);
+ const result = await confirmPromise;
+ expect(result).toEqual({ confirmed: false });
+ });
+});
diff --git a/src/lib/components/ui/data-table/mergeObjects.test.ts b/src/lib/components/ui/data-table/mergeObjects.test.ts
new file mode 100644
index 00000000..9b37d359
--- /dev/null
+++ b/src/lib/components/ui/data-table/mergeObjects.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it, vi } from 'vitest';
+import { mergeObjects } from './data-table.svelte';
+
+describe('mergeObjects', () => {
+ it('returns the single source as a proxy', () => {
+ const result = mergeObjects({ a: 1 });
+ expect(result.a).toBe(1);
+ });
+
+ it('later sources override earlier ones for the same key', () => {
+ const result = mergeObjects({ a: 1, b: 2 }, { a: 99 });
+ expect(result.a).toBe(99);
+ expect(result.b).toBe(2);
+ });
+
+ it('keys from all sources are accessible', () => {
+ const result = mergeObjects({ x: 'hello' }, { y: 'world' });
+ expect(result.x).toBe('hello');
+ expect(result.y).toBe('world');
+ });
+
+ it('resolves thunk (function) sources lazily', () => {
+ const thunk = vi.fn(() => ({ value: 42 }));
+ const result = mergeObjects(thunk) as unknown as { value: number };
+ expect(thunk).not.toHaveBeenCalled();
+ expect(result.value).toBe(42);
+ expect(thunk).toHaveBeenCalledOnce();
+ });
+
+ it('re-evaluates thunk on each property access', () => {
+ let counter = 0;
+ const thunk = () => ({ count: ++counter });
+ const result = mergeObjects(thunk) as unknown as { count: number };
+ void result.count;
+ void result.count;
+ expect(counter).toBe(2);
+ });
+
+ it('thunk returning null/undefined is skipped', () => {
+ const result = mergeObjects(() => null as any, { fallback: true });
+ expect(result.fallback).toBe(true);
+ });
+
+ it('"in" operator returns true for keys present in any source', () => {
+ const result = mergeObjects({ a: 1 }, { b: 2 });
+ expect('a' in result).toBe(true);
+ expect('b' in result).toBe(true);
+ expect('c' in result).toBe(false);
+ });
+
+ it('Object.keys covers keys from all sources', () => {
+ const result = mergeObjects({ a: 1 }, { b: 2 }, { c: 3 });
+ const keys = Object.keys(result).sort();
+ expect(keys).toEqual(['a', 'b', 'c']);
+ });
+
+ it('handles empty sources gracefully', () => {
+ const result = mergeObjects({}, {});
+ expect(Object.keys(result)).toHaveLength(0);
+ });
+
+ it('merges more than two sources in priority order', () => {
+ const result = mergeObjects({ a: 1 }, { a: 2 }, { a: 3 });
+ expect(result.a).toBe(3); // last source wins
+ });
+
+ it('undefined property access returns undefined', () => {
+ const result = mergeObjects({ a: 1 });
+ expect((result as any).nonexistent).toBeUndefined();
+ });
+});
diff --git a/src/lib/signalr/handlers/DeviceStatus.test.ts b/src/lib/signalr/handlers/DeviceStatus.test.ts
new file mode 100644
index 00000000..d53f0ee5
--- /dev/null
+++ b/src/lib/signalr/handlers/DeviceStatus.test.ts
@@ -0,0 +1,88 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mockOnlineHubs = vi.hoisted(() => new Map());
+
+vi.mock('$lib/state/hubs-state.svelte', () => {
+ class HubOnlineState {
+ hubId: string;
+ isOnline: boolean;
+ firmwareVersion: string | null;
+ otaInstall = null;
+ otaResult = null;
+ constructor(id: string, online: boolean, firmware: string | null) {
+ this.hubId = id;
+ this.isOnline = online;
+ this.firmwareVersion = firmware;
+ }
+ }
+ return { onlineHubs: mockOnlineHubs, HubOnlineState };
+});
+
+vi.mock('svelte-sonner', () => ({ toast: { error: vi.fn() } }));
+
+import { toast } from 'svelte-sonner';
+import { handleSignalrDeviceStatus } from './DeviceStatus';
+
+beforeEach(() => {
+ mockOnlineHubs.clear();
+ vi.mocked(toast.error).mockClear();
+});
+
+describe('handleSignalrDeviceStatus', () => {
+ it('creates a new HubOnlineState for an unknown device', () => {
+ handleSignalrDeviceStatus([{ device: 'hub-1', online: true, firmwareVersion: '4.0.0' }]);
+ const hub = mockOnlineHubs.get('hub-1');
+ expect(hub).toBeDefined();
+ expect(hub.hubId).toBe('hub-1');
+ expect(hub.isOnline).toBe(true);
+ expect(hub.firmwareVersion).toBe('4.0.0');
+ });
+
+ it('updates an existing hub without creating a new instance', () => {
+ const existing = { isOnline: false, firmwareVersion: null };
+ mockOnlineHubs.set('hub-1', existing);
+
+ handleSignalrDeviceStatus([{ device: 'hub-1', online: true, firmwareVersion: '4.1.0' }]);
+
+ // Same reference — no new object created
+ expect(mockOnlineHubs.get('hub-1')).toBe(existing);
+ expect(existing.isOnline).toBe(true);
+ expect(existing.firmwareVersion).toBe('4.1.0');
+ });
+
+ it('handles multiple entries in one call', () => {
+ handleSignalrDeviceStatus([
+ { device: 'hub-1', online: true, firmwareVersion: null },
+ { device: 'hub-2', online: false, firmwareVersion: '3.0.0' },
+ ]);
+ expect(mockOnlineHubs.size).toBe(2);
+ });
+
+ it('accepts null firmwareVersion', () => {
+ handleSignalrDeviceStatus([{ device: 'hub-1', online: true, firmwareVersion: null }]);
+ expect(mockOnlineHubs.get('hub-1')?.firmwareVersion).toBeNull();
+ });
+
+ it('shows toast.error and returns early for non-array input', () => {
+ handleSignalrDeviceStatus('not-an-array');
+ expect(vi.mocked(toast.error)).toHaveBeenCalled();
+ expect(mockOnlineHubs.size).toBe(0);
+ });
+
+ it('shows toast.error for array containing invalid entry', () => {
+ handleSignalrDeviceStatus([{ device: 123, online: true, firmwareVersion: null }]);
+ expect(vi.mocked(toast.error)).toHaveBeenCalled();
+ expect(mockOnlineHubs.size).toBe(0);
+ });
+
+ it('shows toast.error for entry with missing required fields', () => {
+ handleSignalrDeviceStatus([{ device: 'hub-1' }]);
+ expect(vi.mocked(toast.error)).toHaveBeenCalled();
+ });
+
+ it('processes empty array without error or toast', () => {
+ handleSignalrDeviceStatus([]);
+ expect(mockOnlineHubs.size).toBe(0);
+ expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/lib/signalr/handlers/DeviceUpdate.test.ts b/src/lib/signalr/handlers/DeviceUpdate.test.ts
new file mode 100644
index 00000000..4727b4d6
--- /dev/null
+++ b/src/lib/signalr/handlers/DeviceUpdate.test.ts
@@ -0,0 +1,57 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mockRefreshOwnHubs = vi.hoisted(() => vi.fn());
+
+vi.mock('$lib/state/hubs-state.svelte', () => ({
+ refreshOwnHubs: mockRefreshOwnHubs,
+}));
+
+vi.mock('svelte-sonner', () => ({ toast: { error: vi.fn() } }));
+
+import { toast } from 'svelte-sonner';
+import { handleSignalrDeviceUpdate } from './DeviceUpdate';
+
+beforeEach(() => {
+ mockRefreshOwnHubs.mockClear();
+ vi.mocked(toast.error).mockClear();
+});
+
+describe('handleSignalrDeviceUpdate', () => {
+ it('calls refreshOwnHubs for a valid HubUpdated event', () => {
+ handleSignalrDeviceUpdate('hub-1', 1 /* HubUpdated */);
+ expect(mockRefreshOwnHubs).toHaveBeenCalledOnce();
+ });
+
+ it('calls refreshOwnHubs for HubCreated', () => {
+ handleSignalrDeviceUpdate('hub-1', 0 /* HubCreated */);
+ expect(mockRefreshOwnHubs).toHaveBeenCalledOnce();
+ });
+
+ it('calls refreshOwnHubs for HubDeleted', () => {
+ handleSignalrDeviceUpdate('hub-1', 3 /* HubDeleted */);
+ expect(mockRefreshOwnHubs).toHaveBeenCalledOnce();
+ });
+
+ it('calls refreshOwnHubs for HubShockersUpdate', () => {
+ handleSignalrDeviceUpdate('hub-1', 2 /* HubShockersUpdate */);
+ expect(mockRefreshOwnHubs).toHaveBeenCalledOnce();
+ });
+
+ it('shows toast.error and skips refresh for invalid deviceId (number)', () => {
+ handleSignalrDeviceUpdate(42, 1);
+ expect(vi.mocked(toast.error)).toHaveBeenCalled();
+ expect(mockRefreshOwnHubs).not.toHaveBeenCalled();
+ });
+
+ it('shows toast.error and skips refresh for invalid updateType', () => {
+ handleSignalrDeviceUpdate('hub-1', 999);
+ expect(vi.mocked(toast.error)).toHaveBeenCalled();
+ expect(mockRefreshOwnHubs).not.toHaveBeenCalled();
+ });
+
+ it('shows toast.error when both arguments are invalid', () => {
+ handleSignalrDeviceUpdate(null, null);
+ expect(vi.mocked(toast.error)).toHaveBeenCalled();
+ expect(mockRefreshOwnHubs).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/lib/signalr/handlers/Log.test.ts b/src/lib/signalr/handlers/Log.test.ts
new file mode 100644
index 00000000..661db871
--- /dev/null
+++ b/src/lib/signalr/handlers/Log.test.ts
@@ -0,0 +1,140 @@
+import { ControlType } from '$lib/signalr/models/ControlType';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('$app/environment', () => ({ dev: false }));
+vi.mock('svelte-sonner', () => ({ toast: { error: vi.fn() } }));
+
+import { toast } from 'svelte-sonner';
+import { addShockEventListener, handleSignalrLog, removeShockEventListener } from './Log';
+
+const validSender = {
+ connectionId: 'conn-1',
+ additionalItems: {},
+ id: 'user-1',
+ name: 'Alice',
+ image: 'avatar.png',
+ customName: null,
+};
+
+function makeLog(overrides: Partial> = {}) {
+ return { ...baseLog(), ...overrides };
+}
+
+function baseLog() {
+ return {
+ shocker: { id: 'sh-1', name: 'Shocker One' },
+ type: ControlType.Vibrate,
+ intensity: 50,
+ duration: 300,
+ executedAt: new Date().toISOString(),
+ };
+}
+
+beforeEach(() => {
+ vi.mocked(toast.error).mockClear();
+});
+
+// Clean up any listeners added during tests to avoid cross-test contamination
+// (Log.ts keeps a module-level listeners array)
+const addedIds: string[] = [];
+afterEach(() => {
+ for (const id of addedIds) {
+ removeShockEventListener(id);
+ }
+ addedIds.length = 0;
+});
+
+function trackListener(
+ id: string,
+ shockerId: string,
+ cb: Parameters[2]
+) {
+ addShockEventListener(id, shockerId, cb);
+ addedIds.push(id);
+}
+
+describe('handleSignalrLog validation', () => {
+ it('shows toast.error for invalid sender (null)', () => {
+ handleSignalrLog(null, [makeLog()]);
+ expect(vi.mocked(toast.error)).toHaveBeenCalled();
+ });
+
+ it('shows toast.error for invalid sender (missing fields)', () => {
+ handleSignalrLog({ id: 'x' }, [makeLog()]);
+ expect(vi.mocked(toast.error)).toHaveBeenCalled();
+ });
+
+ it('shows toast.error when logs is not an array', () => {
+ handleSignalrLog(validSender, 'not-an-array');
+ expect(vi.mocked(toast.error)).toHaveBeenCalled();
+ });
+
+ it('shows toast.error when logs array contains invalid entries', () => {
+ handleSignalrLog(validSender, [{ type: 'bad' }]);
+ expect(vi.mocked(toast.error)).toHaveBeenCalled();
+ });
+
+ it('accepts an empty logs array without error', () => {
+ handleSignalrLog(validSender, []);
+ expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
+ });
+});
+
+describe('handleSignalrLog dispatch', () => {
+ it('calls a registered listener for a matching shocker', () => {
+ const cb = vi.fn();
+ trackListener('l-1', 'sh-1', cb);
+
+ handleSignalrLog(validSender, [makeLog()]);
+
+ expect(cb).toHaveBeenCalledOnce();
+ expect(cb).toHaveBeenCalledWith('sh-1', ControlType.Vibrate, 300, 50);
+ });
+
+ it('does not call listener for a different shocker', () => {
+ const cb = vi.fn();
+ trackListener('l-2', 'sh-99', cb);
+
+ handleSignalrLog(validSender, [makeLog()]);
+
+ expect(cb).not.toHaveBeenCalled();
+ });
+
+ it('calls all matching listeners', () => {
+ const cb1 = vi.fn();
+ const cb2 = vi.fn();
+ trackListener('l-3', 'sh-1', cb1);
+ trackListener('l-4', 'sh-1', cb2);
+
+ handleSignalrLog(validSender, [makeLog()]);
+
+ expect(cb1).toHaveBeenCalledOnce();
+ expect(cb2).toHaveBeenCalledOnce();
+ });
+
+ it('dispatches each log entry independently', () => {
+ const cb = vi.fn();
+ trackListener('l-5', 'sh-1', cb);
+
+ const log1 = makeLog({ intensity: 30 });
+ const log2 = makeLog({ intensity: 70 });
+ handleSignalrLog(validSender, [log1, log2]);
+
+ expect(cb).toHaveBeenCalledTimes(2);
+ });
+});
+
+describe('addShockEventListener / removeShockEventListener', () => {
+ it('listener is not called after removal', () => {
+ const cb = vi.fn();
+ addShockEventListener('l-remove', 'sh-1', cb);
+ removeShockEventListener('l-remove');
+
+ handleSignalrLog(validSender, [makeLog()]);
+ expect(cb).not.toHaveBeenCalled();
+ });
+
+ it('removeShockEventListener is a no-op for unknown id', () => {
+ expect(() => removeShockEventListener('does-not-exist')).not.toThrow();
+ });
+});
diff --git a/src/lib/signalr/handlers/OtaInstallProgress.test.ts b/src/lib/signalr/handlers/OtaInstallProgress.test.ts
new file mode 100644
index 00000000..b31449ed
--- /dev/null
+++ b/src/lib/signalr/handlers/OtaInstallProgress.test.ts
@@ -0,0 +1,96 @@
+import { OtaUpdateProgressTask } from '$lib/signalr/models/OtaUpdateProgressTask';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mockOnlineHubs = vi.hoisted(() => new Map());
+
+vi.mock('$lib/state/hubs-state.svelte', () => ({
+ onlineHubs: mockOnlineHubs,
+}));
+
+vi.mock('svelte-sonner', () => ({ toast: { error: vi.fn() } }));
+
+import { toast } from 'svelte-sonner';
+import { handleSignalrOtaInstallProgress } from './OtaInstallProgress';
+
+beforeEach(() => {
+ mockOnlineHubs.clear();
+ vi.mocked(toast.error).mockClear();
+});
+
+describe('handleSignalrOtaInstallProgress', () => {
+ it('updates otaInstall when hub and updateId match', () => {
+ const hub = {
+ otaInstall: { id: 7, task: OtaUpdateProgressTask.FetchingMetadata, progress: 0 },
+ };
+ mockOnlineHubs.set('hub-1', hub);
+
+ handleSignalrOtaInstallProgress('hub-1', 7, OtaUpdateProgressTask.FlashingApplication, 50);
+
+ expect(hub.otaInstall).toEqual({
+ id: 7,
+ task: OtaUpdateProgressTask.FlashingApplication,
+ progress: 50,
+ });
+ });
+
+ it('is a no-op when the hub is not in onlineHubs', () => {
+ handleSignalrOtaInstallProgress('unknown', 1, OtaUpdateProgressTask.Rebooting, 100);
+ expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
+ });
+
+ it('is a no-op when updateId does not match', () => {
+ const hub = {
+ otaInstall: { id: 5, task: OtaUpdateProgressTask.FetchingMetadata, progress: 10 },
+ };
+ mockOnlineHubs.set('hub-1', hub);
+
+ handleSignalrOtaInstallProgress('hub-1', 99, OtaUpdateProgressTask.FlashingApplication, 80);
+
+ expect(hub.otaInstall.id).toBe(5);
+ expect(hub.otaInstall.progress).toBe(10);
+ });
+
+ it('is a no-op when hub has no otaInstall', () => {
+ mockOnlineHubs.set('hub-1', { otaInstall: null });
+ handleSignalrOtaInstallProgress('hub-1', 1, OtaUpdateProgressTask.Rebooting, 90);
+ expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
+ });
+
+ it('shows toast.error for non-string hubId', () => {
+ handleSignalrOtaInstallProgress(42, 1, OtaUpdateProgressTask.Rebooting, 50);
+ expect(vi.mocked(toast.error)).toHaveBeenCalled();
+ });
+
+ it('shows toast.error for non-number updateId', () => {
+ handleSignalrOtaInstallProgress('hub-1', 'bad', OtaUpdateProgressTask.Rebooting, 50);
+ expect(vi.mocked(toast.error)).toHaveBeenCalled();
+ });
+
+ it('shows toast.error for invalid task value', () => {
+ handleSignalrOtaInstallProgress('hub-1', 1, 999, 50);
+ expect(vi.mocked(toast.error)).toHaveBeenCalled();
+ });
+
+ it('shows toast.error for non-number progress', () => {
+ handleSignalrOtaInstallProgress('hub-1', 1, OtaUpdateProgressTask.Rebooting, 'done');
+ expect(vi.mocked(toast.error)).toHaveBeenCalled();
+ });
+
+ it('preserves other otaInstall fields when updating', () => {
+ const hub = {
+ otaInstall: {
+ id: 3,
+ version: '4.0.0',
+ task: OtaUpdateProgressTask.FetchingMetadata,
+ progress: 0,
+ },
+ };
+ mockOnlineHubs.set('hub-1', hub);
+
+ handleSignalrOtaInstallProgress('hub-1', 3, OtaUpdateProgressTask.FlashingFilesystem, 25);
+
+ expect(hub.otaInstall.version).toBe('4.0.0');
+ expect(hub.otaInstall.task).toBe(OtaUpdateProgressTask.FlashingFilesystem);
+ expect(hub.otaInstall.progress).toBe(25);
+ });
+});
diff --git a/src/lib/signalr/handlers/OtaRollback.test.ts b/src/lib/signalr/handlers/OtaRollback.test.ts
new file mode 100644
index 00000000..e5a9c3f2
--- /dev/null
+++ b/src/lib/signalr/handlers/OtaRollback.test.ts
@@ -0,0 +1,66 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mockOnlineHubs = vi.hoisted(() => new Map());
+
+vi.mock('$lib/state/hubs-state.svelte', () => ({
+ onlineHubs: mockOnlineHubs,
+}));
+
+vi.mock('svelte-sonner', () => ({ toast: { error: vi.fn(), warning: vi.fn() } }));
+
+import { toast } from 'svelte-sonner';
+import { handleSignalrOtaRollback } from './OtaRollback';
+
+beforeEach(() => {
+ mockOnlineHubs.clear();
+ vi.mocked(toast.error).mockClear();
+ vi.mocked(toast.warning).mockClear();
+});
+
+describe('handleSignalrOtaRollback', () => {
+ it('sets otaInstall to null and otaResult to failed when hub and updateId match', () => {
+ const hub = {
+ otaInstall: { id: 5, task: 0, progress: 80 },
+ otaResult: null,
+ };
+ mockOnlineHubs.set('hub-1', hub);
+
+ handleSignalrOtaRollback('hub-1', 5);
+
+ expect(hub.otaInstall).toBeNull();
+ expect(hub.otaResult).toEqual({
+ success: false,
+ message: 'Device rolled back to previous version',
+ });
+ });
+
+ it('always shows toast.warning regardless of hub presence', () => {
+ handleSignalrOtaRollback('hub-1', 1);
+ expect(vi.mocked(toast.warning)).toHaveBeenCalled();
+ });
+
+ it('is a no-op on hub state when hub is not found', () => {
+ handleSignalrOtaRollback('nonexistent', 1);
+ expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
+ });
+
+ it('is a no-op on hub state when updateId does not match', () => {
+ const hub = { otaInstall: { id: 7, task: 0, progress: 50 }, otaResult: null };
+ mockOnlineHubs.set('hub-1', hub);
+
+ handleSignalrOtaRollback('hub-1', 99);
+
+ expect(hub.otaInstall).not.toBeNull();
+ expect(hub.otaResult).toBeNull();
+ });
+
+ it('shows toast.error for non-string hubId', () => {
+ handleSignalrOtaRollback(42, 1);
+ expect(vi.mocked(toast.error)).toHaveBeenCalled();
+ });
+
+ it('shows toast.error for non-number updateId', () => {
+ handleSignalrOtaRollback('hub-1', 'bad');
+ expect(vi.mocked(toast.error)).toHaveBeenCalled();
+ });
+});
diff --git a/src/lib/state/__fixtures__/BreadcrumbRegistrar.svelte b/src/lib/state/__fixtures__/BreadcrumbRegistrar.svelte
new file mode 100644
index 00000000..da1b97cc
--- /dev/null
+++ b/src/lib/state/__fixtures__/BreadcrumbRegistrar.svelte
@@ -0,0 +1,12 @@
+
diff --git a/src/lib/state/backend-metadata-state.test.svelte.ts b/src/lib/state/backend-metadata-state.test.svelte.ts
new file mode 100644
index 00000000..8b26fd7f
--- /dev/null
+++ b/src/lib/state/backend-metadata-state.test.svelte.ts
@@ -0,0 +1,96 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// Mock $lib/api before any module loads. vi.mock is hoisted automatically.
+// The factory is re-called after each vi.resetModules(), so each test gets fresh vi.fn()s.
+vi.mock('$lib/api', () => ({
+ metaApi: {
+ versionGetBackendInfo: vi.fn(),
+ },
+}));
+
+describe('backendMetadata', () => {
+ // Reset module registry before each test so that module-level $state starts as null.
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('state is null before init is called', async () => {
+ const { backendMetadata } = await import('./backend-metadata-state.svelte');
+ expect(backendMetadata.state).toBeNull();
+ });
+
+ it('init stores the API response in state', async () => {
+ const { backendMetadata } = await import('./backend-metadata-state.svelte');
+ const { metaApi } = await import('$lib/api');
+ const mockData = { version: '1.0.0', currentTime: '2026-04-27T00:00:00Z' };
+ vi.mocked(metaApi.versionGetBackendInfo).mockResolvedValue({ data: mockData } as any);
+
+ await backendMetadata.init();
+
+ expect(backendMetadata.state).toEqual(mockData);
+ });
+
+ it('init returns the fetched backend info', async () => {
+ const { backendMetadata } = await import('./backend-metadata-state.svelte');
+ const { metaApi } = await import('$lib/api');
+ const mockData = { version: '2.0.0', currentTime: '2026-04-27T00:00:00Z' };
+ vi.mocked(metaApi.versionGetBackendInfo).mockResolvedValue({ data: mockData } as any);
+
+ const result = await backendMetadata.init();
+
+ expect(result).toEqual(mockData);
+ });
+
+ it('init throws when response.data is null', async () => {
+ const { backendMetadata } = await import('./backend-metadata-state.svelte');
+ const { metaApi } = await import('$lib/api');
+ vi.mocked(metaApi.versionGetBackendInfo).mockResolvedValue({
+ data: null,
+ message: 'Service unavailable',
+ } as any);
+
+ await expect(backendMetadata.init()).rejects.toThrow(
+ 'Failed to get backend info: Service unavailable'
+ );
+ });
+
+ it('init throws when response.data is undefined', async () => {
+ const { backendMetadata } = await import('./backend-metadata-state.svelte');
+ const { metaApi } = await import('$lib/api');
+ vi.mocked(metaApi.versionGetBackendInfo).mockResolvedValue({ data: undefined } as any);
+
+ await expect(backendMetadata.init()).rejects.toThrow('Failed to get backend info');
+ });
+
+ it('state remains null if init throws', async () => {
+ const { backendMetadata } = await import('./backend-metadata-state.svelte');
+ const { metaApi } = await import('$lib/api');
+ vi.mocked(metaApi.versionGetBackendInfo).mockResolvedValue({
+ data: null,
+ message: 'Oops',
+ } as any);
+
+ await backendMetadata.init().catch(() => {});
+
+ expect(backendMetadata.state).toBeNull();
+ });
+
+ it('second init call overwrites state with new data', async () => {
+ const { backendMetadata } = await import('./backend-metadata-state.svelte');
+ const { metaApi } = await import('$lib/api');
+ const first = { version: '1.0.0', currentTime: '2026-01-01T00:00:00Z' };
+ const second = { version: '1.1.0', currentTime: '2026-04-27T00:00:00Z' };
+ vi.mocked(metaApi.versionGetBackendInfo)
+ .mockResolvedValueOnce({ data: first } as any)
+ .mockResolvedValueOnce({ data: second } as any);
+
+ await backendMetadata.init();
+ await backendMetadata.init();
+
+ expect(backendMetadata.state).toEqual(second);
+ });
+});
diff --git a/src/lib/state/breadcrumbs-state.test.svelte.ts b/src/lib/state/breadcrumbs-state.test.svelte.ts
new file mode 100644
index 00000000..58d57780
--- /dev/null
+++ b/src/lib/state/breadcrumbs-state.test.svelte.ts
@@ -0,0 +1,82 @@
+import { cleanup, render } from '@testing-library/svelte';
+import { flushSync } from 'svelte';
+import { afterEach, describe, expect, it } from 'vitest';
+import BreadcrumbRegistrar from './__fixtures__/BreadcrumbRegistrar.svelte';
+import { breadcrumbs } from './breadcrumbs-state.svelte';
+
+// Each render call mounts a component whose onDestroy clears its slot in _slots.
+// cleanup() unmounts all rendered components, keeping state clean between tests.
+afterEach(cleanup);
+
+it('breadcrumbs.state is empty when nothing is registered', () => {
+ expect(breadcrumbs.state).toEqual([]);
+});
+
+describe('registerBreadcrumbs', () => {
+ it('populates state after mount', () => {
+ render(BreadcrumbRegistrar, { entries: [{ label: 'Home', href: '/' }] });
+ flushSync();
+ expect(breadcrumbs.state).toEqual([{ label: 'Home', href: '/' }]);
+ });
+
+ it('defaults href to null when omitted', () => {
+ render(BreadcrumbRegistrar, { entries: [{ label: 'Settings' }] });
+ flushSync();
+ expect(breadcrumbs.state).toEqual([{ label: 'Settings', href: null }]);
+ });
+
+ it('supports multiple entries in one registration', () => {
+ render(BreadcrumbRegistrar, {
+ entries: [
+ { label: 'Home', href: '/' },
+ { label: 'Settings', href: '/settings' },
+ ],
+ });
+ flushSync();
+ expect(breadcrumbs.state).toHaveLength(2);
+ expect(breadcrumbs.state[0]).toEqual({ label: 'Home', href: '/' });
+ expect(breadcrumbs.state[1]).toEqual({ label: 'Settings', href: '/settings' });
+ });
+
+ it('preserves explicit null href', () => {
+ render(BreadcrumbRegistrar, { entries: [{ label: 'Current', href: null }] });
+ flushSync();
+ expect(breadcrumbs.state[0].href).toBeNull();
+ });
+
+ it('removes entries when the component is destroyed', () => {
+ const { unmount } = render(BreadcrumbRegistrar, { entries: [{ label: 'Home', href: '/' }] });
+ flushSync();
+ expect(breadcrumbs.state).toHaveLength(1);
+ unmount();
+ expect(breadcrumbs.state).toHaveLength(0);
+ });
+
+ it('stacks entries from multiple independent registrations', () => {
+ render(BreadcrumbRegistrar, { entries: [{ label: 'First', href: '/first' }] });
+ render(BreadcrumbRegistrar, { entries: [{ label: 'Second', href: '/second' }] });
+ flushSync();
+ expect(breadcrumbs.state).toHaveLength(2);
+ });
+
+ it('removing one registration leaves the others intact', () => {
+ render(BreadcrumbRegistrar, { entries: [{ label: 'Persistent', href: '/p' }] });
+ const { unmount } = render(BreadcrumbRegistrar, {
+ entries: [{ label: 'Transient', href: '/t' }],
+ });
+ flushSync();
+ unmount();
+ flushSync();
+ expect(breadcrumbs.state).toHaveLength(1);
+ expect(breadcrumbs.state[0].label).toBe('Persistent');
+ });
+
+ it('state is empty after all registrations are destroyed', () => {
+ const { unmount: u1 } = render(BreadcrumbRegistrar, { entries: [{ label: 'A', href: '/a' }] });
+ const { unmount: u2 } = render(BreadcrumbRegistrar, { entries: [{ label: 'B', href: '/b' }] });
+ flushSync();
+ u1();
+ u2();
+ expect(breadcrumbs.state).toHaveLength(0);
+ });
+});
diff --git a/src/lib/state/color-scheme-state.test.svelte.ts b/src/lib/state/color-scheme-state.test.svelte.ts
new file mode 100644
index 00000000..a1b97212
--- /dev/null
+++ b/src/lib/state/color-scheme-state.test.svelte.ts
@@ -0,0 +1,165 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('$app/environment', () => ({ browser: true }));
+
+function makeMatchMedia(prefersLight: boolean) {
+ return vi.fn().mockImplementation((query: string) => ({
+ matches: query === '(prefers-color-scheme: light)' ? prefersLight : !prefersLight,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ }));
+}
+
+// jsdom has no matchMedia — stub before the module is loaded so the singleton
+// constructor does not throw if it ever calls matchMedia during init.
+Object.defineProperty(window, 'matchMedia', { value: makeMatchMedia(false), writable: true });
+
+const { colorScheme, getDarkReaderState, initializeColorScheme, ColorScheme } =
+ await import('./color-scheme-state.svelte');
+
+function makeStorageEvent(key: string, newValue: string | null, storageArea: Storage): Event {
+ const event = new Event('storage');
+ Object.defineProperties(event, {
+ key: { value: key },
+ newValue: { value: newValue },
+ storageArea: { value: storageArea },
+ });
+ return event;
+}
+
+const cleanDom = () => {
+ document.documentElement.classList.remove('dark');
+ document.documentElement.removeAttribute('data-darkreader-proxy-injected');
+ document.documentElement.removeAttribute('data-darkreader-scheme');
+ document.head.querySelectorAll('meta[name="darkreader"]').forEach((el) => el.remove());
+};
+
+describe('getDarkReaderState', () => {
+ beforeEach(cleanDom);
+ afterEach(cleanDom);
+
+ it('returns defaults when no DarkReader attributes are present', () => {
+ expect(getDarkReaderState()).toEqual({ isInjected: false, isActive: false, scheme: null });
+ });
+
+ it('detects proxy-injected=true', () => {
+ document.documentElement.setAttribute('data-darkreader-proxy-injected', 'true');
+ expect(getDarkReaderState().isInjected).toBe(true);
+ });
+
+ it('does not treat proxy-injected=false as injected', () => {
+ document.documentElement.setAttribute('data-darkreader-proxy-injected', 'false');
+ expect(getDarkReaderState().isInjected).toBe(false);
+ });
+
+ it('detects active DarkReader meta element', () => {
+ const meta = document.createElement('meta');
+ meta.setAttribute('name', 'darkreader');
+ document.head.appendChild(meta);
+ expect(getDarkReaderState().isActive).toBe(true);
+ });
+
+ it('reads scheme attribute', () => {
+ document.documentElement.setAttribute('data-darkreader-scheme', 'dark');
+ expect(getDarkReaderState().scheme).toBe('dark');
+ });
+
+ it('returns null scheme when attribute is absent', () => {
+ expect(getDarkReaderState().scheme).toBeNull();
+ });
+});
+
+describe('colorScheme singleton', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ window.matchMedia = makeMatchMedia(false);
+ cleanDom();
+ });
+
+ afterEach(() => {
+ colorScheme.reset();
+ });
+
+ it('has System as default value', () => {
+ expect(colorScheme.defaultValue).toBe(ColorScheme.System);
+ });
+
+ it('setting to Dark adds dark class to ', () => {
+ colorScheme.value = ColorScheme.Dark;
+ expect(document.documentElement.classList.contains('dark')).toBe(true);
+ });
+
+ it('setting to Light removes dark class from ', () => {
+ colorScheme.value = ColorScheme.Dark;
+ colorScheme.value = ColorScheme.Light;
+ expect(document.documentElement.classList.contains('dark')).toBe(false);
+ });
+
+ it('System with prefers-light removes dark class', () => {
+ window.matchMedia = makeMatchMedia(true);
+ colorScheme.value = ColorScheme.System;
+ expect(document.documentElement.classList.contains('dark')).toBe(false);
+ });
+
+ it('System without prefers-light defaults to dark', () => {
+ window.matchMedia = makeMatchMedia(false);
+ colorScheme.value = ColorScheme.System;
+ expect(document.documentElement.classList.contains('dark')).toBe(true);
+ });
+
+ it('System with DarkReader injected stays dark even when system prefers light', () => {
+ window.matchMedia = makeMatchMedia(true);
+ document.documentElement.setAttribute('data-darkreader-proxy-injected', 'true');
+ colorScheme.value = ColorScheme.System;
+ expect(document.documentElement.classList.contains('dark')).toBe(true);
+ });
+
+ it('persists value to localStorage under the "theme" key', () => {
+ colorScheme.value = ColorScheme.Dark;
+ expect(localStorage.getItem('theme')).toBe('dark');
+
+ colorScheme.value = ColorScheme.Light;
+ expect(localStorage.getItem('theme')).toBe('light');
+ });
+
+ it('reset removes the storage key and reverts to System', () => {
+ colorScheme.value = ColorScheme.Dark;
+ colorScheme.reset();
+ expect(colorScheme.value).toBe(ColorScheme.System);
+ expect(localStorage.getItem('theme')).toBeNull();
+ });
+
+ it('picks up ColorScheme.Light via cross-tab storage event', () => {
+ window.dispatchEvent(makeStorageEvent('theme', ColorScheme.Light, localStorage));
+ expect(colorScheme.value).toBe(ColorScheme.Light);
+ });
+
+ it('falls back to System for invalid cross-tab storage event values', () => {
+ window.dispatchEvent(makeStorageEvent('theme', 'bogus-scheme', localStorage));
+ expect(colorScheme.value).toBe(ColorScheme.System);
+ });
+
+ it('ignores storage events for unrelated keys', () => {
+ colorScheme.value = ColorScheme.Dark;
+ window.dispatchEvent(makeStorageEvent('other-key', ColorScheme.Light, localStorage));
+ expect(colorScheme.value).toBe(ColorScheme.Dark);
+ });
+});
+
+describe('initializeColorScheme', () => {
+ it('applies dark mode immediately and attaches change listeners to both media queries', () => {
+ const addEventListenerMock = vi.fn();
+ window.matchMedia = vi.fn().mockReturnValue({
+ matches: false,
+ addEventListener: addEventListenerMock,
+ removeEventListener: vi.fn(),
+ });
+
+ initializeColorScheme();
+
+ expect(window.matchMedia).toHaveBeenCalledWith('(prefers-color-scheme: light)');
+ expect(window.matchMedia).toHaveBeenCalledWith('(prefers-color-scheme: dark)');
+ expect(addEventListenerMock).toHaveBeenCalledTimes(2);
+ expect(addEventListenerMock).toHaveBeenCalledWith('change', expect.any(Function));
+ });
+});
diff --git a/src/lib/state/hubs-state.test.svelte.ts b/src/lib/state/hubs-state.test.svelte.ts
new file mode 100644
index 00000000..9e34baa5
--- /dev/null
+++ b/src/lib/state/hubs-state.test.svelte.ts
@@ -0,0 +1,139 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('$lib/api', () => ({
+ shockersV1Api: { shockerListShockers: vi.fn() },
+}));
+
+vi.mock('$lib/errorhandling/apiErrorHandling', () => ({
+ handleApiError: vi.fn(),
+}));
+
+describe('HubOnlineState', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ it('constructor sets all fields', async () => {
+ const { HubOnlineState } = await import('./hubs-state.svelte');
+ const hub = new HubOnlineState('hub-1', true, '4.0.0');
+ expect(hub.hubId).toBe('hub-1');
+ expect(hub.isOnline).toBe(true);
+ expect(hub.firmwareVersion).toBe('4.0.0');
+ expect(hub.otaInstall).toBeNull();
+ expect(hub.otaResult).toBeNull();
+ });
+
+ it('isOnline=false when constructed offline', async () => {
+ const { HubOnlineState } = await import('./hubs-state.svelte');
+ const hub = new HubOnlineState('hub-2', false, null);
+ expect(hub.isOnline).toBe(false);
+ });
+
+ it('firmwareVersion can be null', async () => {
+ const { HubOnlineState } = await import('./hubs-state.svelte');
+ const hub = new HubOnlineState('hub-3', true, null);
+ expect(hub.firmwareVersion).toBeNull();
+ });
+
+ it('otaInstall is independently mutable', async () => {
+ const { HubOnlineState } = await import('./hubs-state.svelte');
+ const hub = new HubOnlineState('hub-4', true, '3.0.0');
+ hub.otaInstall = { id: 1, version: '4.0.0', task: 'Installing' as any, progress: 50 };
+ expect(hub.otaInstall?.progress).toBe(50);
+ expect(hub.otaResult).toBeNull();
+ });
+});
+
+describe('ownHubs / onlineHubs', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ it('ownHubs starts empty', async () => {
+ const { ownHubs } = await import('./hubs-state.svelte');
+ expect(ownHubs.size).toBe(0);
+ });
+
+ it('onlineHubs starts empty', async () => {
+ const { onlineHubs } = await import('./hubs-state.svelte');
+ expect(onlineHubs.size).toBe(0);
+ });
+});
+
+describe('refreshOwnHubs', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('populates ownHubs from API response', async () => {
+ const { refreshOwnHubs, ownHubs } = await import('./hubs-state.svelte');
+ const { shockersV1Api } = await import('$lib/api');
+ const hub = { id: 'hub-1', name: 'Hub One', createdOn: new Date(), shockers: [] };
+ vi.mocked(shockersV1Api.shockerListShockers).mockResolvedValue({ data: [hub] } as any);
+
+ await refreshOwnHubs();
+
+ expect(ownHubs.size).toBe(1);
+ expect(ownHubs.get('hub-1')).toEqual(hub);
+ });
+
+ it('populates multiple hubs', async () => {
+ const { refreshOwnHubs, ownHubs } = await import('./hubs-state.svelte');
+ const { shockersV1Api } = await import('$lib/api');
+ vi.mocked(shockersV1Api.shockerListShockers).mockResolvedValue({
+ data: [
+ { id: 'hub-1', name: 'First', createdOn: new Date(), shockers: [] },
+ { id: 'hub-2', name: 'Second', createdOn: new Date(), shockers: [] },
+ ],
+ } as any);
+
+ await refreshOwnHubs();
+
+ expect(ownHubs.size).toBe(2);
+ });
+
+ it('clears old entries on re-fetch', async () => {
+ const { refreshOwnHubs, ownHubs } = await import('./hubs-state.svelte');
+ const { shockersV1Api } = await import('$lib/api');
+ vi.mocked(shockersV1Api.shockerListShockers)
+ .mockResolvedValueOnce({
+ data: [{ id: 'hub-1', name: 'Old', createdOn: new Date(), shockers: [] }],
+ } as any)
+ .mockResolvedValueOnce({
+ data: [{ id: 'hub-2', name: 'New', createdOn: new Date(), shockers: [] }],
+ } as any);
+
+ await refreshOwnHubs();
+ await refreshOwnHubs();
+
+ expect(ownHubs.has('hub-1')).toBe(false);
+ expect(ownHubs.has('hub-2')).toBe(true);
+ });
+
+ it('calls handleApiError and resolves when response has no data', async () => {
+ const { refreshOwnHubs } = await import('./hubs-state.svelte');
+ const { shockersV1Api } = await import('$lib/api');
+ const { handleApiError } = await import('$lib/errorhandling/apiErrorHandling');
+ vi.mocked(shockersV1Api.shockerListShockers).mockResolvedValue({
+ data: null,
+ message: '',
+ } as any);
+
+ await expect(refreshOwnHubs()).resolves.toBeUndefined();
+ expect(vi.mocked(handleApiError)).toHaveBeenCalled();
+ });
+
+ it('calls handleApiError and resolves when API throws', async () => {
+ const { refreshOwnHubs } = await import('./hubs-state.svelte');
+ const { shockersV1Api } = await import('$lib/api');
+ const { handleApiError } = await import('$lib/errorhandling/apiErrorHandling');
+ vi.mocked(shockersV1Api.shockerListShockers).mockRejectedValue(new Error('Network'));
+
+ await expect(refreshOwnHubs()).resolves.toBeUndefined();
+ expect(vi.mocked(handleApiError)).toHaveBeenCalled();
+ });
+});
diff --git a/src/lib/state/live-control-state.test.svelte.ts b/src/lib/state/live-control-state.test.svelte.ts
new file mode 100644
index 00000000..0f411ed8
--- /dev/null
+++ b/src/lib/state/live-control-state.test.svelte.ts
@@ -0,0 +1,426 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('$lib/api', () => ({
+ hubManagementV1Api: { devicesGetLiveControlGatewayInfo: vi.fn() },
+}));
+
+vi.mock('svelte-sonner', () => ({
+ toast: { error: vi.fn() },
+}));
+
+// ---------------------------------------------------------------------------
+// Mock WebSocket
+// ---------------------------------------------------------------------------
+
+class MockWebSocket {
+ static instances: MockWebSocket[] = [];
+
+ url: string;
+ onopen: ((e: Event) => void) | null = null;
+ onmessage: ((e: MessageEvent) => void) | null = null;
+ onclose: ((e: CloseEvent) => void) | null = null;
+ onerror: ((e: Event) => void) | null = null;
+ send = vi.fn();
+ close = vi.fn();
+
+ constructor(url: string) {
+ this.url = url;
+ MockWebSocket.instances.push(this);
+ }
+
+ triggerOpen() {
+ this.onopen?.(new Event('open'));
+ }
+ triggerMessage(data: unknown) {
+ this.onmessage?.(new MessageEvent('message', { data: JSON.stringify(data) }));
+ }
+ triggerRawMessage(raw: string) {
+ this.onmessage?.(new MessageEvent('message', { data: raw }));
+ }
+ triggerClose() {
+ this.onclose?.(new CloseEvent('close'));
+ }
+ triggerError() {
+ this.onerror?.(new Event('error'));
+ }
+}
+
+beforeEach(() => {
+ vi.resetModules();
+ vi.useFakeTimers();
+ MockWebSocket.instances = [];
+ vi.stubGlobal('WebSocket', MockWebSocket);
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ vi.clearAllMocks();
+});
+
+// ---------------------------------------------------------------------------
+// LiveShockerState
+// ---------------------------------------------------------------------------
+
+describe('LiveShockerState', () => {
+ it('has correct default values', async () => {
+ const { LiveShockerState } = await import('./live-control-state.svelte');
+ const s = new LiveShockerState();
+ expect(s.isDragging).toBe(false);
+ expect(s.intensity).toBe(0);
+ expect(s.isLive).toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// LiveDeviceConnection — static state
+// ---------------------------------------------------------------------------
+
+describe('LiveDeviceConnection constructor', () => {
+ it('stores deviceId and defaults to Disconnected', async () => {
+ const { LiveDeviceConnection, LiveConnectionState } =
+ await import('./live-control-state.svelte');
+ const conn = new LiveDeviceConnection('dev-1');
+ expect(conn.deviceId).toBe('dev-1');
+ expect(conn.state).toBe(LiveConnectionState.Disconnected);
+ expect(conn.gateway).toBeNull();
+ expect(conn.country).toBeNull();
+ expect(conn.latency).toBe(0);
+ });
+});
+
+describe('LiveDeviceConnection — shocker management', () => {
+ it('ensureShockerState creates a new state when absent', async () => {
+ const { LiveDeviceConnection } = await import('./live-control-state.svelte');
+ const conn = new LiveDeviceConnection('dev-1');
+ conn.ensureShockerState('sh-1');
+ expect(conn.shockers.has('sh-1')).toBe(true);
+ });
+
+ it('ensureShockerState is idempotent', async () => {
+ const { LiveDeviceConnection } = await import('./live-control-state.svelte');
+ const conn = new LiveDeviceConnection('dev-1');
+ conn.ensureShockerState('sh-1');
+ const first = conn.shockers.get('sh-1');
+ conn.ensureShockerState('sh-1');
+ expect(conn.shockers.get('sh-1')).toBe(first);
+ });
+
+ it('getShockerState returns undefined when not initialised', async () => {
+ const { LiveDeviceConnection } = await import('./live-control-state.svelte');
+ const conn = new LiveDeviceConnection('dev-1');
+ expect(conn.getShockerState('unknown')).toBeUndefined();
+ });
+
+ it('getShockerState returns state after ensureShockerState', async () => {
+ const { LiveDeviceConnection } = await import('./live-control-state.svelte');
+ const conn = new LiveDeviceConnection('dev-1');
+ conn.ensureShockerState('sh-2');
+ expect(conn.getShockerState('sh-2')).toBeDefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// LiveDeviceConnection — connect / disconnect
+// ---------------------------------------------------------------------------
+
+describe('LiveDeviceConnection.connect', () => {
+ it('sets state to Connecting then Connected on success', async () => {
+ const { LiveDeviceConnection, LiveConnectionState } =
+ await import('./live-control-state.svelte');
+ const { hubManagementV1Api } = await import('$lib/api');
+ vi.mocked(hubManagementV1Api.devicesGetLiveControlGatewayInfo).mockResolvedValue({
+ data: { gateway: 'gw.example.com', country: 'US' },
+ message: '',
+ } as any);
+
+ const conn = new LiveDeviceConnection('dev-1');
+ const connectPromise = conn.connect();
+
+ expect(conn.state).toBe(LiveConnectionState.Connecting);
+
+ await connectPromise;
+ const ws = MockWebSocket.instances[0];
+ ws.triggerOpen();
+
+ expect(conn.state).toBe(LiveConnectionState.Connected);
+ });
+
+ it('sets gateway and country from API response', async () => {
+ const { LiveDeviceConnection } = await import('./live-control-state.svelte');
+ const { hubManagementV1Api } = await import('$lib/api');
+ vi.mocked(hubManagementV1Api.devicesGetLiveControlGatewayInfo).mockResolvedValue({
+ data: { gateway: 'gw.openshock.app', country: 'DE' },
+ message: '',
+ } as any);
+
+ const conn = new LiveDeviceConnection('dev-1');
+ await conn.connect();
+
+ expect(conn.gateway).toBe('gw.openshock.app');
+ expect(conn.country).toBe('DE');
+ });
+
+ it('constructs WebSocket with correct URL', async () => {
+ const { LiveDeviceConnection } = await import('./live-control-state.svelte');
+ const { hubManagementV1Api } = await import('$lib/api');
+ vi.mocked(hubManagementV1Api.devicesGetLiveControlGatewayInfo).mockResolvedValue({
+ data: { gateway: 'gw.example.com', country: 'US' },
+ message: '',
+ } as any);
+
+ const conn = new LiveDeviceConnection('dev-42');
+ await conn.connect();
+
+ expect(MockWebSocket.instances[0].url).toBe('wss://gw.example.com/1/ws/live/dev-42');
+ });
+
+ it('goes Disconnected and shows toast when API returns no data', async () => {
+ const { LiveDeviceConnection, LiveConnectionState } =
+ await import('./live-control-state.svelte');
+ const { hubManagementV1Api } = await import('$lib/api');
+ const { toast } = await import('svelte-sonner');
+ vi.mocked(hubManagementV1Api.devicesGetLiveControlGatewayInfo).mockResolvedValue({
+ data: null,
+ message: '',
+ } as any);
+
+ const conn = new LiveDeviceConnection('dev-1');
+ await conn.connect();
+
+ expect(conn.state).toBe(LiveConnectionState.Disconnected);
+ expect(vi.mocked(toast.error)).toHaveBeenCalled();
+ });
+
+ it('goes Disconnected and shows toast when API throws', async () => {
+ const { LiveDeviceConnection, LiveConnectionState } =
+ await import('./live-control-state.svelte');
+ const { hubManagementV1Api } = await import('$lib/api');
+ const { toast } = await import('svelte-sonner');
+ vi.mocked(hubManagementV1Api.devicesGetLiveControlGatewayInfo).mockRejectedValue(
+ new Error('Network')
+ );
+
+ const conn = new LiveDeviceConnection('dev-1');
+ await conn.connect();
+
+ expect(conn.state).toBe(LiveConnectionState.Disconnected);
+ expect(vi.mocked(toast.error)).toHaveBeenCalled();
+ });
+
+ it('goes Disconnected when WebSocket fires close event', async () => {
+ const { LiveDeviceConnection, LiveConnectionState } =
+ await import('./live-control-state.svelte');
+ const { hubManagementV1Api } = await import('$lib/api');
+ vi.mocked(hubManagementV1Api.devicesGetLiveControlGatewayInfo).mockResolvedValue({
+ data: { gateway: 'gw.example.com', country: 'US' },
+ message: '',
+ } as any);
+
+ const conn = new LiveDeviceConnection('dev-1');
+ await conn.connect();
+ MockWebSocket.instances[0].triggerOpen();
+ MockWebSocket.instances[0].triggerClose();
+
+ expect(conn.state).toBe(LiveConnectionState.Disconnected);
+ });
+
+ it('resets shocker live state on disconnect via WebSocket close', async () => {
+ const { LiveDeviceConnection, LiveConnectionState } =
+ await import('./live-control-state.svelte');
+ const { hubManagementV1Api } = await import('$lib/api');
+ vi.mocked(hubManagementV1Api.devicesGetLiveControlGatewayInfo).mockResolvedValue({
+ data: { gateway: 'gw.example.com', country: 'US' },
+ message: '',
+ } as any);
+
+ const conn = new LiveDeviceConnection('dev-1');
+ conn.ensureShockerState('sh-1');
+ conn.shockers.get('sh-1')!.isLive = true;
+ conn.shockers.get('sh-1')!.intensity = 75;
+
+ await conn.connect();
+ MockWebSocket.instances[0].triggerOpen();
+ MockWebSocket.instances[0].triggerClose();
+
+ expect(conn.state).toBe(LiveConnectionState.Disconnected);
+ expect(conn.shockers.get('sh-1')?.isLive).toBe(false);
+ expect(conn.shockers.get('sh-1')?.intensity).toBe(0);
+ });
+});
+
+describe('LiveDeviceConnection.disconnect', () => {
+ it('sets state to Disconnected and resets latency', async () => {
+ const { LiveDeviceConnection, LiveConnectionState } =
+ await import('./live-control-state.svelte');
+ const conn = new LiveDeviceConnection('dev-1');
+ conn.disconnect();
+ expect(conn.state).toBe(LiveConnectionState.Disconnected);
+ expect(conn.latency).toBe(0);
+ });
+});
+
+describe('LiveDeviceConnection WebSocket messages', () => {
+ it('replies with Pong on Ping message', async () => {
+ const { LiveDeviceConnection } = await import('./live-control-state.svelte');
+ const { hubManagementV1Api } = await import('$lib/api');
+ vi.mocked(hubManagementV1Api.devicesGetLiveControlGatewayInfo).mockResolvedValue({
+ data: { gateway: 'gw.example.com', country: 'US' },
+ message: '',
+ } as any);
+
+ const conn = new LiveDeviceConnection('dev-1');
+ await conn.connect();
+ const ws = MockWebSocket.instances[0];
+ ws.triggerOpen();
+
+ ws.triggerMessage({ ResponseType: 'Ping', Data: { Timestamp: 42 } });
+
+ expect(ws.send).toHaveBeenCalledWith(
+ JSON.stringify({ RequestType: 'Pong', Data: { Timestamp: 42 } })
+ );
+ });
+
+ it('updates latency on LatencyAnnounce message', async () => {
+ const { LiveDeviceConnection } = await import('./live-control-state.svelte');
+ const { hubManagementV1Api } = await import('$lib/api');
+ vi.mocked(hubManagementV1Api.devicesGetLiveControlGatewayInfo).mockResolvedValue({
+ data: { gateway: 'gw.example.com', country: 'US' },
+ message: '',
+ } as any);
+
+ const conn = new LiveDeviceConnection('dev-1');
+ await conn.connect();
+ const ws = MockWebSocket.instances[0];
+ ws.triggerOpen();
+
+ ws.triggerMessage({ ResponseType: 'LatencyAnnounce', Data: { OwnLatency: 33 } });
+
+ expect(conn.latency).toBe(33);
+ });
+
+ it('ignores malformed JSON messages without throwing', async () => {
+ const { LiveDeviceConnection } = await import('./live-control-state.svelte');
+ const { hubManagementV1Api } = await import('$lib/api');
+ vi.mocked(hubManagementV1Api.devicesGetLiveControlGatewayInfo).mockResolvedValue({
+ data: { gateway: 'gw.example.com', country: 'US' },
+ message: '',
+ } as any);
+
+ const conn = new LiveDeviceConnection('dev-1');
+ await conn.connect();
+ const ws = MockWebSocket.instances[0];
+ ws.triggerOpen();
+
+ expect(() => ws.triggerRawMessage('NOT JSON')).not.toThrow();
+ });
+});
+
+describe('LiveDeviceConnection.sendFrame', () => {
+ it('sends a Frame message when connected', async () => {
+ const { LiveDeviceConnection } = await import('./live-control-state.svelte');
+ const { hubManagementV1Api } = await import('$lib/api');
+ const { ControlType } = await import('$lib/signalr/models/ControlType');
+ vi.mocked(hubManagementV1Api.devicesGetLiveControlGatewayInfo).mockResolvedValue({
+ data: { gateway: 'gw.example.com', country: 'US' },
+ message: '',
+ } as any);
+
+ const conn = new LiveDeviceConnection('dev-1');
+ await conn.connect();
+ const ws = MockWebSocket.instances[0];
+ ws.triggerOpen();
+
+ conn.sendFrame('sh-1', 50, ControlType.Vibrate);
+
+ expect(ws.send).toHaveBeenCalledWith(
+ JSON.stringify({
+ RequestType: 'Frame',
+ Data: { Shocker: 'sh-1', Intensity: 50, Type: 'vibrate' },
+ })
+ );
+ });
+
+ it('is a no-op when not connected', async () => {
+ const { LiveDeviceConnection } = await import('./live-control-state.svelte');
+ const { ControlType } = await import('$lib/signalr/models/ControlType');
+ const conn = new LiveDeviceConnection('dev-1');
+ expect(() => conn.sendFrame('sh-1', 50, ControlType.Vibrate)).not.toThrow();
+ expect(MockWebSocket.instances).toHaveLength(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Module-level helpers
+// ---------------------------------------------------------------------------
+
+describe('liveConnections / ensureLiveConnection / getLiveConnection', () => {
+ it('liveConnections starts empty', async () => {
+ const { liveConnections } = await import('./live-control-state.svelte');
+ expect(liveConnections.size).toBe(0);
+ });
+
+ it('ensureLiveConnection creates a new connection', async () => {
+ const { ensureLiveConnection, liveConnections } = await import('./live-control-state.svelte');
+ ensureLiveConnection('dev-1');
+ expect(liveConnections.has('dev-1')).toBe(true);
+ });
+
+ it('ensureLiveConnection is idempotent', async () => {
+ const { ensureLiveConnection, liveConnections } = await import('./live-control-state.svelte');
+ ensureLiveConnection('dev-1');
+ const first = liveConnections.get('dev-1');
+ ensureLiveConnection('dev-1');
+ expect(liveConnections.get('dev-1')).toBe(first);
+ });
+
+ it('getLiveConnection returns undefined for unknown device', async () => {
+ const { getLiveConnection } = await import('./live-control-state.svelte');
+ expect(getLiveConnection('nonexistent')).toBeUndefined();
+ });
+
+ it('getLiveConnection returns the connection after ensureLiveConnection', async () => {
+ const { ensureLiveConnection, getLiveConnection } = await import('./live-control-state.svelte');
+ ensureLiveConnection('dev-2');
+ expect(getLiveConnection('dev-2')).toBeDefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// toggleShockerLiveControl
+// ---------------------------------------------------------------------------
+
+describe('toggleShockerLiveControl', () => {
+ it('sets isLive=true and starts connect when toggling on', async () => {
+ const { toggleShockerLiveControl, liveConnections } =
+ await import('./live-control-state.svelte');
+ const { hubManagementV1Api } = await import('$lib/api');
+ vi.mocked(hubManagementV1Api.devicesGetLiveControlGatewayInfo).mockResolvedValue({
+ data: { gateway: 'gw.example.com', country: 'US' },
+ message: '',
+ } as any);
+
+ await toggleShockerLiveControl('dev-1', 'sh-1');
+
+ const conn = liveConnections.get('dev-1')!;
+ expect(conn.shockers.get('sh-1')?.isLive).toBe(true);
+ });
+
+ it('sets isLive=false when toggling off (already live)', async () => {
+ const { toggleShockerLiveControl, liveConnections } =
+ await import('./live-control-state.svelte');
+ const { hubManagementV1Api } = await import('$lib/api');
+ vi.mocked(hubManagementV1Api.devicesGetLiveControlGatewayInfo).mockResolvedValue({
+ data: { gateway: 'gw.example.com', country: 'US' },
+ message: '',
+ } as any);
+
+ // Toggle on
+ await toggleShockerLiveControl('dev-1', 'sh-1');
+ const conn = liveConnections.get('dev-1')!;
+
+ // Toggle off
+ await toggleShockerLiveControl('dev-1', 'sh-1');
+ expect(conn.shockers.get('sh-1')?.isLive).toBe(false);
+ });
+});
diff --git a/src/lib/state/shared-hubs-state.test.svelte.ts b/src/lib/state/shared-hubs-state.test.svelte.ts
new file mode 100644
index 00000000..62aaeb04
--- /dev/null
+++ b/src/lib/state/shared-hubs-state.test.svelte.ts
@@ -0,0 +1,97 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('$lib/api', () => ({
+ shockersV1Api: { shockerListSharedShockers: vi.fn() },
+}));
+
+vi.mock('$lib/errorhandling/apiErrorHandling', () => ({
+ handleApiError: vi.fn(),
+}));
+
+describe('sharedHubsState', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('starts with an empty array', async () => {
+ const { sharedHubsState } = await import('./shared-hubs-state.svelte');
+ expect(sharedHubsState.value).toEqual([]);
+ });
+});
+
+describe('refreshSharedHubs', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('populates sharedHubsState.value from API response', async () => {
+ const { refreshSharedHubs, sharedHubsState } = await import('./shared-hubs-state.svelte');
+ const { shockersV1Api } = await import('$lib/api');
+ const hub = { id: 'shared-1', name: 'Shared Hub', image: '', devices: [] };
+ vi.mocked(shockersV1Api.shockerListSharedShockers).mockResolvedValue({ data: [hub] } as any);
+
+ await refreshSharedHubs();
+
+ expect(sharedHubsState.value).toEqual([hub]);
+ });
+
+ it('replaces existing value on re-fetch', async () => {
+ const { refreshSharedHubs, sharedHubsState } = await import('./shared-hubs-state.svelte');
+ const { shockersV1Api } = await import('$lib/api');
+ vi.mocked(shockersV1Api.shockerListSharedShockers)
+ .mockResolvedValueOnce({ data: [{ id: 'old', name: 'Old', image: '', devices: [] }] } as any)
+ .mockResolvedValueOnce({ data: [{ id: 'new', name: 'New', image: '', devices: [] }] } as any);
+
+ await refreshSharedHubs();
+ await refreshSharedHubs();
+
+ expect(sharedHubsState.value).toHaveLength(1);
+ expect(sharedHubsState.value[0].id).toBe('new');
+ });
+
+ it('throws and calls handleApiError when response has no data', async () => {
+ const { refreshSharedHubs } = await import('./shared-hubs-state.svelte');
+ const { shockersV1Api } = await import('$lib/api');
+ const { handleApiError } = await import('$lib/errorhandling/apiErrorHandling');
+ vi.mocked(shockersV1Api.shockerListSharedShockers).mockResolvedValue({
+ data: null,
+ message: 'Forbidden',
+ });
+
+ await expect(refreshSharedHubs()).rejects.toThrow('Failed to fetch shared devices');
+ expect(vi.mocked(handleApiError)).toHaveBeenCalled();
+ });
+
+ it('throws and calls handleApiError when API rejects', async () => {
+ const { refreshSharedHubs } = await import('./shared-hubs-state.svelte');
+ const { shockersV1Api } = await import('$lib/api');
+ const { handleApiError } = await import('$lib/errorhandling/apiErrorHandling');
+ const err = new Error('Network error');
+ vi.mocked(shockersV1Api.shockerListSharedShockers).mockRejectedValue(err);
+
+ await expect(refreshSharedHubs()).rejects.toThrow('Network error');
+ expect(vi.mocked(handleApiError)).toHaveBeenCalledWith(err);
+ });
+
+ it('populates multiple shared hubs', async () => {
+ const { refreshSharedHubs, sharedHubsState } = await import('./shared-hubs-state.svelte');
+ const { shockersV1Api } = await import('$lib/api');
+ vi.mocked(shockersV1Api.shockerListSharedShockers).mockResolvedValue({
+ data: [
+ { id: 'sh-1', name: 'Alpha', image: '', devices: [] },
+ { id: 'sh-2', name: 'Beta', image: '', devices: [] },
+ ],
+ } as any);
+
+ await refreshSharedHubs();
+ expect(sharedHubsState.value).toHaveLength(2);
+ });
+});
diff --git a/src/lib/state/user-shares-state.test.svelte.ts b/src/lib/state/user-shares-state.test.svelte.ts
new file mode 100644
index 00000000..a29b4f4f
--- /dev/null
+++ b/src/lib/state/user-shares-state.test.svelte.ts
@@ -0,0 +1,138 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('$lib/api', () => ({
+ shockerSharesV2Api: {
+ userSharesGetSharesByUsers: vi.fn(),
+ userSharesGetOutgoingInvitesList: vi.fn(),
+ userSharesGetIncomingInvitesList: vi.fn(),
+ },
+}));
+
+vi.mock('$lib/errorhandling/apiErrorHandling', () => ({
+ handleApiError: vi.fn(),
+}));
+
+describe('userSharesState initial values', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ it('shares starts as { outgoing: [], incoming: [] }', async () => {
+ const { userSharesState } = await import('./user-shares-state.svelte');
+ expect(userSharesState.shares).toEqual({ outgoing: [], incoming: [] });
+ });
+
+ it('outgoingInvites starts as empty array', async () => {
+ const { userSharesState } = await import('./user-shares-state.svelte');
+ expect(userSharesState.outgoingInvites).toEqual([]);
+ });
+
+ it('incomingInvites starts as empty array', async () => {
+ const { userSharesState } = await import('./user-shares-state.svelte');
+ expect(userSharesState.incomingInvites).toEqual([]);
+ });
+
+ it('shares setter updates the value', async () => {
+ const { userSharesState } = await import('./user-shares-state.svelte');
+ const newShares = { outgoing: [{ id: 'u1' } as any], incoming: [] };
+ userSharesState.shares = newShares;
+ expect(userSharesState.shares).toEqual(newShares);
+ });
+});
+
+describe('refreshUserShares', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('sets shares from API response', async () => {
+ const { refreshUserShares, userSharesState } = await import('./user-shares-state.svelte');
+ const { shockerSharesV2Api } = await import('$lib/api');
+ const data = { outgoing: [{ id: 'u1' } as any], incoming: [] };
+ vi.mocked(shockerSharesV2Api.userSharesGetSharesByUsers).mockResolvedValue(data as any);
+
+ await refreshUserShares();
+ expect(userSharesState.shares).toEqual(data);
+ });
+
+ it('calls handleApiError and rethrows on failure', async () => {
+ const { refreshUserShares } = await import('./user-shares-state.svelte');
+ const { shockerSharesV2Api } = await import('$lib/api');
+ const { handleApiError } = await import('$lib/errorhandling/apiErrorHandling');
+ const err = new Error('Fetch failed');
+ vi.mocked(shockerSharesV2Api.userSharesGetSharesByUsers).mockRejectedValue(err);
+
+ await expect(refreshUserShares()).rejects.toThrow('Fetch failed');
+ expect(vi.mocked(handleApiError)).toHaveBeenCalledWith(err);
+ });
+});
+
+describe('refreshOutgoingInvites', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('sets outgoingInvites from API response', async () => {
+ const { refreshOutgoingInvites, userSharesState } = await import('./user-shares-state.svelte');
+ const { shockerSharesV2Api } = await import('$lib/api');
+ const invite = { id: 'inv-1', code: 'ABC' };
+ vi.mocked(shockerSharesV2Api.userSharesGetOutgoingInvitesList).mockResolvedValue([
+ invite,
+ ] as any);
+
+ await refreshOutgoingInvites();
+ expect(userSharesState.outgoingInvites).toEqual([invite]);
+ });
+
+ it('calls handleApiError and rethrows on failure', async () => {
+ const { refreshOutgoingInvites } = await import('./user-shares-state.svelte');
+ const { shockerSharesV2Api } = await import('$lib/api');
+ const { handleApiError } = await import('$lib/errorhandling/apiErrorHandling');
+ const err = new Error('Network error');
+ vi.mocked(shockerSharesV2Api.userSharesGetOutgoingInvitesList).mockRejectedValue(err);
+
+ await expect(refreshOutgoingInvites()).rejects.toThrow('Network error');
+ expect(vi.mocked(handleApiError)).toHaveBeenCalledWith(err);
+ });
+});
+
+describe('refreshIncomingInvites', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('sets incomingInvites from API response', async () => {
+ const { refreshIncomingInvites, userSharesState } = await import('./user-shares-state.svelte');
+ const { shockerSharesV2Api } = await import('$lib/api');
+ const invite = { id: 'inv-2', code: 'XYZ' };
+ vi.mocked(shockerSharesV2Api.userSharesGetIncomingInvitesList).mockResolvedValue([
+ invite,
+ ] as any);
+
+ await refreshIncomingInvites();
+ expect(userSharesState.incomingInvites).toEqual([invite]);
+ });
+
+ it('calls handleApiError and rethrows on failure', async () => {
+ const { refreshIncomingInvites } = await import('./user-shares-state.svelte');
+ const { shockerSharesV2Api } = await import('$lib/api');
+ const { handleApiError } = await import('$lib/errorhandling/apiErrorHandling');
+ const err = new Error('Timeout');
+ vi.mocked(shockerSharesV2Api.userSharesGetIncomingInvitesList).mockRejectedValue(err);
+
+ await expect(refreshIncomingInvites()).rejects.toThrow('Timeout');
+ expect(vi.mocked(handleApiError)).toHaveBeenCalledWith(err);
+ });
+});
diff --git a/src/lib/state/user-state.test.svelte.ts b/src/lib/state/user-state.test.svelte.ts
new file mode 100644
index 00000000..b3470454
--- /dev/null
+++ b/src/lib/state/user-state.test.svelte.ts
@@ -0,0 +1,190 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('$lib/api', () => ({
+ usersApi: { usersGetSelf: vi.fn() },
+}));
+
+vi.mock('$lib/errorhandling/apiErrorHandling', () => ({
+ handleApiError: vi.fn(),
+}));
+
+describe('userState', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('starts with loading=true, self=null, all=[]', async () => {
+ const { userState } = await import('./user-state.svelte');
+ expect(userState.loading).toBe(true);
+ expect(userState.self).toBeNull();
+ expect(userState.all).toEqual([]);
+ });
+
+ it('reset() sets loading=false and clears self and all', async () => {
+ const { userState } = await import('./user-state.svelte');
+ userState.reset();
+ expect(userState.loading).toBe(false);
+ expect(userState.self).toBeNull();
+ expect(userState.all).toEqual([]);
+ });
+
+ it('setSelf() sets the self user', async () => {
+ const { userState } = await import('./user-state.svelte');
+ const user = { id: 'u1', name: 'Alice', avatar: '', roles: [], email: 'alice@example.com' };
+ userState.setSelf(user);
+ expect(userState.self).toEqual(user);
+ });
+
+ it('setSelf() updates the matching user in the all array', async () => {
+ const { userState } = await import('./user-state.svelte');
+ const original = { id: 'u1', name: 'Old', avatar: '', roles: [], email: 'old@example.com' };
+ // Bootstrap all via refreshSelf would need the API — set via direct state manipulation
+ // We can test updateAllFromSelf indirectly via setSelf after setting all manually:
+ // all is only updated via setSelf/setSelfName/setSelfEmail once refreshSelf runs.
+ // Here we just verify self is updated:
+ userState.setSelf(original);
+ const updated = { ...original, name: 'Alice' };
+ userState.setSelf(updated);
+ expect(userState.self?.name).toBe('Alice');
+ });
+
+ it('setSelfName() updates name on self', async () => {
+ const { userState } = await import('./user-state.svelte');
+ const user = { id: 'u1', name: 'Alice', avatar: '', roles: [], email: 'alice@example.com' };
+ userState.setSelf(user);
+ userState.setSelfName('Bob');
+ expect(userState.self?.name).toBe('Bob');
+ });
+
+ it('setSelfName() is a no-op when self is null', async () => {
+ const { userState } = await import('./user-state.svelte');
+ expect(() => userState.setSelfName('Bob')).not.toThrow();
+ expect(userState.self).toBeNull();
+ });
+
+ it('setSelfEmail() updates email on self', async () => {
+ const { userState } = await import('./user-state.svelte');
+ const user = { id: 'u1', name: 'Alice', avatar: '', roles: [], email: 'alice@example.com' };
+ userState.setSelf(user);
+ userState.setSelfEmail('new@example.com');
+ expect(userState.self?.email).toBe('new@example.com');
+ });
+
+ it('setSelfEmail() is a no-op when self is null', async () => {
+ const { userState } = await import('./user-state.svelte');
+ expect(() => userState.setSelfEmail('x@y.com')).not.toThrow();
+ });
+});
+
+describe('userState.refreshSelf', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('returns true and sets self on successful API response', async () => {
+ const { userState } = await import('./user-state.svelte');
+ const { usersApi } = await import('$lib/api');
+ vi.mocked(usersApi.usersGetSelf).mockResolvedValue({
+ data: {
+ id: 'u1',
+ name: 'Alice',
+ image: 'avatar.png',
+ roles: [],
+ email: 'alice@example.com',
+ rank: '',
+ },
+ } as any);
+
+ const result = await userState.refreshSelf();
+
+ expect(result).toBe(true);
+ expect(userState.loading).toBe(false);
+ expect(userState.self).toMatchObject({ id: 'u1', name: 'Alice' });
+ });
+
+ it('maps image field to avatar', async () => {
+ const { userState } = await import('./user-state.svelte');
+ const { usersApi } = await import('$lib/api');
+ vi.mocked(usersApi.usersGetSelf).mockResolvedValue({
+ data: {
+ id: 'u1',
+ name: 'Alice',
+ image: 'avatar.png',
+ roles: [],
+ email: 'alice@example.com',
+ rank: '',
+ },
+ } as any);
+
+ await userState.refreshSelf();
+ expect(userState.self?.avatar).toBe('avatar.png');
+ });
+
+ it('returns false and calls reset() when response has no data', async () => {
+ const { userState } = await import('./user-state.svelte');
+ const { usersApi } = await import('$lib/api');
+ vi.mocked(usersApi.usersGetSelf).mockResolvedValue({
+ data: null,
+ message: 'Unauthorized',
+ } as any);
+
+ const result = await userState.refreshSelf();
+
+ expect(result).toBe(false);
+ expect(userState.self).toBeNull();
+ expect(userState.loading).toBe(false);
+ });
+
+ it('returns false and calls handleApiError when API throws', async () => {
+ const { userState } = await import('./user-state.svelte');
+ const { usersApi } = await import('$lib/api');
+ const { handleApiError } = await import('$lib/errorhandling/apiErrorHandling');
+ const err = new Error('Network failure');
+ vi.mocked(usersApi.usersGetSelf).mockRejectedValue(err);
+
+ const result = await userState.refreshSelf();
+
+ expect(result).toBe(false);
+ expect(userState.self).toBeNull();
+ expect(vi.mocked(handleApiError)).toHaveBeenCalledWith(err, expect.any(Function));
+ });
+
+ it('updateAllFromSelf updates matching user in the all array', async () => {
+ const { userState } = await import('./user-state.svelte');
+ const { usersApi } = await import('$lib/api');
+
+ const firstCall = {
+ id: 'u1',
+ name: 'OldName',
+ image: '',
+ roles: [],
+ email: 'a@b.com',
+ rank: '',
+ };
+ const secondCall = {
+ id: 'u1',
+ name: 'NewName',
+ image: '',
+ roles: [],
+ email: 'a@b.com',
+ rank: '',
+ };
+
+ vi.mocked(usersApi.usersGetSelf)
+ .mockResolvedValueOnce({ data: firstCall } as any)
+ .mockResolvedValueOnce({ data: secondCall } as any);
+
+ await userState.refreshSelf();
+ await userState.refreshSelf();
+
+ expect(userState.self?.name).toBe('NewName');
+ });
+});
diff --git a/src/routes/(app)/settings/api-tokens/+page.svelte b/src/routes/(app)/settings/api-tokens/+page.svelte
index f644bedc..3a58e1d4 100644
--- a/src/routes/(app)/settings/api-tokens/+page.svelte
+++ b/src/routes/(app)/settings/api-tokens/+page.svelte
@@ -33,14 +33,17 @@
let sorting = $state([]);
function onCreated(token: TokenCreatedResponse) {
- data.push({
- id: token.id,
- name: token.name,
- createdOn: token.createdAt,
- validUntil: token.validUntil,
- lastUsed: token.lastUsed,
- permissions: token.permissions,
- });
+ data = [
+ ...data,
+ {
+ id: token.id,
+ name: token.name,
+ createdOn: token.createdAt,
+ validUntil: token.validUntil,
+ lastUsed: token.lastUsed,
+ permissions: token.permissions,
+ },
+ ];
createdTokenSecret = token.token;
toast.success('Token created successfully');
}
diff --git a/src/routes/(app)/shares/user/outgoing/+page.svelte b/src/routes/(app)/shares/user/outgoing/+page.svelte
index 6beb5398..003fd66f 100644
--- a/src/routes/(app)/shares/user/outgoing/+page.svelte
+++ b/src/routes/(app)/shares/user/outgoing/+page.svelte
@@ -31,15 +31,21 @@
{:then}
-
-
-
- {#each userSharesState.shares.outgoing as userShare, i (userShare.id)}
- openEditDrawer(i)} />
- {/each}
-
-
-
+ {#if userSharesState.shares.outgoing.length === 0}
+
+ No outgoing shares
+
+ {:else}
+
+
+
+ {#each userSharesState.shares.outgoing as userShare, i (userShare.id)}
+ openEditDrawer(i)} />
+ {/each}
+
+
+
+ {/if}
{:catch error}
Failed to load shares: {error.message}
{/await}
diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte
index 2ea0ce63..6112b14f 100644
--- a/src/routes/+error.svelte
+++ b/src/routes/+error.svelte
@@ -9,7 +9,7 @@
let previousPath = $state
(resolve('/'));
afterNavigate(({ from }) => {
- if (from !== null && isValidRedirectURL(from.url)) {
+ if (from?.url && isValidRedirectURL(from.url)) {
previousPath = from.url.pathname;
}
});
diff --git a/vite.config.ts b/vite.config.ts
index 5bf806a4..8d214e92 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -144,7 +144,7 @@ function getPlugins(useLocalRedirect: boolean): PluginOption[] {
}
async function getServerConfig(mode: string, useLocalRedirect: boolean) {
- const vars = { ...env, ...loadEnv(mode, process.cwd(), ['PUBLIC_']) };
+ const vars = { ...env, ...loadEnv(mode, process.cwd(), ['PUBLIC_', 'VITE_']) };
if (!vars.PUBLIC_SITE_URL) {
printError('PUBLIC_SITE_URL must be set in your environment');
process.exit(1);
@@ -152,14 +152,28 @@ async function getServerConfig(mode: string, useLocalRedirect: boolean) {
if (!useLocalRedirect) return undefined;
+ const domain = new URL(vars.PUBLIC_SITE_URL).hostname;
+
+ // When an API proxy target is configured (integration mode), proxy /1 and /2
+ // through the Vite dev server so the browser never has to trust the API's
+ // self-signed certificate directly.
+ const apiProxyTarget = vars.VITE_API_PROXY_TARGET;
+ const proxy: Record = apiProxyTarget
+ ? {
+ '^/(1|2)(/.*)?$': {
+ target: apiProxyTarget,
+ secure: false,
+ changeOrigin: true,
+ },
+ }
+ : {};
+
// Vite 8: pipe browser console.* into the dev terminal so client errors land
// alongside server logs without context-switching to browser devtools.
- const baseDevConfig = { forwardConsole: true, proxy: {} };
-
- const domain = new URL(vars.PUBLIC_SITE_URL).hostname;
+ const baseDevConfig = { forwardConsole: true, proxy };
if (domain === 'localhost') {
- return { ...baseDevConfig, host: 'localhost', port: 8080 };
+ return { ...baseDevConfig, host: 'localhost' };
}
let host = domain;
@@ -230,11 +244,19 @@ async function ensurePortBindable(host: string, port: number): Promise {
}
export default defineConfig(async ({ command, mode, isPreview }) => {
- const isLocalServe = command === 'serve' || isPreview === true;
+ const isVitest = isTruthy(env.VITEST) || mode === 'test';
+ const isLocalServe = (command === 'serve' || isPreview === true) && !isVitest;
const isProduction = mode === 'production' && (isTruthy(env.DOCKER) || isTruthy(env.CF_PAGES));
// If we are running locally, ensure that local.{PUBLIC_SITE_URL} resolves to localhost, and then use mkcert to generate a certificate
- const useLocalRedirect = isLocalServe && !isProduction && !isTruthy(env.CI);
+ const useLocalRedirect =
+ isLocalServe && !isProduction && (!isTruthy(env.CI) || mode === 'integration');
+
+ // Integration mode runs SSR fetches against Vite's mkcert-issued dev cert and a
+ // self-signed API cert; Node's TLS stack doesn't trust either out of the box.
+ if (mode === 'integration') {
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED ??= '0';
+ }
return {
build: {
@@ -253,6 +275,30 @@ export default defineConfig(async ({ command, mode, isPreview }) => {
},
plugins: getPlugins(useLocalRedirect),
server: await getServerConfig(mode, useLocalRedirect),
- test: { include: ['src/**/*.{test,spec}.{js,ts}'] },
+ test: {
+ projects: [
+ {
+ extends: true,
+ test: {
+ name: 'unit',
+ environment: 'node',
+ include: ['src/**/*.{test,spec}.{js,ts}'],
+ exclude: ['src/**/*.{test,spec}.{component,svelte}.{js,ts}'],
+ },
+ },
+ {
+ extends: true,
+ // Resolve Svelte to its browser (client) build so that `mount` and
+ // other client-only APIs are available in the jsdom test environment.
+ resolve: { conditions: ['browser', 'module', 'svelte', 'development', 'production'] },
+ test: {
+ name: 'components',
+ environment: 'jsdom',
+ include: ['src/**/*.{test,spec}.{component,svelte}.{js,ts}'],
+ setupFiles: ['./vitest.setup.ts'],
+ },
+ },
+ ],
+ },
} satisfies UserConfig;
});
diff --git a/vitest.setup.ts b/vitest.setup.ts
new file mode 100644
index 00000000..bb02c60c
--- /dev/null
+++ b/vitest.setup.ts
@@ -0,0 +1 @@
+import '@testing-library/jest-dom/vitest';