/** * Meta-Driven Test Skeleton Generator * * Deterministically generates test skeletons from ProjectMeta/1.0. * No LLM required — derives test structure from: * - apps (which apps exist, their IDs for preview URL lookup) * - sections (which pages exist, what services they consume) * - services (what domain operations are available via methods list) * - dataSchema (what data entities exist) * * Generated test cases are compatible with VLComponentTest "run" action steps. */ /** * Convert a section ID to a human-readable nav label. * "AdminDashboardSection" → "Dashboard" * "CourseAdminSection" → "Course Admin" * "LibraryHomeSection" → "Library Home" * "LoginSection" → "Login" */ function sectionIdToNavLabel(sectionId) { // Remove trailing "Section" let label = sectionId.replace(/Section$/, ''); // Remove known app-role prefixes (AdminXxx → Xxx) label = label.replace(/^(Admin|Student|Teacher)([A-Z])/, '$2'); // Split CamelCase label = label.replace(/([a-z])([A-Z])/g, '$1 $2'); return label.trim() || sectionId; } /** * Detect CRUD capabilities from a service definition. * Falls back to ['list', 'create', 'update', 'delete'] when methods are empty. */ function detectServiceOperations(service) { const methods = service.methods || []; if (methods.length === 0) { return ['list', 'create', 'update', 'delete']; } const ops = new Set(); for (const m of methods) { const name = (typeof m === 'string' ? m : m.name || '').toLowerCase(); if (/^(get|list|fetch|find|search|query|load)/.test(name)) ops.add('list'); if (/^(create|add|insert|register|save|post|new)/.test(name)) ops.add('create'); if (/^(update|edit|modify|change|set|put|patch)/.test(name)) ops.add('update'); if (/^(delete|remove|destroy)/.test(name)) ops.add('delete'); } return ops.size ? [...ops] : ['list']; } /** * Generate an app-level smoke test. * Verifies the app loads and renders interactive elements. */ function generateAppSmokeTest(app, previewUrl, idx) { const appLabel = app.id.replace('App', ' App').trim(); return { id: `WF_${idx}_${app.id}_Smoke`, name: `${appLabel} - Load & Render Smoke Test`, app: app.id, priority: 'P0', category: 'smoke', previewUrl, steps: [ { action: 'open', url: previewUrl, description: `Open ${appLabel} preview` }, { action: 'snapshot', description: 'Capture initial render state' }, { action: 'screenshot', name: `${app.id}_smoke`, description: 'Screenshot after load' }, ], }; } /** * Generate a section navigation test. * Navigates to the section and asserts a key text element is visible. * If componentIndex provides a nav button instance-id for this section, use it directly. */ function generateSectionNavTest(section, app, previewUrl, idx, isHome, componentIndex) { const navLabel = sectionIdToNavLabel(section.id); const steps = [ { action: 'open', url: previewUrl, description: `Open ${app.id} preview` }, ]; if (!isHome) { // Look up nav button instance-id from componentIndex if available const navBtn = componentIndex?.findNavButton(section.id); steps.push(navBtn ? { action: 'click', selector: `vlid:${navBtn}`, description: `Navigate to ${navLabel} via nav (vlid:${navBtn})` } : { action: 'click', selector: `text=${navLabel}`, description: `Navigate to ${navLabel} via nav menu` } ); } steps.push({ action: 'assert', selector: `text=${navLabel}`, assertType: 'visible', description: `Verify ${navLabel} section is visible`, }); steps.push({ action: 'screenshot', name: `${section.id}_nav`, description: `Capture ${navLabel} section`, }); return { id: `WF_${idx}_${section.id}_Nav`, name: `${navLabel} - Navigation Test`, app: app.id, section: section.id, priority: 'P1', category: 'navigation', previewUrl, steps, }; } /** * Generate a CRUD workflow test for a section + service. * Uses instance-id selectors (vlid:xxx) when componentIndex provides them. * Steps marked with [SKELETON] still need selector updates via VLComponentTest listIds. */ function generateCRUDWorkflowTest(section, serviceDomainId, app, previewUrl, ops, idx, componentIndex) { const navLabel = sectionIdToNavLabel(section.id); const entityName = serviceDomainId.replace(/Domain$/, ''); const isHome = /dashboard|home/i.test(section.id); // Look up component IDs for this section if available const ci = componentIndex; const navBtn = ci?.findNavButton(section.id); const createBtn = ci?.findButton(section.id, /create|add|new/i); const editBtn = ci?.findButton(section.id, /edit|update|modify/i); const deleteBtn = ci?.findButton(section.id, /delete|remove/i); const steps = [ { action: 'open', url: previewUrl, description: `Open ${app.id} preview` }, ]; if (!isHome) { steps.push(navBtn ? { action: 'click', selector: `vlid:${navBtn}`, description: `Navigate to ${navLabel} (vlid:${navBtn})` } : { action: 'click', selector: `text=${navLabel}`, description: `Navigate to ${navLabel}` } ); } if (ops.includes('list')) { steps.push({ action: 'snapshot', description: `Verify ${entityName} list — check snapshot for table/list instance-ids`, }); steps.push({ action: 'screenshot', name: `${section.id}_${entityName}_list`, description: `Capture ${entityName} list`, }); } if (ops.includes('create')) { steps.push(createBtn ? { action: 'click', selector: `vlid:${createBtn}`, description: `Click create ${entityName} button (vlid:${createBtn})` } : { action: 'click', selector: `text=Create`, description: `[SKELETON] Click create ${entityName} — run listIds to get vlid:xxx selector` } ); steps.push({ action: 'screenshot', name: `${section.id}_${entityName}_create`, description: `Capture create ${entityName} form`, }); } if (ops.includes('update')) { steps.push(editBtn ? { action: 'click', selector: `vlid:${editBtn}`, description: `Click edit ${entityName} (vlid:${editBtn})` } : { action: 'click', selector: `text=Edit`, description: `[SKELETON] Click edit ${entityName} — run listIds to get vlid:xxx selector` } ); } if (ops.includes('delete')) { steps.push(deleteBtn ? { action: 'click', selector: `vlid:${deleteBtn}`, description: `Click delete ${entityName} (vlid:${deleteBtn}) — triggers confirm dialog` } : { action: 'click', selector: `text=Delete`, description: `[SKELETON] Click delete ${entityName} — run listIds to get vlid:xxx selector` } ); // VL SysUI.showModal produces a native browser confirm — auto-accepted by VLComponentTest } const hasSkeletons = steps.some(s => s.description?.includes('[SKELETON]')); return { id: `WF_${idx}_${section.id}_${entityName}CRUD`, name: `${navLabel} - ${entityName} ${ops.join('+')} Workflow`, app: app.id, section: section.id, service: serviceDomainId, priority: 'P0', category: 'crud', previewUrl, ...(hasSkeletons ? { skeletonNotes: [ '[SKELETON] selectors need vlid:xxx updates — run VLComponentTest {action:"listIds"} to get instance-ids', 'Delete steps trigger SysUI.showModal confirm dialog — auto-accepted by VLComponentTest browser', ], } : {}), steps, }; } /** * Build a ComponentIndex helper from interactiveElements metadata. * Allows test generation to look up instance-ids for buttons/nav by section + intent. * * @param {Array} sections - array of { id, interactiveElements: [{instanceId, type, name, label}] } */ function buildComponentIndex(sections) { // Map: sectionId → list of { instanceId, type, name, label } const bySection = new Map(); for (const sec of (sections || [])) { const id = sec.id || sec.sectionId; if (!id) continue; const els = (sec.interactiveElements || sec.components || []) .filter(e => e.instanceId || e.compId) .map(e => ({ instanceId: e.instanceId || e.compId, type: (e.type || '').toLowerCase(), name: (e.name || '').toLowerCase(), label: (e.label || '').toLowerCase(), })); if (els.length) bySection.set(id, els); } return { /** Find a nav/tab button for a given section by name heuristic */ findNavButton(sectionId) { // Look in all sections for a button/tab whose name matches the section label const label = sectionIdToNavLabel(sectionId).toLowerCase(); for (const [, els] of bySection) { const match = els.find(e => (e.type === 'button' || e.type === 'tab' || e.type === 'navitem') && (e.name.includes(label) || e.label.includes(label) || label.includes(e.name)) ); if (match) return match.instanceId; } return null; }, /** Find a button in a specific section matching an intent regex */ findButton(sectionId, intentRegex) { const els = bySection.get(sectionId) || []; const match = els.find(e => e.type === 'button' && (intentRegex.test(e.name) || intentRegex.test(e.label)) ); return match?.instanceId || null; }, }; } /** * Main skeleton generation function. * * @param {Object} meta - ProjectMeta/1.0 object * @param {Object} previewUrls - { AppId: "https://..." } map from project.json * @param {Object} [options] * @param {Array} [options.interactiveElements] - sections with instanceId component info for ID-based selectors * @returns {{ testCases: TestCase[], summary: Object }} */ export function generateTestSkeleton(meta, previewUrls = {}, options = {}) { const testCases = []; let idx = 1; const apps = meta.apps || []; const sections = meta.sections || []; const services = meta.services || []; // Build service lookup: "Book" → BookDomain service, "BookDomain" → BookDomain service const serviceMap = new Map(); for (const svc of services) { serviceMap.set(svc.domainId, svc); const shortKey = svc.domainId.replace(/Domain$/, ''); serviceMap.set(shortKey, svc); } // Build component index for instance-id based selector generation const componentIndex = options.interactiveElements ? buildComponentIndex(options.interactiveElements) : null; // Identify system services to skip for CRUD tests const SYSTEM_SERVICES = new Set(['TOAST', 'SYSTEM', 'SysUI', 'Router', 'Nav']); // 1. App-level smoke tests (one per app with a preview URL) for (const app of apps) { const url = previewUrls[app.id]; if (!url) continue; testCases.push(generateAppSmokeTest(app, url, String(idx++).padStart(3, '0'))); } // 2. Per-app section navigation + CRUD tests for (const app of apps) { const url = previewUrls[app.id]; if (!url) continue; // Match sections to this app by naming convention. // First try app.pages[].sections (populated by VLMetadata in newer projects). // Fall back to heuristic: sections whose ID contains the app role name. const appRole = app.id.replace('App', '').toLowerCase(); // "admin", "student", "teacher" const listedSectionIds = new Set( (app.pages || []).flatMap(p => p.sections || []).map(s => (typeof s === 'string' ? s : s.id)) ); let appSections; if (listedSectionIds.size > 0) { // Explicit listing from app.pages — most accurate appSections = sections.filter(s => listedSectionIds.has(s.id)); } else { // Heuristic: section ID starts with OR contains the app role appSections = sections.filter(s => { const sid = s.id.toLowerCase(); if (sid.startsWith(appRole) || sid.includes(appRole)) return true; // Shared sections appear in all apps if (/^(login|profile|notification)/.test(sid)) return true; return false; }); } if (appSections.length === 0) continue; // Home section = first dashboard/home, or first section overall const homeSection = appSections.find(s => /dashboard|home/i.test(s.id)) || appSections[0]; // Cap at 8 sections per app to avoid too many tests for (const section of appSections.slice(0, 8)) { const isHome = section === homeSection; const idxStr = String(idx++).padStart(3, '0'); // Navigation test testCases.push(generateSectionNavTest(section, app, url, idxStr, isHome, componentIndex)); // CRUD test for primary (first non-system) service const primaryService = (section.consumesServices || []).find( s => !SYSTEM_SERVICES.has(s) ); if (primaryService) { const svc = serviceMap.get(primaryService) || serviceMap.get(primaryService + 'Domain'); const ops = svc ? detectServiceOperations(svc) : ['list']; const domainId = svc?.domainId || (primaryService + 'Domain'); const idxStr2 = String(idx++).padStart(3, '0'); testCases.push(generateCRUDWorkflowTest(section, domainId, app, url, ops, idxStr2, componentIndex)); } } } const summary = { projectName: meta.projectName || 'Unknown', generatedAt: new Date().toISOString(), totalTests: testCases.length, byCategory: { smoke: testCases.filter(t => t.category === 'smoke').length, navigation: testCases.filter(t => t.category === 'navigation').length, crud: testCases.filter(t => t.category === 'crud').length, }, byPriority: { P0: testCases.filter(t => t.priority === 'P0').length, P1: testCases.filter(t => t.priority === 'P1').length, P2: testCases.filter(t => t.priority === 'P2').length, }, appsWithUrls: apps.filter(a => previewUrls[a.id]).map(a => a.id), instructions: [ '1. Run VLComponentTest {action:"listIds"} on each page to discover real selectors', '2. Update selectors marked [SKELETON] with actual button/nav text from the app', '3. Use VLMetaTest {action:"save"} to persist skeletons to .vl-code/test-skeletons/', '4. Run tests with VLComponentTest {action:"run", steps:[...]}', '5. Multi-option selectors (text=A, text=B) are invalid — pick ONE per step', ], }; return { testCases, summary }; }