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
|
||||
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.trySendPendingInitialMessage();
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SlicePipe } from '@angular/common';
|
||||
import { Component, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
@@ -12,7 +13,7 @@ import { BundleOrganizerApi, type ReleaseControlBundleVersionDetailDto } from '.
|
||||
selector: 'app-create-hotfix',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [FormsModule, RouterModule],
|
||||
imports: [FormsModule, RouterModule, SlicePipe],
|
||||
template: `
|
||||
<div class="hotfix-wizard">
|
||||
<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);
|
||||
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?.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