diff --git a/tests/dashboard-business-flex-state.test.js b/tests/dashboard-business-flex-state.test.js new file mode 100644 index 0000000..cbe1ec0 --- /dev/null +++ b/tests/dashboard-business-flex-state.test.js @@ -0,0 +1,461 @@ +/** + * Tests for: + * - src/observability/dashboard/state/business-flex.js (buildBusinessFlexState) + * - src/observability/dashboard/state/client-state.js (toClientState additions) + * + * owner: RStack developed by Richardson Gunde + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildBusinessFlexState } from '../src/observability/dashboard/state/business-flex.js'; +import { toClientState } from '../src/observability/dashboard/state/client-state.js'; + +// --------------------------------------------------------------------------- +// buildBusinessFlexState — empty / null inputs +// --------------------------------------------------------------------------- + +test('buildBusinessFlexState returns empty structure for empty array', () => { + const result = buildBusinessFlexState([]); + assert.deepEqual(result.profiles, []); + assert.equal(result.budget.runBudgetTotal, 0); + assert.equal(result.budget.estimatedTaskBudget, 0); + assert.equal(result.budget.tasksWithBudget, 0); + assert.deepEqual(result.routingSignals, []); +}); + +test('buildBusinessFlexState returns empty structure for null input', () => { + const result = buildBusinessFlexState(null); + assert.deepEqual(result.profiles, []); + assert.equal(result.budget.runBudgetTotal, 0); + assert.deepEqual(result.routingSignals, []); +}); + +test('buildBusinessFlexState returns empty structure for undefined input', () => { + const result = buildBusinessFlexState(undefined); + assert.deepEqual(result.profiles, []); + assert.equal(result.budget.tasksWithBudget, 0); +}); + +// --------------------------------------------------------------------------- +// buildBusinessFlexState — profile aggregation +// --------------------------------------------------------------------------- + +test('buildBusinessFlexState extracts profile from run.profile.profile', () => { + const runs = [{ + runId: 'run-001', + profile: { + profile: 'business-flex', + name: 'Business Flex Delivery', + workflow: 'production-business-sdlc', + enabled_domains: ['product', 'backend', 'qa'], + enabled_agents: ['business-analyst', 'backend-architect'], + enabled_plugins: ['backend-development', 'unit-testing'], + dashboard_pages: ['command', 'business-flex'], + }, + workflow: 'production-business-sdlc', + budgetPolicy: { run_budget_usd: 10 }, + tasks: [], + }]; + const result = buildBusinessFlexState(runs); + assert.equal(result.profiles.length, 1); + assert.equal(result.profiles[0].profile, 'business-flex'); + assert.equal(result.profiles[0].name, 'Business Flex Delivery'); + assert.equal(result.profiles[0].workflow, 'production-business-sdlc'); + assert.equal(result.profiles[0].runs, 1); + assert.deepEqual(result.profiles[0].enabledDomains, ['product', 'backend', 'qa']); + assert.deepEqual(result.profiles[0].enabledAgents, ['business-analyst', 'backend-architect']); + assert.deepEqual(result.profiles[0].enabledPlugins, ['backend-development', 'unit-testing']); + assert.deepEqual(result.profiles[0].dashboardPages, ['command', 'business-flex']); +}); + +test('buildBusinessFlexState falls back to manifest.profile when run.profile is empty', () => { + const runs = [{ + runId: 'run-002', + profile: null, + manifest: { profile: 'lean-mvp', workflow: 'lean-mvp-sdlc' }, + budgetPolicy: null, + tasks: [], + }]; + const result = buildBusinessFlexState(runs); + assert.equal(result.profiles.length, 1); + assert.equal(result.profiles[0].profile, 'lean-mvp'); +}); + +test('buildBusinessFlexState uses unprofiled for runs with no profile data', () => { + const runs = [{ + runId: 'run-003', + profile: {}, + manifest: {}, + tasks: [], + }]; + const result = buildBusinessFlexState(runs); + assert.equal(result.profiles[0].profile, 'unprofiled'); +}); + +test('buildBusinessFlexState increments runs count for same profile across multiple runs', () => { + const makeRun = (id) => ({ + runId: id, + profile: { + profile: 'business-flex', + enabled_domains: ['product'], + enabled_agents: [], + enabled_plugins: [], + dashboard_pages: [], + }, + budgetPolicy: { run_budget_usd: 10 }, + tasks: [], + }); + const result = buildBusinessFlexState([makeRun('run-1'), makeRun('run-2'), makeRun('run-3')]); + assert.equal(result.profiles.length, 1); + assert.equal(result.profiles[0].runs, 3); +}); + +test('buildBusinessFlexState aggregates domains from multiple runs into a set', () => { + const runs = [ + { + runId: 'run-a', + profile: { profile: 'business-flex', enabled_domains: ['product', 'backend'], enabled_agents: [], enabled_plugins: [], dashboard_pages: [] }, + budgetPolicy: null, tasks: [], + }, + { + runId: 'run-b', + profile: { profile: 'business-flex', enabled_domains: ['backend', 'qa'], enabled_agents: [], enabled_plugins: [], dashboard_pages: [] }, + budgetPolicy: null, tasks: [], + }, + ]; + const result = buildBusinessFlexState(runs); + const domains = result.profiles[0].enabledDomains; + // Set deduplication: product, backend, qa (no duplicate backend) + assert.ok(domains.includes('product')); + assert.ok(domains.includes('backend')); + assert.ok(domains.includes('qa')); + assert.equal(domains.filter((d) => d === 'backend').length, 1); +}); + +test('buildBusinessFlexState groups separate profiles into separate entries', () => { + const runs = [ + { + runId: 'run-flex', + profile: { profile: 'business-flex', enabled_domains: ['product'], enabled_agents: [], enabled_plugins: [], dashboard_pages: [] }, + budgetPolicy: { run_budget_usd: 10 }, tasks: [], + }, + { + runId: 'run-lean', + profile: { profile: 'lean-mvp', enabled_domains: ['backend'], enabled_agents: [], enabled_plugins: [], dashboard_pages: [] }, + budgetPolicy: { run_budget_usd: 5 }, tasks: [], + }, + ]; + const result = buildBusinessFlexState(runs); + assert.equal(result.profiles.length, 2); + const profileIds = result.profiles.map((p) => p.profile); + assert.ok(profileIds.includes('business-flex')); + assert.ok(profileIds.includes('lean-mvp')); +}); + +// --------------------------------------------------------------------------- +// buildBusinessFlexState — budget aggregation +// --------------------------------------------------------------------------- + +test('buildBusinessFlexState sums run budget totals across runs', () => { + const runs = [ + { + runId: 'run-1', + profile: { profile: 'business-flex', enabled_domains: [], enabled_agents: [], enabled_plugins: [], dashboard_pages: [] }, + budgetPolicy: { run_budget_usd: 10 }, + tasks: [], + }, + { + runId: 'run-2', + profile: { profile: 'lean-mvp', enabled_domains: [], enabled_agents: [], enabled_plugins: [], dashboard_pages: [] }, + budgetPolicy: { run_budget_usd: 5 }, + tasks: [], + }, + ]; + const result = buildBusinessFlexState(runs); + assert.equal(result.budget.runBudgetTotal, 15); +}); + +test('buildBusinessFlexState sums task budget envelopes', () => { + const runs = [{ + runId: 'run-budget', + profile: { profile: 'business-flex', enabled_domains: [], enabled_agents: [], enabled_plugins: [], dashboard_pages: [] }, + budgetPolicy: { run_budget_usd: 10 }, + tasks: [ + { id: 'task-1', budget_envelope: { currency: 'USD', estimated_ai_cost_usd: 2 } }, + { id: 'task-2', budget_envelope: { currency: 'USD', estimated_ai_cost_usd: 3.5 } }, + { id: 'task-3' }, // no budget_envelope + ], + }]; + const result = buildBusinessFlexState(runs); + assert.equal(result.budget.tasksWithBudget, 2); + assert.equal(result.budget.estimatedTaskBudget, 5.5); +}); + +test('buildBusinessFlexState handles missing budgetPolicy gracefully', () => { + const runs = [{ + runId: 'run-no-budget', + profile: { profile: 'business-flex', enabled_domains: [], enabled_agents: [], enabled_plugins: [], dashboard_pages: [] }, + budgetPolicy: null, + tasks: [], + }]; + const result = buildBusinessFlexState(runs); + assert.equal(result.budget.runBudgetTotal, 0); +}); + +// --------------------------------------------------------------------------- +// buildBusinessFlexState — routing signals +// --------------------------------------------------------------------------- + +test('buildBusinessFlexState collects routing signals from tasks', () => { + const runs = [{ + runId: 'run-routing', + projectRoot: '/projects/my-app', + profile: { profile: 'business-flex', enabled_domains: [], enabled_agents: [], enabled_plugins: [], dashboard_pages: [] }, + budgetPolicy: null, + tasks: [ + { + id: '02-requirements', + title: 'Requirements', + profile: 'business-flex', + routing: { + selected_by: 'profile-domain-stage-affinity', + explanation: ['profile:business-flex', 'stage-domains:product,docs'], + }, + specialists: ['business-analyst', 'product-manager'], + budget_envelope: { currency: 'USD', estimated_ai_cost_usd: 1 }, + }, + ], + }]; + const result = buildBusinessFlexState(runs); + assert.equal(result.routingSignals.length, 1); + const signal = result.routingSignals[0]; + assert.equal(signal.runId, 'run-routing'); + assert.equal(signal.taskId, '02-requirements'); + assert.equal(signal.title, 'Requirements'); + assert.equal(signal.profile, 'business-flex'); + assert.equal(signal.selectedBy, 'profile-domain-stage-affinity'); + assert.ok(signal.explanation.includes('profile:business-flex')); + assert.ok(signal.specialists.includes('business-analyst')); + assert.ok(signal.budget !== null); +}); + +test('buildBusinessFlexState skips tasks without routing data', () => { + const runs = [{ + runId: 'run-no-routing', + profile: { profile: 'business-flex', enabled_domains: [], enabled_agents: [], enabled_plugins: [], dashboard_pages: [] }, + budgetPolicy: null, + tasks: [ + { id: 'task-no-routing', title: 'Plain task' }, // no routing field + ], + }]; + const result = buildBusinessFlexState(runs); + assert.equal(result.routingSignals.length, 0); +}); + +test('buildBusinessFlexState caps routing signals at 80', () => { + const tasks = Array.from({ length: 100 }, (_, i) => ({ + id: `task-${i}`, + title: `Task ${i}`, + routing: { selected_by: 'profile-domain-stage-affinity', explanation: [] }, + })); + const runs = [{ + runId: 'run-many-tasks', + profile: { profile: 'business-flex', enabled_domains: [], enabled_agents: [], enabled_plugins: [], dashboard_pages: [] }, + budgetPolicy: null, + tasks, + }]; + const result = buildBusinessFlexState(runs); + assert.equal(result.routingSignals.length, 80); +}); + +test('buildBusinessFlexState caps routing explanation at 8 items per task', () => { + const longExplanation = Array.from({ length: 15 }, (_, i) => `explanation-item-${i}`); + const runs = [{ + runId: 'run-long-explanation', + profile: { profile: 'business-flex', enabled_domains: [], enabled_agents: [], enabled_plugins: [], dashboard_pages: [] }, + budgetPolicy: null, + tasks: [{ + id: 'task-01', + title: 'Task', + routing: { selected_by: 'routed', explanation: longExplanation }, + }], + }]; + const result = buildBusinessFlexState(runs); + assert.equal(result.routingSignals[0].explanation.length, 8); +}); + +test('buildBusinessFlexState caps specialists at 8 items per task', () => { + const manySpecialists = Array.from({ length: 12 }, (_, i) => `specialist-${i}`); + const runs = [{ + runId: 'run-many-specialists', + profile: { profile: 'business-flex', enabled_domains: [], enabled_agents: [], enabled_plugins: [], dashboard_pages: [] }, + budgetPolicy: null, + tasks: [{ + id: 'task-01', + routing: { selected_by: 'routed', explanation: [] }, + specialists: manySpecialists, + }], + }]; + const result = buildBusinessFlexState(runs); + assert.equal(result.routingSignals[0].specialists.length, 8); +}); + +test('buildBusinessFlexState routing signal uses task.profile when available, otherwise profile entry id', () => { + const runs = [{ + runId: 'run-profile-fallback', + profile: { profile: 'business-flex', enabled_domains: [], enabled_agents: [], enabled_plugins: [], dashboard_pages: [] }, + budgetPolicy: null, + tasks: [ + { + id: 'task-with-profile', + routing: { selected_by: 'routed', explanation: [] }, + profile: 'lean-mvp', // task has its own profile + }, + { + id: 'task-no-profile', + routing: { selected_by: 'routed', explanation: [] }, + // no profile field — should fall back to parent profileId + }, + ], + }]; + const result = buildBusinessFlexState(runs); + assert.equal(result.routingSignals[0].profile, 'lean-mvp'); + assert.equal(result.routingSignals[1].profile, 'business-flex'); +}); + +// --------------------------------------------------------------------------- +// toClientState — new fields added in this PR +// --------------------------------------------------------------------------- + +test('toClientState passes through workflow, budgetPolicy, and profile on runs', () => { + const state = { + runs: [{ + manifest: { run_id: 'run-001', started_by: { name: 'Alice' } }, + events: [], + evidence: [], + workflow: 'production-business-sdlc', + budgetPolicy: { currency: 'USD', run_budget_usd: 10 }, + profile: { profile: 'business-flex', name: 'Business Flex Delivery' }, + tasks: [], + }], + }; + const client = toClientState(state); + const run = client.runs[0]; + assert.equal(run.workflow, 'production-business-sdlc'); + assert.deepEqual(run.budgetPolicy, { currency: 'USD', run_budget_usd: 10 }); + assert.equal(run.profile.profile, 'business-flex'); +}); + +test('toClientState passes through routing and budget_envelope on tasks', () => { + const routing = { + selected_by: 'profile-domain-stage-affinity', + explanation: ['profile:business-flex'], + }; + const budgetEnvelope = { currency: 'USD', estimated_ai_cost_usd: 2 }; + const state = { + runs: [{ + manifest: { run_id: 'run-002' }, + events: [], + evidence: [], + tasks: [{ + id: 'task-01', + title: 'Build API', + status: 'PENDING', + routing, + budget_envelope: budgetEnvelope, + }], + }], + }; + const client = toClientState(state); + const task = client.runs[0].tasks[0]; + assert.deepEqual(task.routing, routing); + assert.deepEqual(task.budget_envelope, budgetEnvelope); +}); + +test('toClientState includes businessFlex in output', () => { + const businessFlex = { + profiles: [{ profile: 'business-flex', runs: 1, enabledDomains: ['product'] }], + budget: { runBudgetTotal: 10, estimatedTaskBudget: 2, tasksWithBudget: 1 }, + routingSignals: [{ taskId: 'task-01', profile: 'business-flex' }], + }; + const state = { + runs: [], + businessFlex, + }; + const client = toClientState(state); + assert.deepEqual(client.businessFlex, businessFlex); +}); + +test('toClientState provides empty businessFlex default when state.businessFlex is absent', () => { + const state = { runs: [] }; + const client = toClientState(state); + assert.deepEqual(client.businessFlex, { profiles: [], budget: {}, routingSignals: [] }); +}); + +test('toClientState provides empty businessFlex default when state.businessFlex is null', () => { + const state = { runs: [], businessFlex: null }; + const client = toClientState(state); + assert.deepEqual(client.businessFlex, { profiles: [], budget: {}, routingSignals: [] }); +}); + +test('toClientState strips events and evidence from runs but keeps workflow/profile/budgetPolicy', () => { + const state = { + runs: [{ + manifest: { run_id: 'run-strip' }, + events: [{ ts: '2026-01-01', type: 'run_started' }], + evidence: [{ ts: '2026-01-01', kind: 'test', status: 'PASS' }], + workflow: 'lean-mvp-sdlc', + budgetPolicy: { run_budget_usd: 5 }, + profile: { profile: 'lean-mvp' }, + tasks: [], + }], + }; + const client = toClientState(state); + const run = client.runs[0]; + // events and evidence should be stripped from top-level run fields + assert.ok(!('events' in run)); + assert.ok(!('evidence' in run)); + // but evidence metadata is present + assert.equal(run.evidenceCount, 1); + // profile/workflow/budgetPolicy preserved + assert.equal(run.workflow, 'lean-mvp-sdlc'); + assert.equal(run.profile.profile, 'lean-mvp'); +}); + +test('toClientState handles runs with missing workflow/profile/budgetPolicy gracefully', () => { + const state = { + runs: [{ + manifest: { run_id: 'run-missing' }, + events: [], + evidence: [], + tasks: [], + // workflow, budgetPolicy, profile intentionally omitted + }], + }; + const client = toClientState(state); + const run = client.runs[0]; + assert.equal(run.workflow, undefined); + assert.equal(run.budgetPolicy, undefined); + assert.equal(run.profile, undefined); +}); + +test('toClientState handles tasks with no routing or budget_envelope', () => { + const state = { + runs: [{ + manifest: { run_id: 'run-plain-tasks' }, + events: [], + evidence: [], + tasks: [{ + id: 'plain-task', + title: 'Plain', + status: 'PENDING', + // routing and budget_envelope intentionally absent + }], + }], + }; + const client = toClientState(state); + const task = client.runs[0].tasks[0]; + assert.equal(task.routing, undefined); + assert.equal(task.budget_envelope, undefined); +}); \ No newline at end of file diff --git a/tests/harness-contracts.test.js b/tests/harness-contracts.test.js index 2808295..50ba66e 100644 --- a/tests/harness-contracts.test.js +++ b/tests/harness-contracts.test.js @@ -74,6 +74,165 @@ test('builder contract accepts optional Contract v2 execution telemetry', () => assert.ok(result.checks.some((check) => check.name === 'builder_v2_cost_values_are_numeric')); }); +test('builder contract v2 fields fail when present as non-objects', () => { + // execution, cost, context, routing must be plain objects when present + const base = { + task_id: '004-implementation', + agent: 'builder', + status: 'PASS', + summary: 'Contract v2 non-object edge case.', + files_modified: [], + tests_run: [], + risks: [], + next_steps: [], + }; + + for (const field of ['execution', 'cost', 'context', 'routing']) { + // array value — should fail is_object check + const withArray = { ...base, [field]: ['not', 'an', 'object'] }; + const arrayResult = validateBuilderContract(withArray, '004-implementation'); + const arrayCheck = arrayResult.checks.find((c) => c.name === `builder_v2_${field}_is_object`); + assert.ok(arrayCheck, `check builder_v2_${field}_is_object should exist for array value`); + assert.equal(arrayCheck.status, 'FAIL', `${field} as array should fail is_object`); + + // string value — should fail is_object check + const withString = { ...base, [field]: 'just-a-string' }; + const stringResult = validateBuilderContract(withString, '004-implementation'); + const stringCheck = stringResult.checks.find((c) => c.name === `builder_v2_${field}_is_object`); + assert.ok(stringCheck, `check builder_v2_${field}_is_object should exist for string value`); + assert.equal(stringCheck.status, 'FAIL', `${field} as string should fail is_object`); + + // null value — should fail is_object check + const withNull = { ...base, [field]: null }; + const nullResult = validateBuilderContract(withNull, '004-implementation'); + const nullCheck = nullResult.checks.find((c) => c.name === `builder_v2_${field}_is_object`); + assert.ok(nullCheck, `check builder_v2_${field}_is_object should exist for null value`); + assert.equal(nullCheck.status, 'FAIL', `${field} as null should fail is_object`); + + // valid object — should pass + const withObject = { ...base, [field]: { key: 'value' } }; + const objectResult = validateBuilderContract(withObject, '004-implementation'); + const objectCheck = objectResult.checks.find((c) => c.name === `builder_v2_${field}_is_object`); + assert.ok(objectCheck, `check builder_v2_${field}_is_object should exist for object value`); + assert.equal(objectCheck.status, 'PASS', `${field} as object should pass`); + } +}); + +test('builder contract v2 execution.tools_used fails when not an array', () => { + const base = { + task_id: '004-implementation', + agent: 'builder', + status: 'PASS', + summary: 'Testing tools_used types.', + files_modified: [], + tests_run: [], + risks: [], + next_steps: [], + }; + + // string — should fail + const withString = { ...base, execution: { tools_used: 'read_file,edit' } }; + const stringResult = validateBuilderContract(withString, '004-implementation'); + const stringCheck = stringResult.checks.find((c) => c.name === 'builder_v2_execution_tools_used_is_array'); + assert.ok(stringCheck, 'builder_v2_execution_tools_used_is_array check should exist'); + assert.equal(stringCheck.status, 'FAIL'); + + // object — should fail + const withObj = { ...base, execution: { tools_used: { read: true } } }; + const objResult = validateBuilderContract(withObj, '004-implementation'); + assert.equal(objResult.checks.find((c) => c.name === 'builder_v2_execution_tools_used_is_array').status, 'FAIL'); + + // valid array — should pass with correct evidence + const withArray = { ...base, execution: { tools_used: ['read_file', 'patch', 'bash'] } }; + const arrayResult = validateBuilderContract(withArray, '004-implementation'); + const arrayCheck = arrayResult.checks.find((c) => c.name === 'builder_v2_execution_tools_used_is_array'); + assert.equal(arrayCheck.status, 'PASS'); + assert.ok(arrayCheck.evidence.includes('3 tool(s)')); + + // empty array — still a valid array + const withEmpty = { ...base, execution: { tools_used: [] } }; + const emptyResult = validateBuilderContract(withEmpty, '004-implementation'); + assert.equal(emptyResult.checks.find((c) => c.name === 'builder_v2_execution_tools_used_is_array').status, 'PASS'); +}); + +test('builder contract v2 cost values fail when non-numeric', () => { + const base = { + task_id: '004-implementation', + agent: 'builder', + status: 'PASS', + summary: 'Testing cost numeric validation.', + files_modified: [], + tests_run: [], + risks: [], + next_steps: [], + }; + + // string values — should fail + const withStringCost = { ...base, cost: { estimated_usd: 'high', actual_usd: 'unknown' } }; + const stringResult = validateBuilderContract(withStringCost, '004-implementation'); + const stringCheck = stringResult.checks.find((c) => c.name === 'builder_v2_cost_values_are_numeric'); + assert.ok(stringCheck, 'builder_v2_cost_values_are_numeric check should exist'); + assert.equal(stringCheck.status, 'FAIL'); + + // only estimated_usd present and numeric — should pass + const withOnlyEstimated = { ...base, cost: { currency: 'USD', estimated_usd: 1.5 } }; + const estimatedResult = validateBuilderContract(withOnlyEstimated, '004-implementation'); + const estimatedCheck = estimatedResult.checks.find((c) => c.name === 'builder_v2_cost_values_are_numeric'); + assert.ok(estimatedCheck); + assert.equal(estimatedCheck.status, 'PASS'); + + // only actual_usd present and numeric — should pass + const withOnlyActual = { ...base, cost: { currency: 'USD', actual_usd: 0.75 } }; + const actualResult = validateBuilderContract(withOnlyActual, '004-implementation'); + assert.equal(actualResult.checks.find((c) => c.name === 'builder_v2_cost_values_are_numeric').status, 'PASS'); + + // both present and numeric — should pass + const withBoth = { ...base, cost: { currency: 'USD', estimated_usd: 2.0, actual_usd: 1.8 } }; + const bothResult = validateBuilderContract(withBoth, '004-implementation'); + assert.equal(bothResult.checks.find((c) => c.name === 'builder_v2_cost_values_are_numeric').status, 'PASS'); + assert.equal(bothResult.ok, true); + + // estimated_usd is a non-numeric string but actual_usd is null (not present) — only estimated checked + const withBadEstimated = { ...base, cost: { estimated_usd: 'NaN-ish' } }; + const badEstimatedResult = validateBuilderContract(withBadEstimated, '004-implementation'); + assert.equal(badEstimatedResult.checks.find((c) => c.name === 'builder_v2_cost_values_are_numeric').status, 'FAIL'); +}); + +test('builder contract v2 cost check is skipped when both cost values are absent', () => { + const contract = { + task_id: '004-implementation', + agent: 'builder', + status: 'PASS', + summary: 'No cost info.', + files_modified: [], + tests_run: [], + risks: [], + next_steps: [], + cost: { currency: 'USD' }, // no estimated_usd or actual_usd + }; + const result = validateBuilderContract(contract, '004-implementation'); + assert.ok(result.ok); + // cost check should NOT be generated when both values are absent + assert.ok(!result.checks.some((c) => c.name === 'builder_v2_cost_values_are_numeric')); +}); + +test('builder contract v2 checks are absent when v2 fields are not present', () => { + const minimalContract = { + task_id: '004-implementation', + agent: 'builder', + status: 'PASS', + summary: 'Minimal contract with no v2 telemetry.', + files_modified: [], + tests_run: [], + risks: [], + next_steps: [], + }; + const result = validateBuilderContract(minimalContract, '004-implementation'); + assert.equal(result.ok, true); + const v2CheckNames = result.checks.filter((c) => c.name.startsWith('builder_v2_')); + assert.equal(v2CheckNames.length, 0, 'no v2 checks should be generated for minimal contracts'); +}); + test('validator contract requires all Harness fields', () => { const valid = { task_id: '004-implementation', diff --git a/tests/integrations-init.test.js b/tests/integrations-init.test.js index 3f7dc9d..edd8a3a 100644 --- a/tests/integrations-init.test.js +++ b/tests/integrations-init.test.js @@ -103,6 +103,77 @@ test('init framework detection and setup', async (t) => { assert.deepEqual([...FRAMEWORKS], ['pi', 'claude-code', 'operator', 'custom']); }); + await t.test('init with lean-mvp profile writes correct profile and budget files', async () => { + const root = tmpProject('rstack-init-lean-'); + const report = await initFramework(root, 'custom', { profile: 'lean-mvp' }); + assert.equal(report.profile, 'lean-mvp'); + const profile = JSON.parse(readFileSync(join(root, '.rstack', 'rstack.config.json'), 'utf8')); + const budget = JSON.parse(readFileSync(join(root, '.rstack', 'budget.json'), 'utf8')); + assert.equal(profile.profile, 'lean-mvp'); + assert.equal(profile.workflow, 'lean-mvp-sdlc'); + assert.ok(profile.enabled_domains.includes('product')); + assert.ok(!profile.enabled_domains.includes('devops'), 'lean-mvp should not include devops'); + assert.equal(budget.run_budget_usd, 5); + assert.equal(budget.require_approval_above_usd, 10); + rmSync(root, { recursive: true, force: true }); + }); + + await t.test('init with enterprise-webapp profile writes correct profile and budget files', async () => { + const root = tmpProject('rstack-init-ent-'); + const report = await initFramework(root, 'custom', { profile: 'enterprise-webapp' }); + assert.equal(report.profile, 'enterprise-webapp'); + const profile = JSON.parse(readFileSync(join(root, '.rstack', 'rstack.config.json'), 'utf8')); + const budget = JSON.parse(readFileSync(join(root, '.rstack', 'budget.json'), 'utf8')); + assert.equal(profile.profile, 'enterprise-webapp'); + assert.equal(profile.workflow, 'enterprise-webapp-sdlc'); + assert.ok(profile.enabled_domains.includes('security')); + assert.ok(profile.enabled_plugins.includes('security-scanning')); + assert.equal(budget.run_budget_usd, 25); + assert.equal(budget.require_approval_above_usd, 50); + rmSync(root, { recursive: true, force: true }); + }); + + await t.test('init defaults to business-flex profile when profile option is omitted', async () => { + const root = tmpProject('rstack-init-default-profile-'); + const report = await initFramework(root, 'custom'); + assert.equal(report.profile, 'business-flex'); + const profile = JSON.parse(readFileSync(join(root, '.rstack', 'rstack.config.json'), 'utf8')); + assert.equal(profile.profile, 'business-flex'); + rmSync(root, { recursive: true, force: true }); + }); + + await t.test('init report includes profile name in nextSteps', async () => { + const root = tmpProject('rstack-init-nextsteps-'); + const report = await initFramework(root, 'custom', { profile: 'lean-mvp' }); + assert.ok(report.nextSteps.some((step) => step.includes('lean-mvp')), 'nextSteps should mention the active profile'); + assert.ok(report.nextSteps.some((step) => step.includes('rstack.config.json')), 'nextSteps should mention config file'); + assert.ok(report.nextSteps.some((step) => step.includes('budget.json')), 'nextSteps should mention budget file'); + rmSync(root, { recursive: true, force: true }); + }); + + await t.test('init with unknown profile falls back to business-flex', async () => { + const root = tmpProject('rstack-init-unknown-profile-'); + const report = await initFramework(root, 'custom', { profile: 'not-a-real-profile' }); + // profileConfig falls back to business-flex for unknown names + assert.equal(report.profile, 'business-flex'); + const profile = JSON.parse(readFileSync(join(root, '.rstack', 'rstack.config.json'), 'utf8')); + assert.equal(profile.profile, 'business-flex'); + rmSync(root, { recursive: true, force: true }); + }); + + await t.test('init profile files are skipped on second run (idempotent)', async () => { + const root = tmpProject('rstack-init-idempotent-profile-'); + await initFramework(root, 'custom', { profile: 'lean-mvp' }); + // Modify the files to prove they are not overwritten + writeFileSync(join(root, '.rstack', 'rstack.config.json'), JSON.stringify({ profile: 'modified' })); + const second = await initFramework(root, 'custom', { profile: 'lean-mvp' }); + assert.ok(second.skipped.some((item) => item.includes('rstack.config.json')), 'rstack.config.json should be skipped on second run'); + assert.ok(second.skipped.some((item) => item.includes('budget.json')), 'budget.json should be skipped on second run'); + const profile = JSON.parse(readFileSync(join(root, '.rstack', 'rstack.config.json'), 'utf8')); + assert.equal(profile.profile, 'modified', 'manually modified profile should not be overwritten'); + rmSync(root, { recursive: true, force: true }); + }); + rmSync(registryDir, { recursive: true, force: true }); if (previousRegistryDir) process.env.RSTACK_REGISTRY_DIR = previousRegistryDir; else delete process.env.RSTACK_REGISTRY_DIR; diff --git a/tests/profiles.test.js b/tests/profiles.test.js new file mode 100644 index 0000000..1a8ea9e --- /dev/null +++ b/tests/profiles.test.js @@ -0,0 +1,428 @@ +/** + * Tests for src/core/profiles.js + * Covers: BUILT_IN_PROFILES, profileConfig, budgetPolicyForProfile, + * loadProjectProfile, loadBudgetPolicy, budgetEnvelopeForTask + * + * owner: RStack developed by Richardson Gunde + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + BUILT_IN_PROFILES, + profileConfig, + budgetPolicyForProfile, + loadProjectProfile, + loadBudgetPolicy, + budgetEnvelopeForTask, +} from '../src/core/profiles.js'; + +function tmpProject() { + return mkdtempSync(join(tmpdir(), 'rstack-profiles-')); +} + +// --------------------------------------------------------------------------- +// BUILT_IN_PROFILES +// --------------------------------------------------------------------------- + +test('BUILT_IN_PROFILES contains the three expected profile keys', () => { + const keys = Object.keys(BUILT_IN_PROFILES); + assert.ok(keys.includes('business-flex')); + assert.ok(keys.includes('enterprise-webapp')); + assert.ok(keys.includes('lean-mvp')); + assert.equal(keys.length, 3); +}); + +test('BUILT_IN_PROFILES is frozen and cannot be mutated', () => { + assert.ok(Object.isFrozen(BUILT_IN_PROFILES)); + // Attempt to add a property + assert.throws(() => { + 'use strict'; + BUILT_IN_PROFILES['new-profile'] = {}; + }); +}); + +test('business-flex profile has required structure fields', () => { + const profile = BUILT_IN_PROFILES['business-flex']; + assert.equal(profile.profile, 'business-flex'); + assert.equal(profile.workflow, 'production-business-sdlc'); + assert.ok(Array.isArray(profile.enabled_domains)); + assert.ok(Array.isArray(profile.enabled_agents)); + assert.ok(Array.isArray(profile.enabled_plugins)); + assert.ok(Array.isArray(profile.dashboard_pages)); + assert.ok(Array.isArray(profile.business_stage_order)); + assert.ok(profile.enabled_domains.includes('backend')); + assert.ok(profile.enabled_domains.includes('qa')); + assert.ok(profile.enabled_domains.includes('security')); + assert.ok(profile.dashboard_pages.includes('business-flex')); +}); + +test('lean-mvp profile has smaller enabled set than business-flex', () => { + const lean = BUILT_IN_PROFILES['lean-mvp']; + const flex = BUILT_IN_PROFILES['business-flex']; + assert.equal(lean.profile, 'lean-mvp'); + assert.equal(lean.workflow, 'lean-mvp-sdlc'); + assert.ok(lean.enabled_domains.length < flex.enabled_domains.length); + assert.ok(lean.enabled_agents.length < flex.enabled_agents.length); + assert.ok(lean.enabled_plugins.length < flex.enabled_plugins.length); +}); + +test('enterprise-webapp profile has security/compliance oriented domains', () => { + const enterprise = BUILT_IN_PROFILES['enterprise-webapp']; + assert.equal(enterprise.profile, 'enterprise-webapp'); + assert.equal(enterprise.workflow, 'enterprise-webapp-sdlc'); + assert.ok(enterprise.enabled_domains.includes('security')); + assert.ok(enterprise.enabled_domains.includes('devops')); + assert.ok(enterprise.enabled_plugins.includes('security-scanning')); +}); + +// --------------------------------------------------------------------------- +// profileConfig +// --------------------------------------------------------------------------- + +test('profileConfig returns business-flex by default', () => { + const profile = profileConfig(); + assert.equal(profile.profile, 'business-flex'); + assert.equal(profile.workflow, 'production-business-sdlc'); +}); + +test('profileConfig returns the requested profile', () => { + assert.equal(profileConfig('lean-mvp').profile, 'lean-mvp'); + assert.equal(profileConfig('enterprise-webapp').profile, 'enterprise-webapp'); + assert.equal(profileConfig('business-flex').profile, 'business-flex'); +}); + +test('profileConfig returns business-flex for unknown profile names', () => { + const profile = profileConfig('nonexistent-profile'); + assert.equal(profile.profile, 'business-flex'); +}); + +test('profileConfig returns a deep copy — mutations do not affect BUILT_IN_PROFILES', () => { + const profile = profileConfig('business-flex'); + profile.enabled_domains.push('mutated-domain'); + profile.custom_field = 'added'; + // Original must be unchanged + assert.ok(!BUILT_IN_PROFILES['business-flex'].enabled_domains.includes('mutated-domain')); + assert.ok(!('custom_field' in BUILT_IN_PROFILES['business-flex'])); +}); + +test('profileConfig with undefined argument falls back to business-flex', () => { + const profile = profileConfig(undefined); + assert.equal(profile.profile, 'business-flex'); +}); + +// --------------------------------------------------------------------------- +// budgetPolicyForProfile +// --------------------------------------------------------------------------- + +test('budgetPolicyForProfile business-flex returns base budgets', () => { + const policy = budgetPolicyForProfile('business-flex'); + assert.equal(policy.currency, 'USD'); + assert.equal(policy.run_budget_usd, 10); + assert.equal(policy.daily_budget_usd, 50); + assert.equal(policy.monthly_budget_usd, 500); + assert.equal(policy.require_approval_above_usd, 25); + assert.equal(policy.warn_at_percent, 70); + assert.equal(policy.block_at_percent, 100); +}); + +test('budgetPolicyForProfile lean-mvp returns smaller budgets', () => { + const policy = budgetPolicyForProfile('lean-mvp'); + assert.equal(policy.currency, 'USD'); + assert.equal(policy.run_budget_usd, 5); + assert.equal(policy.daily_budget_usd, 20); + assert.equal(policy.monthly_budget_usd, 150); + assert.equal(policy.require_approval_above_usd, 10); + // base fields still present + assert.equal(policy.warn_at_percent, 70); +}); + +test('budgetPolicyForProfile enterprise-webapp returns larger budgets', () => { + const policy = budgetPolicyForProfile('enterprise-webapp'); + assert.equal(policy.run_budget_usd, 25); + assert.equal(policy.daily_budget_usd, 100); + assert.equal(policy.monthly_budget_usd, 1500); + assert.equal(policy.require_approval_above_usd, 50); +}); + +test('budgetPolicyForProfile returns default for unknown profile name', () => { + const policy = budgetPolicyForProfile('unknown-profile'); + assert.equal(policy.run_budget_usd, 10); + assert.equal(policy.require_approval_above_usd, 25); +}); + +test('budgetPolicyForProfile includes model_policy and stage_budgets', () => { + const policy = budgetPolicyForProfile('business-flex'); + assert.ok(typeof policy.model_policy === 'object'); + assert.equal(policy.model_policy.default, 'balanced'); + assert.equal(policy.model_policy.architecture, 'strong'); + assert.ok(typeof policy.stage_budgets === 'object'); + assert.ok(policy.stage_budgets['07-code'] > 0); + assert.ok(policy.stage_budgets['02-requirements'] > 0); +}); + +test('budgetPolicyForProfile with no argument defaults to business-flex base', () => { + const policy = budgetPolicyForProfile(); + assert.equal(policy.run_budget_usd, 10); + assert.equal(policy.currency, 'USD'); +}); + +// --------------------------------------------------------------------------- +// loadProjectProfile +// --------------------------------------------------------------------------- + +test('loadProjectProfile returns business-flex default when no config file exists', async () => { + const root = tmpProject(); + try { + const profile = await loadProjectProfile(root); + assert.equal(profile.profile, 'business-flex'); + assert.ok(Array.isArray(profile.enabled_domains)); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('loadProjectProfile reads profile from rstack.config.json', async () => { + const root = tmpProject(); + try { + mkdirSync(join(root, '.rstack'), { recursive: true }); + writeFileSync(join(root, '.rstack', 'rstack.config.json'), JSON.stringify({ + profile: 'lean-mvp', + })); + const profile = await loadProjectProfile(root); + assert.equal(profile.profile, 'lean-mvp'); + assert.equal(profile.workflow, 'lean-mvp-sdlc'); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('loadProjectProfile merges overrides from config file onto base profile', async () => { + const root = tmpProject(); + try { + mkdirSync(join(root, '.rstack'), { recursive: true }); + writeFileSync(join(root, '.rstack', 'rstack.config.json'), JSON.stringify({ + profile: 'business-flex', + enabled_domains: ['product', 'backend'], + dashboard_pages: ['command', 'business-flex'], + })); + const profile = await loadProjectProfile(root); + assert.equal(profile.profile, 'business-flex'); + assert.deepEqual(profile.enabled_domains, ['product', 'backend']); + assert.deepEqual(profile.dashboard_pages, ['command', 'business-flex']); + // base agents still present (not overridden) + assert.ok(Array.isArray(profile.enabled_agents)); + assert.ok(profile.enabled_agents.length > 0); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('loadProjectProfile falls back to business-flex on invalid JSON', async () => { + const root = tmpProject(); + try { + mkdirSync(join(root, '.rstack'), { recursive: true }); + writeFileSync(join(root, '.rstack', 'rstack.config.json'), 'not valid json }{'); + const profile = await loadProjectProfile(root); + assert.equal(profile.profile, 'business-flex'); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('loadProjectProfile resolves unknown profile name in config to business-flex base', async () => { + const root = tmpProject(); + try { + mkdirSync(join(root, '.rstack'), { recursive: true }); + writeFileSync(join(root, '.rstack', 'rstack.config.json'), JSON.stringify({ + profile: 'does-not-exist', + })); + const profile = await loadProjectProfile(root); + // Unknown profile falls through to business-flex base + assert.ok(Array.isArray(profile.enabled_domains)); + assert.ok(profile.enabled_domains.length > 0); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('loadProjectProfile uses base profile arrays when overrides are omitted', async () => { + const root = tmpProject(); + try { + mkdirSync(join(root, '.rstack'), { recursive: true }); + writeFileSync(join(root, '.rstack', 'rstack.config.json'), JSON.stringify({ + profile: 'enterprise-webapp', + // no domain/agent/plugin overrides + })); + const profile = await loadProjectProfile(root); + assert.equal(profile.profile, 'enterprise-webapp'); + const base = BUILT_IN_PROFILES['enterprise-webapp']; + assert.deepEqual(profile.enabled_domains, base.enabled_domains); + assert.deepEqual(profile.enabled_agents, base.enabled_agents); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// loadBudgetPolicy +// --------------------------------------------------------------------------- + +test('loadBudgetPolicy returns profile defaults when no budget.json exists', async () => { + const root = tmpProject(); + try { + const policy = await loadBudgetPolicy(root, 'lean-mvp'); + assert.equal(policy.run_budget_usd, 5); + assert.equal(policy.currency, 'USD'); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('loadBudgetPolicy reads and merges budget.json overrides', async () => { + const root = tmpProject(); + try { + mkdirSync(join(root, '.rstack'), { recursive: true }); + writeFileSync(join(root, '.rstack', 'budget.json'), JSON.stringify({ + run_budget_usd: 99, + currency: 'EUR', + })); + const policy = await loadBudgetPolicy(root, 'business-flex'); + assert.equal(policy.run_budget_usd, 99); + assert.equal(policy.currency, 'EUR'); + // default fields still present + assert.equal(policy.warn_at_percent, 70); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('loadBudgetPolicy deep-merges model_policy and stage_budgets', async () => { + const root = tmpProject(); + try { + mkdirSync(join(root, '.rstack'), { recursive: true }); + writeFileSync(join(root, '.rstack', 'budget.json'), JSON.stringify({ + model_policy: { builder: 'strong' }, + stage_budgets: { '07-code': 10 }, + })); + const policy = await loadBudgetPolicy(root, 'business-flex'); + assert.equal(policy.model_policy.builder, 'strong'); // overridden + assert.equal(policy.model_policy.default, 'balanced'); // base preserved + assert.equal(policy.stage_budgets['07-code'], 10); // overridden + assert.ok(policy.stage_budgets['02-requirements'] > 0); // base preserved + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('loadBudgetPolicy falls back to defaults on invalid JSON', async () => { + const root = tmpProject(); + try { + mkdirSync(join(root, '.rstack'), { recursive: true }); + writeFileSync(join(root, '.rstack', 'budget.json'), '{ bad json'); + const policy = await loadBudgetPolicy(root, 'enterprise-webapp'); + assert.equal(policy.run_budget_usd, 25); + assert.equal(policy.currency, 'USD'); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('loadBudgetPolicy defaults to business-flex when profileName is omitted', async () => { + const root = tmpProject(); + try { + const policy = await loadBudgetPolicy(root); + assert.equal(policy.run_budget_usd, 10); + assert.equal(policy.require_approval_above_usd, 25); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// budgetEnvelopeForTask +// --------------------------------------------------------------------------- + +test('budgetEnvelopeForTask returns envelope with fallback when no stage_artifacts', () => { + const task = { id: 'test-task' }; + const policy = budgetPolicyForProfile('business-flex'); + const envelope = budgetEnvelopeForTask(task, policy); + assert.equal(envelope.currency, 'USD'); + assert.ok(typeof envelope.estimated_ai_cost_usd === 'number'); + assert.ok(envelope.estimated_ai_cost_usd > 0); // fallback = run_budget / 8 = 1.25 + assert.equal(envelope.approval_required_above_usd, 25); + assert.equal(envelope.warn_at_percent, 70); + assert.equal(envelope.block_at_percent, 100); + assert.ok(typeof envelope.model_policy === 'object'); + assert.ok(typeof envelope.stage_budgets === 'object'); +}); + +test('budgetEnvelopeForTask sums stage budgets from stage_artifacts', () => { + const task = { + id: 'test-task', + stage_artifacts: [ + { stage_id: '07-code' }, + { stage_id: '08-testing' }, + ], + }; + const policy = budgetPolicyForProfile('business-flex'); + const envelope = budgetEnvelopeForTask(task, policy); + // 07-code = 4, 08-testing = 2 → 6 + assert.equal(envelope.estimated_ai_cost_usd, 6); + assert.deepEqual(envelope.stage_budgets, { '07-code': 4, '08-testing': 2 }); +}); + +test('budgetEnvelopeForTask filters out stage_artifacts without stage_id', () => { + const task = { + id: 'test-task', + stage_artifacts: [ + { stage_id: '06-architecture' }, + { path: 'no-stage-id.json' }, // no stage_id + ], + }; + const policy = budgetPolicyForProfile('business-flex'); + const envelope = budgetEnvelopeForTask(task, policy); + assert.equal(envelope.estimated_ai_cost_usd, 2); // only 06-architecture = 2 + assert.deepEqual(envelope.stage_budgets, { '06-architecture': 2 }); +}); + +test('budgetEnvelopeForTask uses fallback when stage_artifacts present but no matching stage budgets', () => { + const task = { + id: 'test-task', + stage_artifacts: [ + { stage_id: 'non-existent-stage' }, + ], + }; + const policy = budgetPolicyForProfile('business-flex'); // run_budget_usd = 10 + const envelope = budgetEnvelopeForTask(task, policy); + // stageBudget = 0, fallback = 10/8 = 1.25 + assert.equal(envelope.estimated_ai_cost_usd, 1.25); +}); + +test('budgetEnvelopeForTask uses default policy when no policy argument provided', () => { + const task = {}; + const envelope = budgetEnvelopeForTask(task); + assert.equal(envelope.currency, 'USD'); + assert.ok(typeof envelope.estimated_ai_cost_usd === 'number'); + assert.equal(envelope.approval_required_above_usd, 25); +}); + +test('budgetEnvelopeForTask approval_required_above_usd varies by profile', () => { + const task = {}; + const leanPolicy = budgetPolicyForProfile('lean-mvp'); + const enterprisePolicy = budgetPolicyForProfile('enterprise-webapp'); + assert.equal(budgetEnvelopeForTask(task, leanPolicy).approval_required_above_usd, 10); + assert.equal(budgetEnvelopeForTask(task, enterprisePolicy).approval_required_above_usd, 50); +}); + +test('budgetEnvelopeForTask rounds estimated cost to 2 decimal places', () => { + const task = { stage_artifacts: [{ stage_id: '07-code' }] }; + const policy = { ...budgetPolicyForProfile('business-flex'), stage_budgets: { '07-code': 1.3333333 } }; + const envelope = budgetEnvelopeForTask(task, policy); + const str = String(envelope.estimated_ai_cost_usd); + const decimals = str.includes('.') ? str.split('.')[1].length : 0; + assert.ok(decimals <= 2, `expected at most 2 decimal places, got ${str}`); +});