mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-30 08:44:57 +00:00
Merge branch 'main' into codex/create-dbal-and-irc-modules-and-components-mloc44
This commit is contained in:
8
dbal/development/src/core/client.ts
Normal file
8
dbal/development/src/core/client.ts
Normal 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 }
|
||||
24
dbal/development/src/core/client/builders.ts
Normal file
24
dbal/development/src/core/client/builders.ts
Normal 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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
25
dbal/development/src/core/client/mappers.ts
Normal file
25
dbal/development/src/core/client/mappers.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
64
detection/detectors/class-detector.ts
Normal file
64
detection/detectors/class-detector.ts
Normal 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
|
||||
}
|
||||
78
detection/detectors/function-detector.ts
Normal file
78
detection/detectors/function-detector.ts
Normal 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
45
detection/index.ts
Normal 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))
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
100
frontends/nextjs/src/hooks/__tests__/useAuth.roles.test.ts
Normal file
100
frontends/nextjs/src/hooks/__tests__/useAuth.roles.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { CssCategory } from '../../../../core/types'
|
||||
|
||||
export const buildAdvancedCssCategories = (): CssCategory[] => []
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { CssCategory } from '../../../../core/types'
|
||||
|
||||
export const buildExperimentalCssCategories = (): CssCategory[] => []
|
||||
@@ -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(),
|
||||
]
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
153
frontends/nextjs/src/lib/github/workflows/analysis/runs/stats.ts
Normal file
153
frontends/nextjs/src/lib/github/workflows/analysis/runs/stats.ts
Normal 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')
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { PackageTemplateConfig } from '../../types'
|
||||
|
||||
export const ADVANCED_PACKAGE_TEMPLATE_CONFIGS: PackageTemplateConfig[] = []
|
||||
267
frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/base.ts
Normal file
267
frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/base.ts
Normal 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'],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { PackageTemplateConfig } from '../../types'
|
||||
|
||||
export const EXPERIMENTAL_PACKAGE_TEMPLATE_CONFIGS: PackageTemplateConfig[] = []
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1 +1 @@
|
||||
export * from '../builder-types'
|
||||
export * from '@/lib/types/builder-types'
|
||||
|
||||
99
frontends/nextjs/src/lib/rendering/page/components.ts
Normal file
99
frontends/nextjs/src/lib/rendering/page/components.ts
Normal 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: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
29
frontends/nextjs/src/lib/rendering/page/utils.ts
Normal file
29
frontends/nextjs/src/lib/rendering/page/utils.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
3
frontends/nextjs/src/lib/schema-utils.ts
Normal file
3
frontends/nextjs/src/lib/schema-utils.ts
Normal 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'
|
||||
@@ -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
|
||||
|
||||
@@ -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()],
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
54
frontends/nextjs/src/lib/schema/default/components.ts
Normal file
54
frontends/nextjs/src/lib/schema/default/components.ts
Normal 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,
|
||||
},
|
||||
]
|
||||
244
frontends/nextjs/src/lib/schema/default/forms.ts
Normal file
244
frontends/nextjs/src/lib/schema/default/forms.ts
Normal 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,
|
||||
},
|
||||
]
|
||||
19
frontends/nextjs/src/lib/schema/default/validation.ts
Normal file
19
frontends/nextjs/src/lib/schema/default/validation.ts
Normal 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 },
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user