diff --git a/.cursor/rules/FRONTEND_PATTERNS.mdc b/.cursor/rules/FRONTEND_PATTERNS.mdc index 61497253..9485669e 100644 --- a/.cursor/rules/FRONTEND_PATTERNS.mdc +++ b/.cursor/rules/FRONTEND_PATTERNS.mdc @@ -1,5 +1,207 @@ # Frontend Development Patterns +## Data Access Patterns + +### Service-Based Data Access with Caching + +**Never query entities directly from components.** Always use a service that abstracts caching and data fetching. + +```typescript +// ✅ Good - Service with caching +@Injectable({ lifetime: 'singleton' }) +export class UsersService { + @Injected(IdentityApiClient) + declare private readonly identityApiClient: IdentityApiClient + + // Cache for individual entities + public userCache = new Cache({ + capacity: 100, + load: async (username: string) => { + const { result } = await this.identityApiClient.call({ + method: 'GET', + action: '/users/:id', + url: { id: username }, + query: {}, + }) + return result + }, + }) + + // Cache for list queries + public userQueryCache = new Cache({ + capacity: 10, + load: async (findOptions: FindOptions>) => { + const { result } = await this.identityApiClient.call({ + method: 'GET', + action: '/users', + query: { findOptions }, + }) + + // Populate individual cache from list results + result.entries.forEach((entry) => { + this.userCache.setExplicitValue({ + loadArgs: [entry.username], + value: { status: 'loaded', value: entry, updatedAt: new Date() }, + }) + }) + + return result + }, + }) + + // Expose cache methods + public getUser = this.userCache.get.bind(this.userCache) + public getUserAsObservable = this.userCache.getObservable.bind(this.userCache) + public findUsers = this.userQueryCache.get.bind(this.userQueryCache) + public findUsersAsObservable = this.userQueryCache.getObservable.bind(this.userQueryCache) + + // Mutations invalidate cache + public updateUser = async (username: string, body: { username: string; roles: Roles }) => { + const { result } = await this.identityApiClient.call({ + method: 'PATCH', + action: '/users/:id', + url: { id: username }, + body, + }) + this.userCache.remove(username) + this.userQueryCache.flushAll() + return result + } +} + +// ❌ Bad - Direct API call in component +const MyComponent = Shade({ + render: ({ injector }) => { + const apiClient = injector.getInstance(IdentityApiClient) + // Don't do this - no caching, duplicate requests + const fetchUser = async () => { + const { result } = await apiClient.call({ method: 'GET', action: '/users/:id', ... }) + } + } +}) + +// ✅ Good - Use service with observable +const MyComponent = Shade({ + render: ({ injector, useObservable }) => { + const usersService = injector.getInstance(UsersService) + const [userState] = useObservable('user', usersService.getUserAsObservable(username)) + + // Handle cache states + if (userState.status === 'loading') return
Loading...
+ if (userState.status === 'failed') return
Error: {userState.error}
+ if (userState.status === 'loaded') return
{userState.value.username}
+ } +}) +``` + +### Cache Invalidation on Mutations + +Always invalidate relevant caches after mutations: + +```typescript +// ✅ Good - Invalidate after mutation +public deleteUser = async (username: string) => { + await this.identityApiClient.call({ + method: 'DELETE', + action: '/users/:id', + url: { id: username }, + }) + this.userCache.remove(username) // Remove specific entry + this.userQueryCache.flushAll() // Invalidate list queries +} +``` + +## Props & State Architecture + +### Keep Props Simple + +Avoid prop drilling and complex prop passing through many layers. Use the injector for shared state. + +```typescript +// ❌ Bad - Prop drilling +const GrandparentComponent = Shade({ + render: () => { + const user = /* ... */ + const onUpdate = /* ... */ + const permissions = /* ... */ + + return + } +}) + +const ParentComponent = Shade<{ user: User; onUpdate: () => void; permissions: Permissions }>({ + render: ({ props }) => { + // Just passing through... + return + } +}) + +// ✅ Good - Use injector for shared state +@Injectable({ lifetime: 'singleton' }) +export class UserEditStateService { + public currentUser = new ObservableValue(null) + public permissions = new ObservableValue(null) + + public async loadUser(username: string) { /* ... */ } + public async updateUser(changes: Partial) { /* ... */ } +} + +const ChildComponent = Shade({ + render: ({ injector, useObservable }) => { + const stateService = injector.getInstance(UserEditStateService) + const [user] = useObservable('user', stateService.currentUser) + // Direct access to state, no prop drilling + } +}) +``` + +### When to Use Injectable State Services + +Create an injectable service when: +- State needs to be shared across multiple components +- Logic is becoming complex with multiple observables +- You find yourself passing the same props through 3+ component levels +- Multiple components need to trigger the same mutations + +```typescript +// ✅ Good - Complex state in service +@Injectable({ lifetime: 'singleton' }) +export class RoleEditorStateService { + public originalRoles = new ObservableValue([]) + public currentRoles = new ObservableValue([]) + public isModified = new ObservableValue(false) + + public addRole(role: Roles[number]) { + const current = this.currentRoles.getValue() + if (!current.includes(role)) { + this.currentRoles.setValue([...current, role]) + this.updateModifiedState() + } + } + + public removeRole(role: Roles[number]) { /* ... */ } + + private updateModifiedState() { + const original = this.originalRoles.getValue() + const current = this.currentRoles.getValue() + this.isModified.setValue( + original.length !== current.length || + !original.every(r => current.includes(r)) + ) + } +} +``` + +### Props Guidelines + +| Scenario | Approach | +|----------|----------| +| Simple display data | Props are fine | +| Callbacks for parent | Props are fine | +| Data needed 3+ levels deep | Use injectable service | +| Complex interdependent state | Use injectable service | +| Shared across sibling components | Use injectable service | + ## Routing ### Route Definition @@ -25,15 +227,49 @@ export const myRoute = { export const myRoutes = [myRoute] as const ``` -### Navigation & Integration +### Navigation + +**Always use `navigateToRoute()` for programmatic navigation.** Never manipulate `window.history` or `LocationService` directly in components. ```typescript -// ✅ Always use navigateToRoute() +// ✅ Good - Use navigateToRoute helper import { navigateToRoute } from '../navigate-to-route.js' -import { myRoute } from './routes/my-routes.js' +import { userDetailsRoute } from './routes/user-routes.js' + +const MyComponent = Shade({ + render: ({ injector }) => { + const handleUserClick = (username: string) => { + navigateToRoute(injector, userDetailsRoute, { username }) + } + + return + } +}) -onclick={() => navigateToRoute(injector, myRoute, { param: 'value' })} +// ❌ Bad - Direct history/location manipulation +const MyComponent = Shade({ + render: ({ injector }) => { + const locationService = injector.getInstance(LocationService) + + const handleUserClick = (username: string) => { + // Don't do this - bypasses route system, no type safety + window.history.pushState({}, '', `/users/${username}`) + locationService.updateState() + } + } +}) +``` +### Why Use navigateToRoute + +1. **Type safety** - Route params are type-checked at compile time +2. **Centralized routes** - Routes defined in one place, referenced everywhere +3. **URL compilation** - Handles path-to-regexp compilation automatically +4. **Consistency** - Same pattern across the codebase + +### Integration + +```typescript // Add routes to body.tsx import { myRoutes } from './routes/my-routes.js' diff --git a/.cursor/rules/TESTING_GUIDELINES.md b/.cursor/rules/TESTING_GUIDELINES.md index 02b5d2b7..f6df381e 100644 --- a/.cursor/rules/TESTING_GUIDELINES.md +++ b/.cursor/rules/TESTING_GUIDELINES.md @@ -287,6 +287,169 @@ const errorNoty = page.locator('shade-noty').first() await expect(errorNoty).toBeVisible() ``` +### User Journey Test Pattern + +E2E tests should follow user journeys, not component isolation. Each test should simulate a complete user workflow from start to finish. + +**Structure:** + +- One test per complete user workflow +- Test from login to logical endpoint +- Clean up any created/modified data at the end +- Use helper functions for reusable steps within the test file + +**Good Example - User Journey:** + +```typescript +test('Admin can navigate to users, edit roles, and verify persistence', async ({ page }) => { + // 1. Login as admin + await login(page) + + // 2. Navigate to app settings, verify Users menu + await navigateToAppSettings(page) + await expect(page.getByText('Users')).toBeVisible() + + // 3. Click Users, verify table structure + await page.getByText('Users').click() + await verifyUsersTableStructure(page) + + // 4. Open user, record initial state + const initialRoleCount = await page.locator('role-tag').count() + + // 5. Make changes (add a role) + await addRoleToUser(page) + await page.getByRole('button', { name: 'Save' }).click() + + // 6. Reload and verify persistence + await page.reload() + await expect(page.locator('role-tag')).toHaveCount(initialRoleCount + 1) + + // 7. Cleanup - restore original state + await removeRoleFromUser(page) + await page.getByRole('button', { name: 'Save' }).click() + await expect(page.locator('role-tag')).toHaveCount(initialRoleCount) +}) +``` + +**Bad Example - Fragmented Tests:** + +```typescript +// ❌ Don't split into many small tests - causes repeated setup and state issues +test.describe('User Management', () => { + test('should display Users menu', ...) + test('should navigate to users list', ...) + test('should display table headers', ...) + test('should display at least one user', ...) + test('should open user details', ...) + test('should add a role', ...) + test('should save changes', ...) +}) +``` + +**Cleanup Pattern:** + +```typescript +test('User can create, customize, and delete a resource', async ({ page }) => { + await login(page) + + // Record initial state if needed + const initialCount = await page.locator('.resource').count() + + // Create resource (use unique identifier) + const resourceName = `test-${Date.now()}` + await createResource(page, resourceName) + + // Perform actions and verifications + await customizeResource(page, resourceName) + await verifyResource(page, resourceName) + + // Cleanup - restore original state + await deleteResource(page, resourceName) + await expect(page.locator('.resource')).toHaveCount(initialCount) +}) +``` + +**Key Principles:** + +- Tests are self-contained and don't depend on other tests +- Tests clean up after themselves to avoid state accumulation +- Use unique identifiers (timestamps, UUIDs) for test data +- Helper functions should be local to the test file unless truly reusable across multiple test files + +### Parallel Test Execution Between Projects + +When tests run in parallel across different browser projects (e.g., Chromium, Firefox), they share the same backend state. To avoid conflicts, **create project-specific resources** for each browser. + +**Pattern: Project-Specific Test Users** + +```typescript +import { test, expect } from '@playwright/test' +import { login, logout, registerUser } from './helpers.js' + +const TEST_USER_PASSWORD = 'testpassword123' + +// Generate unique username per project (browser) +const getTestUserName = (projectName: string) => `test-user-${projectName}@test.com` + +test.describe('User Management', () => { + test('Admin can edit user roles', async ({ page }, testInfo) => { + const projectName = testInfo.project.name // 'chromium', 'firefox', etc. + const testUserName = getTestUserName(projectName) + + // Register the project-specific test user inside the test + await registerUser(page, testUserName, TEST_USER_PASSWORD) + await logout(page) + + // Login as admin + await login(page) + + // Navigate to users list and find the project-specific user + await navigateToUsersSettings(page) + const testUserRow = page.locator('tbody tr', { hasText: testUserName }) + await testUserRow.getByRole('button', { name: 'Edit' }).click() + + // Perform operations on the project-specific user + // ... + }) +}) +``` + +**Note:** Register resources inside the test rather than in `beforeEach` to avoid conflicts when multiple tests would try to create the same resource. + +**Key Guidelines:** + +1. **Use `testInfo.project.name`** to get the current project (browser) name +2. **Create unique resource names** by incorporating the project name (e.g., `role-tester-chromium@test.com`) +3. **Register/create resources in `beforeEach` or `beforeAll`** hooks +4. **Operate on project-specific resources** instead of shared resources +5. **Expect clean application state** - don't handle "already exists" errors gracefully + +**When to Use This Pattern:** + +- Tests that create, modify, or delete shared resources (users, files, settings) +- Tests that depend on specific resource state +- Any test that could conflict with the same test running in another browser + +**Common Resource Types to Isolate:** + +- Users: `test-user-${projectName}@test.com` +- Files/Folders: `test-folder-${projectName}` +- Settings/Configs: Use project-specific identifiers +- Any entity with unique constraints + +**Available Helpers in `e2e/helpers.ts`:** + +```typescript +// Register a new user +export const registerUser = async (page: Page, username: string, password: string) => { ... } + +// Login with credentials +export const login = async (page: Page, username?: string, password?: string) => { ... } + +// Logout current user +export const logout = async (page: Page) => { ... } +``` + ## Unit Testing Best Practices ### Component Testing diff --git a/.cursor/rules/testing-patterns.mdc b/.cursor/rules/testing-patterns.mdc deleted file mode 100644 index 07247e77..00000000 --- a/.cursor/rules/testing-patterns.mdc +++ /dev/null @@ -1,135 +0,0 @@ -# E2E Testing Patterns - -## Playwright Best Practices - -### Locator Strategy (Priority Order) - -1. **Semantic first**: `page.getByRole('button', { name: 'Login' })` -2. **Content-based**: `page.getByText('Username')` -3. **Form elements**: `page.locator('input[name="username"]')` -4. **Component-specific**: `page.locator('shade-login form')` -5. **Style-based (last resort)**: `page.locator('[style*="border-radius: 50%"]')` - -### Helper Function Pattern - -```typescript -export const login = async (page: Page, username = 'testuser@gmail.com', password = 'password') => { - const loginForm = page.locator('shade-login form') - await loginForm.locator('input[name="userName"]').fill(username) - await loginForm.locator('input[name="password"]').fill(password) - await page.getByRole('button', { name: 'Login' }).click() - - // Helper handles verification internally - await expect(page.locator('shade-noty', { hasText: 'Welcome back' })).toBeVisible() - const firstLetter = username.charAt(0).toUpperCase() - await expect(page.getByText(firstLetter).first()).toBeVisible() -} - -export const logout = async (page: Page) => { - // Handle complete logout workflow - const userAvatar = page.locator('[style*="border-radius: 50%"][style*="cursor: pointer"]') - await userAvatar.click() - - const logoutButton = page.getByRole('button', { name: /log out/i }) - await logoutButton.click() - - // Verify logout success - await expect(page.locator('shade-login form')).toBeVisible() -} -``` - -## Test Organization - -### Test Structure - -```typescript -test('Feature Name', async ({ page }) => { - // Setup - await page.goto('/') - await login(page) - - // Action - await performUserAction(page) - - // Verification - await expect(result).toBeVisible() -}) -``` - -### Test Categories - -- **Navigation tests**: Verify routing and page access -- **Form interaction tests**: Input handling, validation -- **Workflow tests**: Complete user journeys end-to-end -- **Error scenario tests**: Test failure conditions - -## Critical Testing Rules - -### ✅ Test What EXISTS - -```typescript -// Good - test actual behavior -const form = page.locator('form[data-form-id]') -await form.locator('input[name="username"]').fill('test') -await expect(form.locator('input[name="username"]')).toHaveValue('test') -``` - -### ❌ Don't Test Assumptions - -```typescript -// Bad - assuming validation that doesn't exist -const errorMessage = page.locator('div', { hasText: 'Password too short' }) -await expect(errorMessage).toBeVisible() // This might not exist! -``` - -### Dynamic Content Handling - -```typescript -// Handle dynamic test data -const testEmail = `user-${Date.now()}@example.com` -const firstLetter = testEmail.charAt(0).toUpperCase() - -// Use in tests -await usernameInput.fill(testEmail) -// Later verify avatar shows correct letter -await expect(page.getByText(firstLetter).first()).toBeVisible() -``` - -### Error Testing - -```typescript -// Test general error handling, not specific messages -await submitInvalidForm() - -// Look for ANY error notification, not specific text -const errorNoty = page.locator('shade-noty').first() -await expect(errorNoty).toBeVisible() -``` - -## Test Maintenance - -### Helper Centralization - -Create helpers that encapsulate: - -- Complex component interactions (avatar dropdown) -- Multi-step workflows (login, logout, navigation) -- Assertion patterns (notification handling) -- Setup/teardown operations - -### Avoid Brittleness - -- Use semantic locators over CSS selectors -- Handle dynamic content appropriately -- Test user workflows, not implementation details -- Keep tests independent and parallelizable - -### Skip Unimplemented Features - -```typescript -test.skip('Future Feature', async ({ page }) => { - // Mark unimplemented features for later -}) -``` - -This prevents test failures on functionality that doesn't exist yet while maintaining a plan for future testing. diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 52745862..0d554ed3 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -36,7 +36,7 @@ jobs: - run: yarn test:e2e:install name: 'Test Installer' - - run: yarn test:e2e + - run: yarn test:e2e --workers=1 name: 'E2E tests' env: E2E_TEMP: /home/node/app/tmp diff --git a/common/package.json b/common/package.json index aa5ecea1..997dcd4e 100644 --- a/common/package.json +++ b/common/package.json @@ -25,13 +25,13 @@ "create-schemas": "node ./dist/bin/create-schemas.js" }, "devDependencies": { - "@types/node": "^25.0.3", + "@types/node": "^25.0.10", "ts-json-schema-generator": "^2.4.0", - "vitest": "^4.0.16" + "vitest": "^4.0.18" }, "dependencies": { - "@furystack/core": "^15.0.31", - "@furystack/rest": "^8.0.31", + "@furystack/core": "^15.0.34", + "@furystack/rest": "^8.0.34", "ollama": "^0.6.3" } } diff --git a/common/schemas/ai-api.json b/common/schemas/ai-api.json index db0ad6fd..f4b379b6 100644 --- a/common/schemas/ai-api.json +++ b/common/schemas/ai-api.json @@ -1,169 +1,98 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { - "GetModelsAction": { + "OllamaApiMessage": { "type": "object", + "additionalProperties": false, "properties": { - "result": { + "images": { "type": "array", "items": { - "$ref": "#/definitions/ModelResponse" + "type": "string" } - } - }, - "required": ["result"], - "additionalProperties": false - }, - "ModelResponse": { - "type": "object", - "properties": { - "name": { - "type": "string" }, - "modified_at": { - "type": "string", - "format": "date-time" - }, - "model": { + "role": { "type": "string" }, - "size": { - "type": "number" - }, - "digest": { + "content": { "type": "string" }, - "details": { - "$ref": "#/definitions/ModelDetails" + "thinking": { + "type": "string" }, - "expires_at": { - "type": "string", - "format": "date-time" + "tool_calls": { + "type": "array", + "items": { + "$ref": "#/definitions/ToolCall" + } }, - "size_vram": { - "type": "number" + "tool_name": { + "type": "string" } }, - "required": ["name", "modified_at", "model", "size", "digest", "details", "expires_at", "size_vram"], - "additionalProperties": false + "required": ["content", "role"] }, - "ModelDetails": { + "Omit": { + "$ref": "#/definitions/Pick%3CMessage%2CExclude%3C(%22role%22%7C%22content%22%7C%22thinking%22%7C%22images%22%7C%22tool_calls%22%7C%22tool_name%22)%2C%22images%22%3E%3E" + }, + "Pick>": { "type": "object", "properties": { - "parent_model": { + "role": { "type": "string" }, - "format": { + "content": { "type": "string" }, - "family": { + "thinking": { "type": "string" }, - "families": { + "tool_calls": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/ToolCall" } }, - "parameter_size": { - "type": "string" - }, - "quantization_level": { + "tool_name": { "type": "string" } }, - "required": ["parent_model", "format", "family", "families", "parameter_size", "quantization_level"], + "required": ["role", "content"], "additionalProperties": false }, - "ChatAction": { + "ToolCall": { "type": "object", "properties": { - "body": { + "function": { "type": "object", - "additionalProperties": false, "properties": { - "stream": { - "type": "boolean", - "const": false - }, - "model": { + "name": { "type": "string" }, - "messages": { - "type": "array", - "items": { - "$ref": "#/definitions/Message" - } - }, - "format": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object" - } - ] - }, - "keep_alive": { - "type": ["string", "number"] - }, - "tools": { - "type": "array", - "items": { - "$ref": "#/definitions/Tool" - } - }, - "think": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "high" - }, - { - "type": "string", - "const": "medium" - }, - { - "type": "string", - "const": "low" - } - ] - }, - "logprobs": { - "type": "boolean" - }, - "top_logprobs": { - "type": "number" - }, - "options": { - "$ref": "#/definitions/Partial%3COptions%3E" + "arguments": { + "type": "object" } }, - "required": ["model"] - }, - "result": { - "$ref": "#/definitions/ChatResponse" + "required": ["name", "arguments"], + "additionalProperties": false } }, - "required": ["body", "result"], + "required": ["function"], "additionalProperties": false }, "ChatRequest": { "type": "object", + "additionalProperties": false, "properties": { - "model": { - "type": "string" - }, "messages": { "type": "array", "items": { - "$ref": "#/definitions/Message" + "$ref": "#/definitions/OllamaApiMessage" } }, + "model": { + "type": "string" + }, "stream": { "type": "boolean" }, @@ -215,105 +144,69 @@ "$ref": "#/definitions/Partial%3COptions%3E" } }, - "required": ["model"], - "additionalProperties": false + "required": ["messages", "model"] + }, + "Omit": { + "$ref": "#/definitions/Pick%3CChatRequest%2CExclude%3C(%22model%22%7C%22messages%22%7C%22stream%22%7C%22format%22%7C%22keep_alive%22%7C%22tools%22%7C%22think%22%7C%22logprobs%22%7C%22top_logprobs%22%7C%22options%22)%2C%22messages%22%3E%3E" }, - "Message": { + "Pick>": { "type": "object", "properties": { - "role": { - "type": "string" - }, - "content": { + "model": { "type": "string" }, - "thinking": { - "type": "string" + "stream": { + "type": "boolean" }, - "images": { + "format": { "anyOf": [ { - "type": "array", - "items": { - "$ref": "#/definitions/Uint8Array" - } + "type": "string" }, { - "type": "array", - "items": { - "type": "string" - } + "type": "object" } ] }, - "tool_calls": { + "keep_alive": { + "type": ["string", "number"] + }, + "tools": { "type": "array", "items": { - "$ref": "#/definitions/ToolCall" + "$ref": "#/definitions/Tool" } }, - "tool_name": { - "type": "string" - } - }, - "required": ["role", "content"], - "additionalProperties": false - }, - "Uint8Array": { - "type": "object", - "properties": { - "BYTES_PER_ELEMENT": { - "type": "number" - }, - "buffer": { - "$ref": "#/definitions/ArrayBufferLike" + "think": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "high" + }, + { + "type": "string", + "const": "medium" + }, + { + "type": "string", + "const": "low" + } + ] }, - "byteLength": { - "type": "number" + "logprobs": { + "type": "boolean" }, - "byteOffset": { + "top_logprobs": { "type": "number" }, - "length": { - "type": "number" - } - }, - "required": ["BYTES_PER_ELEMENT", "buffer", "byteLength", "byteOffset", "length"], - "additionalProperties": { - "type": "number" - } - }, - "ArrayBufferLike": { - "$ref": "#/definitions/ArrayBuffer" - }, - "ArrayBuffer": { - "type": "object", - "properties": { - "byteLength": { - "type": "number" - } - }, - "required": ["byteLength"], - "additionalProperties": false - }, - "ToolCall": { - "type": "object", - "properties": { - "function": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "arguments": { - "type": "object" - } - }, - "required": ["name", "arguments"], - "additionalProperties": false + "options": { + "$ref": "#/definitions/Partial%3COptions%3E" } }, - "required": ["function"], + "required": ["model"], "additionalProperties": false }, "Tool": { @@ -489,7 +382,11 @@ }, "ChatResponse": { "type": "object", + "additionalProperties": false, "properties": { + "message": { + "$ref": "#/definitions/OllamaApiMessage" + }, "model": { "type": "string" }, @@ -497,8 +394,63 @@ "type": "string", "format": "date-time" }, - "message": { - "$ref": "#/definitions/Message" + "done": { + "type": "boolean" + }, + "done_reason": { + "type": "string" + }, + "total_duration": { + "type": "number" + }, + "load_duration": { + "type": "number" + }, + "prompt_eval_count": { + "type": "number" + }, + "prompt_eval_duration": { + "type": "number" + }, + "eval_count": { + "type": "number" + }, + "eval_duration": { + "type": "number" + }, + "logprobs": { + "type": "array", + "items": { + "$ref": "#/definitions/Logprob" + } + } + }, + "required": [ + "created_at", + "done", + "done_reason", + "eval_count", + "eval_duration", + "load_duration", + "message", + "model", + "prompt_eval_count", + "prompt_eval_duration", + "total_duration" + ] + }, + "Omit": { + "$ref": "#/definitions/Pick%3CChatResponse%2CExclude%3C(%22model%22%7C%22created_at%22%7C%22message%22%7C%22done%22%7C%22done_reason%22%7C%22total_duration%22%7C%22load_duration%22%7C%22prompt_eval_count%22%7C%22prompt_eval_duration%22%7C%22eval_count%22%7C%22eval_duration%22%7C%22logprobs%22)%2C%22message%22%3E%3E" + }, + "Pick>": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" }, "done": { "type": "boolean" @@ -534,7 +486,6 @@ "required": [ "model", "created_at", - "message", "done", "done_reason", "total_duration", @@ -578,6 +529,157 @@ "required": ["token", "logprob"], "additionalProperties": false }, + "GetModelsAction": { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": { + "$ref": "#/definitions/ModelResponse" + } + } + }, + "required": ["result"], + "additionalProperties": false + }, + "ModelResponse": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "modified_at": { + "type": "string", + "format": "date-time" + }, + "model": { + "type": "string" + }, + "size": { + "type": "number" + }, + "digest": { + "type": "string" + }, + "details": { + "$ref": "#/definitions/ModelDetails" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "size_vram": { + "type": "number" + } + }, + "required": ["name", "modified_at", "model", "size", "digest", "details", "expires_at", "size_vram"], + "additionalProperties": false + }, + "ModelDetails": { + "type": "object", + "properties": { + "parent_model": { + "type": "string" + }, + "format": { + "type": "string" + }, + "family": { + "type": "string" + }, + "families": { + "type": "array", + "items": { + "type": "string" + } + }, + "parameter_size": { + "type": "string" + }, + "quantization_level": { + "type": "string" + } + }, + "required": ["parent_model", "format", "family", "families", "parameter_size", "quantization_level"], + "additionalProperties": false + }, + "ChatAction": { + "type": "object", + "properties": { + "body": { + "type": "object", + "additionalProperties": false, + "properties": { + "stream": { + "type": "boolean", + "const": false + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/OllamaApiMessage" + } + }, + "model": { + "type": "string" + }, + "format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object" + } + ] + }, + "keep_alive": { + "type": ["string", "number"] + }, + "tools": { + "type": "array", + "items": { + "$ref": "#/definitions/Tool" + } + }, + "think": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "high" + }, + { + "type": "string", + "const": "medium" + }, + { + "type": "string", + "const": "low" + } + ] + }, + "logprobs": { + "type": "boolean" + }, + "top_logprobs": { + "type": "number" + }, + "options": { + "$ref": "#/definitions/Partial%3COptions%3E" + } + }, + "required": ["messages", "model"] + }, + "result": { + "$ref": "#/definitions/ChatResponse" + } + }, + "required": ["body", "result"], + "additionalProperties": false + }, "AiApi": { "type": "object", "properties": { diff --git a/common/schemas/drives-api.json b/common/schemas/drives-api.json index b7ed330e..c7196d84 100644 --- a/common/schemas/drives-api.json +++ b/common/schemas/drives-api.json @@ -530,15 +530,7 @@ "probe_score": { "type": "number" }, - "tags": { - "$ref": "#/definitions/Record%3Cstring%2C(string%7Cnumber)%3E" - } - } - }, - "Record": { - "type": "object", - "additionalProperties": { - "type": ["string", "number"] + "tags": {} } }, "ChapterData": { diff --git a/common/schemas/identity-api.json b/common/schemas/identity-api.json index bb2125c2..18e489f5 100644 --- a/common/schemas/identity-api.json +++ b/common/schemas/identity-api.json @@ -18,7 +18,7 @@ "type": "array", "items": { "type": "string", - "const": "admin" + "enum": ["admin", "media-manager", "viewer", "iot-manager"] } }, "IsAuthenticatedAction": { diff --git a/common/schemas/identity-entities.json b/common/schemas/identity-entities.json index bbcbee20..c457591d 100644 --- a/common/schemas/identity-entities.json +++ b/common/schemas/identity-entities.json @@ -5,7 +5,7 @@ "type": "array", "items": { "type": "string", - "const": "admin" + "enum": ["admin", "media-manager", "viewer", "iot-manager"] } }, "User": { @@ -26,6 +26,66 @@ }, "required": ["username", "roles", "createdAt", "updatedAt"], "additionalProperties": false + }, + "RoleMetadata": { + "type": "object", + "properties": { + "displayName": { + "type": "string", + "description": "Human-readable name for display in UI" + }, + "description": { + "type": "string", + "description": "Brief description of what this role allows" + } + }, + "required": ["displayName", "description"], + "additionalProperties": false, + "description": "Metadata for a role (displayName, description)" + }, + "RoleDefinition": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "enum": ["admin", "media-manager", "viewer", "iot-manager"], + "description": "Role name (matches values in Roles type)" + }, + "displayName": { + "type": "string", + "description": "Human-readable name for display in UI" + }, + "description": { + "type": "string", + "description": "Brief description of what this role allows" + } + }, + "required": ["description", "displayName", "name"], + "description": "Full role definition including the name" + }, + "getRoleDefinition": { + "$comment": "(name: Roles[number]) => RoleDefinition", + "type": "object", + "properties": { + "namedArgs": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["admin", "media-manager", "viewer", "iot-manager"] + } + }, + "required": ["name"], + "additionalProperties": false + } + } + }, + "getAllRoleDefinitions": { + "$comment": "() => RoleDefinition[]" + }, + "def-function": { + "$comment": "() =>undefined" } } } diff --git a/common/schemas/media-api.json b/common/schemas/media-api.json index b82ebcce..309ef60a 100644 --- a/common/schemas/media-api.json +++ b/common/schemas/media-api.json @@ -573,15 +573,7 @@ "probe_score": { "type": "number" }, - "tags": { - "$ref": "#/definitions/Record%3Cstring%2C(string%7Cnumber)%3E" - } - } - }, - "Record": { - "type": "object", - "additionalProperties": { - "type": ["string", "number"] + "tags": {} } }, "ChapterData": { diff --git a/common/schemas/media-entities.json b/common/schemas/media-entities.json index 287fcb3a..ee73c107 100644 --- a/common/schemas/media-entities.json +++ b/common/schemas/media-entities.json @@ -595,15 +595,7 @@ "probe_score": { "type": "number" }, - "tags": { - "$ref": "#/definitions/Record%3Cstring%2C(string%7Cnumber)%3E" - } - } - }, - "Record": { - "type": "object", - "additionalProperties": { - "type": ["string", "number"] + "tags": {} } }, "ChapterData": { diff --git a/common/src/apis/ai.ts b/common/src/apis/ai.ts index eaa118c2..c424a2f3 100644 --- a/common/src/apis/ai.ts +++ b/common/src/apis/ai.ts @@ -6,9 +6,28 @@ import type { PostEndpoint, RestApi, } from '@furystack/rest' -import type { ChatRequest, ChatResponse, ModelResponse } from 'ollama' +import type { + ModelResponse, + ChatRequest as OllamaChatRequest, + ChatResponse as OllamaChatResponse, + Message as OllamaMessage, +} from 'ollama' import type { AiChat, AiChatMessage } from '../models/index.js' +// Override OllamaMessage to use string[] for images instead of Uint8Array[] | string[] +// This avoids schema generation issues with Uint8Array +type OllamaApiMessage = Omit & { + images?: string[] +} + +type ChatRequest = Omit & { + messages: OllamaApiMessage[] +} + +export type ChatResponse = Omit & { + message: OllamaApiMessage +} + export type GetModelsAction = { result: ModelResponse[] } diff --git a/common/src/models/identity/index.ts b/common/src/models/identity/index.ts index 72e50f89..501fb857 100644 --- a/common/src/models/identity/index.ts +++ b/common/src/models/identity/index.ts @@ -1 +1,2 @@ export * from './user.js' +export * from './roles.js' diff --git a/common/src/models/identity/roles.spec.ts b/common/src/models/identity/roles.spec.ts new file mode 100644 index 00000000..02bf4e67 --- /dev/null +++ b/common/src/models/identity/roles.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' +import { AVAILABLE_ROLES, getAllRoleDefinitions, getRoleDefinition } from './roles.js' + +describe('roles', () => { + describe('AVAILABLE_ROLES', () => { + it('should contain all 4 roles', () => { + const roleNames = Object.keys(AVAILABLE_ROLES) + + expect(roleNames).toHaveLength(4) + expect(roleNames).toContain('admin') + expect(roleNames).toContain('media-manager') + expect(roleNames).toContain('viewer') + expect(roleNames).toContain('iot-manager') + }) + + it('should have displayName for each role', () => { + for (const role of Object.values(AVAILABLE_ROLES)) { + expect(role.displayName).toBeDefined() + expect(typeof role.displayName).toBe('string') + expect(role.displayName.length).toBeGreaterThan(0) + } + }) + + it('should have description for each role', () => { + for (const role of Object.values(AVAILABLE_ROLES)) { + expect(role.description).toBeDefined() + expect(typeof role.description).toBe('string') + expect(role.description.length).toBeGreaterThan(0) + } + }) + + it.each([ + ['admin', 'Application Admin', 'Full system access including user management and all settings'], + ['media-manager', 'Media Manager', 'Can manage movies, series, and encoding tasks'], + ['viewer', 'Viewer', 'Can browse and watch media content'], + ['iot-manager', 'IoT Manager', 'Can manage IoT devices and their settings'], + ] as const)('should have correct metadata for %s role', (role, displayName, description) => { + expect(AVAILABLE_ROLES[role]).toEqual({ displayName, description }) + }) + }) + + describe('getRoleDefinition', () => { + it.each([ + ['admin', 'Application Admin', 'Full system access including user management and all settings'], + ['media-manager', 'Media Manager', 'Can manage movies, series, and encoding tasks'], + ['viewer', 'Viewer', 'Can browse and watch media content'], + ['iot-manager', 'IoT Manager', 'Can manage IoT devices and their settings'], + ] as const)('should return correct definition for %s role', (name, displayName, description) => { + const definition = getRoleDefinition(name) + expect(definition).toEqual({ name, displayName, description }) + }) + }) + + describe('getAllRoleDefinitions', () => { + it('should return array of all 4 role definitions', () => { + const definitions = getAllRoleDefinitions() + + expect(definitions).toHaveLength(4) + }) + + it('should include all role names', () => { + const definitions = getAllRoleDefinitions() + const names = definitions.map((d) => d.name) + + expect(names).toContain('admin') + expect(names).toContain('media-manager') + expect(names).toContain('viewer') + expect(names).toContain('iot-manager') + }) + + it('should have all required properties for each definition', () => { + const definitions = getAllRoleDefinitions() + + for (const definition of definitions) { + expect(definition).toHaveProperty('name') + expect(definition).toHaveProperty('displayName') + expect(definition).toHaveProperty('description') + expect(typeof definition.name).toBe('string') + expect(typeof definition.displayName).toBe('string') + expect(typeof definition.description).toBe('string') + } + }) + + it('should return definitions that match getRoleDefinition for each role', () => { + const definitions = getAllRoleDefinitions() + + for (const definition of definitions) { + const singleDefinition = getRoleDefinition(definition.name) + expect(definition).toEqual(singleDefinition) + } + }) + }) +}) diff --git a/common/src/models/identity/roles.ts b/common/src/models/identity/roles.ts new file mode 100644 index 00000000..bec9e753 --- /dev/null +++ b/common/src/models/identity/roles.ts @@ -0,0 +1,56 @@ +import type { Roles } from './user.js' + +/** + * Metadata for a role (displayName, description) + */ +export type RoleMetadata = { + /** Human-readable name for display in UI */ + displayName: string + /** Brief description of what this role allows */ + description: string +} + +/** + * Full role definition including the name + */ +export type RoleDefinition = RoleMetadata & { + /** Role name (matches values in Roles type) */ + name: Roles[number] +} + +/** + * All available roles in the system with their metadata. + * Using Record ensures TS will error if a role is missing. + */ +export const AVAILABLE_ROLES: Record = { + admin: { + displayName: 'Application Admin', + description: 'Full system access including user management and all settings', + }, + 'media-manager': { + displayName: 'Media Manager', + description: 'Can manage movies, series, and encoding tasks', + }, + viewer: { + displayName: 'Viewer', + description: 'Can browse and watch media content', + }, + 'iot-manager': { + displayName: 'IoT Manager', + description: 'Can manage IoT devices and their settings', + }, +} + +/** + * Helper to get full role definition by name + */ +export const getRoleDefinition = (name: Roles[number]): RoleDefinition => ({ + name, + ...AVAILABLE_ROLES[name], +}) + +/** + * Get all role definitions as an array (useful for dropdowns) + */ +export const getAllRoleDefinitions = (): RoleDefinition[] => + (Object.keys(AVAILABLE_ROLES) as Array).map(getRoleDefinition) diff --git a/common/src/models/identity/user.ts b/common/src/models/identity/user.ts index 2b63bce0..390765c9 100644 --- a/common/src/models/identity/user.ts +++ b/common/src/models/identity/user.ts @@ -1,4 +1,4 @@ -export type Roles = Array<'admin'> +export type Roles = Array<'admin' | 'media-manager' | 'viewer' | 'iot-manager'> export class User { public username!: string diff --git a/common/src/models/media/ffprobe-data.ts b/common/src/models/media/ffprobe-data.ts index 059e5a8c..a3251083 100644 --- a/common/src/models/media/ffprobe-data.ts +++ b/common/src/models/media/ffprobe-data.ts @@ -76,7 +76,7 @@ interface FfprobeFormat { size?: number | undefined bit_rate?: number | undefined probe_score?: number | undefined - tags?: Record | undefined + tags?: unknown } export type ChapterData = { diff --git a/e2e/app-config.spec.ts b/e2e/app-config.spec.ts index 544f94b2..dfe7bc0a 100644 --- a/e2e/app-config.spec.ts +++ b/e2e/app-config.spec.ts @@ -1,37 +1,35 @@ import { expect, test } from '@playwright/test' import { assertAndDismissNoty, login, navigateToAppSettings } from './helpers.js' -test.describe('OMDB Settings', () => { - test.beforeEach(async ({ page }) => { +test.describe('App Configuration Settings', () => { + test('Admin can configure OMDB settings, toggle visibility, save, and verify persistence', async ({ page }) => { + // ============================================ + // STEP 1: Login and navigate to app settings + // ============================================ await page.goto('/') await login(page) await navigateToAppSettings(page) - // Wait for the OMDB settings page to load await page.waitForSelector('text=OMDB Settings') - }) - test('should display OMDB settings form', async ({ page }) => { - // Verify OMDB settings heading is visible (using text selector to pierce shadow DOM) + // ============================================ + // STEP 2: Verify OMDB settings form structure + // ============================================ await expect(page.locator('text=OMDB Settings').first()).toBeVisible() - // Verify form fields are present (use locator chain through shadow DOM) const omdbPage = page.locator('omdb-settings-page') const apiKeyInput = omdbPage.locator('input[name="apiKey"]') - await expect(apiKeyInput).toBeVisible() - const searchCheckbox = omdbPage.locator('input[name="trySearchMovieFromTitle"]') - await expect(searchCheckbox).toBeVisible() - const autoDownloadCheckbox = omdbPage.locator('input[name="autoDownloadMetadata"]') - await expect(autoDownloadCheckbox).toBeVisible() - const saveButton = omdbPage.getByRole('button', { name: /save settings/i }) + + await expect(apiKeyInput).toBeVisible() + await expect(searchCheckbox).toBeVisible() + await expect(autoDownloadCheckbox).toBeVisible() await expect(saveButton).toBeVisible() - }) - test('should toggle API key visibility', async ({ page }) => { - const omdbPage = page.locator('omdb-settings-page') - const apiKeyInput = omdbPage.locator('input[name="apiKey"]') + // ============================================ + // STEP 3: Test API key visibility toggle + // ============================================ const toggleButton = omdbPage.locator('[data-toggle-visibility]') // Initially password should be hidden @@ -44,414 +42,269 @@ test.describe('OMDB Settings', () => { // Click toggle to hide again await toggleButton.click() await expect(apiKeyInput).toHaveAttribute('type', 'password') - }) - test('should save OMDB settings successfully', async ({ page }) => { - const omdbPage = page.locator('omdb-settings-page') - const apiKeyInput = omdbPage.locator('input[name="apiKey"]') - const searchCheckbox = omdbPage.locator('input[name="trySearchMovieFromTitle"]') - const autoDownloadCheckbox = omdbPage.locator('input[name="autoDownloadMetadata"]') + // ============================================ + // STEP 4: Record initial values for cleanup + // ============================================ + const initialApiKey = await apiKeyInput.inputValue() + const initialSearchChecked = await searchCheckbox.isChecked() + const initialAutoDownloadChecked = await autoDownloadCheckbox.isChecked() - // Fill in the form - await apiKeyInput.fill('test-api-key-e2e') + // ============================================ + // STEP 5: Fill in and save new settings + // ============================================ + const testApiKey = `test-api-key-${Date.now()}` + await apiKeyInput.fill(testApiKey) - // Ensure checkboxes are in a known state - if (!(await searchCheckbox.isChecked())) { + // Ensure checkboxes are in a known state (toggle them if needed) + if (!initialSearchChecked) { await searchCheckbox.check() } - if (!(await autoDownloadCheckbox.isChecked())) { + if (!initialAutoDownloadChecked) { await autoDownloadCheckbox.check() } - // Submit the form - const saveButton = omdbPage.getByRole('button', { name: /save settings/i }) await saveButton.click() - - // Verify success notification await assertAndDismissNoty(page, 'OMDB settings saved successfully') - }) - test('should persist OMDB settings after save', async ({ page }) => { - const omdbPage = page.locator('omdb-settings-page') - const apiKeyInput = omdbPage.locator('input[name="apiKey"]') - - // Fill in a unique API key - const testApiKey = `test-api-key-${Date.now()}` - await apiKeyInput.fill(testApiKey) - - // Submit the form - const saveButton = omdbPage.getByRole('button', { name: /save settings/i }) - await saveButton.click() - await assertAndDismissNoty(page, 'OMDB settings saved successfully') - - // Navigate away and back (simulates leaving and returning) + // ============================================ + // STEP 6: Navigate away and back to verify persistence + // ============================================ await page.goto('/') await page.waitForSelector('text=Apps') - - // Navigate back to OMDB settings await navigateToAppSettings(page) await page.waitForSelector('text=OMDB Settings') - // Verify the value is persisted + // Verify the API key persisted const apiKeyInputAfter = page.locator('omdb-settings-page').locator('input[name="apiKey"]') await expect(apiKeyInputAfter).toHaveValue(testApiKey) + + // ============================================ + // STEP 7: Cleanup - restore original values + // ============================================ + await apiKeyInputAfter.fill(initialApiKey) + + const searchCheckboxAfter = page.locator('omdb-settings-page').locator('input[name="trySearchMovieFromTitle"]') + const autoDownloadCheckboxAfter = page.locator('omdb-settings-page').locator('input[name="autoDownloadMetadata"]') + + if (initialSearchChecked !== (await searchCheckboxAfter.isChecked())) { + await searchCheckboxAfter.click() + } + if (initialAutoDownloadChecked !== (await autoDownloadCheckboxAfter.isChecked())) { + await autoDownloadCheckboxAfter.click() + } + + const saveButtonAfter = page.locator('omdb-settings-page').getByRole('button', { name: /save settings/i }) + await saveButtonAfter.click() + await assertAndDismissNoty(page, 'OMDB settings saved successfully') }) -}) -test.describe('Streaming Settings', () => { - test.beforeEach(async ({ page }) => { + test('Admin can configure Streaming settings, validate inputs, save, and verify persistence', async ({ page }) => { + // ============================================ + // STEP 1: Login and navigate to streaming settings + // ============================================ await page.goto('/') await login(page) await navigateToAppSettings(page) await page.waitForSelector('text=OMDB Settings') - // Navigate to streaming settings const streamingMenuItem = page.getByText('Streaming Settings') await streamingMenuItem.click() await page.waitForSelector('text=📺 Streaming Settings') - }) - test('should display streaming settings form', async ({ page }) => { - // Verify streaming settings heading (using text selector to pierce shadow DOM) + // ============================================ + // STEP 2: Verify streaming settings form structure + // ============================================ await expect(page.locator('text=📺 Streaming Settings').first()).toBeVisible() const streamingPage = page.locator('streaming-settings-page') - - // Verify form fields are present const extractSubtitlesCheckbox = streamingPage.locator('input[name="autoExtractSubtitles"]') - await expect(extractSubtitlesCheckbox).toBeVisible() - const fullSyncCheckbox = streamingPage.locator('input[name="fullSyncOnStartup"]') - await expect(fullSyncCheckbox).toBeVisible() - const watchFilesCheckbox = streamingPage.locator('input[name="watchFiles"]') - await expect(watchFilesCheckbox).toBeVisible() - const presetSelect = streamingPage.locator('select[name="preset"]') - await expect(presetSelect).toBeVisible() - const threadsInput = streamingPage.locator('input[name="threads"]') - await expect(threadsInput).toBeVisible() - const saveButton = streamingPage.getByRole('button', { name: /save settings/i }) - await expect(saveButton).toBeVisible() - }) - test('should save streaming settings successfully', async ({ page }) => { - const streamingPage = page.locator('streaming-settings-page') - const extractSubtitlesCheckbox = streamingPage.locator('input[name="autoExtractSubtitles"]') - const presetSelect = streamingPage.locator('select[name="preset"]') - const threadsInput = streamingPage.locator('input[name="threads"]') - - // Toggle checkbox - if (!(await extractSubtitlesCheckbox.isChecked())) { - await extractSubtitlesCheckbox.check() - } - - // Select preset - await presetSelect.selectOption('fast') - - // Set threads - await threadsInput.fill('8') - - // Submit the form - const saveButton = streamingPage.getByRole('button', { name: /save settings/i }) - await saveButton.click() + await expect(extractSubtitlesCheckbox).toBeVisible() + await expect(fullSyncCheckbox).toBeVisible() + await expect(watchFilesCheckbox).toBeVisible() + await expect(presetSelect).toBeVisible() + await expect(threadsInput).toBeVisible() + await expect(saveButton).toBeVisible() - // Verify success notification - await assertAndDismissNoty(page, 'Streaming settings saved successfully') - }) + // ============================================ + // STEP 3: Verify input validation attributes + // ============================================ + await expect(threadsInput).toHaveAttribute('min', '1') + await expect(threadsInput).toHaveAttribute('max', '64') - test('should persist streaming settings after save', async ({ page }) => { - const streamingPage = page.locator('streaming-settings-page') - const threadsInput = streamingPage.locator('input[name="threads"]') - const presetSelect = streamingPage.locator('select[name="preset"]') + // ============================================ + // STEP 4: Record initial values for cleanup + // ============================================ + const initialThreads = await threadsInput.inputValue() + const initialPreset = await presetSelect.inputValue() + const initialExtractSubtitles = await extractSubtitlesCheckbox.isChecked() - // Set specific values + // ============================================ + // STEP 5: Update settings and save + // ============================================ await threadsInput.fill('12') await presetSelect.selectOption('veryfast') - // Submit the form - const saveButton = streamingPage.getByRole('button', { name: /save settings/i }) + if (!initialExtractSubtitles) { + await extractSubtitlesCheckbox.check() + } + await saveButton.click() await assertAndDismissNoty(page, 'Streaming settings saved successfully') - // Navigate away and back (simulates leaving and returning) + // ============================================ + // STEP 6: Navigate away and back to verify persistence + // ============================================ await page.goto('/') await page.waitForSelector('text=Apps') - - // Navigate back to app settings await navigateToAppSettings(page) await page.waitForSelector('text=OMDB Settings') - // Navigate to streaming settings - const streamingMenuItem = page.getByText('Streaming Settings') - await streamingMenuItem.click() + await page.getByText('Streaming Settings').click() await page.waitForSelector('text=📺 Streaming Settings') - // Verify the values are persisted const threadsInputAfter = page.locator('streaming-settings-page').locator('input[name="threads"]') const presetSelectAfter = page.locator('streaming-settings-page').locator('select[name="preset"]') + await expect(threadsInputAfter).toHaveValue('12') await expect(presetSelectAfter).toHaveValue('veryfast') - }) - test('should validate threads input', async ({ page }) => { - const streamingPage = page.locator('streaming-settings-page') - const threadsInput = streamingPage.locator('input[name="threads"]') + // ============================================ + // STEP 7: Cleanup - restore original values + // ============================================ + await threadsInputAfter.fill(initialThreads) + await presetSelectAfter.selectOption(initialPreset) - // Verify min/max attributes - await expect(threadsInput).toHaveAttribute('min', '1') - await expect(threadsInput).toHaveAttribute('max', '64') + const extractSubtitlesAfter = page.locator('streaming-settings-page').locator('input[name="autoExtractSubtitles"]') + if (initialExtractSubtitles !== (await extractSubtitlesAfter.isChecked())) { + await extractSubtitlesAfter.click() + } + + const saveButtonAfter = page.locator('streaming-settings-page').getByRole('button', { name: /save settings/i }) + await saveButtonAfter.click() + await assertAndDismissNoty(page, 'Streaming settings saved successfully') }) -}) -test.describe('IOT Settings', () => { - test.beforeEach(async ({ page }) => { + test('Admin can navigate all settings sections and configure IOT and AI settings', async ({ page }) => { + // ============================================ + // STEP 1: Login and navigate to app settings + // ============================================ await page.goto('/') await login(page) await navigateToAppSettings(page) await page.waitForSelector('text=OMDB Settings') - // Navigate to IOT settings - const iotMenuItem = page.getByText('Device Availability') - await iotMenuItem.click() - await page.waitForSelector('text=📡 IOT Device Availability') - }) + // Should redirect to /app-settings/omdb by default + await expect(page).toHaveURL(/\/app-settings\/omdb/) + await expect(page.locator('text=🎬 OMDB Settings').first()).toBeVisible() - test('should display IOT settings form', async ({ page }) => { - // Verify IOT settings heading (using text selector to pierce shadow DOM) + // ============================================ + // STEP 2: Navigate to Streaming settings + // ============================================ + await page.getByText('Streaming Settings').click() + await expect(page).toHaveURL(/\/app-settings\/streaming/) + await expect(page.locator('text=📺 Streaming Settings').first()).toBeVisible() + + // ============================================ + // STEP 3: Navigate to IOT settings and configure + // ============================================ + await page.getByText('Device Availability').click() + await expect(page).toHaveURL(/\/app-settings\/iot/) await expect(page.locator('text=📡 IOT Device Availability').first()).toBeVisible() const iotPage = page.locator('iot-settings-page') - - // Verify form fields are present const pingIntervalInput = iotPage.locator('input[name="pingIntervalMs"]') - await expect(pingIntervalInput).toBeVisible() - const pingTimeoutInput = iotPage.locator('input[name="pingTimeoutMs"]') - await expect(pingTimeoutInput).toBeVisible() - - const saveButton = iotPage.getByRole('button', { name: /save settings/i }) - await expect(saveButton).toBeVisible() - }) + const iotSaveButton = iotPage.getByRole('button', { name: /save settings/i }) - test('should validate ping interval input constraints', async ({ page }) => { - const iotPage = page.locator('iot-settings-page') - const pingIntervalInput = iotPage.locator('input[name="pingIntervalMs"]') + await expect(pingIntervalInput).toBeVisible() + await expect(pingTimeoutInput).toBeVisible() + await expect(iotSaveButton).toBeVisible() - // Verify min/max attributes + // Verify input constraints await expect(pingIntervalInput).toHaveAttribute('min', '1000') await expect(pingIntervalInput).toHaveAttribute('max', '3600000') await expect(pingIntervalInput).toHaveAttribute('type', 'number') - }) - - test('should validate ping timeout input constraints', async ({ page }) => { - const iotPage = page.locator('iot-settings-page') - const pingTimeoutInput = iotPage.locator('input[name="pingTimeoutMs"]') - - // Verify min/max attributes await expect(pingTimeoutInput).toHaveAttribute('min', '100') await expect(pingTimeoutInput).toHaveAttribute('max', '60000') await expect(pingTimeoutInput).toHaveAttribute('type', 'number') - }) - test('should save IOT settings successfully', async ({ page }) => { - const iotPage = page.locator('iot-settings-page') - const pingIntervalInput = iotPage.locator('input[name="pingIntervalMs"]') - const pingTimeoutInput = iotPage.locator('input[name="pingTimeoutMs"]') + // Record initial values for cleanup + const initialPingInterval = await pingIntervalInput.inputValue() + const initialPingTimeout = await pingTimeoutInput.inputValue() - // Set valid values + // Update and save IOT settings await pingIntervalInput.fill('60000') await pingTimeoutInput.fill('5000') - - // Submit the form - const saveButton = iotPage.getByRole('button', { name: /save settings/i }) - await saveButton.click() - - // Verify success notification - await assertAndDismissNoty(page, 'IOT settings saved successfully') - }) - - test('should save and verify IOT settings form accepts valid values', async ({ page }) => { - const iotPage = page.locator('iot-settings-page') - const pingIntervalInput = iotPage.locator('input[name="pingIntervalMs"]') - const pingTimeoutInput = iotPage.locator('input[name="pingTimeoutMs"]') - - // Clear and set new values - await pingIntervalInput.clear() - await pingIntervalInput.fill('90000') - await pingTimeoutInput.clear() - await pingTimeoutInput.fill('8000') - - // Submit the form - const saveButton = iotPage.getByRole('button', { name: /save settings/i }) - await saveButton.click() - - // Verify success notification - this confirms the form accepts valid values + await iotSaveButton.click() await assertAndDismissNoty(page, 'IOT settings saved successfully') - }) -}) -test.describe('AI Settings', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/') - await login(page) - await navigateToAppSettings(page) - await page.waitForSelector('text=OMDB Settings') - - // Navigate to AI settings - const aiMenuItem = page.getByText('Ollama Settings') - await aiMenuItem.click() - await page.waitForSelector('text=🤖 Ollama Integration') - }) - - test('should display AI settings form', async ({ page }) => { - // Verify AI settings heading (using text selector to pierce shadow DOM) + // ============================================ + // STEP 4: Navigate to AI settings and configure + // ============================================ + await page.getByText('Ollama Settings').click() + await expect(page).toHaveURL(/\/app-settings\/ai/) await expect(page.locator('text=🤖 Ollama Integration').first()).toBeVisible() const aiPage = page.locator('ai-settings-page') - - // Verify form fields are present const hostInput = aiPage.locator('input[name="host"]') + const aiSaveButton = aiPage.getByRole('button', { name: /save settings/i }) + await expect(hostInput).toBeVisible() await expect(hostInput).toHaveAttribute('type', 'url') + await expect(aiSaveButton).toBeVisible() - const saveButton = aiPage.getByRole('button', { name: /save settings/i }) - await expect(saveButton).toBeVisible() - }) - - test('should save AI settings successfully with valid URL', async ({ page }) => { - const aiPage = page.locator('ai-settings-page') - const hostInput = aiPage.locator('input[name="host"]') + // Record initial value for cleanup + const initialHost = await hostInput.inputValue() - // Set a valid URL + // Test saving with a valid URL await hostInput.fill('http://localhost:11434') - - // Submit the form - const saveButton = aiPage.getByRole('button', { name: /save settings/i }) - await saveButton.click() - - // Verify success notification + await aiSaveButton.click() await assertAndDismissNoty(page, 'AI settings saved successfully') - }) - - test('should save AI settings successfully with empty URL (disable AI)', async ({ page }) => { - const aiPage = page.locator('ai-settings-page') - const hostInput = aiPage.locator('input[name="host"]') - // Clear the URL to disable AI features + // Test saving with empty URL (disable AI) await hostInput.fill('') - - // Submit the form - const saveButton = aiPage.getByRole('button', { name: /save settings/i }) - await saveButton.click() - - // Verify success notification - await assertAndDismissNoty(page, 'AI settings saved successfully') - }) - - test('should save and verify AI settings form accepts valid URL', async ({ page }) => { - const aiPage = page.locator('ai-settings-page') - const hostInput = aiPage.locator('input[name="host"]') - - // Clear and set a specific URL - await hostInput.clear() - await hostInput.fill('http://test-ollama:11434') - - // Submit the form - const saveButton = aiPage.getByRole('button', { name: /save settings/i }) - await saveButton.click() - - // Verify success notification - this confirms the form accepts valid URLs + await aiSaveButton.click() await assertAndDismissNoty(page, 'AI settings saved successfully') - }) - - test('should have URL input with browser validation', async ({ page }) => { - const aiPage = page.locator('ai-settings-page') - const hostInput = aiPage.locator('input[name="host"]') - - // Verify the input has type="url" which enables browser-native URL validation - await expect(hostInput).toHaveAttribute('type', 'url') - - // Note: Browser's native URL validation will prevent invalid URLs from being submitted - // Our custom validation serves as a fallback and allows empty values (to disable AI features) - }) -}) -test.describe('Settings Navigation', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/') - await login(page) - }) - - test('should navigate between OMDB and Streaming settings', async ({ page }) => { - await navigateToAppSettings(page) - await page.waitForSelector('text=OMDB Settings') - - // Verify we're on OMDB settings by default (using text selector to pierce shadow DOM) - await expect(page.locator('text=🎬 OMDB Settings').first()).toBeVisible() - - // Navigate to Streaming settings - const streamingMenuItem = page.getByText('Streaming Settings') - await streamingMenuItem.click() - await page.waitForSelector('text=📺 Streaming Settings') - - await expect(page.locator('text=📺 Streaming Settings').first()).toBeVisible() - - // Navigate back to OMDB settings - const omdbMenuItem = page.getByText('OMDB Settings') - await omdbMenuItem.click() - await page.waitForSelector('text=🎬 OMDB Settings') - - await expect(page.locator('text=🎬 OMDB Settings').first()).toBeVisible() - }) - - test('should navigate to all settings sections', async ({ page }) => { - await navigateToAppSettings(page) - await page.waitForSelector('text=OMDB Settings') - - // Navigate to IOT settings - const iotMenuItem = page.getByText('Device Availability') - await iotMenuItem.click() - await expect(page).toHaveURL(/\/app-settings\/iot/) - await expect(page.locator('text=📡 IOT Device Availability').first()).toBeVisible() - - // Navigate to AI settings - const aiMenuItem = page.getByText('Ollama Settings') - await aiMenuItem.click() - await expect(page).toHaveURL(/\/app-settings\/ai/) - await expect(page.locator('text=🤖 Ollama Integration').first()).toBeVisible() - - // Navigate back to OMDB - const omdbMenuItem = page.getByText('OMDB Settings') - await omdbMenuItem.click() + // ============================================ + // STEP 5: Navigate back to OMDB settings + // ============================================ + await page.getByText('OMDB Settings').click() await expect(page).toHaveURL(/\/app-settings\/omdb/) await expect(page.locator('text=🎬 OMDB Settings').first()).toBeVisible() - }) - test('should highlight active menu item', async ({ page }) => { - await navigateToAppSettings(page) - await page.waitForSelector('text=OMDB Settings') + // ============================================ + // STEP 6: Cleanup - restore IOT and AI settings + // ============================================ + // Restore IOT settings + await page.getByText('Device Availability').click() + await page.waitForSelector('text=📡 IOT Device Availability') - // Check that OMDB menu item is visible - const omdbMenuItem = page.locator('settings-menu-item').filter({ hasText: 'OMDB Settings' }) - await expect(omdbMenuItem).toBeVisible() + const pingIntervalInputCleanup = page.locator('iot-settings-page').locator('input[name="pingIntervalMs"]') + const pingTimeoutInputCleanup = page.locator('iot-settings-page').locator('input[name="pingTimeoutMs"]') + const iotSaveButtonCleanup = page.locator('iot-settings-page').getByRole('button', { name: /save settings/i }) - // Navigate to streaming - const streamingMenuItem = page.getByText('Streaming Settings') - await streamingMenuItem.click() + await pingIntervalInputCleanup.fill(initialPingInterval) + await pingTimeoutInputCleanup.fill(initialPingTimeout) + await iotSaveButtonCleanup.click() + await assertAndDismissNoty(page, 'IOT settings saved successfully') - // Verify URL changed - await expect(page).toHaveURL(/\/app-settings\/streaming/) - }) + // Restore AI settings + await page.getByText('Ollama Settings').click() + await page.waitForSelector('text=🤖 Ollama Integration') - test('should redirect from base /app-settings to /app-settings/omdb', async ({ page }) => { - // Navigate to app settings via UI and verify default sub-route - await navigateToAppSettings(page) - await page.waitForSelector('text=OMDB Settings') + const hostInputCleanup = page.locator('ai-settings-page').locator('input[name="host"]') + const aiSaveButtonCleanup = page.locator('ai-settings-page').getByRole('button', { name: /save settings/i }) - // Should be on the omdb settings sub-route - await expect(page).toHaveURL(/\/app-settings\/omdb/) + await hostInputCleanup.fill(initialHost) + await aiSaveButtonCleanup.click() + await assertAndDismissNoty(page, 'AI settings saved successfully') }) }) diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 53133b9a..cd821b34 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -3,11 +3,15 @@ import { expect } from '@playwright/test' import { readFile } from 'fs/promises' import { basename } from 'path' -export const assertAndDismissNoty = async (page: Page, text: string) => { +export const assertAndDismissNoty = async (page: Page, text: string, options?: { timeout?: number }) => { + const timeout = options?.timeout ?? 30_000 const noty = page.locator('shade-noty', { hasText: text }) + + await expect(noty).toBeVisible({ timeout }) + const closeNoty = noty.locator('button.dismissNoty') await closeNoty.click() - await noty.waitFor({ state: 'detached' }) + await expect(noty).not.toBeVisible() } export const login = async (page: Page, username = 'testuser@gmail.com', password = 'password') => { @@ -84,6 +88,46 @@ export const navigateToAppSettings = async (page: Page) => { */ export const navigateToAdminSettings = navigateToAppSettings +export const navigateToUsersSettings = async (page: Page) => { + await navigateToAppSettings(page) + + // Click on Users menu item in the Identity section + await page.getByText('Users').click() + + // Wait for URL to change to users list + await page.waitForURL(/\/app-settings\/users/) + + // Verify we're on the users list page + const usersListPage = page.locator('user-list-page') + await expect(usersListPage).toBeVisible({ timeout: 10000 }) +} + +export const registerUser = async (page: Page, username: string, password: string) => { + await page.goto('/') + + // Navigate to registration page + const createAccountButton = page.locator('button', { hasText: 'Create Account' }) + await expect(createAccountButton).toBeVisible() + await createAccountButton.click() + + // Fill registration form + const registerForm = page.locator('shade-register form') + await expect(registerForm).toBeVisible() + + const usernameInput = registerForm.locator('input[name="userName"]') + const passwordInput = registerForm.locator('input[name="password"]') + const confirmPasswordInput = registerForm.locator('input[name="confirmPassword"]') + const createAccountSubmitButton = page.locator('shade-register button', { hasText: 'Create Account' }) + + await usernameInput.fill(username) + await passwordInput.fill(password) + await confirmPasswordInput.fill(password) + await createAccountSubmitButton.click() + + // Should be logged in automatically after successful registration + await assertAndDismissNoty(page, 'Account created successfully') +} + export const uploadFile = async (page: Page, filePath: string, mime: string) => { const fileContent = await readFile(filePath, { encoding: 'utf-8' }) const fileName = basename(filePath) diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 00000000..f08884de --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "composite": false, + "noEmit": true + }, + "include": ["."] +} diff --git a/e2e/user-management.spec.ts b/e2e/user-management.spec.ts new file mode 100644 index 00000000..2f101a5f --- /dev/null +++ b/e2e/user-management.spec.ts @@ -0,0 +1,179 @@ +import type { Page } from '@playwright/test' +import { expect, test } from '@playwright/test' +import { assertAndDismissNoty, login, logout, navigateToAppSettings, registerUser } from './helpers.js' + +const TEST_USER_PASSWORD = 'testpassword123' + +const getTestUserName = (projectName: string) => `role-tester-${projectName}@test.com` + +/** + * Helper: Add a role to the user via dropdown. Returns the role name added, or null if no roles available. + */ +const addRoleToUser = async (page: Page): Promise => { + const detailsPage = page.locator('user-details-page') + const roleSelect = detailsPage.locator('select') + + const selectCount = await roleSelect.count() + if (selectCount === 0) { + return null + } + + const optionsCount = await roleSelect.locator('option').count() + if (optionsCount <= 1) { + return null + } + + const options = roleSelect.locator('option') + const roleValue = await options.nth(1).getAttribute('value') + if (!roleValue) { + return null + } + + await roleSelect.selectOption(roleValue) + return roleValue +} + +/** + * Helper: Remove the first role from the user. Returns the role name removed, or null if none available. + */ +const removeRole = async (page: Page): Promise => { + const detailsPage = page.locator('user-details-page') + const roleTags = detailsPage.locator('role-tag') + + const roleCount = await roleTags.count() + if (roleCount === 0) { + return null + } + + const roleTag = roleTags.first() + const roleText = await roleTag.textContent() + + const removeButton = roleTag.locator('button', { hasText: '×' }) + const removeButtonCount = await removeButton.count() + if (removeButtonCount > 0) { + await removeButton.click() + return roleText?.replace('×', '').replace('↩', '').trim() ?? null + } + + return null +} + +test.describe('User Management', () => { + test('Admin can manage user roles', async ({ page }, testInfo) => { + const projectName = testInfo.project.name + const testUserName = getTestUserName(projectName) + + // ============================================ + // SETUP: Register a project-specific test user + // ============================================ + await registerUser(page, testUserName, TEST_USER_PASSWORD) + await logout(page) + + // ============================================ + // STEP 1: Login as admin and navigate to users + // ============================================ + await login(page) + await navigateToAppSettings(page) + + const usersMenuItem = page.getByText('Users') + await expect(usersMenuItem).toBeVisible() + await usersMenuItem.click() + await page.waitForURL(/\/app-settings\/users/) + + const usersPage = page.locator('user-list-page') + await expect(usersPage).toBeVisible() + await expect(page.locator('text=👥 Users').first()).toBeVisible() + + // ============================================ + // STEP 2: Open the test user's details + // ============================================ + const testUserRow = usersPage.locator('tbody tr', { hasText: testUserName }) + await expect(testUserRow).toBeVisible() + await testUserRow.getByRole('button', { name: 'Edit' }).click() + + await expect(page).toHaveURL(/\/app-settings\/users\//) + + const detailsPage = page.locator('user-details-page') + await expect(detailsPage).toBeVisible() + + const saveButton = detailsPage.getByRole('button', { name: 'Save Changes' }) + const cancelButton = detailsPage.getByRole('button', { name: 'Cancel' }) + + // Save should be disabled initially (no changes) + await expect(saveButton).toBeDisabled() + + // ============================================ + // STEP 3: Verify user starts with no roles + // ============================================ + expect(await detailsPage.locator('role-tag').count(), 'New user should have no roles').toBe(0) + + // ============================================ + // STEP 4: Test Cancel functionality - add a role then cancel + // ============================================ + const addedRole = await addRoleToUser(page) + expect(addedRole, 'Should be able to add a role').not.toBeNull() + await expect(saveButton).toBeEnabled() + + // Cancel should restore original state (no roles) + await cancelButton.click() + await expect(saveButton).toBeDisabled() + expect(await detailsPage.locator('role-tag').count()).toBe(0) + + // ============================================ + // STEP 5: Add a role and save + // ============================================ + const roleToAdd = await addRoleToUser(page) + expect(roleToAdd, 'Should be able to add a role').not.toBeNull() + + await saveButton.click() + await assertAndDismissNoty(page, 'User roles updated successfully') + + // Wait for the details page to stabilize after save + await expect(detailsPage).toBeVisible() + await expect(detailsPage.getByRole('button', { name: 'Save Changes' })).toBeDisabled() + + // Reload and verify persistence + await page.reload() + await expect(detailsPage).toBeVisible() + expect(await detailsPage.locator('role-tag').count()).toBe(1) + + // Re-locate buttons after reload + const saveButtonAfterReload = detailsPage.getByRole('button', { name: 'Save Changes' }) + const cancelButtonAfterReload = detailsPage.getByRole('button', { name: 'Cancel' }) + + // ============================================ + // STEP 6: Test remove and restore functionality + // ============================================ + const removed = await removeRole(page) + expect(removed).not.toBeNull() + + // Restore button should appear for removed role + const restoreButton = detailsPage.locator('role-tag').locator('button', { hasText: '↩' }).first() + await expect(restoreButton).toBeVisible() + await restoreButton.click() + + // No net changes after restore, save should be disabled + await expect(saveButtonAfterReload).toBeDisabled() + + // ============================================ + // STEP 7: Test validation - cannot save with zero roles + // ============================================ + await removeRole(page) + await expect(saveButtonAfterReload).toBeEnabled() + await saveButtonAfterReload.click() + await expect(detailsPage.getByText('User must have at least one role')).toBeVisible() + + // Cancel to restore the role + await cancelButtonAfterReload.click() + expect(await detailsPage.locator('role-tag').count()).toBe(1) + + // ============================================ + // STEP 8: Navigate back to users list + // ============================================ + const backButton = detailsPage.getByRole('button', { name: /back/i }) + await backButton.click() + + await expect(page).toHaveURL(/\/app-settings\/users$/) + await expect(usersPage).toBeVisible() + }) +}) diff --git a/e2e/user-settings-basic.spec.ts b/e2e/user-settings-basic.spec.ts deleted file mode 100644 index ac0a744d..00000000 --- a/e2e/user-settings-basic.spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { expect, test } from '@playwright/test' -import { login, navigateToUserSettings } from './helpers.js' - -test('User Settings Page Access', async ({ page }) => { - await page.goto('/') - await login(page) - - // Navigate to user settings - await navigateToUserSettings(page) - - // Verify we're on the settings page - const settingsHeading = page.locator('h1', { hasText: 'User Settings' }) - await expect(settingsHeading).toBeVisible() - - // Verify Profile section is visible - const profileSection = page.locator('h3', { hasText: 'Profile' }) - await expect(profileSection).toBeVisible() - - // Verify Security section is visible - const securitySection = page.locator('h3', { hasText: 'Security' }) - await expect(securitySection).toBeVisible() -}) - -test('Password Reset Form Elements', async ({ page }) => { - await page.goto('/') - await login(page) - await navigateToUserSettings(page) - - // Verify password change form is present - const passwordForm = page.locator('form[data-password-reset-form]') - await expect(passwordForm).toBeVisible() - - // Verify form inputs are present and functional - const currentPasswordInput = passwordForm.locator('input[name="currentPassword"]') - await expect(currentPasswordInput).toBeVisible() - await expect(currentPasswordInput).toHaveAttribute('type', 'password') - - const newPasswordInput = passwordForm.locator('input[name="newPassword"]') - await expect(newPasswordInput).toBeVisible() - await expect(newPasswordInput).toHaveAttribute('type', 'password') - - const confirmPasswordInput = passwordForm.locator('input[name="confirmPassword"]') - await expect(confirmPasswordInput).toBeVisible() - await expect(confirmPasswordInput).toHaveAttribute('type', 'password') - - const updateButton = passwordForm.getByRole('button', { name: /update password/i }) - await expect(updateButton).toBeVisible() - await expect(updateButton).toBeEnabled() -}) - -test('User Profile Information Display', async ({ page }) => { - await page.goto('/') - await login(page) - await navigateToUserSettings(page) - - // Verify profile section shows user information - const profileSection = page.locator('h3', { hasText: 'Profile' }).locator('..') // Parent element - - // Check that username is displayed - const usernameLabel = profileSection.locator('text=Username') - await expect(usernameLabel).toBeVisible() - - const usernameValue = profileSection.locator('text=testuser@gmail.com') - await expect(usernameValue).toBeVisible() - - // Check that roles are displayed - const rolesLabel = profileSection.locator('text=Roles') - await expect(rolesLabel).toBeVisible() - - // The test user should have admin role - const adminRole = profileSection.locator('text=admin') - await expect(adminRole).toBeVisible() -}) - -test('Form Input Functionality', async ({ page }) => { - await page.goto('/') - await login(page) - await navigateToUserSettings(page) - - const passwordForm = page.locator('form[data-password-reset-form]') - const currentPasswordInput = passwordForm.locator('input[name="currentPassword"]') - const newPasswordInput = passwordForm.locator('input[name="newPassword"]') - const confirmPasswordInput = passwordForm.locator('input[name="confirmPassword"]') - - // Test that inputs can be filled and retain values - await currentPasswordInput.fill('testCurrentPassword') - await newPasswordInput.fill('testNewPassword') - await confirmPasswordInput.fill('testConfirmPassword') - - await expect(currentPasswordInput).toHaveValue('testCurrentPassword') - await expect(newPasswordInput).toHaveValue('testNewPassword') - await expect(confirmPasswordInput).toHaveValue('testConfirmPassword') - - // Test that inputs can be cleared - await currentPasswordInput.fill('') - await newPasswordInput.fill('') - await confirmPasswordInput.fill('') - - await expect(currentPasswordInput).toHaveValue('') - await expect(newPasswordInput).toHaveValue('') - await expect(confirmPasswordInput).toHaveValue('') -}) diff --git a/e2e/user-settings.spec.ts b/e2e/user-settings.spec.ts index 4ba8c3b1..48a3b47d 100644 --- a/e2e/user-settings.spec.ts +++ b/e2e/user-settings.spec.ts @@ -1,106 +1,113 @@ import { expect, test } from '@playwright/test' import { login, navigateToUserSettings } from './helpers.js' -test('User Settings Navigation', async ({ page }) => { - await page.goto('/') - await login(page) - - await navigateToUserSettings(page) - - const settingsHeading = page.locator('h1', { hasText: 'User Settings' }) - await expect(settingsHeading).toBeVisible() - - // Verify Profile section is visible - const profileSection = page.locator('h3', { hasText: 'Profile' }) - await expect(profileSection).toBeVisible() - - // Verify Security section is visible - const securitySection = page.locator('h3', { hasText: 'Security' }) - await expect(securitySection).toBeVisible() - - // Verify password change form is present - const passwordForm = page.locator('form[data-password-reset-form]') - await expect(passwordForm).toBeVisible() - - // Verify form inputs are present - const currentPasswordInput = passwordForm.locator('input[name="currentPassword"]') - await expect(currentPasswordInput).toBeVisible() - - const newPasswordInput = passwordForm.locator('input[name="newPassword"]') - await expect(newPasswordInput).toBeVisible() - - const confirmPasswordInput = passwordForm.locator('input[name="confirmPassword"]') - await expect(confirmPasswordInput).toBeVisible() - - const updateButton = passwordForm.getByRole('button', { name: /update password/i }) - await expect(updateButton).toBeVisible() -}) - -test('Password Reset Basic Flow', async ({ page }) => { - await page.goto('/') - await login(page) - - // Navigate to user settings - await navigateToUserSettings(page) - - // Find the password form - const passwordForm = page.locator('form[data-password-reset-form]') - await expect(passwordForm).toBeVisible() - - const currentPasswordInput = passwordForm.locator('input[name="currentPassword"]') - const newPasswordInput = passwordForm.locator('input[name="newPassword"]') - const confirmPasswordInput = passwordForm.locator('input[name="confirmPassword"]') - const updateButton = passwordForm.getByRole('button', { name: /update password/i }) - - // Test basic form interaction - just verify form can be filled - await currentPasswordInput.fill('password') - await newPasswordInput.fill('newPassword123') - await confirmPasswordInput.fill('newPassword123') - - // Verify form inputs work - await expect(currentPasswordInput).toHaveValue('password') - await expect(newPasswordInput).toHaveValue('newPassword123') - await expect(confirmPasswordInput).toHaveValue('newPassword123') - - // Test that update button is clickable - await expect(updateButton).toBeEnabled() -}) - -test.skip('Password Reset Error Handling', async ({ page }) => { - // Skip this test until error handling is fully implemented - // This test was testing functionality that isn't implemented yet - await page.goto('/') - await login(page) - - await navigateToUserSettings(page) - - // Just verify the form is accessible for now - const passwordForm = page.locator('form[data-password-reset-form]') - await expect(passwordForm).toBeVisible() -}) - -test('User Profile Information Display', async ({ page }) => { - await page.goto('/') - await login(page) - - // Navigate to user settings - await navigateToUserSettings(page) - - // Verify profile section shows user information - const profileSection = page.locator('h3', { hasText: 'Profile' }).locator('..') // Parent element - - // Check that username is displayed - const usernameLabel = profileSection.locator('text=Username') - await expect(usernameLabel).toBeVisible() - - const usernameValue = profileSection.locator('text=testuser@gmail.com') - await expect(usernameValue).toBeVisible() - - // Check that roles are displayed - const rolesLabel = profileSection.locator('text=Roles') - await expect(rolesLabel).toBeVisible() - - // The test user should have admin role - const adminRole = profileSection.locator('text=admin') - await expect(adminRole).toBeVisible() +test.describe('User Settings', () => { + test('User can view profile, verify settings page structure, and interact with password form', async ({ page }) => { + // ============================================ + // STEP 1: Login and navigate to user settings + // ============================================ + await page.goto('/') + await login(page) + await navigateToUserSettings(page) + + // ============================================ + // STEP 2: Verify page structure and heading + // ============================================ + const settingsHeading = page.locator('h1', { hasText: 'User Settings' }) + await expect(settingsHeading).toBeVisible() + + // Verify Profile section is visible + const profileSection = page.locator('h3', { hasText: 'Profile' }) + await expect(profileSection).toBeVisible() + + // Verify Security section is visible + const securitySection = page.locator('h3', { hasText: 'Security' }) + await expect(securitySection).toBeVisible() + + // ============================================ + // STEP 3: Verify user profile information display + // ============================================ + const profileContainer = profileSection.locator('..') // Parent element + + // Check that username is displayed + const usernameLabel = profileContainer.locator('text=Username') + await expect(usernameLabel).toBeVisible() + + const usernameValue = profileContainer.locator('text=testuser@gmail.com') + await expect(usernameValue).toBeVisible() + + // Check that roles are displayed + const rolesLabel = profileContainer.locator('text=Roles') + await expect(rolesLabel).toBeVisible() + + // The test user should have admin role + const adminRole = profileContainer.locator('text=admin') + await expect(adminRole).toBeVisible() + + // ============================================ + // STEP 4: Verify password reset form structure + // ============================================ + const passwordForm = page.locator('form[data-password-reset-form]') + await expect(passwordForm).toBeVisible() + + const currentPasswordInput = passwordForm.locator('input[name="currentPassword"]') + const newPasswordInput = passwordForm.locator('input[name="newPassword"]') + const confirmPasswordInput = passwordForm.locator('input[name="confirmPassword"]') + const updateButton = passwordForm.getByRole('button', { name: /update password/i }) + + await expect(currentPasswordInput).toBeVisible() + await expect(currentPasswordInput).toHaveAttribute('type', 'password') + + await expect(newPasswordInput).toBeVisible() + await expect(newPasswordInput).toHaveAttribute('type', 'password') + + await expect(confirmPasswordInput).toBeVisible() + await expect(confirmPasswordInput).toHaveAttribute('type', 'password') + + await expect(updateButton).toBeVisible() + await expect(updateButton).toBeEnabled() + + // ============================================ + // STEP 5: Test form input functionality + // ============================================ + // Fill in test values + await currentPasswordInput.fill('testCurrentPassword') + await newPasswordInput.fill('testNewPassword') + await confirmPasswordInput.fill('testConfirmPassword') + + // Verify values are retained + await expect(currentPasswordInput).toHaveValue('testCurrentPassword') + await expect(newPasswordInput).toHaveValue('testNewPassword') + await expect(confirmPasswordInput).toHaveValue('testConfirmPassword') + + // Test that inputs can be cleared + await currentPasswordInput.fill('') + await newPasswordInput.fill('') + await confirmPasswordInput.fill('') + + await expect(currentPasswordInput).toHaveValue('') + await expect(newPasswordInput).toHaveValue('') + await expect(confirmPasswordInput).toHaveValue('') + + // ============================================ + // STEP 6: Test basic password reset flow (form interaction only) + // ============================================ + // Fill form with valid-looking data (not actually submitting to change password) + await currentPasswordInput.fill('password') + await newPasswordInput.fill('newPassword123') + await confirmPasswordInput.fill('newPassword123') + + // Verify form inputs work correctly + await expect(currentPasswordInput).toHaveValue('password') + await expect(newPasswordInput).toHaveValue('newPassword123') + await expect(confirmPasswordInput).toHaveValue('newPassword123') + + // Verify update button is clickable (but don't click to avoid changing password) + await expect(updateButton).toBeEnabled() + + // Clear form to leave clean state + await currentPasswordInput.fill('') + await newPasswordInput.fill('') + await confirmPasswordInput.fill('') + }) }) diff --git a/eslint.config.js b/eslint.config.js index d7b5dcb0..b817c867 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -34,7 +34,7 @@ export default tseslint.config( }, languageOptions: { parserOptions: { - project: ['tsconfig.json'], + projectService: true, tsconfigRootDir: import.meta.dirname, }, }, diff --git a/frontend/package.json b/frontend/package.json index a18a5ace..71d68140 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,22 +12,22 @@ "license": "ISC", "devDependencies": { "@codecov/vite-plugin": "^1.9.1", - "@furystack/rest": "^8.0.31", + "@furystack/rest": "^8.0.34", "@types/marked": "^6.0.0", "typescript": "^5.9.3", "vite": "^7.3.1" }, "dependencies": { - "@furystack/cache": "^5.0.25", - "@furystack/core": "^15.0.31", - "@furystack/inject": "^12.0.25", - "@furystack/logging": "^8.0.25", - "@furystack/rest-client-fetch": "^8.0.31", - "@furystack/shades": "^11.0.32", - "@furystack/shades-common-components": "^10.0.32", - "@furystack/shades-lottie": "^7.0.32", - "@furystack/utils": "^8.1.7", - "@types/node": "^25.0.3", + "@furystack/cache": "^5.0.28", + "@furystack/core": "^15.0.34", + "@furystack/inject": "^12.0.28", + "@furystack/logging": "^8.0.28", + "@furystack/rest-client-fetch": "^8.0.34", + "@furystack/shades": "^11.0.35", + "@furystack/shades-common-components": "^10.0.35", + "@furystack/shades-lottie": "^7.0.35", + "@furystack/utils": "^8.1.9", + "@types/node": "^25.0.10", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-web-links": "^0.12.0", diff --git a/frontend/src/components/role-tag/index.spec.tsx b/frontend/src/components/role-tag/index.spec.tsx new file mode 100644 index 00000000..7a7362ed --- /dev/null +++ b/frontend/src/components/role-tag/index.spec.tsx @@ -0,0 +1,264 @@ +import { Injector } from '@furystack/inject' +import { createComponent, initializeShadeRoot } from '@furystack/shades' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { RoleTag } from './index.js' + +describe('RoleTag', () => { + beforeEach(() => { + document.body.innerHTML = '
' + }) + + afterEach(() => { + document.body.innerHTML = '' + }) + + describe('rendering', () => { + it('should render with role display name', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const roleTag = document.querySelector('role-tag') + expect(roleTag).toBeTruthy() + expect(roleTag?.textContent).toContain('Application Admin') + }) + + it('should render media-manager role with correct display name', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const roleTag = document.querySelector('role-tag') + expect(roleTag?.textContent).toContain('Media Manager') + }) + + it('should render viewer role with correct display name', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const roleTag = document.querySelector('role-tag') + expect(roleTag?.textContent).toContain('Viewer') + }) + + it('should render iot-manager role with correct display name', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const roleTag = document.querySelector('role-tag') + expect(roleTag?.textContent).toContain('IoT Manager') + }) + + it('should have title attribute with role description', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const roleTag = document.querySelector('role-tag') + const span = roleTag?.querySelector('span') + expect(span?.getAttribute('title')).toBe('Full system access including user management and all settings') + }) + }) + + describe('variants', () => { + it('should render default variant without remove or restore buttons when no handlers provided', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const roleTag = document.querySelector('role-tag') + const buttons = roleTag?.querySelectorAll('button') + expect(buttons?.length).toBe(0) + }) + + it('should render default variant with remove button when onRemove is provided', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + const onRemove = vi.fn() + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const roleTag = document.querySelector('role-tag') + const removeButton = roleTag?.querySelector('button') + expect(removeButton).toBeTruthy() + expect(removeButton?.textContent).toContain('×') + expect(removeButton?.getAttribute('title')).toBe('Remove role') + }) + + it('should render added variant with remove button', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + const onRemove = vi.fn() + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const roleTag = document.querySelector('role-tag') + const removeButton = roleTag?.querySelector('button') + expect(removeButton).toBeTruthy() + expect(removeButton?.textContent).toContain('×') + }) + + it('should render removed variant with restore button', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + const onRestore = vi.fn() + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const roleTag = document.querySelector('role-tag') + const restoreButton = roleTag?.querySelector('button') + expect(restoreButton).toBeTruthy() + expect(restoreButton?.textContent).toContain('↩') + expect(restoreButton?.getAttribute('title')).toBe('Restore role') + }) + + it('should not render remove button for removed variant', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + const onRemove = vi.fn() + const onRestore = vi.fn() + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const roleTag = document.querySelector('role-tag') + const buttons = roleTag?.querySelectorAll('button') + // Should only have restore button, not remove button + expect(buttons?.length).toBe(1) + expect(buttons?.[0]?.textContent).toContain('↩') + }) + }) + + describe('interactions', () => { + it('should call onRemove when remove button is clicked', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + const onRemove = vi.fn() + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const roleTag = document.querySelector('role-tag') + const removeButton = roleTag?.querySelector('button') as HTMLButtonElement + removeButton.click() + + expect(onRemove).toHaveBeenCalledTimes(1) + }) + + it('should call onRestore when restore button is clicked', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + const onRestore = vi.fn() + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const roleTag = document.querySelector('role-tag') + const restoreButton = roleTag?.querySelector('button') as HTMLButtonElement + restoreButton.click() + + expect(onRestore).toHaveBeenCalledTimes(1) + }) + + it('should stop event propagation when remove button is clicked', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + const onRemove = vi.fn() + const parentClick = vi.fn() + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: ( +
+ +
+ ), + }) + + const roleTag = document.querySelector('role-tag') + const removeButton = roleTag?.querySelector('button') as HTMLButtonElement + removeButton.click() + + expect(onRemove).toHaveBeenCalledTimes(1) + expect(parentClick).not.toHaveBeenCalled() + }) + + it('should stop event propagation when restore button is clicked', () => { + const injector = new Injector() + const rootElement = document.getElementById('root') as HTMLDivElement + const onRestore = vi.fn() + const parentClick = vi.fn() + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: ( +
+ +
+ ), + }) + + const roleTag = document.querySelector('role-tag') + const restoreButton = roleTag?.querySelector('button') as HTMLButtonElement + restoreButton.click() + + expect(onRestore).toHaveBeenCalledTimes(1) + expect(parentClick).not.toHaveBeenCalled() + }) + }) +}) diff --git a/frontend/src/components/role-tag/index.tsx b/frontend/src/components/role-tag/index.tsx new file mode 100644 index 00000000..af7b4a95 --- /dev/null +++ b/frontend/src/components/role-tag/index.tsx @@ -0,0 +1,111 @@ +import { createComponent, Shade } from '@furystack/shades' +import type { Roles } from 'common' +import { getRoleDefinition } from 'common' + +type RoleTagProps = { + roleName: Roles[number] + variant: 'default' | 'added' | 'removed' + onRemove?: () => void + onRestore?: () => void +} + +export const RoleTag = Shade({ + shadowDomName: 'role-tag', + render: ({ props }) => { + const role = getRoleDefinition(props.roleName) + const baseStyle: Partial = { + display: 'inline-flex', + alignItems: 'center', + gap: '6px', + padding: '4px 12px', + borderRadius: '16px', + fontSize: '13px', + fontWeight: '500', + transition: 'all 0.2s ease', + } + + const variantStyles: Record> = { + default: { + backgroundColor: 'var(--theme-background-paper)', + border: '1px solid var(--theme-border-default)', + color: 'var(--theme-text-primary)', + }, + added: { + backgroundColor: 'rgba(76, 175, 80, 0.15)', + border: '1px solid var(--theme-success-main, #4caf50)', + color: 'var(--theme-success-dark, #2e7d32)', + }, + removed: { + backgroundColor: 'rgba(244, 67, 54, 0.15)', + border: '1px solid var(--theme-error-main, #f44336)', + color: 'var(--theme-error-dark, #c62828)', + textDecoration: 'line-through', + }, + } + + const buttonBaseStyle: Partial = { + background: 'none', + border: 'none', + cursor: 'pointer', + padding: '0', + margin: '0', + marginLeft: '4px', + fontSize: '14px', + lineHeight: '1', + opacity: '0.7', + transition: 'opacity 0.2s ease', + } + + const style = { ...baseStyle, ...variantStyles[props.variant] } + + return ( + + {role.displayName} + {props.variant === 'removed' && props.onRestore && ( + + )} + {props.variant !== 'removed' && props.onRemove && ( + + )} + + ) + }, +}) diff --git a/frontend/src/pages/admin/app-settings.tsx b/frontend/src/pages/admin/app-settings.tsx index ff1856f8..65f8e1f9 100644 --- a/frontend/src/pages/admin/app-settings.tsx +++ b/frontend/src/pages/admin/app-settings.tsx @@ -1,4 +1,5 @@ import { createComponent, LocationService, Router, Shade } from '@furystack/shades' +import type { MatchResult } from 'path-to-regexp' import { PiRatLazyLoad } from '../../components/pirat-lazy-load.js' import { SettingsMenuItem, SettingsMenuSection, SettingsSidebar } from '../../components/settings-sidebar/index.js' @@ -47,6 +48,28 @@ const settingsRoutes = [ /> ), }, + { + url: '/app-settings/users/:username', + component: ({ match }: { match: MatchResult<{ username: string }> }) => ( + { + const { UserDetailsPage } = await import('./user-details.js') + return + }} + /> + ), + }, + { + url: '/app-settings/users', + component: () => ( + { + const { UserListPage } = await import('./user-list.js') + return + }} + /> + ), + }, { url: '/app-settings', component: () => ( @@ -103,6 +126,9 @@ export const AppSettingsPage = Shade({ + + +
{ + const allButtons = Array.from(page?.querySelectorAll('button') ?? []) + // Filter out buttons that are inside role-tag elements + return allButtons.filter((btn) => !btn.closest('role-tag')) +} + +describe('UserDetailsPage', () => { + let injector: Injector + let mockUsersService: { + getUserAsObservable: ReturnType + updateUser: ReturnType + } + let mockNotyService: { + emit: ReturnType + } + let userObservable: ObservableValue> + + beforeEach(() => { + document.body.innerHTML = '
' + + userObservable = new ObservableValue>({ + status: 'loaded', + value: createMockUser('testuser@example.com', ['admin']), + updatedAt: new Date(), + }) + + mockUsersService = { + getUserAsObservable: vi.fn().mockReturnValue(userObservable), + updateUser: vi.fn().mockResolvedValue(createMockUser('testuser@example.com', ['admin', 'viewer'])), + } + + mockNotyService = { + emit: vi.fn(), + } + + injector = new Injector() + injector.setExplicitInstance(mockUsersService as unknown as UsersService, UsersService) + injector.setExplicitInstance(mockNotyService as unknown as NotyService, NotyService) + }) + + afterEach(() => { + document.body.innerHTML = '' + }) + + describe('rendering', () => { + it('should render the user details page with header', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + expect(page).toBeTruthy() + expect(page?.textContent).toContain('User Details') + }) + + it('should display loading state', () => { + userObservable.setValue({ status: 'loading' }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + expect(page?.textContent).toContain('Loading user...') + }) + + it('should display error state with go back button', () => { + userObservable.setValue({ + status: 'failed', + error: new Error('User not found'), + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + expect(page?.textContent).toContain('Error: User not found') + // Go Back button exists - Button component renders as button element + const buttons = page?.querySelectorAll('button') + // Should have Back button and Go Back button + expect(buttons?.length).toBeGreaterThanOrEqual(1) + }) + + it('should display fallback error message for non-Error objects', () => { + userObservable.setValue({ + status: 'failed', + error: 'string error', + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + expect(page?.textContent).toContain('Failed to load user') + }) + }) + + describe('user information display', () => { + it('should display username', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + expect(page?.textContent).toContain('Username:') + expect(page?.textContent).toContain('testuser@example.com') + }) + + it('should display created date', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + expect(page?.textContent).toContain('Created:') + }) + + it('should display last updated date', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + expect(page?.textContent).toContain('Last Updated:') + }) + }) + + describe('roles section', () => { + it('should display roles section header', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + expect(page?.textContent).toContain('Roles') + }) + + it('should render role tags for user roles', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + const roleTags = page?.querySelectorAll('role-tag') + + expect(roleTags?.length).toBe(1) + }) + + it('should render multiple role tags for user with multiple roles', () => { + userObservable.setValue({ + status: 'loaded', + value: createMockUser('testuser@example.com', ['admin', 'viewer', 'media-manager']), + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + const roleTags = page?.querySelectorAll('role-tag') + + expect(roleTags?.length).toBe(3) + }) + + it('should display "No roles assigned" for user without roles', () => { + userObservable.setValue({ + status: 'loaded', + value: createMockUser('testuser@example.com', []), + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + expect(page?.textContent).toContain('No roles assigned') + }) + }) + + describe('add role dropdown', () => { + it('should display Add Role dropdown', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + expect(page?.textContent).toContain('Add Role:') + + const select = page?.querySelector('select') + expect(select).toBeTruthy() + }) + + it('should show available roles that user does not have', () => { + userObservable.setValue({ + status: 'loaded', + value: createMockUser('testuser@example.com', ['admin']), + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + const select = page?.querySelector('select') + const options = select?.querySelectorAll('option') + + // Should have placeholder + 3 available roles (viewer, media-manager, iot-manager) + expect(options?.length).toBe(4) + expect(select?.textContent).toContain('Select a role to add...') + expect(select?.textContent).toContain('Viewer') + expect(select?.textContent).toContain('Media Manager') + expect(select?.textContent).toContain('IoT Manager') + expect(select?.textContent).not.toContain('Application Admin') + }) + + it('should not show dropdown when user has all roles', () => { + userObservable.setValue({ + status: 'loaded', + value: createMockUser('testuser@example.com', ['admin', 'viewer', 'media-manager', 'iot-manager']), + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + const select = page?.querySelector('select') + expect(select).toBeFalsy() + }) + }) + + describe('action buttons', () => { + it('should render action buttons', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + // Back button, Save Changes button, and Cancel button + const buttons = page?.querySelectorAll('button') + expect(buttons?.length).toBeGreaterThanOrEqual(3) + }) + + it('should have Save Changes and Cancel buttons disabled when no changes', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + // Button component sets disabled attribute on the button element + const disabledButtons = page?.querySelectorAll('button[disabled]') + // Save and Cancel buttons should be disabled (Back is always enabled) + expect(disabledButtons?.length).toBe(2) + }) + }) + + describe('navigation', () => { + it('should navigate back to user list when Back button is clicked', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + const locationService = injector.getInstance(LocationService) + const updateStateSpy = vi.spyOn(locationService, 'updateState') + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + // Back button is the first button element in the page + const backButton = page?.querySelector('button') as HTMLButtonElement + + backButton.click() + + expect(window.location.pathname).toBe('/app-settings/users') + expect(updateStateSpy).toHaveBeenCalled() + }) + }) + + describe('role editing', () => { + it('should add role when selected from dropdown', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + const select = page?.querySelector('select') as HTMLSelectElement + + // Simulate selecting a role + select.value = 'viewer' + select.dispatchEvent(new Event('change', { bubbles: true })) + + // Should now have 2 role tags (admin + viewer) + const roleTags = page?.querySelectorAll('role-tag') + expect(roleTags?.length).toBe(2) + + // No disabled buttons anymore (Save and Cancel are enabled) + const disabledButtons = page?.querySelectorAll('button[disabled]') + expect(disabledButtons?.length).toBe(0) + }) + + it('should remove role when remove button is clicked', async () => { + userObservable.setValue({ + status: 'loaded', + value: createMockUser('testuser@example.com', ['admin', 'viewer']), + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + + // Find and click the remove button on a role tag + const roleTag = page?.querySelector('role-tag') + const removeButton = roleTag?.querySelector('button') + removeButton?.click() + + // Wait for re-render + await new Promise((resolve) => setTimeout(resolve, 10)) + + // No disabled buttons (Save and Cancel are enabled) + const disabledButtons = page?.querySelectorAll('button[disabled]') + expect(disabledButtons?.length).toBe(0) + }) + + it('should cancel changes when Cancel button is clicked', async () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + const select = page?.querySelector('select') as HTMLSelectElement + + // Add a role + select.value = 'viewer' + select.dispatchEvent(new Event('change', { bubbles: true })) + + // Wait for state update + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Get action buttons (excluding role-tag buttons): Back (0), Save (1), Cancel (2) + const allButtons = Array.from(page?.querySelectorAll('button') ?? []) + const actionButtons = allButtons.filter((btn) => !btn.closest('role-tag')) + // Cancel is the last action button + const cancelButton = actionButtons[actionButtons.length - 1] + cancelButton.click() + + // Wait for re-render + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Should be back to original state (1 role tag) + const roleTags = page?.querySelectorAll('role-tag') + expect(roleTags?.length).toBe(1) + + // Save and Cancel buttons should be disabled again + const disabledButtons = page?.querySelectorAll('button[disabled]') + expect(disabledButtons?.length).toBe(2) + }) + }) + + describe('save functionality', () => { + it('should call updateUser when Save Changes is clicked', async () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + const select = page?.querySelector('select') as HTMLSelectElement + + // Add a role + select.value = 'viewer' + select.dispatchEvent(new Event('change', { bubbles: true })) + + // Wait for state update + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Get action buttons (excluding role-tag buttons): Back (0), Save (1), Cancel (2) + const actionButtons = getActionButtons(page) + const saveButton = actionButtons[1] + saveButton.click() + + // Wait for async save + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(mockUsersService.updateUser).toHaveBeenCalledWith('testuser@example.com', { + username: 'testuser@example.com', + roles: ['admin', 'viewer'], + }) + }) + + it('should show success notification after save', async () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + const select = page?.querySelector('select') as HTMLSelectElement + + // Add a role + select.value = 'viewer' + select.dispatchEvent(new Event('change', { bubbles: true })) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Click Save + const actionButtons = getActionButtons(page) + const saveButton = actionButtons[1] + saveButton.click() + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(mockNotyService.emit).toHaveBeenCalledWith('onNotyAdded', { + title: 'Success', + body: 'User roles updated successfully', + type: 'success', + }) + }) + + it('should show error notification on save failure', async () => { + mockUsersService.updateUser.mockRejectedValueOnce(new Error('Network error')) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + const select = page?.querySelector('select') as HTMLSelectElement + + // Add a role + select.value = 'viewer' + select.dispatchEvent(new Event('change', { bubbles: true })) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Click Save + const actionButtons = getActionButtons(page) + const saveButton = actionButtons[1] + saveButton.click() + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(mockNotyService.emit).toHaveBeenCalledWith('onNotyAdded', { + title: 'Error', + body: 'Network error', + type: 'error', + }) + }) + + it('should disable buttons while saving', async () => { + // Make updateUser slow - need to delay to check the saving state + // eslint-disable-next-line @typescript-eslint/no-misused-promises + mockUsersService.updateUser.mockImplementation(() => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(createMockUser()) + }, 100) + }) + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + const select = page?.querySelector('select') as HTMLSelectElement + + // Add a role + select.value = 'viewer' + select.dispatchEvent(new Event('change', { bubbles: true })) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Verify buttons are enabled before save + const actionButtonsBefore = getActionButtons(page) + expect(actionButtonsBefore[1]?.disabled).toBe(false) // Save + expect(actionButtonsBefore[2]?.disabled).toBe(false) // Cancel + + // Click Save + actionButtonsBefore[1]?.click() + + // Wait a bit for state to update + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Both Save and Cancel should be disabled while saving + const actionButtonsAfter = getActionButtons(page) + expect(actionButtonsAfter[1]?.disabled).toBe(true) // Save + expect(actionButtonsAfter[2]?.disabled).toBe(true) // Cancel + }) + }) + + describe('validation', () => { + it('should show validation error when trying to save with no roles', async () => { + userObservable.setValue({ + status: 'loaded', + value: createMockUser('testuser@example.com', ['viewer']), + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-details-page') + + // Remove the only role + const roleTag = page?.querySelector('role-tag') + const removeButton = roleTag?.querySelector('button') + removeButton?.click() + + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Click Save (index 1 of action buttons, excluding role-tag buttons) + const actionButtons = getActionButtons(page) + const saveButton = actionButtons[1] + saveButton.click() + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(page?.textContent).toContain('User must have at least one role') + expect(mockUsersService.updateUser).not.toHaveBeenCalled() + }) + }) + + describe('service integration', () => { + it('should call getUserAsObservable with username on render', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + expect(mockUsersService.getUserAsObservable).toHaveBeenCalledWith('testuser@example.com') + }) + }) +}) diff --git a/frontend/src/pages/admin/user-details.tsx b/frontend/src/pages/admin/user-details.tsx new file mode 100644 index 00000000..b8f92725 --- /dev/null +++ b/frontend/src/pages/admin/user-details.tsx @@ -0,0 +1,352 @@ +import { hasCacheValue, isFailedCacheResult } from '@furystack/cache' +import { createComponent, LocationService, Shade } from '@furystack/shades' +import { Button, NotyService, Paper } from '@furystack/shades-common-components' +import { ObservableValue } from '@furystack/utils' +import type { Roles } from 'common' +import { getAllRoleDefinitions } from 'common' +import { RoleTag } from '../../components/role-tag/index.js' +import { UsersService } from '../../services/users-service.js' + +type UserDetailsPageProps = { + username: string +} + +type RoleChange = { + originalRoles: Roles + currentRoles: Roles +} + +export const UserDetailsPage = Shade({ + shadowDomName: 'user-details-page', + render: ({ props, injector, useObservable, useDisposable }) => { + const usersService = injector.getInstance(UsersService) + const locationService = injector.getInstance(LocationService) + const notyService = injector.getInstance(NotyService) + + const { username } = props + + const [userState] = useObservable('user', usersService.getUserAsObservable(username)) + + const roleChangeObservable = useDisposable('roleChange', () => new ObservableValue(null)) + const [roleChange] = useObservable('roleChangeValue', roleChangeObservable) + + const isSavingObservable = useDisposable('isSaving', () => new ObservableValue(false)) + const [isSaving] = useObservable('isSavingValue', isSavingObservable) + + const validationErrorObservable = useDisposable('validationError', () => new ObservableValue(null)) + const [validationError] = useObservable('validationErrorValue', validationErrorObservable) + + // Track if role state has been initialized to prevent re-initialization during render cycles + const isRoleStateInitialized = useDisposable('isRoleStateInitialized', () => new ObservableValue(false)) + + // Initialize role change state when user is loaded (check synchronously to avoid race conditions) + if (userState.status === 'loaded' && !isRoleStateInitialized.getValue()) { + isRoleStateInitialized.setValue(true) + roleChangeObservable.setValue({ + originalRoles: [...userState.value.roles], + currentRoles: [...userState.value.roles], + }) + } + + const navigateBack = () => { + window.history.pushState({}, '', '/app-settings/users') + locationService.updateState() + } + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + } + + const getErrorMessage = (error: unknown): string => { + if (error instanceof Error) return error.message + return 'Failed to load user' + } + + const addRole = (roleName: Roles[number]) => { + if (userState.status !== 'loaded') return + const current = roleChange?.currentRoles ?? userState.value.roles + const original = roleChange?.originalRoles ?? userState.value.roles + if (current.includes(roleName)) return + roleChangeObservable.setValue({ + originalRoles: [...original], + currentRoles: [...current, roleName], + }) + validationErrorObservable.setValue(null) + } + + const removeRole = (roleName: Roles[number]) => { + if (userState.status !== 'loaded') return + const current = roleChange?.currentRoles ?? userState.value.roles + const original = roleChange?.originalRoles ?? userState.value.roles + roleChangeObservable.setValue({ + originalRoles: [...original], + currentRoles: current.filter((r) => r !== roleName), + }) + } + + const restoreRole = (roleName: Roles[number]) => { + if (userState.status !== 'loaded') return + const current = roleChange?.currentRoles ?? userState.value.roles + const original = roleChange?.originalRoles ?? userState.value.roles + if (current.includes(roleName)) return + roleChangeObservable.setValue({ + originalRoles: [...original], + currentRoles: [...current, roleName], + }) + validationErrorObservable.setValue(null) + } + + const getRoleVariant = (roleName: Roles[number]): 'default' | 'added' | 'removed' => { + if (userState.status !== 'loaded') return 'default' + const original = roleChange?.originalRoles ?? userState.value.roles + const current = roleChange?.currentRoles ?? userState.value.roles + const isInOriginal = original.includes(roleName) + const isInCurrent = current.includes(roleName) + + if (isInOriginal && isInCurrent) return 'default' + if (!isInOriginal && isInCurrent) return 'added' + if (isInOriginal && !isInCurrent) return 'removed' + return 'default' + } + + const hasChanges = () => { + if (userState.status !== 'loaded') return false + const original = roleChange?.originalRoles ?? userState.value.roles + const current = roleChange?.currentRoles ?? userState.value.roles + const originalSorted = [...original].sort() + const currentSorted = [...current].sort() + if (originalSorted.length !== currentSorted.length) return true + return originalSorted.some((role, idx) => role !== currentSorted[idx]) + } + + const handleSave = async () => { + if (userState.status !== 'loaded') return + + const current = roleChange?.currentRoles ?? userState.value.roles + + // Validation + if (current.length === 0) { + validationErrorObservable.setValue('User must have at least one role') + return + } + + isSavingObservable.setValue(true) + validationErrorObservable.setValue(null) + + try { + await usersService.updateUser(username, { + username: userState.value.username, + roles: current, + }) + + notyService.emit('onNotyAdded', { + title: 'Success', + body: 'User roles updated successfully', + type: 'success', + }) + + // Update the original roles to reflect saved state (guard against disposal during async operation) + if (!roleChangeObservable.isDisposed) { + roleChangeObservable.setValue({ + originalRoles: [...current], + currentRoles: [...current], + }) + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to save user' + notyService.emit('onNotyAdded', { + title: 'Error', + body: errorMessage, + type: 'error', + }) + } finally { + // Guard against disposal during async operation (component may have unmounted) + if (!isSavingObservable.isDisposed) { + isSavingObservable.setValue(false) + } + } + } + + const handleCancel = () => { + if (roleChange) { + roleChangeObservable.setValue({ + ...roleChange, + currentRoles: [...roleChange.originalRoles], + }) + validationErrorObservable.setValue(null) + } + } + + const allRoles = getAllRoleDefinitions() + + // Get current roles from roleChange if available, otherwise from userState + const currentRoles = roleChange?.currentRoles ?? (userState.status === 'loaded' ? userState.value.roles : []) + const originalRoles = roleChange?.originalRoles ?? (userState.status === 'loaded' ? userState.value.roles : []) + + const availableRolesToAdd = allRoles.filter((role) => !currentRoles.includes(role.name)) + + // Get all roles to display (current + removed) + const rolesToDisplay = [ + ...new Set([ + ...originalRoles, // Include original roles (may be removed) + ...currentRoles, // Include current roles (may be added) + ]), + ] + + return ( +
+
+ +

User Details

+
+ + {(userState.status === 'loading' || + userState.status === 'uninitialized' || + userState.status === 'obsolete') && ( + +

Loading user...

+
+ )} + + {isFailedCacheResult(userState) && ( + +

Error: {getErrorMessage(userState.error)}

+ +
+ )} + + {hasCacheValue(userState) && ( + <> + +

+ User Information +

+ +
+ Username: + {userState.value.username} + + Created: + {formatDate(userState.value.createdAt)} + + Last Updated: + {formatDate(userState.value.updatedAt)} +
+
+ + +

Roles

+ +
+
+ {rolesToDisplay.length > 0 ? ( + rolesToDisplay.map((roleName) => { + const variant = getRoleVariant(roleName) + return ( + removeRole(roleName) : undefined} + onRestore={variant === 'removed' ? () => restoreRole(roleName) : undefined} + /> + ) + }) + ) : ( + No roles assigned + )} +
+
+ + {availableRolesToAdd.length > 0 && ( +
+ + +
+ )} + + {validationError && ( +
+ {validationError} +
+ )} + +
+ + +
+
+ + )} +
+ ) + }, +}) diff --git a/frontend/src/pages/admin/user-list.spec.tsx b/frontend/src/pages/admin/user-list.spec.tsx new file mode 100644 index 00000000..b90c934a --- /dev/null +++ b/frontend/src/pages/admin/user-list.spec.tsx @@ -0,0 +1,356 @@ +import { Injector } from '@furystack/inject' +import { createComponent, initializeShadeRoot, LocationService } from '@furystack/shades' +import { ObservableValue } from '@furystack/utils' +import type { User } from 'common' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { type CacheState, createMockUser } from '../../test-utils/user-test-helpers.js' +import { UsersService } from '../../services/users-service.js' +import { UserListPage } from './user-list.js' + +describe('UserListPage', () => { + let injector: Injector + let mockUsersService: { + findUsersAsObservable: ReturnType + findUsers: ReturnType + userQueryCache: { flushAll: ReturnType } + } + let usersObservable: ObservableValue> + + beforeEach(() => { + document.body.innerHTML = '
' + + usersObservable = new ObservableValue>({ + status: 'loaded', + value: { + count: 2, + entries: [createMockUser('user1@example.com', ['admin']), createMockUser('user2@example.com', ['viewer'])], + }, + updatedAt: new Date(), + }) + + mockUsersService = { + findUsersAsObservable: vi.fn().mockReturnValue(usersObservable), + findUsers: vi.fn().mockResolvedValue({ + count: 2, + entries: [createMockUser('user1@example.com', ['admin']), createMockUser('user2@example.com', ['viewer'])], + }), + userQueryCache: { flushAll: vi.fn() }, + } + + injector = new Injector() + injector.setExplicitInstance(mockUsersService as unknown as UsersService, UsersService) + }) + + afterEach(() => { + document.body.innerHTML = '' + }) + + describe('rendering', () => { + it('should render the user list page with header', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + expect(page).toBeTruthy() + expect(page?.textContent).toContain('👥 Users') + expect(page?.textContent).toContain('Manage user accounts and their roles.') + }) + + it('should display loading state', () => { + usersObservable.setValue({ status: 'loading' }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + expect(page?.textContent).toContain('Loading users...') + }) + + it('should display uninitialized state as loading', () => { + usersObservable.setValue({ status: 'uninitialized' }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + expect(page?.textContent).toContain('Loading users...') + }) + + it('should display error state with retry button', () => { + usersObservable.setValue({ + status: 'failed', + error: new Error('Network error'), + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + expect(page?.textContent).toContain('Error: Network error') + + // Button component renders a button element with content in shadow DOM + const retryButton = page?.querySelector('button') + expect(retryButton).toBeTruthy() + }) + + it('should display error message for non-Error objects', () => { + usersObservable.setValue({ + status: 'failed', + error: 'string error', + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + expect(page?.textContent).toContain('Failed to load users') + }) + }) + + describe('table display', () => { + it('should render table with headers', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + const headers = page?.querySelectorAll('th') + + expect(headers?.length).toBe(4) + expect(headers?.[0]?.textContent).toContain('Username') + expect(headers?.[1]?.textContent).toContain('Roles') + expect(headers?.[2]?.textContent).toContain('Created') + expect(headers?.[3]?.textContent).toContain('Actions') + }) + + it('should render users in table rows', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + const rows = page?.querySelectorAll('tbody tr') + + expect(rows?.length).toBe(2) + expect(rows?.[0]?.textContent).toContain('user1@example.com') + expect(rows?.[1]?.textContent).toContain('user2@example.com') + }) + + it('should render role tags for each user', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + const roleTags = page?.querySelectorAll('role-tag') + + expect(roleTags?.length).toBe(2) // One for admin, one for viewer + }) + + it('should display "No roles" message for user without roles', () => { + usersObservable.setValue({ + status: 'loaded', + value: { + count: 1, + entries: [createMockUser('noRoles@example.com', [])], + }, + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + expect(page?.textContent).toContain('No roles') + }) + + it('should display empty state when no users exist', () => { + usersObservable.setValue({ + status: 'loaded', + value: { + count: 0, + entries: [], + }, + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + expect(page?.textContent).toContain('No users found.') + }) + + it('should render Edit button for each user', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + // Button component renders button elements within table rows + const editButtons = page?.querySelectorAll('tbody button') + + expect(editButtons?.length).toBe(2) + // Verify buttons exist (content is in shadow DOM) + expect(editButtons?.[0]).toBeTruthy() + expect(editButtons?.[1]).toBeTruthy() + }) + }) + + describe('navigation', () => { + it('should navigate to user details when Edit button is clicked', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + const locationService = injector.getInstance(LocationService) + const updateStateSpy = vi.spyOn(locationService, 'updateState') + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + const editButton = page?.querySelector('tbody button') as HTMLButtonElement + editButton.click() + + expect(window.location.pathname).toBe('/app-settings/users/user1%40example.com') + expect(updateStateSpy).toHaveBeenCalled() + }) + + it('should navigate to user details when table row is clicked', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + const locationService = injector.getInstance(LocationService) + const updateStateSpy = vi.spyOn(locationService, 'updateState') + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + const row = page?.querySelector('tbody tr') as HTMLTableRowElement + row.click() + + expect(window.location.pathname).toBe('/app-settings/users/user1%40example.com') + expect(updateStateSpy).toHaveBeenCalled() + }) + + it('should encode username in URL to handle special characters', () => { + usersObservable.setValue({ + status: 'loaded', + value: { + count: 1, + entries: [createMockUser('user+special@example.com', ['admin'])], + }, + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + const row = page?.querySelector('tbody tr') as HTMLTableRowElement + row.click() + + expect(window.location.pathname).toBe('/app-settings/users/user%2Bspecial%40example.com') + }) + }) + + describe('retry functionality', () => { + it('should flush cache and refetch when retry is clicked', async () => { + usersObservable.setValue({ + status: 'failed', + error: new Error('Network error'), + updatedAt: new Date(), + }) + + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + const page = document.querySelector('user-list-page') + const retryButton = page?.querySelector('button') as HTMLButtonElement + retryButton.click() + + expect(mockUsersService.userQueryCache.flushAll).toHaveBeenCalled() + expect(mockUsersService.findUsers).toHaveBeenCalledWith({}) + }) + }) + + describe('service integration', () => { + it('should call findUsersAsObservable on render', () => { + const rootElement = document.getElementById('root') as HTMLDivElement + + initializeShadeRoot({ + injector, + rootElement, + jsxElement: , + }) + + expect(mockUsersService.findUsersAsObservable).toHaveBeenCalledWith({}) + }) + }) +}) diff --git a/frontend/src/pages/admin/user-list.tsx b/frontend/src/pages/admin/user-list.tsx new file mode 100644 index 00000000..c04ee312 --- /dev/null +++ b/frontend/src/pages/admin/user-list.tsx @@ -0,0 +1,200 @@ +import { createComponent, LocationService, Shade } from '@furystack/shades' +import { Button, Paper } from '@furystack/shades-common-components' +import { RoleTag } from '../../components/role-tag/index.js' +import { UsersService } from '../../services/users-service.js' + +type UserListPageProps = Record + +export const UserListPage = Shade({ + shadowDomName: 'user-list-page', + render: ({ injector, useObservable }) => { + const usersService = injector.getInstance(UsersService) + const locationService = injector.getInstance(LocationService) + + const [usersState] = useObservable('users', usersService.findUsersAsObservable({})) + + const navigateToUser = (username: string) => { + window.history.pushState({}, '', `/app-settings/users/${encodeURIComponent(username)}`) + locationService.updateState() + } + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + } + + const handleRetry = () => { + usersService.userQueryCache.flushAll() + void usersService.findUsers({}) + } + + const getErrorMessage = (error: unknown): string => { + if (error instanceof Error) return error.message + return 'Failed to load users' + } + + return ( +
+

👥 Users

+

+ Manage user accounts and their roles. +

+ + {(usersState.status === 'loading' || + usersState.status === 'uninitialized' || + usersState.status === 'obsolete') && ( + +

Loading users...

+
+ )} + + {usersState.status === 'failed' && ( + +

Error: {getErrorMessage(usersState.error)}

+ +
+ )} + + {usersState.status === 'loaded' && ( + + + + + + + + + + + + {usersState.value.entries.map((user) => ( + navigateToUser(user.username)} + onmouseenter={(e) => { + ;(e.currentTarget as HTMLTableRowElement).style.backgroundColor = + 'var(--theme-background-default)' + }} + onmouseleave={(e) => { + ;(e.currentTarget as HTMLTableRowElement).style.backgroundColor = '' + }} + > + + + + + + ))} + {usersState.value.entries.length === 0 && ( + + + + )} + +
+ Username + + Roles + + Created + + Actions +
+ {user.username} + +
+ {user.roles.length > 0 ? ( + user.roles.map((roleName) => ) + ) : ( + No roles + )} +
+
+ {formatDate(user.createdAt)} + + +
+ No users found. +
+
+ )} +
+ ) + }, +}) diff --git a/frontend/src/services/users-service.spec.ts b/frontend/src/services/users-service.spec.ts new file mode 100644 index 00000000..4041d588 --- /dev/null +++ b/frontend/src/services/users-service.spec.ts @@ -0,0 +1,447 @@ +import { Injector } from '@furystack/inject' +import { usingAsync } from '@furystack/utils' +import type { User } from 'common' +import { describe, expect, it, vi } from 'vitest' +import { createMockUser } from '../test-utils/user-test-helpers.js' +import { UsersService } from './users-service.js' +import { IdentityApiClient } from './api-clients/identity-api-client.js' + +describe('UsersService', () => { + const createTestInjector = (mockCall: ReturnType) => { + const injector = new Injector() + injector.setExplicitInstance( + { + call: mockCall, + } as unknown as IdentityApiClient, + IdentityApiClient, + ) + return injector + } + + describe('getUser', () => { + it('should fetch a user by username', async () => { + const mockUser = createMockUser() + const mockCall = vi.fn().mockResolvedValue({ result: mockUser }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + const result = await service.getUser('testuser@example.com') + + expect(mockCall).toHaveBeenCalledWith({ + method: 'GET', + action: '/users/:id', + url: { id: 'testuser@example.com' }, + query: {}, + }) + expect(result).toEqual(mockUser) + }) + }) + + it('should cache user results', async () => { + const mockUser = createMockUser() + const mockCall = vi.fn().mockResolvedValue({ result: mockUser }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + await service.getUser('testuser@example.com') + await service.getUser('testuser@example.com') + + expect(mockCall).toHaveBeenCalledTimes(1) + }) + }) + + it('should handle API errors (404)', async () => { + const mockCall = vi.fn().mockRejectedValue(new Error('User not found')) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + await expect(service.getUser('nonexistent@example.com')).rejects.toThrow('User not found') + }) + }) + + it('should handle server errors (500)', async () => { + const mockCall = vi.fn().mockRejectedValue(new Error('Internal server error')) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + await expect(service.getUser('testuser@example.com')).rejects.toThrow('Internal server error') + }) + }) + }) + + describe('getUserAsObservable', () => { + it('should return an observable for user', async () => { + const mockUser = createMockUser() + const mockCall = vi.fn().mockResolvedValue({ result: mockUser }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + const observable = service.getUserAsObservable('testuser@example.com') + + expect(observable).toBeDefined() + expect(observable.getValue().status).toBe('uninitialized') + }) + }) + + it('should share the same observable for the same username', async () => { + const mockUser = createMockUser() + const mockCall = vi.fn().mockResolvedValue({ result: mockUser }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + const observable1 = service.getUserAsObservable('testuser@example.com') + const observable2 = service.getUserAsObservable('testuser@example.com') + + expect(observable1).toBe(observable2) + }) + }) + }) + + describe('findUsers', () => { + it('should find users with query options', async () => { + const mockUsers = { + count: 2, + entries: [createMockUser('user1@example.com'), createMockUser('user2@example.com')], + } + const mockCall = vi.fn().mockResolvedValue({ result: mockUsers }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + const findOptions = { top: 10 } + const result = await service.findUsers(findOptions) + + expect(mockCall).toHaveBeenCalledWith({ + method: 'GET', + action: '/users', + query: { + findOptions, + }, + }) + expect(result).toEqual(mockUsers) + }) + }) + + it('should cache query results', async () => { + const mockUsers = { + count: 1, + entries: [createMockUser()], + } + const mockCall = vi.fn().mockResolvedValue({ result: mockUsers }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + const findOptions = { top: 10 } + await service.findUsers(findOptions) + await service.findUsers(findOptions) + + expect(mockCall).toHaveBeenCalledTimes(1) + }) + }) + + it('should pre-populate individual user cache from query results', async () => { + const user1 = createMockUser('user1@example.com') + const user2 = createMockUser('user2@example.com') + const mockUsers = { + count: 2, + entries: [user1, user2], + } + const mockCall = vi.fn().mockResolvedValue({ result: mockUsers }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + await service.findUsers({ top: 10 }) + + const result = await service.getUser('user1@example.com') + + expect(mockCall).toHaveBeenCalledTimes(1) + expect(result).toEqual(user1) + }) + }) + }) + + describe('findUsersAsObservable', () => { + it('should return an observable for user query', async () => { + const mockUsers = { + count: 1, + entries: [createMockUser()], + } + const mockCall = vi.fn().mockResolvedValue({ result: mockUsers }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + const observable = service.findUsersAsObservable({ top: 10 }) + + expect(observable).toBeDefined() + expect(observable.getValue().status).toBe('uninitialized') + }) + }) + + it('should share the same observable for the same query options', async () => { + const mockUsers = { + count: 1, + entries: [createMockUser()], + } + const mockCall = vi.fn().mockResolvedValue({ result: mockUsers }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + const findOptions = { top: 10 } + const observable1 = service.findUsersAsObservable(findOptions) + const observable2 = service.findUsersAsObservable(findOptions) + + expect(observable1).toBe(observable2) + }) + }) + }) + + describe('updateUser', () => { + it('should update user roles', async () => { + const updatedUser = createMockUser('testuser@example.com', ['admin', 'viewer']) + const reloadedUser = createMockUser('testuser@example.com', ['admin', 'viewer']) + const mockCall = vi + .fn() + .mockResolvedValueOnce({ result: updatedUser }) // 1st: updateUser PATCH + .mockResolvedValueOnce({ result: reloadedUser }) // 2nd: reload triggered by updateUser + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + const body = { + username: 'testuser@example.com', + roles: ['admin', 'viewer'] as User['roles'], + } + const result = await service.updateUser('testuser@example.com', body) + + expect(mockCall).toHaveBeenCalledWith({ + method: 'PATCH', + action: '/users/:id', + url: { id: 'testuser@example.com' }, + body, + }) + expect(result).toEqual(updatedUser) + + // Wait for the async reload triggered by updateUser to complete + await vi.waitFor(() => { + expect(mockCall).toHaveBeenCalledTimes(2) + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + }) + + it('should invalidate cache after update', async () => { + const originalUser = createMockUser('testuser@example.com', ['admin']) + const updatedUser = createMockUser('testuser@example.com', ['admin', 'viewer']) + const reloadedUser = createMockUser('testuser@example.com', ['admin', 'viewer']) + const mockCall = vi + .fn() + .mockResolvedValueOnce({ result: originalUser }) // 1st: initial getUser + .mockResolvedValueOnce({ result: updatedUser }) // 2nd: updateUser PATCH + .mockResolvedValueOnce({ result: reloadedUser }) // 3rd: reload triggered by updateUser + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + await service.getUser('testuser@example.com') + + const body = { + username: 'testuser@example.com', + roles: ['admin', 'viewer'] as User['roles'], + } + await service.updateUser('testuser@example.com', body) + + // Wait for the async reload triggered by updateUser to complete + await vi.waitFor(() => { + expect(mockCall).toHaveBeenCalledTimes(3) + }) + // Flush pending microtasks to ensure reload fully completes + await new Promise((resolve) => setTimeout(resolve, 0)) + + // The reload already populated the cache with fresh data + const result = await service.getUser('testuser@example.com') + + // No additional API call needed - data comes from reload cache + expect(mockCall).toHaveBeenCalledTimes(3) + expect(result).toEqual(reloadedUser) + }) + }) + + it('should flush query cache after update', async () => { + const mockUsers = { + count: 1, + entries: [createMockUser('testuser@example.com', ['admin'])], + } + const updatedUser = createMockUser('testuser@example.com', ['admin', 'viewer']) + const reloadedUser = createMockUser('testuser@example.com', ['admin', 'viewer']) + const updatedMockUsers = { + count: 1, + entries: [updatedUser], + } + const mockCall = vi + .fn() + .mockResolvedValueOnce({ result: mockUsers }) // 1st: findUsers + .mockResolvedValueOnce({ result: updatedUser }) // 2nd: updateUser PATCH + .mockResolvedValueOnce({ result: reloadedUser }) // 3rd: reload triggered by updateUser + .mockResolvedValueOnce({ result: updatedMockUsers }) // 4th: findUsers after flush + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + await service.findUsers({ top: 10 }) + + await service.updateUser('testuser@example.com', { + username: 'testuser@example.com', + roles: ['admin', 'viewer'], + }) + + // Wait for the async reload triggered by updateUser to complete + await vi.waitFor(() => { + expect(mockCall).toHaveBeenCalledTimes(3) + }) + // Flush pending microtasks to ensure reload fully completes + await new Promise((resolve) => setTimeout(resolve, 0)) + + await service.findUsers({ top: 10 }) + + expect(mockCall).toHaveBeenCalledTimes(4) + }) + }) + + it('should handle validation errors (400)', async () => { + const mockCall = vi.fn().mockRejectedValue(new Error('Invalid user data')) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + await expect( + service.updateUser('testuser@example.com', { + username: 'testuser@example.com', + roles: [], + }), + ).rejects.toThrow('Invalid user data') + }) + }) + + it('should handle server errors (500)', async () => { + const mockCall = vi.fn().mockRejectedValue(new Error('Internal server error')) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + await expect( + service.updateUser('testuser@example.com', { + username: 'testuser@example.com', + roles: ['admin'], + }), + ).rejects.toThrow('Internal server error') + }) + }) + }) + + describe('deleteUser', () => { + it('should delete a user', async () => { + const mockCall = vi.fn().mockResolvedValue({}) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + await service.deleteUser('testuser@example.com') + + expect(mockCall).toHaveBeenCalledWith({ + method: 'DELETE', + action: '/users/:id', + url: { id: 'testuser@example.com' }, + }) + }) + }) + + it('should remove user from cache after delete', async () => { + const mockUser = createMockUser() + const mockCall = vi + .fn() + .mockResolvedValueOnce({ result: mockUser }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ result: mockUser }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + await service.getUser('testuser@example.com') + + await service.deleteUser('testuser@example.com') + + await service.getUser('testuser@example.com') + + expect(mockCall).toHaveBeenCalledTimes(3) + }) + }) + + it('should flush query cache after delete', async () => { + const mockUsers = { + count: 1, + entries: [createMockUser()], + } + const emptyMockUsers = { + count: 0, + entries: [], + } + const mockCall = vi + .fn() + .mockResolvedValueOnce({ result: mockUsers }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ result: emptyMockUsers }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + await service.findUsers({ top: 10 }) + + await service.deleteUser('testuser@example.com') + + await service.findUsers({ top: 10 }) + + expect(mockCall).toHaveBeenCalledTimes(3) + }) + }) + + it('should handle not found errors (404)', async () => { + const mockCall = vi.fn().mockRejectedValue(new Error('User not found')) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(UsersService) + + await expect(service.deleteUser('nonexistent@example.com')).rejects.toThrow('User not found') + }) + }) + }) +}) diff --git a/frontend/src/services/users-service.ts b/frontend/src/services/users-service.ts new file mode 100644 index 00000000..22045f9c --- /dev/null +++ b/frontend/src/services/users-service.ts @@ -0,0 +1,76 @@ +import { Cache } from '@furystack/cache' +import type { FindOptions } from '@furystack/core' +import { Injectable, Injected } from '@furystack/inject' +import type { Roles, User } from 'common' +import { IdentityApiClient } from './api-clients/identity-api-client.js' + +@Injectable({ lifetime: 'singleton' }) +export class UsersService { + @Injected(IdentityApiClient) + declare private readonly identityApiClient: IdentityApiClient + + public userCache = new Cache({ + capacity: 100, + load: async (username: string) => { + const { result } = await this.identityApiClient.call({ + method: 'GET', + action: '/users/:id', + url: { id: username }, + query: {}, + }) + return result + }, + }) + + public userQueryCache = new Cache({ + capacity: 10, + load: async (findOptions: FindOptions>) => { + const { result } = await this.identityApiClient.call({ + method: 'GET', + action: '/users', + query: { + findOptions, + }, + }) + + result.entries.forEach((entry) => { + this.userCache.setExplicitValue({ + loadArgs: [entry.username], + value: { status: 'loaded', value: entry, updatedAt: new Date() }, + }) + }) + + return result + }, + }) + + public getUser = this.userCache.get.bind(this.userCache) + + public getUserAsObservable = this.userCache.getObservable.bind(this.userCache) + + public findUsers = this.userQueryCache.get.bind(this.userQueryCache) + + public findUsersAsObservable = this.userQueryCache.getObservable.bind(this.userQueryCache) + + public updateUser = async (username: string, body: { username: string; roles: Roles }) => { + const { result } = await this.identityApiClient.call({ + method: 'PATCH', + action: '/users/:id', + url: { id: username }, + body, + }) + void this.userCache.reload(username) + this.userQueryCache.flushAll() + return result + } + + public deleteUser = async (username: string) => { + await this.identityApiClient.call({ + method: 'DELETE', + action: '/users/:id', + url: { id: username }, + }) + this.userCache.remove(username) + this.userQueryCache.flushAll() + } +} diff --git a/frontend/src/test-utils/user-test-helpers.ts b/frontend/src/test-utils/user-test-helpers.ts new file mode 100644 index 00000000..b873a5fd --- /dev/null +++ b/frontend/src/test-utils/user-test-helpers.ts @@ -0,0 +1,28 @@ +import type { User } from 'common' + +/** + * Cache state type for testing observable cache values + */ +export type CacheState = + | { status: 'uninitialized' } + | { status: 'loading' } + | { status: 'obsolete'; value: T; updatedAt: Date } + | { status: 'loaded'; value: T; updatedAt: Date } + | { status: 'failed'; error: unknown; updatedAt: Date } + +/** + * Factory function to create mock User objects for testing + */ +export const createMockUser = ( + username = 'testuser@example.com', + roles: User['roles'] = ['admin'], + options?: { + createdAt?: string + updatedAt?: string + }, +): User => ({ + username, + roles, + createdAt: options?.createdAt ?? new Date().toISOString(), + updatedAt: options?.updatedAt ?? new Date().toISOString(), +}) diff --git a/package.json b/package.json index 8cca02ba..609293c4 100644 --- a/package.json +++ b/package.json @@ -17,25 +17,25 @@ }, "devDependencies": { "@eslint/js": "^9.39.2", - "@playwright/test": "^1.57.0", - "@types/jsdom": "^27", - "@types/node": "^25.0.3", - "@vitest/coverage-v8": "^4.0.16", + "@playwright/test": "^1.58.0", + "@types/jsdom": "^27.0.0", + "@types/node": "^25.0.10", + "@vitest/coverage-v8": "^4.0.18", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "2.32.0", - "eslint-plugin-jsdoc": "^61.5.0", - "eslint-plugin-playwright": "^2.4.0", - "eslint-plugin-prettier": "^5.5.4", + "eslint-plugin-jsdoc": "^62.4.1", + "eslint-plugin-playwright": "^2.5.1", + "eslint-plugin-prettier": "^5.5.5", "husky": "^9.1.7", "jsdom": "^27.4.0", "lint-staged": "^16.2.7", - "prettier": "^3.7.4", + "prettier": "^3.8.1", "rimraf": "^6.1.2", "typescript": "^5.9.3", - "typescript-eslint": "^8.52.0", + "typescript-eslint": "^8.53.1", "vite": "^7.3.1", - "vitest": "^4.0.16" + "vitest": "^4.0.18" }, "husky": { "hooks": { diff --git a/playwright.config.ts b/playwright.config.ts index b0876f29..fc9d23d5 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -6,10 +6,13 @@ const isInCi = !!process.env.CI const config: PlaywrightTestConfig = { forbidOnly: isInCi, testDir: 'e2e', - fullyParallel: true, + fullyParallel: !isInCi, // Run tests in parallel locally, but serialize in CI to reduce resource contention retries: isInCi ? 2 : 0, + workers: isInCi ? 2 : undefined, // Limit workers in CI to prevent resource exhaustion + timeout: 60000, // 60 second timeout per test reporter: isInCi ? 'github' : 'line', expect: { + timeout: isInCi ? 30000 : 10000, // 30 second timeout for assertions in CI, 10 seconds locally toHaveScreenshot: { maxDiffPixelRatio: 0.05, threshold: 0.3, @@ -18,6 +21,8 @@ const config: PlaywrightTestConfig = { use: { trace: 'on-first-retry', baseURL: 'http://localhost:9090', + actionTimeout: isInCi ? 30000 : 15000, // 30 second timeout for actions like click, fill, etc. in CI, 15 seconds locally + navigationTimeout: isInCi ? 30000 : 15000, // 30 second timeout for navigation in CI, 15 seconds locally }, projects: [ diff --git a/service/package.json b/service/package.json index 9cc9197d..57525ebe 100644 --- a/service/package.json +++ b/service/package.json @@ -14,22 +14,22 @@ "devDependencies": { "@types/ffprobe": "^1.1.8", "@types/formidable": "^3.4.6", - "@types/node": "^25.0.3", + "@types/node": "^25.0.10", "@types/ping": "^0.4.4", "typescript": "^5.9.3" }, "dependencies": { - "@furystack/cache": "^5.0.25", - "@furystack/core": "^15.0.31", - "@furystack/inject": "^12.0.25", - "@furystack/logging": "^8.0.25", - "@furystack/repository": "^10.0.31", - "@furystack/rest": "^8.0.31", - "@furystack/rest-service": "^10.1.3", - "@furystack/security": "^6.0.31", - "@furystack/sequelize-store": "^6.0.31", - "@furystack/utils": "^8.1.7", - "@furystack/websocket-api": "^13.1.3", + "@furystack/cache": "^5.0.28", + "@furystack/core": "^15.0.34", + "@furystack/inject": "^12.0.28", + "@furystack/logging": "^8.0.28", + "@furystack/repository": "^10.0.34", + "@furystack/rest": "^8.0.34", + "@furystack/rest-service": "^11.0.2", + "@furystack/security": "^6.0.34", + "@furystack/sequelize-store": "^6.0.35", + "@furystack/utils": "^8.1.9", + "@furystack/websocket-api": "^13.1.6", "chokidar": "^5.0.0", "common": "workspace:^", "formidable": "^3.5.4", @@ -38,6 +38,6 @@ "ping": "^1.0.0", "sequelize": "^6.37.7", "sqlite3": "^5.1.7", - "vitest": "^4.0.16" + "vitest": "^4.0.18" } } diff --git a/service/src/ai/actions/chat-action.ts b/service/src/ai/actions/chat-action.ts index 1e43eca5..174767ca 100644 --- a/service/src/ai/actions/chat-action.ts +++ b/service/src/ai/actions/chat-action.ts @@ -1,5 +1,5 @@ import { JsonResult, type RequestAction } from '@furystack/rest-service' -import type { ChatAction as ChatActionType } from 'common' +import type { ChatAction as ChatActionType, ChatResponse } from 'common' import { OllamaClientService } from '../ollama-client-service.js' export const ChatAction: RequestAction = async ({ injector, getBody }) => { @@ -7,7 +7,7 @@ export const ChatAction: RequestAction = async ({ injector, getB const ollamaService = injector.getInstance(OllamaClientService) - const result = await ollamaService.chat(payload) + const result = (await ollamaService.chat(payload)) as ChatResponse return JsonResult(result) } diff --git a/service/src/app-models/config/setup-config-store.ts b/service/src/app-models/config/setup-config-store.ts index ae1a768b..83089773 100644 --- a/service/src/app-models/config/setup-config-store.ts +++ b/service/src/app-models/config/setup-config-store.ts @@ -52,6 +52,7 @@ export const setupConfig = async (injector: Injector) => { }) getRepository(injector).createDataSet(Config, 'id', { + authorizeAdd: withRole('admin'), authorizeGet: withRole('admin'), authorizeUpdate: withRole('admin'), authorizeRemove: withRole('admin'), diff --git a/yarn.lock b/yarn.lock index eb2e271f..b9dbdbb8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -224,16 +224,16 @@ __metadata: languageName: node linkType: hard -"@es-joy/jsdoccomment@npm:~0.76.0": - version: 0.76.0 - resolution: "@es-joy/jsdoccomment@npm:0.76.0" +"@es-joy/jsdoccomment@npm:~0.83.0": + version: 0.83.0 + resolution: "@es-joy/jsdoccomment@npm:0.83.0" dependencies: "@types/estree": "npm:^1.0.8" - "@typescript-eslint/types": "npm:^8.46.0" - comment-parser: "npm:1.4.1" - esquery: "npm:^1.6.0" - jsdoc-type-pratt-parser: "npm:~6.10.0" - checksum: 10c0/8fe4edec7d60562787ea8c77193ebe8737a9e28ec3143d383506b63890d0ffd45a2813e913ad1f00f227cb10e3a1fb913e5a696b33d499dc564272ff1a6f3fdb + "@typescript-eslint/types": "npm:^8.53.1" + comment-parser: "npm:1.4.5" + esquery: "npm:^1.7.0" + jsdoc-type-pratt-parser: "npm:~7.1.0" + checksum: 10c0/55fae1cbceac0abe19d83ea2a6b4b3f864655878b990a1ee3c0efa398926ed473042dd9d7e723aaa926eef0b12d4f5b46b61a6f30b3e50542d4da3b2adb182ce languageName: node linkType: hard @@ -551,171 +551,171 @@ __metadata: languageName: node linkType: hard -"@furystack/cache@npm:^5.0.25": - version: 5.0.25 - resolution: "@furystack/cache@npm:5.0.25" +"@furystack/cache@npm:^5.0.28": + version: 5.0.28 + resolution: "@furystack/cache@npm:5.0.28" dependencies: - "@furystack/inject": "npm:^12.0.25" - "@furystack/utils": "npm:^8.1.7" + "@furystack/inject": "npm:^12.0.28" + "@furystack/utils": "npm:^8.1.9" semaphore-async-await: "npm:^1.5.1" - checksum: 10c0/10d6836b80138b924a12ca53040956c8bc16bbebe4f609733fd7e50312e8df52611f4c06a18db26626cc5cef19aa10548004fa2bed02dfe89c3477b4b7ab372d + checksum: 10c0/fe20ac07e62804e2bbca24d261fb14b5afdedd8c87b1a3ccb5628ceb9a06516c7a984051fd8e74b0037b22a652df08d6fa34f602de3fcb78989e68abecc2330a languageName: node linkType: hard -"@furystack/core@npm:^15.0.31": - version: 15.0.31 - resolution: "@furystack/core@npm:15.0.31" +"@furystack/core@npm:^15.0.34": + version: 15.0.34 + resolution: "@furystack/core@npm:15.0.34" dependencies: - "@furystack/inject": "npm:^12.0.25" - "@furystack/utils": "npm:^8.1.7" - checksum: 10c0/2099c704aa0fd8c8b18e18e81bdd3d37c4302200f4673d18e43c234da1f2e2235edb508eae8bf5736d615d342bf0b612235deef3bce70ebd54c73174d34596e8 + "@furystack/inject": "npm:^12.0.28" + "@furystack/utils": "npm:^8.1.9" + checksum: 10c0/ac84e6f45f69e1467166d41d588f162240f4fbca932ea87f56187ef86cff3638ea733c117eeb5f5eed266cc7a0a31302d9ee58cfc91a5d02645c3e0c5df9382b languageName: node linkType: hard -"@furystack/inject@npm:^12.0.25": - version: 12.0.25 - resolution: "@furystack/inject@npm:12.0.25" +"@furystack/inject@npm:^12.0.28": + version: 12.0.28 + resolution: "@furystack/inject@npm:12.0.28" dependencies: - "@furystack/utils": "npm:^8.1.7" - checksum: 10c0/10568cad1c2002fc5d75ad3cede4bde33d21adddde5e0665fa76376ec0f2616a141e91cbdc3d36280e235b8816dd296b51f23e36763fd4506b7bafae3f5f01ca + "@furystack/utils": "npm:^8.1.9" + checksum: 10c0/291c4d3350486c243f487e13dfdf1e1888bf1d22485553bba2a18b728865a651ed59011abe66e53d120d1a4e29f2abc42bee4e761955b51da2e50b0024957f60 languageName: node linkType: hard -"@furystack/logging@npm:^8.0.25": - version: 8.0.25 - resolution: "@furystack/logging@npm:8.0.25" +"@furystack/logging@npm:^8.0.28": + version: 8.0.28 + resolution: "@furystack/logging@npm:8.0.28" dependencies: - "@furystack/inject": "npm:^12.0.25" - checksum: 10c0/a81ede20e01298d04feed79cb92ebdd49fc1ec8110cc2b7d377febcc5573d22d180b937df087fb853bfd847652e8679489f3edad41b762a95f51710006a14105 + "@furystack/inject": "npm:^12.0.28" + checksum: 10c0/51e7d8f287d609b9cc2580f676a2c6a60867d1e6ec6b16a90cf53085cddfb2aee576f8f726815299449bb12c62b86fb1cc9c4be9994a73960ee3663803463cf2 languageName: node linkType: hard -"@furystack/repository@npm:^10.0.31": - version: 10.0.31 - resolution: "@furystack/repository@npm:10.0.31" +"@furystack/repository@npm:^10.0.34": + version: 10.0.34 + resolution: "@furystack/repository@npm:10.0.34" dependencies: - "@furystack/core": "npm:^15.0.31" - "@furystack/inject": "npm:^12.0.25" - "@furystack/utils": "npm:^8.1.7" - checksum: 10c0/6ca381001cd733ebeb07d2acd823d38dd2b7dc525a110cc7d97c1dc9a6c55c9e122223fd2ea0f2d372ea5fb4f0aea2e848891d6a713b8a20e1c5cf0aac8db349 + "@furystack/core": "npm:^15.0.34" + "@furystack/inject": "npm:^12.0.28" + "@furystack/utils": "npm:^8.1.9" + checksum: 10c0/4cca1fd3a42c018c144213a0be062c518534caad73b3e56ff9e40ea7968420d0b457878780ecf81b5963a35cf9add53c5b582e629ccd20db40d917266ff69b36 languageName: node linkType: hard -"@furystack/rest-client-fetch@npm:^8.0.31": - version: 8.0.31 - resolution: "@furystack/rest-client-fetch@npm:8.0.31" +"@furystack/rest-client-fetch@npm:^8.0.34": + version: 8.0.34 + resolution: "@furystack/rest-client-fetch@npm:8.0.34" dependencies: - "@furystack/rest": "npm:^8.0.31" + "@furystack/rest": "npm:^8.0.34" path-to-regexp: "npm:^8.3.0" - checksum: 10c0/161253260d59f5146f7d9ac2536b26a5a791e4c3722c1e9519562eecb3b13d96c08d88e10f8b3687fe0cd6d8d62b05e9c883ba830afdb5927fdc8cbce10fd84e + checksum: 10c0/a7e969d0284797371f604a2187718ba498df4d0ad1b6642f386a67219a2eeb32aef93c8db8a4f3cadb7ba4d7f09971e1b63bcdcb9fa04eee68e694ff6be83338 languageName: node linkType: hard -"@furystack/rest-service@npm:^10.1.3": - version: 10.1.3 - resolution: "@furystack/rest-service@npm:10.1.3" +"@furystack/rest-service@npm:^11.0.2": + version: 11.0.2 + resolution: "@furystack/rest-service@npm:11.0.2" dependencies: - "@furystack/core": "npm:^15.0.31" - "@furystack/inject": "npm:^12.0.25" - "@furystack/repository": "npm:^10.0.31" - "@furystack/rest": "npm:^8.0.31" - "@furystack/security": "npm:^6.0.31" - "@furystack/utils": "npm:^8.1.7" + "@furystack/core": "npm:^15.0.34" + "@furystack/inject": "npm:^12.0.28" + "@furystack/repository": "npm:^10.0.34" + "@furystack/rest": "npm:^8.0.34" + "@furystack/security": "npm:^6.0.34" + "@furystack/utils": "npm:^8.1.9" ajv: "npm:^8.17.1" ajv-formats: "npm:^3.0.1" path-to-regexp: "npm:^8.3.0" semaphore-async-await: "npm:^1.5.1" - checksum: 10c0/d1c651536512890d7de428f3a82dff6a8c4f5f8d8d59801cf26a9c0dd35ca78ddfe7ad5660d656470bc49733ebe50f133b84a5d9d14283f3683c3996d43b0ef1 + checksum: 10c0/0be1475db9c609932e47a7e3436cd7a4921b7e5cb0e15714125be90527dc5366e1621f78af881b2d79619ae72d1bbfec14e8ecd10def862d2f42bb64f70a1626 languageName: node linkType: hard -"@furystack/rest@npm:^8.0.31": - version: 8.0.31 - resolution: "@furystack/rest@npm:8.0.31" +"@furystack/rest@npm:^8.0.34": + version: 8.0.34 + resolution: "@furystack/rest@npm:8.0.34" dependencies: - "@furystack/core": "npm:^15.0.31" - "@furystack/inject": "npm:^12.0.25" - checksum: 10c0/aa1eb03588ac639b83849d05846ea04515dd0331ea21f00b961896d0285e92c5b9f492443cd976e1b51d2a108477e6b1dee8bbdf26dd0e0023c191fa866b3abc + "@furystack/core": "npm:^15.0.34" + "@furystack/inject": "npm:^12.0.28" + checksum: 10c0/e8bcbb290f089f307cec422ba2d2f5d38c776c95932e81272a086a8276927f46407deefa8e79f2287f4d696349d7691b3011aa3f1673df34771a9d768e96d4e1 languageName: node linkType: hard -"@furystack/security@npm:^6.0.31": - version: 6.0.31 - resolution: "@furystack/security@npm:6.0.31" +"@furystack/security@npm:^6.0.34": + version: 6.0.34 + resolution: "@furystack/security@npm:6.0.34" dependencies: - "@furystack/core": "npm:^15.0.31" - "@furystack/inject": "npm:^12.0.25" - checksum: 10c0/5460be04fd5e724eb723fba9ded39217fc6f4d9d26adc6e42d2c57f031727166eab0f5409f6a6fd190e9d5a49644bd40f87ad32b75c94b5a90414e563cdccaf8 + "@furystack/core": "npm:^15.0.34" + "@furystack/inject": "npm:^12.0.28" + checksum: 10c0/0dbe67f7582d5fdfbcf9956c79e122754c69a037f9ea807bd293847a9f5d02e138b2b8b657dee8fd005913334107147e154f499724bc849e4932a5b316d1616c languageName: node linkType: hard -"@furystack/sequelize-store@npm:^6.0.31": - version: 6.0.31 - resolution: "@furystack/sequelize-store@npm:6.0.31" +"@furystack/sequelize-store@npm:^6.0.35": + version: 6.0.35 + resolution: "@furystack/sequelize-store@npm:6.0.35" dependencies: - "@furystack/core": "npm:^15.0.31" - "@furystack/inject": "npm:^12.0.25" - "@furystack/utils": "npm:^8.1.7" + "@furystack/core": "npm:^15.0.34" + "@furystack/inject": "npm:^12.0.28" + "@furystack/utils": "npm:^8.1.9" semaphore-async-await: "npm:^1.5.1" sequelize: "npm:^6.37.7" - checksum: 10c0/d8cc07b2705c3c26071bd067f21c84f8bcb0d9e2590ccfd7a72aea935a6ee11aa2a6e32c0fef236197a11d5ef8b0d15f3b31868de131efdc15eee4f58ed4abbd + checksum: 10c0/ef05580f2bf6f53e07d2fc354d0cabecab8fddf4e3558042728810d7bb3d85483404c84d83a82d9540f8b1efca6401656364ad49836c1069991408e4634553d2 languageName: node linkType: hard -"@furystack/shades-common-components@npm:^10.0.32": - version: 10.0.32 - resolution: "@furystack/shades-common-components@npm:10.0.32" +"@furystack/shades-common-components@npm:^10.0.35": + version: 10.0.35 + resolution: "@furystack/shades-common-components@npm:10.0.35" dependencies: - "@furystack/core": "npm:^15.0.31" - "@furystack/inject": "npm:^12.0.25" - "@furystack/shades": "npm:^11.0.32" - "@furystack/utils": "npm:^8.1.7" + "@furystack/core": "npm:^15.0.34" + "@furystack/inject": "npm:^12.0.28" + "@furystack/shades": "npm:^11.0.35" + "@furystack/utils": "npm:^8.1.9" path-to-regexp: "npm:^8.3.0" semaphore-async-await: "npm:^1.5.1" - checksum: 10c0/bd5ff4544d3bb0836c4e05284a283cb43af1ba2c682cfb9bd0bf51e3cd66b9fe248e8049f61c7b2cded9e66e546343862d73f6958a8334912622c0a034293e24 + checksum: 10c0/7d13c51aac79bca048f4f0abc507ca621774d0552da32ef4afd784ddfc718a13a36f356faa0a6712c042052f5b61b976ec70417a12ff6bae8a0a3c925dc1ac18 languageName: node linkType: hard -"@furystack/shades-lottie@npm:^7.0.32": - version: 7.0.32 - resolution: "@furystack/shades-lottie@npm:7.0.32" +"@furystack/shades-lottie@npm:^7.0.35": + version: 7.0.35 + resolution: "@furystack/shades-lottie@npm:7.0.35" dependencies: - "@furystack/shades": "npm:^11.0.32" + "@furystack/shades": "npm:^11.0.35" "@lottiefiles/lottie-player": "npm:^2.0.12" - checksum: 10c0/5994e6fb2b529fa1464bd2ace19ddf6ecec00adae335fbbf5715b295ede1a81c39789bbf92814e122468c95b377a99285486b106d8f9827529beca34ec8d6712 + checksum: 10c0/910223982a50c619c5be30d967d3ea8175a59e7b668b0fda235ba2ee1966ba41902be4438b28364966c4e14d10abfe18f047ffe53d70462f3c3d36415e23b831 languageName: node linkType: hard -"@furystack/shades@npm:^11.0.32": - version: 11.0.32 - resolution: "@furystack/shades@npm:11.0.32" +"@furystack/shades@npm:^11.0.35": + version: 11.0.35 + resolution: "@furystack/shades@npm:11.0.35" dependencies: - "@furystack/inject": "npm:^12.0.25" - "@furystack/rest": "npm:^8.0.31" - "@furystack/utils": "npm:^8.1.7" + "@furystack/inject": "npm:^12.0.28" + "@furystack/rest": "npm:^8.0.34" + "@furystack/utils": "npm:^8.1.9" path-to-regexp: "npm:^8.3.0" semaphore-async-await: "npm:^1.5.1" - checksum: 10c0/fccac82571618dc67bf968eefe87810a16fe759bccae0fba3746ea8b93b19f837df6ff36a549bddee7208af916b824492255a58c0d454f83218e848f37933ddb + checksum: 10c0/50409720eb1c8692f0a1a1db439a9c053e1b221303db71fcd2e5b92dc6746078238f54bb5cae6762bea0b9ed1cd6f4eb4979eff0a98984a165de60c3bfc6cd12 languageName: node linkType: hard -"@furystack/utils@npm:^8.1.7": - version: 8.1.7 - resolution: "@furystack/utils@npm:8.1.7" - checksum: 10c0/b8f05738549f3cb1b8d25728cd10322adfb0ba66615c6db02122d73ad9a5051b69abdea5314d067a818152e0dd3944ca7daaa38d3232fecbcad0826ac3d91449 +"@furystack/utils@npm:^8.1.9": + version: 8.1.9 + resolution: "@furystack/utils@npm:8.1.9" + checksum: 10c0/a95bac6cf454f997f2a4ef4e7b945bcbc215110d8a40d78aaf1a75e0b42ff6e78c4506c35a1faa0c5a996ad31052c2abc9b4da780d3438ff69804259e830e311 languageName: node linkType: hard -"@furystack/websocket-api@npm:^13.1.3": - version: 13.1.3 - resolution: "@furystack/websocket-api@npm:13.1.3" +"@furystack/websocket-api@npm:^13.1.6": + version: 13.1.6 + resolution: "@furystack/websocket-api@npm:13.1.6" dependencies: - "@furystack/core": "npm:^15.0.31" - "@furystack/inject": "npm:^12.0.25" - "@furystack/rest-service": "npm:^10.1.3" - "@furystack/utils": "npm:^8.1.7" + "@furystack/core": "npm:^15.0.34" + "@furystack/inject": "npm:^12.0.28" + "@furystack/rest-service": "npm:^11.0.2" + "@furystack/utils": "npm:^8.1.9" ws: "npm:^8.19.0" - checksum: 10c0/b8a68d9f86696ec0e8ae320fb23a656e38f23c7e236b9b95cdff7c60173399f96806d41b5cc4b6ffcdb8cb4e954c4b45d7443838fa424252e8f8553f0b74384a + checksum: 10c0/8f9c5e95b3c4641aac5038aed1ee3ebb73be6e34b8f3152aea7d6a686670c8756e789bf435fed2ec329da79d52eb782f4a38f1a92361c32a03364ec05594f98f languageName: node linkType: hard @@ -810,7 +810,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.23, @jridgewell/trace-mapping@npm:^0.3.31": +"@jridgewell/trace-mapping@npm:^0.3.31": version: 0.3.31 resolution: "@jridgewell/trace-mapping@npm:0.3.31" dependencies: @@ -1041,14 +1041,14 @@ __metadata: languageName: node linkType: hard -"@playwright/test@npm:^1.57.0": - version: 1.57.0 - resolution: "@playwright/test@npm:1.57.0" +"@playwright/test@npm:^1.58.0": + version: 1.58.0 + resolution: "@playwright/test@npm:1.58.0" dependencies: - playwright: "npm:1.57.0" + playwright: "npm:1.58.0" bin: playwright: cli.js - checksum: 10c0/35ba4b28be72bf0a53e33dbb11c6cff848fb9a37f49e893ce63a90675b5291ec29a1ba82c8a3b043abaead129400f0589623e9ace2e6a1c8eaa409721ecc3774 + checksum: 10c0/7bf0c509415e4e55a1bcd2dd2af5f0fa5d33d60f7583c35295134af99c03351f514e7884d7c8354ed85fcd8dcea5ec85c8ba917d49d1834bb1a0b02236920e2d languageName: node linkType: hard @@ -1282,7 +1282,7 @@ __metadata: languageName: node linkType: hard -"@types/jsdom@npm:^27": +"@types/jsdom@npm:^27.0.0": version: 27.0.0 resolution: "@types/jsdom@npm:27.0.0" dependencies: @@ -1332,12 +1332,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^25.0.3": - version: 25.0.3 - resolution: "@types/node@npm:25.0.3" +"@types/node@npm:^25.0.10": + version: 25.0.10 + resolution: "@types/node@npm:25.0.10" dependencies: undici-types: "npm:~7.16.0" - checksum: 10c0/b7568f0d765d9469621615e2bb257c7fd1953d95e9acbdb58dffb6627a2c4150d405a4600aa1ad8a40182a94fe5f903cafd3c0a2f5132814debd0e3bfd61f835 + checksum: 10c0/9edc3c812b487c32c76eebac7c87acae1f69515a0bc3f6b545806d513eb9e918c3217bf751dc93da39f60e06bf1b0caa92258ef3a6dd6457124b2e761e54f61f languageName: node linkType: hard @@ -1376,112 +1376,105 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:8.52.0": - version: 8.52.0 - resolution: "@typescript-eslint/eslint-plugin@npm:8.52.0" +"@typescript-eslint/eslint-plugin@npm:8.53.1": + version: 8.53.1 + resolution: "@typescript-eslint/eslint-plugin@npm:8.53.1" dependencies: "@eslint-community/regexpp": "npm:^4.12.2" - "@typescript-eslint/scope-manager": "npm:8.52.0" - "@typescript-eslint/type-utils": "npm:8.52.0" - "@typescript-eslint/utils": "npm:8.52.0" - "@typescript-eslint/visitor-keys": "npm:8.52.0" + "@typescript-eslint/scope-manager": "npm:8.53.1" + "@typescript-eslint/type-utils": "npm:8.53.1" + "@typescript-eslint/utils": "npm:8.53.1" + "@typescript-eslint/visitor-keys": "npm:8.53.1" ignore: "npm:^7.0.5" natural-compare: "npm:^1.4.0" ts-api-utils: "npm:^2.4.0" peerDependencies: - "@typescript-eslint/parser": ^8.52.0 + "@typescript-eslint/parser": ^8.53.1 eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/853e929bf1077f59c47c2a112ca8837ccc53b1c80f0b39a9505806ee8002e5599b85323c5ccaa9ee4d6a6dafcdc99461c5296b5f24d8ab131346bec5bda36c85 + checksum: 10c0/d24e41d0117ef841cc05e4c52d33277de2e57981fa38412f93034082a3467f804201c180f1baca9f967388c7e5965ffcc61e445cf726a0064b8ed71a84f59aa2 languageName: node linkType: hard -"@typescript-eslint/parser@npm:8.52.0": - version: 8.52.0 - resolution: "@typescript-eslint/parser@npm:8.52.0" +"@typescript-eslint/parser@npm:8.53.1": + version: 8.53.1 + resolution: "@typescript-eslint/parser@npm:8.53.1" dependencies: - "@typescript-eslint/scope-manager": "npm:8.52.0" - "@typescript-eslint/types": "npm:8.52.0" - "@typescript-eslint/typescript-estree": "npm:8.52.0" - "@typescript-eslint/visitor-keys": "npm:8.52.0" + "@typescript-eslint/scope-manager": "npm:8.53.1" + "@typescript-eslint/types": "npm:8.53.1" + "@typescript-eslint/typescript-estree": "npm:8.53.1" + "@typescript-eslint/visitor-keys": "npm:8.53.1" debug: "npm:^4.4.3" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/a11304db8068850e04dfcaa2728b73940635f3857c7d0a24cda002d0ad2d9af4ffec44c30f52c91385b065decbf9f134a7337f54d00289160fbbff76fca7649b + checksum: 10c0/fb7602dc3ea45b838f4da2d0173161b222442ed2007487dfce57d6ce24ff16606ec99de9eb6ac114a815e11a47248303d941dca1a7bf13f70350372cee509886 languageName: node linkType: hard -"@typescript-eslint/project-service@npm:8.52.0": - version: 8.52.0 - resolution: "@typescript-eslint/project-service@npm:8.52.0" +"@typescript-eslint/project-service@npm:8.53.1": + version: 8.53.1 + resolution: "@typescript-eslint/project-service@npm:8.53.1" dependencies: - "@typescript-eslint/tsconfig-utils": "npm:^8.52.0" - "@typescript-eslint/types": "npm:^8.52.0" + "@typescript-eslint/tsconfig-utils": "npm:^8.53.1" + "@typescript-eslint/types": "npm:^8.53.1" debug: "npm:^4.4.3" peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/2dc7379572b4b1340daff5923fbf7987ebd2de5a4203ece0ec9e8a9e85cf182cd4cd24c25bd7df62b981fb633c91dd35f27fed1341719c2f8a48eb80682b4658 + checksum: 10c0/eecc7ad86b45c6969a05e984e645a4ece2a1cc27d825af046efb6ed369cab32062c17f33a1154ab6dcab349099885db7b39945f1b318753395630f3dfa1e5895 languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.52.0": - version: 8.52.0 - resolution: "@typescript-eslint/scope-manager@npm:8.52.0" +"@typescript-eslint/scope-manager@npm:8.53.1": + version: 8.53.1 + resolution: "@typescript-eslint/scope-manager@npm:8.53.1" dependencies: - "@typescript-eslint/types": "npm:8.52.0" - "@typescript-eslint/visitor-keys": "npm:8.52.0" - checksum: 10c0/385105ad1bb63eddcfc65039a7c13ec339aef4823c3021110cffe72c545b27c6b197e40ec55000b5b1bf278946a3e1a77eba19203f461c1a77ba3fe82d007f3e + "@typescript-eslint/types": "npm:8.53.1" + "@typescript-eslint/visitor-keys": "npm:8.53.1" + checksum: 10c0/d971eb115f2a2c4c25c79df9eee68b93354b32d7cc1174c167241cd2ebbc77858fe7a032c7ecdbacef936b56e8317b56037d21461cb83b4789f7e764e9faa455 languageName: node linkType: hard -"@typescript-eslint/tsconfig-utils@npm:8.52.0, @typescript-eslint/tsconfig-utils@npm:^8.52.0": - version: 8.52.0 - resolution: "@typescript-eslint/tsconfig-utils@npm:8.52.0" +"@typescript-eslint/tsconfig-utils@npm:8.53.1, @typescript-eslint/tsconfig-utils@npm:^8.53.1": + version: 8.53.1 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.53.1" peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/a45f6c1453031c149b2dedaa4e8ace53aa71c751a5702b028cbd9a899928d46141cc4343d8de6260e3e27024f6645b12669d8759f66ebde4cbae2f703b859747 + checksum: 10c0/e2bfa91f9306dbfa82bdcb64bfcf634fee6313b03e93b35b0010907983c9ffc73c732264deff870896dea18f34b872d39d90d32f7631fd4618e4a6866ffff578 languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:8.52.0": - version: 8.52.0 - resolution: "@typescript-eslint/type-utils@npm:8.52.0" +"@typescript-eslint/type-utils@npm:8.53.1": + version: 8.53.1 + resolution: "@typescript-eslint/type-utils@npm:8.53.1" dependencies: - "@typescript-eslint/types": "npm:8.52.0" - "@typescript-eslint/typescript-estree": "npm:8.52.0" - "@typescript-eslint/utils": "npm:8.52.0" + "@typescript-eslint/types": "npm:8.53.1" + "@typescript-eslint/typescript-estree": "npm:8.53.1" + "@typescript-eslint/utils": "npm:8.53.1" debug: "npm:^4.4.3" ts-api-utils: "npm:^2.4.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/c859ffd10d0a986047af139d3e3a1fa3cb42155a8da13838680ff61bb2880798ecff346c50f9d6214ae742507ca0db39228a2d68b1f099473daba98be037aef3 + checksum: 10c0/d97ac3bf901eeeb1ad01a423409db654f849d49f8ce7a2b0d482e093d5c8c9cab9ed810554d130a1eaf4921ddb2d98dbe9a8d22bfd08fd6c8ab004fb640a3fbe languageName: node linkType: hard -"@typescript-eslint/types@npm:8.52.0, @typescript-eslint/types@npm:^8.52.0": - version: 8.52.0 - resolution: "@typescript-eslint/types@npm:8.52.0" - checksum: 10c0/ad93803aa92570a96cc9f9a201735e68fecee9056a37563c9e5b70c16436927ac823ec38d9712881910d89dd7314b0a40100ef41ef1aca0d42674d3312d5ec8e +"@typescript-eslint/types@npm:8.53.1, @typescript-eslint/types@npm:^8.53.1": + version: 8.53.1 + resolution: "@typescript-eslint/types@npm:8.53.1" + checksum: 10c0/fa49f5f60de6851de45a9aff0a3ba3c4d00a0991100414e8af1a5d6f32764a48b6b7c0f65748a651f0da0e57df0745cdb8f11c590fa0fb22dd0e54e4c6b5c878 languageName: node linkType: hard -"@typescript-eslint/types@npm:^8.46.0": - version: 8.46.1 - resolution: "@typescript-eslint/types@npm:8.46.1" - checksum: 10c0/90887acaa5b33b45af20cf7f87ec4ae098c0daa88484245473e73903fa6e542f613247c22148132167891ca06af6549a60b9d2fd14a65b22871e016901ce3756 - languageName: node - linkType: hard - -"@typescript-eslint/typescript-estree@npm:8.52.0": - version: 8.52.0 - resolution: "@typescript-eslint/typescript-estree@npm:8.52.0" +"@typescript-eslint/typescript-estree@npm:8.53.1": + version: 8.53.1 + resolution: "@typescript-eslint/typescript-estree@npm:8.53.1" dependencies: - "@typescript-eslint/project-service": "npm:8.52.0" - "@typescript-eslint/tsconfig-utils": "npm:8.52.0" - "@typescript-eslint/types": "npm:8.52.0" - "@typescript-eslint/visitor-keys": "npm:8.52.0" + "@typescript-eslint/project-service": "npm:8.53.1" + "@typescript-eslint/tsconfig-utils": "npm:8.53.1" + "@typescript-eslint/types": "npm:8.53.1" + "@typescript-eslint/visitor-keys": "npm:8.53.1" debug: "npm:^4.4.3" minimatch: "npm:^9.0.5" semver: "npm:^7.7.3" @@ -1489,32 +1482,32 @@ __metadata: ts-api-utils: "npm:^2.4.0" peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/e4158a6364d3f009eac780947504ac1dad2ee3f1fdd4dfd99e4a7b48719ce0d342a769dc05fa5d4bc5de9de28175aa8e9ba612385f6b6f215039ff41e91f2de5 + checksum: 10c0/e1b48990ba90f0ee5c9630fe91e2d5123c55348e374e586de6cf25e6e03e6e8274bf15317794d171a2e82d9dc663c229807e603ecc661dbe70d61bd23d0c37c4 languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.52.0": - version: 8.52.0 - resolution: "@typescript-eslint/utils@npm:8.52.0" +"@typescript-eslint/utils@npm:8.53.1": + version: 8.53.1 + resolution: "@typescript-eslint/utils@npm:8.53.1" dependencies: "@eslint-community/eslint-utils": "npm:^4.9.1" - "@typescript-eslint/scope-manager": "npm:8.52.0" - "@typescript-eslint/types": "npm:8.52.0" - "@typescript-eslint/typescript-estree": "npm:8.52.0" + "@typescript-eslint/scope-manager": "npm:8.53.1" + "@typescript-eslint/types": "npm:8.53.1" + "@typescript-eslint/typescript-estree": "npm:8.53.1" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/67e501e8ef4c4a5510237e3bfcfee37512137075a18c24f615924559bcca64ce9903118e7e4288cd4f58361979243f457d43684cdafa6c193fa8963a7431d0f3 + checksum: 10c0/9a2a11c00b97eb9a053782e303cc384649807779e9adeb0b645bc198c83f54431f7ca56d4b38411dcf7ed06a2c2d9aa129874c20c037de2393a4cd0fa3b93c25 languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.52.0": - version: 8.52.0 - resolution: "@typescript-eslint/visitor-keys@npm:8.52.0" +"@typescript-eslint/visitor-keys@npm:8.53.1": + version: 8.53.1 + resolution: "@typescript-eslint/visitor-keys@npm:8.53.1" dependencies: - "@typescript-eslint/types": "npm:8.52.0" + "@typescript-eslint/types": "npm:8.53.1" eslint-visitor-keys: "npm:^4.2.1" - checksum: 10c0/7163735d872df0930301ecccd454602d241a65223b84ff3ef78ede02f27941c0cbb95d0c8b4fe51637d1fbd981e6558d454fc485a2488d7190e264e12a8a355f + checksum: 10c0/73a21d34052bcb0b46ed738f8fddb76ae8f56a0c27932616b49022cf8603c3e36bb6ab30acd709f9bc05c673708180527b4c4aaffcb858acfc66d8fb39cc6c29 languageName: node linkType: hard @@ -1557,50 +1550,49 @@ __metadata: languageName: node linkType: hard -"@vitest/coverage-v8@npm:^4.0.16": - version: 4.0.16 - resolution: "@vitest/coverage-v8@npm:4.0.16" +"@vitest/coverage-v8@npm:^4.0.18": + version: 4.0.18 + resolution: "@vitest/coverage-v8@npm:4.0.18" dependencies: "@bcoe/v8-coverage": "npm:^1.0.2" - "@vitest/utils": "npm:4.0.16" - ast-v8-to-istanbul: "npm:^0.3.8" + "@vitest/utils": "npm:4.0.18" + ast-v8-to-istanbul: "npm:^0.3.10" istanbul-lib-coverage: "npm:^3.2.2" istanbul-lib-report: "npm:^3.0.1" - istanbul-lib-source-maps: "npm:^5.0.6" istanbul-reports: "npm:^3.2.0" magicast: "npm:^0.5.1" obug: "npm:^2.1.1" std-env: "npm:^3.10.0" tinyrainbow: "npm:^3.0.3" peerDependencies: - "@vitest/browser": 4.0.16 - vitest: 4.0.16 + "@vitest/browser": 4.0.18 + vitest: 4.0.18 peerDependenciesMeta: "@vitest/browser": optional: true - checksum: 10c0/3edd18dc994949d5180a3fbd9c1af4ca4756735e82cffb73b3c0918ad23a4c71521287a205cc61a39b63453448e9bfd207f82b2d472fd757dfbb47987dbe99a8 + checksum: 10c0/e23e0da86f0b2a020c51562bc40ebdc7fc7553c24f8071dfb39a6df0161badbd5eaf2eebbf8ceaef18933a18c1934ff52d1c0c4bde77bb87e0c1feb0c8cbee4d languageName: node linkType: hard -"@vitest/expect@npm:4.0.16": - version: 4.0.16 - resolution: "@vitest/expect@npm:4.0.16" +"@vitest/expect@npm:4.0.18": + version: 4.0.18 + resolution: "@vitest/expect@npm:4.0.18" dependencies: "@standard-schema/spec": "npm:^1.0.0" "@types/chai": "npm:^5.2.2" - "@vitest/spy": "npm:4.0.16" - "@vitest/utils": "npm:4.0.16" + "@vitest/spy": "npm:4.0.18" + "@vitest/utils": "npm:4.0.18" chai: "npm:^6.2.1" tinyrainbow: "npm:^3.0.3" - checksum: 10c0/add4dde3548b6f65b6d7d364607713f9db258642add248a23805fa1172e48d76a7822080249efb882120b408772684569b2e78581ed3d5f282e215d7f21be183 + checksum: 10c0/123b0aa111682e82ec5289186df18037b1a1768700e468ee0f9879709aaa320cf790463c15c0d8ee10df92b402f4394baf5d27797e604d78e674766d87bcaadc languageName: node linkType: hard -"@vitest/mocker@npm:4.0.16": - version: 4.0.16 - resolution: "@vitest/mocker@npm:4.0.16" +"@vitest/mocker@npm:4.0.18": + version: 4.0.18 + resolution: "@vitest/mocker@npm:4.0.18" dependencies: - "@vitest/spy": "npm:4.0.16" + "@vitest/spy": "npm:4.0.18" estree-walker: "npm:^3.0.3" magic-string: "npm:^0.30.21" peerDependencies: @@ -1611,54 +1603,54 @@ __metadata: optional: true vite: optional: true - checksum: 10c0/cf4469a4745e3cdd46e8ee4e20aa04369e7f985d40175d974d3a6f6d331bf9d8610f9638c5a18f7ff59a30ff04b19f4b823457b4c79142186fe463fa4cee80c5 + checksum: 10c0/fb0a257e7e167759d4ad228d53fa7bad2267586459c4a62188f2043dd7163b4b02e1e496dc3c227837f776e7d73d6c4343613e89e7da379d9d30de8260f1ee4b languageName: node linkType: hard -"@vitest/pretty-format@npm:4.0.16": - version: 4.0.16 - resolution: "@vitest/pretty-format@npm:4.0.16" +"@vitest/pretty-format@npm:4.0.18": + version: 4.0.18 + resolution: "@vitest/pretty-format@npm:4.0.18" dependencies: tinyrainbow: "npm:^3.0.3" - checksum: 10c0/11243e9c2d2d011ae23825c6b7464a4385a4a4efc4ceb28b7854bb9d73491f440b89d12f62c5c9737d26375cf9585b11bc20183d4dea4e983e79d5e162407eb9 + checksum: 10c0/0086b8c88eeca896d8e4b98fcdef452c8041a1b63eb9e85d3e0bcc96c8aa76d8e9e0b6990ebb0bb0a697c4ebab347e7735888b24f507dbff2742ddce7723fd94 languageName: node linkType: hard -"@vitest/runner@npm:4.0.16": - version: 4.0.16 - resolution: "@vitest/runner@npm:4.0.16" +"@vitest/runner@npm:4.0.18": + version: 4.0.18 + resolution: "@vitest/runner@npm:4.0.18" dependencies: - "@vitest/utils": "npm:4.0.16" + "@vitest/utils": "npm:4.0.18" pathe: "npm:^2.0.3" - checksum: 10c0/7f4614a9fe5e9f3683d30fb82d1489796c669df45fbc0beb22d39539e4b12ebef462062705545ca04391a0406af62088cbf1d613a812ecc9ea753a0edbfd5d26 + checksum: 10c0/fdb4afa411475133c05ba266c8092eaf1e56cbd5fb601f92ec6ccb9bab7ca52e06733ee8626599355cba4ee71cb3a8f28c84d3b69dc972e41047edc50229bc01 languageName: node linkType: hard -"@vitest/snapshot@npm:4.0.16": - version: 4.0.16 - resolution: "@vitest/snapshot@npm:4.0.16" +"@vitest/snapshot@npm:4.0.18": + version: 4.0.18 + resolution: "@vitest/snapshot@npm:4.0.18" dependencies: - "@vitest/pretty-format": "npm:4.0.16" + "@vitest/pretty-format": "npm:4.0.18" magic-string: "npm:^0.30.21" pathe: "npm:^2.0.3" - checksum: 10c0/4fa63ffa4f30c909078210a1edcb059dbfa3ec3deaebb8f93637f65a7efae9a2d7714129bae0cf615512a683e925cf31f281fc4cb02f1fdc4c72f68ce21ca11f + checksum: 10c0/d3bfefa558db9a69a66886ace6575eb96903a5ba59f4d9a5d0fecb4acc2bb8dbb443ef409f5ac1475f2e1add30bd1d71280f98912da35e89c75829df9e84ea43 languageName: node linkType: hard -"@vitest/spy@npm:4.0.16": - version: 4.0.16 - resolution: "@vitest/spy@npm:4.0.16" - checksum: 10c0/2502918e703d60ef64854d0fa83ebf94da64b80e81b80c319568feee3d86069fd46e24880a768edba06c8caba13801e44005e17a0f16d9b389486f24d539f0bf +"@vitest/spy@npm:4.0.18": + version: 4.0.18 + resolution: "@vitest/spy@npm:4.0.18" + checksum: 10c0/6de537890b3994fcadb8e8d8ac05942320ae184f071ec395d978a5fba7fa928cbb0c5de85af86a1c165706c466e840de8779eaff8c93450c511c7abaeb9b8a4e languageName: node linkType: hard -"@vitest/utils@npm:4.0.16": - version: 4.0.16 - resolution: "@vitest/utils@npm:4.0.16" +"@vitest/utils@npm:4.0.18": + version: 4.0.18 + resolution: "@vitest/utils@npm:4.0.18" dependencies: - "@vitest/pretty-format": "npm:4.0.16" + "@vitest/pretty-format": "npm:4.0.18" tinyrainbow: "npm:^3.0.3" - checksum: 10c0/bba35b4e102be03e106ced227809437573aa5c5f64d512301ca8de127dcb91cbedc11a2e823305f8ba82528c909c10510ec8c7e3d92b3d6d1c1aec33e143572a + checksum: 10c0/4a3c43c1421eb90f38576926496f6c80056167ba111e63f77cf118983902673737a1a38880b890d7c06ec0a12475024587344ee502b3c43093781533022f2aeb languageName: node linkType: hard @@ -1971,7 +1963,7 @@ __metadata: languageName: node linkType: hard -"ast-v8-to-istanbul@npm:^0.3.8": +"ast-v8-to-istanbul@npm:^0.3.10": version: 0.3.10 resolution: "ast-v8-to-istanbul@npm:0.3.10" dependencies: @@ -2306,10 +2298,10 @@ __metadata: languageName: node linkType: hard -"comment-parser@npm:1.4.1": - version: 1.4.1 - resolution: "comment-parser@npm:1.4.1" - checksum: 10c0/d6c4be3f5be058f98b24f2d557f745d8fe1cc9eb75bebbdccabd404a0e1ed41563171b16285f593011f8b6a5ec81f564fb1f2121418ac5cbf0f49255bf0840dd +"comment-parser@npm:1.4.5": + version: 1.4.5 + resolution: "comment-parser@npm:1.4.5" + checksum: 10c0/6a6a74697c79927e3bd42bde9608a471f1a9d4995affbc22fa3364cc42b4017f82ef477431a1558b0b6bef959f9bb6964c01c1bbfc06a58ba1730dec9c423b44 languageName: node linkType: hard @@ -2317,12 +2309,12 @@ __metadata: version: 0.0.0-use.local resolution: "common@workspace:common" dependencies: - "@furystack/core": "npm:^15.0.31" - "@furystack/rest": "npm:^8.0.31" - "@types/node": "npm:^25.0.3" + "@furystack/core": "npm:^15.0.34" + "@furystack/rest": "npm:^8.0.34" + "@types/node": "npm:^25.0.10" ollama: "npm:^0.6.3" ts-json-schema-generator: "npm:^2.4.0" - vitest: "npm:^4.0.16" + vitest: "npm:^4.0.18" languageName: unknown linkType: soft @@ -2416,7 +2408,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.4.3": +"debug@npm:4, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.4.3": version: 4.4.3 resolution: "debug@npm:4.4.3" dependencies: @@ -2923,18 +2915,18 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-jsdoc@npm:^61.5.0": - version: 61.5.0 - resolution: "eslint-plugin-jsdoc@npm:61.5.0" +"eslint-plugin-jsdoc@npm:^62.4.1": + version: 62.4.1 + resolution: "eslint-plugin-jsdoc@npm:62.4.1" dependencies: - "@es-joy/jsdoccomment": "npm:~0.76.0" + "@es-joy/jsdoccomment": "npm:~0.83.0" "@es-joy/resolve.exports": "npm:1.2.0" are-docs-informative: "npm:^0.0.2" - comment-parser: "npm:1.4.1" + comment-parser: "npm:1.4.5" debug: "npm:^4.4.3" escape-string-regexp: "npm:^4.0.0" - espree: "npm:^10.4.0" - esquery: "npm:^1.6.0" + espree: "npm:^11.1.0" + esquery: "npm:^1.7.0" html-entities: "npm:^2.6.0" object-deep-merge: "npm:^2.0.0" parse-imports-exports: "npm:^0.2.4" @@ -2943,27 +2935,27 @@ __metadata: to-valid-identifier: "npm:^1.0.0" peerDependencies: eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 - checksum: 10c0/fabb04f6efe58a167a0839d3c05676a76080c6e91d98a269fa768c1bfd835aa0ded5822d400da2874216177044d2d227ebe241d73e923f3fe1c08bafd19cfd3d + checksum: 10c0/1833544a49780662922876db47e4a9808d883d76f1f7d06a9e5b4f50178cc997272a8709a1c010d0efdb98b7ece7ad6a87f4d7d1573f8d378242bd4c91c1602d languageName: node linkType: hard -"eslint-plugin-playwright@npm:^2.4.0": - version: 2.4.0 - resolution: "eslint-plugin-playwright@npm:2.4.0" +"eslint-plugin-playwright@npm:^2.5.1": + version: 2.5.1 + resolution: "eslint-plugin-playwright@npm:2.5.1" dependencies: globals: "npm:^16.4.0" peerDependencies: eslint: ">=8.40.0" - checksum: 10c0/ed6085835b4b9e61662abf24c9b7e94b2cd9046bb2566a42e833efcdc7032729821bdf08eee1171dad6772929ccce9eb71e15435f375026b7006985e0dbc57db + checksum: 10c0/b8b752f8692b20b062f218be344c25ad366192b15e4b5c764e25b67a1fa6cfaffb67456aadd6fce5bc8ac7b0922a4413b76be35c7121a3c76e2fc09d09071f53 languageName: node linkType: hard -"eslint-plugin-prettier@npm:^5.5.4": - version: 5.5.4 - resolution: "eslint-plugin-prettier@npm:5.5.4" +"eslint-plugin-prettier@npm:^5.5.5": + version: 5.5.5 + resolution: "eslint-plugin-prettier@npm:5.5.5" dependencies: - prettier-linter-helpers: "npm:^1.0.0" - synckit: "npm:^0.11.7" + prettier-linter-helpers: "npm:^1.0.1" + synckit: "npm:^0.11.12" peerDependencies: "@types/eslint": ">=8.0.0" eslint: ">=8.0.0" @@ -2974,7 +2966,7 @@ __metadata: optional: true eslint-config-prettier: optional: true - checksum: 10c0/5cc780e0ab002f838ad8057409e86de4ff8281aa2704a50fa8511abff87028060c2e45741bc9cbcbd498712e8d189de8026e70aed9e20e50fe5ba534ee5a8442 + checksum: 10c0/091449b28c77ab2efbbf674e977181f2c8453d95a4df68218bddd87a4dfaa9ecc4eda60164e416f5986fb5d577e66e8d8e1e23d81e8555f8d735375598b03257 languageName: node linkType: hard @@ -3002,6 +2994,13 @@ __metadata: languageName: node linkType: hard +"eslint-visitor-keys@npm:^5.0.0": + version: 5.0.0 + resolution: "eslint-visitor-keys@npm:5.0.0" + checksum: 10c0/5ec68b7ae350f6e7813a9ab469f8c64e01e5a954e6e6ee6dc441cc24d315eb342e5fb81ab5fc21f352cf0125096ab4ed93ca892f602a1576ad1eedce591fe64a + languageName: node + linkType: hard + "eslint@npm:^9.39.2": version: 9.39.2 resolution: "eslint@npm:9.39.2" @@ -3062,7 +3061,18 @@ __metadata: languageName: node linkType: hard -"esquery@npm:^1.5.0, esquery@npm:^1.6.0": +"espree@npm:^11.1.0": + version: 11.1.0 + resolution: "espree@npm:11.1.0" + dependencies: + acorn: "npm:^8.15.0" + acorn-jsx: "npm:^5.3.2" + eslint-visitor-keys: "npm:^5.0.0" + checksum: 10c0/32228d12896f5aa09f59fad8bf5df228d73310e436c21389876cdd21513b620c087d24b40646cdcff848540d11b078653db0e37ea67ac9c7012a12595d86630c + languageName: node + linkType: hard + +"esquery@npm:^1.5.0": version: 1.6.0 resolution: "esquery@npm:1.6.0" dependencies: @@ -3071,6 +3081,15 @@ __metadata: languageName: node linkType: hard +"esquery@npm:^1.7.0": + version: 1.7.0 + resolution: "esquery@npm:1.7.0" + dependencies: + estraverse: "npm:^5.1.0" + checksum: 10c0/77d5173db450b66f3bc685d11af4c90cffeedb340f34a39af96d43509a335ce39c894fd79233df32d38f5e4e219fa0f7076f6ec90bae8320170ba082c0db4793 + languageName: node + linkType: hard + "esrecurse@npm:^4.3.0": version: 4.3.0 resolution: "esrecurse@npm:4.3.0" @@ -3265,18 +3284,18 @@ __metadata: resolution: "frontend@workspace:frontend" dependencies: "@codecov/vite-plugin": "npm:^1.9.1" - "@furystack/cache": "npm:^5.0.25" - "@furystack/core": "npm:^15.0.31" - "@furystack/inject": "npm:^12.0.25" - "@furystack/logging": "npm:^8.0.25" - "@furystack/rest": "npm:^8.0.31" - "@furystack/rest-client-fetch": "npm:^8.0.31" - "@furystack/shades": "npm:^11.0.32" - "@furystack/shades-common-components": "npm:^10.0.32" - "@furystack/shades-lottie": "npm:^7.0.32" - "@furystack/utils": "npm:^8.1.7" + "@furystack/cache": "npm:^5.0.28" + "@furystack/core": "npm:^15.0.34" + "@furystack/inject": "npm:^12.0.28" + "@furystack/logging": "npm:^8.0.28" + "@furystack/rest": "npm:^8.0.34" + "@furystack/rest-client-fetch": "npm:^8.0.34" + "@furystack/shades": "npm:^11.0.35" + "@furystack/shades-common-components": "npm:^10.0.35" + "@furystack/shades-lottie": "npm:^7.0.35" + "@furystack/utils": "npm:^8.1.9" "@types/marked": "npm:^6.0.0" - "@types/node": "npm:^25.0.3" + "@types/node": "npm:^25.0.10" "@xterm/addon-fit": "npm:^0.11.0" "@xterm/addon-search": "npm:^0.16.0" "@xterm/addon-web-links": "npm:^0.12.0" @@ -4157,17 +4176,6 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-source-maps@npm:^5.0.6": - version: 5.0.6 - resolution: "istanbul-lib-source-maps@npm:5.0.6" - dependencies: - "@jridgewell/trace-mapping": "npm:^0.3.23" - debug: "npm:^4.1.1" - istanbul-lib-coverage: "npm:^3.0.0" - checksum: 10c0/ffe75d70b303a3621ee4671554f306e0831b16f39ab7f4ab52e54d356a5d33e534d97563e318f1333a6aae1d42f91ec49c76b6cd3f3fb378addcb5c81da0255f - languageName: node - linkType: hard - "istanbul-reports@npm:^3.2.0": version: 3.2.0 resolution: "istanbul-reports@npm:3.2.0" @@ -4218,10 +4226,10 @@ __metadata: languageName: node linkType: hard -"jsdoc-type-pratt-parser@npm:~6.10.0": - version: 6.10.0 - resolution: "jsdoc-type-pratt-parser@npm:6.10.0" - checksum: 10c0/8ea395df0cae0e41d4bdba5f8d81b8d3e467fe53d1e4182a5d4e653235a5f17d60ed137343d68dbc74fa10e767f1c58fb85b1f6d5489c2cf16fc7216cc6d3e1a +"jsdoc-type-pratt-parser@npm:~7.1.0": + version: 7.1.0 + resolution: "jsdoc-type-pratt-parser@npm:7.1.0" + checksum: 10c0/440c40b465c0bc2611aa1187cc47778ec3caf47512184ba1d3491efa16fffdc180bb41ec43136b7faac9fe41c1fdd2ab17aa2422df7c656c006897ebfd9d448f languageName: node linkType: hard @@ -5307,25 +5315,25 @@ __metadata: resolution: "pi-rat@workspace:." dependencies: "@eslint/js": "npm:^9.39.2" - "@playwright/test": "npm:^1.57.0" - "@types/jsdom": "npm:^27" - "@types/node": "npm:^25.0.3" - "@vitest/coverage-v8": "npm:^4.0.16" + "@playwright/test": "npm:^1.58.0" + "@types/jsdom": "npm:^27.0.0" + "@types/node": "npm:^25.0.10" + "@vitest/coverage-v8": "npm:^4.0.18" eslint: "npm:^9.39.2" eslint-config-prettier: "npm:^10.1.8" eslint-plugin-import: "npm:2.32.0" - eslint-plugin-jsdoc: "npm:^61.5.0" - eslint-plugin-playwright: "npm:^2.4.0" - eslint-plugin-prettier: "npm:^5.5.4" + eslint-plugin-jsdoc: "npm:^62.4.1" + eslint-plugin-playwright: "npm:^2.5.1" + eslint-plugin-prettier: "npm:^5.5.5" husky: "npm:^9.1.7" jsdom: "npm:^27.4.0" lint-staged: "npm:^16.2.7" - prettier: "npm:^3.7.4" + prettier: "npm:^3.8.1" rimraf: "npm:^6.1.2" typescript: "npm:^5.9.3" - typescript-eslint: "npm:^8.52.0" + typescript-eslint: "npm:^8.53.1" vite: "npm:^7.3.1" - vitest: "npm:^4.0.16" + vitest: "npm:^4.0.18" languageName: unknown linkType: soft @@ -5377,27 +5385,27 @@ __metadata: languageName: node linkType: hard -"playwright-core@npm:1.57.0": - version: 1.57.0 - resolution: "playwright-core@npm:1.57.0" +"playwright-core@npm:1.58.0": + version: 1.58.0 + resolution: "playwright-core@npm:1.58.0" bin: playwright-core: cli.js - checksum: 10c0/798e35d83bf48419a8c73de20bb94d68be5dde68de23f95d80a0ebe401e3b83e29e3e84aea7894d67fa6c79d2d3d40cc5bcde3e166f657ce50987aaa2421b6a9 + checksum: 10c0/72f59ffed79357f3042ba8ebced54d63a1bdb01e4422e1d3238415ac1ee25254d4a1809f77c562045fc25805e71ad3de8e8ac0e3521e48b9c6f108cd5419ec48 languageName: node linkType: hard -"playwright@npm:1.57.0": - version: 1.57.0 - resolution: "playwright@npm:1.57.0" +"playwright@npm:1.58.0": + version: 1.58.0 + resolution: "playwright@npm:1.58.0" dependencies: fsevents: "npm:2.3.2" - playwright-core: "npm:1.57.0" + playwright-core: "npm:1.58.0" dependenciesMeta: fsevents: optional: true bin: playwright: cli.js - checksum: 10c0/ab03c99a67b835bdea9059f516ad3b6e42c21025f9adaa161a4ef6bc7ca716dcba476d287140bb240d06126eb23f889a8933b8f5f1f1a56b80659d92d1358899 + checksum: 10c0/f0565966a485f0bf0748897c19aa403f97c4c83bb936b7dfc6b2f1c056d4befb24810f451e4b8cb760db3733bfef1cf7f00b369664637e48befd6fe9e42f541a languageName: node linkType: hard @@ -5448,21 +5456,21 @@ __metadata: languageName: node linkType: hard -"prettier-linter-helpers@npm:^1.0.0": - version: 1.0.0 - resolution: "prettier-linter-helpers@npm:1.0.0" +"prettier-linter-helpers@npm:^1.0.1": + version: 1.0.1 + resolution: "prettier-linter-helpers@npm:1.0.1" dependencies: fast-diff: "npm:^1.1.2" - checksum: 10c0/81e0027d731b7b3697ccd2129470ed9913ecb111e4ec175a12f0fcfab0096516373bf0af2fef132af50cafb0a905b74ff57996d615f59512bb9ac7378fcc64ab + checksum: 10c0/91cea965681bc5f62c9d26bd3ca6358b81557261d4802e96ec1cf0acbd99d4b61632d53320cd2c3ec7d7f7805a81345644108a41ef46ddc9688e783a9ac792d1 languageName: node linkType: hard -"prettier@npm:^3.7.4": - version: 3.7.4 - resolution: "prettier@npm:3.7.4" +"prettier@npm:^3.8.1": + version: 3.8.1 + resolution: "prettier@npm:3.8.1" bin: prettier: bin/prettier.cjs - checksum: 10c0/9675d2cd08eacb1faf1d1a2dbfe24bfab6a912b059fc9defdb380a408893d88213e794a40a2700bd29b140eb3172e0b07c852853f6e22f16f3374659a1a13389 + checksum: 10c0/33169b594009e48f570471271be7eac7cdcf88a209eed39ac3b8d6d78984039bfa9132f82b7e6ba3b06711f3bfe0222a62a1bfb87c43f50c25a83df1b78a2c42 languageName: node linkType: hard @@ -5908,20 +5916,20 @@ __metadata: version: 0.0.0-use.local resolution: "service@workspace:service" dependencies: - "@furystack/cache": "npm:^5.0.25" - "@furystack/core": "npm:^15.0.31" - "@furystack/inject": "npm:^12.0.25" - "@furystack/logging": "npm:^8.0.25" - "@furystack/repository": "npm:^10.0.31" - "@furystack/rest": "npm:^8.0.31" - "@furystack/rest-service": "npm:^10.1.3" - "@furystack/security": "npm:^6.0.31" - "@furystack/sequelize-store": "npm:^6.0.31" - "@furystack/utils": "npm:^8.1.7" - "@furystack/websocket-api": "npm:^13.1.3" + "@furystack/cache": "npm:^5.0.28" + "@furystack/core": "npm:^15.0.34" + "@furystack/inject": "npm:^12.0.28" + "@furystack/logging": "npm:^8.0.28" + "@furystack/repository": "npm:^10.0.34" + "@furystack/rest": "npm:^8.0.34" + "@furystack/rest-service": "npm:^11.0.2" + "@furystack/security": "npm:^6.0.34" + "@furystack/sequelize-store": "npm:^6.0.35" + "@furystack/utils": "npm:^8.1.9" + "@furystack/websocket-api": "npm:^13.1.6" "@types/ffprobe": "npm:^1.1.8" "@types/formidable": "npm:^3.4.6" - "@types/node": "npm:^25.0.3" + "@types/node": "npm:^25.0.10" "@types/ping": "npm:^0.4.4" chokidar: "npm:^5.0.0" common: "workspace:^" @@ -5932,7 +5940,7 @@ __metadata: sequelize: "npm:^6.37.7" sqlite3: "npm:^5.1.7" typescript: "npm:^5.9.3" - vitest: "npm:^4.0.16" + vitest: "npm:^4.0.18" languageName: unknown linkType: soft @@ -6385,12 +6393,12 @@ __metadata: languageName: node linkType: hard -"synckit@npm:^0.11.7": - version: 0.11.11 - resolution: "synckit@npm:0.11.11" +"synckit@npm:^0.11.12": + version: 0.11.12 + resolution: "synckit@npm:0.11.12" dependencies: "@pkgr/core": "npm:^0.2.9" - checksum: 10c0/f0761495953d12d94a86edf6326b3a565496c72f9b94c02549b6961fb4d999f4ca316ce6b3eb8ed2e4bfc5056a8de65cda0bd03a233333a35221cd2fdc0e196b + checksum: 10c0/cc4d446806688ae0d728ae7bb3f53176d065cf9536647fb85bdd721dcefbd7bf94874df6799ff61580f2b03a392659219b778a9254ad499f9a1f56c34787c235 languageName: node linkType: hard @@ -6663,18 +6671,18 @@ __metadata: languageName: node linkType: hard -"typescript-eslint@npm:^8.52.0": - version: 8.52.0 - resolution: "typescript-eslint@npm:8.52.0" +"typescript-eslint@npm:^8.53.1": + version: 8.53.1 + resolution: "typescript-eslint@npm:8.53.1" dependencies: - "@typescript-eslint/eslint-plugin": "npm:8.52.0" - "@typescript-eslint/parser": "npm:8.52.0" - "@typescript-eslint/typescript-estree": "npm:8.52.0" - "@typescript-eslint/utils": "npm:8.52.0" + "@typescript-eslint/eslint-plugin": "npm:8.53.1" + "@typescript-eslint/parser": "npm:8.53.1" + "@typescript-eslint/typescript-estree": "npm:8.53.1" + "@typescript-eslint/utils": "npm:8.53.1" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/9ea293bec97748280f6018ff8287497323ad8f31f3b1b28f6b17444e272623e6a27bacd2cb217bbb9cf3401c52196188a9a4b4a703f5dda09405b35927c04c6b + checksum: 10c0/520d68df8e1e1bba99c2713029b63837b101370c460bf5e75b8065fb0a6bc1ac9c6eb967432dbc220464479fe981630a6b2eddf31cfb378441ee8b8a43c0eb5a languageName: node linkType: hard @@ -6940,17 +6948,17 @@ __metadata: languageName: node linkType: hard -"vitest@npm:^4.0.16": - version: 4.0.16 - resolution: "vitest@npm:4.0.16" +"vitest@npm:^4.0.18": + version: 4.0.18 + resolution: "vitest@npm:4.0.18" dependencies: - "@vitest/expect": "npm:4.0.16" - "@vitest/mocker": "npm:4.0.16" - "@vitest/pretty-format": "npm:4.0.16" - "@vitest/runner": "npm:4.0.16" - "@vitest/snapshot": "npm:4.0.16" - "@vitest/spy": "npm:4.0.16" - "@vitest/utils": "npm:4.0.16" + "@vitest/expect": "npm:4.0.18" + "@vitest/mocker": "npm:4.0.18" + "@vitest/pretty-format": "npm:4.0.18" + "@vitest/runner": "npm:4.0.18" + "@vitest/snapshot": "npm:4.0.18" + "@vitest/spy": "npm:4.0.18" + "@vitest/utils": "npm:4.0.18" es-module-lexer: "npm:^1.7.0" expect-type: "npm:^1.2.2" magic-string: "npm:^0.30.21" @@ -6968,10 +6976,10 @@ __metadata: "@edge-runtime/vm": "*" "@opentelemetry/api": ^1.9.0 "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 - "@vitest/browser-playwright": 4.0.16 - "@vitest/browser-preview": 4.0.16 - "@vitest/browser-webdriverio": 4.0.16 - "@vitest/ui": 4.0.16 + "@vitest/browser-playwright": 4.0.18 + "@vitest/browser-preview": 4.0.18 + "@vitest/browser-webdriverio": 4.0.18 + "@vitest/ui": 4.0.18 happy-dom: "*" jsdom: "*" peerDependenciesMeta: @@ -6995,7 +7003,7 @@ __metadata: optional: true bin: vitest: vitest.mjs - checksum: 10c0/b195c272198f7957c11186eb70ee78e2ec0f4524b4b5306ca8f05e41b3d84c6a4a15d02fca58d82f2b32ba61f610ae8a2a23d463a8336d7323e4832db5eef223 + checksum: 10c0/b913cd32032c95f29ff08c931f4b4c6fd6d2da498908d6770952c561a1b8d75c62499a1f04cadf82fb89cc0f9a33f29fb5dfdb899f6dbb27686a9d91571be5fa languageName: node linkType: hard