| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371 |
- /**
- * 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 };
- }
|