feat(web): advisory-ai conversation resume, hotfix wizard SlicePipe, release-control tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -596,7 +596,11 @@ export class ChatComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Start or load conversation
|
// Start or load conversation
|
||||||
if (this.conversationId) {
|
const existingConv = this.chatService.conversation();
|
||||||
|
if (existingConv && this.conversationId && existingConv.conversationId === this.conversationId) {
|
||||||
|
// Already have this conversation in state — just resume, no API call needed
|
||||||
|
this.trySendPendingInitialMessage();
|
||||||
|
} else if (this.conversationId) {
|
||||||
this.chatService.getConversation(this.conversationId).subscribe(() => {
|
this.chatService.getConversation(this.conversationId).subscribe(() => {
|
||||||
this.trySendPendingInitialMessage();
|
this.trySendPendingInitialMessage();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SlicePipe } from '@angular/common';
|
||||||
import { Component, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core';
|
import { Component, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router, RouterModule } from '@angular/router';
|
import { Router, RouterModule } from '@angular/router';
|
||||||
@@ -12,7 +13,7 @@ import { BundleOrganizerApi, type ReleaseControlBundleVersionDetailDto } from '.
|
|||||||
selector: 'app-create-hotfix',
|
selector: 'app-create-hotfix',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [FormsModule, RouterModule],
|
imports: [FormsModule, RouterModule, SlicePipe],
|
||||||
template: `
|
template: `
|
||||||
<div class="hotfix-wizard">
|
<div class="hotfix-wizard">
|
||||||
<header class="hotfix-header">
|
<header class="hotfix-header">
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { Router, provideRouter } from '@angular/router';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
import { type RegistryImage } from '../../app/core/api/release-management.models';
|
||||||
|
import { AUTH_SERVICE, type AuthService } from '../../app/core/auth/auth.service';
|
||||||
|
import { BundleOrganizerApi, type ReleaseControlBundleDetailDto, type ReleaseControlBundleVersionDetailDto } from '../../app/features/bundles/bundle-organizer.api';
|
||||||
|
import { CreateHotfixComponent } from '../../app/features/release-orchestrator/releases/create-hotfix/create-hotfix.component';
|
||||||
|
import { ReleaseManagementStore } from '../../app/features/release-orchestrator/releases/release.store';
|
||||||
|
|
||||||
|
describe('CreateHotfixComponent', () => {
|
||||||
|
let fixture: ComponentFixture<CreateHotfixComponent>;
|
||||||
|
let component: CreateHotfixComponent;
|
||||||
|
let router: Router;
|
||||||
|
let bundleApi: jasmine.SpyObj<BundleOrganizerApi>;
|
||||||
|
let auth: jasmine.SpyObj<AuthService>;
|
||||||
|
|
||||||
|
const searchResults = signal<RegistryImage[]>([]);
|
||||||
|
const bundleDetail: ReleaseControlBundleDetailDto = {
|
||||||
|
id: 'bundle-hotfix-001',
|
||||||
|
slug: 'checkout-api-hotfix',
|
||||||
|
name: 'Checkout API Hotfix',
|
||||||
|
description: 'type=hotfix | pathIntent=hotfix-prod',
|
||||||
|
totalVersions: 0,
|
||||||
|
latestVersionNumber: null,
|
||||||
|
latestVersionId: null,
|
||||||
|
latestVersionDigest: null,
|
||||||
|
latestPublishedAt: null,
|
||||||
|
createdAt: '2026-03-31T09:00:00Z',
|
||||||
|
updatedAt: '2026-03-31T09:00:00Z',
|
||||||
|
createdBy: 'admin',
|
||||||
|
};
|
||||||
|
const versionDetail: ReleaseControlBundleVersionDetailDto = {
|
||||||
|
id: 'version-hotfix-001',
|
||||||
|
bundleId: 'bundle-hotfix-001',
|
||||||
|
versionNumber: 1,
|
||||||
|
digest: 'sha256:9999999999999999999999999999999999999999999999999999999999999999',
|
||||||
|
status: 'published',
|
||||||
|
componentsCount: 1,
|
||||||
|
changelog: 'type=hotfix',
|
||||||
|
createdAt: '2026-03-31T09:05:00Z',
|
||||||
|
publishedAt: '2026-03-31T09:05:00Z',
|
||||||
|
createdBy: 'admin',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
componentVersionId: 'checkout-api@v1.2.4-hf.20260331.1015',
|
||||||
|
componentName: 'checkout-api',
|
||||||
|
imageDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||||
|
deployOrder: 10,
|
||||||
|
metadataJson: '{"hotfix":true}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const selectedImage: RegistryImage = {
|
||||||
|
name: 'checkout-api',
|
||||||
|
repository: 'registry.example.local/stella/checkout-api',
|
||||||
|
tags: ['v1.2.4'],
|
||||||
|
digests: [
|
||||||
|
{
|
||||||
|
tag: 'v1.2.4',
|
||||||
|
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||||
|
pushedAt: '2026-03-31T09:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
lastPushed: '2026-03-31T09:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
bundleApi = jasmine.createSpyObj<BundleOrganizerApi>('BundleOrganizerApi', [
|
||||||
|
'createBundle',
|
||||||
|
'listBundles',
|
||||||
|
'publishBundleVersion',
|
||||||
|
'materializeBundleVersion',
|
||||||
|
]);
|
||||||
|
bundleApi.createBundle.and.returnValue(of(bundleDetail));
|
||||||
|
bundleApi.listBundles.and.returnValue(of([bundleDetail]));
|
||||||
|
bundleApi.publishBundleVersion.and.returnValue(of(versionDetail));
|
||||||
|
bundleApi.materializeBundleVersion.and.returnValue(of({
|
||||||
|
runId: 'run-001',
|
||||||
|
bundleId: 'bundle-hotfix-001',
|
||||||
|
versionId: 'version-hotfix-001',
|
||||||
|
status: 'queued',
|
||||||
|
targetEnvironment: null,
|
||||||
|
reason: 'console_hotfix_create',
|
||||||
|
requestedBy: 'admin',
|
||||||
|
idempotencyKey: 'hotfix-checkout-api-hotfix-1711872900000',
|
||||||
|
requestedAt: '2026-03-31T09:06:00Z',
|
||||||
|
updatedAt: '2026-03-31T09:06:00Z',
|
||||||
|
}));
|
||||||
|
|
||||||
|
auth = jasmine.createSpyObj<AuthService>('AuthService', ['hasScope']);
|
||||||
|
auth.hasScope.and.returnValue(true);
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CreateHotfixComponent],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{
|
||||||
|
provide: ReleaseManagementStore,
|
||||||
|
useValue: {
|
||||||
|
searchResults,
|
||||||
|
searchImages: jasmine.createSpy('searchImages'),
|
||||||
|
clearSearchResults: jasmine.createSpy('clearSearchResults'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ provide: BundleOrganizerApi, useValue: bundleApi },
|
||||||
|
{ provide: AUTH_SERVICE, useValue: auth },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(CreateHotfixComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
router = TestBed.inject(Router);
|
||||||
|
spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
searchResults.set([]);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes and materializes the selected hotfix through the bundle lifecycle', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-03-31T10:15:00Z'));
|
||||||
|
|
||||||
|
component.selectImage(selectedImage);
|
||||||
|
component.pickDigest(selectedImage.digests[0]);
|
||||||
|
component.description = 'Critical CVE patch';
|
||||||
|
component.sealConfirmed = true;
|
||||||
|
|
||||||
|
component.sealAndCreate();
|
||||||
|
|
||||||
|
expect(bundleApi.createBundle).toHaveBeenCalledWith(
|
||||||
|
jasmine.objectContaining({
|
||||||
|
slug: 'checkout-api-hotfix',
|
||||||
|
name: 'checkout-api-hotfix',
|
||||||
|
description: expect.stringContaining('type=hotfix'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(bundleApi.publishBundleVersion).toHaveBeenCalledWith('bundle-hotfix-001', jasmine.objectContaining({
|
||||||
|
components: [jasmine.objectContaining({
|
||||||
|
componentName: 'checkout-api',
|
||||||
|
imageDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||||
|
})],
|
||||||
|
}));
|
||||||
|
expect(bundleApi.materializeBundleVersion).toHaveBeenCalledWith('bundle-hotfix-001', 'version-hotfix-001', jasmine.objectContaining({
|
||||||
|
reason: 'console_hotfix_create',
|
||||||
|
}));
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/releases/bundles', 'bundle-hotfix-001', 'versions', 'version-hotfix-001'], {
|
||||||
|
queryParams: {
|
||||||
|
source: 'hotfix-create',
|
||||||
|
type: 'hotfix',
|
||||||
|
returnTo: '/releases/versions',
|
||||||
|
},
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces missing orch:operate scope instead of attempting publish', () => {
|
||||||
|
auth.hasScope.and.returnValue(false);
|
||||||
|
component.selectImage(selectedImage);
|
||||||
|
component.pickDigest(selectedImage.digests[0]);
|
||||||
|
component.sealConfirmed = true;
|
||||||
|
|
||||||
|
component.sealAndCreate();
|
||||||
|
|
||||||
|
expect(bundleApi.createBundle).not.toHaveBeenCalled();
|
||||||
|
expect(component.submitError()).toContain('orch:operate');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { Component, signal } from '@angular/core';
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { Router, provideRouter } from '@angular/router';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { type RegistryImage } from '../../app/core/api/release-management.models';
|
||||||
|
import { AUTH_SERVICE, type AuthService } from '../../app/core/auth/auth.service';
|
||||||
|
import { BundleOrganizerApi, type ReleaseControlBundleDetailDto, type ReleaseControlBundleVersionDetailDto } from '../../app/features/bundles/bundle-organizer.api';
|
||||||
|
import { CreateVersionComponent } from '../../app/features/release-orchestrator/releases/create-version/create-version.component';
|
||||||
|
import { ReleaseManagementStore } from '../../app/features/release-orchestrator/releases/release.store';
|
||||||
|
import { ScriptEditorComponent } from '../../app/shared/components/script-editor/script-editor.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-script-editor',
|
||||||
|
standalone: true,
|
||||||
|
template: '',
|
||||||
|
})
|
||||||
|
class StubScriptEditorComponent {
|
||||||
|
getContent(): string {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentExtension(): string {
|
||||||
|
return '.sh';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CreateVersionComponent', () => {
|
||||||
|
let fixture: ComponentFixture<CreateVersionComponent>;
|
||||||
|
let component: CreateVersionComponent;
|
||||||
|
let router: Router;
|
||||||
|
let bundleApi: jasmine.SpyObj<BundleOrganizerApi>;
|
||||||
|
let auth: jasmine.SpyObj<AuthService>;
|
||||||
|
|
||||||
|
const searchResults = signal<RegistryImage[]>([]);
|
||||||
|
const bundleDetail: ReleaseControlBundleDetailDto = {
|
||||||
|
id: 'bundle-001',
|
||||||
|
slug: 'checkout-api',
|
||||||
|
name: 'Checkout API',
|
||||||
|
description: 'Release version bundle',
|
||||||
|
totalVersions: 0,
|
||||||
|
latestVersionNumber: null,
|
||||||
|
latestVersionId: null,
|
||||||
|
latestVersionDigest: null,
|
||||||
|
latestPublishedAt: null,
|
||||||
|
createdAt: '2026-03-31T09:00:00Z',
|
||||||
|
updatedAt: '2026-03-31T09:00:00Z',
|
||||||
|
createdBy: 'admin',
|
||||||
|
};
|
||||||
|
const versionDetail: ReleaseControlBundleVersionDetailDto = {
|
||||||
|
id: 'version-001',
|
||||||
|
bundleId: 'bundle-001',
|
||||||
|
versionNumber: 1,
|
||||||
|
digest: 'sha256:1111111111111111111111111111111111111111111111111111111111111111',
|
||||||
|
status: 'published',
|
||||||
|
componentsCount: 1,
|
||||||
|
changelog: 'Version v1.2.3',
|
||||||
|
createdAt: '2026-03-31T09:05:00Z',
|
||||||
|
publishedAt: '2026-03-31T09:05:00Z',
|
||||||
|
createdBy: 'admin',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
componentVersionId: 'checkout-api@v1.2.3',
|
||||||
|
componentName: 'checkout-api',
|
||||||
|
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
deployOrder: 10,
|
||||||
|
metadataJson: '{}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
bundleApi = jasmine.createSpyObj<BundleOrganizerApi>('BundleOrganizerApi', [
|
||||||
|
'createBundle',
|
||||||
|
'listBundles',
|
||||||
|
'publishBundleVersion',
|
||||||
|
]);
|
||||||
|
bundleApi.createBundle.and.returnValue(of(bundleDetail));
|
||||||
|
bundleApi.listBundles.and.returnValue(of([bundleDetail]));
|
||||||
|
bundleApi.publishBundleVersion.and.returnValue(of(versionDetail));
|
||||||
|
|
||||||
|
auth = jasmine.createSpyObj<AuthService>('AuthService', ['hasScope']);
|
||||||
|
auth.hasScope.and.returnValue(true);
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CreateVersionComponent],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{
|
||||||
|
provide: ReleaseManagementStore,
|
||||||
|
useValue: {
|
||||||
|
searchResults,
|
||||||
|
searchImages: jasmine.createSpy('searchImages'),
|
||||||
|
clearSearchResults: jasmine.createSpy('clearSearchResults'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ provide: BundleOrganizerApi, useValue: bundleApi },
|
||||||
|
{ provide: AUTH_SERVICE, useValue: auth },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideComponent(CreateVersionComponent, {
|
||||||
|
remove: { imports: [ScriptEditorComponent] },
|
||||||
|
add: { imports: [StubScriptEditorComponent] },
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(CreateVersionComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
router = TestBed.inject(Router);
|
||||||
|
spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
searchResults.set([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds script components with inline content and shell metadata', () => {
|
||||||
|
component.scriptEditor = {
|
||||||
|
getContent: () => 'echo ok',
|
||||||
|
getCurrentExtension: () => '.sh',
|
||||||
|
} as unknown as ScriptEditorComponent;
|
||||||
|
component.scriptName = 'health-check';
|
||||||
|
|
||||||
|
component.addScriptComponent();
|
||||||
|
|
||||||
|
expect(component.components).toHaveLength(1);
|
||||||
|
expect(component.components[0]).toEqual(
|
||||||
|
jasmine.objectContaining({
|
||||||
|
name: 'health-check',
|
||||||
|
imageRef: 'script://health-check.sh',
|
||||||
|
type: 'script',
|
||||||
|
scriptContent: 'echo ok',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(component.components[0].digest).toContain('sha256:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes through the canonical bundle lifecycle and navigates to the version detail', () => {
|
||||||
|
component.form.name = 'Checkout API';
|
||||||
|
component.form.version = 'v1.2.3';
|
||||||
|
component.form.description = 'Release version bundle';
|
||||||
|
component.components = [
|
||||||
|
{
|
||||||
|
name: 'checkout-api',
|
||||||
|
imageRef: 'registry.example.local/stella/checkout-api',
|
||||||
|
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
tag: 'v1.2.3',
|
||||||
|
version: 'v1.2.3',
|
||||||
|
type: 'container',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
component.sealConfirmed = true;
|
||||||
|
|
||||||
|
component.sealVersion();
|
||||||
|
|
||||||
|
expect(bundleApi.createBundle).toHaveBeenCalledWith({
|
||||||
|
slug: 'checkout-api',
|
||||||
|
name: 'Checkout API',
|
||||||
|
description: 'Release version bundle',
|
||||||
|
});
|
||||||
|
expect(bundleApi.publishBundleVersion).toHaveBeenCalledWith('bundle-001', jasmine.objectContaining({
|
||||||
|
changelog: 'Release version bundle',
|
||||||
|
components: [jasmine.objectContaining({
|
||||||
|
componentName: 'checkout-api',
|
||||||
|
componentVersionId: 'checkout-api@v1.2.3',
|
||||||
|
})],
|
||||||
|
}));
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/releases/versions', 'version-001'], {
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces missing orch:operate scope instead of attempting publish', () => {
|
||||||
|
auth.hasScope.and.returnValue(false);
|
||||||
|
component.form.name = 'Checkout API';
|
||||||
|
component.form.version = 'v1.2.3';
|
||||||
|
component.components = [
|
||||||
|
{
|
||||||
|
name: 'checkout-api',
|
||||||
|
imageRef: 'registry.example.local/stella/checkout-api',
|
||||||
|
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
version: 'v1.2.3',
|
||||||
|
type: 'container',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
component.sealConfirmed = true;
|
||||||
|
|
||||||
|
component.sealVersion();
|
||||||
|
|
||||||
|
expect(bundleApi.createBundle).not.toHaveBeenCalled();
|
||||||
|
expect(component.submitError()).toContain('orch:operate');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,10 +15,12 @@ describe('HotfixesQueueComponent', () => {
|
|||||||
const fixture = TestBed.createComponent(HotfixesQueueComponent);
|
const fixture = TestBed.createComponent(HotfixesQueueComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const reviewLink = fixture.nativeElement.querySelector('.action-link') as HTMLAnchorElement | null;
|
const reviewLink = fixture.nativeElement.querySelector('.row-action') as HTMLAnchorElement | null;
|
||||||
|
const createLink = fixture.nativeElement.querySelector('.header-actions .btn-primary') as HTMLAnchorElement | null;
|
||||||
|
|
||||||
expect(reviewLink).not.toBeNull();
|
expect(reviewLink).not.toBeNull();
|
||||||
expect(reviewLink?.getAttribute('href')).toBe('/releases/hotfixes/platform-bundle-1-3-1-hotfix1');
|
expect(reviewLink?.getAttribute('href')).toBe('/releases/hotfixes/platform-bundle-1-3-1-hotfix1');
|
||||||
expect(reviewLink?.textContent?.trim()).toBe('Review');
|
expect(createLink).not.toBeNull();
|
||||||
|
expect(createLink?.getAttribute('href')).toBe('/releases/hotfixes/new');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user