Merge branch 'main' into codex/create-dbal-and-irc-modules-and-components-mloc44

This commit is contained in:
2025-12-28 04:11:41 +00:00
committed by GitHub
112 changed files with 6552 additions and 4912 deletions

View File

@@ -0,0 +1,8 @@
import type { DBALConfig } from '../runtime/config'
import { DBALClient } from './client/client'
export { buildAdapter, buildEntityOperations } from './client/builders'
export { normalizeClientConfig, validateClientConfig } from './client/mappers'
export const createDBALClient = (config: DBALConfig) => new DBALClient(config)
export { DBALClient }

View File

@@ -0,0 +1,24 @@
import type { DBALAdapter } from '../../adapters/adapter'
import type { DBALConfig } from '../../runtime/config'
import { createAdapter } from './adapter-factory'
import {
createComponentOperations,
createLuaScriptOperations,
createPackageOperations,
createPageOperations,
createSessionOperations,
createUserOperations,
createWorkflowOperations
} from '../entities'
export const buildAdapter = (config: DBALConfig): DBALAdapter => createAdapter(config)
export const buildEntityOperations = (adapter: DBALAdapter) => ({
users: createUserOperations(adapter),
pages: createPageOperations(adapter),
components: createComponentOperations(adapter),
workflows: createWorkflowOperations(adapter),
luaScripts: createLuaScriptOperations(adapter),
packages: createPackageOperations(adapter),
sessions: createSessionOperations(adapter)
})

View File

@@ -1,7 +1,7 @@
/**
* @file client.ts
* @description DBAL Client - Main interface for database operations
*
*
* Provides CRUD operations for all entities through modular operation handlers.
* Each entity type has its own dedicated operations module following the
* single-responsibility pattern.
@@ -9,82 +9,67 @@
import type { DBALConfig } from '../../runtime/config'
import type { DBALAdapter } from '../../adapters/adapter'
import { createAdapter } from './adapter-factory'
import {
createUserOperations,
createPageOperations,
createComponentOperations,
createWorkflowOperations,
createLuaScriptOperations,
createPackageOperations,
createSessionOperations,
} from '../entities'
import { buildAdapter, buildEntityOperations } from './builders'
import { normalizeClientConfig, validateClientConfig } from './mappers'
export class DBALClient {
private adapter: DBALAdapter
private config: DBALConfig
private operations: ReturnType<typeof buildEntityOperations>
constructor(config: DBALConfig) {
this.config = config
// Validate configuration
if (!config.adapter) {
throw new Error('Adapter type must be specified')
}
if (config.mode !== 'production' && !config.database?.url) {
throw new Error('Database URL must be specified for non-production mode')
}
this.adapter = createAdapter(config)
this.config = normalizeClientConfig(validateClientConfig(config))
this.adapter = buildAdapter(this.config)
this.operations = buildEntityOperations(this.adapter)
}
/**
* User entity operations
*/
get users() {
return createUserOperations(this.adapter)
return this.operations.users
}
/**
* Page entity operations
*/
get pages() {
return createPageOperations(this.adapter)
return this.operations.pages
}
/**
* Component hierarchy entity operations
*/
get components() {
return createComponentOperations(this.adapter)
return this.operations.components
}
/**
* Workflow entity operations
*/
get workflows() {
return createWorkflowOperations(this.adapter)
return this.operations.workflows
}
/**
* Lua script entity operations
*/
get luaScripts() {
return createLuaScriptOperations(this.adapter)
return this.operations.luaScripts
}
/**
* Package entity operations
*/
get packages() {
return createPackageOperations(this.adapter)
return this.operations.packages
}
/**
* Session entity operations
*/
get sessions() {
return createSessionOperations(this.adapter)
return this.operations.sessions
}
/**

View File

@@ -0,0 +1,25 @@
import type { DBALConfig } from '../../runtime/config'
import { DBALError } from '../foundation/errors'
export const validateClientConfig = (config: DBALConfig): DBALConfig => {
if (!config.adapter) {
throw DBALError.validationError('Adapter type must be specified', [])
}
if (config.mode !== 'production' && !config.database?.url) {
throw DBALError.validationError('Database URL must be specified for non-production mode', [])
}
return config
}
export const normalizeClientConfig = (config: DBALConfig): DBALConfig => ({
...config,
security: {
sandbox: config.security?.sandbox ?? 'strict',
enableAuditLog: config.security?.enableAuditLog ?? true
},
performance: {
...config.performance
}
})

View File

@@ -1,4 +1,4 @@
export { DBALClient } from './core/client/client'
export { DBALClient, createDBALClient } from './core/client'
export type { DBALConfig } from './runtime/config'
export type * from './core/foundation/types'
export { DBALError, DBALErrorCode } from './core/foundation/errors'

View File

@@ -0,0 +1,64 @@
import * as ts from 'typescript'
import { Detector, DetectionFinding, DetectorContext } from '..'
const getLocation = (sourceFile: ts.SourceFile, node: ts.Node) => {
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart())
return {
line: line + 1,
column: character + 1
}
}
const getClassName = (
node: ts.ClassDeclaration | ts.ClassExpression,
sourceFile: ts.SourceFile
): string => {
if (node.name) {
return node.name.getText(sourceFile)
}
const parent = node.parent
if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
return parent.name.text
}
return 'anonymous'
}
const collectClasses = (context: DetectorContext): DetectionFinding[] => {
const sourceFile = ts.createSourceFile(
context.filePath,
context.source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX
)
const findings: DetectionFinding[] = []
const visit = (node: ts.Node) => {
if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) {
const name = getClassName(node, sourceFile)
findings.push({
detectorId: 'class-detector',
name,
message: `Class detected: ${name}`,
location: getLocation(sourceFile, node)
})
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return findings
}
export const classDetector: Detector = {
id: 'class-detector',
description: 'Detects class declarations and expressions within a TypeScript/TSX source file.',
detect: collectClasses
}

View File

@@ -0,0 +1,78 @@
import * as ts from 'typescript'
import { Detector, DetectionFinding, DetectorContext } from '..'
type FunctionLike =
| ts.FunctionDeclaration
| ts.FunctionExpression
| ts.ArrowFunction
| ts.MethodDeclaration
| ts.ConstructorDeclaration
const getLocation = (sourceFile: ts.SourceFile, node: ts.Node) => {
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart())
return {
line: line + 1,
column: character + 1
}
}
const getFunctionName = (node: FunctionLike, sourceFile: ts.SourceFile): string => {
if ('name' in node && node.name) {
return node.name.getText(sourceFile)
}
const parent = node.parent
if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
return parent.name.text
}
if (ts.isPropertyAssignment(parent) && ts.isIdentifier(parent.name)) {
return parent.name.text
}
return 'anonymous'
}
const collectFunctions = (context: DetectorContext): DetectionFinding[] => {
const sourceFile = ts.createSourceFile(
context.filePath,
context.source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX
)
const findings: DetectionFinding[] = []
const visit = (node: ts.Node) => {
if (
ts.isFunctionDeclaration(node) ||
ts.isFunctionExpression(node) ||
ts.isArrowFunction(node) ||
ts.isMethodDeclaration(node) ||
ts.isConstructorDeclaration(node)
) {
const name = getFunctionName(node, sourceFile)
findings.push({
detectorId: 'function-detector',
name,
message: `Function detected: ${name}`,
location: getLocation(sourceFile, node)
})
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return findings
}
export const functionDetector: Detector = {
id: 'function-detector',
description: 'Detects functions and methods within a TypeScript/TSX source file.',
detect: collectFunctions
}

45
detection/index.ts Normal file
View File

@@ -0,0 +1,45 @@
import { classDetector } from './detectors/class-detector'
import { functionDetector } from './detectors/function-detector'
export type DetectorContext = {
filePath: string
source: string
}
export type DetectionFinding = {
detectorId: string
name: string
message: string
location?: {
line: number
column: number
}
}
export interface Detector {
id: string
description: string
detect: (context: DetectorContext) => DetectionFinding[]
}
export class DetectorRegistry {
private readonly detectors: Detector[] = []
register(detector: Detector): void {
this.detectors.push(detector)
}
list(): Detector[] {
return [...this.detectors]
}
run(context: DetectorContext): DetectionFinding[] {
return this.detectors.flatMap((detector) => detector.detect(context))
}
}
export const registry = new DetectorRegistry()
const builtInDetectors: Detector[] = [functionDetector, classDetector]
builtInDetectors.forEach((detector) => registry.register(detector))

View File

@@ -0,0 +1,83 @@
import { Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
export interface GodCredentialsFormProps {
duration: number
unit: 'minutes' | 'hours'
onDurationChange: (value: number) => void
onUnitChange: (unit: 'minutes' | 'hours') => void
onSave: () => void
onResetExpiry: () => void
onClearExpiry: () => void
}
export function GodCredentialsForm({
duration,
unit,
onDurationChange,
onUnitChange,
onSave,
onResetExpiry,
onClearExpiry,
}: GodCredentialsFormProps) {
return (
<div className="space-y-6">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="duration">Expiry Duration</Label>
<div className="flex gap-2">
<Input
id="duration"
type="number"
min="1"
max={unit === 'hours' ? '24' : '1440'}
value={duration}
onChange={(e) => onDurationChange(Number(e.target.value))}
className="flex-1"
/>
<Select value={unit} onValueChange={(value) => onUnitChange(value as 'minutes' | 'hours')}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="minutes">Minutes</SelectItem>
<SelectItem value="hours">Hours</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">
Set the duration for how long credentials are visible (1 minute to 24 hours)
</p>
</div>
<div className="flex gap-2">
<Button onClick={onSave} className="flex-1">
Save Duration
</Button>
</div>
</div>
<div className="border-t pt-4 space-y-3">
<div className="space-y-2">
<Label>Expiry Management</Label>
<p className="text-xs text-muted-foreground">
Reset or clear the current expiry timer
</p>
</div>
<div className="flex gap-2">
<Button onClick={onResetExpiry} variant="outline" className="flex-1">
Reset Timer
</Button>
<Button onClick={onClearExpiry} variant="outline" className="flex-1">
Clear Expiry
</Button>
</div>
<p className="text-xs text-muted-foreground">
<strong>Reset Timer:</strong> Restart the countdown using the configured duration<br />
<strong>Clear Expiry:</strong> Remove expiry time (credentials will show on next page load)
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,42 @@
import { Alert, AlertDescription, Badge } from '@/components/ui'
import { CheckCircle, WarningCircle } from '@phosphor-icons/react'
export interface GodCredentialsSummaryProps {
isActive: boolean
expiryTime: number
timeRemaining: string
}
export function GodCredentialsSummary({ isActive, expiryTime, timeRemaining }: GodCredentialsSummaryProps) {
if (isActive) {
return (
<Alert className="bg-gradient-to-br from-purple-500/10 to-orange-500/10 border-purple-500/50">
<CheckCircle className="h-5 w-5 text-green-500" />
<AlertDescription className="ml-2">
<div className="space-y-1">
<p className="font-semibold text-sm flex items-center gap-2">
God credentials are currently visible
<Badge variant="secondary" className="font-mono">Active</Badge>
</p>
<p className="text-xs text-muted-foreground">
Time remaining: <span className="font-mono font-semibold">{timeRemaining}</span>
</p>
</div>
</AlertDescription>
</Alert>
)
}
if (!isActive && expiryTime > 0) {
return (
<Alert>
<WarningCircle className="h-5 w-5 text-yellow-500" />
<AlertDescription className="ml-2">
<p className="text-sm">God credentials have expired or been hidden</p>
</AlertDescription>
</Alert>
)
}
return null
}

View File

@@ -0,0 +1,48 @@
import { Button, Input, Label, Alert, AlertDescription } from '@/components/ui'
import { SignIn } from '@phosphor-icons/react'
export interface LoginFormProps {
username: string
password: string
onUsernameChange: (value: string) => void
onPasswordChange: (value: string) => void
onSubmit: () => void
}
export function LoginForm({ username, password, onUsernameChange, onPasswordChange, onSubmit }: LoginFormProps) {
return (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="login-username">Username</Label>
<Input
id="login-username"
value={username}
onChange={(e) => onUsernameChange(e.target.value)}
placeholder="Enter username"
onKeyDown={(e) => e.key === 'Enter' && onSubmit()}
/>
</div>
<div className="space-y-2">
<Label htmlFor="login-password">Password</Label>
<Input
id="login-password"
type="password"
value={password}
onChange={(e) => onPasswordChange(e.target.value)}
placeholder="Enter password"
onKeyDown={(e) => e.key === 'Enter' && onSubmit()}
/>
</div>
<Button className="w-full" onClick={onSubmit}>
<SignIn className="mr-2" size={16} />
Sign In
</Button>
<Alert>
<AlertDescription className="text-xs">
<p className="font-semibold mb-1">Test Credentials:</p>
<p>Check browser console for default user passwords (they are scrambled on first run)</p>
</AlertDescription>
</Alert>
</div>
)
}

View File

@@ -0,0 +1,50 @@
import { Button, Separator } from '@/components/ui'
import { GoogleLogo, GithubLogo, IconProps } from '@phosphor-icons/react'
export interface Provider {
name: string
description?: string
icon?: React.ComponentType<IconProps>
}
export interface ProviderListProps {
providers: Provider[]
onSelect?: (provider: Provider) => void
}
const FALLBACK_PROVIDERS: Provider[] = [
{ name: 'Google', description: 'Use your Google Workspace account', icon: GoogleLogo },
{ name: 'GitHub', description: 'Developer SSO via GitHub', icon: GithubLogo },
]
export function ProviderList({ providers, onSelect }: ProviderListProps) {
const entries = providers.length > 0 ? providers : FALLBACK_PROVIDERS
return (
<div className="space-y-3">
<Separator />
<p className="text-xs text-muted-foreground text-center">Or continue with</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{entries.map((provider) => {
const Icon = provider.icon
return (
<Button
key={provider.name}
variant="outline"
className="w-full justify-start gap-3"
onClick={() => onSelect?.(provider)}
>
{Icon ? <Icon size={18} /> : null}
<span className="text-sm font-medium">{provider.name}</span>
{provider.description ? (
<span className="text-xs text-muted-foreground block leading-tight text-left">
{provider.description}
</span>
) : null}
</Button>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,185 @@
import { Badge, Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, ScrollArea, Separator, Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import type { PackageCatalogData } from '@/lib/packages/core/package-catalog'
import type { InstalledPackage } from '@/lib/package-types'
import { Download, Star, Tag, Trash, User } from '@phosphor-icons/react'
import { DependenciesTab } from './tabs/DependenciesTab'
import { ScriptsTab } from './tabs/ScriptsTab'
interface PackageDetailsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
selectedPackage: PackageCatalogData | null
installing: boolean
onInstall: (packageId: string) => void
onUninstall: (packageId: string) => void
installedPackages: InstalledPackage[]
getCatalogEntry: (packageId: string) => PackageCatalogData | undefined
}
export function PackageDetailsDialog({
open,
onOpenChange,
selectedPackage,
installing,
onInstall,
onUninstall,
installedPackages,
getCatalogEntry,
}: PackageDetailsDialogProps) {
if (!selectedPackage) return null
const { manifest, content } = selectedPackage
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-purple-500 to-purple-700 flex items-center justify-center text-3xl flex-shrink-0">
{manifest.icon}
</div>
<div className="flex-1">
<DialogTitle className="text-2xl">{manifest.name}</DialogTitle>
<DialogDescription className="mt-1">{manifest.description}</DialogDescription>
<div className="flex items-center gap-3 mt-3">
<Badge variant="secondary">{manifest.category}</Badge>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Download size={14} />
<span>{manifest.downloadCount.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Star size={14} weight="fill" className="text-yellow-500" />
<span>{manifest.rating}</span>
</div>
</div>
</div>
</div>
</DialogHeader>
<Separator className="my-4" />
<Tabs defaultValue="overview" className="flex-1 flex flex-col">
<div className="px-1">
<TabsList className="grid grid-cols-3 w-full">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="dependencies">Dependencies</TabsTrigger>
<TabsTrigger value="scripts">Scripts</TabsTrigger>
</TabsList>
</div>
<TabsContent value="overview" className="flex-1 m-0">
<ScrollArea className="h-[50vh]">
<div className="space-y-6 pr-4">
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="font-semibold mb-2">Author</h4>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<User size={16} />
<span>{manifest.author}</span>
</div>
</div>
<div>
<h4 className="font-semibold mb-2">Version</h4>
<p className="text-sm text-muted-foreground">{manifest.version}</p>
</div>
</div>
<div>
<h4 className="font-semibold mb-2">Tags</h4>
<div className="flex flex-wrap gap-2">
{manifest.tags.map(tag => (
<Badge key={tag} variant="outline">
<Tag size={12} className="mr-1" />
{tag}
</Badge>
))}
</div>
</div>
<div>
<h4 className="font-semibold mb-2">Includes</h4>
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-muted">
<div className="font-medium text-sm">Data Models</div>
<div className="text-2xl font-bold text-primary">{content.schemas.length}</div>
</div>
<div className="p-3 rounded-lg bg-muted">
<div className="font-medium text-sm">Pages</div>
<div className="text-2xl font-bold text-primary">{content.pages.length}</div>
</div>
<div className="p-3 rounded-lg bg-muted">
<div className="font-medium text-sm">Workflows</div>
<div className="text-2xl font-bold text-primary">{content.workflows.length}</div>
</div>
<div className="p-3 rounded-lg bg-muted">
<div className="font-medium text-sm">Scripts</div>
<div className="text-2xl font-bold text-primary">{content.luaScripts.length}</div>
</div>
</div>
</div>
{content.schemas.length > 0 && (
<div>
<h4 className="font-semibold mb-2">Data Models</h4>
<div className="space-y-2">
{content.schemas.map(schema => (
<div key={schema.name} className="p-3 rounded-lg border">
<div className="font-medium">{schema.displayName || schema.name}</div>
<div className="text-sm text-muted-foreground">{schema.fields.length} fields</div>
</div>
))}
</div>
</div>
)}
{content.pages.length > 0 && (
<div>
<h4 className="font-semibold mb-2">Pages</h4>
<div className="space-y-2">
{content.pages.map(page => (
<div key={page.id} className="p-3 rounded-lg border">
<div className="font-medium">{page.title}</div>
<div className="text-sm text-muted-foreground font-mono">{page.path}</div>
</div>
))}
</div>
</div>
)}
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="dependencies" className="flex-1 m-0">
<ScrollArea className="h-[50vh] pr-4">
<DependenciesTab
dependencies={manifest.dependencies}
installedPackages={installedPackages}
resolveCatalogEntry={getCatalogEntry}
/>
</ScrollArea>
</TabsContent>
<TabsContent value="scripts" className="flex-1 m-0">
<ScrollArea className="h-[50vh] pr-4">
<ScriptsTab scripts={content.luaScripts} />
</ScrollArea>
</TabsContent>
</Tabs>
<DialogFooter className="mt-4">
{manifest.installed ? (
<Button variant="destructive" onClick={() => onUninstall(manifest.id)}>
<Trash size={16} className="mr-2" />
Uninstall
</Button>
) : (
<Button onClick={() => onInstall(manifest.id)} disabled={installing}>
<Download size={16} className="mr-2" />
{installing ? 'Installing...' : 'Install Package'}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,12 +1,12 @@
import { useState } from 'react'
import { Badge, Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, ScrollArea, Separator } from '@/components/ui'
import { toast } from 'sonner'
import { installPackage, togglePackageEnabled, uninstallPackage } from '@/lib/api/packages'
import { Button } from '@/components/ui'
import type { PackageCatalogData } from '@/lib/packages/core/package-catalog'
import { ArrowSquareIn, Download, Export, Package, Star, Tag, Trash, User } from '@phosphor-icons/react'
import { ArrowSquareIn, Export, Package } from '@phosphor-icons/react'
import { PackageDetailsDialog } from './PackageDetailsDialog'
import { PackageImportExport } from './PackageImportExport'
import { PackageFilters } from './package-manager/PackageFilters'
import { PackageTabs } from './package-manager/PackageTabs'
import { usePackageActions } from './package-manager/usePackageActions'
import { usePackages } from './package-manager/usePackages'
interface PackageManagerProps {
@@ -31,61 +31,12 @@ export function PackageManager({ onClose }: PackageManagerProps) {
} = usePackages()
const [selectedPackage, setSelectedPackage] = useState<PackageCatalogData | null>(null)
const [showDetails, setShowDetails] = useState(false)
const [installing, setInstalling] = useState(false)
const [showImportExport, setShowImportExport] = useState(false)
const [importExportMode, setImportExportMode] = useState<'import' | 'export'>('export')
const handleInstallPackage = async (packageId: string) => {
setInstalling(true)
try {
const packageEntry = getCatalogEntry(packageId)
if (!packageEntry) {
toast.error('Package not found')
return
}
await installPackage(packageId)
toast.success(`${packageEntry.manifest.name} installed successfully!`)
await loadPackages()
setShowDetails(false)
} catch (error) {
console.error('Installation error:', error)
toast.error('Failed to install package')
} finally {
setInstalling(false)
}
}
const handleUninstallPackage = async (packageId: string) => {
try {
const packageEntry = getCatalogEntry(packageId)
if (!packageEntry) {
toast.error('Package not found')
return
}
await uninstallPackage(packageId)
toast.success(`${packageEntry.manifest.name} uninstalled successfully!`)
await loadPackages()
setShowDetails(false)
} catch (error) {
console.error('Uninstallation error:', error)
toast.error('Failed to uninstall package')
}
}
const handleTogglePackage = async (packageId: string, enabled: boolean) => {
try {
await togglePackageEnabled(packageId, enabled)
toast.success(enabled ? 'Package enabled' : 'Package disabled')
await loadPackages()
} catch (error) {
console.error('Toggle error:', error)
toast.error('Failed to toggle package')
}
}
const { installing, handleInstallPackage, handleUninstallPackage, handleTogglePackage } = usePackageActions({
loadPackages,
getCatalogEntry,
})
const openPackageDetails = (packageId: string) => {
const catalogEntry = getCatalogEntry(packageId)
@@ -162,131 +113,16 @@ export function PackageManager({ onClose }: PackageManagerProps) {
/>
</div>
<Dialog open={showDetails} onOpenChange={setShowDetails}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
{selectedPackage && (
<>
<DialogHeader>
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-purple-500 to-purple-700 flex items-center justify-center text-3xl flex-shrink-0">
{selectedPackage.manifest.icon}
</div>
<div className="flex-1">
<DialogTitle className="text-2xl">{selectedPackage.manifest.name}</DialogTitle>
<DialogDescription className="mt-1">{selectedPackage.manifest.description}</DialogDescription>
<div className="flex items-center gap-3 mt-3">
<Badge variant="secondary">{selectedPackage.manifest.category}</Badge>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Download size={14} />
<span>{selectedPackage.manifest.downloadCount.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Star size={14} weight="fill" className="text-yellow-500" />
<span>{selectedPackage.manifest.rating}</span>
</div>
</div>
</div>
</div>
</DialogHeader>
<Separator className="my-4" />
<ScrollArea className="flex-1">
<div className="space-y-6 pr-4">
<div>
<h4 className="font-semibold mb-2">Author</h4>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<User size={16} />
<span>{selectedPackage.manifest.author}</span>
</div>
</div>
<div>
<h4 className="font-semibold mb-2">Version</h4>
<p className="text-sm text-muted-foreground">{selectedPackage.manifest.version}</p>
</div>
<div>
<h4 className="font-semibold mb-2">Tags</h4>
<div className="flex flex-wrap gap-2">
{selectedPackage.manifest.tags.map(tag => (
<Badge key={tag} variant="outline">
<Tag size={12} className="mr-1" />
{tag}
</Badge>
))}
</div>
</div>
<div>
<h4 className="font-semibold mb-2">Includes</h4>
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-muted">
<div className="font-medium text-sm">Data Models</div>
<div className="text-2xl font-bold text-primary">{selectedPackage.content.schemas.length}</div>
</div>
<div className="p-3 rounded-lg bg-muted">
<div className="font-medium text-sm">Pages</div>
<div className="text-2xl font-bold text-primary">{selectedPackage.content.pages.length}</div>
</div>
<div className="p-3 rounded-lg bg-muted">
<div className="font-medium text-sm">Workflows</div>
<div className="text-2xl font-bold text-primary">{selectedPackage.content.workflows.length}</div>
</div>
<div className="p-3 rounded-lg bg-muted">
<div className="font-medium text-sm">Scripts</div>
<div className="text-2xl font-bold text-primary">{selectedPackage.content.luaScripts.length}</div>
</div>
</div>
</div>
{selectedPackage.content.schemas.length > 0 && (
<div>
<h4 className="font-semibold mb-2">Data Models</h4>
<div className="space-y-2">
{selectedPackage.content.schemas.map(schema => (
<div key={schema.name} className="p-3 rounded-lg border">
<div className="font-medium">{schema.displayName || schema.name}</div>
<div className="text-sm text-muted-foreground">{schema.fields.length} fields</div>
</div>
))}
</div>
</div>
)}
{selectedPackage.content.pages.length > 0 && (
<div>
<h4 className="font-semibold mb-2">Pages</h4>
<div className="space-y-2">
{selectedPackage.content.pages.map(page => (
<div key={page.id} className="p-3 rounded-lg border">
<div className="font-medium">{page.title}</div>
<div className="text-sm text-muted-foreground font-mono">{page.path}</div>
</div>
))}
</div>
</div>
)}
</div>
</ScrollArea>
<DialogFooter className="mt-4">
{selectedPackage.manifest.installed ? (
<Button variant="destructive" onClick={() => handleUninstallPackage(selectedPackage.manifest.id)}>
<Trash size={16} className="mr-2" />
Uninstall
</Button>
) : (
<Button onClick={() => handleInstallPackage(selectedPackage.manifest.id)} disabled={installing}>
<Download size={16} className="mr-2" />
{installing ? 'Installing...' : 'Install Package'}
</Button>
)}
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
<PackageDetailsDialog
open={showDetails}
onOpenChange={setShowDetails}
selectedPackage={selectedPackage}
installing={installing}
onInstall={(packageId) => handleInstallPackage(packageId, () => setShowDetails(false))}
onUninstall={(packageId) => handleUninstallPackage(packageId, () => setShowDetails(false))}
installedPackages={installedPackages}
getCatalogEntry={getCatalogEntry}
/>
<PackageImportExport
open={showImportExport}

View File

@@ -1,29 +1,24 @@
import type React from 'react'
import { Button } from '@/components/ui'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui'
import { Label } from '@/components/ui'
import { Input } from '@/components/ui'
import { Textarea } from '@/components/ui'
import { Checkbox } from '@/components/ui'
import { ScrollArea } from '@/components/ui'
import { Separator } from '@/components/ui'
import {
Button,
Card,
CardDescription,
CardHeader,
CardTitle,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
ScrollArea,
Separator,
} from '@/components/ui'
import { Export, Package, Database as DatabaseIcon, FileArrowDown } from '@phosphor-icons/react'
import type { PackageManifest } from '@/lib/package-types'
import type { ExportPackageOptions } from '@/lib/packages/core/package-export'
const exportOptionLabels: { key: keyof ExportPackageOptions; label: string }[] = [
{ key: 'includeSchemas', label: 'Include data schemas' },
{ key: 'includePages', label: 'Include page configurations' },
{ key: 'includeWorkflows', label: 'Include workflows' },
{ key: 'includeLuaScripts', label: 'Include Lua scripts' },
{ key: 'includeComponentHierarchy', label: 'Include component hierarchies' },
{ key: 'includeComponentConfigs', label: 'Include component configurations' },
{ key: 'includeCssClasses', label: 'Include CSS classes' },
{ key: 'includeDropdownConfigs', label: 'Include dropdown configurations' },
{ key: 'includeSeedData', label: 'Include seed data' },
{ key: 'includeAssets', label: 'Include assets (images, videos, audio, documents)' },
]
import { ExportOptions } from './ExportOptions'
import { ExportManifestForm } from './ExportManifestForm'
interface ExportDialogProps {
open: boolean
@@ -100,104 +95,18 @@ export const ExportDialog = ({
<Separator />
<div className="space-y-4">
<div>
<Label htmlFor="package-name">Package Name *</Label>
<Input
id="package-name"
placeholder="My Awesome Package"
value={manifest.name}
onChange={e => setManifest(prev => ({ ...prev, name: e.target.value }))}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="package-version">Version</Label>
<Input
id="package-version"
placeholder="1.0.0"
value={manifest.version}
onChange={e => setManifest(prev => ({ ...prev, version: e.target.value }))}
/>
</div>
<div>
<Label htmlFor="package-author">Author</Label>
<Input
id="package-author"
placeholder="Your Name"
value={manifest.author}
onChange={e => setManifest(prev => ({ ...prev, author: e.target.value }))}
/>
</div>
</div>
<div>
<Label htmlFor="package-description">Description</Label>
<Textarea
id="package-description"
placeholder="Describe what this package does..."
value={manifest.description}
onChange={e => setManifest(prev => ({ ...prev, description: e.target.value }))}
rows={3}
/>
</div>
<div>
<Label htmlFor="package-tags">Tags</Label>
<div className="flex gap-2 mb-2">
<Input
id="package-tags"
placeholder="Add a tag..."
value={tagInput}
onChange={e => setTagInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), onAddTag())}
/>
<Button type="button" onClick={onAddTag}>
Add
</Button>
</div>
{manifest.tags && manifest.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{manifest.tags.map(tag => (
<div key={tag} className="px-2 py-1 bg-secondary rounded-md text-sm flex items-center gap-2">
<span>{tag}</span>
<button
type="button"
onClick={() => onRemoveTag(tag)}
className="text-muted-foreground hover:text-foreground"
>
×
</button>
</div>
))}
</div>
)}
</div>
</div>
<ExportManifestForm
manifest={manifest}
setManifest={setManifest}
tagInput={tagInput}
setTagInput={setTagInput}
onAddTag={onAddTag}
onRemoveTag={onRemoveTag}
/>
<Separator />
<div>
<Label className="mb-3 block">Export Options</Label>
<div className="space-y-3">
{exportOptionLabels.map(({ key, label }) => (
<div className="flex items-center gap-2" key={key}>
<Checkbox
id={`export-${key}`}
checked={exportOptions[key] as boolean}
onCheckedChange={checked =>
setExportOptions(prev => ({ ...prev, [key]: checked as boolean }))
}
/>
<Label htmlFor={`export-${key}`} className="font-normal cursor-pointer">
{label}
</Label>
</div>
))}
</div>
</div>
<ExportOptions exportOptions={exportOptions} setExportOptions={setExportOptions} />
</div>
</ScrollArea>

View File

@@ -0,0 +1,100 @@
import type React from 'react'
import { Button, Input, Label, Textarea } from '@/components/ui'
import type { PackageManifest } from '@/lib/package-types'
interface ExportManifestFormProps {
manifest: Partial<PackageManifest>
setManifest: React.Dispatch<React.SetStateAction<Partial<PackageManifest>>>
tagInput: string
setTagInput: (value: string) => void
onAddTag: () => void
onRemoveTag: (tag: string) => void
}
export function ExportManifestForm({
manifest,
setManifest,
tagInput,
setTagInput,
onAddTag,
onRemoveTag,
}: ExportManifestFormProps) {
return (
<div className="space-y-4">
<div>
<Label htmlFor="package-name">Package Name *</Label>
<Input
id="package-name"
placeholder="My Awesome Package"
value={manifest.name}
onChange={e => setManifest(prev => ({ ...prev, name: e.target.value }))}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="package-version">Version</Label>
<Input
id="package-version"
placeholder="1.0.0"
value={manifest.version}
onChange={e => setManifest(prev => ({ ...prev, version: e.target.value }))}
/>
</div>
<div>
<Label htmlFor="package-author">Author</Label>
<Input
id="package-author"
placeholder="Your Name"
value={manifest.author}
onChange={e => setManifest(prev => ({ ...prev, author: e.target.value }))}
/>
</div>
</div>
<div>
<Label htmlFor="package-description">Description</Label>
<Textarea
id="package-description"
placeholder="Describe what this package does..."
value={manifest.description}
onChange={e => setManifest(prev => ({ ...prev, description: e.target.value }))}
rows={3}
/>
</div>
<div>
<Label htmlFor="package-tags">Tags</Label>
<div className="flex gap-2 mb-2">
<Input
id="package-tags"
placeholder="Add a tag..."
value={tagInput}
onChange={e => setTagInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), onAddTag())}
/>
<Button type="button" onClick={onAddTag}>
Add
</Button>
</div>
{manifest.tags && manifest.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{manifest.tags.map(tag => (
<div key={tag} className="px-2 py-1 bg-secondary rounded-md text-sm flex items-center gap-2">
<span>{tag}</span>
<button
type="button"
onClick={() => onRemoveTag(tag)}
className="text-muted-foreground hover:text-foreground"
>
×
</button>
</div>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,42 @@
import { Checkbox, Label } from '@/components/ui'
import type { ExportPackageOptions } from '@/lib/packages/core/package-export'
const exportOptionLabels: { key: keyof ExportPackageOptions; label: string }[] = [
{ key: 'includeSchemas', label: 'Include data schemas' },
{ key: 'includePages', label: 'Include page configurations' },
{ key: 'includeWorkflows', label: 'Include workflows' },
{ key: 'includeLuaScripts', label: 'Include Lua scripts' },
{ key: 'includeComponentHierarchy', label: 'Include component hierarchies' },
{ key: 'includeComponentConfigs', label: 'Include component configurations' },
{ key: 'includeCssClasses', label: 'Include CSS classes' },
{ key: 'includeDropdownConfigs', label: 'Include dropdown configurations' },
{ key: 'includeSeedData', label: 'Include seed data' },
{ key: 'includeAssets', label: 'Include assets (images, videos, audio, documents)' },
]
interface ExportOptionsProps {
exportOptions: ExportPackageOptions
setExportOptions: React.Dispatch<React.SetStateAction<ExportPackageOptions>>
}
export function ExportOptions({ exportOptions, setExportOptions }: ExportOptionsProps) {
return (
<div>
<Label className="mb-3 block">Export Options</Label>
<div className="space-y-3">
{exportOptionLabels.map(({ key, label }) => (
<div className="flex items-center gap-2" key={key}>
<Checkbox
id={`export-${key}`}
checked={exportOptions[key] as boolean}
onCheckedChange={checked => setExportOptions(prev => ({ ...prev, [key]: checked as boolean }))}
/>
<Label htmlFor={`export-${key}`} className="font-normal cursor-pointer">
{label}
</Label>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,83 @@
import { useCallback, useState } from 'react'
import { toast } from 'sonner'
import { installPackage, togglePackageEnabled, uninstallPackage } from '@/lib/api/packages'
import type { PackageCatalogData } from '@/lib/packages/core/package-catalog'
interface UsePackageActionsProps {
loadPackages: () => Promise<void>
getCatalogEntry: (packageId: string) => PackageCatalogData | undefined
}
export function usePackageActions({ loadPackages, getCatalogEntry }: UsePackageActionsProps) {
const [installing, setInstalling] = useState(false)
const resolvePackage = useCallback(
(packageId: string) => {
const packageEntry = getCatalogEntry(packageId)
if (!packageEntry) {
toast.error('Package not found')
return null
}
return packageEntry
},
[getCatalogEntry]
)
const handleInstallPackage = useCallback(
async (packageId: string, onComplete?: () => void) => {
setInstalling(true)
const packageEntry = resolvePackage(packageId)
if (!packageEntry) {
setInstalling(false)
return
}
try {
await installPackage(packageId)
toast.success(`${packageEntry.manifest.name} installed successfully!`)
await loadPackages()
onComplete?.()
} catch (error) {
console.error('Installation error:', error)
toast.error('Failed to install package')
} finally {
setInstalling(false)
}
},
[loadPackages, resolvePackage]
)
const handleUninstallPackage = useCallback(
async (packageId: string, onComplete?: () => void) => {
const packageEntry = resolvePackage(packageId)
if (!packageEntry) return
try {
await uninstallPackage(packageId)
toast.success(`${packageEntry.manifest.name} uninstalled successfully!`)
await loadPackages()
onComplete?.()
} catch (error) {
console.error('Uninstallation error:', error)
toast.error('Failed to uninstall package')
}
},
[loadPackages, resolvePackage]
)
const handleTogglePackage = useCallback(
async (packageId: string, enabled: boolean) => {
try {
await togglePackageEnabled(packageId, enabled)
toast.success(enabled ? 'Package enabled' : 'Package disabled')
await loadPackages()
} catch (error) {
console.error('Toggle error:', error)
toast.error('Failed to toggle package')
}
},
[loadPackages]
)
return { installing, handleInstallPackage, handleUninstallPackage, handleTogglePackage }
}

View File

@@ -0,0 +1,57 @@
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import type { PackageCatalogData } from '@/lib/packages/core/package-catalog'
import type { InstalledPackage } from '@/lib/package-types'
import { CheckCircle, WarningCircle } from '@phosphor-icons/react'
interface DependenciesTabProps {
dependencies: string[]
installedPackages: InstalledPackage[]
resolveCatalogEntry: (packageId: string) => PackageCatalogData | undefined
}
export function DependenciesTab({ dependencies, installedPackages, resolveCatalogEntry }: DependenciesTabProps) {
if (dependencies.length === 0) {
return <p className="text-sm text-muted-foreground">No dependencies required.</p>
}
return (
<div className="space-y-3">
{dependencies.map(dependencyId => {
const catalogEntry = resolveCatalogEntry(dependencyId)
const isInstalled = installedPackages.some(pkg => pkg.packageId === dependencyId)
const dependencyName = catalogEntry?.manifest.name ?? dependencyId
const dependencyDescription = catalogEntry?.manifest.description ?? 'Dependency not found in catalog.'
return (
<Card key={dependencyId}>
<CardHeader className="flex flex-row items-start justify-between gap-3">
<div>
<CardTitle className="text-base flex items-center gap-2">
{dependencyName}
<Badge variant={isInstalled ? 'secondary' : 'outline'}>{isInstalled ? 'Installed' : 'Missing'}</Badge>
</CardTitle>
<CardDescription>{dependencyDescription}</CardDescription>
</div>
{isInstalled ? (
<CheckCircle size={20} className="text-green-500" />
) : (
<WarningCircle size={20} className="text-amber-500" />
)}
</CardHeader>
{catalogEntry?.manifest.tags?.length ? (
<CardContent>
<div className="flex flex-wrap gap-2">
{catalogEntry.manifest.tags.map(tag => (
<Badge key={`${dependencyId}-${tag}`} variant="outline">
{tag}
</Badge>
))}
</div>
</CardContent>
) : null}
</Card>
)
})}
</div>
)
}

View File

@@ -0,0 +1,42 @@
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
interface Script {
id?: string
name?: string
description?: string
category?: string
code?: string
}
interface ScriptsTabProps {
scripts: Script[]
}
export function ScriptsTab({ scripts }: ScriptsTabProps) {
if (!scripts.length) {
return <p className="text-sm text-muted-foreground">No scripts included in this package.</p>
}
return (
<div className="space-y-3">
{scripts.map((script, index) => (
<Card key={script.id ?? script.name ?? index}>
<CardHeader className="flex flex-row items-start justify-between gap-3">
<div>
<CardTitle className="text-base">{script.name ?? 'Unnamed Script'}</CardTitle>
<CardDescription>{script.description ?? 'No description provided.'}</CardDescription>
</div>
{script.category ? <Badge variant="outline">{script.category}</Badge> : null}
</CardHeader>
{script.code ? (
<CardContent>
<pre className="bg-muted rounded-lg p-3 text-xs overflow-x-auto">
<code>{script.code}</code>
</pre>
</CardContent>
) : null}
</Card>
))}
</div>
)
}

View File

@@ -1,13 +1,10 @@
import { useState, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { Button } from '@/components/ui'
import { Input } from '@/components/ui'
import { Label } from '@/components/ui'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
import { Alert, AlertDescription } from '@/components/ui'
import { GodCredentialsForm } from '@/components/auth/god-credentials/Form'
import { GodCredentialsSummary } from '@/components/auth/god-credentials/Summary'
import { Database } from '@/lib/database'
import { toast } from 'sonner'
import { Clock, Key, WarningCircle, CheckCircle } from '@phosphor-icons/react'
import { Key } from '@phosphor-icons/react'
export function GodCredentialsSettings() {
const [duration, setDuration] = useState<number>(60)
@@ -112,91 +109,21 @@ export function GodCredentialsSettings() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{isActive && (
<Alert className="bg-gradient-to-br from-purple-500/10 to-orange-500/10 border-purple-500/50">
<CheckCircle className="h-5 w-5 text-green-500" />
<AlertDescription className="ml-2">
<div className="space-y-1">
<p className="font-semibold text-sm">
God credentials are currently visible on the front page
</p>
<p className="text-xs text-muted-foreground">
Time remaining: <span className="font-mono font-semibold">{timeRemaining}</span>
</p>
</div>
</AlertDescription>
</Alert>
)}
<GodCredentialsSummary
isActive={isActive}
expiryTime={expiryTime}
timeRemaining={timeRemaining}
/>
{!isActive && expiryTime > 0 && (
<Alert>
<WarningCircle className="h-5 w-5 text-yellow-500" />
<AlertDescription className="ml-2">
<p className="text-sm">
God credentials have expired or been hidden
</p>
</AlertDescription>
</Alert>
)}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="duration">Expiry Duration</Label>
<div className="flex gap-2">
<Input
id="duration"
type="number"
min="1"
max={unit === 'hours' ? '24' : '1440'}
value={duration}
onChange={(e) => setDuration(Number(e.target.value))}
className="flex-1"
/>
<Select value={unit} onValueChange={(v) => setUnit(v as 'minutes' | 'hours')}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="minutes">Minutes</SelectItem>
<SelectItem value="hours">Hours</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">
Set the duration for how long credentials are visible (1 minute to 24 hours)
</p>
</div>
<div className="flex gap-2">
<Button onClick={handleSave} className="flex-1">
<Clock className="mr-2" size={16} />
Save Duration
</Button>
</div>
</div>
<div className="border-t pt-4 space-y-3">
<div className="space-y-2">
<Label>Expiry Management</Label>
<p className="text-xs text-muted-foreground">
Reset or clear the current expiry timer
</p>
</div>
<div className="flex gap-2">
<Button onClick={handleResetExpiry} variant="outline" className="flex-1">
Reset Timer
</Button>
<Button onClick={handleClearExpiry} variant="outline" className="flex-1">
Clear Expiry
</Button>
</div>
<p className="text-xs text-muted-foreground">
<strong>Reset Timer:</strong> Restart the countdown using the configured duration<br />
<strong>Clear Expiry:</strong> Remove expiry time (credentials will show on next page load)
</p>
</div>
<GodCredentialsForm
duration={duration}
unit={unit}
onDurationChange={setDuration}
onUnitChange={setUnit}
onSave={handleSave}
onResetExpiry={handleResetExpiry}
onClearExpiry={handleClearExpiry}
/>
</CardContent>
</Card>
)

View File

@@ -4,11 +4,13 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Input } from '@/components/ui'
import { Label } from '@/components/ui'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
import { SignIn, UserPlus, ArrowLeft, Envelope } from '@phosphor-icons/react'
import { ArrowLeft, Envelope, SignIn, UserPlus } from '@phosphor-icons/react'
import { toast } from 'sonner'
import { Database, hashPassword } from '@/lib/database'
import { generateScrambledPassword, simulateEmailSend } from '@/lib/password-utils'
import { Alert, AlertDescription } from '@/components/ui'
import { LoginForm } from '@/components/auth/unified-login/LoginForm'
import { Provider, ProviderList } from '@/components/auth/unified-login/ProviderList'
export interface UnifiedLoginProps {
onLogin: (credentials: { username: string; password: string }) => void
@@ -20,6 +22,14 @@ export function UnifiedLogin({ onLogin, onRegister, onBack }: UnifiedLoginProps)
const [loginForm, setLoginForm] = useState({ username: '', password: '' })
const [registerForm, setRegisterForm] = useState({ username: '', email: '' })
const [resetEmail, setResetEmail] = useState('')
const providers: Provider[] = [
{ name: 'Google', description: 'Use your Google Workspace account' },
{ name: 'GitHub', description: 'Developer SSO via GitHub' },
]
const handleProviderSelect = (provider: Provider) => {
toast.info(`${provider.name} login is coming soon`)
}
const handleLogin = () => {
if (!loginForm.username || !loginForm.password) {
@@ -119,37 +129,14 @@ export function UnifiedLogin({ onLogin, onRegister, onBack }: UnifiedLoginProps)
</TabsList>
<TabsContent value="login" className="space-y-4 mt-6">
<div className="space-y-2">
<Label htmlFor="login-username">Username</Label>
<Input
id="login-username"
value={loginForm.username}
onChange={(e) => setLoginForm({ ...loginForm, username: e.target.value })}
placeholder="Enter username"
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
/>
</div>
<div className="space-y-2">
<Label htmlFor="login-password">Password</Label>
<Input
id="login-password"
type="password"
value={loginForm.password}
onChange={(e) => setLoginForm({ ...loginForm, password: e.target.value })}
placeholder="Enter password"
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
/>
</div>
<Button className="w-full" onClick={handleLogin}>
<SignIn className="mr-2" size={16} />
Sign In
</Button>
<Alert>
<AlertDescription className="text-xs">
<p className="font-semibold mb-1">Test Credentials:</p>
<p>Check browser console for default user passwords (they are scrambled on first run)</p>
</AlertDescription>
</Alert>
<LoginForm
username={loginForm.username}
password={loginForm.password}
onUsernameChange={(username) => setLoginForm({ ...loginForm, username })}
onPasswordChange={(password) => setLoginForm({ ...loginForm, password })}
onSubmit={handleLogin}
/>
<ProviderList providers={providers} onSelect={handleProviderSelect} />
</TabsContent>
<TabsContent value="register" className="space-y-4 mt-6">

View File

@@ -0,0 +1,120 @@
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, Separator } from '@/components/ui'
import { PageDefinition } from '@/lib/rendering/page/page-renderer'
import { Eye, Layout, ShieldCheck } from '@phosphor-icons/react'
interface GenericPagePreviewProps {
page: PageDefinition
updatedAt?: string
footerText?: string
}
const layoutCopy: Record<PageDefinition['layout'], string> = {
default: 'Default layout with header and footer',
sidebar: 'Sidebar layout with navigation',
dashboard: 'Dashboard layout with widgets',
blank: 'Blank canvas for custom layouts'
}
export function Preview({ page, updatedAt, footerText }: GenericPagePreviewProps) {
const showHeader = page.metadata?.showHeader !== false
const showFooter = page.metadata?.showFooter !== false
return (
<Card className="h-full">
<CardHeader className="space-y-1">
<CardTitle className="flex items-center gap-2">
<Eye size={20} weight="duotone" />
Page preview
</CardTitle>
<CardDescription className="flex items-center gap-2 text-muted-foreground">
<Layout size={16} />
{layoutCopy[page.layout]}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">{page.description || 'No description provided.'}</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="capitalize">
Level {page.level}
</Badge>
<Badge variant="secondary">{page.components.length} components</Badge>
{page.permissions?.requiresAuth && (
<span className="inline-flex items-center gap-1">
<ShieldCheck size={14} />
Auth required{page.permissions?.requiredRole ? ` (${page.permissions.requiredRole})` : ''}
</span>
)}
{updatedAt && <span>Last updated {updatedAt}</span>}
</div>
</div>
<Badge>{page.metadata?.headerTitle || page.title}</Badge>
</div>
<div className="rounded-lg border bg-card p-4 shadow-inner">
{showHeader && (
<div className="mb-3 flex items-center justify-between rounded-md border border-dashed border-border/60 bg-muted/60 px-3 py-2 text-sm">
<span className="font-semibold">Header</span>
<Badge variant="outline">{page.metadata?.headerTitle || 'Default title'}</Badge>
</div>
)}
<div
className={`grid gap-3 ${page.layout === 'dashboard' ? 'lg:grid-cols-3 md:grid-cols-2 grid-cols-1' : ''} ${
page.layout === 'sidebar' ? 'lg:grid-cols-[240px_1fr]' : ''
}`}
>
{page.layout === 'sidebar' && (
<div className="rounded-md border border-dashed border-border/60 bg-muted/50 p-3 text-sm text-muted-foreground">
Sidebar navigation
</div>
)}
<div className="space-y-3 rounded-md border border-dashed border-border/60 bg-background p-3">
<p className="text-sm font-semibold">Component tree</p>
<div className="grid gap-2 md:grid-cols-2">
{page.components.slice(0, 4).map(component => (
<div key={component.id} className="rounded border bg-muted/40 p-2 text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span className="font-semibold text-foreground">{component.type}</span>
{component.children && component.children.length > 0 && (
<Badge variant="outline">{component.children.length} children</Badge>
)}
</div>
{component.props?.className && <p className="line-clamp-1">{component.props.className}</p>}
</div>
))}
{page.components.length === 0 && (
<p className="text-xs text-muted-foreground">Add components to see them previewed here.</p>
)}
</div>
</div>
</div>
{showFooter && (
<div className="mt-3 flex items-center justify-between rounded-md border border-dashed border-border/60 bg-muted/60 px-3 py-2 text-sm text-muted-foreground">
<span>Footer</span>
<Badge variant="secondary">{footerText || 'Configured in metadata'}</Badge>
</div>
)}
</div>
<Separator />
<div className="grid gap-2 text-sm text-muted-foreground md:grid-cols-2">
<div className="space-y-1">
<p className="text-xs uppercase tracking-wide text-foreground">Lua hooks</p>
<p>onLoad: {page.luaScripts?.onLoad || 'Not configured'}</p>
<p>onUnload: {page.luaScripts?.onUnload || 'Not configured'}</p>
</div>
<div className="space-y-1">
<p className="text-xs uppercase tracking-wide text-foreground">Metadata</p>
<p>Header actions: {page.metadata?.headerActions?.length ?? 0}</p>
<p>Sidebar items: {page.metadata?.sidebarItems?.length ?? 0}</p>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,101 @@
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, ScrollArea, Separator } from '@/components/ui'
import { ListNumbers, Plus, PushPinSimple, SquaresFour } from '@phosphor-icons/react'
export interface PageSection {
id: string
title: string
description?: string
componentCount?: number
status?: 'draft' | 'review' | 'published'
updatedAt?: string
}
interface SectionListProps {
sections: PageSection[]
selectedSectionId?: string
onSelectSection?: (section: PageSection) => void
onCreateSection?: () => void
}
const statusVariant: Record<NonNullable<PageSection['status']>, 'default' | 'secondary' | 'outline'> = {
draft: 'secondary',
review: 'outline',
published: 'default'
}
export function SectionList({ sections, selectedSectionId, onSelectSection, onCreateSection }: SectionListProps) {
return (
<Card className="h-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div className="space-y-1">
<CardTitle className="flex items-center gap-2">
<ListNumbers size={20} weight="duotone" />
Sections
</CardTitle>
<CardDescription>Outline the sections that make up your generic page.</CardDescription>
</div>
<Button size="sm" onClick={onCreateSection} variant="secondary">
<Plus size={16} />
Add Section
</Button>
</CardHeader>
<CardContent className="p-0">
{sections.length === 0 ? (
<div className="py-10 text-center text-muted-foreground">
<p className="text-sm">No sections yet. Create your first section to start building the page.</p>
</div>
) : (
<ScrollArea className="max-h-[520px]">
<div className="divide-y divide-border">
{sections.map(section => (
<button
key={section.id}
className={`w-full text-left transition hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${
selectedSectionId === section.id ? 'bg-muted' : ''
}`}
onClick={() => onSelectSection?.(section)}
>
<div className="flex items-start gap-3 px-4 py-3">
<div className="mt-1">
{section.status ? (
<Badge variant={statusVariant[section.status]} className="capitalize">
{section.status}
</Badge>
) : (
<Badge variant="outline">Draft</Badge>
)}
</div>
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between gap-2">
<div>
<p className="font-semibold leading-none">{section.title}</p>
{section.description && (
<p className="text-sm text-muted-foreground line-clamp-2">{section.description}</p>
)}
</div>
{section.updatedAt && (
<p className="text-xs text-muted-foreground whitespace-nowrap">Updated {section.updatedAt}</p>
)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<SquaresFour size={14} />
{section.componentCount ?? 0} components
</span>
<Separator orientation="vertical" className="h-4" />
<span className="inline-flex items-center gap-1">
<PushPinSimple size={14} />
ID: {section.id}
</span>
</div>
</div>
</div>
</button>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,69 @@
import Image from 'next/image'
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label } from '@/components/ui'
import { FilmSlate, ImageSquare } from '@phosphor-icons/react'
interface MediaPaneProps {
thumbnailUrl?: string
videoUrl?: string
onThumbnailChange?: (value: string) => void
onVideoChange?: (value: string) => void
}
export function MediaPane({ thumbnailUrl, videoUrl, onThumbnailChange, onVideoChange }: MediaPaneProps) {
return (
<Card className="h-full">
<CardHeader className="space-y-1">
<CardTitle className="flex items-center gap-2">
<FilmSlate size={20} weight="duotone" />
Media
</CardTitle>
<CardDescription>Optional visuals to make the quick guide easier to follow.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="thumbnail-url">Thumbnail image</Label>
<Input
id="thumbnail-url"
value={thumbnailUrl || ''}
onChange={(e) => onThumbnailChange?.(e.target.value)}
placeholder="https://images.example.com/quick-guide.png"
/>
<p className="text-xs text-muted-foreground">Shown in dashboards and previews.</p>
{thumbnailUrl && (
<div className="relative aspect-[16/9] overflow-hidden rounded-lg border bg-muted">
<Image src={thumbnailUrl} alt="Quick guide thumbnail" fill className="object-cover" />
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="video-url">Demo video (optional)</Label>
<Input
id="video-url"
value={videoUrl || ''}
onChange={(e) => onVideoChange?.(e.target.value)}
placeholder="YouTube or direct MP4 link"
/>
<p className="text-xs text-muted-foreground">Embed a short clip that shows the flow in action.</p>
{videoUrl && (
<div className="rounded-lg border bg-black p-3 text-sm text-muted-foreground">
<Badge variant="secondary" className="mb-2 inline-flex items-center gap-1">
<ImageSquare size={14} />
Preview
</Badge>
<div className="aspect-video overflow-hidden rounded-md bg-muted">
<iframe
className="h-full w-full"
src={videoUrl}
title="Quick guide demo"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</div>
)}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,139 @@
import { useEffect, useState } from 'react'
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label, Textarea } from '@/components/ui'
import { ArrowCounterClockwise, ListNumbers, Plus, Trash } from '@phosphor-icons/react'
export interface GuideStep {
id: string
title: string
description: string
mediaUrl?: string
duration?: string
}
interface StepsEditorProps {
steps: GuideStep[]
onChange?: (steps: GuideStep[]) => void
}
export function StepsEditor({ steps, onChange }: StepsEditorProps) {
const [localSteps, setLocalSteps] = useState<GuideStep[]>(steps)
useEffect(() => {
setLocalSteps(steps)
}, [steps])
const updateStep = (id: string, payload: Partial<GuideStep>) => {
const nextSteps = localSteps.map(step => (step.id === id ? { ...step, ...payload } : step))
setLocalSteps(nextSteps)
onChange?.(nextSteps)
}
const removeStep = (id: string) => {
const nextSteps = localSteps.filter(step => step.id !== id)
setLocalSteps(nextSteps)
onChange?.(nextSteps)
}
const addStep = () => {
const newStep: GuideStep = {
id: crypto.randomUUID(),
title: 'New step',
description: 'Describe what happens in this step.',
duration: '1-2 min'
}
const nextSteps = [...localSteps, newStep]
setLocalSteps(nextSteps)
onChange?.(nextSteps)
}
const resetOrdering = () => {
const nextSteps = localSteps.map((step, index) => ({ ...step, id: `step_${index + 1}` }))
setLocalSteps(nextSteps)
onChange?.(nextSteps)
}
return (
<Card className="h-full">
<CardHeader className="flex items-center justify-between space-y-0">
<div>
<CardTitle className="flex items-center gap-2">
<ListNumbers size={20} weight="duotone" />
Steps
</CardTitle>
<CardDescription>Keep your quick guide instructions concise and actionable.</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={resetOrdering}>
<ArrowCounterClockwise size={16} />
Reset IDs
</Button>
<Button size="sm" onClick={addStep}>
<Plus size={16} />
Add Step
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{localSteps.length === 0 ? (
<p className="text-sm text-muted-foreground">Add your first step to get started.</p>
) : (
<div className="space-y-4">
{localSteps.map((step, index) => (
<div key={step.id} className="rounded-lg border border-border/80 bg-card/60 p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge variant="outline">Step {index + 1}</Badge>
<span>Duration: {step.duration || 'n/a'}</span>
</div>
<Button variant="ghost" size="icon" onClick={() => removeStep(step.id)}>
<Trash size={16} />
</Button>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`title-${step.id}`}>Title</Label>
<Input
id={`title-${step.id}`}
value={step.title}
onChange={(e) => updateStep(step.id, { title: e.target.value })}
placeholder="Give this step a short name"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`duration-${step.id}`}>Expected duration</Label>
<Input
id={`duration-${step.id}`}
value={step.duration || ''}
onChange={(e) => updateStep(step.id, { duration: e.target.value })}
placeholder="e.g. 30s, 1-2 min"
/>
</div>
</div>
<div className="mt-3 space-y-2">
<Label htmlFor={`description-${step.id}`}>Description</Label>
<Textarea
id={`description-${step.id}`}
value={step.description}
onChange={(e) => updateStep(step.id, { description: e.target.value })}
rows={3}
placeholder="Outline the actions or context for this step"
/>
</div>
<div className="mt-3 space-y-2">
<Label htmlFor={`media-${step.id}`}>Media URL (optional)</Label>
<Input
id={`media-${step.id}`}
value={step.mediaUrl || ''}
onChange={(e) => updateStep(step.id, { mediaUrl: e.target.value })}
placeholder="Link to an image, GIF, or short video"
/>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,112 @@
import { useMemo } from 'react'
import { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Input, Label, Switch } from '@/components/ui'
import { EnvelopeSimple, FloppyDisk } from '@phosphor-icons/react'
import type { SMTPConfig } from '@/lib/password-utils'
interface ConnectionFormProps {
value: SMTPConfig
onChange: (value: SMTPConfig) => void
onSave?: () => void
onTest?: () => void
}
export function ConnectionForm({ value, onChange, onSave, onTest }: ConnectionFormProps) {
const securePort = useMemo(() => (value.tls ? 465 : 587), [value.tls])
const updateField = <K extends keyof SMTPConfig>(key: K, fieldValue: SMTPConfig[K]) => {
onChange({ ...value, [key]: fieldValue })
}
return (
<Card className="h-full">
<CardHeader className="space-y-1">
<CardTitle className="flex items-center gap-2">
<EnvelopeSimple size={20} weight="duotone" />
SMTP connection
</CardTitle>
<CardDescription>Configure how MetaBuilder connects to your mail provider.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="host">Host</Label>
<Input
id="host"
value={value.host}
onChange={(e) => updateField('host', e.target.value)}
placeholder="smtp.example.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="port">Port</Label>
<Input
id="port"
type="number"
value={value.port}
onChange={(e) => updateField('port', parseInt(e.target.value || '0', 10))}
placeholder={securePort.toString()}
/>
</div>
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
value={value.username}
onChange={(e) => updateField('username', e.target.value)}
placeholder="user@example.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={value.password}
onChange={(e) => updateField('password', e.target.value)}
placeholder="App password or token"
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="fromName">From name</Label>
<Input
id="fromName"
value={value.fromName || ''}
onChange={(e) => updateField('fromName', e.target.value)}
placeholder="MetaBuilder"
/>
</div>
<div className="space-y-2">
<Label htmlFor="fromEmail">From email</Label>
<Input
id="fromEmail"
type="email"
value={value.fromEmail}
onChange={(e) => updateField('fromEmail', e.target.value)}
placeholder="no-reply@example.com"
/>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border bg-muted/40 p-3">
<div>
<p className="font-medium">Use secure connection (TLS)</p>
<p className="text-sm text-muted-foreground">Switching on updates the recommended port to {securePort}.</p>
</div>
<Switch checked={value.tls} onCheckedChange={(checked) => updateField('tls', checked)} />
</div>
</CardContent>
<CardFooter className="flex flex-wrap items-center gap-2">
<Button variant="secondary" onClick={onTest}>
Test connection
</Button>
<Button onClick={onSave}>
<FloppyDisk size={16} />
Save configuration
</Button>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,55 @@
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { CheckCircle, Clock, WarningCircle } from '@phosphor-icons/react'
import type { ReactNode } from 'react'
export type ConnectionStatus = 'idle' | 'connected' | 'error'
interface StatusCardProps {
status: ConnectionStatus
host?: string
lastChecked?: string
message?: string
}
const statusCopy: Record<ConnectionStatus, { label: string; tone: string; icon: ReactNode }> = {
idle: {
label: 'Not tested',
tone: 'bg-muted text-muted-foreground',
icon: <Clock size={16} />
},
connected: {
label: 'Connected',
tone: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
icon: <CheckCircle size={16} />
},
error: {
label: 'Connection failed',
tone: 'bg-destructive/15 text-destructive',
icon: <WarningCircle size={16} />
}
}
export function StatusCard({ status, host, lastChecked, message }: StatusCardProps) {
const copy = statusCopy[status]
return (
<Card className="h-full">
<CardHeader>
<CardTitle>Connection status</CardTitle>
<CardDescription>Stay aware of how the platform talks to your SMTP provider.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Badge variant="secondary" className={`inline-flex items-center gap-2 ${copy.tone}`}>
{copy.icon}
{copy.label}
</Badge>
<div className="text-sm text-muted-foreground space-y-1">
<p>Host: {host || 'Not configured'}</p>
<p>Last checked: {lastChecked || 'Pending test'}</p>
<p>{message || 'Run a test to see connection details.'}</p>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,100 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import type { User } from '@/lib/level-types'
import { useAuth } from '@/hooks/useAuth'
import { fetchSession } from '@/lib/auth/api/fetch-session'
import { login as loginRequest } from '@/lib/auth/api/login'
import { logout as logoutRequest } from '@/lib/auth/api/logout'
vi.mock('@/lib/auth/api/fetch-session', () => ({
fetchSession: vi.fn(),
}))
vi.mock('@/lib/auth/api/login', () => ({
login: vi.fn(),
}))
vi.mock('@/lib/auth/api/logout', () => ({
logout: vi.fn(),
}))
const mockFetchSession = vi.mocked(fetchSession)
const mockLogin = vi.mocked(loginRequest)
const mockLogout = vi.mocked(logoutRequest)
const createUser = (overrides?: Partial<User>): User => ({
id: 'user_1',
username: 'alice',
email: 'alice@example.com',
role: 'user',
createdAt: 1000,
tenantId: undefined,
profilePicture: undefined,
bio: undefined,
isInstanceOwner: false,
...overrides,
})
const waitForIdle = async (result: { current: { isLoading: boolean } }) => {
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
}
const resetAuthStore = async () => {
const { result, unmount } = renderHook(() => useAuth())
await waitForIdle(result)
await act(async () => {
await result.current.logout()
})
await waitForIdle(result)
unmount()
}
describe('useAuth role mapping', () => {
beforeEach(async () => {
mockFetchSession.mockReset()
mockLogin.mockReset()
mockLogout.mockReset()
mockFetchSession.mockResolvedValue(null)
mockLogout.mockResolvedValue(undefined)
await resetAuthStore()
})
it.each([
{ role: 'public', expectedLevel: 1 },
{ role: 'user', expectedLevel: 2 },
{ role: 'admin', expectedLevel: 4 },
{ role: 'supergod', expectedLevel: 6 },
{ role: 'unknown', expectedLevel: 0 },
])('applies level for role "$role"', async ({ role, expectedLevel }) => {
const { result, unmount } = renderHook(() => useAuth())
mockLogin.mockResolvedValue(createUser({ role }))
await waitForIdle(result)
await act(async () => {
await result.current.login('alice@example.com', 'password')
})
expect(result.current.user?.level).toBe(expectedLevel)
unmount()
})
it('maps refreshed session roles to levels', async () => {
const { result, unmount } = renderHook(() => useAuth())
mockFetchSession.mockResolvedValue(createUser({ role: 'moderator' }))
await act(async () => {
await result.current.refresh()
})
await waitForIdle(result)
expect(result.current.user?.level).toBe(3)
unmount()
})
})

View File

@@ -1,6 +1,11 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import type { User } from '@/lib/level-types'
import { useAuth } from '@/hooks/useAuth'
import { fetchSession } from '@/lib/auth/api/fetch-session'
import { login as loginRequest } from '@/lib/auth/api/login'
import { register as registerRequest } from '@/lib/auth/api/register'
import { logout as logoutRequest } from '@/lib/auth/api/logout'
vi.mock('@/lib/auth/api/fetch-session', () => ({
fetchSession: vi.fn(),
@@ -18,12 +23,6 @@ vi.mock('@/lib/auth/api/logout', () => ({
logout: vi.fn(),
}))
import { useAuth } from '@/hooks/useAuth'
import { fetchSession } from '@/lib/auth/api/fetch-session'
import { login as loginRequest } from '@/lib/auth/api/login'
import { register as registerRequest } from '@/lib/auth/api/register'
import { logout as logoutRequest } from '@/lib/auth/api/logout'
const mockFetchSession = vi.mocked(fetchSession)
const mockLogin = vi.mocked(loginRequest)
const mockRegister = vi.mocked(registerRequest)
@@ -48,7 +47,17 @@ const waitForIdle = async (result: { current: { isLoading: boolean } }) => {
})
}
describe('useAuth', () => {
const resetAuthStore = async () => {
const { result, unmount } = renderHook(() => useAuth())
await waitForIdle(result)
await act(async () => {
await result.current.logout()
})
await waitForIdle(result)
unmount()
}
describe('useAuth session flows', () => {
beforeEach(async () => {
mockFetchSession.mockReset()
mockLogin.mockReset()
@@ -57,16 +66,10 @@ describe('useAuth', () => {
mockFetchSession.mockResolvedValue(null)
mockLogout.mockResolvedValue(undefined)
const { result, unmount } = renderHook(() => useAuth())
await waitForIdle(result)
await act(async () => {
await result.current.logout()
})
await waitForIdle(result)
unmount()
await resetAuthStore()
})
it('should start unauthenticated after session check', async () => {
it('starts unauthenticated after session check', async () => {
const { result, unmount } = renderHook(() => useAuth())
await waitForIdle(result)
@@ -77,28 +80,20 @@ describe('useAuth', () => {
unmount()
})
it.each([
{ email: 'alice@example.com', expectedName: 'alice' },
{ email: 'bob.smith@corp.io', expectedName: 'bob.smith' },
])('should authenticate $email', async ({ email, expectedName }) => {
it('authenticates on login', async () => {
const { result, unmount } = renderHook(() => useAuth())
mockLogin.mockResolvedValue(createUser({
id: 'user_1',
username: expectedName,
email,
}))
mockLogin.mockResolvedValue(createUser())
await waitForIdle(result)
await act(async () => {
await result.current.login(email, 'password')
await result.current.login('alice@example.com', 'password')
})
expect(result.current.user).toMatchObject({
id: 'user_1',
email,
name: expectedName,
username: expectedName,
email: 'alice@example.com',
username: 'alice',
level: 2,
})
expect(result.current.isAuthenticated).toBe(true)
@@ -106,7 +101,7 @@ describe('useAuth', () => {
unmount()
})
it('should clear user on logout', async () => {
it('clears user on logout', async () => {
const { result, unmount } = renderHook(() => useAuth())
mockLogin.mockResolvedValue(createUser())
@@ -126,14 +121,16 @@ describe('useAuth', () => {
unmount()
})
it('should register and authenticate', async () => {
it('registers and authenticates', async () => {
const { result, unmount } = renderHook(() => useAuth())
mockRegister.mockResolvedValue(createUser({
id: 'user_2',
username: 'newbie',
email: 'newbie@example.com',
}))
mockRegister.mockResolvedValue(
createUser({
id: 'user_2',
username: 'newbie',
email: 'newbie@example.com',
})
)
await waitForIdle(result)
await act(async () => {
@@ -143,7 +140,6 @@ describe('useAuth', () => {
expect(result.current.user).toMatchObject({
id: 'user_2',
email: 'newbie@example.com',
name: 'newbie',
username: 'newbie',
level: 2,
})
@@ -152,7 +148,7 @@ describe('useAuth', () => {
unmount()
})
it('should sync state across hooks', async () => {
it('syncs state across hooks', async () => {
const first = renderHook(() => useAuth())
const second = renderHook(() => useAuth())

View File

@@ -1,54 +1,34 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { useKV } from '@/hooks/useKV'
import { useKV } from '@/hooks/data/useKV'
describe('useKV', () => {
const STORAGE_PREFIX = 'mb_kv:'
let store: Record<string, string>
const STORAGE_PREFIX = 'mb_kv:'
let store: Record<string, string>
const setupLocalStorage = (): void => {
store = {}
vi.stubGlobal('localStorage', {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
Object.keys(store).forEach(k => delete store[k])
}),
length: 0,
key: vi.fn(() => null),
})
}
describe('useKV storage', () => {
beforeEach(() => {
// Mock localStorage
store = {}
vi.stubGlobal('localStorage', {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => { store[key] = value }),
removeItem: vi.fn((key: string) => { delete store[key] }),
clear: vi.fn(() => { Object.keys(store).forEach(k => delete store[k]) }),
length: 0,
key: vi.fn(() => null),
})
setupLocalStorage()
})
it.each([
{ key: 'user_name', defaultValue: 'John', description: 'string value' },
{ key: 'user_count', defaultValue: 0, description: 'number value' },
{ key: 'is_active', defaultValue: true, description: 'boolean value' },
{ key: 'user_data', defaultValue: { id: 1, name: 'John' }, description: 'object value' },
])('should initialize hook with $description', ({ key, defaultValue }) => {
const { result } = renderHook(() => useKV(key, defaultValue))
const [value] = result.current
expect(value).toBe(defaultValue)
})
it('should initialize with undefined when no default value provided', () => {
const { result } = renderHook(() => useKV('empty_key'))
const [value] = result.current
expect(value).toBeUndefined()
})
it('should load value from localStorage when available', async () => {
localStorage.setItem(`${STORAGE_PREFIX}stored_key`, JSON.stringify('stored'))
const { result } = renderHook(() => useKV('stored_key', 'default'))
await waitFor(() => {
expect(result.current[0]).toBe('stored')
})
})
it('should migrate legacy localStorage entries to namespaced keys', () => {
it('migrates legacy localStorage entries to namespaced keys', () => {
localStorage.setItem('legacy_key', JSON.stringify('legacy'))
const { result } = renderHook(() => useKV('legacy_key', 'default'))
@@ -58,7 +38,7 @@ describe('useKV', () => {
expect(localStorage.getItem('legacy_key')).toBeNull()
})
it('should update value when using updater function', async () => {
it('updates value when using updater function', async () => {
const { result } = renderHook(() => useKV('counter', 0))
const [, updateValue] = result.current
@@ -71,9 +51,8 @@ describe('useKV', () => {
expect(newValue).toBe(1)
})
it('should update value when providing direct value', async () => {
it('updates value when providing direct value', async () => {
const { result } = renderHook(() => useKV('name', 'John'))
const [, updateValue] = result.current
await act(async () => {
@@ -84,7 +63,7 @@ describe('useKV', () => {
expect(newValue).toBe('Jane')
})
it('should handle complex object updates', async () => {
it('handles complex object updates', async () => {
const initialObject = { id: 1, name: 'John', email: 'john@example.com' }
const { result } = renderHook(() => useKV('user', initialObject))
@@ -101,7 +80,7 @@ describe('useKV', () => {
expect(newValue).toEqual({ id: 1, name: 'Jane', email: 'john@example.com' })
})
it('should handle array updates', async () => {
it('handles array updates', async () => {
const initialArray = [1, 2, 3]
const { result } = renderHook(() => useKV('items', initialArray))
@@ -115,7 +94,7 @@ describe('useKV', () => {
expect(newValue).toEqual([1, 2, 3, 4])
})
it('should maintain separate state for different keys', async () => {
it('maintains separate state for different keys', () => {
const { result: result1 } = renderHook(() => useKV('key1', 'value1'))
const { result: result2 } = renderHook(() => useKV('key2', 'value2'))
@@ -126,7 +105,7 @@ describe('useKV', () => {
expect(value2).toBe('value2')
})
it('should persist updates across multiple hooks with same key', async () => {
it('persists updates across multiple hooks with same key', async () => {
const { result: firstHook } = renderHook(() => useKV('shared_key', 'initial'))
const [, updateValue] = firstHook.current
@@ -134,14 +113,13 @@ describe('useKV', () => {
await updateValue('updated')
})
// Create a new hook with the same key
const { result: secondHook } = renderHook(() => useKV('shared_key', 'initial'))
const [value] = secondHook.current
expect(value).toBe('updated')
})
it('should sync updates across mounted hooks with same key', async () => {
it('syncs updates across mounted hooks with same key', async () => {
const { result: firstHook } = renderHook(() => useKV('sync_key', 'initial'))
const { result: secondHook } = renderHook(() => useKV('sync_key', 'initial'))
@@ -154,19 +132,7 @@ describe('useKV', () => {
})
})
it.each([
{ initialValue: null, key: 'falsy_key_null', description: 'null value' },
{ initialValue: false, key: 'falsy_key_false', description: 'false boolean' },
{ initialValue: 0, key: 'falsy_key_zero', description: 'zero number' },
{ initialValue: '', key: 'falsy_key_empty', description: 'empty string' },
])('should handle falsy $description correctly', ({ initialValue, key }) => {
const { result } = renderHook(() => useKV(key, initialValue))
const [value] = result.current
expect(value).toBe(initialValue)
})
it('should handle rapid updates correctly', async () => {
it('handles rapid updates correctly', async () => {
const { result } = renderHook(() => useKV('rapid_key', 0))
const [, updateValue] = result.current
@@ -183,7 +149,7 @@ describe('useKV', () => {
expect(finalValue).toBeGreaterThanOrEqual(1)
})
it('should persist updates to localStorage', async () => {
it('persists updates to localStorage', async () => {
const { result } = renderHook(() => useKV('persist_key', 'initial'))
const [, updateValue] = result.current

View File

@@ -0,0 +1,70 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useKV } from '@/hooks/data/useKV'
const STORAGE_PREFIX = 'mb_kv:'
let store: Record<string, string>
const setupLocalStorage = (): void => {
store = {}
vi.stubGlobal('localStorage', {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
Object.keys(store).forEach(k => delete store[k])
}),
length: 0,
key: vi.fn(() => null),
})
}
describe('useKV validation', () => {
beforeEach(() => {
setupLocalStorage()
})
it.each([
{ key: 'user_name', defaultValue: 'John', description: 'string value' },
{ key: 'user_count', defaultValue: 0, description: 'number value' },
{ key: 'is_active', defaultValue: true, description: 'boolean value' },
{ key: 'user_data', defaultValue: { id: 1, name: 'John' }, description: 'object value' },
])('initializes with $description', ({ key, defaultValue }) => {
const { result } = renderHook(() => useKV(key, defaultValue))
const [value] = result.current
expect(value).toBe(defaultValue)
})
it('initializes with undefined when no default value provided', () => {
const { result } = renderHook(() => useKV('empty_key'))
const [value] = result.current
expect(value).toBeUndefined()
})
it.each([
{ initialValue: null, key: 'falsy_key_null', description: 'null value' },
{ initialValue: false, key: 'falsy_key_false', description: 'false boolean' },
{ initialValue: 0, key: 'falsy_key_zero', description: 'zero number' },
{ initialValue: '', key: 'falsy_key_empty', description: 'empty string' },
])('handles $description correctly', ({ initialValue, key }) => {
const { result } = renderHook(() => useKV(key, initialValue))
const [value] = result.current
expect(value).toBe(initialValue)
})
it('loads value from localStorage when available', () => {
localStorage.setItem(`${STORAGE_PREFIX}stored_key`, JSON.stringify('stored'))
const { result } = renderHook(() => useKV('stored_key', 'default'))
const [value] = result.current
expect(value).toBe('stored')
})
})

View File

@@ -0,0 +1,74 @@
/**
* Auto-refresh error-handling tests
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useAutoRefresh } from '../useAutoRefresh'
describe('useAutoRefresh error handling', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('stops refresh when disabled after being enabled', () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
useAutoRefresh({
intervalMs: 5000,
onRefresh,
enabled: true,
})
)
act(() => {
vi.advanceTimersByTime(2500)
})
act(() => {
result.current.setEnabled(false)
})
act(() => {
vi.advanceTimersByTime(10000)
})
expect(onRefresh).not.toHaveBeenCalled()
})
it('continues scheduling refreshes after onRefresh errors', () => {
const erroringRefresh = vi.fn().mockImplementation(() =>
Promise.reject(new Error('refresh failed')).catch(() => {})
)
const { result } = renderHook(() =>
useAutoRefresh({
intervalMs: 2000,
onRefresh: erroringRefresh,
enabled: true,
})
)
expect(result.current.secondsUntilNextRefresh).toBe(2)
act(() => {
vi.advanceTimersByTime(2000)
})
expect(erroringRefresh).toHaveBeenCalledTimes(1)
act(() => {
vi.advanceTimersByTime(1000)
})
expect(result.current.secondsUntilNextRefresh).toBe(1)
act(() => {
vi.advanceTimersByTime(1000)
})
expect(result.current.secondsUntilNextRefresh).toBe(2)
expect(erroringRefresh).toHaveBeenCalledTimes(2)
})
})

View File

@@ -1,13 +1,11 @@
/**
* Tests for useAutoRefresh hook - Auto-refresh polling management
* Following parameterized test pattern per project conventions
* Auto-refresh polling tests
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { useAutoRefresh } from './useAutoRefresh'
import { useAutoRefresh } from '../useAutoRefresh'
describe('useAutoRefresh', () => {
describe('useAutoRefresh polling', () => {
beforeEach(() => {
vi.useFakeTimers()
})
@@ -21,7 +19,7 @@ describe('useAutoRefresh', () => {
{ enabled: false, expectAutoRefreshing: false },
{ enabled: true, expectAutoRefreshing: true },
{ enabled: undefined, expectAutoRefreshing: false },
])('should initialize with enabled=$enabled -> isAutoRefreshing=$expectAutoRefreshing', ({ enabled, expectAutoRefreshing }) => {
])('initializes with enabled=$enabled -> isAutoRefreshing=$expectAutoRefreshing', ({ enabled, expectAutoRefreshing }) => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
@@ -39,7 +37,7 @@ describe('useAutoRefresh', () => {
{ intervalMs: 30000, expectedSeconds: 30 },
{ intervalMs: 60000, expectedSeconds: 60 },
{ intervalMs: 5000, expectedSeconds: 5 },
])('should set secondsUntilNextRefresh from intervalMs=$intervalMs', ({ intervalMs, expectedSeconds }) => {
])('sets secondsUntilNextRefresh from intervalMs=$intervalMs', ({ intervalMs, expectedSeconds }) => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
@@ -55,7 +53,7 @@ describe('useAutoRefresh', () => {
})
describe('toggleAutoRefresh', () => {
it('should toggle from disabled to enabled', () => {
it('toggles from disabled to enabled', () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
@@ -75,7 +73,7 @@ describe('useAutoRefresh', () => {
expect(result.current.isAutoRefreshing).toBe(true)
})
it('should toggle from enabled to disabled', () => {
it('toggles from enabled to disabled', () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
@@ -102,7 +100,7 @@ describe('useAutoRefresh', () => {
{ initial: true, setTo: false, expected: false },
{ initial: false, setTo: false, expected: false },
{ initial: true, setTo: true, expected: true },
])('should set from $initial to $setTo', ({ initial, setTo, expected }) => {
])('sets from $initial to $setTo', ({ initial, setTo, expected }) => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
@@ -122,7 +120,7 @@ describe('useAutoRefresh', () => {
})
describe('refresh timing', () => {
it('should call onRefresh after intervalMs when enabled', async () => {
it('calls onRefresh after intervalMs when enabled', async () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
renderHook(() =>
@@ -133,23 +131,20 @@ describe('useAutoRefresh', () => {
})
)
// Not called initially
expect(onRefresh).not.toHaveBeenCalled()
// Advance to just before interval
act(() => {
vi.advanceTimersByTime(4999)
})
expect(onRefresh).not.toHaveBeenCalled()
// Advance past interval
act(() => {
vi.advanceTimersByTime(1)
})
expect(onRefresh).toHaveBeenCalledTimes(1)
})
it('should not call onRefresh when disabled', () => {
it('does not call onRefresh when disabled', () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
renderHook(() =>
@@ -167,7 +162,7 @@ describe('useAutoRefresh', () => {
expect(onRefresh).not.toHaveBeenCalled()
})
it('should call onRefresh multiple times at interval', () => {
it('calls onRefresh multiple times at interval', () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
renderHook(() =>
@@ -179,7 +174,7 @@ describe('useAutoRefresh', () => {
)
act(() => {
vi.advanceTimersByTime(15000) // 3 intervals
vi.advanceTimersByTime(15000)
})
expect(onRefresh).toHaveBeenCalledTimes(3)
@@ -187,7 +182,7 @@ describe('useAutoRefresh', () => {
})
describe('countdown', () => {
it('should decrement countdown every second when enabled', () => {
it('decrements countdown every second when enabled', () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
@@ -211,7 +206,7 @@ describe('useAutoRefresh', () => {
expect(result.current.secondsUntilNextRefresh).toBe(3)
})
it('should reset countdown after reaching zero', () => {
it('resets countdown after reaching zero', () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
@@ -224,45 +219,11 @@ describe('useAutoRefresh', () => {
expect(result.current.secondsUntilNextRefresh).toBe(3)
// Advance 3 seconds to reach zero
act(() => {
vi.advanceTimersByTime(3000)
})
// Should reset to initial value
expect(result.current.secondsUntilNextRefresh).toBe(3)
})
})
describe('cleanup', () => {
it('should stop refresh when disabled after being enabled', () => {
const onRefresh = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
useAutoRefresh({
intervalMs: 5000,
onRefresh,
enabled: true,
})
)
// Advance half interval
act(() => {
vi.advanceTimersByTime(2500)
})
// Disable
act(() => {
result.current.setEnabled(false)
})
// Advance another full interval
act(() => {
vi.advanceTimersByTime(10000)
})
// Should not have been called (disabled before first interval completed)
expect(onRefresh).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,3 @@
import type { CssCategory } from '../../../../core/types'
export const buildAdvancedCssCategories = (): CssCategory[] => []

View File

@@ -0,0 +1,278 @@
import type { CssCategory } from '../../../../core/types'
import { buildSizingClasses, buildSpacingClasses } from '../build-css-classes'
export const buildBaseCssCategories = (): CssCategory[] => [
{
name: 'Layout',
classes: [
'block',
'inline-block',
'inline',
'flex',
'inline-flex',
'grid',
'inline-grid',
'contents',
'hidden',
'flex-row',
'flex-row-reverse',
'flex-col',
'flex-col-reverse',
'flex-wrap',
'flex-wrap-reverse',
'flex-nowrap',
],
},
{
name: 'Spacing',
classes: buildSpacingClasses(),
},
{
name: 'Sizing',
classes: buildSizingClasses(),
},
{
name: 'Typography',
classes: [
'text-xs',
'text-sm',
'text-base',
'text-lg',
'text-xl',
'text-2xl',
'text-3xl',
'text-4xl',
'text-5xl',
'text-6xl',
'font-thin',
'font-light',
'font-normal',
'font-medium',
'font-semibold',
'font-bold',
'font-extrabold',
'font-black',
'leading-none',
'leading-tight',
'leading-snug',
'leading-normal',
'leading-relaxed',
'leading-loose',
'tracking-tighter',
'tracking-tight',
'tracking-normal',
'tracking-wide',
'tracking-wider',
'tracking-widest',
'text-left',
'text-center',
'text-right',
'text-justify',
'uppercase',
'lowercase',
'capitalize',
'normal-case',
'italic',
'not-italic',
'underline',
'no-underline',
'line-through',
'font-sans',
'font-serif',
'font-mono',
],
},
{
name: 'Colors',
classes: [
'text-foreground',
'text-muted-foreground',
'text-primary',
'text-primary-foreground',
'text-secondary',
'text-secondary-foreground',
'text-accent',
'text-accent-foreground',
'text-destructive',
'text-destructive-foreground',
'bg-background',
'bg-card',
'bg-muted',
'bg-accent',
'bg-primary',
'bg-secondary',
'bg-destructive',
'bg-popover',
'bg-transparent',
'bg-white',
'bg-black',
'text-white',
'text-black',
'border-border',
'border-input',
'border-primary',
'border-secondary',
'border-accent',
'border-destructive',
'ring-ring',
'ring-primary',
'ring-secondary',
'ring-accent',
'ring-destructive',
],
},
{
name: 'Borders',
classes: [
'border',
'border-0',
'border-2',
'border-4',
'border-8',
'border-t',
'border-b',
'border-l',
'border-r',
'border-x',
'border-y',
'border-solid',
'border-dashed',
'border-dotted',
'border-double',
'border-hidden',
'rounded-none',
'rounded-sm',
'rounded',
'rounded-md',
'rounded-lg',
'rounded-xl',
'rounded-2xl',
'rounded-3xl',
'rounded-full',
],
},
{
name: 'Effects',
classes: [
'shadow-none',
'shadow-sm',
'shadow',
'shadow-md',
'shadow-lg',
'shadow-xl',
'shadow-2xl',
'shadow-inner',
'ring-0',
'ring-1',
'ring-2',
'ring-4',
'ring-offset-1',
'ring-offset-2',
'opacity-0',
'opacity-25',
'opacity-50',
'opacity-75',
'opacity-100',
'transition',
'transition-all',
'transition-colors',
'transition-opacity',
'transition-transform',
'duration-75',
'duration-100',
'duration-150',
'duration-200',
'duration-300',
'duration-500',
'ease-in',
'ease-out',
'ease-in-out',
'blur-none',
'blur-sm',
'blur',
'blur-md',
'blur-lg',
'backdrop-blur',
'backdrop-blur-sm',
],
},
{
name: 'Positioning',
classes: [
'static',
'relative',
'absolute',
'fixed',
'sticky',
'inset-0',
'inset-x-0',
'inset-y-0',
'top-0',
'right-0',
'bottom-0',
'left-0',
'z-auto',
'z-0',
'z-10',
'z-20',
'z-30',
'z-40',
'z-50',
'overflow-hidden',
'overflow-auto',
'overflow-scroll',
'overflow-visible',
'overflow-x-auto',
'overflow-y-auto',
],
},
{
name: 'Alignment',
classes: [
'items-start',
'items-center',
'items-end',
'items-stretch',
'items-baseline',
'justify-start',
'justify-center',
'justify-end',
'justify-between',
'justify-around',
'justify-evenly',
'content-start',
'content-center',
'content-end',
'self-start',
'self-center',
'self-end',
'self-stretch',
'place-items-start',
'place-items-center',
'place-items-end',
],
},
{
name: 'Interactivity',
classes: [
'cursor-pointer',
'cursor-default',
'cursor-not-allowed',
'pointer-events-none',
'pointer-events-auto',
'select-none',
'select-text',
'select-all',
'select-auto',
'hover:bg-accent',
'hover:text-accent-foreground',
'hover:underline',
'active:scale-95',
'focus:ring-2',
'focus:ring-primary',
'focus-visible:outline-none',
'disabled:opacity-50',
'disabled:pointer-events-none',
],
},
]

View File

@@ -0,0 +1,3 @@
import type { CssCategory } from '../../../../core/types'
export const buildExperimentalCssCategories = (): CssCategory[] => []

View File

@@ -1,278 +1,10 @@
import type { CssCategory } from '../../../core/types'
import { buildSizingClasses, buildSpacingClasses } from './build-css-classes'
import { buildAdvancedCssCategories } from './categories/advanced'
import { buildBaseCssCategories } from './categories/base'
import { buildExperimentalCssCategories } from './categories/experimental'
export const buildDefaultCssCategories = (): CssCategory[] => [
{
name: 'Layout',
classes: [
'block',
'inline-block',
'inline',
'flex',
'inline-flex',
'grid',
'inline-grid',
'contents',
'hidden',
'flex-row',
'flex-row-reverse',
'flex-col',
'flex-col-reverse',
'flex-wrap',
'flex-wrap-reverse',
'flex-nowrap',
],
},
{
name: 'Spacing',
classes: buildSpacingClasses(),
},
{
name: 'Sizing',
classes: buildSizingClasses(),
},
{
name: 'Typography',
classes: [
'text-xs',
'text-sm',
'text-base',
'text-lg',
'text-xl',
'text-2xl',
'text-3xl',
'text-4xl',
'text-5xl',
'text-6xl',
'font-thin',
'font-light',
'font-normal',
'font-medium',
'font-semibold',
'font-bold',
'font-extrabold',
'font-black',
'leading-none',
'leading-tight',
'leading-snug',
'leading-normal',
'leading-relaxed',
'leading-loose',
'tracking-tighter',
'tracking-tight',
'tracking-normal',
'tracking-wide',
'tracking-wider',
'tracking-widest',
'text-left',
'text-center',
'text-right',
'text-justify',
'uppercase',
'lowercase',
'capitalize',
'normal-case',
'italic',
'not-italic',
'underline',
'no-underline',
'line-through',
'font-sans',
'font-serif',
'font-mono',
],
},
{
name: 'Colors',
classes: [
'text-foreground',
'text-muted-foreground',
'text-primary',
'text-primary-foreground',
'text-secondary',
'text-secondary-foreground',
'text-accent',
'text-accent-foreground',
'text-destructive',
'text-destructive-foreground',
'bg-background',
'bg-card',
'bg-muted',
'bg-accent',
'bg-primary',
'bg-secondary',
'bg-destructive',
'bg-popover',
'bg-transparent',
'bg-white',
'bg-black',
'text-white',
'text-black',
'border-border',
'border-input',
'border-primary',
'border-secondary',
'border-accent',
'border-destructive',
'ring-ring',
'ring-primary',
'ring-secondary',
'ring-accent',
'ring-destructive',
],
},
{
name: 'Borders',
classes: [
'border',
'border-0',
'border-2',
'border-4',
'border-8',
'border-t',
'border-b',
'border-l',
'border-r',
'border-x',
'border-y',
'border-solid',
'border-dashed',
'border-dotted',
'border-double',
'border-hidden',
'rounded-none',
'rounded-sm',
'rounded',
'rounded-md',
'rounded-lg',
'rounded-xl',
'rounded-2xl',
'rounded-3xl',
'rounded-full',
],
},
{
name: 'Effects',
classes: [
'shadow-none',
'shadow-sm',
'shadow',
'shadow-md',
'shadow-lg',
'shadow-xl',
'shadow-2xl',
'shadow-inner',
'ring-0',
'ring-1',
'ring-2',
'ring-4',
'ring-offset-1',
'ring-offset-2',
'opacity-0',
'opacity-25',
'opacity-50',
'opacity-75',
'opacity-100',
'transition',
'transition-all',
'transition-colors',
'transition-opacity',
'transition-transform',
'duration-75',
'duration-100',
'duration-150',
'duration-200',
'duration-300',
'duration-500',
'ease-in',
'ease-out',
'ease-in-out',
'blur-none',
'blur-sm',
'blur',
'blur-md',
'blur-lg',
'backdrop-blur',
'backdrop-blur-sm',
],
},
{
name: 'Positioning',
classes: [
'static',
'relative',
'absolute',
'fixed',
'sticky',
'inset-0',
'inset-x-0',
'inset-y-0',
'top-0',
'right-0',
'bottom-0',
'left-0',
'z-auto',
'z-0',
'z-10',
'z-20',
'z-30',
'z-40',
'z-50',
'overflow-hidden',
'overflow-auto',
'overflow-scroll',
'overflow-visible',
'overflow-x-auto',
'overflow-y-auto',
],
},
{
name: 'Alignment',
classes: [
'items-start',
'items-center',
'items-end',
'items-stretch',
'items-baseline',
'justify-start',
'justify-center',
'justify-end',
'justify-between',
'justify-around',
'justify-evenly',
'content-start',
'content-center',
'content-end',
'self-start',
'self-center',
'self-end',
'self-stretch',
'place-items-start',
'place-items-center',
'place-items-end',
],
},
{
name: 'Interactivity',
classes: [
'cursor-pointer',
'cursor-default',
'cursor-not-allowed',
'pointer-events-none',
'pointer-events-auto',
'select-none',
'select-text',
'select-all',
'select-auto',
'hover:bg-accent',
'hover:text-accent-foreground',
'hover:underline',
'active:scale-95',
'focus:ring-2',
'focus:ring-primary',
'focus-visible:outline-none',
'disabled:opacity-50',
'disabled:pointer-events-none',
],
},
...buildBaseCssCategories(),
...buildAdvancedCssCategories(),
...buildExperimentalCssCategories(),
]

View File

@@ -1,5 +1,52 @@
import { describe, it, expect } from 'vitest'
import { summarizeWorkflowRuns } from './analyze-workflow-runs'
import {
analyzeWorkflowRuns,
parseWorkflowRuns,
summarizeWorkflowRuns,
} from './analyze-workflow-runs'
describe('parseWorkflowRuns', () => {
it('normalizes unknown entries and ignores items without numeric IDs', () => {
const runs = [
{
id: 1,
name: 'Build',
status: 'completed',
conclusion: 'success',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:10:00Z',
head_branch: 'main',
event: 'push',
},
{ id: 'not-a-number' },
{
id: 2,
name: '',
status: '',
conclusion: 'failure',
created_at: '',
updated_at: '',
head_branch: '',
event: '',
},
]
const parsed = parseWorkflowRuns(runs)
expect(parsed).toHaveLength(2)
expect(parsed[0].name).toBe('Build')
expect(parsed[1]).toEqual({
id: 2,
name: 'Unknown workflow',
status: 'unknown',
conclusion: 'failure',
created_at: '',
updated_at: '',
head_branch: 'unknown',
event: 'unknown',
})
})
})
describe('summarizeWorkflowRuns', () => {
it('summarizes totals, success rate, and failure hotspots', () => {
@@ -60,3 +107,24 @@ describe('summarizeWorkflowRuns', () => {
expect(summary.mostRecent).toBeNull()
})
})
describe('analyzeWorkflowRuns', () => {
it('returns parsed summary and formatted output', () => {
const result = analyzeWorkflowRuns([
{
id: 7,
name: 'Deploy',
status: 'completed',
conclusion: 'success',
created_at: '2024-02-01T00:00:00Z',
updated_at: '2024-02-01T00:05:00Z',
head_branch: 'main',
event: 'workflow_dispatch',
},
])
expect(result.summary.total).toBe(1)
expect(result.formatted).toContain('Workflow Run Analysis')
expect(result.formatted).toContain('Deploy')
})
})

View File

@@ -1,164 +1,18 @@
export type WorkflowRunLike = {
id: number
name: string
status: string
conclusion: string | null
created_at: string
updated_at: string
head_branch: string
event: string
}
import { parseWorkflowRuns, WorkflowRunLike } from './parser'
import { formatWorkflowRunAnalysis, summarizeWorkflowRuns, WorkflowRunSummary } from './stats'
export type WorkflowRunSummary = {
total: number
completed: number
successful: number
failed: number
cancelled: number
inProgress: number
successRate: number
mostRecent: WorkflowRunLike | null
recentRuns: WorkflowRunLike[]
topFailingWorkflows: Array<{ name: string; failures: number }>
failingBranches: Array<{ branch: string; failures: number }>
failingEvents: Array<{ event: string; failures: number }>
}
export type { WorkflowRunLike, WorkflowRunSummary }
export { parseWorkflowRuns, summarizeWorkflowRuns, formatWorkflowRunAnalysis }
const DEFAULT_RECENT_COUNT = 5
const DEFAULT_TOP_COUNT = 3
function toTopCounts(
values: string[],
topCount: number
): Array<{ key: string; count: number }> {
const counts = new Map<string, number>()
values.forEach((value) => {
counts.set(value, (counts.get(value) || 0) + 1)
})
return Array.from(counts.entries())
.map(([key, count]) => ({ key, count }))
.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
.slice(0, topCount)
}
export function summarizeWorkflowRuns(
runs: WorkflowRunLike[],
export function analyzeWorkflowRuns(
runs: unknown[],
options?: { recentCount?: number; topCount?: number }
): WorkflowRunSummary {
const recentCount = options?.recentCount ?? DEFAULT_RECENT_COUNT
const topCount = options?.topCount ?? DEFAULT_TOP_COUNT
const total = runs.length
const completedRuns = runs.filter((run) => run.status === 'completed')
const successful = completedRuns.filter((run) => run.conclusion === 'success').length
const failed = completedRuns.filter((run) => run.conclusion === 'failure').length
const cancelled = completedRuns.filter((run) => run.conclusion === 'cancelled').length
const inProgress = total - completedRuns.length
const successRate = completedRuns.length
? Math.round((successful / completedRuns.length) * 100)
: 0
const sortedByUpdated = [...runs].sort(
(a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
)
const mostRecent = sortedByUpdated[0] ?? null
const recentRuns = sortedByUpdated.slice(0, recentCount)
const failureRuns = completedRuns.filter((run) => run.conclusion === 'failure')
const topFailingWorkflows = toTopCounts(
failureRuns.map((run) => run.name),
topCount
).map((entry) => ({ name: entry.key, failures: entry.count }))
const failingBranches = toTopCounts(
failureRuns.map((run) => run.head_branch),
topCount
).map((entry) => ({ branch: entry.key, failures: entry.count }))
const failingEvents = toTopCounts(
failureRuns.map((run) => run.event),
topCount
).map((entry) => ({ event: entry.key, failures: entry.count }))
) {
const parsedRuns = parseWorkflowRuns(runs)
const summary = summarizeWorkflowRuns(parsedRuns, options)
return {
total,
completed: completedRuns.length,
successful,
failed,
cancelled,
inProgress,
successRate,
mostRecent,
recentRuns,
topFailingWorkflows,
failingBranches,
failingEvents,
summary,
formatted: formatWorkflowRunAnalysis(summary),
}
}
export function formatWorkflowRunAnalysis(summary: WorkflowRunSummary) {
const lines: string[] = []
lines.push('Workflow Run Analysis')
lines.push('---------------------')
lines.push(`Total runs: ${summary.total}`)
lines.push(
`Completed: ${summary.completed} (success: ${summary.successful}, failed: ${summary.failed}, cancelled: ${summary.cancelled})`
)
lines.push(`In progress: ${summary.inProgress}`)
lines.push(`Success rate: ${summary.successRate}%`)
if (summary.mostRecent) {
lines.push('')
lines.push('Most recent run:')
lines.push(
`- ${summary.mostRecent.name} | ${summary.mostRecent.status}${
summary.mostRecent.conclusion ? `/${summary.mostRecent.conclusion}` : ''
} | ${summary.mostRecent.head_branch} | ${summary.mostRecent.updated_at}`
)
}
if (summary.recentRuns.length > 0) {
lines.push('')
lines.push('Recent runs:')
summary.recentRuns.forEach((run) => {
lines.push(
`- ${run.name} | ${run.status}${
run.conclusion ? `/${run.conclusion}` : ''
} | ${run.head_branch} | ${run.updated_at}`
)
})
}
if (summary.topFailingWorkflows.length > 0) {
lines.push('')
lines.push('Top failing workflows:')
summary.topFailingWorkflows.forEach((entry) => {
lines.push(`- ${entry.name}: ${entry.failures}`)
})
}
if (summary.failingBranches.length > 0) {
lines.push('')
lines.push('Failing branches:')
summary.failingBranches.forEach((entry) => {
lines.push(`- ${entry.branch}: ${entry.failures}`)
})
}
if (summary.failingEvents.length > 0) {
lines.push('')
lines.push('Failing events:')
summary.failingEvents.forEach((entry) => {
lines.push(`- ${entry.event}: ${entry.failures}`)
})
}
if (summary.total === 0) {
lines.push('')
lines.push('No workflow runs available to analyze.')
}
return lines.join('\n')
}

View File

@@ -0,0 +1,50 @@
export type WorkflowRunLike = {
id: number
name: string
status: string
conclusion: string | null
created_at: string
updated_at: string
head_branch: string
event: string
}
const FALLBACK_NAME = 'Unknown workflow'
const FALLBACK_STATUS = 'unknown'
const FALLBACK_BRANCH = 'unknown'
const FALLBACK_EVENT = 'unknown'
function toStringOrFallback(value: unknown, fallback: string) {
return typeof value === 'string' && value.trim() ? value : fallback
}
export function parseWorkflowRuns(runs: unknown[]): WorkflowRunLike[] {
if (!Array.isArray(runs)) {
return []
}
return runs
.map((run) => {
const candidate = run as Partial<WorkflowRunLike> & { id?: unknown }
const id = Number(candidate.id)
if (!Number.isFinite(id)) {
return null
}
return {
id,
name: toStringOrFallback(candidate.name, FALLBACK_NAME),
status: toStringOrFallback(candidate.status, FALLBACK_STATUS),
conclusion:
candidate.conclusion === null || typeof candidate.conclusion === 'string'
? candidate.conclusion
: null,
created_at: toStringOrFallback(candidate.created_at, ''),
updated_at: toStringOrFallback(candidate.updated_at, ''),
head_branch: toStringOrFallback(candidate.head_branch, FALLBACK_BRANCH),
event: toStringOrFallback(candidate.event, FALLBACK_EVENT),
}
})
.filter((run): run is WorkflowRunLike => Boolean(run))
}

View File

@@ -0,0 +1,153 @@
import { WorkflowRunLike } from './parser'
export type WorkflowRunSummary = {
total: number
completed: number
successful: number
failed: number
cancelled: number
inProgress: number
successRate: number
mostRecent: WorkflowRunLike | null
recentRuns: WorkflowRunLike[]
topFailingWorkflows: Array<{ name: string; failures: number }>
failingBranches: Array<{ branch: string; failures: number }>
failingEvents: Array<{ event: string; failures: number }>
}
const DEFAULT_RECENT_COUNT = 5
const DEFAULT_TOP_COUNT = 3
function toTopCounts(
values: string[],
topCount: number
): Array<{ key: string; count: number }> {
const counts = new Map<string, number>()
values.forEach((value) => {
counts.set(value, (counts.get(value) || 0) + 1)
})
return Array.from(counts.entries())
.map(([key, count]) => ({ key, count }))
.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
.slice(0, topCount)
}
export function summarizeWorkflowRuns(
runs: WorkflowRunLike[],
options?: { recentCount?: number; topCount?: number }
): WorkflowRunSummary {
const recentCount = options?.recentCount ?? DEFAULT_RECENT_COUNT
const topCount = options?.topCount ?? DEFAULT_TOP_COUNT
const total = runs.length
const completedRuns = runs.filter((run) => run.status === 'completed')
const successful = completedRuns.filter((run) => run.conclusion === 'success').length
const failed = completedRuns.filter((run) => run.conclusion === 'failure').length
const cancelled = completedRuns.filter((run) => run.conclusion === 'cancelled').length
const inProgress = total - completedRuns.length
const successRate = completedRuns.length
? Math.round((successful / completedRuns.length) * 100)
: 0
const sortedByUpdated = [...runs].sort(
(a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
)
const mostRecent = sortedByUpdated[0] ?? null
const recentRuns = sortedByUpdated.slice(0, recentCount)
const failureRuns = completedRuns.filter((run) => run.conclusion === 'failure')
const topFailingWorkflows = toTopCounts(
failureRuns.map((run) => run.name),
topCount
).map((entry) => ({ name: entry.key, failures: entry.count }))
const failingBranches = toTopCounts(
failureRuns.map((run) => run.head_branch),
topCount
).map((entry) => ({ branch: entry.key, failures: entry.count }))
const failingEvents = toTopCounts(
failureRuns.map((run) => run.event),
topCount
).map((entry) => ({ event: entry.key, failures: entry.count }))
return {
total,
completed: completedRuns.length,
successful,
failed,
cancelled,
inProgress,
successRate,
mostRecent,
recentRuns,
topFailingWorkflows,
failingBranches,
failingEvents,
}
}
export function formatWorkflowRunAnalysis(summary: WorkflowRunSummary) {
const lines: string[] = []
lines.push('Workflow Run Analysis')
lines.push('---------------------')
lines.push(`Total runs: ${summary.total}`)
lines.push(
`Completed: ${summary.completed} (success: ${summary.successful}, failed: ${summary.failed}, cancelled: ${summary.cancelled})`
)
lines.push(`In progress: ${summary.inProgress}`)
lines.push(`Success rate: ${summary.successRate}%`)
if (summary.mostRecent) {
lines.push('')
lines.push('Most recent run:')
lines.push(
`- ${summary.mostRecent.name} | ${summary.mostRecent.status}${
summary.mostRecent.conclusion ? `/${summary.mostRecent.conclusion}` : ''
} | ${summary.mostRecent.head_branch} | ${summary.mostRecent.updated_at}`
)
}
if (summary.recentRuns.length > 0) {
lines.push('')
lines.push('Recent runs:')
summary.recentRuns.forEach((run) => {
lines.push(
`- ${run.name} | ${run.status}${run.conclusion ? `/${run.conclusion}` : ''} | ${run.head_branch} | ${run.updated_at}`
)
})
}
if (summary.topFailingWorkflows.length > 0) {
lines.push('')
lines.push('Top failing workflows:')
summary.topFailingWorkflows.forEach((entry) => {
lines.push(`- ${entry.name}: ${entry.failures}`)
})
}
if (summary.failingBranches.length > 0) {
lines.push('')
lines.push('Failing branches:')
summary.failingBranches.forEach((entry) => {
lines.push(`- ${entry.branch}: ${entry.failures}`)
})
}
if (summary.failingEvents.length > 0) {
lines.push('')
lines.push('Failing events:')
summary.failingEvents.forEach((entry) => {
lines.push(`- ${entry.event}: ${entry.failures}`)
})
}
if (summary.total === 0) {
lines.push('')
lines.push('No workflow runs available to analyze.')
}
return lines.join('\n')
}

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { LuaEngine, createLuaEngine } from '../lua-engine'
describe('lua-engine events', () => {
let engine: LuaEngine
beforeEach(() => {
engine = createLuaEngine()
})
afterEach(() => {
engine.destroy()
})
describe('logging', () => {
it('should capture log() calls', async () => {
const result = await engine.execute(`
log("message 1")
log("message 2")
return "done"
`)
expect(result.success).toBe(true)
expect(result.logs).toContain('message 1')
expect(result.logs).toContain('message 2')
})
it('should capture print() calls', async () => {
const result = await engine.execute(`
print("printed output")
return true
`)
expect(result.success).toBe(true)
expect(result.logs).toContain('printed output')
})
it.each([
{ name: 'number', code: 'log(42)', expected: '42' },
{ name: 'boolean', code: 'log(true)', expected: 'true' },
{ name: 'multiple args', code: 'log("a", "b", "c")', expected: 'a b c' },
])('should log $name correctly', async ({ code, expected }) => {
const result = await engine.execute(code)
expect(result.logs).toContain(expected)
})
})
describe('error handling', () => {
it.each([
{
name: 'syntax error',
code: 'this is not valid lua !!!',
errorContains: 'Syntax error',
},
{
name: 'undefined variable',
code: 'return undefinedVar.property',
errorContains: 'Runtime error',
},
{
name: 'type error',
code: 'return "string" + 5',
errorContains: 'error',
},
{
name: 'explicit error',
code: 'error("intentional error")',
errorContains: 'intentional error',
},
])('should handle $name', async ({ code, errorContains }) => {
const result = await engine.execute(code)
expect(result.success).toBe(false)
expect(result.error?.toLowerCase()).toContain(errorContains.toLowerCase())
})
})
})

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { LuaEngine, createLuaEngine, type LuaExecutionContext } from './lua-engine'
import { LuaEngine, createLuaEngine, type LuaExecutionContext } from '../lua-engine'
describe('lua-engine', () => {
describe('lua-engine execution', () => {
let engine: LuaEngine
beforeEach(() => {
@@ -81,31 +81,31 @@ describe('lua-engine', () => {
{
name: 'access context.data number',
code: 'return context.data.value * 2',
context: { data: { value: 21 } },
context: { data: { value: 21 } } satisfies LuaExecutionContext,
expected: 42,
},
{
name: 'access context.data string',
code: 'return context.data.name',
context: { data: { name: 'test' } },
context: { data: { name: 'test' } } satisfies LuaExecutionContext,
expected: 'test',
},
{
name: 'access context.data boolean',
code: 'return context.data.active',
context: { data: { active: true } },
context: { data: { active: true } } satisfies LuaExecutionContext,
expected: true,
},
{
name: 'access nested context.data',
code: 'return context.data.user.name',
context: { data: { user: { name: 'Alice' } } },
context: { data: { user: { name: 'Alice' } } } satisfies LuaExecutionContext,
expected: 'Alice',
},
{
name: 'access context.data array',
code: 'return context.data.items[2]',
context: { data: { items: [10, 20, 30] } },
context: { data: { items: [10, 20, 30] } } satisfies LuaExecutionContext,
expected: 20,
},
])('should $name', async ({ code, context, expected }) => {
@@ -241,66 +241,6 @@ describe('lua-engine', () => {
})
})
describe('logging', () => {
it('should capture log() calls', async () => {
const result = await engine.execute(`
log("message 1")
log("message 2")
return "done"
`)
expect(result.success).toBe(true)
expect(result.logs).toContain('message 1')
expect(result.logs).toContain('message 2')
})
it('should capture print() calls', async () => {
const result = await engine.execute(`
print("printed output")
return true
`)
expect(result.success).toBe(true)
expect(result.logs).toContain('printed output')
})
it.each([
{ name: 'number', code: 'log(42)', expected: '42' },
{ name: 'boolean', code: 'log(true)', expected: 'true' },
{ name: 'multiple args', code: 'log("a", "b", "c")', expected: 'a b c' },
])('should log $name correctly', async ({ code, expected }) => {
const result = await engine.execute(code)
expect(result.logs).toContain(expected)
})
})
describe('error handling', () => {
it.each([
{
name: 'syntax error',
code: 'this is not valid lua !!!',
errorContains: 'Syntax error',
},
{
name: 'undefined variable',
code: 'return undefinedVar.property',
errorContains: 'Runtime error',
},
{
name: 'type error',
code: 'return "string" + 5',
errorContains: 'error',
},
{
name: 'explicit error',
code: 'error("intentional error")',
errorContains: 'intentional error',
},
])('should handle $name', async ({ code, errorContains }) => {
const result = await engine.execute(code)
expect(result.success).toBe(false)
expect(result.error?.toLowerCase()).toContain(errorContains.toLowerCase())
})
})
describe('multiple return values', () => {
it('should handle multiple return values', async () => {
const result = await engine.execute('return 1, 2, 3')

View File

@@ -0,0 +1,3 @@
import type { PackageTemplateConfig } from '../../types'
export const ADVANCED_PACKAGE_TEMPLATE_CONFIGS: PackageTemplateConfig[] = []

View File

@@ -0,0 +1,267 @@
import type { PackageTemplateConfig, ReactAppTemplateConfig } from '../../types'
export const BASE_REACT_APP_TEMPLATE_CONFIG: ReactAppTemplateConfig = {
id: 'react_next_starter',
name: 'Next.js Web App',
description: 'A clean Next.js starter with app router, hero component, and typed config files.',
rootName: 'web_app',
tags: ['nextjs', 'react', 'web', 'starter'],
}
const socialHubComponents = [
{
id: 'social_hub_root',
type: 'Stack',
props: { className: 'flex flex-col gap-6' },
children: [
{
id: 'social_hub_hero',
type: 'Card',
props: { className: 'p-6' },
children: [
{
id: 'social_hub_heading',
type: 'Heading',
props: { children: 'Social Hub', level: '2', className: 'text-2xl font-bold' },
children: [],
},
{
id: 'social_hub_subtitle',
type: 'Text',
props: { children: 'A modern feed for creator updates, curated stories, and live moments.' },
children: [],
},
],
},
{
id: 'social_hub_stats',
type: 'Grid',
props: { className: 'grid grid-cols-3 gap-4' },
children: [
{
id: 'social_hub_stat_1',
type: 'Card',
props: { className: 'p-4' },
children: [
{
id: 'social_hub_stat_label_1',
type: 'Text',
props: { children: 'Creators live', className: 'text-sm text-muted-foreground' },
children: [],
},
{
id: 'social_hub_stat_value_1',
type: 'Heading',
props: { children: '128', level: '3', className: 'text-xl font-semibold' },
children: [],
},
],
},
{
id: 'social_hub_stat_2',
type: 'Card',
props: { className: 'p-4' },
children: [
{
id: 'social_hub_stat_label_2',
type: 'Text',
props: { children: 'Trending tags', className: 'text-sm text-muted-foreground' },
children: [],
},
{
id: 'social_hub_stat_value_2',
type: 'Heading',
props: { children: '42', level: '3', className: 'text-xl font-semibold' },
children: [],
},
],
},
{
id: 'social_hub_stat_3',
type: 'Card',
props: { className: 'p-4' },
children: [
{
id: 'social_hub_stat_label_3',
type: 'Text',
props: { children: 'Live rooms', className: 'text-sm text-muted-foreground' },
children: [],
},
{
id: 'social_hub_stat_value_3',
type: 'Heading',
props: { children: '7', level: '3', className: 'text-xl font-semibold' },
children: [],
},
],
},
],
},
{
id: 'social_hub_composer',
type: 'Card',
props: { className: 'p-4' },
children: [
{
id: 'social_hub_composer_label',
type: 'Label',
props: { children: 'Share a quick update' },
children: [],
},
{
id: 'social_hub_composer_input',
type: 'Textarea',
props: { placeholder: 'What are you building today?', rows: 3 },
children: [],
},
{
id: 'social_hub_composer_actions',
type: 'Flex',
props: { className: 'flex gap-2' },
children: [
{
id: 'social_hub_composer_publish',
type: 'Button',
props: { children: 'Publish', variant: 'default' },
children: [],
},
{
id: 'social_hub_composer_media',
type: 'Button',
props: { children: 'Add media', variant: 'outline' },
children: [],
},
],
},
],
},
{
id: 'social_hub_feed',
type: 'Stack',
props: { className: 'flex flex-col gap-4' },
children: [
{
id: 'social_hub_feed_post_1',
type: 'Card',
props: { className: 'p-5' },
children: [
{
id: 'social_hub_feed_post_1_title',
type: 'Heading',
props: { children: 'Launch day recap', level: '3', className: 'text-lg font-semibold' },
children: [],
},
{
id: 'social_hub_feed_post_1_body',
type: 'Text',
props: { children: 'We shipped the new live rooms and saw a 32% boost in engagement.' },
children: [],
},
{
id: 'social_hub_feed_post_1_badge',
type: 'Badge',
props: { children: 'Community' },
children: [],
},
],
},
{
id: 'social_hub_feed_post_2',
type: 'Card',
props: { className: 'p-5' },
children: [
{
id: 'social_hub_feed_post_2_title',
type: 'Heading',
props: { children: 'Creator spotlight', level: '3', className: 'text-lg font-semibold' },
children: [],
},
{
id: 'social_hub_feed_post_2_body',
type: 'Text',
props: { children: 'Nova shares her workflow for livestreaming and managing subscribers.' },
children: [],
},
{
id: 'social_hub_feed_post_2_badge',
type: 'Badge',
props: { children: 'Spotlight', variant: 'secondary' },
children: [],
},
],
},
],
},
],
},
]
const socialHubExamples = {
feedItems: [
{
id: 'post_001',
author: 'Nova',
title: 'Launch day recap',
summary: 'We shipped live rooms and doubled community sessions.',
tags: ['launch', 'community'],
},
{
id: 'post_002',
author: 'Kai',
title: 'Build log: day 42',
summary: 'Refined the moderation pipeline and added creator scorecards.',
tags: ['buildinpublic'],
},
],
trendingTags: ['#buildinpublic', '#metabuilder', '#live'],
rooms: [
{ id: 'room_1', title: 'Creator Q&A', host: 'Eli', live: true },
{ id: 'room_2', title: 'Patch Notes', host: 'Nova', live: false },
],
}
const socialHubLuaScripts = [
{
fileName: 'init.lua',
description: 'Lifecycle hooks for package installation.',
code: 'local M = {}\\n\\nfunction M.on_install(context)\\n return { message = "Social Hub installed", version = context.version }\\nend\\n\\nfunction M.on_uninstall()\\n return { message = "Social Hub removed" }\\nend\\n\\nreturn M',
},
{
fileName: 'permissions.lua',
description: 'Role-based access rules for posting and moderation.',
code: 'local Permissions = {}\\n\\nfunction Permissions.can_post(user)\\n return user and (user.role == "user" or user.role == "admin" or user.role == "god")\\nend\\n\\nfunction Permissions.can_moderate(user)\\n return user and (user.role == "admin" or user.role == "god" or user.role == "supergod")\\nend\\n\\nreturn Permissions',
},
{
fileName: 'feed_rank.lua',
description: 'Score feed items based on recency and engagement.',
code: 'local FeedRank = {}\\n\\nfunction FeedRank.score(item)\\n local freshness = item.age_minutes and (100 - item.age_minutes) or 50\\n local engagement = (item.likes or 0) * 2 + (item.comments or 0) * 3\\n return freshness + engagement\\nend\\n\\nreturn FeedRank',
},
{
fileName: 'moderation.lua',
description: 'Flag content for review using lightweight heuristics.',
code: 'local Moderation = {}\\n\\nfunction Moderation.flag(content)\\n local lowered = string.lower(content or "")\\n if string.find(lowered, "spam") then\\n return { flagged = true, reason = "spam_keyword" }\\n end\\n return { flagged = false }\\nend\\n\\nreturn Moderation',
},
{
fileName: 'analytics.lua',
description: 'Aggregate engagement signals for dashboards.',
code: 'local Analytics = {}\\n\\nfunction Analytics.aggregate(events)\\n local summary = { views = 0, likes = 0, comments = 0 }\\n for _, event in ipairs(events or {}) do\\n summary.views = summary.views + (event.views or 0)\\n summary.likes = summary.likes + (event.likes or 0)\\n summary.comments = summary.comments + (event.comments or 0)\\n end\\n return summary\\nend\\n\\nreturn Analytics',
},
]
export const BASE_PACKAGE_TEMPLATE_CONFIGS: PackageTemplateConfig[] = [
{
id: 'package_social_hub',
name: 'Social Hub Package',
description: 'A package blueprint for social feeds, creator updates, and live rooms.',
rootName: 'social_hub',
packageId: 'social_hub',
author: 'MetaBuilder',
version: '1.0.0',
category: 'social',
summary: 'Modern social feed with creator tools and live rooms.',
components: socialHubComponents,
examples: socialHubExamples,
luaScripts: socialHubLuaScripts,
tags: ['package', 'social', 'feed', 'lua'],
},
]

View File

@@ -0,0 +1,3 @@
import type { PackageTemplateConfig } from '../../types'
export const EXPERIMENTAL_PACKAGE_TEMPLATE_CONFIGS: PackageTemplateConfig[] = []

View File

@@ -1,267 +1,12 @@
import type { PackageTemplateConfig, ReactAppTemplateConfig } from './types'
import type { PackageTemplateConfig, ReactAppTemplateConfig } from '../types'
import { ADVANCED_PACKAGE_TEMPLATE_CONFIGS } from './configs/advanced'
import { BASE_PACKAGE_TEMPLATE_CONFIGS, BASE_REACT_APP_TEMPLATE_CONFIG } from './configs/base'
import { EXPERIMENTAL_PACKAGE_TEMPLATE_CONFIGS } from './configs/experimental'
export const REACT_APP_TEMPLATE_CONFIG: ReactAppTemplateConfig = {
id: 'react_next_starter',
name: 'Next.js Web App',
description: 'A clean Next.js starter with app router, hero component, and typed config files.',
rootName: 'web_app',
tags: ['nextjs', 'react', 'web', 'starter'],
}
const socialHubComponents = [
{
id: 'social_hub_root',
type: 'Stack',
props: { className: 'flex flex-col gap-6' },
children: [
{
id: 'social_hub_hero',
type: 'Card',
props: { className: 'p-6' },
children: [
{
id: 'social_hub_heading',
type: 'Heading',
props: { children: 'Social Hub', level: '2', className: 'text-2xl font-bold' },
children: [],
},
{
id: 'social_hub_subtitle',
type: 'Text',
props: { children: 'A modern feed for creator updates, curated stories, and live moments.' },
children: [],
},
],
},
{
id: 'social_hub_stats',
type: 'Grid',
props: { className: 'grid grid-cols-3 gap-4' },
children: [
{
id: 'social_hub_stat_1',
type: 'Card',
props: { className: 'p-4' },
children: [
{
id: 'social_hub_stat_label_1',
type: 'Text',
props: { children: 'Creators live', className: 'text-sm text-muted-foreground' },
children: [],
},
{
id: 'social_hub_stat_value_1',
type: 'Heading',
props: { children: '128', level: '3', className: 'text-xl font-semibold' },
children: [],
},
],
},
{
id: 'social_hub_stat_2',
type: 'Card',
props: { className: 'p-4' },
children: [
{
id: 'social_hub_stat_label_2',
type: 'Text',
props: { children: 'Trending tags', className: 'text-sm text-muted-foreground' },
children: [],
},
{
id: 'social_hub_stat_value_2',
type: 'Heading',
props: { children: '42', level: '3', className: 'text-xl font-semibold' },
children: [],
},
],
},
{
id: 'social_hub_stat_3',
type: 'Card',
props: { className: 'p-4' },
children: [
{
id: 'social_hub_stat_label_3',
type: 'Text',
props: { children: 'Live rooms', className: 'text-sm text-muted-foreground' },
children: [],
},
{
id: 'social_hub_stat_value_3',
type: 'Heading',
props: { children: '7', level: '3', className: 'text-xl font-semibold' },
children: [],
},
],
},
],
},
{
id: 'social_hub_composer',
type: 'Card',
props: { className: 'p-4' },
children: [
{
id: 'social_hub_composer_label',
type: 'Label',
props: { children: 'Share a quick update' },
children: [],
},
{
id: 'social_hub_composer_input',
type: 'Textarea',
props: { placeholder: 'What are you building today?', rows: 3 },
children: [],
},
{
id: 'social_hub_composer_actions',
type: 'Flex',
props: { className: 'flex gap-2' },
children: [
{
id: 'social_hub_composer_publish',
type: 'Button',
props: { children: 'Publish', variant: 'default' },
children: [],
},
{
id: 'social_hub_composer_media',
type: 'Button',
props: { children: 'Add media', variant: 'outline' },
children: [],
},
],
},
],
},
{
id: 'social_hub_feed',
type: 'Stack',
props: { className: 'flex flex-col gap-4' },
children: [
{
id: 'social_hub_feed_post_1',
type: 'Card',
props: { className: 'p-5' },
children: [
{
id: 'social_hub_feed_post_1_title',
type: 'Heading',
props: { children: 'Launch day recap', level: '3', className: 'text-lg font-semibold' },
children: [],
},
{
id: 'social_hub_feed_post_1_body',
type: 'Text',
props: { children: 'We shipped the new live rooms and saw a 32% boost in engagement.' },
children: [],
},
{
id: 'social_hub_feed_post_1_badge',
type: 'Badge',
props: { children: 'Community' },
children: [],
},
],
},
{
id: 'social_hub_feed_post_2',
type: 'Card',
props: { className: 'p-5' },
children: [
{
id: 'social_hub_feed_post_2_title',
type: 'Heading',
props: { children: 'Creator spotlight', level: '3', className: 'text-lg font-semibold' },
children: [],
},
{
id: 'social_hub_feed_post_2_body',
type: 'Text',
props: { children: 'Nova shares her workflow for livestreaming and managing subscribers.' },
children: [],
},
{
id: 'social_hub_feed_post_2_badge',
type: 'Badge',
props: { children: 'Spotlight', variant: 'secondary' },
children: [],
},
],
},
],
},
],
},
]
const socialHubExamples = {
feedItems: [
{
id: 'post_001',
author: 'Nova',
title: 'Launch day recap',
summary: 'We shipped live rooms and doubled community sessions.',
tags: ['launch', 'community'],
},
{
id: 'post_002',
author: 'Kai',
title: 'Build log: day 42',
summary: 'Refined the moderation pipeline and added creator scorecards.',
tags: ['buildinpublic'],
},
],
trendingTags: ['#buildinpublic', '#metabuilder', '#live'],
rooms: [
{ id: 'room_1', title: 'Creator Q&A', host: 'Eli', live: true },
{ id: 'room_2', title: 'Patch Notes', host: 'Nova', live: false },
],
}
const socialHubLuaScripts = [
{
fileName: 'init.lua',
description: 'Lifecycle hooks for package installation.',
code: 'local M = {}\n\nfunction M.on_install(context)\n return { message = "Social Hub installed", version = context.version }\nend\n\nfunction M.on_uninstall()\n return { message = "Social Hub removed" }\nend\n\nreturn M',
},
{
fileName: 'permissions.lua',
description: 'Role-based access rules for posting and moderation.',
code: 'local Permissions = {}\n\nfunction Permissions.can_post(user)\n return user and (user.role == "user" or user.role == "admin" or user.role == "god")\nend\n\nfunction Permissions.can_moderate(user)\n return user and (user.role == "admin" or user.role == "god" or user.role == "supergod")\nend\n\nreturn Permissions',
},
{
fileName: 'feed_rank.lua',
description: 'Score feed items based on recency and engagement.',
code: 'local FeedRank = {}\n\nfunction FeedRank.score(item)\n local freshness = item.age_minutes and (100 - item.age_minutes) or 50\n local engagement = (item.likes or 0) * 2 + (item.comments or 0) * 3\n return freshness + engagement\nend\n\nreturn FeedRank',
},
{
fileName: 'moderation.lua',
description: 'Flag content for review using lightweight heuristics.',
code: 'local Moderation = {}\n\nfunction Moderation.flag(content)\n local lowered = string.lower(content or "")\n if string.find(lowered, "spam") then\n return { flagged = true, reason = "spam_keyword" }\n end\n return { flagged = false }\nend\n\nreturn Moderation',
},
{
fileName: 'analytics.lua',
description: 'Aggregate engagement signals for dashboards.',
code: 'local Analytics = {}\n\nfunction Analytics.aggregate(events)\n local summary = { views = 0, likes = 0, comments = 0 }\n for _, event in ipairs(events or {}) do\n summary.views = summary.views + (event.views or 0)\n summary.likes = summary.likes + (event.likes or 0)\n summary.comments = summary.comments + (event.comments or 0)\n end\n return summary\nend\n\nreturn Analytics',
},
]
export const REACT_APP_TEMPLATE_CONFIG: ReactAppTemplateConfig = BASE_REACT_APP_TEMPLATE_CONFIG
export const PACKAGE_TEMPLATE_CONFIGS: PackageTemplateConfig[] = [
{
id: 'package_social_hub',
name: 'Social Hub Package',
description: 'A package blueprint for social feeds, creator updates, and live rooms.',
rootName: 'social_hub',
packageId: 'social_hub',
author: 'MetaBuilder',
version: '1.0.0',
category: 'social',
summary: 'Modern social feed with creator tools and live rooms.',
components: socialHubComponents,
examples: socialHubExamples,
luaScripts: socialHubLuaScripts,
tags: ['package', 'social', 'feed', 'lua'],
},
...BASE_PACKAGE_TEMPLATE_CONFIGS,
...ADVANCED_PACKAGE_TEMPLATE_CONFIGS,
...EXPERIMENTAL_PACKAGE_TEMPLATE_CONFIGS,
]

View File

@@ -1,238 +1,44 @@
import type { PackageContent } from '../../package-types'
import { ircWebchatComponentConfig } from './irc-webchat/schema/layout'
type IrcWebchatUiSchema = Pick<PackageContent, 'pages' | 'componentHierarchy' | 'componentConfigs'>
export const createIrcWebchatUiSchema = (): IrcWebchatUiSchema => ({
pages: [
{
id: 'page_chat',
path: '/chat',
title: 'IRC Webchat',
level: 2,
componentTree: [
{
id: 'comp_chat_root',
type: 'IRCWebchat',
props: {
channelName: 'general',
},
children: [],
const pages: IrcWebchatUiSchema['pages'] = [
{
id: 'page_chat',
path: '/chat',
title: 'IRC Webchat',
level: 2,
componentTree: [
{
id: 'comp_chat_root',
type: 'IRCWebchat',
props: {
channelName: 'general',
},
],
requiresAuth: true,
requiredRole: 'user',
},
],
componentHierarchy: {
page_chat: {
id: 'comp_chat_root',
type: 'IRCWebchat',
props: {},
children: [],
},
},
componentConfigs: {
IRCWebchat: {
type: 'IRCWebchat',
category: 'social',
label: 'IRC Webchat',
description: 'IRC-style chat component with channels and commands',
icon: '💬',
props: [
{
name: 'channelName',
type: 'string',
label: 'Channel Name',
defaultValue: 'general',
required: false,
},
{
name: 'showSettings',
type: 'boolean',
label: 'Show Settings',
defaultValue: false,
required: false,
},
{
name: 'height',
type: 'string',
label: 'Height',
defaultValue: '600px',
required: false,
},
],
config: {
layout: 'Card',
styling: {
className: 'h-[600px] flex flex-col',
},
children: [
{
id: 'header',
type: 'CardHeader',
props: {
className: 'border-b border-border pb-3',
},
children: [
{
id: 'title_container',
type: 'Flex',
props: {
className: 'flex items-center justify-between',
},
children: [
{
id: 'title',
type: 'CardTitle',
props: {
className: 'flex items-center gap-2 text-lg',
content: '#{channelName}',
},
},
{
id: 'actions',
type: 'Flex',
props: {
className: 'flex items-center gap-2',
},
children: [
{
id: 'user_badge',
type: 'Badge',
props: {
variant: 'secondary',
className: 'gap-1.5',
icon: 'Users',
content: '{onlineUsersCount}',
},
},
{
id: 'settings_button',
type: 'Button',
props: {
size: 'sm',
variant: 'ghost',
icon: 'Gear',
onClick: 'toggleSettings',
},
},
],
},
],
},
],
},
{
id: 'content',
type: 'CardContent',
props: {
className: 'flex-1 flex flex-col p-0 overflow-hidden',
},
children: [
{
id: 'main_area',
type: 'Flex',
props: {
className: 'flex flex-1 overflow-hidden',
},
children: [
{
id: 'messages_area',
type: 'ScrollArea',
props: {
className: 'flex-1 p-4',
},
children: [
{
id: 'messages_container',
type: 'MessageList',
props: {
className: 'space-y-2 font-mono text-sm',
dataSource: 'messages',
itemRenderer: 'renderMessage',
},
},
],
},
{
id: 'sidebar',
type: 'Container',
props: {
className: 'w-48 border-l border-border p-4 bg-muted/20',
conditional: 'showSettings',
},
children: [
{
id: 'sidebar_title',
type: 'Heading',
props: {
level: '4',
className: 'font-semibold text-sm mb-3',
content: 'Online Users',
},
},
{
id: 'users_list',
type: 'UserList',
props: {
className: 'space-y-1.5 text-sm',
dataSource: 'onlineUsers',
},
},
],
},
],
},
{
id: 'input_area',
type: 'Container',
props: {
className: 'border-t border-border p-4',
},
children: [
{
id: 'input_row',
type: 'Flex',
props: {
className: 'flex gap-2',
},
children: [
{
id: 'message_input',
type: 'Input',
props: {
className: 'flex-1 font-mono',
placeholder: 'Type a message... (/help for commands)',
onKeyPress: 'handleKeyPress',
value: '{inputMessage}',
onChange: 'updateInputMessage',
},
},
{
id: 'send_button',
type: 'Button',
props: {
size: 'icon',
icon: 'PaperPlaneTilt',
onClick: 'handleSendMessage',
},
},
],
},
{
id: 'help_text',
type: 'Text',
props: {
className: 'text-xs text-muted-foreground mt-2',
content: 'Press Enter to send. Type /help for commands.',
},
},
],
},
],
},
],
children: [],
},
},
],
requiresAuth: true,
requiredRole: 'user',
},
]
const componentHierarchy: IrcWebchatUiSchema['componentHierarchy'] = {
page_chat: {
id: 'comp_chat_root',
type: 'IRCWebchat',
props: {},
children: [],
},
}
const componentConfigs: IrcWebchatUiSchema['componentConfigs'] = {
IRCWebchat: ircWebchatComponentConfig,
}
export const createIrcWebchatUiSchema = (): IrcWebchatUiSchema => ({
pages,
componentHierarchy,
componentConfigs,
})

View File

@@ -1,197 +1,13 @@
import type { PackageContent } from '../../package-types'
import { commandActions } from './irc-webchat/actions/commands'
import { eventActions } from './irc-webchat/actions/events'
type IrcWebchatWorkflows = Pick<PackageContent, 'workflows' | 'luaScripts'>
export const createIrcWebchatWorkflowActions = (): IrcWebchatWorkflows => ({
workflows: [
{
id: 'workflow_send_message',
name: 'Send Chat Message',
description: 'Workflow for sending a chat message',
nodes: [],
edges: [],
enabled: true,
},
{
id: 'workflow_join_channel',
name: 'Join Channel',
description: 'Workflow for joining a chat channel',
nodes: [],
edges: [],
enabled: true,
},
],
luaScripts: [
{
id: 'lua_irc_send_message',
name: 'Send IRC Message',
description: 'Sends a message to the chat channel',
code: `-- Send IRC Message
function sendMessage(channelId, username, userId, message)
local msgId = "msg_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999)
local msg = {
id = msgId,
channelId = channelId,
username = username,
userId = userId,
message = message,
type = "message",
timestamp = os.time() * 1000
}
log("Sending message: " .. message)
return msg
end
return sendMessage`,
parameters: [
{ name: 'channelId', type: 'string' },
{ name: 'username', type: 'string' },
{ name: 'userId', type: 'string' },
{ name: 'message', type: 'string' },
],
returnType: 'table',
},
{
id: 'lua_irc_handle_command',
name: 'Handle IRC Command',
description: 'Processes IRC commands like /help, /users, etc',
code: `-- Handle IRC Command
function handleCommand(command, channelId, username, onlineUsers)
local parts = {}
for part in string.gmatch(command, "%S+") do
table.insert(parts, part)
end
local cmd = parts[1]:lower()
local response = {
id = "msg_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999),
username = "System",
userId = "system",
type = "system",
timestamp = os.time() * 1000,
channelId = channelId
}
if cmd == "/help" then
response.message = "Available commands: /help, /users, /clear, /me <action>"
elseif cmd == "/users" then
local userCount = #onlineUsers
local userList = table.concat(onlineUsers, ", ")
response.message = "Online users (" .. userCount .. "): " .. userList
elseif cmd == "/clear" then
response.message = "CLEAR_MESSAGES"
response.type = "command"
elseif cmd == "/me" then
if #parts > 1 then
local action = table.concat(parts, " ", 2)
response.message = action
response.username = username
response.userId = username
response.type = "system"
else
response.message = "Usage: /me <action>"
end
else
response.message = "Unknown command: " .. cmd .. ". Type /help for available commands."
end
return response
end
return handleCommand`,
parameters: [
{ name: 'command', type: 'string' },
{ name: 'channelId', type: 'string' },
{ name: 'username', type: 'string' },
{ name: 'onlineUsers', type: 'table' },
],
returnType: 'table',
},
{
id: 'lua_irc_format_time',
name: 'Format Timestamp',
description: 'Formats a timestamp for display',
code: `-- Format Timestamp
function formatTime(timestamp)
local date = os.date("*t", timestamp / 1000)
local hour = date.hour
local ampm = "AM"
if hour >= 12 then
ampm = "PM"
if hour > 12 then
hour = hour - 12
end
end
if hour == 0 then
hour = 12
end
return string.format("%02d:%02d %s", hour, date.min, ampm)
end
return formatTime`,
parameters: [
{ name: 'timestamp', type: 'number' },
],
returnType: 'string',
},
{
id: 'lua_irc_user_join',
name: 'User Join Channel',
description: 'Handles user joining a channel',
code: `-- User Join Channel
function userJoin(channelId, username, userId)
local joinMsg = {
id = "msg_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999),
channelId = channelId,
username = "System",
userId = "system",
message = username .. " has joined the channel",
type = "join",
timestamp = os.time() * 1000
}
log(username .. " joined channel " .. channelId)
return joinMsg
end
return userJoin`,
parameters: [
{ name: 'channelId', type: 'string' },
{ name: 'username', type: 'string' },
{ name: 'userId', type: 'string' },
],
returnType: 'table',
},
{
id: 'lua_irc_user_leave',
name: 'User Leave Channel',
description: 'Handles user leaving a channel',
code: `-- User Leave Channel
function userLeave(channelId, username, userId)
local leaveMsg = {
id = "msg_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999),
channelId = channelId,
username = "System",
userId = "system",
message = username .. " has left the channel",
type = "leave",
timestamp = os.time() * 1000
}
log(username .. " left channel " .. channelId)
return leaveMsg
end
return userLeave`,
parameters: [
{ name: 'channelId', type: 'string' },
{ name: 'username', type: 'string' },
{ name: 'userId', type: 'string' },
],
returnType: 'table',
},
],
const mergeActions = (...actions: IrcWebchatWorkflows[]): IrcWebchatWorkflows => ({
workflows: actions.flatMap((action) => action.workflows),
luaScripts: actions.flatMap((action) => action.luaScripts),
})
export const createIrcWebchatWorkflowActions = (): IrcWebchatWorkflows =>
mergeActions(commandActions, eventActions)

View File

@@ -0,0 +1,103 @@
import type { PackageContent } from '../../../../package-types'
type IrcWebchatWorkflowActions = Pick<PackageContent, 'workflows' | 'luaScripts'>
export const commandActions: IrcWebchatWorkflowActions = {
workflows: [
{
id: 'workflow_send_message',
name: 'Send Chat Message',
description: 'Workflow for sending a chat message',
nodes: [],
edges: [],
enabled: true,
},
],
luaScripts: [
{
id: 'lua_irc_send_message',
name: 'Send IRC Message',
description: 'Sends a message to the chat channel',
code: `-- Send IRC Message
function sendMessage(channelId, username, userId, message)
local msgId = "msg_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999)
local msg = {
id = msgId,
channelId = channelId,
username = username,
userId = userId,
message = message,
type = "message",
timestamp = os.time() * 1000
}
log("Sending message: " .. message)
return msg
end
return sendMessage`,
parameters: [
{ name: 'channelId', type: 'string' },
{ name: 'username', type: 'string' },
{ name: 'userId', type: 'string' },
{ name: 'message', type: 'string' },
],
returnType: 'table',
},
{
id: 'lua_irc_handle_command',
name: 'Handle IRC Command',
description: 'Processes IRC commands like /help, /users, etc',
code: `-- Handle IRC Command
function handleCommand(command, channelId, username, onlineUsers)
local parts = {}
for part in string.gmatch(command, "%S+") do
table.insert(parts, part)
end
local cmd = parts[1]:lower()
local response = {
id = "msg_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999),
username = "System",
userId = "system",
type = "system",
timestamp = os.time() * 1000,
channelId = channelId
}
if cmd == "/help" then
response.message = "Available commands: /help, /users, /clear, /me <action>"
elseif cmd == "/users" then
local userCount = #onlineUsers
local userList = table.concat(onlineUsers, ", ")
response.message = "Online users (" .. userCount .. "): " .. userList
elseif cmd == "/clear" then
response.message = "CLEAR_MESSAGES"
response.type = "command"
elseif cmd == "/me" then
if #parts > 1 then
local action = table.concat(parts, " ", 2)
response.message = action
response.username = username
response.userId = username
response.type = "system"
else
response.message = "Usage: /me <action>"
end
else
response.message = "Unknown command: " .. cmd .. ". Type /help for available commands."
end
return response
end
return handleCommand`,
parameters: [
{ name: 'command', type: 'string' },
{ name: 'channelId', type: 'string' },
{ name: 'username', type: 'string' },
{ name: 'onlineUsers', type: 'table' },
],
returnType: 'table',
},
],
}

View File

@@ -0,0 +1,104 @@
import type { PackageContent } from '../../../../package-types'
type IrcWebchatWorkflowActions = Pick<PackageContent, 'workflows' | 'luaScripts'>
export const eventActions: IrcWebchatWorkflowActions = {
workflows: [
{
id: 'workflow_join_channel',
name: 'Join Channel',
description: 'Workflow for joining a chat channel',
nodes: [],
edges: [],
enabled: true,
},
],
luaScripts: [
{
id: 'lua_irc_format_time',
name: 'Format Timestamp',
description: 'Formats a timestamp for display',
code: `-- Format Timestamp
function formatTime(timestamp)
local date = os.date("*t", timestamp / 1000)
local hour = date.hour
local ampm = "AM"
if hour >= 12 then
ampm = "PM"
if hour > 12 then
hour = hour - 12
end
end
if hour == 0 then
hour = 12
end
return string.format("%02d:%02d %s", hour, date.min, ampm)
end
return formatTime`,
parameters: [
{ name: 'timestamp', type: 'number' },
],
returnType: 'string',
},
{
id: 'lua_irc_user_join',
name: 'User Join Channel',
description: 'Handles user joining a channel',
code: `-- User Join Channel
function userJoin(channelId, username, userId)
local joinMsg = {
id = "msg_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999),
channelId = channelId,
username = "System",
userId = "system",
message = username .. " has joined the channel",
type = "join",
timestamp = os.time() * 1000
}
log(username .. " joined channel " .. channelId)
return joinMsg
end
return userJoin`,
parameters: [
{ name: 'channelId', type: 'string' },
{ name: 'username', type: 'string' },
{ name: 'userId', type: 'string' },
],
returnType: 'table',
},
{
id: 'lua_irc_user_leave',
name: 'User Leave Channel',
description: 'Handles user leaving a channel',
code: `-- User Leave Channel
function userLeave(channelId, username, userId)
local leaveMsg = {
id = "msg_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999),
channelId = channelId,
username = "System",
userId = "system",
message = username .. " has left the channel",
type = "leave",
timestamp = os.time() * 1000
}
log(username .. " left channel " .. channelId)
return leaveMsg
end
return userLeave`,
parameters: [
{ name: 'channelId', type: 'string' },
{ name: 'username', type: 'string' },
{ name: 'userId', type: 'string' },
],
returnType: 'table',
},
],
}

View File

@@ -0,0 +1,143 @@
import { createMessageArea, createMessageInputArea } from './messages'
const createHeaderSection = () => ({
id: 'header',
type: 'CardHeader',
props: {
className: 'border-b border-border pb-3',
},
children: [
{
id: 'title_container',
type: 'Flex',
props: {
className: 'flex items-center justify-between',
},
children: [
{
id: 'title',
type: 'CardTitle',
props: {
className: 'flex items-center gap-2 text-lg',
content: '#{channelName}',
},
},
{
id: 'actions',
type: 'Flex',
props: {
className: 'flex items-center gap-2',
},
children: [
{
id: 'user_badge',
type: 'Badge',
props: {
variant: 'secondary',
className: 'gap-1.5',
icon: 'Users',
content: '{onlineUsersCount}',
},
},
{
id: 'settings_button',
type: 'Button',
props: {
size: 'sm',
variant: 'ghost',
icon: 'Gear',
onClick: 'toggleSettings',
},
},
],
},
],
},
],
})
const createSidebar = () => ({
id: 'sidebar',
type: 'Container',
props: {
className: 'w-48 border-l border-border p-4 bg-muted/20',
conditional: 'showSettings',
},
children: [
{
id: 'sidebar_title',
type: 'Heading',
props: {
level: '4',
className: 'font-semibold text-sm mb-3',
content: 'Online Users',
},
},
{
id: 'users_list',
type: 'UserList',
props: {
className: 'space-y-1.5 text-sm',
dataSource: 'onlineUsers',
},
},
],
})
export const ircWebchatComponentConfig = {
type: 'IRCWebchat',
category: 'social',
label: 'IRC Webchat',
description: 'IRC-style chat component with channels and commands',
icon: '💬',
props: [
{
name: 'channelName',
type: 'string',
label: 'Channel Name',
defaultValue: 'general',
required: false,
},
{
name: 'showSettings',
type: 'boolean',
label: 'Show Settings',
defaultValue: false,
required: false,
},
{
name: 'height',
type: 'string',
label: 'Height',
defaultValue: '600px',
required: false,
},
],
config: {
layout: 'Card',
styling: {
className: 'h-[600px] flex flex-col',
},
children: [
createHeaderSection(),
{
id: 'content',
type: 'CardContent',
props: {
className: 'flex-1 flex flex-col p-0 overflow-hidden',
},
children: [
{
id: 'main_area',
type: 'Flex',
props: {
className: 'flex flex-1 overflow-hidden',
},
children: [createMessageArea(), createSidebar()],
},
createMessageInputArea(),
],
},
],
},
}

View File

@@ -0,0 +1,65 @@
export const createMessageArea = () => ({
id: 'messages_area',
type: 'ScrollArea',
props: {
className: 'flex-1 p-4',
},
children: [
{
id: 'messages_container',
type: 'MessageList',
props: {
className: 'space-y-2 font-mono text-sm',
dataSource: 'messages',
itemRenderer: 'renderMessage',
},
},
],
})
export const createMessageInputArea = () => ({
id: 'input_area',
type: 'Container',
props: {
className: 'border-t border-border p-4',
},
children: [
{
id: 'input_row',
type: 'Flex',
props: {
className: 'flex gap-2',
},
children: [
{
id: 'message_input',
type: 'Input',
props: {
className: 'flex-1 font-mono',
placeholder: 'Type a message... (/help for commands)',
onKeyPress: 'handleKeyPress',
value: '{inputMessage}',
onChange: 'updateInputMessage',
},
},
{
id: 'send_button',
type: 'Button',
props: {
size: 'icon',
icon: 'PaperPlaneTilt',
onClick: 'handleSendMessage',
},
},
],
},
{
id: 'help_text',
type: 'Text',
props: {
className: 'text-xs text-muted-foreground mt-2',
content: 'Press Enter to send. Type /help for commands.',
},
},
],
})

View File

@@ -1,30 +1,13 @@
/**
* Tests for package-glue module - Package registry utilities
* Following parameterized test pattern per project conventions
* Shared helpers for package-glue test suites.
* Individual suites live under ./package-glue/.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import type { PackageRegistry, PackageDefinition, LuaScriptFile } from './package-glue'
import {
getPackage,
getPackagesByCategory,
getPackageComponents,
getPackageScripts,
getPackageScriptFiles,
getAllPackageScripts,
getPackageExamples,
checkDependencies,
installPackageComponents,
installPackageScripts,
installPackage,
uninstallPackage,
getInstalledPackages,
isPackageInstalled,
exportAllPackagesForSeed,
} from './package-glue'
import { vi } from 'vitest'
// Helper to create mock package definitions
function createMockPackage(
import type { LuaScriptFile, PackageDefinition, PackageRegistry } from '../package-glue'
export function createMockPackage(
id: string,
options: Partial<PackageDefinition> = {}
): PackageDefinition {
@@ -44,8 +27,7 @@ function createMockPackage(
}
}
// Helper to create mock registry
function createMockRegistry(packages: PackageDefinition[]): PackageRegistry {
export function createMockRegistry(packages: PackageDefinition[]): PackageRegistry {
const registry: PackageRegistry = {}
for (const pkg of packages) {
registry[pkg.packageId] = pkg
@@ -53,8 +35,9 @@ function createMockRegistry(packages: PackageDefinition[]): PackageRegistry {
return registry
}
// Helper to create mock database
function createMockDb() {
export type MockDb = ReturnType<typeof createMockDb>
export function createMockDb() {
const data: Record<string, Record<string, any>> = {}
return {
set: vi.fn(async (table: string, key: string, value: any) => {
@@ -74,546 +57,7 @@ function createMockDb() {
}
}
describe('package-glue', () => {
describe('getPackage', () => {
it.each([
{
name: 'returns package when found',
registry: createMockRegistry([createMockPackage('test_pkg')]),
packageId: 'test_pkg',
expectFound: true,
},
{
name: 'returns undefined when not found',
registry: createMockRegistry([createMockPackage('other_pkg')]),
packageId: 'test_pkg',
expectFound: false,
},
{
name: 'returns undefined from empty registry',
registry: createMockRegistry([]),
packageId: 'test_pkg',
expectFound: false,
},
])('should handle $name', ({ registry, packageId, expectFound }) => {
const result = getPackage(registry, packageId)
if (expectFound) {
expect(result).toBeDefined()
expect(result?.packageId).toBe(packageId)
} else {
expect(result).toBeUndefined()
}
})
})
describe('getPackagesByCategory', () => {
const mixedRegistry = createMockRegistry([
createMockPackage('pkg1', { category: 'ui' }),
createMockPackage('pkg2', { category: 'ui' }),
createMockPackage('pkg3', { category: 'data' }),
createMockPackage('pkg4', { category: 'util' }),
])
it.each([
{
name: 'returns packages in category',
registry: mixedRegistry,
category: 'ui',
expectedCount: 2,
},
{
name: 'returns single package in category',
registry: mixedRegistry,
category: 'data',
expectedCount: 1,
},
{
name: 'returns empty array for unknown category',
registry: mixedRegistry,
category: 'unknown',
expectedCount: 0,
},
{
name: 'returns empty array from empty registry',
registry: createMockRegistry([]),
category: 'ui',
expectedCount: 0,
},
])('should handle $name', ({ registry, category, expectedCount }) => {
const result = getPackagesByCategory(registry, category)
expect(result).toHaveLength(expectedCount)
result.forEach((pkg) => {
expect(pkg.category).toBe(category)
})
})
})
describe('getPackageComponents', () => {
it.each([
{
name: 'returns components array',
pkg: createMockPackage('test', {
components: [{ id: 'c1' }, { id: 'c2' }],
}),
expectedLength: 2,
},
{
name: 'returns empty array when no components',
pkg: createMockPackage('test', { components: [] }),
expectedLength: 0,
},
{
name: 'returns empty array when components is undefined',
pkg: { ...createMockPackage('test'), components: undefined as any },
expectedLength: 0,
},
])('should handle $name', ({ pkg, expectedLength }) => {
const result = getPackageComponents(pkg)
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(expectedLength)
})
})
describe('getPackageScripts', () => {
it.each([
{
name: 'returns scripts string',
pkg: createMockPackage('test', { scripts: 'return 42' }),
expected: 'return 42',
},
{
name: 'returns empty string when undefined',
pkg: createMockPackage('test'),
expected: '',
},
{
name: 'returns empty string when null',
pkg: { ...createMockPackage('test'), scripts: null as any },
expected: '',
},
])('should handle $name', ({ pkg, expected }) => {
const result = getPackageScripts(pkg)
expect(result).toBe(expected)
})
})
describe('getPackageScriptFiles', () => {
const mockScriptFiles: LuaScriptFile[] = [
{ name: 'init', path: 'scripts/init.lua', code: 'return {}' },
{ name: 'utils', path: 'scripts/utils.lua', code: 'return true' },
]
it.each([
{
name: 'returns script files array',
pkg: createMockPackage('test', { scriptFiles: mockScriptFiles }),
expectedLength: 2,
},
{
name: 'returns empty array when undefined',
pkg: createMockPackage('test'),
expectedLength: 0,
},
{
name: 'returns empty array when empty',
pkg: createMockPackage('test', { scriptFiles: [] }),
expectedLength: 0,
},
])('should handle $name', ({ pkg, expectedLength }) => {
const result = getPackageScriptFiles(pkg)
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(expectedLength)
})
})
describe('getAllPackageScripts', () => {
const mockScriptFiles: LuaScriptFile[] = [
{ name: 'init', path: 'scripts/init.lua', code: 'return {}' },
]
it.each([
{
name: 'returns both legacy and files',
pkg: createMockPackage('test', {
scripts: 'legacy code',
scriptFiles: mockScriptFiles,
}),
expectedLegacy: 'legacy code',
expectedFilesLength: 1,
},
{
name: 'handles missing legacy',
pkg: createMockPackage('test', { scriptFiles: mockScriptFiles }),
expectedLegacy: '',
expectedFilesLength: 1,
},
{
name: 'handles missing files',
pkg: createMockPackage('test', { scripts: 'code' }),
expectedLegacy: 'code',
expectedFilesLength: 0,
},
{
name: 'handles both missing',
pkg: createMockPackage('test'),
expectedLegacy: '',
expectedFilesLength: 0,
},
])('should handle $name', ({ pkg, expectedLegacy, expectedFilesLength }) => {
const result = getAllPackageScripts(pkg)
expect(result.legacy).toBe(expectedLegacy)
expect(result.files).toHaveLength(expectedFilesLength)
})
})
describe('getPackageExamples', () => {
it.each([
{
name: 'returns examples object',
pkg: createMockPackage('test', {
examples: { demo: 'code' },
}),
hasExamples: true,
},
{
name: 'returns empty object when undefined',
pkg: createMockPackage('test'),
hasExamples: false,
},
])('should handle $name', ({ pkg, hasExamples }) => {
const result = getPackageExamples(pkg)
expect(typeof result).toBe('object')
if (hasExamples) {
expect(result.demo).toBe('code')
} else {
expect(Object.keys(result)).toHaveLength(0)
}
})
})
describe('checkDependencies', () => {
it.each([
{
name: 'satisfied when no dependencies',
registry: createMockRegistry([createMockPackage('test')]),
packageId: 'test',
expectedSatisfied: true,
expectedMissing: [],
},
{
name: 'satisfied when all dependencies present',
registry: createMockRegistry([
createMockPackage('test', { dependencies: ['dep1', 'dep2'] }),
createMockPackage('dep1'),
createMockPackage('dep2'),
]),
packageId: 'test',
expectedSatisfied: true,
expectedMissing: [],
},
{
name: 'not satisfied when dependencies missing',
registry: createMockRegistry([
createMockPackage('test', { dependencies: ['dep1', 'dep2'] }),
createMockPackage('dep1'),
]),
packageId: 'test',
expectedSatisfied: false,
expectedMissing: ['dep2'],
},
{
name: 'not satisfied when package not found',
registry: createMockRegistry([]),
packageId: 'nonexistent',
expectedSatisfied: false,
expectedMissing: ['nonexistent'],
},
])('should handle $name', ({ registry, packageId, expectedSatisfied, expectedMissing }) => {
const result = checkDependencies(registry, packageId)
expect(result.satisfied).toBe(expectedSatisfied)
expect(result.missing).toEqual(expectedMissing)
})
})
describe('installPackageComponents', () => {
it('should install all components to database', async () => {
const db = createMockDb()
const pkg = createMockPackage('test', {
components: [
{ id: 'comp1', type: 'button' },
{ id: 'comp2', type: 'form' },
],
})
await installPackageComponents(pkg, db)
expect(db.set).toHaveBeenCalledTimes(2)
expect(db.set).toHaveBeenCalledWith('components', 'comp1', { id: 'comp1', type: 'button' })
expect(db.set).toHaveBeenCalledWith('components', 'comp2', { id: 'comp2', type: 'form' })
})
it('should handle empty components array', async () => {
const db = createMockDb()
const pkg = createMockPackage('test', { components: [] })
await installPackageComponents(pkg, db)
expect(db.set).not.toHaveBeenCalled()
})
})
describe('installPackageScripts', () => {
it('should install legacy script', async () => {
const db = createMockDb()
const pkg = createMockPackage('test', { scripts: 'return 42' })
await installPackageScripts(pkg, db)
expect(db.set).toHaveBeenCalledWith('lua_scripts', 'package_test', {
id: 'package_test',
name: 'Package test Scripts',
code: 'return 42',
category: 'package',
packageId: 'test',
})
})
it('should install multi-file scripts', async () => {
const db = createMockDb()
const pkg = createMockPackage('test', {
scriptFiles: [
{ name: 'init', path: 'scripts/init.lua', code: 'return {}', category: 'setup', description: 'Init script' },
],
})
await installPackageScripts(pkg, db)
expect(db.set).toHaveBeenCalledWith('lua_scripts', 'package_test_init', {
id: 'package_test_init',
name: 'Package test - init',
code: 'return {}',
category: 'setup',
packageId: 'test',
path: 'scripts/init.lua',
description: 'Init script',
})
})
it('should install both legacy and multi-file scripts', async () => {
const db = createMockDb()
const pkg = createMockPackage('test', {
scripts: 'legacy',
scriptFiles: [{ name: 'utils', path: 'scripts/utils.lua', code: 'helpers' }],
})
await installPackageScripts(pkg, db)
expect(db.set).toHaveBeenCalledTimes(2)
})
})
describe('installPackage', () => {
it.each([
{
name: 'successfully installs package',
registry: createMockRegistry([createMockPackage('test')]),
packageId: 'test',
expectSuccess: true,
},
{
name: 'fails when package not found',
registry: createMockRegistry([]),
packageId: 'nonexistent',
expectSuccess: false,
expectedError: 'Package nonexistent not found',
},
{
name: 'fails when dependencies missing',
registry: createMockRegistry([
createMockPackage('test', { dependencies: ['missing'] }),
]),
packageId: 'test',
expectSuccess: false,
expectedError: 'Missing dependencies: missing',
},
])('should handle $name', async ({ registry, packageId, expectSuccess, expectedError }) => {
const db = createMockDb()
const result = await installPackage(registry, packageId, db)
expect(result.success).toBe(expectSuccess)
if (expectedError) {
expect(result.error).toContain(expectedError)
}
if (expectSuccess) {
expect(db.set).toHaveBeenCalledWith(
'installed_packages',
packageId,
expect.objectContaining({ packageId, name: expect.any(String) })
)
}
})
})
describe('uninstallPackage', () => {
it.each([
{
name: 'successfully uninstalls package',
registry: createMockRegistry([
createMockPackage('test', { components: [{ id: 'c1' }] }),
]),
packageId: 'test',
expectSuccess: true,
},
{
name: 'fails when package not found',
registry: createMockRegistry([]),
packageId: 'nonexistent',
expectSuccess: false,
expectedError: 'Package nonexistent not found',
},
])('should handle $name', async ({ registry, packageId, expectSuccess, expectedError }) => {
const db = createMockDb()
const result = await uninstallPackage(registry, packageId, db)
expect(result.success).toBe(expectSuccess)
if (expectedError) {
expect(result.error).toContain(expectedError)
}
if (expectSuccess) {
expect(db.delete).toHaveBeenCalledWith('installed_packages', packageId)
}
})
})
describe('getInstalledPackages', () => {
it('should return installed package IDs', async () => {
const db = createMockDb()
db._data['installed_packages'] = { pkg1: {}, pkg2: {} }
const result = await getInstalledPackages(db)
expect(result).toEqual(['pkg1', 'pkg2'])
})
it('should return empty array on error', async () => {
const db = createMockDb()
db.getAll = vi.fn().mockRejectedValue(new Error('DB error'))
const result = await getInstalledPackages(db)
expect(result).toEqual([])
})
it('should return empty array when no packages', async () => {
const db = createMockDb()
const result = await getInstalledPackages(db)
expect(result).toEqual([])
})
})
describe('isPackageInstalled', () => {
it.each([
{
name: 'returns true when installed',
setupDb: (db: ReturnType<typeof createMockDb>) => {
db._data['installed_packages'] = { test: { packageId: 'test' } }
},
packageId: 'test',
expected: true,
},
{
name: 'returns false when not installed',
setupDb: () => {},
packageId: 'test',
expected: false,
},
])('should handle $name', async ({ setupDb, packageId, expected }) => {
const db = createMockDb()
setupDb(db)
const result = await isPackageInstalled(packageId, db)
expect(result).toBe(expected)
})
it('should return false on error', async () => {
const db = createMockDb()
db.get = vi.fn().mockRejectedValue(new Error('DB error'))
const result = await isPackageInstalled('test', db)
expect(result).toBe(false)
})
})
describe('exportAllPackagesForSeed', () => {
it('should export all package data', () => {
const registry = createMockRegistry([
createMockPackage('pkg1', {
name: 'Package 1',
components: [{ id: 'c1' }],
scripts: 'lua code',
}),
createMockPackage('pkg2', {
name: 'Package 2',
scriptFiles: [{ name: 'init', path: 'scripts/init.lua', code: 'return {}' }],
}),
])
const result = exportAllPackagesForSeed(registry)
expect(result.components).toHaveLength(1)
expect(result.scripts).toHaveLength(2) // 1 legacy + 1 file
expect(result.packages).toHaveLength(2)
expect(result.packages[0].packageId).toBe('pkg1')
expect(result.packages[1].packageId).toBe('pkg2')
})
it('should handle empty registry', () => {
const result = exportAllPackagesForSeed({})
expect(result.components).toEqual([])
expect(result.scripts).toEqual([])
expect(result.packages).toEqual([])
})
it('should include script metadata', () => {
const registry = createMockRegistry([
createMockPackage('pkg', {
scriptFiles: [
{
name: 'utils',
path: 'scripts/utils.lua',
code: 'return true',
category: 'helpers',
description: 'Utility functions',
},
],
}),
])
const result = exportAllPackagesForSeed(registry)
expect(result.scripts[0]).toMatchObject({
id: 'package_pkg_utils',
name: 'Package pkg - utils',
code: 'return true',
category: 'helpers',
path: 'scripts/utils.lua',
description: 'Utility functions',
})
})
})
})
export const mockScriptFiles: LuaScriptFile[] = [
{ name: 'init', path: 'scripts/init.lua', code: 'return {}' },
{ name: 'utils', path: 'scripts/utils.lua', code: 'return true' },
]

View File

@@ -0,0 +1,229 @@
import { describe, it, expect, vi } from 'vitest'
import {
getInstalledPackages,
installPackage,
installPackageComponents,
installPackageScripts,
isPackageInstalled,
uninstallPackage,
} from '../../package-glue'
import { createMockDb, createMockPackage, createMockRegistry, mockScriptFiles } from '../package-glue.test'
describe('package-glue execution', () => {
describe('installPackageComponents', () => {
it('should install all components to database', async () => {
const db = createMockDb()
const pkg = createMockPackage('test', {
components: [
{ id: 'comp1', type: 'button' },
{ id: 'comp2', type: 'form' },
],
})
await installPackageComponents(pkg, db)
expect(db.set).toHaveBeenCalledTimes(2)
expect(db.set).toHaveBeenCalledWith('components', 'comp1', { id: 'comp1', type: 'button' })
expect(db.set).toHaveBeenCalledWith('components', 'comp2', { id: 'comp2', type: 'form' })
})
it('should handle empty components array', async () => {
const db = createMockDb()
const pkg = createMockPackage('test', { components: [] })
await installPackageComponents(pkg, db)
expect(db.set).not.toHaveBeenCalled()
})
})
describe('installPackageScripts', () => {
it('should install legacy script', async () => {
const db = createMockDb()
const pkg = createMockPackage('test', { scripts: 'return 42' })
await installPackageScripts(pkg, db)
expect(db.set).toHaveBeenCalledWith('lua_scripts', 'package_test', {
id: 'package_test',
name: 'Package test Scripts',
code: 'return 42',
category: 'package',
packageId: 'test',
})
})
it('should install multi-file scripts', async () => {
const db = createMockDb()
const pkg = createMockPackage('test', {
scriptFiles: [
{ name: 'init', path: 'scripts/init.lua', code: 'return {}', category: 'setup', description: 'Init script' },
],
})
await installPackageScripts(pkg, db)
expect(db.set).toHaveBeenCalledWith('lua_scripts', 'package_test_init', {
id: 'package_test_init',
name: 'Package test - init',
code: 'return {}',
category: 'setup',
packageId: 'test',
path: 'scripts/init.lua',
description: 'Init script',
})
})
it('should install both legacy and multi-file scripts', async () => {
const db = createMockDb()
const scriptFiles = mockScriptFiles.slice(0, 1)
const pkg = createMockPackage('test', {
scripts: 'legacy',
scriptFiles,
})
await installPackageScripts(pkg, db)
expect(db.set).toHaveBeenCalledTimes(2)
})
})
describe('installPackage', () => {
it.each([
{
name: 'successfully installs package',
registry: createMockRegistry([createMockPackage('test')]),
packageId: 'test',
expectSuccess: true,
},
{
name: 'fails when package not found',
registry: createMockRegistry([]),
packageId: 'nonexistent',
expectSuccess: false,
expectedError: 'Package nonexistent not found',
},
{
name: 'fails when dependencies missing',
registry: createMockRegistry([
createMockPackage('test', { dependencies: ['missing'] }),
]),
packageId: 'test',
expectSuccess: false,
expectedError: 'Missing dependencies: missing',
},
])('should handle $name', async ({ registry, packageId, expectSuccess, expectedError }) => {
const db = createMockDb()
const result = await installPackage(registry, packageId, db)
expect(result.success).toBe(expectSuccess)
if (expectedError) {
expect(result.error).toContain(expectedError)
}
if (expectSuccess) {
expect(db.set).toHaveBeenCalledWith(
'installed_packages',
packageId,
expect.objectContaining({ packageId, name: expect.any(String) })
)
}
})
})
describe('uninstallPackage', () => {
it.each([
{
name: 'successfully uninstalls package',
registry: createMockRegistry([
createMockPackage('test', { components: [{ id: 'c1' }] }),
]),
packageId: 'test',
expectSuccess: true,
},
{
name: 'fails when package not found',
registry: createMockRegistry([]),
packageId: 'nonexistent',
expectSuccess: false,
expectedError: 'Package nonexistent not found',
},
])('should handle $name', async ({ registry, packageId, expectSuccess, expectedError }) => {
const db = createMockDb()
const result = await uninstallPackage(registry, packageId, db)
expect(result.success).toBe(expectSuccess)
if (expectedError) {
expect(result.error).toContain(expectedError)
}
if (expectSuccess) {
expect(db.delete).toHaveBeenCalledWith('installed_packages', packageId)
}
})
})
describe('getInstalledPackages', () => {
it('should return installed package IDs', async () => {
const db = createMockDb()
db._data['installed_packages'] = { pkg1: {}, pkg2: {} }
const result = await getInstalledPackages(db)
expect(result).toEqual(['pkg1', 'pkg2'])
})
it('should return empty array on error', async () => {
const db = createMockDb()
db.getAll = vi.fn().mockRejectedValue(new Error('DB error'))
const result = await getInstalledPackages(db)
expect(result).toEqual([])
})
it('should return empty array when no packages', async () => {
const db = createMockDb()
const result = await getInstalledPackages(db)
expect(result).toEqual([])
})
})
describe('isPackageInstalled', () => {
it.each([
{
name: 'returns true when installed',
setupDb: (db: ReturnType<typeof createMockDb>) => {
db._data['installed_packages'] = { test: { packageId: 'test' } }
},
packageId: 'test',
expected: true,
},
{
name: 'returns false when not installed',
setupDb: () => {},
packageId: 'test',
expected: false,
},
])('should handle $name', async ({ setupDb, packageId, expected }) => {
const db = createMockDb()
setupDb(db)
const result = await isPackageInstalled(packageId, db)
expect(result).toBe(expected)
})
it('should return false on error', async () => {
const db = createMockDb()
db.get = vi.fn().mockRejectedValue(new Error('DB error'))
const result = await isPackageInstalled('test', db)
expect(result).toBe(false)
})
})
})

View File

@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest'
import { exportAllPackagesForSeed } from '../../package-glue'
import { createMockPackage, createMockRegistry } from '../package-glue.test'
describe('package-glue regressions', () => {
describe('exportAllPackagesForSeed', () => {
it('should export all package data', () => {
const registry = createMockRegistry([
createMockPackage('pkg1', {
name: 'Package 1',
components: [{ id: 'c1' }],
scripts: 'lua code',
}),
createMockPackage('pkg2', {
name: 'Package 2',
scriptFiles: [{ name: 'init', path: 'scripts/init.lua', code: 'return {}' }],
}),
])
const result = exportAllPackagesForSeed(registry)
expect(result.components).toHaveLength(1)
expect(result.scripts).toHaveLength(2)
expect(result.packages).toHaveLength(2)
expect(result.packages[0].packageId).toBe('pkg1')
expect(result.packages[1].packageId).toBe('pkg2')
})
it('should handle empty registry', () => {
const result = exportAllPackagesForSeed({})
expect(result.components).toEqual([])
expect(result.scripts).toEqual([])
expect(result.packages).toEqual([])
})
it('should include script metadata', () => {
const registry = createMockRegistry([
createMockPackage('pkg', {
scriptFiles: [
{
name: 'utils',
path: 'scripts/utils.lua',
code: 'return true',
category: 'helpers',
description: 'Utility functions',
},
],
}),
])
const result = exportAllPackagesForSeed(registry)
expect(result.scripts[0]).toMatchObject({
id: 'package_pkg_utils',
name: 'Package pkg - utils',
code: 'return true',
category: 'helpers',
path: 'scripts/utils.lua',
description: 'Utility functions',
})
})
})
})

View File

@@ -0,0 +1,284 @@
import { describe, it, expect } from 'vitest'
import type { LuaScriptFile } from '../../package-glue'
import {
checkDependencies,
getAllPackageScripts,
getPackage,
getPackageComponents,
getPackageExamples,
getPackageScriptFiles,
getPackageScripts,
getPackagesByCategory,
} from '../../package-glue'
import { createMockPackage, createMockRegistry } from '../package-glue.test'
describe('package-glue validation', () => {
describe('getPackage', () => {
it.each([
{
name: 'returns package when found',
registry: createMockRegistry([createMockPackage('test_pkg')]),
packageId: 'test_pkg',
expectFound: true,
},
{
name: 'returns undefined when not found',
registry: createMockRegistry([createMockPackage('other_pkg')]),
packageId: 'test_pkg',
expectFound: false,
},
{
name: 'returns undefined from empty registry',
registry: createMockRegistry([]),
packageId: 'test_pkg',
expectFound: false,
},
])('should handle $name', ({ registry, packageId, expectFound }) => {
const result = getPackage(registry, packageId)
if (expectFound) {
expect(result).toBeDefined()
expect(result?.packageId).toBe(packageId)
} else {
expect(result).toBeUndefined()
}
})
})
describe('getPackagesByCategory', () => {
const mixedRegistry = createMockRegistry([
createMockPackage('pkg1', { category: 'ui' }),
createMockPackage('pkg2', { category: 'ui' }),
createMockPackage('pkg3', { category: 'data' }),
createMockPackage('pkg4', { category: 'util' }),
])
it.each([
{
name: 'returns packages in category',
registry: mixedRegistry,
category: 'ui',
expectedCount: 2,
},
{
name: 'returns single package in category',
registry: mixedRegistry,
category: 'data',
expectedCount: 1,
},
{
name: 'returns empty array for unknown category',
registry: mixedRegistry,
category: 'unknown',
expectedCount: 0,
},
{
name: 'returns empty array from empty registry',
registry: createMockRegistry([]),
category: 'ui',
expectedCount: 0,
},
])('should handle $name', ({ registry, category, expectedCount }) => {
const result = getPackagesByCategory(registry, category)
expect(result).toHaveLength(expectedCount)
result.forEach((pkg) => {
expect(pkg.category).toBe(category)
})
})
})
describe('getPackageComponents', () => {
it.each([
{
name: 'returns components array',
pkg: createMockPackage('test', {
components: [{ id: 'c1' }, { id: 'c2' }],
}),
expectedLength: 2,
},
{
name: 'returns empty array when no components',
pkg: createMockPackage('test', { components: [] }),
expectedLength: 0,
},
{
name: 'returns empty array when components is undefined',
pkg: { ...createMockPackage('test'), components: undefined as any },
expectedLength: 0,
},
])('should handle $name', ({ pkg, expectedLength }) => {
const result = getPackageComponents(pkg)
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(expectedLength)
})
})
describe('getPackageScripts', () => {
it.each([
{
name: 'returns scripts string',
pkg: createMockPackage('test', { scripts: 'return 42' }),
expected: 'return 42',
},
{
name: 'returns empty string when undefined',
pkg: createMockPackage('test'),
expected: '',
},
{
name: 'returns empty string when null',
pkg: { ...createMockPackage('test'), scripts: null as any },
expected: '',
},
])('should handle $name', ({ pkg, expected }) => {
const result = getPackageScripts(pkg)
expect(result).toBe(expected)
})
})
describe('getPackageScriptFiles', () => {
const mockScriptFiles: LuaScriptFile[] = [
{ name: 'init', path: 'scripts/init.lua', code: 'return {}' },
{ name: 'utils', path: 'scripts/utils.lua', code: 'return true' },
]
it.each([
{
name: 'returns script files array',
pkg: createMockPackage('test', { scriptFiles: mockScriptFiles }),
expectedLength: 2,
},
{
name: 'returns empty array when undefined',
pkg: createMockPackage('test'),
expectedLength: 0,
},
{
name: 'returns empty array when empty',
pkg: createMockPackage('test', { scriptFiles: [] }),
expectedLength: 0,
},
])('should handle $name', ({ pkg, expectedLength }) => {
const result = getPackageScriptFiles(pkg)
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(expectedLength)
})
})
describe('getAllPackageScripts', () => {
const mockScriptFiles: LuaScriptFile[] = [
{ name: 'init', path: 'scripts/init.lua', code: 'return {}' },
]
it.each([
{
name: 'returns both legacy and files',
pkg: createMockPackage('test', {
scripts: 'legacy code',
scriptFiles: mockScriptFiles,
}),
expectedLegacy: 'legacy code',
expectedFilesLength: 1,
},
{
name: 'handles missing legacy',
pkg: createMockPackage('test', { scriptFiles: mockScriptFiles }),
expectedLegacy: '',
expectedFilesLength: 1,
},
{
name: 'handles missing files',
pkg: createMockPackage('test', { scripts: 'code' }),
expectedLegacy: 'code',
expectedFilesLength: 0,
},
{
name: 'handles both missing',
pkg: createMockPackage('test'),
expectedLegacy: '',
expectedFilesLength: 0,
},
])('should handle $name', ({ pkg, expectedLegacy, expectedFilesLength }) => {
const result = getAllPackageScripts(pkg)
expect(result.legacy).toBe(expectedLegacy)
expect(result.files).toHaveLength(expectedFilesLength)
})
})
describe('getPackageExamples', () => {
it.each([
{
name: 'returns examples object',
pkg: createMockPackage('test', {
examples: { demo: 'code' },
}),
hasExamples: true,
},
{
name: 'returns empty object when undefined',
pkg: createMockPackage('test'),
hasExamples: false,
},
])('should handle $name', ({ pkg, hasExamples }) => {
const result = getPackageExamples(pkg)
expect(typeof result).toBe('object')
if (hasExamples) {
expect(result.demo).toBe('code')
} else {
expect(Object.keys(result)).toHaveLength(0)
}
})
})
describe('checkDependencies', () => {
it.each([
{
name: 'satisfied when no dependencies',
registry: createMockRegistry([createMockPackage('test')]),
packageId: 'test',
expectedSatisfied: true,
expectedMissing: [],
},
{
name: 'satisfied when all dependencies present',
registry: createMockRegistry([
createMockPackage('test', { dependencies: ['dep1', 'dep2'] }),
createMockPackage('dep1'),
createMockPackage('dep2'),
]),
packageId: 'test',
expectedSatisfied: true,
expectedMissing: [],
},
{
name: 'not satisfied when dependencies missing',
registry: createMockRegistry([
createMockPackage('test', { dependencies: ['dep1', 'dep2'] }),
createMockPackage('dep1'),
]),
packageId: 'test',
expectedSatisfied: false,
expectedMissing: ['dep2'],
},
{
name: 'not satisfied when package not found',
registry: createMockRegistry([]),
packageId: 'nonexistent',
expectedSatisfied: false,
expectedMissing: ['nonexistent'],
},
])('should handle $name', ({ registry, packageId, expectedSatisfied, expectedMissing }) => {
const result = checkDependencies(registry, packageId)
expect(result.satisfied).toBe(expectedSatisfied)
expect(result.missing).toEqual(expectedMissing)
})
})
})

View File

@@ -1 +1 @@
export * from '../builder-types'
export * from '@/lib/types/builder-types'

View File

@@ -0,0 +1,99 @@
import type { ComponentInstance } from '@/lib/types/builder-types'
export const buildHeaderActions = (): ComponentInstance[] => [
{
id: 'header_login_btn',
type: 'Button',
props: {
children: 'Login',
variant: 'default',
size: 'sm',
},
children: [],
},
]
export const buildProfileCard = (): ComponentInstance => ({
id: 'comp_profile',
type: 'Card',
props: {
className: 'p-6',
},
children: [
{
id: 'comp_profile_header',
type: 'Heading',
props: {
level: 2,
children: 'User Profile',
className: 'text-2xl font-bold mb-4',
},
children: [],
},
{
id: 'comp_profile_content',
type: 'Container',
props: {
className: 'space-y-4',
},
children: [
{
id: 'comp_profile_bio',
type: 'Textarea',
props: {
placeholder: 'Tell us about yourself...',
className: 'min-h-32',
},
children: [],
},
{
id: 'comp_profile_save',
type: 'Button',
props: {
children: 'Save Profile',
variant: 'default',
},
children: [],
},
],
},
],
})
export const buildCommentsCard = (): ComponentInstance => ({
id: 'comp_comments',
type: 'Card',
props: {
className: 'p-6',
},
children: [
{
id: 'comp_comments_header',
type: 'Heading',
props: {
level: 2,
children: 'Community Comments',
className: 'text-2xl font-bold mb-4',
},
children: [],
},
{
id: 'comp_comments_input',
type: 'Textarea',
props: {
placeholder: 'Share your thoughts...',
className: 'mb-4',
},
children: [],
},
{
id: 'comp_comments_post',
type: 'Button',
props: {
children: 'Post Comment',
variant: 'default',
},
children: [],
},
],
})

View File

@@ -1,131 +1,46 @@
import type { PageDefinition } from './page-renderer'
import type { ComponentInstance } from './builder-types'
import { Database } from '@/lib/database'
import { buildCommentsCard, buildProfileCard } from '@/lib/rendering/page/components'
import type { PageDefinition } from '@/lib/rendering/page/page-renderer'
export function buildLevel2UserDashboard(): PageDefinition {
const profileCard: ComponentInstance = {
id: 'comp_profile',
type: 'Card',
props: {
className: 'p-6'
},
children: [
return {
id: 'page_level2_dashboard',
level: 2,
title: 'User Dashboard',
description: 'User dashboard with profile and comments',
layout: 'dashboard',
components: [buildProfileCard(), buildCommentsCard()],
permissions: {
requiresAuth: true,
requiredRole: 'user',
},
metadata: {
showHeader: true,
showFooter: false,
headerTitle: 'Dashboard',
sidebarItems: [
{
id: 'comp_profile_header',
type: 'Heading',
props: {
level: 2,
children: 'User Profile',
className: 'text-2xl font-bold mb-4'
},
children: []
id: 'nav_home',
label: 'Home',
icon: '🏠',
action: 'navigate',
target: '1',
},
{
id: 'comp_profile_content',
type: 'Container',
props: {
className: 'space-y-4'
},
children: [
{
id: 'comp_profile_bio',
type: 'Textarea',
props: {
placeholder: 'Tell us about yourself...',
className: 'min-h-32'
},
children: []
},
{
id: 'comp_profile_save',
type: 'Button',
props: {
children: 'Save Profile',
variant: 'default'
},
children: []
}
]
}
]
}
const commentsCard: ComponentInstance = {
id: 'comp_comments',
type: 'Card',
props: {
className: 'p-6'
},
children: [
{
id: 'comp_comments_header',
type: 'Heading',
props: {
level: 2,
children: 'Community Comments',
className: 'text-2xl font-bold mb-4'
},
children: []
id: 'nav_profile',
label: 'Profile',
icon: '👤',
action: 'navigate',
target: '2',
},
{
id: 'comp_comments_input',
type: 'Textarea',
props: {
placeholder: 'Share your thoughts...',
className: 'mb-4'
},
children: []
id: 'nav_chat',
label: 'Chat',
icon: '💬',
action: 'navigate',
target: '2',
},
{
id: 'comp_comments_post',
type: 'Button',
props: {
children: 'Post Comment',
variant: 'default'
},
children: []
}
]
}
return {
id: 'page_level2_dashboard',
level: 2,
title: 'User Dashboard',
description: 'User dashboard with profile and comments',
layout: 'dashboard',
components: [profileCard, commentsCard],
permissions: {
requiresAuth: true,
requiredRole: 'user'
},
metadata: {
showHeader: true,
showFooter: false,
headerTitle: 'Dashboard',
sidebarItems: [
{
id: 'nav_home',
label: 'Home',
icon: '🏠',
action: 'navigate',
target: '1'
},
{
id: 'nav_profile',
label: 'Profile',
icon: '👤',
action: 'navigate',
target: '2'
},
{
id: 'nav_chat',
label: 'Chat',
icon: '💬',
action: 'navigate',
target: '2'
}
]
}
}
],
},
}
}

View File

@@ -1,4 +1,4 @@
import type { PageDefinition } from './page-renderer'
import type { PageDefinition } from '@/lib/rendering/page/page-renderer'
import type { ComponentInstance } from './builder-types'
import { Database } from '@/lib/database'

View File

@@ -1,4 +1,4 @@
import type { PageDefinition } from './page-renderer'
import type { PageDefinition } from '@/lib/rendering/page/page-renderer'
import type { ComponentInstance } from './builder-types'
import { Database } from '@/lib/database'

View File

@@ -1,4 +1,4 @@
import type { PageDefinition } from './page-renderer'
import type { PageDefinition } from '@/lib/rendering/page/page-renderer'
import type { ComponentInstance } from './builder-types'
import { Database } from '@/lib/database'

View File

@@ -1,21 +1,9 @@
import type { PageDefinition } from '@/lib/rendering/page/page-renderer'
import type { ComponentInstance } from '@/lib/rendering/page/builder-types'
import { buildHeaderActions } from '@/lib/rendering/page/components'
import { buildFeaturesComponent } from './build-features-component'
import { buildHeroComponent } from './build-hero-component'
const buildHeaderActions = (): ComponentInstance[] => [
{
id: 'header_login_btn',
type: 'Button',
props: {
children: 'Login',
variant: 'default',
size: 'sm'
},
children: []
}
]
export const buildLevel1Homepage = (): PageDefinition => {
const heroComponent = buildHeroComponent()
const featuresComponent = buildFeaturesComponent()

View File

@@ -1,4 +1,4 @@
import type { PageDefinition } from './page-renderer'
import type { PageDefinition } from '@/lib/rendering/page/page-renderer'
import type { ComponentInstance } from './builder-types'
import { Database } from '@/lib/database'

View File

@@ -1,8 +1,8 @@
import type { ComponentInstance } from '../types/builder-types'
import type { User } from '../types/level-types'
import { Database } from '../database'
import type { LuaEngine } from '../lua-engine'
import { executeLuaScriptWithProfile } from '../lua/execute-lua-script-with-profile'
import { Database } from '@/lib/database'
import type { LuaEngine } from '@/lib/lua-engine'
import { executeLuaScriptWithProfile } from '@/lib/lua/execute-lua-script-with-profile'
import type { ComponentInstance } from '@/lib/types/builder-types'
import type { User } from '@/lib/types/level-types'
export interface PageDefinition {
id: string

View File

@@ -0,0 +1,29 @@
import type { ComponentInstance } from '@/lib/types/builder-types'
import type { User, UserRole } from '@/lib/types/level-types'
import type { PageDefinition } from './page-renderer'
export function createMockPage(
id: string,
options: Partial<PageDefinition> = {}
): PageDefinition {
return {
id,
level: options.level ?? 1,
title: options.title ?? `Page ${id}`,
layout: options.layout ?? 'default',
components: (options.components as ComponentInstance[] | undefined) ?? [],
permissions: options.permissions,
luaScripts: options.luaScripts,
metadata: options.metadata,
}
}
export function createMockUser(role: UserRole | string, id = 'user1'): User {
return {
id,
username: `User ${id}`,
role: role as UserRole,
email: `${id}@test.com`,
createdAt: Date.now(),
}
}

View File

@@ -0,0 +1,127 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { DeclarativeComponentRenderer } from '@/lib/rendering/declarative-component-renderer'
describe('declarative-component-renderer evaluation', () => {
let renderer: DeclarativeComponentRenderer
beforeEach(() => {
renderer = new DeclarativeComponentRenderer()
})
describe('interpolateValue', () => {
it.each([
{
name: 'simple interpolation',
template: 'Hello {name}!',
context: { name: 'World' },
expected: 'Hello World!',
},
{
name: 'multiple placeholders',
template: '{greeting} {name}, welcome to {place}',
context: { greeting: 'Hi', name: 'Alice', place: 'Wonderland' },
expected: 'Hi Alice, welcome to Wonderland',
},
{
name: 'missing placeholder',
template: 'Hello {name}, age: {age}',
context: { name: 'Bob' },
expected: 'Hello Bob, age: {age}',
},
{
name: 'numeric value',
template: 'Count: {count}',
context: { count: 42 },
expected: 'Count: 42',
},
{
name: 'boolean value',
template: 'Active: {active}',
context: { active: true },
expected: 'Active: true',
},
{
name: 'empty template',
template: '',
context: { name: 'test' },
expected: '',
},
{
name: 'no placeholders',
template: 'Plain text',
context: { name: 'ignored' },
expected: 'Plain text',
},
{
name: 'null template',
template: null as any,
context: { name: 'test' },
expected: null,
},
{
name: 'undefined value in context',
template: 'Value: {val}',
context: { val: undefined },
expected: 'Value: {val}',
},
])('should handle $name', ({ template, context, expected }) => {
expect(renderer.interpolateValue(template, context)).toBe(expected)
})
})
describe('evaluateConditional', () => {
it.each([
{ name: 'boolean true', condition: true, context: {}, expected: true },
{ name: 'boolean false', condition: false, context: {}, expected: false },
{ name: 'empty string condition', condition: '', context: {}, expected: true },
{ name: 'null condition', condition: null as any, context: {}, expected: true },
{ name: 'undefined condition', condition: undefined as any, context: {}, expected: true },
{ name: 'truthy context value', condition: 'isActive', context: { isActive: true }, expected: true },
{ name: 'falsy context value', condition: 'isActive', context: { isActive: false }, expected: false },
{ name: 'missing context key', condition: 'missing', context: {}, expected: false },
{ name: 'truthy string value', condition: 'name', context: { name: 'test' }, expected: true },
{ name: 'empty string value', condition: 'name', context: { name: '' }, expected: false },
{ name: 'zero value', condition: 'count', context: { count: 0 }, expected: false },
{ name: 'positive number', condition: 'count', context: { count: 5 }, expected: true },
])('should return $expected for $name', ({ condition, context, expected }) => {
expect(renderer.evaluateConditional(condition, context)).toBe(expected)
})
})
describe('resolveDataSource', () => {
it.each([
{
name: 'existing array data source',
dataSource: 'items',
context: { items: [1, 2, 3] },
expected: [1, 2, 3],
},
{
name: 'empty array data source',
dataSource: 'items',
context: { items: [] },
expected: [],
},
{
name: 'missing data source',
dataSource: 'missing',
context: {},
expected: [],
},
{
name: 'null data source key',
dataSource: '',
context: { items: [1] },
expected: [],
},
{
name: 'object array data source',
dataSource: 'users',
context: { users: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }] },
expected: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }],
},
])('should resolve $name', ({ dataSource, context, expected }) => {
expect(renderer.resolveDataSource(dataSource, context)).toEqual(expected)
})
})
})

View File

@@ -0,0 +1,183 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
DeclarativeComponentRenderer,
getDeclarativeRenderer,
loadPackageComponents,
type DeclarativeComponentConfig,
} from '@/lib/rendering/declarative-component-renderer'
describe('declarative-component-renderer lifecycle', () => {
let renderer: DeclarativeComponentRenderer
beforeEach(() => {
renderer = new DeclarativeComponentRenderer()
})
describe('registerComponentConfig', () => {
it.each([
{
name: 'basic component',
type: 'button',
config: {
type: 'button',
category: 'input',
label: 'Button',
description: 'A clickable button',
icon: 'click',
props: [],
config: { layout: 'inline', styling: { className: 'btn' }, children: [] },
},
},
{
name: 'component with props',
type: 'input',
config: {
type: 'input',
category: 'form',
label: 'Input Field',
description: 'Text input',
icon: 'text',
props: [
{ name: 'placeholder', type: 'string', label: 'Placeholder', required: false },
{ name: 'value', type: 'string', label: 'Value', required: true, defaultValue: '' },
],
config: { layout: 'block', styling: { className: 'input' }, children: [] },
},
},
])('should register $name', ({ type, config }) => {
renderer.registerComponentConfig(type, config as DeclarativeComponentConfig)
expect(renderer.hasComponentConfig(type)).toBe(true)
expect(renderer.getComponentConfig(type)).toEqual(config)
})
})
describe('hasComponentConfig', () => {
it.each([
{ type: 'registered', shouldRegister: true, expected: true },
{ type: 'unregistered', shouldRegister: false, expected: false },
])('should return $expected for $type component', ({ type, shouldRegister, expected }) => {
if (shouldRegister) {
renderer.registerComponentConfig(type, {
type,
category: 'test',
label: 'Test',
description: '',
icon: '',
props: [],
config: { layout: '', styling: { className: '' }, children: [] },
})
}
expect(renderer.hasComponentConfig(type)).toBe(expected)
})
})
describe('getComponentConfig', () => {
it('should return undefined for non-existent component', () => {
expect(renderer.getComponentConfig('nonexistent')).toBeUndefined()
})
it('should return config for registered component', () => {
const config: DeclarativeComponentConfig = {
type: 'test',
category: 'test',
label: 'Test Component',
description: 'A test',
icon: 'test',
props: [],
config: { layout: 'block', styling: { className: 'test' }, children: [] },
}
renderer.registerComponentConfig('test', config)
expect(renderer.getComponentConfig('test')).toEqual(config)
})
})
describe('getDeclarativeRenderer', () => {
it('should return a global renderer instance', () => {
const renderer1 = getDeclarativeRenderer()
const renderer2 = getDeclarativeRenderer()
expect(renderer1).toBe(renderer2)
expect(renderer1).toBeInstanceOf(DeclarativeComponentRenderer)
})
})
describe('loadPackageComponents', () => {
it('should load component configs from package', () => {
const renderer = getDeclarativeRenderer()
const testType = `loadTest_${Date.now()}`
loadPackageComponents({
componentConfigs: {
[testType]: {
type: testType,
category: 'test',
label: 'Loaded Component',
description: 'Loaded from package',
icon: 'package',
props: [],
config: { layout: 'block', styling: { className: 'loaded' }, children: [] },
},
},
})
expect(renderer.hasComponentConfig(testType)).toBe(true)
})
it('should load Lua scripts from package', () => {
const luaExecuteSpy = vi.spyOn(DeclarativeComponentRenderer.prototype as any, 'executeLuaScript')
loadPackageComponents({
luaScripts: [
{
id: `pkgScript_${Date.now()}`,
code: 'function formatTime() return 1 end',
parameters: [],
returnType: 'number',
},
],
})
expect(luaExecuteSpy).not.toHaveBeenCalled()
})
it('should handle empty package content', () => {
loadPackageComponents({})
loadPackageComponents({ componentConfigs: {} })
loadPackageComponents({ luaScripts: [] })
expect(true).toBe(true)
})
it('should handle package with both configs and scripts', () => {
const renderer = getDeclarativeRenderer()
const uniqueId = Date.now()
loadPackageComponents({
componentConfigs: {
[`combo_${uniqueId}`]: {
type: `combo_${uniqueId}`,
category: 'combo',
label: 'Combo',
description: 'Combined',
icon: 'combo',
props: [],
config: { layout: 'flex', styling: { className: 'combo' }, children: [] },
},
},
luaScripts: [
{
id: `comboScript_${uniqueId}`,
code: 'function userJoin(name) return "Welcome " .. name end',
parameters: [{ name: 'name' }],
returnType: 'string',
},
],
})
expect(renderer.hasComponentConfig(`combo_${uniqueId}`)).toBe(true)
})
})
})

View File

@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { DeclarativeComponentRenderer } from '@/lib/rendering/declarative-component-renderer'
describe('declarative-component-renderer lua integration', () => {
let renderer: DeclarativeComponentRenderer
beforeEach(() => {
renderer = new DeclarativeComponentRenderer()
})
describe('registerLuaScript', () => {
it('should register and store Lua scripts', () => {
const script = {
code: 'return x + y',
parameters: [{ name: 'x' }, { name: 'y' }],
returnType: 'number',
}
renderer.registerLuaScript('add', script)
expect(renderer.executeLuaScript('add', [1, 2])).resolves.toBeDefined()
})
})
describe('executeLuaScript', () => {
it('should throw error for non-existent script', async () => {
await expect(renderer.executeLuaScript('nonexistent', [])).rejects.toThrow(
'Lua script not found: nonexistent'
)
})
it('should execute script with parameters', async () => {
renderer.registerLuaScript('testScript', {
code: `
function formatTime(timestamp)
return timestamp * 1000
end
`,
parameters: [{ name: 'timestamp' }],
returnType: 'number',
})
const result = await renderer.executeLuaScript('testScript', [5])
expect(result).toBe(5000)
})
it('should handle script with no parameters', async () => {
renderer.registerLuaScript('constantScript', {
code: `
function formatTime()
return 42
end
`,
parameters: [],
returnType: 'number',
})
const result = await renderer.executeLuaScript('constantScript', [])
expect(result).toBe(42)
})
})
})

View File

@@ -1,355 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
DeclarativeComponentRenderer,
getDeclarativeRenderer,
loadPackageComponents,
type DeclarativeComponentConfig,
} from './declarative-component-renderer'
describe('declarative-component-renderer', () => {
let renderer: DeclarativeComponentRenderer
beforeEach(() => {
renderer = new DeclarativeComponentRenderer()
})
describe('DeclarativeComponentRenderer', () => {
describe('registerComponentConfig', () => {
it.each([
{
name: 'basic component',
type: 'button',
config: {
type: 'button',
category: 'input',
label: 'Button',
description: 'A clickable button',
icon: 'click',
props: [],
config: { layout: 'inline', styling: { className: 'btn' }, children: [] },
},
},
{
name: 'component with props',
type: 'input',
config: {
type: 'input',
category: 'form',
label: 'Input Field',
description: 'Text input',
icon: 'text',
props: [
{ name: 'placeholder', type: 'string', label: 'Placeholder', required: false },
{ name: 'value', type: 'string', label: 'Value', required: true, defaultValue: '' },
],
config: { layout: 'block', styling: { className: 'input' }, children: [] },
},
},
])('should register $name', ({ type, config }) => {
renderer.registerComponentConfig(type, config as DeclarativeComponentConfig)
expect(renderer.hasComponentConfig(type)).toBe(true)
expect(renderer.getComponentConfig(type)).toEqual(config)
})
})
describe('hasComponentConfig', () => {
it.each([
{ type: 'registered', shouldRegister: true, expected: true },
{ type: 'unregistered', shouldRegister: false, expected: false },
])('should return $expected for $type component', ({ type, shouldRegister, expected }) => {
if (shouldRegister) {
renderer.registerComponentConfig(type, {
type,
category: 'test',
label: 'Test',
description: '',
icon: '',
props: [],
config: { layout: '', styling: { className: '' }, children: [] },
})
}
expect(renderer.hasComponentConfig(type)).toBe(expected)
})
})
describe('getComponentConfig', () => {
it('should return undefined for non-existent component', () => {
expect(renderer.getComponentConfig('nonexistent')).toBeUndefined()
})
it('should return config for registered component', () => {
const config: DeclarativeComponentConfig = {
type: 'test',
category: 'test',
label: 'Test Component',
description: 'A test',
icon: 'test',
props: [],
config: { layout: 'block', styling: { className: 'test' }, children: [] },
}
renderer.registerComponentConfig('test', config)
expect(renderer.getComponentConfig('test')).toEqual(config)
})
})
describe('interpolateValue', () => {
it.each([
{
name: 'simple interpolation',
template: 'Hello {name}!',
context: { name: 'World' },
expected: 'Hello World!',
},
{
name: 'multiple placeholders',
template: '{greeting} {name}, welcome to {place}',
context: { greeting: 'Hi', name: 'Alice', place: 'Wonderland' },
expected: 'Hi Alice, welcome to Wonderland',
},
{
name: 'missing placeholder',
template: 'Hello {name}, age: {age}',
context: { name: 'Bob' },
expected: 'Hello Bob, age: {age}',
},
{
name: 'numeric value',
template: 'Count: {count}',
context: { count: 42 },
expected: 'Count: 42',
},
{
name: 'boolean value',
template: 'Active: {active}',
context: { active: true },
expected: 'Active: true',
},
{
name: 'empty template',
template: '',
context: { name: 'test' },
expected: '',
},
{
name: 'no placeholders',
template: 'Plain text',
context: { name: 'ignored' },
expected: 'Plain text',
},
{
name: 'null template',
template: null as any,
context: { name: 'test' },
expected: null,
},
{
name: 'undefined value in context',
template: 'Value: {val}',
context: { val: undefined },
expected: 'Value: {val}',
},
])('should handle $name', ({ template, context, expected }) => {
expect(renderer.interpolateValue(template, context)).toBe(expected)
})
})
describe('evaluateConditional', () => {
it.each([
{ name: 'boolean true', condition: true, context: {}, expected: true },
{ name: 'boolean false', condition: false, context: {}, expected: false },
{ name: 'empty string condition', condition: '', context: {}, expected: true },
{ name: 'null condition', condition: null as any, context: {}, expected: true },
{ name: 'undefined condition', condition: undefined as any, context: {}, expected: true },
{ name: 'truthy context value', condition: 'isActive', context: { isActive: true }, expected: true },
{ name: 'falsy context value', condition: 'isActive', context: { isActive: false }, expected: false },
{ name: 'missing context key', condition: 'missing', context: {}, expected: false },
{ name: 'truthy string value', condition: 'name', context: { name: 'test' }, expected: true },
{ name: 'empty string value', condition: 'name', context: { name: '' }, expected: false },
{ name: 'zero value', condition: 'count', context: { count: 0 }, expected: false },
{ name: 'positive number', condition: 'count', context: { count: 5 }, expected: true },
])('should return $expected for $name', ({ condition, context, expected }) => {
expect(renderer.evaluateConditional(condition, context)).toBe(expected)
})
})
describe('resolveDataSource', () => {
it.each([
{
name: 'existing array data source',
dataSource: 'items',
context: { items: [1, 2, 3] },
expected: [1, 2, 3],
},
{
name: 'empty array data source',
dataSource: 'items',
context: { items: [] },
expected: [],
},
{
name: 'missing data source',
dataSource: 'missing',
context: {},
expected: [],
},
{
name: 'null data source key',
dataSource: '',
context: { items: [1] },
expected: [],
},
{
name: 'object array data source',
dataSource: 'users',
context: { users: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }] },
expected: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }],
},
])('should resolve $name', ({ dataSource, context, expected }) => {
expect(renderer.resolveDataSource(dataSource, context)).toEqual(expected)
})
})
describe('registerLuaScript', () => {
it('should register and store Lua scripts', () => {
const script = {
code: 'return x + y',
parameters: [{ name: 'x' }, { name: 'y' }],
returnType: 'number',
}
renderer.registerLuaScript('add', script)
// Verify registration by attempting to execute
// The script is stored internally
expect(true).toBe(true) // Script registered without error
})
})
describe('executeLuaScript', () => {
it('should throw error for non-existent script', async () => {
await expect(renderer.executeLuaScript('nonexistent', [])).rejects.toThrow(
'Lua script not found: nonexistent'
)
})
it('should execute script with parameters', async () => {
renderer.registerLuaScript('testScript', {
code: `
function formatTime(timestamp)
return timestamp * 1000
end
`,
parameters: [{ name: 'timestamp' }],
returnType: 'number',
})
const result = await renderer.executeLuaScript('testScript', [5])
expect(result).toBe(5000)
})
it('should handle script with no parameters', async () => {
renderer.registerLuaScript('constantScript', {
code: `
function formatTime()
return 42
end
`,
parameters: [],
returnType: 'number',
})
const result = await renderer.executeLuaScript('constantScript', [])
expect(result).toBe(42)
})
})
})
describe('getDeclarativeRenderer', () => {
it('should return a global renderer instance', () => {
const renderer1 = getDeclarativeRenderer()
const renderer2 = getDeclarativeRenderer()
expect(renderer1).toBe(renderer2)
expect(renderer1).toBeInstanceOf(DeclarativeComponentRenderer)
})
})
describe('loadPackageComponents', () => {
it('should load component configs from package', () => {
const renderer = getDeclarativeRenderer()
const testType = `loadTest_${Date.now()}`
loadPackageComponents({
componentConfigs: {
[testType]: {
type: testType,
category: 'test',
label: 'Loaded Component',
description: 'Loaded from package',
icon: 'package',
props: [],
config: { layout: 'block', styling: { className: 'loaded' }, children: [] },
},
},
})
expect(renderer.hasComponentConfig(testType)).toBe(true)
})
it('should load Lua scripts from package', () => {
loadPackageComponents({
luaScripts: [
{
id: `pkgScript_${Date.now()}`,
code: 'function formatTime() return 1 end',
parameters: [],
returnType: 'number',
},
],
})
// Script loaded without error
expect(true).toBe(true)
})
it('should handle empty package content', () => {
// Should not throw
loadPackageComponents({})
loadPackageComponents({ componentConfigs: {} })
loadPackageComponents({ luaScripts: [] })
expect(true).toBe(true)
})
it('should handle package with both configs and scripts', () => {
const renderer = getDeclarativeRenderer()
const uniqueId = Date.now()
loadPackageComponents({
componentConfigs: {
[`combo_${uniqueId}`]: {
type: `combo_${uniqueId}`,
category: 'combo',
label: 'Combo',
description: 'Combined',
icon: 'combo',
props: [],
config: { layout: 'flex', styling: { className: 'combo' }, children: [] },
},
},
luaScripts: [
{
id: `comboScript_${uniqueId}`,
code: 'function userJoin(name) return "Welcome " .. name end',
parameters: [{ name: 'name' }],
returnType: 'string',
},
],
})
expect(renderer.hasComponentConfig(`combo_${uniqueId}`)).toBe(true)
})
})
})

View File

@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PageRenderer } from '@/lib/rendering/page/page-renderer'
import { createMockPage } from '@/lib/rendering/page/utils'
const { Database, MockLuaEngine } = vi.hoisted(() => {
class MockLuaEngine {
execute = vi.fn()
}
return {
Database: {
getPages: vi.fn(),
addPage: vi.fn(),
getLuaScripts: vi.fn(),
},
MockLuaEngine,
}
})
vi.mock('@/lib/database', () => ({ Database }))
vi.mock('@/lib/lua-engine', () => ({ LuaEngine: MockLuaEngine }))
describe('page-renderer layout queries', () => {
let renderer: PageRenderer
beforeEach(() => {
vi.clearAllMocks()
renderer = new PageRenderer()
Database.getPages.mockResolvedValue([])
Database.addPage.mockResolvedValue(undefined)
Database.getLuaScripts.mockResolvedValue([])
})
describe('getPagesByLevel', () => {
it('should filter pages by level', async () => {
await renderer.registerPage(createMockPage('p1', { level: 1 }))
await renderer.registerPage(createMockPage('p2', { level: 2 }))
await renderer.registerPage(createMockPage('p3', { level: 2 }))
await renderer.registerPage(createMockPage('p4', { level: 3 }))
const level2Pages = renderer.getPagesByLevel(2)
expect(level2Pages).toHaveLength(2)
expect(level2Pages.map(p => p.id)).toContain('p2')
expect(level2Pages.map(p => p.id)).toContain('p3')
})
it('should return empty array for level with no pages', async () => {
await renderer.registerPage(createMockPage('p1', { level: 1 }))
const level5Pages = renderer.getPagesByLevel(5)
expect(level5Pages).toHaveLength(0)
})
})
})

View File

@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getPageRenderer, PageRenderer } from '@/lib/rendering/page/page-renderer'
import { createMockPage } from '@/lib/rendering/page/utils'
const { Database, MockLuaEngine } = vi.hoisted(() => {
class MockLuaEngine {
execute = vi.fn()
}
return {
Database: {
getPages: vi.fn(),
addPage: vi.fn(),
getLuaScripts: vi.fn(),
},
MockLuaEngine,
}
})
vi.mock('@/lib/database', () => ({ Database }))
vi.mock('@/lib/lua-engine', () => ({ LuaEngine: MockLuaEngine }))
describe('page-renderer lifecycle', () => {
let renderer: PageRenderer
beforeEach(() => {
vi.clearAllMocks()
renderer = new PageRenderer()
Database.getPages.mockResolvedValue([])
Database.addPage.mockResolvedValue(undefined)
Database.getLuaScripts.mockResolvedValue([])
})
describe('registerPage', () => {
it('should register a page and add to database', async () => {
const page = createMockPage('test-page', { title: 'Test Page' })
await renderer.registerPage(page)
expect(Database.addPage).toHaveBeenCalledWith(
expect.objectContaining({
id: 'test-page',
title: 'Test Page',
})
)
expect(renderer.getPage('test-page')).toEqual(page)
})
it('should handle pages with permissions', async () => {
const page = createMockPage('auth-page', {
permissions: {
requiresAuth: true,
requiredRole: 'admin',
},
})
await renderer.registerPage(page)
expect(Database.addPage).toHaveBeenCalledWith(
expect.objectContaining({
requiresAuth: true,
requiredRole: 'admin',
})
)
})
})
describe('loadPages', () => {
it('should load pages from database', async () => {
Database.getPages.mockResolvedValue([
{
id: 'page1',
title: 'Page 1',
level: 2,
componentTree: [],
requiresAuth: false,
},
{
id: 'page2',
title: 'Page 2',
level: 3,
componentTree: [{ id: 'c1', type: 'text' }],
requiresAuth: true,
requiredRole: 'admin',
},
])
await renderer.loadPages()
expect(renderer.getPage('page1')).toBeDefined()
expect(renderer.getPage('page2')).toBeDefined()
expect(renderer.getPage('page1')?.title).toBe('Page 1')
expect(renderer.getPage('page2')?.permissions?.requiresAuth).toBe(true)
})
it('should handle empty database', async () => {
Database.getPages.mockResolvedValue([])
await renderer.loadPages()
expect(renderer.getPage('nonexistent')).toBeUndefined()
})
})
describe('getPage', () => {
it.each([
{
name: 'returns page when exists',
pageId: 'existing',
expectFound: true,
},
{
name: 'returns undefined when not exists',
pageId: 'nonexistent',
expectFound: false,
},
])('should handle $name', async ({ pageId, expectFound }) => {
await renderer.registerPage(createMockPage('existing'))
const result = renderer.getPage(pageId)
if (expectFound) {
expect(result).toBeDefined()
expect(result?.id).toBe(pageId)
} else {
expect(result).toBeUndefined()
}
})
})
describe('getPageRenderer singleton', () => {
it('should return the same instance', () => {
const instance1 = getPageRenderer()
const instance2 = getPageRenderer()
expect(instance1).toBe(instance2)
})
})
})

View File

@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PageRenderer, type PageDefinition } from '@/lib/rendering/page/page-renderer'
import { createMockPage, createMockUser } from '@/lib/rendering/page/utils'
const { Database, MockLuaEngine } = vi.hoisted(() => {
class MockLuaEngine {
execute = vi.fn()
}
return {
Database: {
getPages: vi.fn(),
addPage: vi.fn(),
getLuaScripts: vi.fn(),
},
MockLuaEngine,
}
})
vi.mock('@/lib/database', () => ({ Database }))
vi.mock('@/lib/lua-engine', () => ({ LuaEngine: MockLuaEngine }))
describe('page-renderer permissions', () => {
let renderer: PageRenderer
beforeEach(() => {
vi.clearAllMocks()
renderer = new PageRenderer()
Database.getPages.mockResolvedValue([])
Database.addPage.mockResolvedValue(undefined)
Database.getLuaScripts.mockResolvedValue([])
})
describe('checkPermissions', () => {
it.each([
{
name: 'allows when no permissions defined',
page: createMockPage('open'),
user: null,
expectedAllowed: true,
},
{
name: 'blocks unauthenticated user when auth required',
page: createMockPage('auth', {
permissions: { requiresAuth: true },
}),
user: null,
expectedAllowed: false,
expectedReason: 'Authentication required',
},
{
name: 'allows authenticated user when auth required',
page: createMockPage('auth', {
permissions: { requiresAuth: true },
}),
user: createMockUser('user'),
expectedAllowed: true,
},
{
name: 'blocks user with insufficient role',
page: createMockPage('admin', {
permissions: { requiresAuth: true, requiredRole: 'admin' },
}),
user: createMockUser('user'),
expectedAllowed: false,
expectedReason: 'Insufficient permissions',
},
{
name: 'allows user with sufficient role',
page: createMockPage('admin', {
permissions: { requiresAuth: true, requiredRole: 'admin' },
}),
user: createMockUser('admin'),
expectedAllowed: true,
},
{
name: 'allows god role for admin page',
page: createMockPage('admin', {
permissions: { requiresAuth: true, requiredRole: 'admin' },
}),
user: createMockUser('god'),
expectedAllowed: true,
},
{
name: 'allows supergod role for god page',
page: createMockPage('god', {
permissions: { requiresAuth: true, requiredRole: 'god' },
}),
user: createMockUser('supergod'),
expectedAllowed: true,
},
])('should handle $name', async ({ page, user, expectedAllowed, expectedReason }) => {
const result = await renderer.checkPermissions(page as PageDefinition, user)
expect(result.allowed).toBe(expectedAllowed)
if (expectedReason) {
expect(result.reason).toBe(expectedReason)
}
})
})
})

View File

@@ -1,265 +0,0 @@
/**
* Tests for page-renderer.ts - Page rendering and permission checking
* Following parameterized test pattern per project conventions
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import type { PageDefinition } from './page-renderer'
import type { User, UserRole } from '../types/level-types'
// Mock Database
const { Database, MockLuaEngine } = vi.hoisted(() => {
class MockLuaEngine {
execute = vi.fn()
}
return {
Database: {
getPages: vi.fn(),
addPage: vi.fn(),
getLuaScripts: vi.fn(),
},
MockLuaEngine,
}
})
vi.mock('../database', () => ({ Database }))
vi.mock('../lua-engine', () => ({ LuaEngine: MockLuaEngine }))
import { PageRenderer, getPageRenderer } from './page-renderer'
// Helper to create mock page definitions
function createMockPage(
id: string,
options: Partial<PageDefinition> = {}
): PageDefinition {
return {
id,
level: options.level ?? 1,
title: options.title ?? `Page ${id}`,
layout: options.layout ?? 'default',
components: options.components ?? [],
permissions: options.permissions,
luaScripts: options.luaScripts,
metadata: options.metadata,
}
}
// Helper to create mock users
function createMockUser(role: string, id = 'user1'): User {
return {
id,
username: `User ${id}`,
role: role as UserRole,
email: `${id}@test.com`,
createdAt: Date.now(),
}
}
describe('page-renderer', () => {
let renderer: PageRenderer
beforeEach(() => {
vi.clearAllMocks()
renderer = new PageRenderer()
Database.getPages.mockResolvedValue([])
Database.addPage.mockResolvedValue(undefined)
Database.getLuaScripts.mockResolvedValue([])
})
describe('registerPage', () => {
it('should register a page and add to database', async () => {
const page = createMockPage('test-page', { title: 'Test Page' })
await renderer.registerPage(page)
expect(Database.addPage).toHaveBeenCalledWith(
expect.objectContaining({
id: 'test-page',
title: 'Test Page',
})
)
expect(renderer.getPage('test-page')).toEqual(page)
})
it('should handle pages with permissions', async () => {
const page = createMockPage('auth-page', {
permissions: {
requiresAuth: true,
requiredRole: 'admin',
},
})
await renderer.registerPage(page)
expect(Database.addPage).toHaveBeenCalledWith(
expect.objectContaining({
requiresAuth: true,
requiredRole: 'admin',
})
)
})
})
describe('loadPages', () => {
it('should load pages from database', async () => {
Database.getPages.mockResolvedValue([
{
id: 'page1',
title: 'Page 1',
level: 2,
componentTree: [],
requiresAuth: false,
},
{
id: 'page2',
title: 'Page 2',
level: 3,
componentTree: [{ id: 'c1', type: 'text' }],
requiresAuth: true,
requiredRole: 'admin',
},
])
await renderer.loadPages()
expect(renderer.getPage('page1')).toBeDefined()
expect(renderer.getPage('page2')).toBeDefined()
expect(renderer.getPage('page1')?.title).toBe('Page 1')
expect(renderer.getPage('page2')?.permissions?.requiresAuth).toBe(true)
})
it('should handle empty database', async () => {
Database.getPages.mockResolvedValue([])
await renderer.loadPages()
expect(renderer.getPage('nonexistent')).toBeUndefined()
})
})
describe('getPage', () => {
it.each([
{
name: 'returns page when exists',
pageId: 'existing',
expectFound: true,
},
{
name: 'returns undefined when not exists',
pageId: 'nonexistent',
expectFound: false,
},
])('should handle $name', async ({ pageId, expectFound }) => {
await renderer.registerPage(createMockPage('existing'))
const result = renderer.getPage(pageId)
if (expectFound) {
expect(result).toBeDefined()
expect(result?.id).toBe(pageId)
} else {
expect(result).toBeUndefined()
}
})
})
describe('getPagesByLevel', () => {
it('should filter pages by level', async () => {
await renderer.registerPage(createMockPage('p1', { level: 1 }))
await renderer.registerPage(createMockPage('p2', { level: 2 }))
await renderer.registerPage(createMockPage('p3', { level: 2 }))
await renderer.registerPage(createMockPage('p4', { level: 3 }))
const level2Pages = renderer.getPagesByLevel(2)
expect(level2Pages).toHaveLength(2)
expect(level2Pages.map(p => p.id)).toContain('p2')
expect(level2Pages.map(p => p.id)).toContain('p3')
})
it('should return empty array for level with no pages', async () => {
await renderer.registerPage(createMockPage('p1', { level: 1 }))
const level5Pages = renderer.getPagesByLevel(5)
expect(level5Pages).toHaveLength(0)
})
})
describe('checkPermissions', () => {
it.each([
{
name: 'allows when no permissions defined',
page: createMockPage('open'),
user: null,
expectedAllowed: true,
},
{
name: 'blocks unauthenticated user when auth required',
page: createMockPage('auth', {
permissions: { requiresAuth: true },
}),
user: null,
expectedAllowed: false,
expectedReason: 'Authentication required',
},
{
name: 'allows authenticated user when auth required',
page: createMockPage('auth', {
permissions: { requiresAuth: true },
}),
user: createMockUser('user'),
expectedAllowed: true,
},
{
name: 'blocks user with insufficient role',
page: createMockPage('admin', {
permissions: { requiresAuth: true, requiredRole: 'admin' },
}),
user: createMockUser('user'),
expectedAllowed: false,
expectedReason: 'Insufficient permissions',
},
{
name: 'allows user with sufficient role',
page: createMockPage('admin', {
permissions: { requiresAuth: true, requiredRole: 'admin' },
}),
user: createMockUser('admin'),
expectedAllowed: true,
},
{
name: 'allows god role for admin page',
page: createMockPage('admin', {
permissions: { requiresAuth: true, requiredRole: 'admin' },
}),
user: createMockUser('god'),
expectedAllowed: true,
},
{
name: 'allows supergod role for god page',
page: createMockPage('god', {
permissions: { requiresAuth: true, requiredRole: 'god' },
}),
user: createMockUser('supergod'),
expectedAllowed: true,
},
])('should handle $name', async ({ page, user, expectedAllowed, expectedReason }) => {
const result = await renderer.checkPermissions(page, user)
expect(result.allowed).toBe(expectedAllowed)
if (expectedReason) {
expect(result.reason).toBe(expectedReason)
}
})
})
describe('getPageRenderer singleton', () => {
it('should return the same instance', () => {
const instance1 = getPageRenderer()
const instance2 = getPageRenderer()
expect(instance1).toBe(instance2)
})
})
})

View File

@@ -0,0 +1,3 @@
// Backward compatibility entry point for schema utilities
// Prefer importing from '@/lib/schema' but keep legacy path working
export * from './schema'

View File

@@ -1,27 +1,27 @@
import type { FieldSchema, ModelSchema, SchemaConfig } from '@/lib/schema-types'
// Import individual functions (lambdas)
import { getModelKey } from './functions/get-model-key'
import { getRecordsKey } from './functions/get-records-key'
import { findModel } from './functions/find-model'
import { getFieldLabel } from './functions/get-field-label'
import { getModelLabel } from './functions/get-model-label'
import { getModelLabelPlural } from './functions/get-model-label-plural'
import { getHelpText } from './functions/get-help-text'
import { generateId } from './functions/generate-id'
import { validateField } from './functions/validate-field'
import { validateRecord } from './functions/validate-record'
import { getDefaultValue } from './functions/get-default-value'
import { createEmptyRecord } from './functions/create-empty-record'
import { sortRecords } from './functions/sort-records'
import { filterRecords } from './functions/filter-records'
import {
createEmptyRecord,
findModel,
filterRecords,
generateId,
getDefaultValue,
getFieldLabel,
getHelpText,
getModelKey,
getModelLabel,
getModelLabelPlural,
getRecordsKey,
sortRecords,
validateField,
validateRecord,
} from './functions'
/**
* SchemaUtils - Class wrapper for schema utility functions
*
*
* This class serves as a container for lambda functions related to schema operations.
* Each method delegates to an individual function file in the functions/ directory.
*
*
* Pattern: "class is container for lambdas"
* - Each lambda is defined in its own file under functions/
* - This class wraps them for convenient namespaced access

View File

@@ -0,0 +1,30 @@
import type { FieldSchema, ModelSchema, SchemaConfig } from '@/lib/schema-types'
export const createMockField = (): FieldSchema => ({
name: 'email',
type: 'email',
label: 'Email Address',
required: true,
helpText: 'Enter a valid email',
})
export const createMockModel = (): ModelSchema => ({
name: 'User',
label: 'User Account',
labelPlural: 'Users',
fields: [
{ name: 'id', type: 'string', required: true },
{ name: 'name', type: 'string', required: true, label: 'Full Name' },
{ name: 'email', type: 'email', required: true },
{ name: 'age', type: 'number' },
],
})
export const createMockSchema = (): SchemaConfig => ({
apps: [
{
name: 'TestApp',
models: [createMockModel()],
},
],
})

View File

@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest'
import { findModel, getModelKey, getRecordsKey } from '@/lib/schema-utils'
import type { SchemaConfig } from '@/lib/schema-types'
import { createMockSchema } from './schema-utils.fixtures'
describe('schema-utils migration', () => {
describe('getModelKey', () => {
it.each([
{ appName: 'MyApp', modelName: 'User', expected: 'MyApp_User' },
{ appName: 'app-v2', modelName: 'User_Profile', expected: 'app-v2_User_Profile' },
{ appName: '', modelName: 'Model', expected: '_Model' },
])('should generate key "$expected" for app=$appName, model=$modelName', ({ appName, modelName, expected }) => {
const result = getModelKey(appName, modelName)
expect(result).toBe(expected)
})
})
describe('getRecordsKey', () => {
it('should generate a records key with prefix', () => {
const result = getRecordsKey('MyApp', 'User')
expect(result).toBe('records_MyApp_User')
})
it('should include records prefix', () => {
const result = getRecordsKey('app', 'data')
expect(result).toMatch(/^records_/)
})
})
describe('findModel', () => {
it('should find a model by app and model name', () => {
const result = findModel(createMockSchema(), 'TestApp', 'User')
expect(result).toBeDefined()
expect(result?.name).toBe('User')
})
it('should return undefined if app not found', () => {
const result = findModel(createMockSchema(), 'NonExistentApp', 'User')
expect(result).toBeUndefined()
})
it('should return undefined if model not found in app', () => {
const result = findModel(createMockSchema(), 'TestApp', 'NonExistentModel')
expect(result).toBeUndefined()
})
it('should handle multiple apps correctly', () => {
const multiAppSchema: SchemaConfig = {
apps: [
{ name: 'App1', models: [{ name: 'Model1', fields: [] }] },
{ name: 'App2', models: [{ name: 'Model2', fields: [] }] },
],
}
const result = findModel(multiAppSchema, 'App2', 'Model2')
expect(result?.name).toBe('Model2')
})
})
})

View File

@@ -1,108 +1,22 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
getModelKey,
getRecordsKey,
findModel,
createEmptyRecord,
filterRecords,
generateId,
getDefaultValue,
getFieldLabel,
getHelpText,
getModelLabel,
getModelLabelPlural,
getHelpText,
generateId,
validateField,
validateRecord,
getDefaultValue,
createEmptyRecord,
sortRecords,
filterRecords,
} from '@/lib/schema-utils'
import type { FieldSchema, ModelSchema, SchemaConfig } from '@/lib/schema-types'
describe('schema-utils', () => {
// Test data setup
const mockField: FieldSchema = {
name: 'email',
type: 'email',
label: 'Email Address',
required: true,
helpText: 'Enter a valid email',
}
const mockModel: ModelSchema = {
name: 'User',
label: 'User Account',
labelPlural: 'Users',
fields: [
{ name: 'id', type: 'string', required: true },
{ name: 'name', type: 'string', required: true, label: 'Full Name' },
{ name: 'email', type: 'email', required: true },
{ name: 'age', type: 'number' },
],
}
const mockSchema: SchemaConfig = {
apps: [
{
name: 'TestApp',
models: [mockModel],
},
],
}
describe('getModelKey', () => {
it.each([
{ appName: 'MyApp', modelName: 'User', expected: 'MyApp_User' },
{ appName: 'app-v2', modelName: 'User_Profile', expected: 'app-v2_User_Profile' },
{ appName: '', modelName: 'Model', expected: '_Model' },
])('should generate key "$expected" for app=$appName, model=$modelName', ({ appName, modelName, expected }) => {
const result = getModelKey(appName, modelName)
expect(result).toBe(expected)
})
})
describe('getRecordsKey', () => {
it('should generate a records key with prefix', () => {
const result = getRecordsKey('MyApp', 'User')
expect(result).toBe('records_MyApp_User')
})
it('should include records prefix', () => {
const result = getRecordsKey('app', 'data')
expect(result).toMatch(/^records_/)
})
})
describe('findModel', () => {
it('should find a model by app and model name', () => {
const result = findModel(mockSchema, 'TestApp', 'User')
expect(result).toBeDefined()
expect(result?.name).toBe('User')
})
it('should return undefined if app not found', () => {
const result = findModel(mockSchema, 'NonExistentApp', 'User')
expect(result).toBeUndefined()
})
it('should return undefined if model not found in app', () => {
const result = findModel(mockSchema, 'TestApp', 'NonExistentModel')
expect(result).toBeUndefined()
})
it('should handle multiple apps correctly', () => {
const multiAppSchema: SchemaConfig = {
apps: [
{ name: 'App1', models: [{ name: 'Model1', fields: [] }] },
{ name: 'App2', models: [{ name: 'Model2', fields: [] }] },
],
}
const result = findModel(multiAppSchema, 'App2', 'Model2')
expect(result?.name).toBe('Model2')
})
})
import type { FieldSchema, ModelSchema } from '@/lib/schema-types'
import { createMockField, createMockModel } from './schema-utils.fixtures'
describe('schema-utils serialization', () => {
describe('getFieldLabel', () => {
it.each([
{ field: mockField, expected: 'Email Address', description: 'custom label' },
{ field: createMockField(), expected: 'Email Address', description: 'custom label' },
{ field: { name: 'email', type: 'email' }, expected: 'Email', description: 'auto-capitalized field name' },
{ field: { name: 'firstName', type: 'string' }, expected: 'FirstName', description: 'multi-word field name' },
])('should return $description', ({ field, expected }) => {
@@ -113,7 +27,7 @@ describe('schema-utils', () => {
describe('getModelLabel', () => {
it('should return custom label if provided', () => {
const result = getModelLabel(mockModel)
const result = getModelLabel(createMockModel())
expect(result).toBe('User Account')
})
@@ -126,7 +40,7 @@ describe('schema-utils', () => {
describe('getModelLabelPlural', () => {
it('should return custom plural label if provided', () => {
const result = getModelLabelPlural(mockModel)
const result = getModelLabelPlural(createMockModel())
expect(result).toBe('Users')
})
@@ -139,7 +53,7 @@ describe('schema-utils', () => {
describe('getHelpText', () => {
it('should return help text if string', () => {
const result = getHelpText(mockField)
const result = getHelpText(createMockField())
expect(result).toBe('Enter a valid email')
})
@@ -178,135 +92,6 @@ describe('schema-utils', () => {
})
})
describe('validateField', () => {
it.each([
{
name: 'required field empty',
field: { name: 'email', type: 'email', required: true },
value: '',
shouldHaveError: true,
},
{
name: 'non-required field empty',
field: { name: 'nickname', type: 'string', required: false },
value: '',
shouldHaveError: false,
},
{
name: 'invalid email',
field: { name: 'email', type: 'email' },
value: 'invalid',
shouldHaveError: true,
},
{
name: 'valid email',
field: { name: 'email', type: 'email' },
value: 'test@example.com',
shouldHaveError: false,
},
{
name: 'invalid URL',
field: { name: 'website', type: 'url' },
value: 'not a url',
shouldHaveError: true,
},
{
name: 'valid URL',
field: { name: 'website', type: 'url' },
value: 'https://example.com',
shouldHaveError: false,
},
{
name: 'number below min',
field: { name: 'age', type: 'number', validation: { min: 0, max: 150 } },
value: -1,
shouldHaveError: true,
},
{
name: 'number above max',
field: { name: 'age', type: 'number', validation: { min: 0, max: 150 } },
value: 200,
shouldHaveError: true,
},
{
name: 'valid number in range',
field: { name: 'age', type: 'number', validation: { min: 0, max: 150 } },
value: 25,
shouldHaveError: false,
},
{
name: 'string too short',
field: { name: 'password', type: 'string', validation: { minLength: 8, maxLength: 20 } },
value: 'short',
shouldHaveError: true,
},
{
name: 'string too long',
field: { name: 'password', type: 'string', validation: { minLength: 8, maxLength: 20 } },
value: 'verylongpasswordthatexceedslimit',
shouldHaveError: true,
},
{
name: 'valid string length',
field: { name: 'password', type: 'string', validation: { minLength: 8, maxLength: 20 } },
value: 'goodpass123',
shouldHaveError: false,
},
{
name: 'valid pattern match',
field: { name: 'code', type: 'string', validation: { pattern: '^[A-Z]{3}-\\d{3}$' } },
value: 'ABC-123',
shouldHaveError: false,
},
{
name: 'invalid pattern match',
field: { name: 'code', type: 'string', validation: { pattern: '^[A-Z]{3}-\\d{3}$' } },
value: 'abc-123',
shouldHaveError: true,
},
])('should $name', ({ field, value, shouldHaveError }) => {
const result = validateField(field as FieldSchema, value)
if (shouldHaveError) {
expect(result).toBeTruthy()
} else {
expect(result).toBeNull()
}
})
})
describe('validateRecord', () => {
it('should validate all fields in a record', () => {
const record = { id: '1', name: 'John', email: 'invalid-email' }
const errors = validateRecord(mockModel, record)
expect(errors.email).toBeTruthy()
})
it('should return empty errors for valid record', () => {
const record = {
id: '1',
name: 'John Doe',
email: 'john@example.com',
age: 30,
}
const errors = validateRecord(mockModel, record)
expect(Object.keys(errors).length).toBe(0)
})
it('should skip non-editable fields', () => {
const model: ModelSchema = {
name: 'Post',
fields: [
{ name: 'id', type: 'string', editable: false },
{ name: 'title', type: 'string', required: true },
],
}
const record = { title: '' }
const errors = validateRecord(model, record)
expect(errors.id).toBeUndefined()
expect(errors.title).toBeTruthy()
})
})
describe('getDefaultValue', () => {
it.each([
{ field: { name: 'count', type: 'number', default: 42 }, expected: 42, description: 'custom default' },
@@ -335,7 +120,7 @@ describe('schema-utils', () => {
describe('createEmptyRecord', () => {
it('should create a record with all fields', () => {
const record = createEmptyRecord(mockModel)
const record = createEmptyRecord(createMockModel())
expect(record.id).toBeDefined()
expect(record.name).toBe('')
expect(record.email).toBe('')
@@ -343,8 +128,8 @@ describe('schema-utils', () => {
})
it('should generate unique ID', () => {
const record1 = createEmptyRecord(mockModel)
const record2 = createEmptyRecord(mockModel)
const record1 = createEmptyRecord(createMockModel())
const record2 = createEmptyRecord(createMockModel())
expect(record1.id).not.toBe(record2.id)
})

View File

@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest'
import { validateField, validateRecord } from '@/lib/schema-utils'
import type { FieldSchema, ModelSchema } from '@/lib/schema-types'
import { createMockModel } from './schema-utils.fixtures'
describe('schema-utils validation', () => {
describe('validateField', () => {
it.each([
{
name: 'required field empty',
field: { name: 'email', type: 'email', required: true },
value: '',
shouldHaveError: true,
},
{
name: 'non-required field empty',
field: { name: 'nickname', type: 'string', required: false },
value: '',
shouldHaveError: false,
},
{
name: 'invalid email',
field: { name: 'email', type: 'email' },
value: 'invalid',
shouldHaveError: true,
},
{
name: 'valid email',
field: { name: 'email', type: 'email' },
value: 'test@example.com',
shouldHaveError: false,
},
{
name: 'invalid URL',
field: { name: 'website', type: 'url' },
value: 'not a url',
shouldHaveError: true,
},
{
name: 'valid URL',
field: { name: 'website', type: 'url' },
value: 'https://example.com',
shouldHaveError: false,
},
{
name: 'number below min',
field: { name: 'age', type: 'number', validation: { min: 0, max: 150 } },
value: -1,
shouldHaveError: true,
},
{
name: 'number above max',
field: { name: 'age', type: 'number', validation: { min: 0, max: 150 } },
value: 200,
shouldHaveError: true,
},
{
name: 'valid number in range',
field: { name: 'age', type: 'number', validation: { min: 0, max: 150 } },
value: 25,
shouldHaveError: false,
},
{
name: 'string too short',
field: { name: 'password', type: 'string', validation: { minLength: 8, maxLength: 20 } },
value: 'short',
shouldHaveError: true,
},
{
name: 'string too long',
field: { name: 'password', type: 'string', validation: { minLength: 8, maxLength: 20 } },
value: 'verylongpasswordthatexceedslimit',
shouldHaveError: true,
},
{
name: 'valid string length',
field: { name: 'password', type: 'string', validation: { minLength: 8, maxLength: 20 } },
value: 'goodpass123',
shouldHaveError: false,
},
{
name: 'valid pattern match',
field: { name: 'code', type: 'string', validation: { pattern: '^[A-Z]{3}-\\d{3}$' } },
value: 'ABC-123',
shouldHaveError: false,
},
{
name: 'invalid pattern match',
field: { name: 'code', type: 'string', validation: { pattern: '^[A-Z]{3}-\\d{3}$' } },
value: 'abc-123',
shouldHaveError: true,
},
])('should $name', ({ field, value, shouldHaveError }) => {
const result = validateField(field as FieldSchema, value)
if (shouldHaveError) {
expect(result).toBeTruthy()
} else {
expect(result).toBeNull()
}
})
})
describe('validateRecord', () => {
it('should validate all fields in a record', () => {
const record = { id: '1', name: 'John', email: 'invalid-email' }
const errors = validateRecord(createMockModel(), record)
expect(errors.email).toBeTruthy()
})
it('should return empty errors for valid record', () => {
const record = {
id: '1',
name: 'John Doe',
email: 'john@example.com',
age: 30,
}
const errors = validateRecord(createMockModel(), record)
expect(Object.keys(errors).length).toBe(0)
})
it('should skip non-editable fields', () => {
const model: ModelSchema = {
name: 'Post',
fields: [
{ name: 'id', type: 'string', editable: false },
{ name: 'title', type: 'string', required: true },
],
}
const record = { title: '' }
const errors = validateRecord(model, record)
expect(errors.id).toBeUndefined()
expect(errors.title).toBeTruthy()
})
})
})

View File

@@ -1,308 +1,6 @@
import type { SchemaConfig } from '../types/schema-types'
import { defaultApps } from './default/components'
export const defaultSchema: SchemaConfig = {
apps: [
{
name: 'blog',
label: 'Blog',
models: [
{
name: 'post',
label: 'Post',
labelPlural: 'Posts',
icon: 'Article',
listDisplay: ['title', 'author', 'status', 'publishedAt'],
listFilter: ['status', 'author'],
searchFields: ['title', 'content'],
ordering: ['-publishedAt'],
fields: [
{
name: 'id',
type: 'string',
label: 'ID',
required: true,
unique: true,
editable: false,
listDisplay: false,
},
{
name: 'title',
type: 'string',
label: 'Title',
required: true,
validation: {
minLength: 3,
maxLength: 200,
},
listDisplay: true,
searchable: true,
sortable: true,
},
{
name: 'slug',
type: 'string',
label: 'Slug',
required: true,
unique: true,
helpText: 'URL-friendly version of the title',
validation: {
pattern: '^[a-z0-9-]+$',
},
listDisplay: false,
sortable: true,
},
{
name: 'content',
type: 'text',
label: 'Content',
required: true,
helpText: 'Main post content',
listDisplay: false,
searchable: true,
},
{
name: 'excerpt',
type: 'text',
label: 'Excerpt',
required: false,
helpText: ['Short summary of the post', 'Used in list views and previews'],
validation: {
maxLength: 500,
},
listDisplay: false,
},
{
name: 'author',
type: 'relation',
label: 'Author',
required: true,
relatedModel: 'author',
listDisplay: true,
sortable: true,
},
{
name: 'status',
type: 'select',
label: 'Status',
required: true,
default: 'draft',
choices: [
{ value: 'draft', label: 'Draft' },
{ value: 'published', label: 'Published' },
{ value: 'archived', label: 'Archived' },
],
listDisplay: true,
sortable: true,
},
{
name: 'featured',
type: 'boolean',
label: 'Featured',
default: false,
helpText: 'Display on homepage',
listDisplay: true,
},
{
name: 'publishedAt',
type: 'datetime',
label: 'Published At',
required: false,
listDisplay: true,
sortable: true,
},
{
name: 'tags',
type: 'json',
label: 'Tags',
required: false,
helpText: 'JSON array of tag strings',
listDisplay: false,
},
{
name: 'views',
type: 'number',
label: 'Views',
default: 0,
validation: {
min: 0,
},
listDisplay: false,
},
],
},
{
name: 'author',
label: 'Author',
labelPlural: 'Authors',
icon: 'User',
listDisplay: ['name', 'email', 'active', 'createdAt'],
listFilter: ['active'],
searchFields: ['name', 'email'],
ordering: ['name'],
fields: [
{
name: 'id',
type: 'string',
label: 'ID',
required: true,
unique: true,
editable: false,
listDisplay: false,
},
{
name: 'name',
type: 'string',
label: 'Name',
required: true,
validation: {
minLength: 2,
maxLength: 100,
},
listDisplay: true,
searchable: true,
sortable: true,
},
{
name: 'email',
type: 'email',
label: 'Email',
required: true,
unique: true,
listDisplay: true,
searchable: true,
sortable: true,
},
{
name: 'bio',
type: 'text',
label: 'Bio',
required: false,
helpText: 'Author biography',
validation: {
maxLength: 1000,
},
listDisplay: false,
},
{
name: 'website',
type: 'url',
label: 'Website',
required: false,
listDisplay: false,
},
{
name: 'active',
type: 'boolean',
label: 'Active',
default: true,
listDisplay: true,
},
{
name: 'createdAt',
type: 'datetime',
label: 'Created At',
required: true,
editable: false,
listDisplay: true,
sortable: true,
},
],
},
],
},
{
name: 'ecommerce',
label: 'E-Commerce',
models: [
{
name: 'product',
label: 'Product',
labelPlural: 'Products',
icon: 'ShoppingCart',
listDisplay: ['name', 'price', 'stock', 'available'],
listFilter: ['available', 'category'],
searchFields: ['name', 'description'],
ordering: ['name'],
fields: [
{
name: 'id',
type: 'string',
label: 'ID',
required: true,
unique: true,
editable: false,
listDisplay: false,
},
{
name: 'name',
type: 'string',
label: 'Product Name',
required: true,
validation: {
minLength: 3,
maxLength: 200,
},
listDisplay: true,
searchable: true,
sortable: true,
},
{
name: 'description',
type: 'text',
label: 'Description',
required: false,
helpText: 'Product description',
listDisplay: false,
searchable: true,
},
{
name: 'price',
type: 'number',
label: 'Price',
required: true,
validation: {
min: 0,
},
listDisplay: true,
sortable: true,
},
{
name: 'stock',
type: 'number',
label: 'Stock',
required: true,
default: 0,
validation: {
min: 0,
},
listDisplay: true,
sortable: true,
},
{
name: 'category',
type: 'select',
label: 'Category',
required: true,
choices: [
{ value: 'electronics', label: 'Electronics' },
{ value: 'clothing', label: 'Clothing' },
{ value: 'books', label: 'Books' },
{ value: 'home', label: 'Home & Garden' },
{ value: 'toys', label: 'Toys' },
],
listDisplay: false,
sortable: true,
},
{
name: 'available',
type: 'boolean',
label: 'Available',
default: true,
listDisplay: true,
},
],
},
],
},
],
apps: defaultApps,
}

View File

@@ -0,0 +1,54 @@
import type { AppSchema, ModelSchema } from '../../types/schema-types'
import { authorFields, postFields, productFields } from './forms'
export const blogModels: ModelSchema[] = [
{
name: 'post',
label: 'Post',
labelPlural: 'Posts',
icon: 'Article',
listDisplay: ['title', 'author', 'status', 'publishedAt'],
listFilter: ['status', 'author'],
searchFields: ['title', 'content'],
ordering: ['-publishedAt'],
fields: postFields,
},
{
name: 'author',
label: 'Author',
labelPlural: 'Authors',
icon: 'User',
listDisplay: ['name', 'email', 'active', 'createdAt'],
listFilter: ['active'],
searchFields: ['name', 'email'],
ordering: ['name'],
fields: authorFields,
},
]
export const ecommerceModels: ModelSchema[] = [
{
name: 'product',
label: 'Product',
labelPlural: 'Products',
icon: 'ShoppingCart',
listDisplay: ['name', 'price', 'stock', 'available'],
listFilter: ['available', 'category'],
searchFields: ['name', 'description'],
ordering: ['name'],
fields: productFields,
},
]
export const defaultApps: AppSchema[] = [
{
name: 'blog',
label: 'Blog',
models: blogModels,
},
{
name: 'ecommerce',
label: 'E-Commerce',
models: ecommerceModels,
},
]

View File

@@ -0,0 +1,244 @@
import type { FieldSchema } from '../../types/schema-types'
import { authorValidations, postValidations, productValidations } from './validation'
export const postFields: FieldSchema[] = [
{
name: 'id',
type: 'string',
label: 'ID',
required: true,
unique: true,
editable: false,
listDisplay: false,
},
{
name: 'title',
type: 'string',
label: 'Title',
required: true,
validation: postValidations.title,
listDisplay: true,
searchable: true,
sortable: true,
},
{
name: 'slug',
type: 'string',
label: 'Slug',
required: true,
unique: true,
helpText: 'URL-friendly version of the title',
validation: postValidations.slug,
listDisplay: false,
sortable: true,
},
{
name: 'content',
type: 'text',
label: 'Content',
required: true,
helpText: 'Main post content',
listDisplay: false,
searchable: true,
},
{
name: 'excerpt',
type: 'text',
label: 'Excerpt',
required: false,
helpText: ['Short summary of the post', 'Used in list views and previews'],
validation: postValidations.excerpt,
listDisplay: false,
},
{
name: 'author',
type: 'relation',
label: 'Author',
required: true,
relatedModel: 'author',
listDisplay: true,
sortable: true,
},
{
name: 'status',
type: 'select',
label: 'Status',
required: true,
default: 'draft',
choices: [
{ value: 'draft', label: 'Draft' },
{ value: 'published', label: 'Published' },
{ value: 'archived', label: 'Archived' },
],
listDisplay: true,
sortable: true,
},
{
name: 'featured',
type: 'boolean',
label: 'Featured',
default: false,
helpText: 'Display on homepage',
listDisplay: true,
},
{
name: 'publishedAt',
type: 'datetime',
label: 'Published At',
required: false,
listDisplay: true,
sortable: true,
},
{
name: 'tags',
type: 'json',
label: 'Tags',
required: false,
helpText: 'JSON array of tag strings',
listDisplay: false,
},
{
name: 'views',
type: 'number',
label: 'Views',
default: 0,
validation: postValidations.views,
listDisplay: false,
},
]
export const authorFields: FieldSchema[] = [
{
name: 'id',
type: 'string',
label: 'ID',
required: true,
unique: true,
editable: false,
listDisplay: false,
},
{
name: 'name',
type: 'string',
label: 'Name',
required: true,
validation: authorValidations.name,
listDisplay: true,
searchable: true,
sortable: true,
},
{
name: 'email',
type: 'email',
label: 'Email',
required: true,
unique: true,
listDisplay: true,
searchable: true,
sortable: true,
},
{
name: 'bio',
type: 'text',
label: 'Bio',
required: false,
helpText: 'Author biography',
validation: authorValidations.bio,
listDisplay: false,
},
{
name: 'website',
type: 'url',
label: 'Website',
required: false,
listDisplay: false,
},
{
name: 'active',
type: 'boolean',
label: 'Active',
default: true,
listDisplay: true,
},
{
name: 'createdAt',
type: 'datetime',
label: 'Created At',
required: true,
editable: false,
listDisplay: true,
sortable: true,
},
]
export const productFields: FieldSchema[] = [
{
name: 'id',
type: 'string',
label: 'ID',
required: true,
unique: true,
editable: false,
listDisplay: false,
},
{
name: 'name',
type: 'string',
label: 'Product Name',
required: true,
validation: productValidations.name,
listDisplay: true,
searchable: true,
sortable: true,
},
{
name: 'description',
type: 'text',
label: 'Description',
required: false,
helpText: 'Product description',
listDisplay: false,
searchable: true,
},
{
name: 'price',
type: 'number',
label: 'Price',
required: true,
validation: productValidations.price,
listDisplay: true,
sortable: true,
},
{
name: 'stock',
type: 'number',
label: 'Stock',
required: true,
default: 0,
validation: productValidations.stock,
listDisplay: true,
sortable: true,
},
{
name: 'category',
type: 'select',
label: 'Category',
required: true,
choices: [
{ value: 'electronics', label: 'Electronics' },
{ value: 'clothing', label: 'Clothing' },
{ value: 'books', label: 'Books' },
{ value: 'home', label: 'Home & Garden' },
{ value: 'toys', label: 'Toys' },
],
listDisplay: false,
sortable: true,
},
{
name: 'available',
type: 'boolean',
label: 'Available',
default: true,
listDisplay: true,
},
]

View File

@@ -0,0 +1,19 @@
import type { FieldSchema } from '../../types/schema-types'
export const postValidations: Record<string, FieldSchema['validation']> = {
title: { minLength: 3, maxLength: 200 },
slug: { pattern: '^[a-z0-9-]+$' },
excerpt: { maxLength: 500 },
views: { min: 0 },
}
export const authorValidations: Record<string, FieldSchema['validation']> = {
name: { minLength: 2, maxLength: 100 },
bio: { maxLength: 1000 },
}
export const productValidations: Record<string, FieldSchema['validation']> = {
name: { minLength: 3, maxLength: 200 },
price: { min: 0 },
stock: { min: 0 },
}

View File

@@ -1,15 +1,15 @@
// Individual function exports
export { getModelKey } from './get-model-key'
export { getRecordsKey } from './get-records-key'
export { findModel } from './find-model'
export { getFieldLabel } from './get-field-label'
export { getModelLabel } from './get-model-label'
export { getModelLabelPlural } from './get-model-label-plural'
export { getHelpText } from './get-help-text'
export { generateId } from './generate-id'
export { validateField } from './validate-field'
export { validateRecord } from './validate-record'
export { getDefaultValue } from './get-default-value'
export { createEmptyRecord } from './create-empty-record'
export { sortRecords } from './sort-records'
export { filterRecords } from './filter-records'
export { getModelKey } from './model/get-model-key'
export { getRecordsKey } from './record/get-records-key'
export { findModel } from './model/find-model'
export { getFieldLabel } from './field/get-field-label'
export { getModelLabel } from './model/get-model-label'
export { getModelLabelPlural } from './model/get-model-label-plural'
export { getHelpText } from './field/get-help-text'
export { generateId } from './record/crud/generate-id'
export { validateField } from './field/validate-field'
export { validateRecord } from './record/validate-record'
export { getDefaultValue } from './field/get-default-value'
export { createEmptyRecord } from './record/crud/create-empty-record'
export { sortRecords } from './record/sort-records'
export { filterRecords } from './record/filter-records'

View File

@@ -1,6 +1,6 @@
import type { ModelSchema } from '@/lib/schema-types'
import { generateId } from './generate-id'
import { getDefaultValue } from './get-default-value'
import { getDefaultValue } from '../../field/get-default-value'
/**
* Create an empty record with default values for a model
@@ -9,7 +9,7 @@ import { getDefaultValue } from './get-default-value'
*/
export const createEmptyRecord = (model: ModelSchema): any => {
const record: any = {}
for (const field of model.fields) {
if (field.name === 'id') {
record.id = generateId()
@@ -19,6 +19,6 @@ export const createEmptyRecord = (model: ModelSchema): any => {
record[field.name] = getDefaultValue(field)
}
}
return record
}

View File

@@ -1,5 +1,5 @@
import type { ModelSchema } from '@/lib/schema-types'
import { validateField } from './validate-field'
import { validateField } from '../field/validate-field'
/**
* Validate a record against its model schema
@@ -12,7 +12,7 @@ export const validateRecord = (
record: any
): Record<string, string> => {
const errors: Record<string, string> = {}
for (const field of model.fields) {
if (field.editable === false) continue
const error = validateField(field, record[field.name])

View File

@@ -1,3 +1,6 @@
// Schema utilities exports
export * from './schema-utils'
export { defaultSchema } from './default-schema'
export * from './default/components'
export * from './default/forms'
export * from './default/validation'

View File

@@ -4,181 +4,12 @@
*/
import type { SecurityPattern } from '../types'
import { JAVASCRIPT_INJECTION_PATTERNS } from './javascript/injection'
import { JAVASCRIPT_MISC_PATTERNS } from './javascript/misc'
import { JAVASCRIPT_XSS_PATTERNS } from './javascript/xss'
export const JAVASCRIPT_PATTERNS: SecurityPattern[] = [
{
pattern: /eval\s*\(/gi,
type: 'dangerous',
severity: 'critical',
message: 'Use of eval() detected - can execute arbitrary code',
recommendation: 'Use safe alternatives like JSON.parse() or Function constructor with strict validation'
},
{
pattern: /Function\s*\(/gi,
type: 'dangerous',
severity: 'high',
message: 'Dynamic Function constructor detected',
recommendation: 'Avoid dynamic code generation or use with extreme caution'
},
{
pattern: /innerHTML\s*=/gi,
type: 'dangerous',
severity: 'high',
message: 'innerHTML assignment detected - XSS vulnerability risk',
recommendation: 'Use textContent, createElement, or React JSX instead'
},
{
pattern: /dangerouslySetInnerHTML/gi,
type: 'dangerous',
severity: 'high',
message: 'dangerouslySetInnerHTML detected - XSS vulnerability risk',
recommendation: 'Sanitize HTML content or use safe alternatives'
},
{
pattern: /document\.write\s*\(/gi,
type: 'dangerous',
severity: 'medium',
message: 'document.write() detected - can cause security issues',
recommendation: 'Use DOM manipulation methods instead'
},
{
pattern: /\.call\s*\(\s*window/gi,
type: 'suspicious',
severity: 'medium',
message: 'Calling functions with window context',
recommendation: 'Be careful with context manipulation'
},
{
pattern: /\.apply\s*\(\s*window/gi,
type: 'suspicious',
severity: 'medium',
message: 'Applying functions with window context',
recommendation: 'Be careful with context manipulation'
},
{
pattern: /__proto__/gi,
type: 'dangerous',
severity: 'critical',
message: 'Prototype pollution attempt detected',
recommendation: 'Never manipulate __proto__ directly'
},
{
pattern: /constructor\s*\[\s*['"]prototype['"]\s*\]/gi,
type: 'dangerous',
severity: 'critical',
message: 'Prototype manipulation detected',
recommendation: 'Use Object.create() or proper class syntax'
},
{
pattern: /import\s+.*\s+from\s+['"]https?:/gi,
type: 'dangerous',
severity: 'critical',
message: 'Remote code import detected',
recommendation: 'Only import from trusted, local sources'
},
{
pattern: /<script[^>]*>/gi,
type: 'dangerous',
severity: 'critical',
message: 'Script tag injection detected',
recommendation: 'Never inject script tags dynamically'
},
{
pattern: /on(click|load|error|mouseover|mouseout|focus|blur)\s*=/gi,
type: 'suspicious',
severity: 'medium',
message: 'Inline event handler detected',
recommendation: 'Use addEventListener or React event handlers'
},
{
pattern: /javascript:\s*/gi,
type: 'dangerous',
severity: 'high',
message: 'javascript: protocol detected',
recommendation: 'Never use javascript: protocol in URLs'
},
{
pattern: /data:\s*text\/html/gi,
type: 'dangerous',
severity: 'high',
message: 'Data URI with HTML detected',
recommendation: 'Avoid data URIs with executable content'
},
{
pattern: /setTimeout\s*\(\s*['"`]/gi,
type: 'dangerous',
severity: 'high',
message: 'setTimeout with string argument detected',
recommendation: 'Use setTimeout with function reference instead'
},
{
pattern: /setInterval\s*\(\s*['"`]/gi,
type: 'dangerous',
severity: 'high',
message: 'setInterval with string argument detected',
recommendation: 'Use setInterval with function reference instead'
},
{
pattern: /localStorage|sessionStorage/gi,
type: 'warning',
severity: 'low',
message: 'Local/session storage usage detected',
recommendation: 'Use useKV hook for persistent data instead'
},
{
pattern: /crypto\.subtle|atob|btoa/gi,
type: 'warning',
severity: 'low',
message: 'Cryptographic operation detected',
recommendation: 'Ensure proper key management and secure practices'
},
{
pattern: /XMLHttpRequest|fetch\s*\(\s*['"`]http/gi,
type: 'warning',
severity: 'medium',
message: 'External HTTP request detected',
recommendation: 'Ensure CORS and security headers are properly configured'
},
{
pattern: /window\.open/gi,
type: 'suspicious',
severity: 'medium',
message: 'window.open detected',
recommendation: 'Be cautious with popup windows'
},
{
pattern: /location\.href\s*=/gi,
type: 'suspicious',
severity: 'medium',
message: 'Direct location manipulation detected',
recommendation: 'Use React Router or validate URLs carefully'
},
{
pattern: /require\s*\(\s*[^'"`]/gi,
type: 'dangerous',
severity: 'high',
message: 'Dynamic require() detected',
recommendation: 'Use static imports only'
},
{
pattern: /\.exec\s*\(|child_process|spawn|fork|execFile/gi,
type: 'malicious',
severity: 'critical',
message: 'System command execution attempt detected',
recommendation: 'This is not allowed in browser environment'
},
{
pattern: /fs\.|path\.|os\./gi,
type: 'malicious',
severity: 'critical',
message: 'Node.js system module usage detected',
recommendation: 'File system access not allowed in browser'
},
{
pattern: /process\.env|process\.exit/gi,
type: 'suspicious',
severity: 'medium',
message: 'Process manipulation detected',
recommendation: 'Not applicable in browser environment'
}
...JAVASCRIPT_INJECTION_PATTERNS,
...JAVASCRIPT_XSS_PATTERNS,
...JAVASCRIPT_MISC_PATTERNS
]

View File

@@ -0,0 +1,53 @@
import type { SecurityPattern } from '../../types'
export const JAVASCRIPT_INJECTION_PATTERNS: SecurityPattern[] = [
{
pattern: /eval\s*\(/gi,
type: 'dangerous',
severity: 'critical',
message: 'Use of eval() detected - can execute arbitrary code',
recommendation: 'Use safe alternatives like JSON.parse() or Function constructor with strict validation'
},
{
pattern: /Function\s*\(/gi,
type: 'dangerous',
severity: 'high',
message: 'Dynamic Function constructor detected',
recommendation: 'Avoid dynamic code generation or use with extreme caution'
},
{
pattern: /import\s+.*\s+from\s+['"]https?:/gi,
type: 'dangerous',
severity: 'critical',
message: 'Remote code import detected',
recommendation: 'Only import from trusted, local sources'
},
{
pattern: /setTimeout\s*\(\s*['"`]/gi,
type: 'dangerous',
severity: 'high',
message: 'setTimeout with string argument detected',
recommendation: 'Use setTimeout with function reference instead'
},
{
pattern: /setInterval\s*\(\s*['"`]/gi,
type: 'dangerous',
severity: 'high',
message: 'setInterval with string argument detected',
recommendation: 'Use setInterval with function reference instead'
},
{
pattern: /require\s*\(\s*[^'"`]/gi,
type: 'dangerous',
severity: 'high',
message: 'Dynamic require() detected',
recommendation: 'Use static imports only'
},
{
pattern: /\.exec\s*\(|child_process|spawn|fork|execFile/gi,
type: 'malicious',
severity: 'critical',
message: 'System command execution attempt detected',
recommendation: 'This is not allowed in browser environment'
}
]

View File

@@ -0,0 +1,81 @@
import type { SecurityPattern } from '../../types'
export const JAVASCRIPT_MISC_PATTERNS: SecurityPattern[] = [
{
pattern: /\.call\s*\(\s*window/gi,
type: 'suspicious',
severity: 'medium',
message: 'Calling functions with window context',
recommendation: 'Be careful with context manipulation'
},
{
pattern: /\.apply\s*\(\s*window/gi,
type: 'suspicious',
severity: 'medium',
message: 'Applying functions with window context',
recommendation: 'Be careful with context manipulation'
},
{
pattern: /__proto__/gi,
type: 'dangerous',
severity: 'critical',
message: 'Prototype pollution attempt detected',
recommendation: 'Never manipulate __proto__ directly'
},
{
pattern: /constructor\s*\[\s*['"]prototype['"]\s*\]/gi,
type: 'dangerous',
severity: 'critical',
message: 'Prototype manipulation detected',
recommendation: 'Use Object.create() or proper class syntax'
},
{
pattern: /localStorage|sessionStorage/gi,
type: 'warning',
severity: 'low',
message: 'Local/session storage usage detected',
recommendation: 'Use useKV hook for persistent data instead'
},
{
pattern: /crypto\.subtle|atob|btoa/gi,
type: 'warning',
severity: 'low',
message: 'Cryptographic operation detected',
recommendation: 'Ensure proper key management and secure practices'
},
{
pattern: /XMLHttpRequest|fetch\s*\(\s*['"`]http/gi,
type: 'warning',
severity: 'medium',
message: 'External HTTP request detected',
recommendation: 'Ensure CORS and security headers are properly configured'
},
{
pattern: /window\.open/gi,
type: 'suspicious',
severity: 'medium',
message: 'window.open detected',
recommendation: 'Be cautious with popup windows'
},
{
pattern: /location\.href\s*=/gi,
type: 'suspicious',
severity: 'medium',
message: 'Direct location manipulation detected',
recommendation: 'Use React Router or validate URLs carefully'
},
{
pattern: /fs\.|path\.|os\./gi,
type: 'malicious',
severity: 'critical',
message: 'Node.js system module usage detected',
recommendation: 'File system access not allowed in browser'
},
{
pattern: /process\.env|process\.exit/gi,
type: 'suspicious',
severity: 'medium',
message: 'Process manipulation detected',
recommendation: 'Not applicable in browser environment'
}
]

View File

@@ -0,0 +1,53 @@
import type { SecurityPattern } from '../../types'
export const JAVASCRIPT_XSS_PATTERNS: SecurityPattern[] = [
{
pattern: /innerHTML\s*=/gi,
type: 'dangerous',
severity: 'high',
message: 'innerHTML assignment detected - XSS vulnerability risk',
recommendation: 'Use textContent, createElement, or React JSX instead'
},
{
pattern: /dangerouslySetInnerHTML/gi,
type: 'dangerous',
severity: 'high',
message: 'dangerouslySetInnerHTML detected - XSS vulnerability risk',
recommendation: 'Sanitize HTML content or use safe alternatives'
},
{
pattern: /document\.write\s*\(/gi,
type: 'dangerous',
severity: 'medium',
message: 'document.write() detected - can cause security issues',
recommendation: 'Use DOM manipulation methods instead'
},
{
pattern: /<script[^>]*>/gi,
type: 'dangerous',
severity: 'critical',
message: 'Script tag injection detected',
recommendation: 'Never inject script tags dynamically'
},
{
pattern: /on(click|load|error|mouseover|mouseout|focus|blur)\s*=/gi,
type: 'suspicious',
severity: 'medium',
message: 'Inline event handler detected',
recommendation: 'Use addEventListener or React event handlers'
},
{
pattern: /javascript:\s*/gi,
type: 'dangerous',
severity: 'high',
message: 'javascript: protocol detected',
recommendation: 'Never use javascript: protocol in URLs'
},
{
pattern: /data:\s*text\/html/gi,
type: 'dangerous',
severity: 'high',
message: 'Data URI with HTML detected',
recommendation: 'Avoid data URIs with executable content'
}
]

View File

@@ -0,0 +1,234 @@
import { describe, expect, it } from 'vitest'
import { scanForVulnerabilities, securityScanner } from '@/lib/security-scanner'
describe('security-scanner detection', () => {
describe('scanJavaScript', () => {
it.each([
{
name: 'flag eval usage as critical',
code: ['const safe = true;', 'const result = eval("1 + 1")'].join('\n'),
expectedSeverity: 'critical',
expectedSafe: false,
expectedIssueType: 'dangerous',
expectedIssuePattern: 'eval',
expectedLine: 2,
},
{
name: 'warn on localStorage usage but stay safe',
code: 'localStorage.setItem("k", "v")',
expectedSeverity: 'low',
expectedSafe: true,
expectedIssueType: 'warning',
expectedIssuePattern: 'localStorage',
},
{
name: 'return safe for benign code',
code: 'const sum = (a, b) => a + b',
expectedSeverity: 'safe',
expectedSafe: true,
},
])(
'should $name',
({ code, expectedSeverity, expectedSafe, expectedIssueType, expectedIssuePattern, expectedLine }) => {
const result = securityScanner.scanJavaScript(code)
expect(result.severity).toBe(expectedSeverity)
expect(result.safe).toBe(expectedSafe)
if (expectedIssueType || expectedIssuePattern) {
const issue = result.issues.find(item => {
const matchesType = expectedIssueType ? item.type === expectedIssueType : true
const matchesPattern = expectedIssuePattern ? item.pattern.includes(expectedIssuePattern) : true
return matchesType && matchesPattern
})
expect(issue).toBeDefined()
if (expectedLine !== undefined) {
expect(issue?.line).toBe(expectedLine)
}
} else {
expect(result.issues.length).toBe(0)
}
if (expectedSafe) {
expect(result.sanitizedCode).toBe(code)
} else {
expect(result.sanitizedCode).toBeUndefined()
}
}
)
})
describe('scanLua', () => {
it.each([
{
name: 'flag os.execute usage as critical',
code: 'os.execute("rm -rf /")',
expectedSeverity: 'critical',
expectedSafe: false,
expectedIssueType: 'malicious',
expectedIssuePattern: 'os.execute',
},
{
name: 'return safe for simple Lua function',
code: 'function add(a, b) return a + b end',
expectedSeverity: 'safe',
expectedSafe: true,
},
])('should $name', ({ code, expectedSeverity, expectedSafe, expectedIssueType, expectedIssuePattern }) => {
const result = securityScanner.scanLua(code)
expect(result.severity).toBe(expectedSeverity)
expect(result.safe).toBe(expectedSafe)
if (expectedIssueType || expectedIssuePattern) {
const issue = result.issues.find(item => {
const matchesType = expectedIssueType ? item.type === expectedIssueType : true
const matchesPattern = expectedIssuePattern ? item.pattern.includes(expectedIssuePattern) : true
return matchesType && matchesPattern
})
expect(issue).toBeDefined()
} else {
expect(result.issues.length).toBe(0)
}
if (expectedSafe) {
expect(result.sanitizedCode).toBe(code)
} else {
expect(result.sanitizedCode).toBeUndefined()
}
})
})
describe('scanJSON', () => {
it.each([
{
name: 'flag invalid JSON as medium severity',
json: '{"value": }',
expectedSeverity: 'medium',
expectedSafe: false,
expectedIssuePattern: 'JSON parse error',
},
{
name: 'flag prototype pollution in JSON as critical',
json: '{"__proto__": {"polluted": true}}',
expectedSeverity: 'critical',
expectedSafe: false,
expectedIssuePattern: '__proto__',
},
{
name: 'return safe for valid JSON',
json: '{"ok": true}',
expectedSeverity: 'safe',
expectedSafe: true,
},
])('should $name', ({ json, expectedSeverity, expectedSafe, expectedIssuePattern }) => {
const result = securityScanner.scanJSON(json)
expect(result.severity).toBe(expectedSeverity)
expect(result.safe).toBe(expectedSafe)
if (expectedIssuePattern) {
expect(result.issues.some(issue => issue.pattern.includes(expectedIssuePattern))).toBe(true)
} else {
expect(result.issues.length).toBe(0)
}
if (expectedSafe) {
expect(result.sanitizedCode).toBe(json)
} else {
expect(result.sanitizedCode).toBeUndefined()
}
})
})
describe('scanHTML', () => {
it.each([
{
name: 'flag script tags as critical',
html: '<div><script>alert(1)</script></div>',
expectedSeverity: 'critical',
expectedSafe: false,
},
{
name: 'flag inline handlers as high',
html: '<button onclick="alert(1)">Click</button>',
expectedSeverity: 'high',
expectedSafe: false,
},
{
name: 'return safe for plain markup',
html: '<div><span>Safe</span></div>',
expectedSeverity: 'safe',
expectedSafe: true,
},
])('should $name', ({ html, expectedSeverity, expectedSafe }) => {
const result = securityScanner.scanHTML(html)
expect(result.severity).toBe(expectedSeverity)
expect(result.safe).toBe(expectedSafe)
})
})
describe('sanitizeInput', () => {
it.each([
{
name: 'remove script tags and inline handlers from text',
input: '<div onclick="alert(1)">Click</div><script>alert(2)</script><a href="javascript:alert(3)">x</a>',
type: 'text' as const,
shouldExclude: ['<script', 'onclick', 'javascript:'],
},
{
name: 'remove data html URIs from html',
input: '<img src="data:text/html;base64,abc"><script>alert(1)</script>',
type: 'html' as const,
shouldExclude: ['data:text/html', '<script'],
},
{
name: 'neutralize prototype pollution in json',
input: '{"__proto__": {"polluted": true}, "note": "constructor[\\"prototype\\"]"}',
type: 'json' as const,
shouldInclude: ['_proto_'],
shouldExclude: ['__proto__', 'constructor["prototype"]'],
},
])('should $name', ({ input, type, shouldExclude = [], shouldInclude = [] }) => {
const sanitized = securityScanner.sanitizeInput(input, type)
shouldExclude.forEach(value => {
expect(sanitized).not.toContain(value)
})
shouldInclude.forEach(value => {
expect(sanitized).toContain(value)
})
})
})
describe('scanForVulnerabilities', () => {
it.each([
{
name: 'auto-detects JSON and flags prototype pollution',
code: '{"__proto__": {"polluted": true}}',
expectedSeverity: 'critical',
},
{
name: 'auto-detects Lua when function/end present',
code: 'function dangerous() os.execute("rm -rf /") end',
expectedSeverity: 'critical',
},
{
name: 'auto-detects HTML and flags script tags',
code: '<div><script>alert(1)</script></div>',
expectedSeverity: 'critical',
},
{
name: 'falls back to JavaScript scanning',
code: 'const result = eval("1 + 1")',
expectedSeverity: 'critical',
},
{
name: 'honors explicit type parameter',
code: 'return 1',
type: 'lua' as const,
expectedSeverity: 'safe',
},
])('should $name', ({ code, type, expectedSeverity }) => {
const result = scanForVulnerabilities(code, type)
expect(result.severity).toBe(expectedSeverity)
})
})
})

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { getSeverityColor, getSeverityIcon } from '@/lib/security-scanner'
describe('security-scanner reporting', () => {
describe('getSeverityColor', () => {
it.each([
{ severity: 'critical', expected: 'error' },
{ severity: 'high', expected: 'warning' },
{ severity: 'medium', expected: 'info' },
{ severity: 'low', expected: 'secondary' },
{ severity: 'safe', expected: 'success' },
])('should map $severity to expected classes', ({ severity, expected }) => {
expect(getSeverityColor(severity)).toBe(expected)
})
})
describe('getSeverityIcon', () => {
it.each([
{ severity: 'critical', expected: '\u{1F6A8}' },
{ severity: 'high', expected: '\u26A0\uFE0F' },
{ severity: 'medium', expected: '\u26A1' },
{ severity: 'low', expected: '\u2139\uFE0F' },
{ severity: 'safe', expected: '\u2713' },
])('should map $severity to expected icon', ({ severity, expected }) => {
expect(getSeverityIcon(severity)).toBe(expected)
})
})
})

View File

@@ -1,257 +1,2 @@
import { describe, it, expect } from 'vitest'
import { securityScanner, scanForVulnerabilities, getSeverityColor, getSeverityIcon } from '@/lib/security-scanner'
describe('security-scanner', () => {
describe('scanJavaScript', () => {
it.each([
{
name: 'flag eval usage as critical',
code: ['const safe = true;', 'const result = eval("1 + 1")'].join('\n'),
expectedSeverity: 'critical',
expectedSafe: false,
expectedIssueType: 'dangerous',
expectedIssuePattern: 'eval',
expectedLine: 2,
},
{
name: 'warn on localStorage usage but stay safe',
code: 'localStorage.setItem("k", "v")',
expectedSeverity: 'low',
expectedSafe: true,
expectedIssueType: 'warning',
expectedIssuePattern: 'localStorage',
},
{
name: 'return safe for benign code',
code: 'const sum = (a, b) => a + b',
expectedSeverity: 'safe',
expectedSafe: true,
},
])(
'should $name',
({ code, expectedSeverity, expectedSafe, expectedIssueType, expectedIssuePattern, expectedLine }) => {
const result = securityScanner.scanJavaScript(code)
expect(result.severity).toBe(expectedSeverity)
expect(result.safe).toBe(expectedSafe)
if (expectedIssueType || expectedIssuePattern) {
const issue = result.issues.find(item => {
const matchesType = expectedIssueType ? item.type === expectedIssueType : true
const matchesPattern = expectedIssuePattern ? item.pattern.includes(expectedIssuePattern) : true
return matchesType && matchesPattern
})
expect(issue).toBeDefined()
if (expectedLine !== undefined) {
expect(issue?.line).toBe(expectedLine)
}
} else {
expect(result.issues.length).toBe(0)
}
if (expectedSafe) {
expect(result.sanitizedCode).toBe(code)
} else {
expect(result.sanitizedCode).toBeUndefined()
}
}
)
})
describe('scanLua', () => {
it.each([
{
name: 'flag os.execute usage as critical',
code: 'os.execute("rm -rf /")',
expectedSeverity: 'critical',
expectedSafe: false,
expectedIssueType: 'malicious',
expectedIssuePattern: 'os.execute',
},
{
name: 'return safe for simple Lua function',
code: 'function add(a, b) return a + b end',
expectedSeverity: 'safe',
expectedSafe: true,
},
])('should $name', ({ code, expectedSeverity, expectedSafe, expectedIssueType, expectedIssuePattern }) => {
const result = securityScanner.scanLua(code)
expect(result.severity).toBe(expectedSeverity)
expect(result.safe).toBe(expectedSafe)
if (expectedIssueType || expectedIssuePattern) {
const issue = result.issues.find(item => {
const matchesType = expectedIssueType ? item.type === expectedIssueType : true
const matchesPattern = expectedIssuePattern ? item.pattern.includes(expectedIssuePattern) : true
return matchesType && matchesPattern
})
expect(issue).toBeDefined()
} else {
expect(result.issues.length).toBe(0)
}
if (expectedSafe) {
expect(result.sanitizedCode).toBe(code)
} else {
expect(result.sanitizedCode).toBeUndefined()
}
})
})
describe('scanJSON', () => {
it.each([
{
name: 'flag invalid JSON as medium severity',
json: '{"value": }',
expectedSeverity: 'medium',
expectedSafe: false,
expectedIssuePattern: 'JSON parse error',
},
{
name: 'flag prototype pollution in JSON as critical',
json: '{"__proto__": {"polluted": true}}',
expectedSeverity: 'critical',
expectedSafe: false,
expectedIssuePattern: '__proto__',
},
{
name: 'return safe for valid JSON',
json: '{"ok": true}',
expectedSeverity: 'safe',
expectedSafe: true,
},
])('should $name', ({ json, expectedSeverity, expectedSafe, expectedIssuePattern }) => {
const result = securityScanner.scanJSON(json)
expect(result.severity).toBe(expectedSeverity)
expect(result.safe).toBe(expectedSafe)
if (expectedIssuePattern) {
expect(result.issues.some(issue => issue.pattern.includes(expectedIssuePattern))).toBe(true)
} else {
expect(result.issues.length).toBe(0)
}
if (expectedSafe) {
expect(result.sanitizedCode).toBe(json)
} else {
expect(result.sanitizedCode).toBeUndefined()
}
})
})
describe('scanHTML', () => {
it.each([
{
name: 'flag script tags as critical',
html: '<div><script>alert(1)</script></div>',
expectedSeverity: 'critical',
expectedSafe: false,
},
{
name: 'flag inline handlers as high',
html: '<button onclick="alert(1)">Click</button>',
expectedSeverity: 'high',
expectedSafe: false,
},
{
name: 'return safe for plain markup',
html: '<div><span>Safe</span></div>',
expectedSeverity: 'safe',
expectedSafe: true,
},
])('should $name', ({ html, expectedSeverity, expectedSafe }) => {
const result = securityScanner.scanHTML(html)
expect(result.severity).toBe(expectedSeverity)
expect(result.safe).toBe(expectedSafe)
})
})
describe('sanitizeInput', () => {
it.each([
{
name: 'remove script tags and inline handlers from text',
input: '<div onclick="alert(1)">Click</div><script>alert(2)</script><a href="javascript:alert(3)">x</a>',
type: 'text' as const,
shouldExclude: ['<script', 'onclick', 'javascript:'],
},
{
name: 'remove data html URIs from html',
input: '<img src="data:text/html;base64,abc"><script>alert(1)</script>',
type: 'html' as const,
shouldExclude: ['data:text/html', '<script'],
},
{
name: 'neutralize prototype pollution in json',
input: '{"__proto__": {"polluted": true}, "note": "constructor[\\"prototype\\"]"}',
type: 'json' as const,
shouldInclude: ['_proto_'],
shouldExclude: ['__proto__', 'constructor["prototype"]'],
},
])('should $name', ({ input, type, shouldExclude = [], shouldInclude = [] }) => {
const sanitized = securityScanner.sanitizeInput(input, type)
shouldExclude.forEach(value => {
expect(sanitized).not.toContain(value)
})
shouldInclude.forEach(value => {
expect(sanitized).toContain(value)
})
})
})
describe('getSeverityColor', () => {
it.each([
{ severity: 'critical', expected: 'error' },
{ severity: 'high', expected: 'warning' },
{ severity: 'medium', expected: 'info' },
{ severity: 'low', expected: 'secondary' },
{ severity: 'safe', expected: 'success' },
])('should map $severity to expected classes', ({ severity, expected }) => {
expect(getSeverityColor(severity)).toBe(expected)
})
})
describe('getSeverityIcon', () => {
it.each([
{ severity: 'critical', expected: '\u{1F6A8}' },
{ severity: 'high', expected: '\u26A0\uFE0F' },
{ severity: 'medium', expected: '\u26A1' },
{ severity: 'low', expected: '\u2139\uFE0F' },
{ severity: 'safe', expected: '\u2713' },
])('should map $severity to expected icon', ({ severity, expected }) => {
expect(getSeverityIcon(severity)).toBe(expected)
})
})
describe('scanForVulnerabilities', () => {
it.each([
{
name: 'auto-detects JSON and flags prototype pollution',
code: '{"__proto__": {"polluted": true}}',
expectedSeverity: 'critical',
},
{
name: 'auto-detects Lua when function/end present',
code: 'function dangerous() os.execute("rm -rf /") end',
expectedSeverity: 'critical',
},
{
name: 'auto-detects HTML and flags script tags',
code: '<div><script>alert(1)</script></div>',
expectedSeverity: 'critical',
},
{
name: 'falls back to JavaScript scanning',
code: 'const result = eval("1 + 1")',
expectedSeverity: 'critical',
},
{
name: 'honors explicit type parameter',
code: 'return 1',
type: 'lua' as const,
expectedSeverity: 'safe',
},
])('should $name', ({ code, type, expectedSeverity }) => {
const result = scanForVulnerabilities(code, type)
expect(result.severity).toBe(expectedSeverity)
})
})
})
import './__tests__/security-scanner.detection.test'
import './__tests__/security-scanner.reporting.test'

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { WorkflowEngine } from '../workflow-engine'
import { createContext, createNode, createWorkflow } from './workflow-engine.fixtures'
describe('workflow-engine errors', () => {
it('fails unknown node types with a clear error', async () => {
const workflow = createWorkflow('err-1', 'Unknown node', [
createNode('mystery', 'unknown' as any, 'Mystery node'),
])
const result = await WorkflowEngine.execute(workflow, createContext({}))
expect(result.success).toBe(false)
expect(result.error).toContain('Unknown node type')
})
it('reports condition evaluation failures', async () => {
const workflow = createWorkflow('err-2', 'Bad condition', [
createNode('trigger', 'trigger', 'Start trigger'),
createNode('condition', 'condition', 'Broken', {
condition: '(() => { throw new Error("nope") })()',
}),
])
const result = await WorkflowEngine.execute(workflow, createContext({}))
expect(result.success).toBe(false)
expect(result.error).toContain('Condition evaluation failed')
expect(result.outputs.trigger).toEqual({})
})
it('stops after configured retries when a node keeps failing', async () => {
const workflow = createWorkflow('err-3', 'Retry failure', [
createNode('trigger', 'trigger', 'Start trigger'),
createNode('retry', 'transform', 'Keep failing', {
retry: { maxAttempts: 2, delayMs: 0 },
transform: '(() => { throw new Error("still failing") })()',
}),
])
const result = await WorkflowEngine.execute(workflow, createContext({ data: 1 }))
expect(result.success).toBe(false)
expect(result.error).toContain('Transform failed')
expect(result.logs.filter((log) => log.includes('Retrying node'))).toHaveLength(1)
})
it('propagates Lua script resolution errors', async () => {
const workflow = createWorkflow('err-4', 'Missing script', [
createNode('lua', 'lua', 'Lookup', { scriptId: 'missing-script' }),
])
const result = await WorkflowEngine.execute(workflow, createContext({}, { scripts: [] }))
expect(result.success).toBe(false)
expect(result.error).toContain('Script not found: missing-script')
})
})

View File

@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createWorkflowEngine, WorkflowEngine } from '../workflow-engine'
import { createContext, createNode, createWorkflow } from './workflow-engine.fixtures'
describe('workflow-engine execution', () => {
let engine: WorkflowEngine
beforeEach(() => {
engine = createWorkflowEngine()
})
it('executes nodes sequentially and returns aggregated outputs', async () => {
const workflow = createWorkflow('exec-1', 'Sequential run', [
createNode('trigger', 'trigger', 'Start trigger'),
createNode('transform', 'transform', 'Add one', { transform: 'data.value + 1' }),
createNode('action', 'action', 'Echo'),
])
const result = await engine.executeWorkflow(workflow, createContext({ value: 5 }))
expect(result.success).toBe(true)
expect(result.outputs.trigger).toEqual({ value: 5 })
expect(result.outputs.transform).toBe(6)
expect(result.outputs.action).toBe(6)
expect(result.logs.at(-1)).toContain('Workflow completed successfully')
})
it('stops execution after a false condition while keeping prior outputs', async () => {
const workflow = createWorkflow('exec-2', 'Early stop', [
createNode('trigger', 'trigger', 'Start trigger'),
createNode('condition', 'condition', 'Stopper', { condition: 'false' }),
createNode('action', 'action', 'Should not run'),
])
const result = await engine.executeWorkflow(workflow, createContext({}))
expect(result.success).toBe(true)
expect(result.outputs.action).toBeUndefined()
expect(Object.keys(result.outputs)).toHaveLength(2)
expect(result.logs.some((log) => log.includes('Condition node returned false'))).toBe(true)
})
it('passes user context through to Lua nodes', async () => {
const workflow = createWorkflow('exec-3', 'Lua context', [
createNode('lua', 'lua', 'User echo', { code: 'return context.user.id' }),
])
const context = createContext({}, { user: { id: 'user-123' } })
const result = await WorkflowEngine.execute(workflow, context)
expect(result.success).toBe(true)
expect(result.outputs.lua).toBe('user-123')
expect(result.logs[0]).toContain('Starting workflow: Lua context')
})
})

View File

@@ -0,0 +1,22 @@
import type { Workflow, WorkflowNode } from '../../../types/level-types'
import type { WorkflowExecutionContext } from '../../workflow-execution-context'
export function createNode(
id: string,
type: WorkflowNode['type'],
label: string,
config: Record<string, any> = {}
): WorkflowNode {
return { id, type, label, config, position: { x: 0, y: 0 } }
}
export function createWorkflow(id: string, name: string, nodes: WorkflowNode[]): Workflow {
return { id, name, nodes, edges: [], enabled: true }
}
export function createContext(
data: any = {},
overrides: Partial<WorkflowExecutionContext> = {}
): WorkflowExecutionContext {
return { data, ...overrides }
}

Some files were not shown because too many files have changed in this diff Show More