Did some things, made more improvements.
Some checks failed
CI/CD Pipeline / Backend Tests (Python) (push) Has been cancelled
CI/CD Pipeline / Frontend Tests (TypeScript/Vue) (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled

Signed-off-by: Cliff Hill <xlorep@darkhelm.org>
This commit is contained in:
2025-10-19 21:35:02 -04:00
parent 6068faf026
commit 8aa8d41e8a
21 changed files with 3247 additions and 406 deletions

17
frontend/env.d.ts vendored
View File

@@ -5,3 +5,20 @@ declare module '*.vue' {
const component: DefineComponent<{}, {}, any>
export default component
}
// Vite environment variables
interface ImportMetaEnv {
readonly VITE_API_URL?: string
readonly DEV: boolean
readonly PROD: boolean
readonly VITEST: boolean
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
// Global variables for automatic validation
declare global {
var __AUTO_VALIDATE__: boolean | undefined
}

View File

@@ -11,23 +11,34 @@
"lint:fix": "eslint . --fix",
"lint:tsdoc": "eslint . --fix",
"format": "prettier --write src/",
"type-check": "vue-tsc --noEmit"
"format:check": "prettier --check src/",
"type-check": "vue-tsc --noEmit",
"test": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test"
},
"dependencies": {
"vue": "^3.4.0"
"vue": "^3.4.0",
"zod": "^3.22.0"
},
"devDependencies": {
"@playwright/test": "^1.40.0",
"@types/node": "^20.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitejs/plugin-vue": "^5.0.0",
"@vitest/coverage-v8": "^3.2.4",
"@vue/test-utils": "^2.4.0",
"@vue/tsconfig": "^0.5.0",
"eslint": "^9.33.0",
"eslint-plugin-jsdoc": "^50.0.0",
"eslint-plugin-tsdoc": "^0.3.0",
"eslint-plugin-vue": "^9.28.0",
"jsdom": "^23.0.0",
"prettier": "^3.0.0",
"typescript": "~5.3.0",
"vite": "^5.0.0",
"vite": "^7.1.10",
"vitest": "^3.2.4",
"vue-eslint-parser": "^9.4.0",
"vue-tsc": "^2.0.0"
}

View File

@@ -0,0 +1,38 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
timeout: 30 * 1000,
expect: {
timeout: 5000
},
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
}
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
}
})

126
frontend/src/validation.ts Normal file
View File

@@ -0,0 +1,126 @@
/**
* Hypermodern Zod validation setup - automatic runtime validation without explicit .parse() calls
* Follows the same philosophy as typeguard for Python
*/
import { z } from 'zod';
// Global schemas for automatic validation
export const PlaylistSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
description: z.string().optional(),
tracks: z.array(z.string()).default([]),
});
export const UserSchema = z.object({
id: z.number(),
username: z.string().min(1),
email: z.string().email(),
preferences: z
.object({
theme: z.enum(['light', 'dark']).default('light'),
notifications: z.boolean().default(true),
})
.default({}),
});
// Inferred types (no manual type definitions needed)
export type Playlist = z.infer<typeof PlaylistSchema>;
export type User = z.infer<typeof UserSchema>;
/**
* Global schema registry for automatic validation
*/
class SchemaRegistry {
private static schemas = new Map<string, z.ZodTypeAny>([
['Playlist', PlaylistSchema as z.ZodTypeAny],
['User', UserSchema as z.ZodTypeAny],
]);
static register<T extends z.ZodTypeAny>(name: string, schema: T): void {
this.schemas.set(name, schema as z.ZodTypeAny);
}
static get(name: string): z.ZodTypeAny | undefined {
return this.schemas.get(name);
}
static validate<T>(typeName: string, data: unknown): T {
const schema = this.schemas.get(typeName);
if (!schema) {
throw new Error(`No schema registered for type: ${typeName}`);
}
return schema.parse(data) as T;
}
static safeValidate<T>(
typeName: string,
data: unknown
): { success: true; data: T } | { success: false; error: z.ZodError } {
const schema = this.schemas.get(typeName);
if (!schema) {
return {
success: false,
error: new z.ZodError([
{
code: 'custom',
message: `No schema registered for type: ${typeName}`,
path: [],
},
]),
};
}
const result = schema.safeParse(data);
return result.success
? { success: true, data: result.data as T }
: { success: false, error: result.error };
}
}
/**
* Automatic validation wrapper - works like typeguard for Python
* In development/test: validates automatically
* In production: can be stripped by build process
*/
export class AutoValidator {
static validate<T>(typeName: string, data: unknown): T {
// Check if auto-validation is enabled (development/test mode)
if (typeof globalThis !== 'undefined' && (globalThis as any).__AUTO_VALIDATE__) {
return SchemaRegistry.validate<T>(typeName, data);
}
// In production, validation is skipped for performance
return data as T;
}
static safeValidate<T>(
typeName: string,
data: unknown
): { success: true; data: T } | { success: false; error: z.ZodError } {
if (typeof globalThis !== 'undefined' && (globalThis as any).__AUTO_VALIDATE__) {
return SchemaRegistry.safeValidate<T>(typeName, data);
}
// In production, assume data is valid
return { success: true, data: data as T };
}
static register<T extends z.ZodTypeAny>(name: string, schema: T): void {
SchemaRegistry.register(name, schema);
}
}
// Enable automatic validation in development/test environments
declare global {
var __AUTO_VALIDATE__: boolean;
}
// Check environment and log status
const isDev = typeof globalThis !== 'undefined' && (globalThis as any).__AUTO_VALIDATE__;
if (isDev) {
console.log('🛡️ Hypermodern Zod validation enabled (development/test mode)');
} else {
console.log('🏃‍♂️ Production mode - Zod validation optimized away');
}

View File

@@ -0,0 +1,35 @@
/**
* End-to-end tests using Playwright
*/
import { test, expect } from '@playwright/test'
test.describe('Plex Playlist App', () => {
test('should display app title', async ({ page }) => {
await page.goto('/')
await expect(page.locator('h1')).toContainText('Plex Playlist')
})
test('should have welcome message', async ({ page }) => {
await page.goto('/')
await expect(page.locator('p')).toContainText('Welcome to the Plex Playlist Manager')
})
test('should load without errors', async ({ page }) => {
const errors: string[] = []
page.on('console', (msg) => {
if (msg.type() === 'error') {
errors.push(msg.text())
}
})
await page.goto('/')
// Wait for app to fully load
await page.waitForLoadState('networkidle')
expect(errors).toHaveLength(0)
})
})

View File

@@ -0,0 +1,123 @@
/**
* Example unit tests with automatic Zod validation
* Uses hypermodern approach where validation happens automatically
*/
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import App from '@/App.vue'
import { AutoValidator, type Playlist, type User } from '@/validation'
/**
* Creates a playlist - validation happens automatically in development/test mode
* @param name - Playlist name
* @param description - Optional description
* @returns Playlist object (automatically validated)
*/
function createPlaylist(name: string, description?: string): Playlist {
const rawData = {
id: crypto.randomUUID(),
name,
description,
tracks: []
}
// In hypermodern setup, this would be handled by build-time transform
// For now, we use our auto-validator
return AutoValidator.validate<Playlist>('Playlist', rawData)
}
/**
* Simulates API response handling with automatic validation
* @param apiResponse - Raw API data
* @returns Validated user object
*/
function handleUserApiResponse(apiResponse: unknown): User {
// Validation happens automatically - no explicit .parse() needed
return AutoValidator.validate<User>('User', apiResponse)
}
describe('App.vue', () => {
it('renders properly', () => {
const wrapper = mount(App)
expect(wrapper.text()).toContain('Plex Playlist')
})
})
describe('Automatic Zod validation (hypermodern approach)', () => {
it('validates playlist creation automatically', () => {
const playlist = createPlaylist('Test Playlist', 'Test description')
expect(playlist.name).toBe('Test Playlist')
expect(playlist.description).toBe('Test description')
expect(playlist.tracks).toEqual([])
expect(playlist.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
})
it('creates playlist without description automatically', () => {
const playlist = createPlaylist('Simple Playlist')
expect(playlist.name).toBe('Simple Playlist')
expect(playlist.description).toBeUndefined()
expect(playlist.tracks).toEqual([])
})
it('automatically validates API responses', () => {
const apiResponse = {
id: 123,
username: 'testuser',
email: 'test@example.com',
preferences: {
theme: 'dark',
notifications: false
}
}
const user = handleUserApiResponse(apiResponse)
expect(user.username).toBe('testuser')
expect(user.preferences.theme).toBe('dark')
})
it('automatically catches validation errors', () => {
const invalidPlaylistData = {
id: 'not-a-uuid', // Invalid UUID
name: '', // Empty name
tracks: 'invalid' // Should be array
}
expect(() => {
AutoValidator.validate<Playlist>('Playlist', invalidPlaylistData)
}).toThrow()
})
it('provides safe validation with error handling', () => {
const malformedData = {
id: 'not-a-uuid',
description: 'Missing name field'
}
const result = AutoValidator.safeValidate<Playlist>('Playlist', malformedData)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues).toBeDefined()
expect(result.error.issues.length).toBeGreaterThan(0)
}
})
it('handles successful safe validation', () => {
const validData = {
id: crypto.randomUUID(),
name: 'Valid Playlist',
tracks: ['track1', 'track2']
}
const result = AutoValidator.safeValidate<Playlist>('Playlist', validData)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.name).toBe('Valid Playlist')
expect(result.data.tracks).toEqual(['track1', 'track2'])
}
})
})

View File

@@ -14,11 +14,19 @@
"paths": {
"@/*": ["./src/*"]
},
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
"exactOptionalPropertyTypes": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
}
}

View File

@@ -2,9 +2,34 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
// Custom plugin for automatic Zod validation in development
function zodAutoValidation() {
return {
name: 'zod-auto-validation',
transform(code: string, id: string) {
// Only apply in development/test environments
if (process.env.NODE_ENV === 'production') {
return null
}
// Add automatic validation for functions with Zod schemas
if (id.includes('src/') && id.endsWith('.ts')) {
// This would normally be a more sophisticated transform
// For now, we rely on our AutoValidator pattern
return null
}
return null
}
}
}
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
plugins: [
vue(),
zodAutoValidation()
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
@@ -17,8 +42,12 @@ export default defineConfig({
'/api': {
target: 'http://backend:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
rewrite: (path: string) => path.replace(/^\/api/, '')
}
}
},
define: {
// Enable automatic validation in development
__AUTO_VALIDATE__: JSON.stringify(process.env.NODE_ENV !== 'production')
}
})

29
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,29 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
test: {
environment: 'jsdom',
globals: true,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'dist/',
'**/*.config.*',
'**/*.d.ts',
'coverage/',
'tests/',
'playwright.config.ts'
]
}
}
})

File diff suppressed because it is too large Load Diff