From 72e2b2df67ef15c8a5e04adf877e35ada8ee38d7 Mon Sep 17 00:00:00 2001 From: MarkS Date: Fri, 24 Oct 2025 16:18:06 -0600 Subject: [PATCH] serval admin: detect faulted job, add draft-jobs.component tests `BuildCompletedAsync` occurs and can alert about a Faulted job. Its sf project id is in payload. --- .../draft-jobs.component.html | 4 +- .../draft-jobs.component.spec.ts | 218 ++++++++++ .../draft-jobs.component.ts | 13 +- .../serval-administration/sample-events.json | 391 ++++++++++++++++++ 4 files changed, 622 insertions(+), 4 deletions(-) create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/sample-events.json diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.html index cd1fd05128..ae2d342153 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.html @@ -27,7 +27,7 @@
- +
Status @@ -159,7 +159,7 @@ @if (rows.length === 0) { -

No draft jobs found for the selected time period.

+

No draft jobs found for the selected time period.

} } } @else { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.spec.ts new file mode 100644 index 0000000000..fadfafb94a --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.spec.ts @@ -0,0 +1,218 @@ +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { DebugElement } from '@angular/core'; +import { ComponentFixture, fakeAsync, flush, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { ActivatedRoute, provideRouter } from '@angular/router'; +import { BehaviorSubject } from 'rxjs'; +import { anything, mock, verify, when } from 'ts-mockito'; +import { AuthService } from 'xforge-common/auth.service'; +import { DialogService } from 'xforge-common/dialog.service'; +import { I18nService } from 'xforge-common/i18n.service'; +import { OnlineStatusService } from 'xforge-common/online-status.service'; +import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers'; +import { TestOnlineStatusService } from 'xforge-common/test-online-status.service'; +import { provideTestRealtime } from 'xforge-common/test-realtime-providers'; +import { TestRealtimeService } from 'xforge-common/test-realtime.service'; +import { configureTestingModule, getTestTranslocoModule } from 'xforge-common/test-utils'; +import { SF_TYPE_REGISTRY } from '../core/models/sf-type-registry'; +import { SFProjectService } from '../core/sf-project.service'; +import { EventMetric, EventScope } from '../event-metrics/event-metric'; +import { DraftJob, DraftJobsComponent } from './draft-jobs.component'; +import sampleEvents from './sample-events.json'; +import { ServalAdministrationService } from './serval-administration.service'; + +const mockedActivatedRoute = mock(ActivatedRoute); +const mockedAuthService = mock(AuthService); +const mockedDialogService = mock(DialogService); +const mockedI18nService = mock(I18nService); +const mockedProjectService = mock(SFProjectService); +const mockedServalAdministrationService = mock(ServalAdministrationService); + +describe('DraftJobsComponent', () => { + configureTestingModule(() => ({ + imports: [getTestTranslocoModule(), DraftJobsComponent], + providers: [ + provideTestOnlineStatus(), + provideTestRealtime(SF_TYPE_REGISTRY), + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + provideRouter([]), + provideNoopAnimations(), + { provide: ActivatedRoute, useMock: mockedActivatedRoute }, + { provide: AuthService, useMock: mockedAuthService }, + { provide: DialogService, useMock: mockedDialogService }, + { provide: I18nService, useMock: mockedI18nService }, + { provide: OnlineStatusService, useClass: TestOnlineStatusService }, + { provide: SFProjectService, useMock: mockedProjectService }, + { provide: ServalAdministrationService, useMock: mockedServalAdministrationService } + ] + })); + + it('should show jobs from event metrics', fakeAsync(() => { + const env = new TestEnvironment(); + env.wait(); + expect(env.component.rows.length).toBeGreaterThan(0); + expect(env.rows.length).toBeGreaterThan(0); + expect(env.emptyMessage).toBeNull(); + })); + + it('should display empty state when no jobs', fakeAsync(() => { + const env = new TestEnvironment({ hasEvents: false }); + env.wait(); + expect(env.rows.length).toBe(0); + expect(env.emptyMessage).not.toBeNull(); + expect(env.emptyMessage!.nativeElement.textContent).toContain('No draft jobs found'); + })); + + it('should reload data when time period changes', fakeAsync(() => { + const env = new TestEnvironment(); + env.wait(); + env.component.daysBack = 14; + env.wait(); + verify(mockedProjectService.onlineAllEventMetricsForConstructingDraftJobs(anything(), anything(), 14)).once(); + })); + + describe('associates events into jobs', () => { + it('successful jobs', fakeAsync(() => { + const env = new TestEnvironment(); + env.wait(); + // The Serval build id and event metric ids are written here from looking at the sample data. + const servalBuildId = 'serval-build-1'; + + const draftJob: DraftJob = env.component.rows.filter(row => row.job.buildId === servalBuildId)[0].job; + // Confirm test setup. + expect(draftJob.status).toEqual('success'); + + expect(draftJob!.startEvent!.id).toEqual('event-metric-05'); + expect(draftJob!.buildEvent!.id).toEqual('event-metric-04'); + expect(draftJob!.cancelEvent).toBeUndefined(); + expect(draftJob!.finishEvent!.id).toEqual('event-metric-03'); + })); + + it('cancelled jobs', fakeAsync(() => { + const env = new TestEnvironment(); + env.wait(); + const servalBuildId = 'serval-build-2'; + + const draftJob: DraftJob = env.component.rows.filter(row => row.job.buildId === servalBuildId)[0].job; + // Confirm test setup. + expect(draftJob.status).toEqual('cancelled'); + + expect(draftJob!.startEvent!.id).toEqual('event-metric-14'); + expect(draftJob!.buildEvent!.id).toEqual('event-metric-13'); + expect(draftJob!.cancelEvent!.id).toEqual('event-metric-12'); + expect(draftJob!.finishEvent).toBeUndefined(); + })); + + it('faulted jobs', fakeAsync(() => { + const env = new TestEnvironment(); + env.wait(); + const servalBuildId = 'serval-build-4'; + + const draftJob: DraftJob = env.component.rows.filter(row => row.job.buildId === servalBuildId)[0].job; + // Confirm test setup. + expect(draftJob.status).toEqual('failed'); + + expect(draftJob!.startEvent!.id).toEqual('event-metric-17'); + expect(draftJob!.buildEvent!.id).toEqual('event-metric-16'); + expect(draftJob!.cancelEvent).toBeUndefined(); + expect(draftJob!.finishEvent!.id).toEqual('event-metric-15'); + })); + + it('in-progress jobs', fakeAsync(() => { + // This Serval job had StartPreTranslationBuildAsync and BuildProjectAsync but nothing more yet. + const env = new TestEnvironment(); + env.wait(); + const servalBuildId = 'serval-build-5'; + + const draftJob: DraftJob = env.component.rows.filter(row => row.job.buildId === servalBuildId)[0].job; + // Confirm test setup. + expect(draftJob.status).toEqual('running'); + + expect(draftJob!.startEvent!.id).toEqual('event-metric-07'); + expect(draftJob!.buildEvent!.id).toEqual('event-metric-06'); + expect(draftJob!.cancelEvent).toBeUndefined(); + expect(draftJob!.finishEvent).toBeUndefined(); + })); + }); + + class TestEnvironment { + readonly component: DraftJobsComponent; + readonly fixture: ComponentFixture; + readonly realtimeService: TestRealtimeService = TestBed.inject(TestRealtimeService); + readonly testOnlineStatusService: TestOnlineStatusService = TestBed.inject( + OnlineStatusService + ) as TestOnlineStatusService; + private readonly queryParams$ = new BehaviorSubject({}); + + constructor({ hasEvents = true }: { hasEvents?: boolean } = {}) { + when(mockedActivatedRoute.queryParams).thenReturn(this.queryParams$.asObservable()); + when(mockedAuthService.currentUserRoles).thenReturn([]); + when(mockedI18nService.formatDate(anything(), anything())).thenCall((date: Date) => date.toISOString()); + if (hasEvents) this.setupDraftJobsData(); + this.fixture = TestBed.createComponent(DraftJobsComponent); + this.component = this.fixture.componentInstance; + this.fixture.detectChanges(); + } + + get table(): DebugElement | null { + return this.fixture.debugElement.query(By.css('#draft-jobs-table')); + } + + get rows(): DebugElement[] { + if (!this.table) return []; + return this.table.queryAll(By.css('.job-row')); + } + + get emptyMessage(): DebugElement | null { + return this.fixture.debugElement.query(By.css('#no-draft-jobs-found-message')); + } + + wait(): void { + flush(); + this.fixture.detectChanges(); + } + + setupDraftJobsData(): void { + const eventMetrics: EventMetric[] = this.transformJsonToEventMetrics(sampleEvents); + + when( + mockedProjectService.onlineAllEventMetricsForConstructingDraftJobs(anything(), anything(), anything()) + ).thenCall((eventTypes: string[], projectId?: string, _daysBack?: number) => { + let events: EventMetric[] = eventMetrics.filter(event => eventTypes.includes(event.eventType)); + if (projectId != null) events = events.filter(event => event.projectId === projectId); + return { results: events, unpagedCount: events.length }; + }); + + // Mock project names via ServalAdministrationService for projects in JSON + const projectIds = [...new Set(eventMetrics.map(e => e.projectId).filter((id): id is string => id != null))]; + projectIds.forEach(projectId => { + when(mockedServalAdministrationService.get(projectId)).thenResolve({ + id: projectId, + data: { name: `Project ${projectId.substring(0, 8)}`, shortName: projectId.substring(0, 4) } + } as any); + }); + } + + /** + * Transforms JSON event data to EventMetric objects. + * Transforms "timeStamp":{"$date":"foo"} to just "timeStamp". + */ + private transformJsonToEventMetrics(jsonData: any[]): EventMetric[] { + return jsonData.map(event => ({ + id: event._id, + eventType: event.eventType, + timeStamp: typeof event.timeStamp === 'string' ? event.timeStamp : (event.timeStamp?.$date ?? ''), + scope: event.scope as EventScope, + payload: event.payload ?? {}, + userId: event.userId, + projectId: event.projectId, + result: event.result, + executionTime: event.executionTime, + exception: event.exception + })); + } + } +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.ts index 0010f5dff9..4d1b83daa5 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.ts @@ -45,7 +45,9 @@ interface ProjectBooks { books: string[]; } -interface DraftJob { +/** Defines information about a Serval draft generation request. This is exported so it can be used in tests. */ +export interface DraftJob { + /** Serval build ID */ buildId: string | null; projectId: string; startEvent?: EventMetric; // Made optional since incomplete jobs might not have a start event @@ -84,6 +86,7 @@ const DRAFTING_EVENTS = [ 'BuildProjectAsync', 'RetrievePreTranslationStatusAsync', 'ExecuteWebhookAsync', + 'BuildCompletedAsync', 'CancelPreTranslationBuildAsync' ]; @@ -322,11 +325,13 @@ export class DraftJobsComponent extends DataLoadingComponent implements OnInit { // Step 3: Find the first completion event after the build const candidateCompletionEvents = this.draftEvents.filter(event => { - if (event.projectId !== buildEvent.projectId) return false; + if (event.projectId !== buildEvent.projectId && event.payload.sfProjectId !== buildEvent.projectId) + return false; if (new Date(event.timeStamp) <= buildTime) return false; // Check if it's a completion event type if ( + event.eventType === 'BuildCompletedAsync' || event.eventType === 'RetrievePreTranslationStatusAsync' || event.eventType === 'ExecuteWebhookAsync' || event.eventType === 'CancelPreTranslationBuildAsync' @@ -461,6 +466,10 @@ export class DraftJobsComponent extends DataLoadingComponent implements OnInit { if (job.finishEvent.exception != null) { status = 'failed'; errorMessage = job.finishEvent.exception; + } else if (job.finishEvent.payload?.buildState === 'Faulted') { + // We might expect the buildState to match BuildStates.Faulted, but the EventMetric object uses TitleCase rather + // than the all caps of BuildStates. + status = 'failed'; } else { status = 'success'; } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/sample-events.json b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/sample-events.json new file mode 100644 index 0000000000..ade37ff064 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/sample-events.json @@ -0,0 +1,391 @@ +[ + { + "_id": "event-metric-01", + "eventType": "BuildCompletedAsync", + "executionTime": "00:01:00.0000000", + "payload": { + "sfProjectId": "sf-user-5", + "buildId": "serval-build-1", + "buildState": "Completed", + "websiteUrl": "https://example.com/" + }, + "scope": "Drafting", + "timeStamp": { + "$date": "2020-01-04T00:08:00.000Z" + }, + "userId": "sf-user-5" + }, + { + "_id": "event-metric-02", + "eventType": "RetrievePreTranslationStatusAsync", + "executionTime": "00:01:00.0000000", + "payload": { + "sfProjectId": "sf-user-5" + }, + "projectId": "sf-user-5", + "result": "serval-build-1", + "scope": "Drafting", + "timeStamp": { + "$date": "2020-01-04T00:06:00.000Z" + } + }, + { + "_id": "event-metric-03", + "eventType": "ExecuteWebhookAsync", + "payload": { + "buildId": "serval-build-1", + "buildState": "Completed", + "event": "TranslationBuildFinished", + "translationEngineId": "translation-engine-1" + }, + "projectId": "sf-user-5", + "result": "serval-build-1", + "scope": "Drafting", + "timeStamp": { + "$date": "2020-01-04T00:04:00.000Z" + } + }, + { + "_id": "event-metric-04", + "eventType": "BuildProjectAsync", + "executionTime": "00:01:00.0000000", + "payload": { + "curUserId": "sf-user-8", + "buildConfig": { + "TrainingScriptureRanges": [ + { + "ProjectId": "sf-project-1", + "ScriptureRange": "MAT;MRK" + } + ], + "TranslationScriptureRanges": [ + { + "ProjectId": "sf-project-1", + "ScriptureRange": "GEN;EXO" + } + ], + "SendEmailOnBuildFinished": true, + "ProjectId": "sf-user-5" + }, + "preTranslate": true + }, + "projectId": "sf-user-5", + "result": "serval-build-1", + "scope": "Drafting", + "timeStamp": { + "$date": "2020-01-04T00:02:00.000Z" + }, + "userId": "sf-user-8" + }, + { + "_id": "event-metric-05", + "eventType": "StartPreTranslationBuildAsync", + "executionTime": "00:01:00.0000000", + "payload": { + "curUserId": "sf-user-8", + "buildConfig": { + "TrainingScriptureRanges": [ + { + "ProjectId": "sf-project-1", + "ScriptureRange": "MAT;MRK" + } + ], + "TranslationScriptureRanges": [ + { + "ProjectId": "sf-project-1", + "ScriptureRange": "GEN;EXO" + } + ], + "SendEmailOnBuildFinished": true, + "ProjectId": "sf-user-5" + } + }, + "projectId": "sf-user-5", + "scope": "Drafting", + "timeStamp": { + "$date": "2020-01-04T00:00:00.000Z" + }, + "userId": "sf-user-8" + }, + { + "_id": "event-metric-06", + "eventType": "BuildProjectAsync", + "executionTime": "00:01:00.0000000", + "payload": { + "curUserId": "sf-user-8", + "buildConfig": { + "TrainingScriptureRanges": [ + { + "ProjectId": "sf-project-1", + "ScriptureRange": "MAT;MRK" + } + ], + "TranslationScriptureRanges": [ + { + "ProjectId": "sf-project-1", + "ScriptureRange": "GEN;EXO" + } + ], + "SendEmailOnBuildFinished": true, + "ProjectId": "sf-user-5" + }, + "preTranslate": true + }, + "projectId": "sf-user-5", + "result": "serval-build-5", + "scope": "Drafting", + "timeStamp": { + "$date": "2020-01-03T00:02:00.000Z" + }, + "userId": "sf-user-8" + }, + { + "_id": "event-metric-07", + "eventType": "StartPreTranslationBuildAsync", + "executionTime": "00:01:00.0000000", + "payload": { + "curUserId": "sf-user-8", + "buildConfig": { + "TrainingScriptureRanges": [ + { + "ProjectId": "sf-project-1", + "ScriptureRange": "MAT;MRK" + } + ], + "TranslationScriptureRanges": [ + { + "ProjectId": "sf-project-1", + "ScriptureRange": "GEN;EXO" + } + ], + "SendEmailOnBuildFinished": true, + "ProjectId": "sf-user-5" + } + }, + "projectId": "sf-user-5", + "scope": "Drafting", + "timeStamp": { + "$date": "2020-01-03T00:00:00.000Z" + }, + "userId": "sf-user-8" + }, + + { + "_id": "event-metric-08", + "eventType": "BuildCompletedAsync", + "executionTime": "00:01:00.0000000", + "payload": { + "sfProjectId": "sf-user-5", + "buildId": "serval-build-3", + "buildState": "Completed", + "websiteUrl": "https://example.com/" + }, + "scope": "Drafting", + "timeStamp": { + "$date": "2020-01-02T00:12:00.000Z" + }, + "userId": "sf-user-5" + }, + { + "_id": "event-metric-09", + "eventType": "RetrievePreTranslationStatusAsync", + "executionTime": "00:01:00.0000000", + "payload": { + "sfProjectId": "sf-user-5" + }, + "projectId": "sf-user-5", + "result": "serval-build-3", + "scope": "Drafting", + "timeStamp": { + "$date": "2020-01-02T00:10:00.000Z" + } + }, + { + "_id": "event-metric-10", + "eventType": "ExecuteWebhookAsync", + "payload": { + "buildId": "serval-build-3", + "buildState": "Completed", + "event": "TranslationBuildFinished", + "translationEngineId": "translation-engine-1" + }, + "projectId": "sf-user-5", + "result": "serval-build-3", + "scope": "Drafting", + "timeStamp": { + "$date": "2020-01-02T00:08:00.000Z" + } + }, + { + "_id": "event-metric-11", + "eventType": "BuildCompletedAsync", + "executionTime": "00:01:00.0000000", + "payload": { + "sfProjectId": "sf-user-4", + "buildId": "serval-build-2", + "buildState": "Canceled", + "websiteUrl": "https://example.com/" + }, + "scope": "Drafting", + "timeStamp": { + "$date": "2020-01-02T00:06:00.000Z" + }, + "userId": "sf-user-4" + }, + { + "_id": "event-metric-12", + "eventType": "CancelPreTranslationBuildAsync", + "executionTime": "00:01:00.0000000", + "payload": { + "curUserId": "sf-user-2", + "sfProjectId": "sf-user-4" + }, + "projectId": "sf-user-4", + "result": "serval-build-2", + "scope": "Drafting", + "timeStamp": { + "$date": "2020-01-02T00:04:00.000Z" + }, + "userId": "sf-user-2" + }, + { + "_id": "event-metric-13", + "eventType": "BuildProjectAsync", + "executionTime": "00:01:00.0000000", + "payload": { + "curUserId": "sf-user-2", + "buildConfig": { + "TrainingScriptureRanges": [ + { + "ProjectId": "sf-project-6", + "ScriptureRange": "MAT;MRK" + } + ], + "TranslationScriptureRanges": [ + { + "ProjectId": "sf-project-6", + "ScriptureRange": "GEN;EXO" + } + ], + "SendEmailOnBuildFinished": true, + "ProjectId": "sf-user-4" + }, + "preTranslate": true + }, + "projectId": "sf-user-4", + "result": "serval-build-2", + "scope": "Drafting", + "timeStamp": { + "$date": "2020-01-02T00:02:00.000Z" + }, + "userId": "sf-user-2" + }, + { + "_id": "event-metric-14", + "eventType": "StartPreTranslationBuildAsync", + "executionTime": "00:01:00.0000000", + "payload": { + "curUserId": "sf-user-2", + "buildConfig": { + "TrainingScriptureRanges": [ + { + "ProjectId": "sf-project-6", + "ScriptureRange": "MAT;MRK" + } + ], + "TranslationScriptureRanges": [ + { + "ProjectId": "sf-project-6", + "ScriptureRange": "GEN;EXO" + } + ], + "SendEmailOnBuildFinished": true, + "ProjectId": "sf-user-4" + } + }, + "projectId": "sf-user-4", + "scope": "Drafting", + "timeStamp": { + "$date": "2020-01-02T00:00:00.000Z" + }, + "userId": "sf-user-2" + }, + { + "_id": "event-metric-15", + "eventType": "BuildCompletedAsync", + "executionTime": "00:01:00.0000000", + "payload": { + "sfProjectId": "sf-user-1", + "buildId": "serval-build-4", + "buildState": "Faulted", + "websiteUrl": "https://example.com/" + }, + "scope": "Drafting", + "timeStamp": { + "$date": "2020-01-01T00:04:00.000Z" + }, + "userId": "sf-user-1" + }, + { + "_id": "event-metric-16", + "eventType": "BuildProjectAsync", + "executionTime": "00:01:00.0000000", + "payload": { + "curUserId": "sf-user-6", + "buildConfig": { + "TrainingScriptureRanges": [ + { + "ProjectId": "sf-project-9", + "ScriptureRange": "MAT;MRK" + } + ], + "TranslationScriptureRanges": [ + { + "ProjectId": "sf-project-9", + "ScriptureRange": "GEN;EXO" + } + ], + "SendEmailOnBuildFinished": true, + "ProjectId": "sf-user-1" + }, + "preTranslate": true + }, + "projectId": "sf-user-1", + "result": "serval-build-4", + "scope": "Drafting", + "timeStamp": { + "$date": "2020-01-01T00:02:00.000Z" + }, + "userId": "sf-user-6" + }, + { + "_id": "event-metric-17", + "eventType": "StartPreTranslationBuildAsync", + "executionTime": "00:01:00.0000000", + "payload": { + "curUserId": "sf-user-6", + "buildConfig": { + "TrainingScriptureRanges": [ + { + "ProjectId": "sf-project-9", + "ScriptureRange": "MAT;MRK" + } + ], + "TranslationScriptureRanges": [ + { + "ProjectId": "sf-project-9", + "ScriptureRange": "GEN;EXO" + } + ], + "SendEmailOnBuildFinished": true, + "ProjectId": "sf-user-1" + } + }, + "projectId": "sf-user-1", + "scope": "Drafting", + "timeStamp": { + "$date": "2020-01-01T00:00:00.000Z" + }, + "userId": "sf-user-6" + } +]