Did some things, made more improvements.
Signed-off-by: Cliff Hill <xlorep@darkhelm.org>
This commit is contained in:
17
frontend/env.d.ts
vendored
17
frontend/env.d.ts
vendored
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
38
frontend/playwright.config.ts
Normal file
38
frontend/playwright.config.ts
Normal 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
126
frontend/src/validation.ts
Normal 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');
|
||||
}
|
||||
35
frontend/tests/e2e/app.spec.ts
Normal file
35
frontend/tests/e2e/app.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
123
frontend/tests/unit/App.test.ts
Normal file
123
frontend/tests/unit/App.test.ts
Normal 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'])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
29
frontend/vitest.config.ts
Normal 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'
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
1794
frontend/yarn.lock
1794
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user