test-adjust-workflow-execution.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. import fs from 'fs/promises';
  2. import path from 'path';
  3. import { WorkflowExecutor } from './src/vl/workflow-executor.js';
  4. let passed = 0;
  5. let failed = 0;
  6. function test(name, fn) {
  7. return Promise.resolve()
  8. .then(fn)
  9. .then(() => {
  10. console.log(` ✓ ${name}`);
  11. passed++;
  12. })
  13. .catch((err) => {
  14. console.log(` ✗ ${name}: ${err.message}`);
  15. failed++;
  16. });
  17. }
  18. function assert(cond, msg) {
  19. if (!cond) throw new Error(msg || 'Assertion failed');
  20. }
  21. function json(value) {
  22. return JSON.stringify(value);
  23. }
  24. function normalizeMessages(messages = []) {
  25. return messages.map((msg) => ({
  26. role: msg.role,
  27. text: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
  28. }));
  29. }
  30. async function collectFiles(dir, prefix = '') {
  31. const entries = await fs.readdir(path.join(dir, prefix), { withFileTypes: true }).catch(() => []);
  32. const files = [];
  33. for (const entry of entries) {
  34. const rel = path.join(prefix, entry.name);
  35. if (entry.isDirectory()) files.push(...await collectFiles(dir, rel));
  36. else files.push(rel);
  37. }
  38. return files.sort();
  39. }
  40. function makeCurrentMeta() {
  41. return {
  42. specVersion: 'ProjectMeta/1.0',
  43. projectName: 'OpsSuite',
  44. vlVersion: '3.5',
  45. config: {
  46. defaultDevice: 'Phone',
  47. defaultResolution: '375x812',
  48. themeFile: 'Theme/BrandTheme.vth',
  49. colorScheme: 'light',
  50. },
  51. database: { file: 'Database/AppDB.vdb' },
  52. theme: { file: 'Theme/BrandTheme.vth' },
  53. services: [
  54. { id: 'ReportService', domainId: 'ReportService', file: 'Services/ReportService.vs', filePath: 'Services/ReportService.vs' },
  55. ],
  56. components: [
  57. { id: 'StatusCard', file: 'ExtComponents/StatusCard.cp', filePath: 'ExtComponents/StatusCard.cp' },
  58. ],
  59. sections: [
  60. { id: 'Dashboard', file: 'Sections/Dashboard.sc', filePath: 'Sections/Dashboard.sc' },
  61. ],
  62. apps: [
  63. { id: 'MainApp', file: 'Apps/MainApp.vx', filePath: 'Apps/MainApp.vx' },
  64. { id: 'AdminApp', file: 'Apps/AdminPortalShell.vx', filePath: 'Apps/AdminPortalShell.vx' },
  65. ],
  66. };
  67. }
  68. async function seedProject(workDir) {
  69. const files = {
  70. '.vl-code/ProjectMeta.json': json(makeCurrentMeta()),
  71. 'Database/AppDB.vdb': '// VL_VERSION:3.5\nDATABASE AppDB { }\n',
  72. 'Theme/BrandTheme.vth': '// VL_VERSION:3.5\nTHEME BrandTheme {\n COLOR primary = #005B4F\n}\n',
  73. 'Services/ReportService.vs': '// VL_VERSION:3.5\nSERVICE ReportService {\n METHOD list(): [OBJECT]\n}\n',
  74. 'Sections/Dashboard.sc': '// VL_VERSION:3.5\nSECTION Dashboard {\n INIT { }\n}\n',
  75. 'ExtComponents/StatusCard.cp': '// VL_VERSION:3.5\nCOMPONENT StatusCard {\n VIEW { Text("status") }\n}\n',
  76. 'Apps/MainApp.vx': '// VL_VERSION:3.5\nAPP MainApp {\n ROUTES { }\n}\n',
  77. 'Apps/AdminPortalShell.vx': '// VL_VERSION:3.5\nAPP AdminApp {\n ROUTES { path:\"/dashboard\" -> Dashboard }\n}\n',
  78. };
  79. for (const [rel, content] of Object.entries(files)) {
  80. const full = path.join(workDir, rel);
  81. await fs.mkdir(path.dirname(full), { recursive: true });
  82. await fs.writeFile(full, content, 'utf8');
  83. }
  84. }
  85. function createAdjustLLM() {
  86. let themeCascadeCount = 0;
  87. return {
  88. async call(params) {
  89. const allText = normalizeMessages(params.messages).map((msg) => msg.text).join('\n');
  90. if (allText.includes('Plan the new page. Determine')) {
  91. const updatedMeta = makeCurrentMeta();
  92. updatedMeta.sections = [
  93. ...updatedMeta.sections,
  94. {
  95. id: 'AnalyticsPage',
  96. file: 'Sections/AnalyticsPage.sc',
  97. filePath: 'Sections/AnalyticsPage.sc',
  98. consumesServices: ['ReportService'],
  99. usesComponents: ['KpiCard'],
  100. },
  101. ];
  102. updatedMeta.components = [
  103. ...updatedMeta.components,
  104. { id: 'KpiCard', file: 'ExtComponents/KpiCard.cp', filePath: 'ExtComponents/KpiCard.cp' },
  105. ];
  106. return {
  107. content: json({
  108. section: {
  109. id: 'AnalyticsPage',
  110. filePath: 'Sections/AnalyticsPage.sc',
  111. description: 'Analytics dashboard page',
  112. consumesServices: ['ReportService'],
  113. usesComponents: ['KpiCard'],
  114. bindings: [],
  115. events: [],
  116. },
  117. components: [
  118. { id: 'KpiCard', name: 'KpiCard', filePath: 'ExtComponents/KpiCard.cp', description: 'Summary KPI card' },
  119. ],
  120. service: {
  121. name: 'ReportService',
  122. domainId: 'ReportService',
  123. filePath: 'Services/ReportService.vs',
  124. methods: [{ id: 'getAnalytics' }],
  125. },
  126. targetApp: 'AdminApp',
  127. targetAppFilePath: 'Apps/AdminPortalShell.vx',
  128. route: '/analytics',
  129. updatedMeta,
  130. }),
  131. model: 'fake-adjust',
  132. usage: {},
  133. };
  134. }
  135. if (allText.includes('Plan the new service domain. Determine')) {
  136. const updatedMeta = makeCurrentMeta();
  137. updatedMeta.services = [
  138. ...updatedMeta.services,
  139. { id: 'AuditService', domainId: 'AuditService', file: 'Services/AuditService.vs', filePath: 'Services/AuditService.vs' },
  140. ];
  141. return {
  142. content: json({
  143. service: {
  144. name: 'AuditService',
  145. domainId: 'AuditService',
  146. filePath: 'Services/AuditService.vs',
  147. methods: [{ id: 'listAuditLogs' }],
  148. routes: [],
  149. },
  150. dbChanges: {
  151. newTables: [{ id: 'AuditLog' }],
  152. modifiedTables: [],
  153. },
  154. updatedMeta,
  155. }),
  156. model: 'fake-adjust',
  157. usage: {},
  158. };
  159. }
  160. if (allText.includes('Analyze the current theme and plan the changes')) {
  161. return {
  162. content: json({
  163. tokenChanges: [{ token: 'primary', oldValue: '#005B4F', newValue: '#0A7A68' }],
  164. affectedFiles: [
  165. { file: 'Sections/Dashboard.sc', reason: 'Uses primary theme token' },
  166. { file: 'ExtComponents/StatusCard.cp', reason: 'Uses primary theme token' },
  167. ],
  168. summary: 'Refresh primary accent color',
  169. }),
  170. model: 'fake-adjust',
  171. usage: {},
  172. };
  173. }
  174. if (allText.includes('Generate the .sc section file for this new section')) {
  175. return { content: '// VL_VERSION:3.5\nSECTION AnalyticsPage {\n INIT { }\n}\n', model: 'fake-adjust', usage: {} };
  176. }
  177. if (allText.includes('Generate the .cp component file for:')) {
  178. return { content: '// VL_VERSION:3.5\nCOMPONENT KpiCard {\n VIEW { Text("kpi") }\n}\n', model: 'fake-adjust', usage: {} };
  179. }
  180. if (allText.includes('Add or update the service domain with new methods for the page')) {
  181. return { content: '// VL_VERSION:3.5\nSERVICE ReportService {\n METHOD list(): [OBJECT]\n METHOD getAnalytics(): OBJECT\n}\n', model: 'fake-adjust', usage: {} };
  182. }
  183. if (allText.includes('Update the app .vx file to include the new page route')) {
  184. return { content: '// VL_VERSION:3.5\nAPP AdminApp {\n ROUTES { path:"/dashboard" -> Dashboard, path:"/analytics" -> AnalyticsPage }\n}\n', model: 'fake-adjust', usage: {} };
  185. }
  186. if (allText.includes('Update the .vdb database schema file')) {
  187. return { content: '// VL_VERSION:3.5\nDATABASE AppDB {\n TABLE AuditLog { id INT }\n}\n', model: 'fake-adjust', usage: {} };
  188. }
  189. if (allText.includes('Generate the .vs service domain file for:')) {
  190. return { content: '// VL_VERSION:3.5\nSERVICE AuditService {\n METHOD listAuditLogs(): [OBJECT]\n}\n', model: 'fake-adjust', usage: {} };
  191. }
  192. if (allText.includes('Generate the COMPLETE updated .vth theme file')) {
  193. return { content: '// VL_VERSION:3.5\nTHEME BrandTheme {\n COLOR primary = #0A7A68\n}\n', model: 'fake-adjust', usage: {} };
  194. }
  195. if (allText.includes('Update this file to use the new theme tokens')) {
  196. themeCascadeCount += 1;
  197. if (themeCascadeCount === 1) {
  198. return { content: '// VL_VERSION:3.5\nSECTION Dashboard {\n INIT { }\n VIEW { Text("dashboard") }\n}\n', model: 'fake-adjust', usage: {} };
  199. }
  200. return { content: '// VL_VERSION:3.5\nCOMPONENT StatusCard {\n VIEW { Text("status-updated") }\n}\n', model: 'fake-adjust', usage: {} };
  201. }
  202. throw new Error(`Unhandled fake LLM prompt: ${allText.slice(0, 220)}`);
  203. },
  204. };
  205. }
  206. async function runWorkflow(fileName, params) {
  207. const workflow = JSON.parse(await fs.readFile(path.join(process.cwd(), '.vl-code', 'workflows', fileName), 'utf8'));
  208. const workDir = path.join('/tmp/vlcode-lite-adjust-workflows', `${fileName.replace(/\.json$/, '')}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
  209. await fs.rm(workDir, { recursive: true, force: true });
  210. await fs.mkdir(workDir, { recursive: true });
  211. await seedProject(workDir);
  212. const executor = new WorkflowExecutor({ workDir, model: 'fake-adjust' });
  213. executor._resolveDocCenterDocs = async () => {};
  214. executor._buildLLMAdapter = function buildLLMAdapter() {
  215. return createAdjustLLM();
  216. };
  217. const errors = [];
  218. await executor.execute(workflow, params, {
  219. onNodeError: (evt) => errors.push(evt.error || evt),
  220. onError: (msg) => errors.push(msg),
  221. });
  222. return { workDir, files: await collectFiles(workDir), errors };
  223. }
  224. console.log('\n── Adjust Workflow Execution Regression ──');
  225. await test('add-page writes the planned target app file instead of dropping the route update', async () => {
  226. const currentMeta = makeCurrentMeta();
  227. const result = await runWorkflow('add-page.json', {
  228. pageDescription: 'Add an analytics page to the admin app',
  229. currentMeta,
  230. approved: true,
  231. });
  232. assert(result.errors.length === 0, `Unexpected errors: ${result.errors.join(', ')}`);
  233. assert(result.files.includes('Sections/AnalyticsPage.sc'), 'Expected new section file');
  234. assert(result.files.includes('ExtComponents/KpiCard.cp'), 'Expected new component file');
  235. assert(result.files.includes('Services/ReportService.vs'), 'Expected service file update');
  236. assert(result.files.includes('Apps/AdminPortalShell.vx'), 'Expected target app file update');
  237. });
  238. await test('add-service respects currentMeta.database.file instead of hardcoding Database/main.vdb', async () => {
  239. const currentMeta = makeCurrentMeta();
  240. const result = await runWorkflow('add-service.json', {
  241. serviceDescription: 'Add audit log backend service',
  242. currentMeta,
  243. });
  244. assert(result.errors.length === 0, `Unexpected errors: ${result.errors.join(', ')}`);
  245. assert(result.files.includes('Database/AppDB.vdb'), 'Expected dynamic database path');
  246. assert(!result.files.includes('Database/main.vdb'), 'Should not write hardcoded main.vdb');
  247. assert(result.files.includes('Services/AuditService.vs'), 'Expected generated service file');
  248. });
  249. await test('theme-customize writes both the theme file and cascade-updated VL files', async () => {
  250. const currentMeta = makeCurrentMeta();
  251. const result = await runWorkflow('theme-customize.json', {
  252. themeRequest: 'Refresh the brand color',
  253. currentMeta,
  254. });
  255. assert(result.errors.length === 0, `Unexpected errors: ${result.errors.join(', ')}`);
  256. assert(result.files.includes('Theme/BrandTheme.vth'), 'Expected dynamic theme file path');
  257. assert(result.files.includes('Sections/Dashboard.sc'), 'Expected updated section file');
  258. assert(result.files.includes('ExtComponents/StatusCard.cp'), 'Expected updated component file');
  259. });
  260. console.log(`\n── Results ──\n\n ${passed} passed, ${failed} failed\n`);
  261. process.exit(failed > 0 ? 1 : 0);