meta-test-generator.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. /**
  2. * Meta-Driven Test Skeleton Generator
  3. *
  4. * Deterministically generates test skeletons from ProjectMeta/1.0.
  5. * No LLM required — derives test structure from:
  6. * - apps (which apps exist, their IDs for preview URL lookup)
  7. * - sections (which pages exist, what services they consume)
  8. * - services (what domain operations are available via methods list)
  9. * - dataSchema (what data entities exist)
  10. *
  11. * Generated test cases are compatible with VLComponentTest "run" action steps.
  12. */
  13. /**
  14. * Convert a section ID to a human-readable nav label.
  15. * "AdminDashboardSection" → "Dashboard"
  16. * "CourseAdminSection" → "Course Admin"
  17. * "LibraryHomeSection" → "Library Home"
  18. * "LoginSection" → "Login"
  19. */
  20. function sectionIdToNavLabel(sectionId) {
  21. // Remove trailing "Section"
  22. let label = sectionId.replace(/Section$/, '');
  23. // Remove known app-role prefixes (AdminXxx → Xxx)
  24. label = label.replace(/^(Admin|Student|Teacher)([A-Z])/, '$2');
  25. // Split CamelCase
  26. label = label.replace(/([a-z])([A-Z])/g, '$1 $2');
  27. return label.trim() || sectionId;
  28. }
  29. /**
  30. * Detect CRUD capabilities from a service definition.
  31. * Falls back to ['list', 'create', 'update', 'delete'] when methods are empty.
  32. */
  33. function detectServiceOperations(service) {
  34. const methods = service.methods || [];
  35. if (methods.length === 0) {
  36. return ['list', 'create', 'update', 'delete'];
  37. }
  38. const ops = new Set();
  39. for (const m of methods) {
  40. const name = (typeof m === 'string' ? m : m.name || '').toLowerCase();
  41. if (/^(get|list|fetch|find|search|query|load)/.test(name)) ops.add('list');
  42. if (/^(create|add|insert|register|save|post|new)/.test(name)) ops.add('create');
  43. if (/^(update|edit|modify|change|set|put|patch)/.test(name)) ops.add('update');
  44. if (/^(delete|remove|destroy)/.test(name)) ops.add('delete');
  45. }
  46. return ops.size ? [...ops] : ['list'];
  47. }
  48. /**
  49. * Generate an app-level smoke test.
  50. * Verifies the app loads and renders interactive elements.
  51. */
  52. function generateAppSmokeTest(app, previewUrl, idx) {
  53. const appLabel = app.id.replace('App', ' App').trim();
  54. return {
  55. id: `WF_${idx}_${app.id}_Smoke`,
  56. name: `${appLabel} - Load & Render Smoke Test`,
  57. app: app.id,
  58. priority: 'P0',
  59. category: 'smoke',
  60. previewUrl,
  61. steps: [
  62. { action: 'open', url: previewUrl, description: `Open ${appLabel} preview` },
  63. { action: 'snapshot', description: 'Capture initial render state' },
  64. { action: 'screenshot', name: `${app.id}_smoke`, description: 'Screenshot after load' },
  65. ],
  66. };
  67. }
  68. /**
  69. * Generate a section navigation test.
  70. * Navigates to the section and asserts a key text element is visible.
  71. * If componentIndex provides a nav button instance-id for this section, use it directly.
  72. */
  73. function generateSectionNavTest(section, app, previewUrl, idx, isHome, componentIndex) {
  74. const navLabel = sectionIdToNavLabel(section.id);
  75. const steps = [
  76. { action: 'open', url: previewUrl, description: `Open ${app.id} preview` },
  77. ];
  78. if (!isHome) {
  79. // Look up nav button instance-id from componentIndex if available
  80. const navBtn = componentIndex?.findNavButton(section.id);
  81. steps.push(navBtn
  82. ? { action: 'click', selector: `vlid:${navBtn}`, description: `Navigate to ${navLabel} via nav (vlid:${navBtn})` }
  83. : { action: 'click', selector: `text=${navLabel}`, description: `Navigate to ${navLabel} via nav menu` }
  84. );
  85. }
  86. steps.push({
  87. action: 'assert',
  88. selector: `text=${navLabel}`,
  89. assertType: 'visible',
  90. description: `Verify ${navLabel} section is visible`,
  91. });
  92. steps.push({
  93. action: 'screenshot',
  94. name: `${section.id}_nav`,
  95. description: `Capture ${navLabel} section`,
  96. });
  97. return {
  98. id: `WF_${idx}_${section.id}_Nav`,
  99. name: `${navLabel} - Navigation Test`,
  100. app: app.id,
  101. section: section.id,
  102. priority: 'P1',
  103. category: 'navigation',
  104. previewUrl,
  105. steps,
  106. };
  107. }
  108. /**
  109. * Generate a CRUD workflow test for a section + service.
  110. * Uses instance-id selectors (vlid:xxx) when componentIndex provides them.
  111. * Steps marked with [SKELETON] still need selector updates via VLComponentTest listIds.
  112. */
  113. function generateCRUDWorkflowTest(section, serviceDomainId, app, previewUrl, ops, idx, componentIndex) {
  114. const navLabel = sectionIdToNavLabel(section.id);
  115. const entityName = serviceDomainId.replace(/Domain$/, '');
  116. const isHome = /dashboard|home/i.test(section.id);
  117. // Look up component IDs for this section if available
  118. const ci = componentIndex;
  119. const navBtn = ci?.findNavButton(section.id);
  120. const createBtn = ci?.findButton(section.id, /create|add|new/i);
  121. const editBtn = ci?.findButton(section.id, /edit|update|modify/i);
  122. const deleteBtn = ci?.findButton(section.id, /delete|remove/i);
  123. const steps = [
  124. { action: 'open', url: previewUrl, description: `Open ${app.id} preview` },
  125. ];
  126. if (!isHome) {
  127. steps.push(navBtn
  128. ? { action: 'click', selector: `vlid:${navBtn}`, description: `Navigate to ${navLabel} (vlid:${navBtn})` }
  129. : { action: 'click', selector: `text=${navLabel}`, description: `Navigate to ${navLabel}` }
  130. );
  131. }
  132. if (ops.includes('list')) {
  133. steps.push({
  134. action: 'snapshot',
  135. description: `Verify ${entityName} list — check snapshot for table/list instance-ids`,
  136. });
  137. steps.push({
  138. action: 'screenshot',
  139. name: `${section.id}_${entityName}_list`,
  140. description: `Capture ${entityName} list`,
  141. });
  142. }
  143. if (ops.includes('create')) {
  144. steps.push(createBtn
  145. ? { action: 'click', selector: `vlid:${createBtn}`, description: `Click create ${entityName} button (vlid:${createBtn})` }
  146. : { action: 'click', selector: `text=Create`, description: `[SKELETON] Click create ${entityName} — run listIds to get vlid:xxx selector` }
  147. );
  148. steps.push({
  149. action: 'screenshot',
  150. name: `${section.id}_${entityName}_create`,
  151. description: `Capture create ${entityName} form`,
  152. });
  153. }
  154. if (ops.includes('update')) {
  155. steps.push(editBtn
  156. ? { action: 'click', selector: `vlid:${editBtn}`, description: `Click edit ${entityName} (vlid:${editBtn})` }
  157. : { action: 'click', selector: `text=Edit`, description: `[SKELETON] Click edit ${entityName} — run listIds to get vlid:xxx selector` }
  158. );
  159. }
  160. if (ops.includes('delete')) {
  161. steps.push(deleteBtn
  162. ? { action: 'click', selector: `vlid:${deleteBtn}`, description: `Click delete ${entityName} (vlid:${deleteBtn}) — triggers confirm dialog` }
  163. : { action: 'click', selector: `text=Delete`, description: `[SKELETON] Click delete ${entityName} — run listIds to get vlid:xxx selector` }
  164. );
  165. // VL SysUI.showModal produces a native browser confirm — auto-accepted by VLComponentTest
  166. }
  167. const hasSkeletons = steps.some(s => s.description?.includes('[SKELETON]'));
  168. return {
  169. id: `WF_${idx}_${section.id}_${entityName}CRUD`,
  170. name: `${navLabel} - ${entityName} ${ops.join('+')} Workflow`,
  171. app: app.id,
  172. section: section.id,
  173. service: serviceDomainId,
  174. priority: 'P0',
  175. category: 'crud',
  176. previewUrl,
  177. ...(hasSkeletons ? {
  178. skeletonNotes: [
  179. '[SKELETON] selectors need vlid:xxx updates — run VLComponentTest {action:"listIds"} to get instance-ids',
  180. 'Delete steps trigger SysUI.showModal confirm dialog — auto-accepted by VLComponentTest browser',
  181. ],
  182. } : {}),
  183. steps,
  184. };
  185. }
  186. /**
  187. * Build a ComponentIndex helper from interactiveElements metadata.
  188. * Allows test generation to look up instance-ids for buttons/nav by section + intent.
  189. *
  190. * @param {Array} sections - array of { id, interactiveElements: [{instanceId, type, name, label}] }
  191. */
  192. function buildComponentIndex(sections) {
  193. // Map: sectionId → list of { instanceId, type, name, label }
  194. const bySection = new Map();
  195. for (const sec of (sections || [])) {
  196. const id = sec.id || sec.sectionId;
  197. if (!id) continue;
  198. const els = (sec.interactiveElements || sec.components || [])
  199. .filter(e => e.instanceId || e.compId)
  200. .map(e => ({
  201. instanceId: e.instanceId || e.compId,
  202. type: (e.type || '').toLowerCase(),
  203. name: (e.name || '').toLowerCase(),
  204. label: (e.label || '').toLowerCase(),
  205. }));
  206. if (els.length) bySection.set(id, els);
  207. }
  208. return {
  209. /** Find a nav/tab button for a given section by name heuristic */
  210. findNavButton(sectionId) {
  211. // Look in all sections for a button/tab whose name matches the section label
  212. const label = sectionIdToNavLabel(sectionId).toLowerCase();
  213. for (const [, els] of bySection) {
  214. const match = els.find(e =>
  215. (e.type === 'button' || e.type === 'tab' || e.type === 'navitem') &&
  216. (e.name.includes(label) || e.label.includes(label) || label.includes(e.name))
  217. );
  218. if (match) return match.instanceId;
  219. }
  220. return null;
  221. },
  222. /** Find a button in a specific section matching an intent regex */
  223. findButton(sectionId, intentRegex) {
  224. const els = bySection.get(sectionId) || [];
  225. const match = els.find(e =>
  226. e.type === 'button' &&
  227. (intentRegex.test(e.name) || intentRegex.test(e.label))
  228. );
  229. return match?.instanceId || null;
  230. },
  231. };
  232. }
  233. /**
  234. * Main skeleton generation function.
  235. *
  236. * @param {Object} meta - ProjectMeta/1.0 object
  237. * @param {Object} previewUrls - { AppId: "https://..." } map from project.json
  238. * @param {Object} [options]
  239. * @param {Array} [options.interactiveElements] - sections with instanceId component info for ID-based selectors
  240. * @returns {{ testCases: TestCase[], summary: Object }}
  241. */
  242. export function generateTestSkeleton(meta, previewUrls = {}, options = {}) {
  243. const testCases = [];
  244. let idx = 1;
  245. const apps = meta.apps || [];
  246. const sections = meta.sections || [];
  247. const services = meta.services || [];
  248. // Build service lookup: "Book" → BookDomain service, "BookDomain" → BookDomain service
  249. const serviceMap = new Map();
  250. for (const svc of services) {
  251. serviceMap.set(svc.domainId, svc);
  252. const shortKey = svc.domainId.replace(/Domain$/, '');
  253. serviceMap.set(shortKey, svc);
  254. }
  255. // Build component index for instance-id based selector generation
  256. const componentIndex = options.interactiveElements
  257. ? buildComponentIndex(options.interactiveElements)
  258. : null;
  259. // Identify system services to skip for CRUD tests
  260. const SYSTEM_SERVICES = new Set(['TOAST', 'SYSTEM', 'SysUI', 'Router', 'Nav']);
  261. // 1. App-level smoke tests (one per app with a preview URL)
  262. for (const app of apps) {
  263. const url = previewUrls[app.id];
  264. if (!url) continue;
  265. testCases.push(generateAppSmokeTest(app, url, String(idx++).padStart(3, '0')));
  266. }
  267. // 2. Per-app section navigation + CRUD tests
  268. for (const app of apps) {
  269. const url = previewUrls[app.id];
  270. if (!url) continue;
  271. // Match sections to this app by naming convention.
  272. // First try app.pages[].sections (populated by VLMetadata in newer projects).
  273. // Fall back to heuristic: sections whose ID contains the app role name.
  274. const appRole = app.id.replace('App', '').toLowerCase(); // "admin", "student", "teacher"
  275. const listedSectionIds = new Set(
  276. (app.pages || []).flatMap(p => p.sections || []).map(s => (typeof s === 'string' ? s : s.id))
  277. );
  278. let appSections;
  279. if (listedSectionIds.size > 0) {
  280. // Explicit listing from app.pages — most accurate
  281. appSections = sections.filter(s => listedSectionIds.has(s.id));
  282. } else {
  283. // Heuristic: section ID starts with OR contains the app role
  284. appSections = sections.filter(s => {
  285. const sid = s.id.toLowerCase();
  286. if (sid.startsWith(appRole) || sid.includes(appRole)) return true;
  287. // Shared sections appear in all apps
  288. if (/^(login|profile|notification)/.test(sid)) return true;
  289. return false;
  290. });
  291. }
  292. if (appSections.length === 0) continue;
  293. // Home section = first dashboard/home, or first section overall
  294. const homeSection =
  295. appSections.find(s => /dashboard|home/i.test(s.id)) || appSections[0];
  296. // Cap at 8 sections per app to avoid too many tests
  297. for (const section of appSections.slice(0, 8)) {
  298. const isHome = section === homeSection;
  299. const idxStr = String(idx++).padStart(3, '0');
  300. // Navigation test
  301. testCases.push(generateSectionNavTest(section, app, url, idxStr, isHome, componentIndex));
  302. // CRUD test for primary (first non-system) service
  303. const primaryService = (section.consumesServices || []).find(
  304. s => !SYSTEM_SERVICES.has(s)
  305. );
  306. if (primaryService) {
  307. const svc = serviceMap.get(primaryService) || serviceMap.get(primaryService + 'Domain');
  308. const ops = svc ? detectServiceOperations(svc) : ['list'];
  309. const domainId = svc?.domainId || (primaryService + 'Domain');
  310. const idxStr2 = String(idx++).padStart(3, '0');
  311. testCases.push(generateCRUDWorkflowTest(section, domainId, app, url, ops, idxStr2, componentIndex));
  312. }
  313. }
  314. }
  315. const summary = {
  316. projectName: meta.projectName || 'Unknown',
  317. generatedAt: new Date().toISOString(),
  318. totalTests: testCases.length,
  319. byCategory: {
  320. smoke: testCases.filter(t => t.category === 'smoke').length,
  321. navigation: testCases.filter(t => t.category === 'navigation').length,
  322. crud: testCases.filter(t => t.category === 'crud').length,
  323. },
  324. byPriority: {
  325. P0: testCases.filter(t => t.priority === 'P0').length,
  326. P1: testCases.filter(t => t.priority === 'P1').length,
  327. P2: testCases.filter(t => t.priority === 'P2').length,
  328. },
  329. appsWithUrls: apps.filter(a => previewUrls[a.id]).map(a => a.id),
  330. instructions: [
  331. '1. Run VLComponentTest {action:"listIds"} on each page to discover real selectors',
  332. '2. Update selectors marked [SKELETON] with actual button/nav text from the app',
  333. '3. Use VLMetaTest {action:"save"} to persist skeletons to .vl-code/test-skeletons/',
  334. '4. Run tests with VLComponentTest {action:"run", steps:[...]}',
  335. '5. Multi-option selectors (text=A, text=B) are invalid — pick ONE per step',
  336. ],
  337. };
  338. return { testCases, summary };
  339. }