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:
master
2026-04-01 10:35:38 +03:00
parent 31634a8c13
commit 9e75c49e59
5 changed files with 378 additions and 4 deletions

View File

@@ -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();
});

View File

@@ -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">

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});