diff --git a/.cursor/rules/CODE_STYLE.mdc b/.cursor/rules/CODE_STYLE.mdc new file mode 100644 index 0000000..1c5c3c7 --- /dev/null +++ b/.cursor/rules/CODE_STYLE.mdc @@ -0,0 +1,285 @@ +--- +name: Code Style +description: Formatting, naming conventions, import ordering, and file organization for the Boilerplate app +globs: + - '**/*.ts' + - '**/*.tsx' + - '**/*.js' + - '**/*.jsx' +alwaysApply: true +--- + +# Code Style Guidelines + +## Formatting Standards + +### Prettier Configuration + +This project uses Prettier for code formatting. **Always use the project's Prettier configuration**. + +**Configuration file:** `prettier.config.js` + +```bash +# Format code +yarn prettier + +# Check formatting +yarn prettier:check +``` + +### ESLint Configuration + +This project uses ESLint for code quality. **Always use the project's ESLint configuration**. + +**Configuration file:** `eslint.config.js` + +```bash +# Lint code +yarn lint +``` + +### Automated Formatting + +Code is automatically formatted on commit via Husky and lint-staged: + +```json +{ + "lint-staged": { + "*.{ts,tsx}": ["eslint --ext .tsx,.ts --cache --fix", "prettier --write", "git add"] + } +} +``` + +## Naming Conventions + +### Files and Directories + +#### Source Files + +- **kebab-case** for all files +- `.ts` for TypeScript, `.tsx` for JSX components +- One main export per file + +``` +✅ Good: +frontend/src/components/theme-switch/index.tsx +frontend/src/services/session.ts +service/src/shutdown-handler.ts + +❌ Avoid: +frontend/src/components/ThemeSwitch.tsx +frontend/src/services/Session.ts +service/src/ShutdownHandler.ts +``` + +#### Test Files + +- **Same name as source** with `.spec.ts` suffix +- Co-located with source file when possible + +``` +✅ Good: +service/src/config.ts +service/src/config.spec.ts + +❌ Avoid: +service/test/config.spec.ts (not co-located) +service/src/config.test.ts (use .spec.ts) +``` + +### Components and Classes + +- **PascalCase** for components, classes, and types +- Descriptive and clear purpose + +```typescript +// ✅ Good +export const ThemeSwitch = createComponent(...) +export class SessionService {} +export type BoilerplateApi = {} + +// ❌ Avoid +export const themeSwitch = createComponent(...) // Wrong case +export class sessionService {} // Wrong case +``` + +### Functions and Variables + +- **camelCase** for functions and variables +- **UPPER_SNAKE_CASE** for constants + +```typescript +// ✅ Good +export function getCurrentUser() {} +export const isAuthenticated = true; +export const DEFAULT_PORT = 9090; + +// ❌ Avoid +export function GetCurrentUser() {} +export const IsAuthenticated = true; +``` + +## Project Structure + +### Workspace Organization + +``` +boilerplate/ +├── common/ # Shared types and API definitions +│ ├── src/ +│ │ ├── index.ts # Main exports +│ │ ├── boilerplate-api.ts # API type definitions +│ │ └── models/ # Entity models +│ └── schemas/ # JSON schemas for validation +├── frontend/ # Shades-based frontend +│ └── src/ +│ ├── index.tsx # App entry point +│ ├── components/ # Reusable components +│ ├── pages/ # Page components +│ └── services/ # Frontend services +├── service/ # REST backend service +│ └── src/ +│ ├── service.ts # Service entry point +│ ├── config.ts # Configuration +│ └── seed.ts # Database seeding +└── e2e/ # End-to-end tests + └── page.spec.ts +``` + +### Component File Structure + +Components should be organized in directories when they have assets: + +``` +components/ +├── theme-switch/ +│ └── index.tsx # Component with no assets +├── github-logo/ +│ ├── index.tsx # Component +│ ├── gh-dark.png # Dark theme asset +│ └── gh-light.png # Light theme asset +└── header.tsx # Simple component (no directory needed) +``` + +## Import Ordering + +### Import Order + +1. **Node built-ins** +2. **External dependencies** (npm packages) +3. **FuryStack packages** (`@furystack/*`) +4. **Workspace packages** (`common`) +5. **Relative imports** +6. **Type imports** (if separated) + +```typescript +// ✅ Good - organized imports +import { EventEmitter } from 'events'; + +import { createComponent, Shade } from '@furystack/shades'; +import { Injector } from '@furystack/inject'; + +import type { BoilerplateApi } from 'common'; + +import { SessionService } from '../services/session.js'; +import type { UserProps } from './types.js'; + +// ❌ Avoid - random ordering +import { SessionService } from '../services/session.js'; +import { EventEmitter } from 'events'; +import { Injector } from '@furystack/inject'; +``` + +### Import Grouping + +Separate import groups with blank lines: + +```typescript +// ✅ Good - separated groups +import { Injector } from '@furystack/inject'; +import { getLogger, useLogging } from '@furystack/logging'; + +import type { User } from 'common'; + +import { SessionService } from './services/session.js'; +``` + +## Code Organization + +### Service Class Structure + +```typescript +// ✅ Good - organized service +@Injectable({ lifetime: 'singleton' }) +export class SessionService { + // 1. Injected dependencies + @Injected(ApiClient) + private declare apiClient: ApiClient; + + // 2. Public properties + public currentUser = new ObservableValue(null); + + // 3. Constructor (if needed) + constructor() { + this.init(); + } + + // 4. Public methods + public async login(credentials: Credentials): Promise { + // Implementation + } + + // 5. Private methods + private async init(): Promise { + // Implementation + } + + // 6. Disposal + public [Symbol.dispose](): void { + // Cleanup + } +} +``` + +### Component File Structure + +```typescript +// 1. Imports +import { createComponent, Shade } from '@furystack/shades'; +import { Injected } from '@furystack/inject'; + +// 2. Types +type MyComponentProps = { + title: string; +}; + +// 3. Component +export const MyComponent = Shade({ + shadowDomName: 'my-component', + render: ({ props }) => { + return
{props.title}
; + }, +}); +``` + +## Summary + +**Key Principles:** + +1. **Follow project's Prettier and ESLint configs** +2. **Use consistent naming:** + - PascalCase: Components, classes, types + - camelCase: Functions, variables + - kebab-case: File names + - UPPER_SNAKE_CASE: Constants +3. **Organize imports** by category with blank lines +4. **Co-locate tests** with source files +5. **Structure components** in directories when they have assets + +**Tools:** + +- Prettier: `yarn prettier` +- ESLint: `yarn lint` +- Type Check: `yarn build` +- Unit Tests: `yarn test:unit` +- E2E Tests: `yarn test:e2e` diff --git a/.cursor/rules/REST_SERVICE.mdc b/.cursor/rules/REST_SERVICE.mdc new file mode 100644 index 0000000..40ffda8 --- /dev/null +++ b/.cursor/rules/REST_SERVICE.mdc @@ -0,0 +1,395 @@ +--- +name: REST Service +description: Backend REST service patterns using @furystack/rest-service +globs: + - 'service/**/*.ts' +alwaysApply: false +--- + +# REST Service Guidelines + +## Service Architecture + +### Entry Point Structure + +The service entry point (`service/src/service.ts`) sets up the REST API: + +```typescript +import type { BoilerplateApi } from 'common'; +import BoilerplateApiSchemas from 'common/schemas/boilerplate-api.json' with { type: 'json' }; + +import { + useHttpAuthentication, + useRestService, + useStaticFiles, +} from '@furystack/rest-service'; +import { injector } from './config.js'; + +// Set up authentication +useHttpAuthentication(injector, { + getUserStore: (sm) => sm.getStoreFor(User, 'username'), + getSessionStore: (sm) => sm.getStoreFor(DefaultSession, 'sessionId'), +}); + +// Set up REST API +useRestService({ + injector, + root: 'api', + port, + api: { + GET: { /* endpoints */ }, + POST: { /* endpoints */ }, + }, +}); + +// Serve static frontend files +useStaticFiles({ + injector, + baseUrl: '/', + path: '../frontend/dist', + port, + fallback: 'index.html', +}); +``` + +## API Type Definition + +### Define API in Common Package + +The API contract is defined in `common/src/boilerplate-api.ts`: + +```typescript +import type { RestApi } from '@furystack/rest'; +import type { User } from './models/index.js'; + +// Define endpoint types +export type GetUserEndpoint = { + url: { id: string }; + result: User; +}; + +export type CreateUserEndpoint = { + body: { username: string; email: string }; + result: User; +}; + +// Define the API interface +export interface BoilerplateApi extends RestApi { + GET: { + '/isAuthenticated': { result: { isAuthenticated: boolean } }; + '/currentUser': { result: User }; + '/users/:id': GetUserEndpoint; + }; + POST: { + '/login': { result: User; body: { username: string; password: string } }; + '/logout': { result: unknown }; + '/users': CreateUserEndpoint; + }; +} +``` + +## Endpoint Implementation + +### Using Built-in Actions + +Use FuryStack's built-in actions when possible: + +```typescript +import { + GetCurrentUser, + IsAuthenticated, + LoginAction, + LogoutAction, +} from '@furystack/rest-service'; + +useRestService({ + injector, + api: { + GET: { + '/currentUser': GetCurrentUser, + '/isAuthenticated': IsAuthenticated, + }, + POST: { + '/login': LoginAction, + '/logout': LogoutAction, + }, + }, +}); +``` + +### Custom Endpoint Actions + +Create custom actions with proper typing: + +```typescript +import { JsonResult, Validate } from '@furystack/rest-service'; + +useRestService({ + injector, + api: { + GET: { + '/testQuery': Validate({ schema: BoilerplateApiSchemas, schemaName: 'TestQueryEndpoint' })( + async (options) => JsonResult({ param1Value: options.getQuery().param1 }) + ), + '/testUrlParams/:urlParam': Validate({ schema: BoilerplateApiSchemas, schemaName: 'TestUrlParamsEndpoint' })( + async (options) => JsonResult({ urlParamValue: options.getUrlParams().urlParam }) + ), + }, + POST: { + '/testPostBody': Validate({ schema: BoilerplateApiSchemas, schemaName: 'TestPostBodyEndpoint' })( + async (options) => { + const body = await options.getBody(); + return JsonResult({ bodyValue: body.value }); + } + ), + }, + }, +}); +``` + +## Request Validation + +### JSON Schema Validation + +Use the `Validate` wrapper with JSON schemas: + +```typescript +import { Validate, JsonResult } from '@furystack/rest-service'; +import BoilerplateApiSchemas from 'common/schemas/boilerplate-api.json' with { type: 'json' }; + +const endpoint = Validate({ + schema: BoilerplateApiSchemas, + schemaName: 'MyEndpointType', +})(async (options) => { + // Request is validated before reaching here + const body = await options.getBody(); + return JsonResult({ success: true }); +}); +``` + +### Generate Schemas + +Generate JSON schemas from TypeScript types: + +```bash +# Generate schemas +yarn create-schemas +``` + +The schema generator is configured in `common/src/bin/create-schemas.ts`. + +## Configuration + +### Injector Setup + +Set up the injector with stores and services in `service/src/config.ts`: + +```typescript +import { addStore, InMemoryStore } from '@furystack/core'; +import { FileSystemStore } from '@furystack/filesystem-store'; +import { Injector } from '@furystack/inject'; +import { useLogging, VerboseConsoleLogger } from '@furystack/logging'; +import { getRepository } from '@furystack/repository'; +import { usePasswordPolicy } from '@furystack/security'; +import { DefaultSession } from '@furystack/rest-service'; +import { User } from 'common'; + +export const injector = new Injector(); + +// Set up logging +useLogging(injector, VerboseConsoleLogger); + +// Add stores +addStore(injector, new FileSystemStore({ + model: User, + primaryKey: 'username', + fileName: join(process.cwd(), 'users.json'), +})); + +addStore(injector, new InMemoryStore({ + model: DefaultSession, + primaryKey: 'sessionId', +})); + +// Set up password policy +usePasswordPolicy(injector); +``` + +### Authorization + +Define authorization functions for data sets: + +```typescript +import { isAuthenticated } from '@furystack/core'; +import type { AuthorizationResult, DataSetSettings } from '@furystack/repository'; + +export const authorizedOnly = async (options: { injector: Injector }): Promise => { + const isAllowed = await isAuthenticated(options.injector); + return isAllowed + ? { isAllowed } + : { isAllowed, message: 'You are not authorized' }; +}; + +export const authorizedDataSet: Partial> = { + authorizeAdd: authorizedOnly, + authorizeGet: authorizedOnly, + authorizeRemove: authorizedOnly, + authorizeUpdate: authorizedOnly, +}; +``` + +## Store Types + +### FileSystemStore + +Use for persistent data stored as JSON files: + +```typescript +import { FileSystemStore } from '@furystack/filesystem-store'; + +addStore(injector, new FileSystemStore({ + model: User, + primaryKey: 'username', + tickMs: 30 * 1000, // Save interval + fileName: join(process.cwd(), 'users.json'), +})); +``` + +### InMemoryStore + +Use for session data or temporary storage: + +```typescript +import { InMemoryStore } from '@furystack/core'; + +addStore(injector, new InMemoryStore({ + model: DefaultSession, + primaryKey: 'sessionId', +})); +``` + +## Error Handling + +### Service Startup Errors + +Handle errors during service startup: + +```typescript +useRestService({ + injector, + // ... config +}).catch((err) => { + console.error(err); + process.exit(1); +}); +``` + +### Graceful Shutdown + +Implement graceful shutdown handling: + +```typescript +// service/src/shutdown-handler.ts +export const attachShutdownHandler = async (injector: Injector): Promise => { + const logger = getLogger(injector).withScope('ShutdownHandler'); + + const shutdown = async (signal: string) => { + await logger.information({ message: `Received ${signal}, shutting down...` }); + await injector[Symbol.asyncDispose](); + process.exit(0); + }; + + process.on('SIGTERM', () => void shutdown('SIGTERM')); + process.on('SIGINT', () => void shutdown('SIGINT')); +}; + +// In service.ts +void attachShutdownHandler(injector); +``` + +## Data Seeding + +### Seed Script + +Create a seed script for initial data: + +```typescript +// service/src/seed.ts +import { StoreManager } from '@furystack/core'; +import { PasswordAuthenticator, PasswordCredential } from '@furystack/security'; +import { User } from 'common'; +import { injector } from './config.js'; + +export const seed = async (i: Injector): Promise => { + const sm = i.getInstance(StoreManager); + const userStore = sm.getStoreFor(User, 'username'); + const pwcStore = sm.getStoreFor(PasswordCredential, 'userName'); + + // Create default user credentials + const cred = await i.getInstance(PasswordAuthenticator).hasher.createCredential( + 'testuser', + 'password' + ); + + // Save to stores + await pwcStore.add(cred); + await userStore.add({ username: 'testuser', roles: [] }); +}; + +await seed(injector); +await injector[Symbol.asyncDispose](); +``` + +Run with: + +```bash +yarn seed +``` + +## CORS Configuration + +### Configure CORS + +Set up CORS for frontend access: + +```typescript +useRestService({ + injector, + cors: { + credentials: true, + origins: ['http://localhost:8080'], + headers: ['cache', 'content-type'], + }, + // ... rest of config +}); +``` + +## Summary + +**Key Principles:** + +1. **Type the API** - Define API in `common` package with `RestApi` interface +2. **Use built-in actions** - `LoginAction`, `LogoutAction`, `GetCurrentUser`, etc. +3. **Validate requests** - Use `Validate` wrapper with JSON schemas +4. **Configure stores** - `FileSystemStore` for persistence, `InMemoryStore` for sessions +5. **Handle authorization** - Define authorization functions for data sets +6. **Graceful shutdown** - Implement proper cleanup with `Symbol.asyncDispose` +7. **CORS setup** - Configure for frontend origins + +**Service Checklist:** + +- [ ] API types defined in `common` package +- [ ] JSON schemas generated for validation +- [ ] Stores configured for all models +- [ ] Authentication set up with `useHttpAuthentication` +- [ ] Authorization functions defined +- [ ] CORS configured for frontend +- [ ] Graceful shutdown handler attached +- [ ] Error handling for startup failures + +**Commands:** + +- Start service: `yarn start:service` +- Seed data: `yarn seed` +- Generate schemas: `yarn create-schemas` +- Build: `yarn build` diff --git a/.cursor/rules/SHADES_COMPONENTS.mdc b/.cursor/rules/SHADES_COMPONENTS.mdc new file mode 100644 index 0000000..ea9e0f1 --- /dev/null +++ b/.cursor/rules/SHADES_COMPONENTS.mdc @@ -0,0 +1,343 @@ +--- +name: Shades Components +description: Frontend component patterns using @furystack/shades and common-components +globs: + - 'frontend/**/*.tsx' + - 'frontend/**/*.ts' +alwaysApply: false +--- + +# Shades Component Guidelines + +## Component Structure + +### Basic Component with Shade + +Use the `Shade` function to create components: + +```typescript +import { createComponent, Shade } from '@furystack/shades'; + +type MyComponentProps = { + title: string; + onAction?: () => void; +}; + +export const MyComponent = Shade({ + shadowDomName: 'my-component', + render: ({ props, injector }) => { + return ( +
+

{props.title}

+ {props.onAction && } +
+ ); + }, +}); +``` + +### Shadow DOM Naming + +Always provide a unique `shadowDomName` in kebab-case: + +```typescript +// ✅ Good - unique, descriptive kebab-case names +shadowDomName: 'shade-app-layout' +shadowDomName: 'shade-login' +shadowDomName: 'theme-switch' +shadowDomName: 'github-logo' + +// ❌ Avoid - generic or poorly named +shadowDomName: 'my-component' +shadowDomName: 'div' +shadowDomName: 'Component1' +``` + +## Render Function Parameters + +### Available Parameters + +The render function receives several useful parameters: + +```typescript +render: ({ props, injector, children, element, useState, useObservable, useDisposable }) => { + // props - Component props + // injector - DI container for accessing services + // children - Child elements passed to the component + // element - The actual DOM element + // useState - Hook for local state + // useObservable - Hook for subscribing to ObservableValue + // useDisposable - Hook for managing disposable resources +} +``` + +## Using Services with Injector + +### Access Services via Injector + +Get service instances using the injector: + +```typescript +export const MyComponent = Shade({ + shadowDomName: 'my-component', + render: ({ injector }) => { + const themeProvider = injector.getInstance(ThemeProviderService); + const sessionService = injector.getInstance(SessionService); + + return ( +
+ {/* Component content */} +
+ ); + }, +}); +``` + +## State Management + +### Local State with useState + +Use `useState` for component-local state: + +```typescript +export const Counter = Shade({ + shadowDomName: 'app-counter', + render: ({ useState }) => { + const [count, setCount] = useState('count', 0); + + return ( +
+ Count: {count} + +
+ ); + }, +}); +``` + +### Observable State with useObservable + +Subscribe to `ObservableValue` from services: + +```typescript +export const UserStatus = Shade({ + shadowDomName: 'user-status', + render: ({ injector, useObservable }) => { + const sessionService = injector.getInstance(SessionService); + + // Subscribe to observable values + const [isOperationInProgress] = useObservable( + 'isOperationInProgress', + sessionService.isOperationInProgress + ); + const [currentUser] = useObservable('currentUser', sessionService.currentUser); + const [state] = useObservable('state', sessionService.state); + + if (isOperationInProgress) { + return
Loading...
; + } + + return ( +
+ {currentUser ? `Welcome, ${currentUser.username}` : 'Not logged in'} +
+ ); + }, +}); +``` + +## Resource Disposal + +### Using useDisposable + +Properly manage subscriptions and resources: + +```typescript +export const ThemeSwitch = Shade({ + shadowDomName: 'theme-switch', + render: ({ injector, useState, useDisposable }) => { + const themeProvider = injector.getInstance(ThemeProviderService); + const [theme, setTheme] = useState<'light' | 'dark'>('theme', 'dark'); + + // Subscribe to theme changes with automatic cleanup + useDisposable('traceThemeChange', () => + themeProvider.subscribe('themeChanged', (newTheme) => { + setTheme(newTheme.name === 'dark' ? 'dark' : 'light'); + }), + ); + + return ; + }, +}); +``` + +## Common Components + +### Using shades-common-components + +Import and use common components from `@furystack/shades-common-components`: + +```typescript +import { + Button, + Form, + Input, + Paper, + ThemeProviderService, + NotyService, +} from '@furystack/shades-common-components'; + +export const LoginForm = Shade({ + shadowDomName: 'login-form', + render: ({ injector }) => { + const sessionService = injector.getInstance(SessionService); + + return ( + + + validate={(data) => data.username?.length > 0 && data.password?.length > 0} + onSubmit={({ username, password }) => { + void sessionService.login(username, password); + }} + > + + + + + + ); + }, +}); +``` + +## Theming + +### Access Theme Properties + +Use `ThemeProviderService` for consistent theming: + +```typescript +export const ThemedComponent = Shade({ + shadowDomName: 'themed-component', + render: ({ injector }) => { + const theme = injector.getInstance(ThemeProviderService).theme; + + return ( +
+ Themed content +
+ ); + }, +}); +``` + +### Theme Switching + +Allow users to switch between themes: + +```typescript +import { defaultDarkTheme, defaultLightTheme } from '@furystack/shades-common-components'; + +const toggleTheme = (themeProvider: ThemeProviderService, currentTheme: 'light' | 'dark') => { + themeProvider.setAssignedTheme( + currentTheme === 'dark' ? defaultLightTheme : defaultDarkTheme + ); +}; +``` + +## Page Components + +### Organizing Pages + +Place page components in `frontend/src/pages/`: + +```typescript +// frontend/src/pages/dashboard.tsx +import { createComponent, Shade } from '@furystack/shades'; + +export const Dashboard = Shade({ + shadowDomName: 'page-dashboard', + render: ({ injector }) => { + return ( +
+

Dashboard

+ {/* Page content */} +
+ ); + }, +}); +``` + +### Page Routing + +Export pages from an index file: + +```typescript +// frontend/src/pages/index.ts +export * from './dashboard.js'; +export * from './login.js'; +export * from './hello-world.js'; +``` + +## Application Entry Point + +### Initializing the Shades App + +Set up the application in the entry point: + +```typescript +// frontend/src/index.tsx +import { Injector } from '@furystack/inject'; +import { getLogger, useLogging, VerboseConsoleLogger } from '@furystack/logging'; +import { createComponent, initializeShadeRoot } from '@furystack/shades'; +import { defaultDarkTheme, ThemeProviderService } from '@furystack/shades-common-components'; +import { Layout } from './components/layout.js'; +import { SessionService } from './services/session.js'; + +const shadeInjector = new Injector(); + +// Set up logging +useLogging(shadeInjector, VerboseConsoleLogger); + +// Initialize services +shadeInjector.getInstance(SessionService); + +// Set default theme +shadeInjector.getInstance(ThemeProviderService).setAssignedTheme(defaultDarkTheme); + +// Initialize the app +const rootElement = document.getElementById('root') as HTMLDivElement; +initializeShadeRoot({ + injector: shadeInjector, + rootElement, + jsxElement: , +}); +``` + +## Summary + +**Key Principles:** + +1. **Use Shade function** - Create components with `Shade({ ... })` +2. **Unique shadowDomName** - Every component needs a unique kebab-case name +3. **Access services via injector** - Use `injector.getInstance(Service)` +4. **useState for local state** - Simple component state +5. **useObservable for reactive state** - Subscribe to service observables +6. **useDisposable for cleanup** - Manage subscriptions properly +7. **Use common components** - Leverage `@furystack/shades-common-components` +8. **Consistent theming** - Use `ThemeProviderService` for styles + +**Component Checklist:** + +- [ ] Props type defined (if any) +- [ ] Unique `shadowDomName` in kebab-case +- [ ] Services accessed via `injector.getInstance()` +- [ ] Observable subscriptions use `useObservable` +- [ ] Manual subscriptions cleaned up with `useDisposable` +- [ ] Theme values from `ThemeProviderService` diff --git a/.cursor/rules/TESTING_GUIDELINES.mdc b/.cursor/rules/TESTING_GUIDELINES.mdc new file mode 100644 index 0000000..18cdf72 --- /dev/null +++ b/.cursor/rules/TESTING_GUIDELINES.mdc @@ -0,0 +1,397 @@ +--- +name: Testing Guidelines +description: Unit testing with Vitest and E2E testing with Playwright +globs: + - '**/*.spec.ts' + - '**/*.spec.tsx' + - 'e2e/**/*.ts' +alwaysApply: false +--- + +# Testing Guidelines + +## Test Configuration + +### Vitest for Unit Tests + +The project uses Vitest for unit testing with workspace-based configuration: + +```typescript +// vitest.config.mts +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + coverage: { + enabled: true, + include: ['common/src/**/*.ts', 'frontend/src/**/*.ts', 'service/src/**/*.ts'], + }, + projects: [ + { + test: { + name: 'Common', + include: ['common/src/**/*.spec.ts'], + }, + }, + { + test: { + name: 'Service', + include: ['service/src/**/*.spec.ts'], + }, + }, + { + test: { + name: 'Frontend', + environment: 'jsdom', + include: ['frontend/src/**/*.spec.(ts|tsx)'], + }, + }, + ], + }, +}); +``` + +### Playwright for E2E Tests + +E2E tests use Playwright with the service auto-started: + +```typescript +// playwright.config.ts +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +const config: PlaywrightTestConfig = { + testDir: 'e2e', + fullyParallel: true, + use: { + trace: 'on-first-retry', + baseURL: 'http://localhost:9090', + }, + webServer: { + command: 'yarn start:service', + url: 'http://localhost:9090', + reuseExistingServer: !process.env.CI, + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, + ], +}; +``` + +## Unit Testing with Vitest + +### Test File Location + +Place test files next to source files with `.spec.ts` suffix: + +``` +service/src/ +├── config.ts +├── config.spec.ts # Co-located test +├── service.ts +└── service.spec.ts +``` + +### Basic Test Structure + +Use `describe`, `it`, and `expect` from Vitest: + +```typescript +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +describe('MyService', () => { + describe('methodName', () => { + it('should do something when condition', () => { + // Arrange + const input = 'test'; + + // Act + const result = myFunction(input); + + // Assert + expect(result).toBe('expected'); + }); + }); +}); +``` + +### Testing Services + +Test service methods with mocked dependencies: + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Injector } from '@furystack/inject'; + +describe('SessionService', () => { + let injector: Injector; + let sessionService: SessionService; + + beforeEach(() => { + injector = new Injector(); + // Set up mocks + const mockApiClient = { + call: vi.fn(), + }; + injector.setExplicitInstance(BoilerplateApiClient, mockApiClient); + sessionService = injector.getInstance(SessionService); + }); + + it('should initialize with unauthenticated state', async () => { + expect(sessionService.state.getValue()).toBe('initializing'); + }); +}); +``` + +### Mocking with Vitest + +Use `vi.fn()` for function mocks and `vi.spyOn()` for spying: + +```typescript +import { vi } from 'vitest'; + +// Mock a function +const mockFn = vi.fn().mockReturnValue('mocked'); + +// Mock with implementation +const mockFn = vi.fn().mockImplementation((arg) => `result: ${arg}`); + +// Mock async function +const mockAsync = vi.fn().mockResolvedValue({ data: 'test' }); + +// Spy on method +const spy = vi.spyOn(service, 'method'); + +// Verify calls +expect(mockFn).toHaveBeenCalled(); +expect(mockFn).toHaveBeenCalledWith('arg'); +expect(mockFn).toHaveBeenCalledTimes(2); +``` + +### Testing Observable Values + +Test ObservableValue subscriptions: + +```typescript +import { ObservableValue } from '@furystack/utils'; + +describe('ObservableValue', () => { + it('should notify subscribers on value change', () => { + const observable = new ObservableValue('initial'); + const values: string[] = []; + + const subscription = observable.subscribe((value) => values.push(value)); + observable.setValue('updated'); + + expect(values).toEqual(['initial', 'updated']); + + subscription.dispose(); + }); +}); +``` + +## E2E Testing with Playwright + +### Test File Location + +Place E2E tests in the `e2e/` directory: + +``` +e2e/ +├── page.spec.ts # Main page tests +├── auth.spec.ts # Authentication tests +└── fixtures/ # Test fixtures if needed +``` + +### Basic E2E Test Structure + +Use Playwright's test API: + +```typescript +import { expect, test } from '@playwright/test'; + +test.describe('Feature Name', () => { + test('should do something', async ({ page }) => { + // Navigate + await page.goto('/'); + + // Find elements + const element = page.locator('selector'); + + // Assert visibility + await expect(element).toBeVisible(); + + // Interact + await element.click(); + + // Assert result + await expect(page.locator('.result')).toHaveText('Expected'); + }); +}); +``` + +### Locating Shades Components + +Use shadow DOM component names as selectors: + +```typescript +test('should interact with Shades components', async ({ page }) => { + // Locate by shadow DOM name + const loginForm = page.locator('shade-login form'); + await expect(loginForm).toBeVisible(); + + // Locate inputs within components + const usernameInput = loginForm.locator('input[name="userName"]'); + const passwordInput = loginForm.locator('input[name="password"]'); + + // Fill inputs + await usernameInput.type('testuser'); + await passwordInput.type('password'); + + // Click buttons + const submitButton = page.locator('button', { hasText: 'Login' }); + await submitButton.click(); +}); +``` + +### Authentication Flow Test + +Example of testing login/logout: + +```typescript +import { expect, test } from '@playwright/test'; + +test.describe('Authentication', () => { + test('Login and logout roundtrip', async ({ page }) => { + await page.goto('/'); + + // Wait for login form + const loginForm = page.locator('shade-login form'); + await expect(loginForm).toBeVisible(); + + // Fill credentials + await loginForm.locator('input[name="userName"]').type('testuser'); + await loginForm.locator('input[name="password"]').type('password'); + + // Submit + await page.locator('button', { hasText: 'Login' }).click(); + + // Verify logged in state + const welcomeTitle = page.locator('hello-world div h2'); + await expect(welcomeTitle).toBeVisible(); + await expect(welcomeTitle).toHaveText('Hello, testuser !'); + + // Logout + const logoutButton = page.locator('shade-app-bar button >> text="Log Out"'); + await logoutButton.click(); + + // Verify logged out + await expect(page.locator('shade-login form')).toBeVisible(); + }); +}); +``` + +### Waiting for Elements + +Use Playwright's auto-waiting or explicit waits: + +```typescript +// Auto-wait (recommended) +await expect(element).toBeVisible(); + +// Explicit wait +await page.waitForSelector('selector'); +await page.waitForLoadState('networkidle'); + +// Wait for response +await page.waitForResponse('**/api/endpoint'); +``` + +## Running Tests + +### Unit Tests + +```bash +# Run all unit tests +yarn test:unit + +# Run with coverage +yarn test:unit --coverage + +# Run specific workspace +yarn test:unit --project=Service + +# Watch mode +yarn test:unit --watch +``` + +### E2E Tests + +```bash +# Run all E2E tests +yarn test:e2e + +# Run specific test file +yarn playwright test e2e/page.spec.ts + +# Run in headed mode (see browser) +yarn playwright test --headed + +# Run specific browser +yarn playwright test --project=chromium + +# Debug mode +yarn playwright test --debug +``` + +## Test Coverage + +### Coverage Configuration + +Coverage is configured in `vitest.config.mts`: + +```typescript +coverage: { + enabled: true, + include: [ + 'common/src/**/*.ts', + 'frontend/src/**/*.ts', + 'service/src/**/*.ts', + ], +} +``` + +### Coverage Goals + +- **Service code**: Aim for high coverage on business logic +- **Frontend components**: Focus on critical user flows +- **Common types**: Types don't need test coverage + +## Summary + +**Key Principles:** + +1. **Co-locate tests** - Place `.spec.ts` files next to source files +2. **Use Vitest for unit tests** - Fast, modern test runner +3. **Use Playwright for E2E** - Cross-browser testing +4. **Test Shades components** - Use shadow DOM names as locators +5. **Mock FuryStack services** - Use Injector for DI in tests +6. **Test Observable values** - Subscribe and verify value changes +7. **Test auth flows E2E** - Cover login/logout in E2E tests + +**Testing Checklist:** + +- [ ] Unit tests for service logic +- [ ] Unit tests for utility functions +- [ ] E2E tests for critical user flows +- [ ] E2E tests for authentication +- [ ] Mocks for external dependencies +- [ ] Coverage enabled for source files + +**Commands:** + +- Unit tests: `yarn test:unit` +- E2E tests: `yarn test:e2e` +- Coverage: `yarn test:unit --coverage` +- Debug E2E: `yarn playwright test --debug` diff --git a/.cursor/rules/TYPESCRIPT_GUIDELINES.mdc b/.cursor/rules/TYPESCRIPT_GUIDELINES.mdc new file mode 100644 index 0000000..7713c8d --- /dev/null +++ b/.cursor/rules/TYPESCRIPT_GUIDELINES.mdc @@ -0,0 +1,265 @@ +--- +name: TypeScript Guidelines +description: Type safety, strict typing, NEVER use any, and type patterns for the Boilerplate app +globs: + - '**/*.ts' + - '**/*.tsx' +alwaysApply: true +--- + +# TypeScript Guidelines + +## Type Safety + +### NEVER use `any` + +There are no acceptable uses of `any` in this codebase: + +```typescript +// ✅ Good - use unknown for truly unknown types +export function processData(data: unknown): string { + if (typeof data === 'string') { + return data; + } + return JSON.stringify(data); +} + +// ✅ Good - use generics for flexible types +export function identity(value: T): T { + return value; +} + +// ❌ FORBIDDEN - using any +export function processData(data: any): string { + return data.toString(); +} +``` + +### Explicit Types for Public APIs + +All exported functions and public methods should have explicit types: + +```typescript +// ✅ Good - explicit types +export async function getCurrentUser(): Promise { + // Implementation +} + +export class SessionService { + public async login(credentials: LoginCredentials): Promise { + // Implementation + } +} + +// ❌ Avoid - implicit return types for public APIs +export async function getCurrentUser() { + // Return type is inferred - not ideal for public APIs +} +``` + +## Type Definitions + +### Prefer `type` over `interface` + +```typescript +// ✅ Good +type UserProps = { + name: string; + email: string; +}; + +type ApiResponse = { + data: T; + status: number; +}; + +// ❌ Avoid +interface UserProps { + name: string; + email: string; +} +``` + +### Component Props Types + +Define explicit prop types for all components: + +```typescript +// ✅ Good - explicit props type +type ButtonProps = { + label: string; + onClick: () => void; + disabled?: boolean; +}; + +export const Button = Shade({ + shadowDomName: 'app-button', + render: ({ props }) => { + return ( + + ); + }, +}); +``` + +## API Type Safety + +### Using the BoilerplateApi Type + +The `common` package defines the API contract. Always import and use it: + +```typescript +// ✅ Good - typed API client +import type { BoilerplateApi } from 'common'; + +const client = new RestClient({ + baseUrl: '/api', +}); + +// Type-safe API calls +const user = await client.call({ method: 'GET', action: '/currentUser' }); +``` + +### REST Service Endpoints + +Use the typed API definition when creating endpoints: + +```typescript +// ✅ Good - typed REST service +import type { BoilerplateApi } from 'common'; + +useRestService({ + injector, + api: { + GET: { + '/currentUser': GetCurrentUser, + '/isAuthenticated': IsAuthenticated, + }, + POST: { + '/login': LoginAction, + '/logout': LogoutAction, + }, + }, +}); +``` + +## Type Guards + +### Provide Type Guards for Runtime Checks + +```typescript +// ✅ Good - type guard +export function isUser(value: unknown): value is User { + return ( + typeof value === 'object' && + value !== null && + 'username' in value && + typeof (value as User).username === 'string' + ); +} + +// Usage +if (isUser(data)) { + console.log(data.username); // TypeScript knows data is User +} +``` + +## Observable Types + +### Type Observable Values + +Always provide explicit types for ObservableValue: + +```typescript +// ✅ Good - explicit observable type +public currentUser = new ObservableValue(null); +public isLoading = new ObservableValue(false); +public errors = new ObservableValue([]); + +// ❌ Avoid - relying on inference for complex types +public currentUser = new ObservableValue(null); // Type is ObservableValue +``` + +## Generic Patterns + +### Use Descriptive Generic Names + +```typescript +// ✅ Good - descriptive generic names +type ApiResponse = { + data: TData; + status: number; +}; + +type CacheEntry = { + value: TValue; + timestamp: number; +}; + +// ❌ Avoid - unclear generic names +type ApiResponse = { + data: T; + status: number; +}; +``` + +### Constrain Generics When Appropriate + +```typescript +// ✅ Good - constrained generic +export function getProperty( + obj: TObject, + key: TKey +): TObject[TKey] { + return obj[key]; +} +``` + +## Strict Null Checks + +### Handle Null and Undefined Explicitly + +```typescript +// ✅ Good - explicit null handling +export async function getUser(id: string): Promise { + const user = await fetchUser(id); + if (!user) { + return null; + } + return user; +} + +// ✅ Good - use optional chaining +const userName = user?.name ?? 'Unknown'; + +// ❌ Avoid - ignoring potential null +const userName = user.name; // Error if user is null +``` + +## Summary + +**Key Principles:** + +1. **NEVER use `any`** - Use `unknown`, generics, or proper types +2. **Explicit types for exports** - Document the contract +3. **Prefer `type` over `interface`** +4. **Type component props** - Every Shade component should have typed props +5. **Use the API type** - Import `BoilerplateApi` from `common` +6. **Type observables** - Always provide explicit types for ObservableValue +7. **Handle nulls** - Use strict null checks and optional chaining + +**Type Safety Checklist:** + +- [ ] No `any` types anywhere +- [ ] All exported functions have explicit return types +- [ ] Component props are typed +- [ ] API calls use the `BoilerplateApi` type +- [ ] Observable values have explicit types +- [ ] Null/undefined handled explicitly + +**Tools:** + +- Type checking: `yarn build` +- Strict mode: Enabled in `tsconfig.json` diff --git a/.cursor/rules/rules-index.mdc b/.cursor/rules/rules-index.mdc new file mode 100644 index 0000000..5508c45 --- /dev/null +++ b/.cursor/rules/rules-index.mdc @@ -0,0 +1,10 @@ +--- +alwaysApply: true +--- +This file contains a list of helpful information and context that the agent can reference when working in this codebase. Each entry provides specific guidance or rules for different aspects of the project. You can read these files using the readFile tool if the users prompt seems related. + +- [Code formatting, naming conventions, and file organization](./CODE_STYLE.md) +- [TypeScript type safety and strict typing guidelines](./TYPESCRIPT_GUIDELINES.md) +- [Shades component patterns and frontend development](./SHADES_COMPONENTS.md) +- [REST service patterns and backend development](./REST_SERVICE.md) +- [Testing guidelines for Vitest and Playwright](./TESTING_GUIDELINES.md) diff --git a/.github/workflows/check-version-bump.yml b/.github/workflows/check-version-bump.yml index 381634f..cf3df5d 100644 --- a/.github/workflows/check-version-bump.yml +++ b/.github/workflows/check-version-bump.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: - node-version: [20.x] + node-version: [24.x] steps: - name: Checkout