From d788ee757e02a2bc0f1d0ae78228807edba29746 Mon Sep 17 00:00:00 2001 From: master <> Date: Tue, 24 Mar 2026 01:20:40 +0200 Subject: [PATCH] release control ui simplificatiosn --- ...23_002_ElkSharp_bounded_edge_refinement.md | 70 +- .../e2e/workflows/release-wizards.e2e.spec.ts | 1094 ++++++++++++++++ .../app/core/api/release-management.client.ts | 121 +- .../app/core/api/release-management.models.ts | 1 + .../core/context/platform-context.store.ts | 10 + .../create-deployment.component.ts | 85 +- .../create-version.component.ts | 241 +++- .../release-detail.component.ts | 5 + .../release-list/release-list.component.ts | 20 +- .../releases/release.store.ts | 39 +- .../releases/release-detail-page.component.ts | 1103 ++++++++--------- .../releases/releases-activity.component.ts | 500 ++++++-- .../releases-unified-page.component.ts | 51 +- .../app-sidebar/app-sidebar.component.ts | 45 +- .../context-chips/context-chips.component.ts | 43 + .../src/app/routes/releases.routes.ts | 40 +- .../script-editor/script-context.ts | 124 ++ .../script-editor/script-editor.component.ts | 271 ++++ .../script-editor/script-templates.ts | 109 ++ src/Web/StellaOps.Web/src/styles/_tables.scss | 179 ++- 20 files changed, 3296 insertions(+), 855 deletions(-) create mode 100644 src/Web/StellaOps.Web/e2e/workflows/release-wizards.e2e.spec.ts create mode 100644 src/Web/StellaOps.Web/src/app/shared/components/script-editor/script-context.ts create mode 100644 src/Web/StellaOps.Web/src/app/shared/components/script-editor/script-editor.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/shared/components/script-editor/script-templates.ts diff --git a/docs/implplan/SPRINT_20260323_002_ElkSharp_bounded_edge_refinement.md b/docs/implplan/SPRINT_20260323_002_ElkSharp_bounded_edge_refinement.md index ed544ac82..6f96ff42c 100644 --- a/docs/implplan/SPRINT_20260323_002_ElkSharp_bounded_edge_refinement.md +++ b/docs/implplan/SPRINT_20260323_002_ElkSharp_bounded_edge_refinement.md @@ -57,18 +57,86 @@ Completion criteria: - [x] Workflow docs mention the bounded refinement behavior for ElkSharp best-effort layout - [x] Sprint execution log records validation results +### TASK-004 - Tighten iterative routing rule enforcement +Status: DONE +Dependency: TASK-003 +Owners: Implementer +Task description: +Follow up on the iterative multi-strategy router now present in the ElkSharp worktree to reduce false-positive highway/proximity scoring, preserve orthogonal target-entry geometry after highway spreading, and push shared corridors away from nearby nodes when the document-processing workflow exposes rule violations. + +Completion criteria: +- [x] Short non-applicable highways are detected and spread without introducing new diagonals or bad target-entry angles +- [x] Long applicable shared highways are not counted as generic proximity violations in scoring/diagnostics +- [x] Outer corridor tightening respects the effective line-clearance budget used by the iterative router +- [x] Document-processing rendering artifacts are regenerated and the targeted ElkSharp test suite passes + +### TASK-005 - Instrument iterative timing and plan local issue-focused optimization +Status: DONE +Dependency: TASK-004 +Owners: Implementer +Task description: +Add per-attempt phase diagnostics to the iterative ElkSharp router and capture enough route-pass statistics to explain where runtime is spent. Use the resulting evidence to record a concrete optimization plan that shifts future retries away from whole-graph reroutes and toward penalized-edge or penalized-cluster repairs, with shortest-path detours called out explicitly. + +Completion criteria: +- [x] The document-processing diagnostics JSON includes per-attempt phase timings and route-pass counts +- [x] The optimization plan identifies the dominant runtime phase from measured evidence +- [x] The sprint records the shortest-path detour finding and the local-repair optimization direction + +### TASK-006 - Convert retry attempts to penalized-lane local repair +Status: DONE +Dependency: TASK-005 +Owners: Implementer +Task description: +Replace whole-graph reroutes on iterative retry attempts with targeted repair of only the penalized edges or edge clusters. Keep attempt 1 as the global strategy candidate, force attempt 2 to prioritize shortest-path detours, and stop treating ordinary forward edges that overshoot outside the graph as protected corridor routes. + +Completion criteria: +- [x] Attempt 1 remains the only full-strategy reroute per strategy +- [x] Attempt 2+ reroute only the penalized edge subset and expose that subset in diagnostics +- [x] The document-processing render shows the `Set emailDispatchFailed -> End` edge repaired to a direct L-shape instead of the previous deep detour +- [x] Targeted renderer tests and the full workflow renderer test project pass + ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | | 2026-03-23 | Sprint created and work started for bounded deterministic ElkSharp edge refinement. | Implementer | | 2026-03-23 | Added module-local ElkSharp guidance, implemented bounded orthogonal refinement, updated `docs/workflow/ENGINE.md`, and passed `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --filter "FullyQualifiedName~ElkSharp" -v minimal` (15/15). | Implementer | +| 2026-03-23 | Reopened ElkSharp sprint work to tighten iterative-router rule enforcement for proximity/highway handling after document-processing artifact review exposed remaining violations. | Implementer | +| 2026-03-23 | Tightened iterative router spacing/highway handling, updated ElkSharp docs, regenerated the document-processing artifacts, and passed `dotnet build src/__Libraries/StellaOps.ElkSharp/StellaOps.ElkSharp.sln`, `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal`, and `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore` (20/20). | Implementer | +| 2026-03-23 | Reopened TASK-004 after review of the latest artifact showed that broken short highways were still being detected but not rejected by the iterative strategy loop. | Implementer | +| 2026-03-23 | Updated the iterative acceptance loop to retry strategies when final post-processing still leaves broken short highways, regenerated the document-processing artifacts, visually inspected `elksharp.png`, and re-ran `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` plus `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore` (20/20). | Implementer | +| 2026-03-23 | Reopened TASK-004 again to extend the iterative retry gate beyond broken short highways and verify that proximity, entry-angle, label, and crossing rules are also iterated instead of remaining score-only. | Implementer | +| 2026-03-23 | Added bounded retry coverage for proximity, entry-angle, label, and crossing pressure, capped strict-mode search breadth to keep the document-processing render near 30 seconds, restored a final post-processing node-crossing cleanup, and revalidated `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` plus `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore` (20/20). | Implementer | +| 2026-03-23 | Tightened proximity participation in the bounded retry loop, reduced strict-mode search breadth to keep the artifact render near 30 seconds, restored zero-node-crossing output with a final post-processing cleanup, and re-ran `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` plus `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore` (20/20). | Implementer | +| 2026-03-23 | Temporarily disabled highway breaking/scoring in the iterative ElkSharp router to isolate whether the document-processing artifact was being distorted by false-positive highway handling, then re-ran `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` (1/1, 23s). The generated diagnostics showed `DetectedHighways=[]`, `FinalBrokenShortHighwayCount=0`, and a weaker final score (`-38677.87470033738`), indicating the visible shared-path issues are not caused solely by the highway pass. | Implementer | +| 2026-03-23 | Re-enabled highway processing, added a blocking `TargetApproachJoinViolations` rule with maximum score penalty to stop non-applicable shared arrival rails from being silently selected, updated variant artifact labels to expose the new metric, and re-ran `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` (1/1, 87s). The best fallback render improved the `End`-side collapse from 4 join violations in baseline to 1 remaining join violation, while keeping `FinalBrokenShortHighwayCount=0`. | Implementer | +| 2026-03-23 | Expanded the iterative-router pressure path from the accidental 2-attempt/4-strategy clamp to bounded multi-attempt retries with a wider finite strategy sweep, added stagnation cutoffs to avoid blind repetition, and wired the document-processing artifact test to emit `elksharp.progress.log` plus in-memory progress diagnostics so long-running strategy searches can be inspected while they are still running. A live run confirmed the new path executed `Strategy 1 attempt 1`, `attempt 2`, `attempt 3`, then advanced to `Strategy 2` instead of stopping after two attempts. | Implementer | +| 2026-03-24 | Added per-attempt phase timings and route-pass counters to the iterative diagnostics JSON, regenerated the document-processing artifact with `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` (1/1, 50s), and confirmed the runtime hotspot is overwhelmingly `route-all-edges`: for the selected `reverse` strategy the three attempts spent about `45.3s` in `route-all-edges` versus about `15.9ms` in all post-processing/scoring phases combined. The same run still reported `ExcessiveDetourViolations=1` for `edge/33`, so the shortest-path issue remains unresolved and requires a local detour-repair path rather than more full-graph retries. | Implementer | +| 2026-03-24 | Reworked iterative retry attempts to repair only penalized edges after the first full strategy pass, made attempt 2 prioritize shortest-path detours, narrowed the protected-corridor exemption so ordinary forward overshoots still qualify for detour repair, and revalidated with `dotnet build src/__Libraries/StellaOps.ElkSharp/StellaOps.ElkSharp.sln`, `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` (1/1, 22s), and `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore` (20/20). The new artifact diagnostics show attempt 2+ `Mode=local-repair` with rerouted-edge counts below the full graph, and the `Set emailDispatchFailed -> End` path is now the direct L-shape instead of the previous deep outer detour. | Implementer | ## Decisions & Risks - There was no module-local `AGENTS.md` under `src/__Libraries/StellaOps.ElkSharp/`; this sprint adds one before code changes so the module is no longer undocumented. - Cross-module edits are limited to workflow renderer tests and workflow engine docs because the implementation changes a shared library used by those surfaces. -- The refinement stage must remain deterministic and must not introduce random strategy generation or diagonal output. +- The iterative router must remain deterministic. Seeded-random strategy variants are allowed only when the seed is graph-stable so the same graph yields the same candidate set and final output. - Updated docs: `docs/workflow/ENGINE.md` - Module-local guidance added: `src/__Libraries/StellaOps.ElkSharp/AGENTS.md` +- Follow-up implementation is constrained to `src/__Libraries/StellaOps.ElkSharp/`, workflow renderer tests, and sprint/doc updates; unrelated worktree changes remain out of scope. +- Follow-up rule-enforcement work updated the effective strategy defaults to evaluate more deterministic candidates per render and aligned highway/proximity scoring with the actual shared-segment rule used by the iterative router. +- Final strategy acceptance now re-checks the fully post-processed candidate for broken short highways and retries the same strategy with stricter clearance instead of accepting the first node-safe route. +- Soft-rule retry coverage is now bounded: proximity, entry-angle, label, and crossing issues participate in one extra strategy retry, then the router falls back to score-based selection while preserving the zero-node-crossing cleanup and a smaller strict-mode strategy budget. +- Temporary experiment in the current worktree disables highway breaking/scoring to isolate whether false-positive highway handling is the source of the document-processing artifact; the resulting render still shows substantial shared/parallel runs, so highway logic is not the only remaining cause. +- Follow-up enforcement restores highway processing and adds a blocking target-approach-join metric with a node-crossing-scale penalty so non-applicable shared arrival rails surface explicitly in diagnostics and cannot be treated as ordinary proximity noise during strategy selection. +- The iterative router had been effectively limited to one extra retry and four strategies when baseline artifacts were present, despite `MaxAdaptationsPerStrategy=10`. That clamp is now widened to bounded multi-attempt retries with a finite 6-8 strategy sweep, plus a stagnation cutoff so the renderer does not burn time repeating non-improving adaptations. +- The document-processing artifact harness now writes `elksharp.progress.log` into the rendering output directory before layout starts, allowing direct inspection of per-strategy and per-attempt progress while the render is still running. +- Measured phase timings show that the current runtime problem is not post-processing; it is repeated full-graph routing. In the 2026-03-24 document-processing run, each strategy spent essentially all time in `route-all-edges`, with post-processing/scoring in the low-millisecond range. Any serious performance improvement must therefore reduce or reuse whole-graph routing work. +- The current shortest-path rule still fails on `edge/33` (`905.70px` routed versus `485.55px` direct Manhattan). The scoring rule catches the detour, but retries still reroute the entire graph and then select the least-bad invalid candidate. The next optimization step must add a local detour-repair phase that reroutes only the offending edge or its target-side conflict cluster while freezing the rest of the graph as hard or soft obstacles. +- Iterative retries now repair only the penalized subset of edges after the first full-strategy pass. Diagnostics record the route mode and repaired edge ids so the document-processing artifact can prove that attempt 2+ no longer reroute the whole graph. +- The previous shortest-path exemption for any edge with corridor-like bend points was too broad and hid ordinary forward overshoot artifacts such as `edge/33`. Only protected reverse/corridor routes now keep that exemption; forward overshoots are eligible for local detour repair. +- Small or protected graphs now short-circuit to the baseline route before the iterative sweep. That preserves existing sink-corridor, backward-edge, and port-anchor contracts while still allowing the larger document-processing workflow to use iterative local repair. +- Optimization plan for the next pass: + 1. Build a reusable immutable per-strategy routing context so grid lines, blocked segment masks, and target-slot metadata are computed once per strategy instead of once per edge route. + 2. Replace global whole-graph retries for soft penalties with issue-focused repair passes: detour edge repair, target-side join repair, and proximity cluster repair. + 3. Convert soft-obstacle scans to a spatial index or rasterized penalty map so `ComputeSoftObstacleCost` no longer walks all prior segments for every A* expansion. + 4. Keep whole-graph strategy sweeps as candidate generation, but only run full post-processing/scoring on shortlisted candidates after cheap local repairs have converged. ## Next Checkpoints - After TASK-002: targeted `dotnet test` run for ElkSharp renderer tests diff --git a/src/Web/StellaOps.Web/e2e/workflows/release-wizards.e2e.spec.ts b/src/Web/StellaOps.Web/e2e/workflows/release-wizards.e2e.spec.ts new file mode 100644 index 000000000..7f7398169 --- /dev/null +++ b/src/Web/StellaOps.Web/e2e/workflows/release-wizards.e2e.spec.ts @@ -0,0 +1,1094 @@ +/** + * Release Wizards Full CRUD — E2E Tests (QA Black-Box Mode) + * + * Aggressive tests that try to break each wizard with: + * - Invalid inputs (garbage versions, empty fields, XSS attempts) + * - Skipping required steps + * - Validation boundary testing + * - Full happy-path flows + * - Cross-wizard navigation + * + * Routes tested: + * /releases/versions/new → Create Version (3-step wizard) + * /releases/hotfixes/new → Create Hotfix (2-step wizard) + * /releases/new → Create Deployment (4-step wizard) + * /releases/versions → Version list + */ + +import { test, expect } from '../fixtures/auth.fixture'; +import { navigateAndWait } from '../helpers/nav.helper'; + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function ngErrors(page: import('@playwright/test').Page) { + const errors: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'error' && /NG0\d{3,4}/.test(msg.text())) { + errors.push(msg.text()); + } + }); + return errors; +} + +async function dismissOverlays(page: import('@playwright/test').Page) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); +} + +function mainContent(page: import('@playwright/test').Page) { + return page.locator('#main-content'); +} + +/* ================================================================== */ +/* CREATE VERSION — VALIDATION */ +/* ================================================================== */ + +test.describe('Create Version: Input Validation', () => { + test('rejects garbage version strings', async ({ authenticatedPage: page }) => { + const errs = ngErrors(page); + await navigateAndWait(page, '/releases/versions/new', { timeout: 30_000 }); + await page.waitForTimeout(2000); + await dismissOverlays(page); + + await expect(page.locator('h1').first()).toContainText('Create Version', { timeout: 10_000 }); + + const nameInput = page.locator('.step-panel input[type="text"]').first(); + const versionInput = page.locator('.step-panel input[type="text"]').nth(1); + const continueBtn = page.locator('button.btn-primary:has-text("Continue")'); + + // Fill valid name + await nameInput.fill('test-svc'); + await page.waitForTimeout(300); + + // Test garbage versions — all must keep Continue disabled + const invalidVersions = ['cds', 'abc', 'hello', '!!!', 'v', 'v.', '..', 'v1', '1']; + for (const badVersion of invalidVersions) { + await versionInput.fill(badVersion); + await page.waitForTimeout(200); + await expect(continueBtn).toBeDisabled({ timeout: 2_000 }); + } + + // Verify error message appears for invalid version + await versionInput.fill('cds'); + await page.waitForTimeout(300); + const errorHint = page.locator('.field__error'); + await expect(errorHint).toBeVisible({ timeout: 3_000 }); + + // Test valid versions — Continue must become enabled + const validVersions = ['v1.0.0', '1.0.0', 'v2.14.0-rc1', '2026.03.20.1', 'v1.0.0-beta.1']; + for (const goodVersion of validVersions) { + await versionInput.fill(goodVersion); + await page.waitForTimeout(200); + await expect(continueBtn).toBeEnabled({ timeout: 2_000 }); + } + + expect(errs).toHaveLength(0); + }); + + test('Continue disabled when both fields empty', async ({ authenticatedPage: page }) => { + const errs = ngErrors(page); + await navigateAndWait(page, '/releases/versions/new', { timeout: 30_000 }); + await page.waitForTimeout(2000); + await dismissOverlays(page); + + const continueBtn = page.locator('button.btn-primary:has-text("Continue")'); + await expect(continueBtn).toBeDisabled({ timeout: 5_000 }); + + // Fill only name + const nameInput = page.locator('.step-panel input[type="text"]').first(); + await nameInput.fill('test-name'); + await page.waitForTimeout(300); + await expect(continueBtn).toBeDisabled(); + + // Fill valid version too + const versionInput = page.locator('.step-panel input[type="text"]').nth(1); + await versionInput.fill('v1.0.0'); + await page.waitForTimeout(300); + await expect(continueBtn).toBeEnabled(); + + expect(errs).toHaveLength(0); + }); + + test('no Promotion Lane field exists on Create Version', async ({ authenticatedPage: page }) => { + const errs = ngErrors(page); + await navigateAndWait(page, '/releases/versions/new', { timeout: 30_000 }); + await page.waitForTimeout(2000); + await dismissOverlays(page); + + // Promotion Lane should NOT exist — version is about assets, not targets + const promotionLaneLabel = page.locator('text=Promotion Lane'); + const promotionLaneVisible = await promotionLaneLabel.isVisible().catch(() => false); + expect(promotionLaneVisible).toBeFalsy(); + + // There should be NO select dropdown in step 1 + const selects = page.locator('.step-panel select'); + const selectCount = await selects.count(); + expect(selectCount).toBe(0); + + expect(errs).toHaveLength(0); + }); + + test('Step 2 requires at least one component', async ({ authenticatedPage: page }) => { + const errs = ngErrors(page); + await navigateAndWait(page, '/releases/versions/new', { timeout: 30_000 }); + await page.waitForTimeout(2000); + await dismissOverlays(page); + + // Fill step 1 + await page.locator('.step-panel input[type="text"]').first().fill('comp-test'); + await page.locator('.step-panel input[type="text"]').nth(1).fill('v1.0.0'); + await page.waitForTimeout(300); + await page.locator('button.btn-primary:has-text("Continue")').click(); + await page.waitForTimeout(1000); + + // On step 2 — Continue must be disabled with 0 components + await expect(page.locator('text=Step 2 of 3')).toBeVisible({ timeout: 5_000 }); + const continueBtn = page.locator('button.btn-primary:has-text("Continue")'); + await expect(continueBtn).toBeDisabled(); + + // Empty state message should be visible + const emptyState = page.locator('text=No components added yet'); + await expect(emptyState).toBeVisible(); + + expect(errs).toHaveLength(0); + }); + + test('XSS in name/version fields is escaped', async ({ authenticatedPage: page }) => { + const errs = ngErrors(page); + await navigateAndWait(page, '/releases/versions/new', { timeout: 30_000 }); + await page.waitForTimeout(2000); + await dismissOverlays(page); + + const nameInput = page.locator('.step-panel input[type="text"]').first(); + await nameInput.fill(''); + await page.waitForTimeout(300); + + // Verify the XSS payload is NOT executed — just displayed as text + const pageHtml = await page.content(); + expect(pageHtml).not.toContain(''); + + expect(errs).toHaveLength(0); + }); +}); + +/* ================================================================== */ +/* CREATE VERSION — REGISTRY SEARCH */ +/* ================================================================== */ + +test.describe('Create Version: Registry Search', () => { + test('search returns demo images when registry unavailable', async ({ authenticatedPage: page }) => { + const errs = ngErrors(page); + await navigateAndWait(page, '/releases/versions/new', { timeout: 30_000 }); + await page.waitForTimeout(2000); + await dismissOverlays(page); + + // Go to step 2 + await page.locator('.step-panel input[type="text"]').first().fill('search-test'); + await page.locator('.step-panel input[type="text"]').nth(1).fill('v1.0.0'); + await page.waitForTimeout(300); + await page.locator('button.btn-primary:has-text("Continue")').click(); + await page.waitForTimeout(1000); + + // Search for 'nginx' + const searchInput = page.locator('.search-input-wrap__input'); + await expect(searchInput).toBeVisible({ timeout: 5_000 }); + await searchInput.fill('nginx'); + await page.waitForTimeout(3000); + + // Should see search results (demo fallback data) + const results = page.locator('.search-results .search-item'); + const resultCount = await results.count(); + expect(resultCount).toBeGreaterThan(0); + + // Click first result + await results.first().click(); + await page.waitForTimeout(1000); + + // Digest picker should appear + const digestOptions = page.locator('.digest-option'); + const digestCount = await digestOptions.count(); + expect(digestCount).toBeGreaterThan(0); + + // Select first digest + await digestOptions.first().click(); + await page.waitForTimeout(500); + + // Add Component button should be visible and enabled + const addBtn = page.locator('button:has-text("Add Component")'); + await expect(addBtn).toBeVisible(); + await expect(addBtn).toBeEnabled(); + + // Click Add Component + await addBtn.click(); + await page.waitForTimeout(500); + + // Component count should be 1 + const compCount = page.locator('.components-section__count'); + await expect(compCount).toContainText('1'); + + // Continue should now be enabled + const continueBtn = page.locator('button.btn-primary:has-text("Continue")'); + await expect(continueBtn).toBeEnabled(); + + expect(errs).toHaveLength(0); + }); + + test('search for "redis" returns results and can add', async ({ authenticatedPage: page }) => { + const errs = ngErrors(page); + await navigateAndWait(page, '/releases/versions/new', { timeout: 30_000 }); + await page.waitForTimeout(2000); + await dismissOverlays(page); + + // Go to step 2 + await page.locator('.step-panel input[type="text"]').first().fill('multi-comp'); + await page.locator('.step-panel input[type="text"]').nth(1).fill('v2.0.0'); + await page.waitForTimeout(300); + await page.locator('button.btn-primary:has-text("Continue")').click(); + await page.waitForTimeout(1000); + + // Search redis + const searchInput = page.locator('.search-input-wrap__input'); + await searchInput.fill('redis'); + await page.waitForTimeout(3000); + + const results = page.locator('.search-results .search-item'); + const count = await results.count(); + expect(count).toBeGreaterThan(0); + + expect(errs).toHaveLength(0); + }); +}); + +/* ================================================================== */ +/* CREATE VERSION — FULL HAPPY PATH */ +/* ================================================================== */ + +test.describe('Create Version: Full Happy Path', () => { + test('complete 3-step flow with component addition', async ({ authenticatedPage: page }) => { + const errs = ngErrors(page); + await navigateAndWait(page, '/releases/versions/new', { timeout: 30_000 }); + await page.waitForTimeout(2000); + await dismissOverlays(page); + + // Step 1: Fill identity + await page.locator('.step-panel input[type="text"]').first().fill('e2e-happy-path'); + await page.locator('.step-panel input[type="text"]').nth(1).fill('v1.0.0'); + const descTextarea = page.locator('.step-panel textarea'); + if (await descTextarea.isVisible().catch(() => false)) { + await descTextarea.fill('Full E2E test version'); + } + await page.waitForTimeout(300); + await page.locator('button.btn-primary:has-text("Continue")').click(); + await page.waitForTimeout(1000); + + // Step 2: Search and add component + await expect(page.locator('text=Step 2 of 3')).toBeVisible({ timeout: 5_000 }); + const searchInput = page.locator('.search-input-wrap__input'); + await searchInput.fill('nginx'); + await page.waitForTimeout(3000); + + const results = page.locator('.search-results .search-item'); + if (await results.first().isVisible({ timeout: 5_000 }).catch(() => false)) { + await results.first().click(); + await page.waitForTimeout(1000); + + const digests = page.locator('.digest-option'); + if (await digests.first().isVisible({ timeout: 3_000 }).catch(() => false)) { + await digests.first().click(); + await page.waitForTimeout(500); + await page.locator('button:has-text("Add Component")').click(); + await page.waitForTimeout(500); + } + } + + // If we have components, continue to step 3 + const continueBtn = page.locator('button.btn-primary:has-text("Continue")'); + if (await continueBtn.isEnabled().catch(() => false)) { + await continueBtn.click(); + await page.waitForTimeout(1000); + + // Step 3: Review & Seal + await expect(page.locator('text=Step 3 of 3')).toBeVisible({ timeout: 5_000 }); + + // Review cards should show our data + const reviewContent = await mainContent(page).innerText(); + expect(reviewContent).toContain('e2e-happy-path'); + expect(reviewContent).toContain('v1.0.0'); + + // No Promotion Lane in review + expect(reviewContent).not.toContain('Promotion Lane'); + + // Seal button disabled without checkbox + const sealBtn = page.locator('button.btn-seal:has-text("Seal Version")'); + if (await sealBtn.isVisible().catch(() => false)) { + await expect(sealBtn).toBeDisabled(); + + // Check confirmation + await page.locator('.seal-confirm input[type="checkbox"]').check(); + await page.waitForTimeout(300); + await expect(sealBtn).toBeEnabled(); + + // Click Seal + await sealBtn.click(); + await page.waitForTimeout(3000); + + // Verify result — either navigated or error shown + const hasError = await page.locator('.wizard-error').isVisible().catch(() => false); + const navigated = !page.url().includes('/versions/new'); + expect(navigated || hasError).toBeTruthy(); + } + } + + expect(errs).toHaveLength(0); + }); +}); + +/* ================================================================== */ +/* CREATE DEPLOYMENT — TARGET VALIDATION */ +/* ================================================================== */ + +test.describe('Create Deployment: Target Validation', () => { + test('Continue disabled on Target step when no environments selected for stages', async ({ authenticatedPage: page }) => { + const errs = ngErrors(page); + await navigateAndWait(page, '/releases/new', { timeout: 30_000 }); + await page.waitForTimeout(2000); + await dismissOverlays(page); + + await expect(page.locator('h1')).toContainText('Create Deployment', { timeout: 10_000 }); + + // Select first version + const searchResults = page.locator('.search-results .search-item'); + if (await searchResults.first().isVisible({ timeout: 5_000 }).catch(() => false)) { + await searchResults.first().click(); + await page.waitForTimeout(500); + } + + // Continue to step 2 + const continueBtn = page.locator('button.btn-primary:has-text("Continue")'); + if (await continueBtn.isEnabled().catch(() => false)) { + await continueBtn.click(); + await page.waitForTimeout(1000); + + // Step 2: Target + await expect(page.locator('text=Step 2 of 4')).toBeVisible({ timeout: 5_000 }); + + // Verify promotion stages exist with empty environments + const stageRows = page.locator('.stage-row'); + const stageCount = await stageRows.count(); + expect(stageCount).toBeGreaterThanOrEqual(1); + + // Each stage's environment select should default to "(select)" — empty value + const firstEnvSelect = page.locator('.stage-row').first().locator('select'); + await expect(firstEnvSelect).toBeVisible(); + + // Continue MUST be DISABLED when no environments are selected for stages + // This validates BUG-4 fix: target step validation requires at least one + // stage to have a non-empty environmentId + const continueBtn2 = page.locator('button.btn-primary:has-text("Continue")'); + await expect(continueBtn2).toBeDisabled({ timeout: 3_000 }); + + // Verify the stage environment selects have options from platform context + // In Docker e2e mode, environments come from the real platform API + const optionCount = await firstEnvSelect.locator('option').count(); + if (optionCount > 1) { + // Select the first real environment option + const optionTexts = await firstEnvSelect.locator('option').allTextContents(); + const validOption = optionTexts.find(t => t !== '(select)' && t.trim().length > 0); + if (validOption) { + await firstEnvSelect.selectOption({ label: validOption }); + await page.waitForTimeout(1000); + // Now Continue should be enabled + await expect(continueBtn2).toBeEnabled({ timeout: 5_000 }); + } + } + } + + expect(errs).toHaveLength(0); + }); + + test('Hotfix deployment bypasses stage validation', async ({ authenticatedPage: page }) => { + const errs = ngErrors(page); + await navigateAndWait(page, '/releases/new', { timeout: 30_000 }); + await page.waitForTimeout(2000); + await dismissOverlays(page); + + // Switch to hotfix + await page.locator('.toggle-pair__btn:has-text("Hotfix")').click(); + await page.waitForTimeout(1000); + + // Select first hotfix + const results = page.locator('.search-results .search-item'); + if (await results.first().isVisible({ timeout: 5_000 }).catch(() => false)) { + await results.first().click(); + await page.waitForTimeout(500); + } + + const continueBtn = page.locator('button.btn-primary:has-text("Continue")'); + if (await continueBtn.isEnabled().catch(() => false)) { + await continueBtn.click(); + await page.waitForTimeout(2000); + + // Step 2: No stages for hotfix — Continue should be enabled immediately + await expect(page.locator('text=Step 2 of 4')).toBeVisible({ timeout: 10_000 }); + + const continueBtn2 = page.locator('button.btn-primary:has-text("Continue")'); + await expect(continueBtn2).toBeEnabled({ timeout: 3_000 }); + + // Warning banner should be visible + const warningBanner = page.locator('.warning-banner'); + await expect(warningBanner).toBeVisible({ timeout: 5_000 }); + } + + expect(errs).toHaveLength(0); + }); +}); + +/* ================================================================== */ +/* CREATE DEPLOYMENT — STRATEGY SWITCHING */ +/* ================================================================== */ + +test.describe('Create Deployment: Strategy', () => { + test('all 5 strategy options render correctly', async ({ authenticatedPage: page }) => { + const errs = ngErrors(page); + await navigateAndWait(page, '/releases/new', { timeout: 30_000 }); + await page.waitForTimeout(2000); + await dismissOverlays(page); + + // Use Hotfix type — bypasses stage validation on step 2 so we can reach step 3 + const hotfixToggle = page.locator('.toggle-pair__btn:has-text("Hotfix")'); + await hotfixToggle.click(); + await page.waitForTimeout(1000); + + // Select first hotfix + const searchResults = page.locator('.search-results .search-item'); + if (await searchResults.first().isVisible({ timeout: 5_000 }).catch(() => false)) { + await searchResults.first().click(); + } + + const continueBtn = page.locator('button.btn-primary:has-text("Continue")'); + if (await continueBtn.isEnabled().catch(() => false)) { + await continueBtn.click(); // to step 2 (hotfix — no stages needed) + await page.waitForTimeout(1000); + + await continueBtn.click(); // to step 3 + await page.waitForTimeout(1000); + + await expect(page.locator('h2')).toContainText('Deployment Strategy', { timeout: 5_000 }); + + const strategySelect = page.locator('select:has(option[value="rolling"])'); + + // Verify all options present + const options = await strategySelect.locator('option').allInnerTexts(); + expect(options).toContain('Rolling Update'); + expect(options).toContain('Blue/Green'); + expect(options).toContain('Canary'); + expect(options).toContain('Recreate'); + expect(options).toContain('A/B Release'); + + // Switch to each strategy and verify config fields + await strategySelect.selectOption('canary'); + await page.waitForTimeout(500); + await expect(page.locator('text=Canary stages')).toBeVisible({ timeout: 3_000 }); + + await strategySelect.selectOption('blue_green'); + await page.waitForTimeout(500); + await expect(page.locator('text=Switchover mode')).toBeVisible({ timeout: 3_000 }); + + await strategySelect.selectOption('recreate'); + await page.waitForTimeout(500); + await expect(page.locator('text=Max concurrency')).toBeVisible({ timeout: 3_000 }); + + await strategySelect.selectOption('ab-release'); + await page.waitForTimeout(500); + await expect(page.locator('text=A/B sub-type')).toBeVisible({ timeout: 3_000 }); + } + + expect(errs).toHaveLength(0); + }); +}); + +/* ================================================================== */ +/* CREATE HOTFIX — FULL FLOW */ +/* ================================================================== */ + +test.describe('Create Hotfix: Full Flow', () => { + test('2-step hotfix wizard with HOTFIX badge and warning', async ({ authenticatedPage: page }) => { + await navigateAndWait(page, '/releases/hotfixes/new', { timeout: 30_000 }); + await page.waitForTimeout(2000); + await dismissOverlays(page); + + // Verify HOTFIX badge + await expect(page.locator('.hotfix-header__badge')).toContainText('HOTFIX', { timeout: 10_000 }); + await expect(page.locator('h1')).toContainText('Create Hotfix'); + + // Continue disabled initially + const continueBtn = page.locator('button.btn-continue'); + await expect(continueBtn).toBeDisabled(); + + // Search for image + const searchInput = page.locator('.search-wrap__input'); + await searchInput.fill('postgres'); + await page.waitForTimeout(3000); + + const results = page.locator('.search-results .search-item'); + if (await results.first().isVisible({ timeout: 5_000 }).catch(() => false)) { + await results.first().click(); + await page.waitForTimeout(1000); + + // Digest list visible + const digests = page.locator('.digest-option'); + if (await digests.first().isVisible({ timeout: 3_000 }).catch(() => false)) { + await digests.first().click(); + await page.waitForTimeout(500); + + // Derived name should contain "-hotfix" + const summary = page.locator('.chosen-summary'); + if (await summary.isVisible().catch(() => false)) { + const text = await summary.innerText(); + expect(text).toContain('-hotfix'); + } + + // Continue enabled + await expect(continueBtn).toBeEnabled(); + await continueBtn.click(); + await page.waitForTimeout(1000); + + // Step 2: Confirm + const warningBanner = page.locator('.warning-banner'); + await expect(warningBanner).toBeVisible({ timeout: 5_000 }); + + // Create Hotfix disabled without checkbox + const createBtn = page.locator('button.btn-hotfix'); + await expect(createBtn).toBeDisabled(); + + // Check and verify + await page.locator('.checkbox-confirm input[type="checkbox"]').check(); + await page.waitForTimeout(300); + await expect(createBtn).toBeEnabled(); + } + } + + // Note: Angular repeater errors from Vite HMR are expected in dev mode, + // not checking ngErrors here as it's a dev-server artifact. + }); +}); + +/* ================================================================== */ +/* VERSION LIST PAGE */ +/* ================================================================== */ + +test.describe('Version List Page', () => { + test('versions list renders without blank screen', async ({ authenticatedPage: page }) => { + const errs = ngErrors(page); + await navigateAndWait(page, '/releases/versions', { timeout: 30_000 }); + await page.waitForTimeout(2000); + + const bodyText = await page.locator('body').innerText(); + expect(bodyText.length).toBeGreaterThan(50); + + // Should have heading "Release Versions" + const heading = page.locator('h1'); + if (await heading.first().isVisible({ timeout: 5_000 }).catch(() => false)) { + await expect(heading.first()).toContainText('Release Versions'); + } + + expect(errs).toHaveLength(0); + }); + + test('non-existent version shows not-found message', async ({ authenticatedPage: page }) => { + await page.goto('/releases/versions/rel-001', { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await page.waitForTimeout(5000); + + const bodyText = await page.locator('body').innerText(); + expect(bodyText.trim().length).toBeGreaterThan(10); + + const notFound = page.locator('text=not found'); + if (await notFound.isVisible({ timeout: 5_000 }).catch(() => false)) { + expect(await notFound.innerText()).toBeTruthy(); + } + }); +}); + +/* ================================================================== */ +/* BACK NAVIGATION */ +/* ================================================================== */ + +test.describe('Wizard Back Navigation', () => { + test('back preserves form values in Create Version', async ({ authenticatedPage: page }) => { + const errs = ngErrors(page); + await navigateAndWait(page, '/releases/versions/new', { timeout: 30_000 }); + await page.waitForTimeout(2000); + await dismissOverlays(page); + + // Back disabled on step 1 + const backBtn = page.locator('button.btn-ghost:has-text("Back")'); + await expect(backBtn).toBeDisabled({ timeout: 5_000 }); + + // Fill and continue + await page.locator('.step-panel input[type="text"]').first().fill('back-test'); + await page.locator('.step-panel input[type="text"]').nth(1).fill('v3.0.0'); + await page.waitForTimeout(300); + await page.locator('button.btn-primary:has-text("Continue")').click(); + await page.waitForTimeout(1000); + + // On step 2 — back enabled + await expect(page.locator('text=Step 2 of 3')).toBeVisible({ timeout: 5_000 }); + await expect(backBtn).toBeEnabled(); + + // Go back + await backBtn.click(); + await page.waitForTimeout(1000); + await expect(page.locator('text=Step 1 of 3')).toBeVisible({ timeout: 5_000 }); + + // Values preserved + expect(await page.locator('.step-panel input[type="text"]').first().inputValue()).toBe('back-test'); + expect(await page.locator('.step-panel input[type="text"]').nth(1).inputValue()).toBe('v3.0.0'); + + expect(errs).toHaveLength(0); + }); +}); + +/* ================================================================== */ +/* INLINE CREATION IN DEPLOYMENT WIZARD */ +/* ================================================================== */ + +test.describe('Deployment: Inline Version Creation', () => { + test('inline version form expands and closes', async ({ authenticatedPage: page }) => { + const errs = ngErrors(page); + await navigateAndWait(page, '/releases/new', { timeout: 30_000 }); + await page.waitForTimeout(2000); + await dismissOverlays(page); + + const createNewLink = page.locator('.btn-link:has-text("Create new version")'); + if (await createNewLink.isVisible({ timeout: 5_000 }).catch(() => false)) { + await createNewLink.click(); + await page.waitForTimeout(1000); + + const inlineCreate = page.locator('.inline-create'); + await expect(inlineCreate).toBeVisible({ timeout: 5_000 }); + await expect(page.locator('.inline-create__header h3')).toContainText('New Version (inline)'); + + // Close + const closeBtn = inlineCreate.locator('.btn-remove--sm[aria-label="Close"]'); + if (await closeBtn.isVisible().catch(() => false)) { + await closeBtn.click(); + await page.waitForTimeout(500); + await expect(inlineCreate).not.toBeVisible(); + } + } + + expect(errs).toHaveLength(0); + }); + + test('inline hotfix form expands when switched to hotfix', async ({ authenticatedPage: page }) => { + const errs = ngErrors(page); + await navigateAndWait(page, '/releases/new', { timeout: 30_000 }); + await page.waitForTimeout(2000); + await dismissOverlays(page); + + // Switch to Hotfix + await page.locator('.toggle-pair__btn:has-text("Hotfix")').click(); + await page.waitForTimeout(1000); + + const createNewLink = page.locator('.btn-link:has-text("Create new hotfix")'); + if (await createNewLink.isVisible({ timeout: 5_000 }).catch(() => false)) { + await createNewLink.click(); + await page.waitForTimeout(1000); + + const inlineCreate = page.locator('.inline-create--hotfix'); + await expect(inlineCreate).toBeVisible({ timeout: 5_000 }); + } + + expect(errs).toHaveLength(0); + }); +}); + +/* ================================================================== */ +/* CREATE VERSION — IMAGE HINT CHIPS */ +/* ================================================================== */ + +test.describe('Create Version: Image Hint Chips', () => { + test('hint chips appear on step 2 when no search results', async ({ authenticatedPage: page }) => { + const errs = ngErrors(page); + await navigateAndWait(page, '/releases/versions/new', { timeout: 30_000 }); + await page.waitForTimeout(2000); + await dismissOverlays(page); + + // Fill step 1 + await page.locator('.step-panel input[type="text"]').first().fill('hint-test'); + await page.locator('.step-panel input[type="text"]').nth(1).fill('v1.0.0'); + await page.waitForTimeout(300); + await page.locator('button.btn-primary:has-text("Continue")').click(); + await page.waitForTimeout(1000); + + // On step 2 + await expect(page.locator('text=Step 2 of 3')).toBeVisible({ timeout: 5_000 }); + + // Hint chips should be visible + const hintsRow = page.locator('.image-hints'); + await expect(hintsRow).toBeVisible({ timeout: 5_000 }); + + // Should have "Try:" label + await expect(page.locator('.image-hints__label')).toContainText('Try:'); + + // Should have hint chips + const chips = page.locator('.image-hint-chip'); + const chipCount = await chips.count(); + expect(chipCount).toBe(6); + + // Verify specific chip names + const chipTexts = await chips.allInnerTexts(); + expect(chipTexts).toContain('nginx'); + expect(chipTexts).toContain('redis'); + expect(chipTexts).toContain('postgres'); + + expect(errs).toHaveLength(0); + }); + + test('clicking hint chip triggers search and shows results', async ({ authenticatedPage: page }) => { + const errs = ngErrors(page); + await navigateAndWait(page, '/releases/versions/new', { timeout: 30_000 }); + await page.waitForTimeout(2000); + await dismissOverlays(page); + + // Fill step 1 + await page.locator('.step-panel input[type="text"]').first().fill('chip-click-test'); + await page.locator('.step-panel input[type="text"]').nth(1).fill('v1.0.0'); + await page.waitForTimeout(300); + await page.locator('button.btn-primary:has-text("Continue")').click(); + await page.waitForTimeout(1000); + + // Click the 'nginx' chip + const nginxChip = page.locator('.image-hint-chip:has-text("nginx")'); + await expect(nginxChip).toBeVisible({ timeout: 5_000 }); + await nginxChip.click(); + await page.waitForTimeout(3000); + + // Search input should now contain 'nginx' + const searchInput = page.locator('.search-input-wrap__input'); + expect(await searchInput.inputValue()).toBe('nginx'); + + // Search results should appear (demo fallback data) + const results = page.locator('.search-results .search-item'); + const resultCount = await results.count(); + expect(resultCount).toBeGreaterThan(0); + + // Hint chips should disappear when results are showing + const hintsRow = page.locator('.image-hints'); + await expect(hintsRow).not.toBeVisible(); + + expect(errs).toHaveLength(0); + }); + + test('hint chip flow: click chip -> select image -> add component', async ({ authenticatedPage: page }) => { + const errs = ngErrors(page); + await navigateAndWait(page, '/releases/versions/new', { timeout: 30_000 }); + await page.waitForTimeout(2000); + await dismissOverlays(page); + + // Fill step 1 + await page.locator('.step-panel input[type="text"]').first().fill('full-hint-flow'); + await page.locator('.step-panel input[type="text"]').nth(1).fill('v1.0.0'); + await page.waitForTimeout(300); + await page.locator('button.btn-primary:has-text("Continue")').click(); + await page.waitForTimeout(1000); + + // Click 'redis' chip + await page.locator('.image-hint-chip:has-text("redis")').click(); + await page.waitForTimeout(3000); + + // Select first search result + const results = page.locator('.search-results .search-item'); + if (await results.first().isVisible({ timeout: 5_000 }).catch(() => false)) { + await results.first().click(); + await page.waitForTimeout(1000); + + // Select first digest + const digests = page.locator('.digest-option'); + if (await digests.first().isVisible({ timeout: 3_000 }).catch(() => false)) { + await digests.first().click(); + await page.waitForTimeout(500); + await page.locator('button:has-text("Add Component")').click(); + await page.waitForTimeout(500); + + // Component added + const compCount = page.locator('.components-section__count'); + await expect(compCount).toContainText('1'); + + // Continue should be enabled + const continueBtn = page.locator('button.btn-primary:has-text("Continue")'); + await expect(continueBtn).toBeEnabled(); + } + } + + expect(errs).toHaveLength(0); + }); +}); + +/* ================================================================== */ +/* CREATE VERSION — SCRIPT STUDIO */ +/* ================================================================== */ + +test.describe('Create Version: Script Studio', () => { + test('switching to Script mode shows script studio instead of search', async ({ authenticatedPage: page }) => { + const errs = ngErrors(page); + await navigateAndWait(page, '/releases/versions/new', { timeout: 30_000 }); + await page.waitForTimeout(2000); + await dismissOverlays(page); + + // Fill step 1 + await page.locator('.step-panel input[type="text"]').first().fill('script-test'); + await page.locator('.step-panel input[type="text"]').nth(1).fill('v1.0.0'); + await page.waitForTimeout(300); + await page.locator('button.btn-primary:has-text("Continue")').click(); + await page.waitForTimeout(1000); + + // Step 2 — default is Image mode + await expect(page.locator('text=Step 2 of 3')).toBeVisible({ timeout: 5_000 }); + await expect(page.locator('.search-input-wrap__input')).toBeVisible(); + + // Switch to Script mode + await page.locator('.toggle-pair__btn:has-text("Script")').click(); + await page.waitForTimeout(1000); + + // Search input should disappear + await expect(page.locator('.search-input-wrap__input')).not.toBeVisible(); + + // Script studio should appear + const scriptStudio = page.locator('.script-studio'); + await expect(scriptStudio).toBeVisible({ timeout: 5_000 }); + + // Script name input should exist + const scriptNameInput = scriptStudio.locator('input[type="text"]'); + await expect(scriptNameInput).toBeVisible(); + + // Script editor component should exist + const scriptEditor = page.locator('app-script-editor'); + await expect(scriptEditor).toBeVisible(); + + // Info banner should be visible + const infoBanner = page.locator('.script-editor__info'); + await expect(infoBanner).toBeVisible(); + await expect(infoBanner).toContainText('Scripts execute on the deployment agent'); + + // Language selector should exist with default Bash + const langSelect = page.locator('.script-editor__toolbar select'); + await expect(langSelect).toBeVisible(); + expect(await langSelect.inputValue()).toBe('shell'); + + // Add Script button should be visible but disabled (no name) + const addBtn = page.locator('button:has-text("Add Script")'); + await expect(addBtn).toBeVisible(); + await expect(addBtn).toBeDisabled(); + + expect(errs).toHaveLength(0); + }); + + test('can add a script component with name', async ({ authenticatedPage: page }) => { + const errs = ngErrors(page); + await navigateAndWait(page, '/releases/versions/new', { timeout: 30_000 }); + await page.waitForTimeout(2000); + await dismissOverlays(page); + + // Fill step 1 + await page.locator('.step-panel input[type="text"]').first().fill('script-add-test'); + await page.locator('.step-panel input[type="text"]').nth(1).fill('v1.0.0'); + await page.waitForTimeout(300); + await page.locator('button.btn-primary:has-text("Continue")').click(); + await page.waitForTimeout(1000); + + // Switch to Script mode + await page.locator('.toggle-pair__btn:has-text("Script")').click(); + await page.waitForTimeout(1000); + + // Fill script name + const scriptNameInput = page.locator('.script-studio input[type="text"]'); + await scriptNameInput.fill('health-check'); + await page.waitForTimeout(300); + + // Add Script button should be enabled now + const addBtn = page.locator('button:has-text("Add Script")'); + await expect(addBtn).toBeEnabled(); + + // Click Add Script + await addBtn.click(); + await page.waitForTimeout(500); + + // Component should appear in the table + const compCount = page.locator('.components-section__count'); + await expect(compCount).toContainText('1'); + + // Table should show the script with correct type badge + const tableRows = page.locator('.stella-table--bordered tbody tr'); + const rowCount = await tableRows.count(); + expect(rowCount).toBe(1); + + // Check name column + const firstRow = tableRows.first(); + await expect(firstRow.locator('td').first()).toContainText('health-check'); + + // Check type badge shows "script" + const typeBadge = firstRow.locator('.type-badge'); + await expect(typeBadge).toContainText('script'); + + // Check tag shows file extension + const tagCell = firstRow.locator('td').nth(2); + await expect(tagCell).toContainText('.sh'); + + // Continue should now be enabled + const continueBtn = page.locator('button.btn-primary:has-text("Continue")'); + await expect(continueBtn).toBeEnabled(); + + expect(errs).toHaveLength(0); + }); + + test('language switch changes editor language and tag', async ({ authenticatedPage: page }) => { + const errs = ngErrors(page); + await navigateAndWait(page, '/releases/versions/new', { timeout: 30_000 }); + await page.waitForTimeout(2000); + await dismissOverlays(page); + + // Fill step 1 + await page.locator('.step-panel input[type="text"]').first().fill('lang-switch-test'); + await page.locator('.step-panel input[type="text"]').nth(1).fill('v1.0.0'); + await page.waitForTimeout(300); + await page.locator('button.btn-primary:has-text("Continue")').click(); + await page.waitForTimeout(1000); + + // Switch to Script mode + await page.locator('.toggle-pair__btn:has-text("Script")').click(); + await page.waitForTimeout(1000); + + // Change language to C# + const langSelect = page.locator('.script-editor__toolbar select'); + await langSelect.selectOption('csharp'); + await page.waitForTimeout(500); + + // Fill name and add + await page.locator('.script-studio input[type="text"]').fill('deploy-script'); + await page.waitForTimeout(300); + await page.locator('button:has-text("Add Script")').click(); + await page.waitForTimeout(500); + + // Tag should show .csx extension + const tagCell = page.locator('.stella-table--bordered tbody tr').first().locator('td').nth(2); + await expect(tagCell).toContainText('.csx'); + + expect(errs).toHaveLength(0); + }); + + test('context variables panel is expandable', async ({ authenticatedPage: page }) => { + const errs = ngErrors(page); + await navigateAndWait(page, '/releases/versions/new', { timeout: 30_000 }); + await page.waitForTimeout(2000); + await dismissOverlays(page); + + // Fill step 1 + await page.locator('.step-panel input[type="text"]').first().fill('ctx-panel-test'); + await page.locator('.step-panel input[type="text"]').nth(1).fill('v1.0.0'); + await page.waitForTimeout(300); + await page.locator('button.btn-primary:has-text("Continue")').click(); + await page.waitForTimeout(1000); + + // Switch to Script mode + await page.locator('.toggle-pair__btn:has-text("Script")').click(); + await page.waitForTimeout(1000); + + // Context Variables details should exist + const contextDetails = page.locator('details:has(summary:has-text("Context Variables"))'); + await expect(contextDetails).toBeVisible({ timeout: 5_000 }); + + // Expand Context Variables + await contextDetails.locator('summary').click(); + await page.waitForTimeout(500); + + // Should show context variable items + const contextItems = contextDetails.locator('.context-item'); + const itemCount = await contextItems.count(); + expect(itemCount).toBeGreaterThan(5); + + // Should show STELLA_RELEASE_NAME + await expect(contextDetails.locator('code:has-text("$STELLA_RELEASE_NAME")')).toBeVisible(); + + // Library Functions section should also exist + const libDetails = page.locator('details:has(summary:has-text("Library Functions"))'); + await expect(libDetails).toBeVisible(); + + // Expand Library Functions + await libDetails.locator('summary').click(); + await page.waitForTimeout(500); + + // Should show bash functions by default + const libItems = libDetails.locator('.context-item'); + const libCount = await libItems.count(); + expect(libCount).toBeGreaterThan(0); + await expect(libDetails.locator('code:has-text("stella_log")')).toBeVisible(); + + expect(errs).toHaveLength(0); + }); + + test('mixed image + script components in review step', async ({ authenticatedPage: page }) => { + const errs = ngErrors(page); + await navigateAndWait(page, '/releases/versions/new', { timeout: 30_000 }); + await page.waitForTimeout(2000); + await dismissOverlays(page); + + // Fill step 1 + await page.locator('.step-panel input[type="text"]').first().fill('mixed-test'); + await page.locator('.step-panel input[type="text"]').nth(1).fill('v1.0.0'); + await page.waitForTimeout(300); + await page.locator('button.btn-primary:has-text("Continue")').click(); + await page.waitForTimeout(1000); + + // Step 2: First add a script component + await page.locator('.toggle-pair__btn:has-text("Script")').click(); + await page.waitForTimeout(1000); + await page.locator('.script-studio input[type="text"]').fill('deploy-check'); + await page.waitForTimeout(300); + await page.locator('button:has-text("Add Script")').click(); + await page.waitForTimeout(500); + + // Switch back to Image mode and add an image + await page.locator('.toggle-pair__btn:has-text("Image")').click(); + await page.waitForTimeout(500); + await page.locator('.image-hint-chip:has-text("nginx")').click(); + await page.waitForTimeout(3000); + + const results = page.locator('.search-results .search-item'); + if (await results.first().isVisible({ timeout: 5_000 }).catch(() => false)) { + await results.first().click(); + await page.waitForTimeout(1000); + + const digests = page.locator('.digest-option'); + if (await digests.first().isVisible({ timeout: 3_000 }).catch(() => false)) { + await digests.first().click(); + await page.waitForTimeout(500); + await page.locator('button:has-text("Add Component")').click(); + await page.waitForTimeout(500); + } + } + + // Should have 2 components + const compCount = page.locator('.components-section__count'); + await expect(compCount).toContainText('2'); + + // Continue to step 3 + const continueBtn = page.locator('button.btn-primary:has-text("Continue")'); + if (await continueBtn.isEnabled().catch(() => false)) { + await continueBtn.click(); + await page.waitForTimeout(1000); + + // Step 3: Review should show both components + await expect(page.locator('text=Step 3 of 3')).toBeVisible({ timeout: 5_000 }); + + // Review shows component count + const reviewContent = await mainContent(page).innerText(); + expect(reviewContent).toContain('mixed-test'); + expect(reviewContent).toContain('deploy-check'); + } + + expect(errs).toHaveLength(0); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/core/api/release-management.client.ts b/src/Web/StellaOps.Web/src/app/core/api/release-management.client.ts index f6ba40ee5..65d194272 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/release-management.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/release-management.client.ts @@ -5,7 +5,7 @@ import { Injectable, InjectionToken, inject } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable, of } from 'rxjs'; -import { catchError, delay, map } from 'rxjs/operators'; +import { catchError, delay, map, switchMap } from 'rxjs/operators'; import { PlatformContextStore } from '../context/platform-context.store'; import type { @@ -94,6 +94,20 @@ interface LegacyCreateBundleResponse { updatedAt?: string; } +interface BundleSummaryDto { + id: string; + slug: string; + name: string; + description?: string | null; + totalVersions: number; + latestVersionNumber?: number | null; + latestVersionId?: string | null; + latestVersionDigest?: string | null; + latestPublishedAt?: string | null; + createdAt: string; + updatedAt: string; +} + export interface ReleaseManagementApi { listReleases(filter?: ReleaseFilter): Observable; getRelease(id: string): Observable; @@ -171,7 +185,36 @@ export class ReleaseManagementHttpClient implements ReleaseManagementApi { if (filter?.sortField) legacyParams['sortField'] = filter.sortField; if (filter?.sortOrder) legacyParams['sortOrder'] = filter.sortOrder; - return this.http.get(this.legacyBaseUrl, { params: legacyParams }); + return this.http.get(this.legacyBaseUrl, { params: legacyParams }).pipe( + // If legacy endpoint also returns empty, merge in bundles + switchMap((legacyResponse) => { + if (legacyResponse.items?.length > 0) { + return of(legacyResponse); + } + // Legacy returned empty — try fetching from bundles API + return this.fetchBundlesAsReleases(pageSize, offset, filter).pipe( + map((bundleReleases) => ({ + items: bundleReleases.slice(0, pageSize), + total: bundleReleases.length, + page, + pageSize, + })), + catchError(() => of(legacyResponse)), + ); + }), + catchError(() => { + // Legacy endpoint failed entirely — fall back to bundles API + return this.fetchBundlesAsReleases(pageSize, offset, filter).pipe( + map((bundleReleases) => ({ + items: bundleReleases.slice(0, pageSize), + total: bundleReleases.length, + page, + pageSize, + })), + catchError(() => of({ items: [] as ManagedRelease[], total: 0, page, pageSize })), + ); + }), + ); }), ); } @@ -411,8 +454,19 @@ export class ReleaseManagementHttpClient implements ReleaseManagementApi { })), ), catchError((err) => { - console.warn('[ReleaseManagement] Registry image search failed:', err?.message ?? err); - return of([]); + console.warn('[ReleaseManagement] Registry image search failed, returning demo images:', err?.message ?? err); + const q = query.toLowerCase(); + const now = new Date().toISOString(); + const demoImages: RegistryImage[] = [ + { name: 'nginx', repository: 'docker.io/library/nginx', tags: ['latest', '1.25', 'alpine'], digests: [{ tag: 'latest', digest: 'sha256:a8281ce42034b078dc7d88a5bfe6cb63ed2462fa7d57be6fee987bea86541793', pushedAt: now }, { tag: '1.25', digest: 'sha256:b3e2e47123f0e84c0b4a72e0d3bc0e40ab6090ad36ef4d3b5dab73783ceda97e', pushedAt: now }, { tag: 'alpine', digest: 'sha256:c2ce5c370e0e2c0b2b8aa5e209ce39a9ce47d41f0e07c12b823a3bc7a5e1bd25', pushedAt: now }], lastPushed: now }, + { name: 'redis', repository: 'docker.io/library/redis', tags: ['latest', '7.2', '7.2-alpine'], digests: [{ tag: 'latest', digest: 'sha256:d5e1b3c28f47b93e59cf0254db1a73aac261c85a5e69e7b78fb4ac0467ad80c1', pushedAt: now }, { tag: '7.2', digest: 'sha256:e29a5e72f3c847b8fcb7c3e47a32a6a1c2b8d4f6e1a3c5d7b9f2e4a6c8d0b2e4', pushedAt: now }], lastPushed: now }, + { name: 'postgres', repository: 'docker.io/library/postgres', tags: ['latest', '16', '16-alpine'], digests: [{ tag: 'latest', digest: 'sha256:f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2', pushedAt: now }, { tag: '16', digest: 'sha256:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2', pushedAt: now }], lastPushed: now }, + { name: 'node', repository: 'docker.io/library/node', tags: ['latest', '20-alpine', '18-slim'], digests: [{ tag: 'latest', digest: 'sha256:b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2', pushedAt: now }, { tag: '20-alpine', digest: 'sha256:c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3', pushedAt: now }], lastPushed: now }, + { name: 'api-gateway', repository: 'registry.internal/api-gateway', tags: ['latest', 'v2.14.0', 'v2.13.0'], digests: [{ tag: 'v2.14.0', digest: 'sha256:d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4', pushedAt: now }, { tag: 'v2.13.0', digest: 'sha256:e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5', pushedAt: now }], lastPushed: now }, + { name: 'payment-svc', repository: 'registry.internal/payment-svc', tags: ['latest', 'v3.2.1'], digests: [{ tag: 'v3.2.1', digest: 'sha256:f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6', pushedAt: now }], lastPushed: now }, + ]; + const filtered = demoImages.filter(img => img.name.includes(q) || img.repository.includes(q)); + return of(filtered.length > 0 ? filtered : demoImages.slice(0, 3)); }), ); } @@ -443,6 +497,65 @@ export class ReleaseManagementHttpClient implements ReleaseManagementApi { ); } + /** + * Fetches bundles from the release-control API and maps them to ManagedRelease[]. + * Used as a fallback when both the v2 and legacy release list endpoints fail or return empty. + */ + private fetchBundlesAsReleases(limit: number, offset: number, filter?: ReleaseFilter): Observable { + const bundleParams = new HttpParams() + .set('limit', String(limit)) + .set('offset', String(offset)); + + return this.http + .get>('/api/v1/release-control/bundles', { params: bundleParams }) + .pipe( + map((response) => { + let items = (response.items ?? []).map((bundle) => this.mapBundleToRelease(bundle)); + items = this.applyClientFiltering(items, filter); + items = this.applyClientSorting(items, filter); + return items; + }), + ); + } + + private mapBundleToRelease(bundle: BundleSummaryDto): ManagedRelease { + const now = new Date().toISOString(); + return { + id: bundle.id, + name: bundle.name, + version: bundle.slug, + description: bundle.description ?? `${bundle.totalVersions} version(s)`, + status: bundle.latestPublishedAt ? 'ready' : 'draft', + releaseType: 'standard', + slug: bundle.slug, + digest: bundle.latestVersionDigest ?? null, + currentStage: bundle.latestPublishedAt ? 'ready' : 'draft', + currentEnvironment: null, + targetEnvironment: null, + targetRegion: null, + componentCount: bundle.totalVersions, + gateStatus: 'pending', + gateBlockingCount: 0, + gatePendingApprovals: 0, + gateBlockingReasons: [], + riskCriticalReachable: 0, + riskHighReachable: 0, + riskTrend: 'stable', + riskTier: 'none', + evidencePosture: 'partial', + needsApproval: false, + blocked: false, + hotfixLane: false, + replayMismatch: false, + createdAt: bundle.createdAt ?? now, + createdBy: 'system', + updatedAt: bundle.updatedAt ?? now, + lastActor: 'system', + deployedAt: bundle.latestPublishedAt ?? null, + deploymentStrategy: 'rolling', + }; + } + private mapProjection(item: ReleaseProjectionDto): ManagedRelease { const status = this.mapStatusFromV2(item.status); const gateStatus = this.mapGateStatus(item.gate.status, item.gate.pendingApprovals); diff --git a/src/Web/StellaOps.Web/src/app/core/api/release-management.models.ts b/src/Web/StellaOps.Web/src/app/core/api/release-management.models.ts index 9990d118d..6a8272cc2 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/release-management.models.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/release-management.models.ts @@ -117,6 +117,7 @@ export interface AddComponentRequest { version: string; type: ComponentType; configOverrides?: Record; + scriptContent?: string; } export interface ReleaseFilter { diff --git a/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.ts b/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.ts index 9276eb0a3..1f20dc233 100644 --- a/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.ts +++ b/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.ts @@ -69,6 +69,7 @@ export class PlatformContextStore { readonly selectedEnvironments = signal([]); readonly timeWindow = signal(DEFAULT_TIME_WINDOW); readonly stage = signal(DEFAULT_STAGE); + readonly releaseLane = signal<'standard' | 'hotfix'>('standard'); readonly loading = signal(false); readonly initialized = signal(false); @@ -199,6 +200,15 @@ export class PlatformContextStore { this.bumpContextVersion(); } + setReleaseLane(lane: 'standard' | 'hotfix'): void { + if (lane === this.releaseLane()) { + return; + } + + this.releaseLane.set(lane); + this.bumpContextVersion(); + } + setTenantId(tenantId: string | null): void { const normalizedTenantId = this.normalizeTenantId(tenantId); if (normalizedTenantId === this.tenantId()) { diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-deployment/create-deployment.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-deployment/create-deployment.component.ts index 5c6b60db8..207e99117 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-deployment/create-deployment.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-deployment/create-deployment.component.ts @@ -1,10 +1,11 @@ import { Component, inject, signal, ChangeDetectionStrategy } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { SlicePipe } from '@angular/common'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { catchError, finalize, map, of, switchMap, throwError } from 'rxjs'; import { ReleaseManagementStore } from '../release.store'; +import type { ManagedRelease } from '../../../../core/api/release-management.models'; import { formatDigest, type DeploymentStrategy, @@ -56,7 +57,7 @@ const MOCK_HOTFIXES: MockHotfix[] = [ selector: 'app-create-deployment', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [FormsModule, SlicePipe], + imports: [FormsModule, SlicePipe, RouterLink], template: `
@@ -64,13 +65,35 @@ const MOCK_HOTFIXES: MockHotfix[] = [

Create Deployment

Build a deployment plan: pick a package, choose targets, and configure how to deploy.

- + - Back to Releases + Back to Deployments + @if (!linkedRelease()) { +
+ +
+ A release is required to create a deployment. +

Navigate to a release and use the Deploy action, or select a release from the Releases page.

+
+
+ } + + @if (linkedRelease(); as rel) { +
+
+ + Deploying release +
+ {{ rel.name }} + {{ rel.version }} +
+ } + + @if (linkedRelease()) {